diff --git a/package.json b/package.json
index f0ccfad..7722a8a 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,8 @@
"dev": "npm --workspace web run dev",
"build": "npm --workspace web run build",
"test": "npm --workspaces run test",
- "verify:recommended-flows": "node web/scripts/verify-recommended-flow-catalog.mjs",
+ "verify:catalog-gates": "node web/scripts/verify-catalog-gates.mjs",
+ "verify:recommended-flows": "npm run verify:catalog-gates && node web/scripts/verify-recommended-flow-catalog.mjs",
"preview": "npm --workspace web run preview",
"dev:teams": "CLOUD_DEV_ORIGIN=http://127.0.0.1:3101 npm --workspace web run dev -- --hostname 127.0.0.1 --port 3100"
}
diff --git a/web/app/flows/plugins/PluginsGallery.tsx b/web/app/flows/plugins/PluginsGallery.tsx
index 265e262..8846f69 100644
--- a/web/app/flows/plugins/PluginsGallery.tsx
+++ b/web/app/flows/plugins/PluginsGallery.tsx
@@ -60,8 +60,8 @@ export function PluginsGallery() {
A curated catalog of schema-2 flow extensions. Each card pins a public GitHub
- directory and an install badge that opens Cloud with the base flow and the plugin
- source.
+ directory. Install controls appear only after every declared runtime dependency has
+ merge and deployment evidence.
@@ -106,7 +106,7 @@ export function PluginsGallery() {
const pluginUrl = flowPluginSourceUrl(plugin);
const tier = FLOW_PLUGIN_TRUST_TIER_COPY[plugin.tier];
const badgeMarkdown =
- flowUrl &&
+ installHref && flowUrl &&
flowPluginBadgeMarkdown({
flowUrl,
plugins: [pluginUrl],
@@ -130,7 +130,14 @@ export function PluginsGallery() {
))}
sha {plugin.ref.slice(0, 12)}
sha256:{plugin.digest.slice(0, 12)}
+ runtime {plugin.runtime.version}
+ {plugin.activation.state === 'blocked' ? (
+
+ Catalog only: activation is blocked. Required merge and deployment evidence:
+ {' '}{plugin.activation.dependencies.map(dependency => dependency.id).join(', ')}.
+
+ ) : null}
{pluginHasUnroutableTriggers(plugin) ? (
Fail-closed: GitHub pull_request.ready_for_review,{' '}
diff --git a/web/app/flows/plugins/page.tsx b/web/app/flows/plugins/page.tsx
index c58d9f0..94c4951 100644
--- a/web/app/flows/plugins/page.tsx
+++ b/web/app/flows/plugins/page.tsx
@@ -6,9 +6,9 @@ import { SiteNav } from '../../../components/SiteNav';
import { absoluteUrl, SITE_NAME } from '../../../lib/site';
import { PluginsGallery } from './PluginsGallery';
-const title = 'Flow plugins — Install onto a base flow';
+const title = 'Flow plugins — Catalog and release status';
const description =
- 'A curated catalog of schema-2 flow extensions. Install Babysitter onto Software Garden with a pinned sha, digest, and trust tier.';
+ 'A curated catalog of schema-2 flow extensions with pinned artifacts, runtime provenance, trust tiers, and fail-closed activation gates.';
export const metadata: Metadata = {
title,
diff --git a/web/content/docs/relayflows/plugins.mdx b/web/content/docs/relayflows/plugins.mdx
index 80815e8..f371ed9 100644
--- a/web/content/docs/relayflows/plugins.mdx
+++ b/web/content/docs/relayflows/plugins.mdx
@@ -9,7 +9,7 @@ A **flow plugin** is a directory in a public GitHub repository whose `flows-plug
{ "schema": 2, "kind": "flow-extension" }
```
-The entry default-exports `flow()` and declares handlers, hooks, triggers, permissions, compat, and the same mandatory `preflight`. Browse the vendored catalog on the [plugin gallery](/flows/plugins). Each card's **Install plugin** badge opens Cloud with the base flow and the plugin source:
+The entry default-exports `flow()` and declares handlers, hooks, triggers, permissions, compat, and the same mandatory `preflight`. Browse the vendored catalog on the [plugin gallery](/flows/plugins). An available card's **Install plugin** badge opens Cloud with the base flow and the plugin source:
```text
/cloud/flows/deploy?flow=&plugin=
@@ -18,7 +18,7 @@ The entry default-exports `flow()` and declares handlers, hooks, triggers, permi
`plugin` may repeat. The badge helper appends each one so a second plugin does not overwrite the first.
- See the plugin gallery — Babysitter on Software Garden, with the install badge.
+ See the plugin gallery — including Babysitter's pinned artifact and release gate.
## `flows-plugin.json` schema 2
@@ -67,7 +67,7 @@ Rules that matter at review time:
- Manifest triggers are validated against the surface registry `providerEventTypes`. An event the registry cannot route is `plugin_event_unroutable`.
- Relayflows 2.0.26 carries all eleven Babysitter event identities, but the authored executor still refuses a matching handler before either body starts because schema-2 permissions are not isolated there. Cloud may enable Babysitter only through the separately authorized existing-session lineage path; an arbitrary labelled pull request is not execution authority.
+ Relayflows SDK 2.0.31 carries the isolated hosted runtime for the reviewed Babysitter artifact, but no Cloud capability adapter or Relay native existing-session delivery release exists yet. The catalog therefore remains metadata-only and exposes no install control. Cloud may enable Babysitter only after both dependencies are merged and deployed; an arbitrary labelled pull request is not execution authority.
## `flows add`
@@ -118,7 +118,7 @@ The gallery and the deploy wizard display a tier. **The label is never used to s
| **verified** | bundle `identity.json` keyid matches a publisher key registered in Cloud |
| **community** | anything else |
-Babysitter in catalog v2 is **first-party**: its `v2.0.26` commit is reachable from `AgentWorkforce/flows` main. The label does not widen runtime authority.
+Babysitter in catalog v3 is **first-party**: its reviewed `v2.0.26` artifact commit is reachable from `AgentWorkforce/flows` main, and the hosted runtime provenance is the published SDK `v2.0.31` release. The label does not widen runtime authority.
`permissions.writes` stays a reviewed declaration labelled UNENFORCED until gate 8. Displaying a first-party or verified badge does not enforce write scope, skip the digest, or route an event the registry does not carry.
@@ -126,12 +126,26 @@ Babysitter in catalog v2 is **first-party**: its `v2.0.26` commit is reachable f
## Install badge
-`flowPluginBadgeMarkdown()` renders the README form. `plugin` is appended so repeats survive:
-
-```markdown
-[](https://agentrelay.com/cloud/flows/deploy?flow=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Fblob%2F8b33ebab8347514f80d9da5a81206a087f641714%2Fexamples%2Fsoftware-factory%2Fsoftware-factory.flow.ts&plugin=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Ftree%2F8b33ebab8347514f80d9da5a81206a087f641714%2Fextensions%2Fbabysitter)
-```
-
-The first plugin in the vendored catalog is Babysitter. Its base flow URL is Software Garden (`software-factory`); its plugin URL is the tree at the pinned sha. The [plugin gallery](/flows/plugins) shows the live badge.
-
-The catalog itself is versioned JSON, `{version:2, plugins:[{name, description, source:{owner,repo,path}, ref, digest, manifestSha256, compat, tier, base}]}`. Any public repo with a `flows-plugin.json` is installable by URL; the catalog is a curated index, not a gate.
+`flowPluginBadgeMarkdown()` renders the README form for an extension whose activation gate is ready. `plugin` is appended so repeats survive.
+
+Babysitter's gate requires both `cloud-babysitter-capability-adapter` and `relay-native-existing-session-delivery` to carry a pull request, merge commit, merge time, deployment receipt, and deployment time. Until both proofs are recorded and the gate state is `ready`, `flowPluginInstallHref()` returns `null` and the gallery shows catalog status plus source only.
+
+The catalog itself is versioned JSON, `{version:3, plugins:[{name, description, source:{owner,repo,path}, ref, digest, manifestSha256, compat, runtime, activation, tier, base}]}`. Any public repo with a `flows-plugin.json` remains installable by an explicit source URL; the curated catalog does not advertise an activation route until its dependency gate is satisfied.
+
+Ready catalog entries must pass live, read-only GitHub receipt verification in CI.
+The code-owned implementation contract binds `cloud-babysitter-capability-adapter`
+to [Cloud PR #3989](https://github.com/AgentWorkforce/cloud/pull/3989) and
+`relay-native-existing-session-delivery` to
+[Relay PR #1851](https://github.com/AgentWorkforce/relay/pull/1851). Unrelated PRs
+in those repositories cannot satisfy either dependency. A replacement implementation
+requires a reviewed code change to this contract.
+`pullRequestUrl` must identify a merged PR into the dependency repository's `main`,
+with the exact `mergedCommit` and `mergedAt`. `deploymentUrl` must be the canonical
+GitHub API deployment URL (`https://api.github.com/repos/OWNER/REPO/deployments/ID`).
+That deployment must identify the same commit in the non-transient `production`
+environment, and its latest status must be successful with `created_at` matching
+`deployedAt`. Arbitrary HTTPS links, build runs, private/unavailable receipts and
+unsupported deployment systems cannot make an entry ready. Blocked entries do not
+require network receipts and may retain evidence without enabling installation.
+The client checks the published catalog's shape; CI verifies its provenance, and
+Cloud remains responsible for server-side activation authorization.
diff --git a/web/content/docs/relayflows/recommended.mdx b/web/content/docs/relayflows/recommended.mdx
index c855865..6c45e21 100644
--- a/web/content/docs/relayflows/recommended.mdx
+++ b/web/content/docs/relayflows/recommended.mdx
@@ -9,9 +9,9 @@ Recommended flows are maintained starting points. The catalog is public, version
Software Garden is the display name of the first recommended flow. Its stable catalog and authored flow ID is `software-factory`: a GitHub issue starts implementation, deterministic repository checks, adversarial review, and a pull request for a human decision.
-The released source uses Claude Code for implementation and review, so catalog version 2 allows and defaults only that harness. A future catalog version can point at a new released source with a different requirement; clients do not rewrite the authored flow.
+The released source uses Claude Code for implementation and review, so catalog version 3 allows and defaults only that harness. A future catalog version can point at a new released source with a different requirement; clients do not rewrite the authored flow.
-Software Garden currently supports GitHub repositories. Deploy one listener per repository using the direct-source API below. The catalog deliberately does not define Babysitter behavior. Babysitter is a [flow plugin](/docs/relayflows/plugins) installed onto Software Garden, not a recommended flow of its own.
+Software Garden currently supports GitHub repositories. Deploy one listener per repository using the direct-source API below. Babysitter remains a [flow plugin](/docs/relayflows/plugins), not a recommended flow of its own. The catalog carries its immutable artifact and Relayflows SDK 2.0.31 runtime provenance as inert extension metadata; activation stays blocked until the Cloud capability adapter and Relay native delivery both carry merge and deployment evidence.
## Catalog API
@@ -22,7 +22,7 @@ GET https://agentrelay.com/api/v1/flows/catalog
GET https://agentrelay.com/api/v1/flows/catalog/software-factory
```
-The list response starts with `schemaVersion: 1` and `catalogVersion: 2`. Every flow has a stable `id`, its own numeric `version`, display copy, repository-host support, trigger defaults, required/default activation inputs, and an immutable source reference.
+The list response starts with `schemaVersion: 1` and `catalogVersion: 3`. Every flow has a stable `id`, its own numeric `version`, display copy, repository-host support, trigger defaults, required/default activation inputs, and an immutable source reference. Optional extension entries are metadata only unless their activation gate is `ready` and every declared dependency has merge and deployment evidence.
The catalog does not copy or generate the flow body. `source` names the canonical `AgentWorkforce/flows` owner, repository, path, release tag, full commit SHA, GitHub blob and raw URLs, media type, and SHA-256 content digest. Both URLs contain the commit SHA, never a mutable branch:
diff --git a/web/data/flow-plugin-catalog.v1.json b/web/data/flow-plugin-catalog.v1.json
index e163ad7..8ed1a36 100644
--- a/web/data/flow-plugin-catalog.v1.json
+++ b/web/data/flow-plugin-catalog.v1.json
@@ -1,14 +1,32 @@
{
- "version": 2,
+ "version": 3,
"plugins": [
{
"name": "babysitter",
- "description": "Native Babysitter for Software Garden: eleven GitHub wake events request one authorized existing-session turn. Cloud rechecks the live babysit label, pull-request head, and session binding; the extension holds no GitHub write or merge authority. Handler execution remains fail-closed until the Cloud capability adapter and native delivery path ship.",
+ "description": "Native Babysitter for Software Garden: eleven GitHub wake events request one authorized existing-session turn. Cloud rechecks the live babysit label, pull-request head, and session binding; the extension holds no GitHub write or merge authority. This entry is catalog-only until the Cloud capability adapter and Relay native delivery are both merged and deployed.",
"source": { "owner": "AgentWorkforce", "repo": "flows", "path": "extensions/babysitter" },
"ref": "8b33ebab8347514f80d9da5a81206a087f641714",
"digest": "bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee",
"manifestSha256": "5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226",
"compat": { "surface": "^2.0.26", "sdk": "^2.0.26", "base": ["software-factory"] },
+ "runtime": { "package": "@relayflows/sdk", "version": "2.0.31", "release": "v2.0.31" },
+ "activation": {
+ "state": "blocked",
+ "dependencies": [
+ {
+ "id": "cloud-babysitter-capability-adapter",
+ "repository": "AgentWorkforce/cloud",
+ "requiredState": "merged-and-deployed",
+ "evidence": null
+ },
+ {
+ "id": "relay-native-existing-session-delivery",
+ "repository": "AgentWorkforce/relay",
+ "requiredState": "merged-and-deployed",
+ "evidence": null
+ }
+ ]
+ },
"tier": "first-party",
"base": ["software-factory"]
}
diff --git a/web/data/recommended-flow-catalog.v1.json b/web/data/recommended-flow-catalog.v1.json
index 08fb7d1..752eec4 100644
--- a/web/data/recommended-flow-catalog.v1.json
+++ b/web/data/recommended-flow-catalog.v1.json
@@ -1,10 +1,10 @@
{
"schemaVersion": 1,
- "catalogVersion": 2,
+ "catalogVersion": 3,
"flows": [
{
"id": "software-factory",
- "version": 2,
+ "version": 3,
"name": "Software Garden",
"summary": "Turn GitHub issues into reviewed, tested pull requests.",
"description": "Implement a ticket, run the repository checks outside the agent, put the change through adversarial review, and open a pull request for a human decision.",
@@ -21,6 +21,35 @@
},
"allowedAgents": ["claude"]
},
+ "extensions": [
+ {
+ "id": "babysitter",
+ "version": "0.2.0",
+ "runtime": { "package": "@relayflows/sdk", "version": "2.0.31", "release": "v2.0.31" },
+ "artifact": {
+ "ref": "github:AgentWorkforce/flows@8b33ebab8347514f80d9da5a81206a087f641714#extensions/babysitter",
+ "digest": "bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee",
+ "manifestSha256": "5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226"
+ },
+ "activation": {
+ "state": "blocked",
+ "dependencies": [
+ {
+ "id": "cloud-babysitter-capability-adapter",
+ "repository": "AgentWorkforce/cloud",
+ "requiredState": "merged-and-deployed",
+ "evidence": null
+ },
+ {
+ "id": "relay-native-existing-session-delivery",
+ "repository": "AgentWorkforce/relay",
+ "requiredState": "merged-and-deployed",
+ "evidence": null
+ }
+ ]
+ }
+ }
+ ],
"source": {
"kind": "github",
"owner": "AgentWorkforce",
diff --git a/web/lib/flow-plugin-catalog.ts b/web/lib/flow-plugin-catalog.ts
index e1e4654..a8886c1 100644
--- a/web/lib/flow-plugin-catalog.ts
+++ b/web/lib/flow-plugin-catalog.ts
@@ -1,9 +1,37 @@
import catalogJson from '../data/flow-plugin-catalog.v1.json';
+import { FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS } from './flow-plugin-implementation-prs.mjs';
import { SITE_URL } from './site';
export const FLOW_PLUGIN_TRUST_TIERS = ['first-party', 'verified', 'community'] as const;
export type FlowPluginTrustTier = (typeof FLOW_PLUGIN_TRUST_TIERS)[number];
+export const FLOW_PLUGIN_REQUIRED_DEPENDENCIES: Readonly>>> = {
+ babysitter: {
+ 'cloud-babysitter-capability-adapter': 'AgentWorkforce/cloud',
+ 'relay-native-existing-session-delivery': 'AgentWorkforce/relay',
+ },
+};
+
+export type FlowPluginDeploymentEvidence = {
+ pullRequestUrl: string;
+ mergedCommit: string;
+ mergedAt: string;
+ deploymentUrl: string;
+ deployedAt: string;
+};
+
+export type FlowPluginActivationDependency = {
+ id: string;
+ repository: string;
+ requiredState: 'merged-and-deployed';
+ evidence: FlowPluginDeploymentEvidence | null;
+};
+
+export type FlowPluginActivationGate = {
+ state: 'blocked' | 'ready';
+ dependencies: FlowPluginActivationDependency[];
+};
+
export type FlowPluginCatalogEntry = {
name: string;
description: string;
@@ -12,12 +40,14 @@ export type FlowPluginCatalogEntry = {
digest: string;
manifestSha256: string;
compat: { surface: string; sdk: string; base: string[] };
+ runtime: { package: '@relayflows/sdk'; version: string; release: string };
+ activation: FlowPluginActivationGate;
tier: FlowPluginTrustTier;
base: string[];
};
export type FlowPluginCatalog = {
- version: 2;
+ version: 3;
plugins: FlowPluginCatalogEntry[];
};
@@ -89,6 +119,56 @@ export function pluginHasUnroutableTriggers(plugin: FlowPluginCatalogEntry): boo
return plugin.description.includes('plugin_event_unroutable');
}
+const FULL_SHA = /^[0-9a-f]{40}$/;
+const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
+
+function timestampMillis(value: string): number | null {
+ if (!ISO_TIMESTAMP.test(value)) return null;
+ const milliseconds = Date.parse(value);
+ if (!Number.isFinite(milliseconds)) return null;
+ const normalized = value.includes('.') ? value : value.replace(/Z$/, '.000Z');
+ return new Date(milliseconds).toISOString() === normalized ? milliseconds : null;
+}
+
+export function flowPluginDependencyHasDeploymentEvidence(
+ dependency: FlowPluginActivationDependency,
+): boolean {
+ const evidence = dependency.evidence;
+ if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)
+ || dependency.requiredState !== 'merged-and-deployed') return false;
+ if (Object.keys(evidence).sort().join(',') !== 'deployedAt,deploymentUrl,mergedAt,mergedCommit,pullRequestUrl'
+ || !Object.values(evidence).every(value => typeof value === 'string')) return false;
+ if (!Object.hasOwn(FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS, dependency.id)
+ || evidence.pullRequestUrl !== FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS[dependency.id]) return false;
+ try {
+ const pullRequestUrl = new URL(evidence.pullRequestUrl);
+ const deploymentUrl = new URL(evidence.deploymentUrl);
+ const mergedAt = timestampMillis(evidence.mergedAt);
+ const deployedAt = timestampMillis(evidence.deployedAt);
+ return pullRequestUrl.origin === 'https://github.com'
+ && new RegExp(`^/${dependency.repository}/pull/[1-9][0-9]*$`).test(pullRequestUrl.pathname)
+ && FULL_SHA.test(evidence.mergedCommit)
+ && mergedAt !== null
+ && deploymentUrl.href === `https://api.github.com/repos/${dependency.repository}/deployments/${deploymentUrl.pathname.split('/').pop()}`
+ && /^[1-9][0-9]*$/.test(deploymentUrl.pathname.split('/').pop() ?? '')
+ && deployedAt !== null
+ && deployedAt >= mergedAt;
+ } catch {
+ return false;
+ }
+}
+
+export function flowPluginIsActivatable(plugin: FlowPluginCatalogEntry): boolean {
+ const required = FLOW_PLUGIN_REQUIRED_DEPENDENCIES[plugin.name];
+ if (!Object.hasOwn(FLOW_PLUGIN_REQUIRED_DEPENDENCIES, plugin.name)) return false;
+ const actual = plugin.activation.dependencies.map(dependency => dependency.id);
+ return plugin.activation.state === 'ready'
+ && actual.length === Object.keys(required).length
+ && Object.keys(required).every(id => actual.includes(id))
+ && plugin.activation.dependencies.every(dependency => required[dependency.id] === dependency.repository)
+ && plugin.activation.dependencies.every(flowPluginDependencyHasDeploymentEvidence);
+}
+
function originFrom(appOrigin: string): string {
return appOrigin.replace(/\/+$/, '');
}
@@ -111,6 +191,7 @@ export function flowPluginInstallPath(input: { flowUrl: string; plugins: string[
}
export function flowPluginInstallHref(plugin: FlowPluginCatalogEntry): string | null {
+ if (!flowPluginIsActivatable(plugin)) return null;
const flowUrl = pluginInstallFlowUrl(plugin);
if (!flowUrl) return null;
return flowPluginInstallPath({ flowUrl, plugins: [flowPluginSourceUrl(plugin)] });
diff --git a/web/lib/flow-plugin-implementation-prs.d.mts b/web/lib/flow-plugin-implementation-prs.d.mts
new file mode 100644
index 0000000..369e0e0
--- /dev/null
+++ b/web/lib/flow-plugin-implementation-prs.d.mts
@@ -0,0 +1 @@
+export const FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS: Readonly>;
diff --git a/web/lib/flow-plugin-implementation-prs.mjs b/web/lib/flow-plugin-implementation-prs.mjs
new file mode 100644
index 0000000..e8e1035
--- /dev/null
+++ b/web/lib/flow-plugin-implementation-prs.mjs
@@ -0,0 +1,8 @@
+/**
+ * Code-owned capability implementations, independent of catalog-supplied receipts.
+ * A replacement implementation requires review of this contract, not a catalog edit.
+ */
+export const FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS = Object.freeze({
+ 'cloud-babysitter-capability-adapter': 'https://github.com/AgentWorkforce/cloud/pull/3989',
+ 'relay-native-existing-session-delivery': 'https://github.com/AgentWorkforce/relay/pull/1851',
+});
diff --git a/web/lib/test/deployment-receipts.test.ts b/web/lib/test/deployment-receipts.test.ts
new file mode 100644
index 0000000..6e85eed
--- /dev/null
+++ b/web/lib/test/deployment-receipts.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it, vi } from 'vitest';
+import { verifyDeploymentReceipt } from '../../scripts/verify-deployment-receipts.mjs';
+
+const repository = 'AgentWorkforce/cloud';
+const api = `https://api.github.com/repos/${repository}`;
+const evidence = {
+ pullRequestUrl: `https://github.com/${repository}/pull/3989`,
+ mergedCommit: 'a'.repeat(40), mergedAt: '2026-09-24T12:00:00Z',
+ deploymentUrl: `${api}/deployments/7`, deployedAt: '2026-09-24T12:05:00Z',
+};
+const dependency = { id: 'cloud-babysitter-capability-adapter', repository,
+ requiredState: 'merged-and-deployed' as const, evidence };
+const pr = { merged: true, base: { ref: 'main', repo: { full_name: repository } },
+ html_url: evidence.pullRequestUrl, merge_commit_sha: evidence.mergedCommit, merged_at: evidence.mergedAt };
+const deployment = { url: evidence.deploymentUrl, sha: evidence.mergedCommit,
+ repository_url: api, environment: 'production', production_environment: true,
+ transient_environment: false, created_at: '2026-09-24T12:01:00Z' };
+const success = { state: 'success', environment: 'production', deployment_url: evidence.deploymentUrl,
+ repository_url: api, created_at: evidence.deployedAt };
+function requester(bodies: unknown[]) {
+ return vi.fn().mockImplementation(async () => Response.json(bodies.shift()));
+}
+
+describe('authoritative dependency receipts', () => {
+ it('checks the merged PR, exact production SHA and latest successful status', async () => {
+ const request = requester([pr, deployment, [success]]);
+ await expect(verifyDeploymentReceipt(dependency, request)).resolves.toBeUndefined();
+ expect(request.mock.calls.map(call => call[0])).toEqual([
+ `${api}/pulls/3989`, `${api}/deployments/7`, `${api}/deployments/7/statuses?per_page=1`,
+ ]);
+ });
+ it('rejects an unrelated merged/deployed PR in either dependency repository', async () => {
+ for (const [id, repo] of [
+ ['cloud-babysitter-capability-adapter', 'AgentWorkforce/cloud'],
+ ['relay-native-existing-session-delivery', 'AgentWorkforce/relay'],
+ ]) {
+ const pullRequestUrl = `https://github.com/${repo}/pull/42`;
+ const repositoryUrl = `https://api.github.com/repos/${repo}`;
+ const deploymentUrl = `${repositoryUrl}/deployments/7`;
+ const request = requester([
+ { ...pr, html_url: pullRequestUrl, base: { ref: 'main', repo: { full_name: repo } } },
+ { ...deployment, url: deploymentUrl, repository_url: repositoryUrl },
+ [{ ...success, deployment_url: deploymentUrl, repository_url: repositoryUrl }],
+ ]);
+ await expect(verifyDeploymentReceipt({ ...dependency, id, repository: repo,
+ evidence: { ...evidence, pullRequestUrl, deploymentUrl },
+ }, request)).rejects.toThrow('declared capability implementation');
+ expect(request).not.toHaveBeenCalled();
+ }
+ });
+ it('rejects a nonexistent PR, private receipt, rate limit or failed request', async () => {
+ for (const status of [404, 403, 429, 500]) {
+ const request = vi.fn().mockResolvedValue(new Response('', { status }));
+ await expect(verifyDeploymentReceipt(dependency, request)).rejects.toThrow('unavailable');
+ }
+ await expect(verifyDeploymentReceipt(dependency, vi.fn()
+ .mockRejectedValue(new Error('network unavailable')))).rejects.toThrow();
+ });
+ it('refuses arbitrary URLs and mismatched repositories before making requests', async () => {
+ const request = requester([]);
+ for (const deploymentUrl of ['https://example.com/not-a-deployment', `${api}/deployments/7?fake=1`,
+ 'https://api.github.com/repos/other/repo/deployments/7']) {
+ await expect(verifyDeploymentReceipt({ ...dependency, evidence: { ...evidence, deploymentUrl } }, request))
+ .rejects.toThrow();
+ }
+ await expect(verifyDeploymentReceipt({ ...dependency, repository: 'other/repo' }, request)).rejects.toThrow();
+ expect(request).not.toHaveBeenCalled();
+ });
+ it('rejects unmerged, wrong-commit, wrong-date and wrong-base PR receipts', async () => {
+ for (const patch of [{ merged: false }, { merge_commit_sha: 'b'.repeat(40) },
+ { merged_at: '2026-09-24T11:00:00Z' }, { base: { ref: 'other', repo: { full_name: repository } } }]) {
+ await expect(verifyDeploymentReceipt(dependency, requester([{ ...pr, ...patch }])))
+ .rejects.toThrow('PR receipt');
+ }
+ });
+ it('rejects another SHA, staging, transient, and pre-merge deployments', async () => {
+ for (const patch of [{ sha: 'b'.repeat(40) }, { environment: 'staging' },
+ { production_environment: false }, { transient_environment: true },
+ { created_at: '2026-09-24T11:00:00Z' }]) {
+ await expect(verifyDeploymentReceipt(dependency, requester([pr, { ...deployment, ...patch }])))
+ .rejects.toThrow('deployment receipt');
+ }
+ });
+ it('rejects absent, failed, inactive, wrong-date or wrong-deployment statuses', async () => {
+ for (const statuses of [[], [{ ...success, state: 'failure' }, success],
+ [{ ...success, state: 'inactive' }], [{ ...success, created_at: '2026-09-24T12:04:00Z' }],
+ [{ ...success, deployment_url: `${api}/deployments/8` }]]) {
+ await expect(verifyDeploymentReceipt(dependency, requester([pr, deployment, statuses])))
+ .rejects.toThrow('latest deployment status');
+ }
+ });
+});
diff --git a/web/lib/test/flow-plugin-catalog.test.ts b/web/lib/test/flow-plugin-catalog.test.ts
index 68a52b0..36a65b1 100644
--- a/web/lib/test/flow-plugin-catalog.test.ts
+++ b/web/lib/test/flow-plugin-catalog.test.ts
@@ -1,12 +1,15 @@
import { describe, expect, it } from 'vitest';
+import { deploymentEvidenceIsValid } from '../../scripts/verify-catalog-gates.mjs';
import {
FLOW_PLUGIN_TRUST_TIERS,
SOFTWARE_FACTORY_FLOW_URL,
flowPluginBadgeMarkdown,
+ flowPluginDependencyHasDeploymentEvidence,
flowPluginGithubRef,
flowPluginInstallHref,
flowPluginInstallPath,
+ flowPluginIsActivatable,
flowPluginSourceUrl,
getFlowPlugin,
getFlowPluginCatalog,
@@ -21,8 +24,8 @@ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
describe('flow plugin catalog', () => {
const catalog = getFlowPluginCatalog();
- it('is version 2 with unique kebab-case plugin names', () => {
- expect(catalog.version).toBe(2);
+ it('is version 3 with unique kebab-case plugin names', () => {
+ expect(catalog.version).toBe(3);
expect(Array.isArray(catalog.plugins)).toBe(true);
expect(catalog.plugins.length).toBeGreaterThan(0);
const names = catalog.plugins.map((plugin) => plugin.name);
@@ -38,6 +41,24 @@ describe('flow plugin catalog', () => {
digest: 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee',
manifestSha256: '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226',
compat: { surface: '^2.0.26', sdk: '^2.0.26', base: ['software-factory'] },
+ runtime: { package: '@relayflows/sdk', version: '2.0.31', release: 'v2.0.31' },
+ activation: {
+ state: 'blocked',
+ dependencies: [
+ {
+ id: 'cloud-babysitter-capability-adapter',
+ repository: 'AgentWorkforce/cloud',
+ requiredState: 'merged-and-deployed',
+ evidence: null,
+ },
+ {
+ id: 'relay-native-existing-session-delivery',
+ repository: 'AgentWorkforce/relay',
+ requiredState: 'merged-and-deployed',
+ evidence: null,
+ },
+ ],
+ },
tier: 'first-party',
base: ['software-factory'],
});
@@ -45,10 +66,123 @@ describe('flow plugin catalog', () => {
expect(babysitter!.digest).toMatch(HEX64);
expect(babysitter!.manifestSha256).toMatch(HEX64);
expect(babysitter!.description).not.toContain('plugin_event_unroutable');
- expect(babysitter!.description).toContain('fail-closed');
+ expect(babysitter!.description).toContain('catalog-only');
expect(babysitter!.description).toContain('no GitHub write or merge authority');
expect(FLOW_PLUGIN_TRUST_TIERS.includes(babysitter!.tier)).toBe(true);
expect(pluginHasUnroutableTriggers(babysitter!)).toBe(false);
+ expect(flowPluginIsActivatable(babysitter!)).toBe(false);
+ expect(flowPluginInstallHref(babysitter!)).toBeNull();
+ });
+
+ it('requires merge and deployment evidence for every dependency before activation', () => {
+ const babysitter = getFlowPlugin('babysitter')!;
+ const evidence = (repository: string, pull: number) => ({
+ pullRequestUrl: `https://github.com/${repository}/pull/${pull}`,
+ mergedCommit: 'a'.repeat(40),
+ mergedAt: '2026-09-24T12:00:00Z',
+ deploymentUrl: `https://api.github.com/repos/${repository}/deployments/${pull}`,
+ deployedAt: '2026-09-24T12:05:00Z',
+ });
+ const ready = {
+ ...babysitter,
+ activation: {
+ state: 'ready' as const,
+ dependencies: babysitter.activation.dependencies.map((dependency, index) => ({
+ ...dependency,
+ evidence: evidence(dependency.repository, [3989, 1851][index]!),
+ })),
+ },
+ };
+ expect(ready.activation.dependencies.every(flowPluginDependencyHasDeploymentEvidence)).toBe(true);
+ expect(flowPluginIsActivatable(ready)).toBe(true);
+ const install = new URL(flowPluginInstallHref(ready)!, 'https://agentrelay.com');
+ expect(install.pathname).toBe('/cloud/flows/deploy');
+ expect(install.searchParams.get('flow')).toBe(SOFTWARE_FACTORY_FLOW_URL);
+ expect(install.searchParams.getAll('plugin')).toEqual([flowPluginSourceUrl(ready)]);
+
+ const dependency = ready.activation.dependencies[0]!;
+ const invalidEvidence: unknown[] = [null, [], {}, { ...dependency.evidence, extra: 'field' }];
+ for (const [key, value] of Object.entries(dependency.evidence)) {
+ for (const malformed of [[value], null, 123, {}, true]) {
+ invalidEvidence.push({ ...dependency.evidence, [key]: malformed });
+ }
+ const missing = { ...dependency.evidence };
+ delete missing[key as keyof typeof missing];
+ invalidEvidence.push(missing);
+ }
+ for (const evidence of invalidEvidence) {
+ const invalid = { ...dependency, evidence } as typeof dependency;
+ expect(flowPluginDependencyHasDeploymentEvidence(invalid)).toBe(false);
+ expect(deploymentEvidenceIsValid(invalid)).toBe(false);
+ expect(flowPluginInstallHref({ ...ready, activation: {
+ ...ready.activation, dependencies: [invalid, ready.activation.dependencies[1]!],
+ } })).toBeNull();
+ }
+ expect(deploymentEvidenceIsValid(dependency)).toBe(true);
+ expect(flowPluginDependencyHasDeploymentEvidence({ ...dependency, evidence: null })).toBe(false);
+
+ for (const dependency of ready.activation.dependencies) {
+ const unrelated = { ...dependency, evidence: { ...dependency.evidence,
+ pullRequestUrl: `https://github.com/${dependency.repository}/pull/42`,
+ } };
+ expect(flowPluginDependencyHasDeploymentEvidence(unrelated)).toBe(false);
+ expect(deploymentEvidenceIsValid(unrelated)).toBe(false);
+ expect(flowPluginInstallHref({ ...ready, activation: { ...ready.activation,
+ dependencies: ready.activation.dependencies.map(value => value.id === unrelated.id ? unrelated : value),
+ } })).toBeNull();
+ }
+
+ // Evidence is internally consistent but belongs to the wrong repository.
+ const wrongRepository = { ...dependency, repository: 'AgentWorkforce/other',
+ evidence: evidence('AgentWorkforce/other', 1) };
+ expect(flowPluginDependencyHasDeploymentEvidence(wrongRepository)).toBe(false);
+ expect(flowPluginInstallHref({ ...ready, activation: {
+ ...ready.activation, dependencies: [wrongRepository, ready.activation.dependencies[1]!],
+ } })).toBeNull();
+
+ const missingDeployment = {
+ ...ready.activation.dependencies[0]!,
+ evidence: { ...ready.activation.dependencies[0]!.evidence!, deploymentUrl: '' },
+ };
+ expect(flowPluginDependencyHasDeploymentEvidence(missingDeployment)).toBe(false);
+ expect(flowPluginIsActivatable({
+ ...ready,
+ activation: { ...ready.activation, dependencies: [missingDeployment, ready.activation.dependencies[1]!] },
+ })).toBe(false);
+
+ expect(flowPluginDependencyHasDeploymentEvidence({
+ ...ready.activation.dependencies[0]!,
+ evidence: {
+ ...ready.activation.dependencies[0]!.evidence!,
+ pullRequestUrl: 'https://github.com/AgentWorkforce/cloud/pull/not-a-number',
+ },
+ })).toBe(false);
+
+ expect(flowPluginIsActivatable({
+ ...ready,
+ activation: { ...ready.activation, dependencies: [ready.activation.dependencies[0]!] },
+ })).toBe(false);
+
+ expect(flowPluginDependencyHasDeploymentEvidence({
+ ...ready.activation.dependencies[0]!,
+ evidence: {
+ ...ready.activation.dependencies[0]!.evidence!,
+ deployedAt: '2026-09-24T11:59:59Z',
+ },
+ })).toBe(false);
+
+ expect(flowPluginDependencyHasDeploymentEvidence({
+ ...ready.activation.dependencies[0]!,
+ evidence: {
+ ...ready.activation.dependencies[0]!.evidence!,
+ mergedAt: '2026-02-30T12:00:00Z',
+ },
+ })).toBe(false);
+
+ expect(flowPluginIsActivatable({
+ ...ready,
+ activation: { ...ready.activation, state: 'blocked' },
+ })).toBe(false);
});
it('builds a GitHub tree URL at the pinned sha, not a branch', () => {
@@ -107,14 +241,10 @@ describe('flowPluginBadgeMarkdown', () => {
]);
});
- it('wires the babysitter gallery card to Software Garden plus the pinned plugin tree', () => {
+ it('does not expose the Babysitter install route while its dependencies are blocked', () => {
const babysitter = getFlowPlugin('babysitter')!;
const href = flowPluginInstallHref(babysitter);
- expect(href).toBeTruthy();
- const url = new URL(href!, 'https://agentrelay.com');
- expect(url.pathname).toBe('/cloud/flows/deploy');
- expect(url.searchParams.get('flow')).toBe(SOFTWARE_FACTORY_FLOW_URL);
- expect(url.searchParams.getAll('plugin')).toEqual([flowPluginSourceUrl(babysitter)]);
+ expect(href).toBeNull();
});
});
diff --git a/web/lib/test/plugin-artifacts.test.ts b/web/lib/test/plugin-artifacts.test.ts
new file mode 100644
index 0000000..f052bed
--- /dev/null
+++ b/web/lib/test/plugin-artifacts.test.ts
@@ -0,0 +1,32 @@
+import { createHash } from 'node:crypto';
+import { describe, expect, it } from 'vitest';
+import { assertPluginArtifact } from '../../scripts/verify-plugin-artifacts.mjs';
+
+const hash = (bytes: Buffer | string) => createHash('sha256').update(bytes).digest('hex');
+const files = [
+ { path: 'flows-plugin.json', data: Buffer.from('{"schemaVersion":2}') },
+ { path: 'handler.ts', data: Buffer.from('export const handler = true;') },
+];
+const plugin = {
+ name: 'fixture',
+ digest: hash(JSON.stringify(files.map(file => ({
+ bytes: file.data.length, path: file.path, sha256: hash(file.data),
+ })))),
+ manifestSha256: hash(files[0].data),
+};
+
+describe('immutable plugin artifact verification', () => {
+ it('accepts matching bytes independent of enumeration order', () => {
+ expect(() => assertPluginArtifact(plugin, [...files].reverse())).not.toThrow();
+ });
+ it('rejects absent manifests, changed content, missing files and unexpected files', () => {
+ for (const invalid of [[], files.slice(1), files.slice(0, 1),
+ [...files, { path: 'extra.ts', data: Buffer.from('extra') }],
+ [files[0], { ...files[1], data: Buffer.from('tampered') }],
+ ]) expect(() => assertPluginArtifact(plugin, invalid)).toThrow();
+ });
+ it('checks the manifest hash independently of the payload digest', () => {
+ expect(() => assertPluginArtifact({ ...plugin, manifestSha256: '0'.repeat(64) }, files))
+ .toThrow('manifest digest mismatch');
+ });
+});
diff --git a/web/lib/test/recommended-extension-gates.test.ts b/web/lib/test/recommended-extension-gates.test.ts
new file mode 100644
index 0000000..5343bfe
--- /dev/null
+++ b/web/lib/test/recommended-extension-gates.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+import pluginsJson from '../../data/flow-plugin-catalog.v1.json';
+import recommendedJson from '../../data/recommended-flow-catalog.v1.json';
+import { validateRecommendedExtensions } from '../../scripts/verify-catalog-gates.mjs';
+
+function fixture() {
+ const plugins = structuredClone(pluginsJson);
+ const catalog = structuredClone(recommendedJson);
+ // A second compatible base demonstrates traversal beyond Software Garden.
+ plugins.plugins[0].base.push('second-flow');
+ catalog.flows.push({ ...structuredClone(catalog.flows[0]), id: 'second-flow' });
+ return { plugins, catalog };
+}
+
+describe('every recommended extension gate', () => {
+ it('accepts matching blocked contracts across all compatible flows', () => {
+ const { plugins, catalog } = fixture();
+ expect(() => validateRecommendedExtensions(catalog, plugins)).not.toThrow();
+ });
+ it('rejects an unsupported second extension on either flow', () => {
+ for (const index of [0, 1]) {
+ const { plugins, catalog } = fixture();
+ catalog.flows[index].extensions.push({
+ ...structuredClone(catalog.flows[index].extensions[0]), id: 'unsupported',
+ activation: { state: 'ready', dependencies: [] },
+ });
+ expect(() => validateRecommendedExtensions(catalog, plugins)).toThrow('no supported plugin contract');
+ }
+ });
+ it('validates evidence for a supported extension on the second flow', () => {
+ const { plugins, catalog } = fixture();
+ catalog.flows[1].extensions[0].activation.state = 'ready';
+ expect(() => validateRecommendedExtensions(catalog, plugins)).toThrow('may be ready only');
+ });
+ it('rejects every artifact or runtime mismatch on the second flow', () => {
+ for (const field of ['ref', 'digest', 'manifestSha256'] as const) {
+ const { plugins, catalog } = fixture();
+ catalog.flows[1].extensions[0].artifact[field] = 'unverified';
+ expect(() => validateRecommendedExtensions(catalog, plugins)).toThrow('artifact coordinates');
+ }
+ const { plugins, catalog } = fixture();
+ catalog.flows[1].extensions[0].runtime.version = '99.0.0';
+ expect(() => validateRecommendedExtensions(catalog, plugins)).toThrow('runtime provenance');
+ });
+ it('rejects duplicate extensions, duplicate plugin contracts and incompatible bases', () => {
+ const duplicate = fixture();
+ duplicate.catalog.flows[1].extensions.push(duplicate.catalog.flows[1].extensions[0]);
+ expect(() => validateRecommendedExtensions(duplicate.catalog, duplicate.plugins)).toThrow('is repeated');
+ const ambiguous = fixture();
+ ambiguous.plugins.plugins.push(ambiguous.plugins.plugins[0]);
+ expect(() => validateRecommendedExtensions(ambiguous.catalog, ambiguous.plugins)).toThrow('repeats');
+ const incompatible = fixture();
+ incompatible.plugins.plugins[0].base = ['software-factory'];
+ expect(() => validateRecommendedExtensions(incompatible.catalog, incompatible.plugins)).toThrow('incompatible');
+ });
+ it('rejects malformed extension lists instead of skipping them', () => {
+ for (const extensions of [null, {}, 'babysitter']) {
+ const { plugins, catalog } = fixture();
+ const malformed = { ...catalog, flows: [catalog.flows[0], { ...catalog.flows[1], extensions }] };
+ expect(() => validateRecommendedExtensions(malformed, plugins)).toThrow('extensions must be an array');
+ }
+ });
+});
diff --git a/web/lib/test/recommended-flow-catalog.test.ts b/web/lib/test/recommended-flow-catalog.test.ts
index 6aadea3..23e69eb 100644
--- a/web/lib/test/recommended-flow-catalog.test.ts
+++ b/web/lib/test/recommended-flow-catalog.test.ts
@@ -11,10 +11,10 @@ describe('recommended flow catalog', () => {
const catalog = getRecommendedFlowCatalog();
expect(catalog).toEqual({
schemaVersion: 1,
- catalogVersion: 2,
+ catalogVersion: 3,
flows: [{
id: 'software-factory',
- version: 2,
+ version: 3,
name: 'Software Garden',
summary: expect.any(String),
description: expect.any(String),
@@ -26,6 +26,33 @@ describe('recommended flow catalog', () => {
defaults: { agents: ['claude'] },
allowedAgents: ['claude'],
},
+ extensions: [{
+ id: 'babysitter',
+ version: '0.2.0',
+ runtime: { package: '@relayflows/sdk', version: '2.0.31', release: 'v2.0.31' },
+ artifact: {
+ ref: 'github:AgentWorkforce/flows@8b33ebab8347514f80d9da5a81206a087f641714#extensions/babysitter',
+ digest: 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee',
+ manifestSha256: '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226',
+ },
+ activation: {
+ state: 'blocked',
+ dependencies: [
+ {
+ id: 'cloud-babysitter-capability-adapter',
+ repository: 'AgentWorkforce/cloud',
+ requiredState: 'merged-and-deployed',
+ evidence: null,
+ },
+ {
+ id: 'relay-native-existing-session-delivery',
+ repository: 'AgentWorkforce/relay',
+ requiredState: 'merged-and-deployed',
+ evidence: null,
+ },
+ ],
+ },
+ }],
source: {
kind: 'github',
owner: 'AgentWorkforce',
@@ -41,6 +68,7 @@ describe('recommended flow catalog', () => {
}],
});
expect(JSON.parse(JSON.stringify(catalog))).toEqual(catalog);
+ expect(catalog.flows[0]?.extensions[0]?.activation.state).toBe('blocked');
});
it('keeps display branding separate from the canonical activation id', () => {
@@ -76,6 +104,7 @@ describe('recommended flow catalog HTTP surface', () => {
id: 'software-factory',
name: 'Software Garden',
source: { ref: '8b33ebab8347514f80d9da5a81206a087f641714' },
+ extensions: [{ id: 'babysitter', activation: { state: 'blocked' } }],
});
});
diff --git a/web/scripts/verify-catalog-gates.d.mts b/web/scripts/verify-catalog-gates.d.mts
new file mode 100644
index 0000000..e2d5156
--- /dev/null
+++ b/web/scripts/verify-catalog-gates.d.mts
@@ -0,0 +1,3 @@
+import type { FlowPluginActivationDependency } from '../lib/flow-plugin-catalog';
+export function deploymentEvidenceIsValid(dependency: FlowPluginActivationDependency): boolean;
+export function validateRecommendedExtensions(recommendedCatalog: unknown, pluginCatalog: unknown): void;
diff --git a/web/scripts/verify-catalog-gates.mjs b/web/scripts/verify-catalog-gates.mjs
new file mode 100644
index 0000000..b6db178
--- /dev/null
+++ b/web/scripts/verify-catalog-gates.mjs
@@ -0,0 +1,158 @@
+import { FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS } from '../lib/flow-plugin-implementation-prs.mjs';
+import { readFile } from 'node:fs/promises';
+import { verifyDeploymentReceipt } from './verify-deployment-receipts.mjs';
+
+const pluginCatalogUrl = new URL('../data/flow-plugin-catalog.v1.json', import.meta.url);
+const recommendedCatalogUrl = new URL('../data/recommended-flow-catalog.v1.json', import.meta.url);
+
+const [pluginCatalog, recommendedCatalog] = await Promise.all([
+ readFile(pluginCatalogUrl, 'utf8').then(JSON.parse),
+ readFile(recommendedCatalogUrl, 'utf8').then(JSON.parse),
+]);
+
+const REQUIRED_PLUGIN_DEPENDENCIES = new Map([
+ ['babysitter', new Map([
+ ['cloud-babysitter-capability-adapter', 'AgentWorkforce/cloud'],
+ ['relay-native-existing-session-delivery', 'AgentWorkforce/relay'],
+ ])],
+]);
+const SHA = /^[0-9a-f]{40}$/;
+const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
+
+function fail(message) {
+ throw new Error(`catalog dependency gate: ${message}`);
+}
+
+function timestampMillis(value) {
+ if (!ISO_TIMESTAMP.test(value)) return null;
+ const milliseconds = Date.parse(value);
+ if (!Number.isFinite(milliseconds)) return null;
+ const normalized = value.includes('.') ? value : value.replace(/Z$/, '.000Z');
+ return new Date(milliseconds).toISOString() === normalized ? milliseconds : null;
+}
+
+export function deploymentEvidenceIsValid(dependency) {
+ const evidence = dependency.evidence;
+ if (evidence === null) return false;
+ if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) return false;
+ if (Object.keys(evidence).sort().join(',') !== 'deployedAt,deploymentUrl,mergedAt,mergedCommit,pullRequestUrl') return false;
+ if (!Object.values(evidence).every(value => typeof value === 'string')) return false;
+ if (!Object.hasOwn(FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS, dependency.id)
+ || evidence.pullRequestUrl !== FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS[dependency.id]) return false;
+ let pullRequestUrl;
+ let deploymentUrl;
+ try {
+ pullRequestUrl = new URL(evidence.pullRequestUrl);
+ deploymentUrl = new URL(evidence.deploymentUrl);
+ } catch {
+ return false;
+ }
+ const mergedAt = timestampMillis(evidence.mergedAt);
+ const deployedAt = timestampMillis(evidence.deployedAt);
+ return pullRequestUrl.origin === 'https://github.com'
+ && new RegExp(`^/${dependency.repository}/pull/[1-9][0-9]*$`).test(pullRequestUrl.pathname)
+ && SHA.test(evidence.mergedCommit)
+ && mergedAt !== null
+ && deploymentUrl.href === `https://api.github.com/repos/${dependency.repository}/deployments/${deploymentUrl.pathname.split('/').pop()}`
+ && /^[1-9][0-9]*$/.test(deploymentUrl.pathname.split('/').pop() ?? '')
+ && deployedAt !== null
+ && deployedAt >= mergedAt;
+}
+
+function validateActivationGate(gate, label, requiredDependencies) {
+ if (!gate || typeof gate !== 'object' || Array.isArray(gate)) fail(`${label} has no activation gate`);
+ if (Object.keys(gate).sort().join(',') !== 'dependencies,state') fail(`${label} activation gate has unknown fields`);
+ if (gate.state !== 'blocked' && gate.state !== 'ready') fail(`${label} activation state must be blocked or ready`);
+ if (!Array.isArray(gate.dependencies) || gate.dependencies.length !== requiredDependencies.size) {
+ fail(`${label} must declare its complete runtime dependency set`);
+ }
+ const seen = new Set();
+ for (const dependency of gate.dependencies) {
+ if (!dependency || typeof dependency !== 'object' || Array.isArray(dependency)) fail(`${label} has an invalid dependency`);
+ if (Object.keys(dependency).sort().join(',') !== 'evidence,id,repository,requiredState') {
+ fail(`${label} dependency ${dependency.id ?? ''} has unknown fields`);
+ }
+ if (seen.has(dependency.id)) fail(`${label} repeats dependency ${dependency.id}`);
+ seen.add(dependency.id);
+ if (requiredDependencies.get(dependency.id) !== dependency.repository) {
+ fail(`${label} dependency ${dependency.id} has the wrong repository`);
+ }
+ if (dependency.requiredState !== 'merged-and-deployed') {
+ fail(`${label} dependency ${dependency.id} must require merged-and-deployed`);
+ }
+ }
+ const allDependenciesProven = gate.dependencies.every(deploymentEvidenceIsValid);
+ if (gate.state === 'ready' && !allDependenciesProven) {
+ fail(`${label} may be ready only when every dependency carries merge and deployment evidence`);
+ }
+}
+
+if (pluginCatalog.version !== 3 || !Array.isArray(pluginCatalog.plugins)) {
+ fail('flow plugin catalog must be version 3');
+}
+if (recommendedCatalog.schemaVersion !== 1 || recommendedCatalog.catalogVersion !== 3 || !Array.isArray(recommendedCatalog.flows)) {
+ fail('recommended flow catalog must be schemaVersion 1 and catalogVersion 3');
+}
+
+const plugin = pluginCatalog.plugins.find(entry => entry.name === 'babysitter');
+const garden = recommendedCatalog.flows.find(flow => flow.id === 'software-factory');
+const extension = garden?.extensions?.find(entry => entry.id === 'babysitter');
+if (!plugin || !garden || !extension) fail('Babysitter must exist in both catalogs');
+
+/** Every published extension must use a supported, validated plugin contract. */
+export function validateRecommendedExtensions(recommendedCatalog, pluginCatalog) {
+ const plugins = new Map();
+ for (const entry of pluginCatalog.plugins) {
+ if (plugins.has(entry.name)) fail(`plugin catalog repeats ${entry.name}`);
+ const requiredDependencies = REQUIRED_PLUGIN_DEPENDENCIES.get(entry.name);
+ if (!requiredDependencies) fail(`${entry.name} has no independently defined dependency contract`);
+ validateActivationGate(entry.activation, `plugin catalog ${entry.name}`, requiredDependencies);
+ plugins.set(entry.name, entry);
+ }
+ const flowIds = new Set();
+ for (const flow of recommendedCatalog.flows) {
+ if (flowIds.has(flow.id)) fail(`recommended catalog repeats flow ${flow.id}`);
+ flowIds.add(flow.id);
+ if (flow.extensions === undefined) continue;
+ if (!Array.isArray(flow.extensions)) fail(`${flow.id} extensions must be an array`);
+ const extensionIds = new Set();
+ for (const extension of flow.extensions) {
+ const label = `${flow.id} extension ${extension?.id ?? ''}`;
+ if (!extension || typeof extension !== 'object' || Array.isArray(extension)) fail(`${label} is invalid`);
+ if (extensionIds.has(extension.id)) fail(`${label} is repeated`);
+ extensionIds.add(extension.id);
+ const plugin = plugins.get(extension.id);
+ if (!plugin) fail(`${label} has no supported plugin contract`);
+ if (!plugin.base.includes(flow.id)) fail(`${label} is incompatible with this base flow`);
+ validateActivationGate(extension.activation, label, REQUIRED_PLUGIN_DEPENDENCIES.get(extension.id));
+ const ref = `github:${plugin.source.owner}/${plugin.source.repo}@${plugin.ref}#${plugin.source.path}`;
+ if (!extension.artifact || extension.artifact.ref !== ref
+ || extension.artifact.digest !== plugin.digest
+ || extension.artifact.manifestSha256 !== plugin.manifestSha256) {
+ fail(`${label} artifact coordinates drifted from its plugin contract`);
+ }
+ if (JSON.stringify(extension.runtime) !== JSON.stringify(plugin.runtime)) {
+ fail(`${label} runtime provenance drifted from its plugin contract`);
+ }
+ if (JSON.stringify(extension.activation) !== JSON.stringify(plugin.activation)) {
+ fail(`${label} activation gate drifted from its plugin contract`);
+ }
+ }
+ }
+}
+
+validateRecommendedExtensions(recommendedCatalog, pluginCatalog);
+
+if (plugin.runtime.package !== '@relayflows/sdk'
+ || plugin.runtime.version !== '2.0.31'
+ || plugin.runtime.release !== 'v2.0.31') {
+ fail('Babysitter runtime must identify published @relayflows/sdk v2.0.31');
+}
+
+// Shape checks alone are not deployment proof. Only ready entries need live receipts.
+for (const entry of pluginCatalog.plugins) {
+ if (entry.activation.state !== 'ready') continue;
+ for (const dependency of entry.activation.dependencies) await verifyDeploymentReceipt(dependency);
+}
+
+console.log('catalog gates: Babysitter metadata is pinned and activation is fail-closed');
diff --git a/web/scripts/verify-deployment-receipts.d.mts b/web/scripts/verify-deployment-receipts.d.mts
new file mode 100644
index 0000000..6e13634
--- /dev/null
+++ b/web/scripts/verify-deployment-receipts.d.mts
@@ -0,0 +1,5 @@
+import type { FlowPluginActivationDependency } from '../lib/flow-plugin-catalog';
+export function verifyDeploymentReceipt(
+ dependency: FlowPluginActivationDependency,
+ request?: typeof fetch,
+): Promise;
diff --git a/web/scripts/verify-deployment-receipts.mjs b/web/scripts/verify-deployment-receipts.mjs
new file mode 100644
index 0000000..96638ea
--- /dev/null
+++ b/web/scripts/verify-deployment-receipts.mjs
@@ -0,0 +1,58 @@
+import { FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS } from '../lib/flow-plugin-implementation-prs.mjs';
+
+const REPOSITORIES = new Map([
+ ['cloud-babysitter-capability-adapter', 'AgentWorkforce/cloud'],
+ ['relay-native-existing-session-delivery', 'AgentWorkforce/relay'],
+]);
+
+/** Read-only, public GitHub receipts. Missing/private/rate-limited proof fails closed. */
+export async function verifyDeploymentReceipt(dependency, request = fetch) {
+ const repository = REPOSITORIES.get(dependency.id);
+ const evidence = dependency.evidence;
+ if (!repository || dependency.repository !== repository || !evidence) {
+ throw new Error('dependency has no supported receipt contract');
+ }
+ if (!Object.hasOwn(FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS, dependency.id)
+ || evidence.pullRequestUrl !== FLOW_PLUGIN_IMPLEMENTATION_PULL_REQUESTS[dependency.id]) {
+ throw new Error('PR receipt does not identify the declared capability implementation');
+ }
+ const prPrefix = `https://github.com/${repository}/pull/`;
+ const deploymentPrefix = `https://api.github.com/repos/${repository}/deployments/`;
+ const id = (value, prefix) => typeof value === 'string' && value.startsWith(prefix)
+ && /^[1-9][0-9]*$/.test(value.slice(prefix.length)) ? value.slice(prefix.length) : null;
+ const pull = id(evidence.pullRequestUrl, prPrefix);
+ const deployment = id(evidence.deploymentUrl, deploymentPrefix);
+ if (!pull || !deployment) throw new Error('receipt must identify a PR and GitHub deployment in the required repository');
+ const api = `https://api.github.com/repos/${repository}`;
+ const read = async url => {
+ const response = await request(url, {
+ headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' },
+ redirect: 'error', signal: AbortSignal.timeout(30_000),
+ });
+ if (!response.ok) throw new Error(`dependency receipt unavailable: HTTP ${response.status}`);
+ return response.json();
+ };
+ const pr = await read(`${api}/pulls/${pull}`);
+ if (pr.merged !== true || pr.base?.repo?.full_name !== repository || pr.base?.ref !== 'main'
+ || pr.html_url !== evidence.pullRequestUrl || pr.merge_commit_sha !== evidence.mergedCommit
+ || pr.merged_at !== evidence.mergedAt) {
+ throw new Error('PR receipt does not prove the declared merge into main');
+ }
+ const receipt = await read(`${deploymentPrefix}${deployment}`);
+ if (receipt.url !== evidence.deploymentUrl || receipt.sha !== evidence.mergedCommit
+ || receipt.repository_url !== api || receipt.environment !== 'production'
+ || receipt.production_environment !== true || receipt.transient_environment !== false
+ || !Number.isFinite(Date.parse(receipt.created_at))
+ || Date.parse(receipt.created_at) < Date.parse(evidence.mergedAt)) {
+ throw new Error('deployment receipt does not prove this merged commit in production');
+ }
+ // GitHub lists the newest status first; a past success followed by failure/inactive is insufficient.
+ const statuses = await read(`${deploymentPrefix}${deployment}/statuses?per_page=1`);
+ const latest = Array.isArray(statuses) ? statuses[0] : null;
+ if (!latest || latest.state !== 'success' || latest.environment !== 'production'
+ || latest.deployment_url !== evidence.deploymentUrl || latest.repository_url !== api
+ || latest.created_at !== evidence.deployedAt
+ || Date.parse(latest.created_at) < Date.parse(receipt.created_at)) {
+ throw new Error('latest deployment status does not prove the declared successful deployment');
+ }
+}
diff --git a/web/scripts/verify-plugin-artifacts.d.mts b/web/scripts/verify-plugin-artifacts.d.mts
new file mode 100644
index 0000000..d43d60e
--- /dev/null
+++ b/web/scripts/verify-plugin-artifacts.d.mts
@@ -0,0 +1,6 @@
+import type { FlowPluginCatalogEntry } from '../lib/flow-plugin-catalog';
+export function assertPluginArtifact(
+ plugin: Pick,
+ files: { path: string; data: Buffer }[],
+): void;
+export function verifyPluginArtifact(plugin: FlowPluginCatalogEntry): Promise;
diff --git a/web/scripts/verify-plugin-artifacts.mjs b/web/scripts/verify-plugin-artifacts.mjs
new file mode 100644
index 0000000..4369e31
--- /dev/null
+++ b/web/scripts/verify-plugin-artifacts.mjs
@@ -0,0 +1,65 @@
+import { execFile } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { promisify } from 'node:util';
+
+const exec = promisify(execFile);
+const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
+
+/** Match the SDK payloadManifest: path-sorted entries with canonical object keys. */
+export function assertPluginArtifact(plugin, files) {
+ const manifest = files.find(file => file.path === 'flows-plugin.json');
+ if (!manifest) throw new Error(`${plugin.name}: flows-plugin.json is missing`);
+ const entries = [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
+ .map(file => ({ bytes: file.data.length, path: file.path, sha256: sha256(file.data) }));
+ if (sha256(JSON.stringify(entries)) !== plugin.digest) {
+ throw new Error(`${plugin.name}: artifact digest mismatch`);
+ }
+ if (sha256(manifest.data) !== plugin.manifestSha256) {
+ throw new Error(`${plugin.name}: manifest digest mismatch`);
+ }
+}
+
+/** Fetch the immutable commit, enumerate its actual directory, and hash every file. */
+export async function verifyPluginArtifact(plugin) {
+ const { owner, repo, path } = plugin.source;
+ if (![owner, repo].every(value => typeof value === 'string' && /^[A-Za-z0-9_.-]+$/.test(value))
+ || typeof plugin.ref !== 'string' || !/^[0-9a-f]{40}$/.test(plugin.ref)
+ || typeof path !== 'string' || !path || path.startsWith('/')
+ || path.split('/').some(part => !part || part === '.' || part === '..')) {
+ throw new Error(`${plugin.name}: invalid artifact coordinates`);
+ }
+ const directory = await mkdtemp(join(tmpdir(), 'catalog-artifact-'));
+ const git = (...args) => exec('git', args, {
+ cwd: directory, timeout: 60_000, maxBuffer: 4_000_000, encoding: 'buffer',
+ });
+ try {
+ await git('init', '--quiet');
+ await git('fetch', '--quiet', '--depth=1', '--no-tags',
+ `https://github.com/${owner}/${repo}.git`, plugin.ref);
+ const { stdout: head } = await git('rev-parse', 'FETCH_HEAD');
+ if (head.toString().trim() !== plugin.ref) throw new Error(`${plugin.name}: fetched commit mismatch`);
+ const prefix = `${path}/`;
+ const { stdout } = await git('ls-tree', '-r', '-z', '--long', plugin.ref, '--', path);
+ const records = stdout.toString().split('\0').filter(Boolean);
+ if (!records.length || records.length > 500) throw new Error(`${plugin.name}: missing or oversized artifact`);
+ const files = [];
+ let total = 0;
+ for (const record of records) {
+ const match = /^(100644|100755) blob ([0-9a-f]{40}) +([0-9]+)\t(.+)$/s.exec(record);
+ if (!match || !match[4].startsWith(prefix)) throw new Error(`${plugin.name}: artifact must contain plain files`);
+ const size = Number(match[3]);
+ total += size;
+ if (size > 256_000 || total > 2_000_000) throw new Error(`${plugin.name}: artifact exceeds size limit`);
+ const { stdout: data } = await git('cat-file', 'blob', match[2]);
+ if (data.length !== size) throw new Error(`${plugin.name}: blob size mismatch`);
+ files.push({ path: match[4].slice(prefix.length), data });
+ }
+ assertPluginArtifact(plugin, files);
+ console.log(`${plugin.name}: verified ${plugin.ref}#${path} sha256:${plugin.digest}`);
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+}
diff --git a/web/scripts/verify-recommended-flow-catalog.mjs b/web/scripts/verify-recommended-flow-catalog.mjs
index 15246f7..68c8442 100644
--- a/web/scripts/verify-recommended-flow-catalog.mjs
+++ b/web/scripts/verify-recommended-flow-catalog.mjs
@@ -1,3 +1,4 @@
+import { verifyPluginArtifact } from './verify-plugin-artifacts.mjs';
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
@@ -9,8 +10,8 @@ const catalogUrl = new URL('../data/recommended-flow-catalog.v1.json', import.me
const catalog = JSON.parse(await readFile(catalogUrl, 'utf8'));
const MAX_SOURCE_BYTES = 1024 * 1024;
-if (catalog.schemaVersion !== 1 || catalog.catalogVersion !== 2 || !Array.isArray(catalog.flows)) {
- throw new Error('recommended-flow catalog must be schemaVersion 1, catalogVersion 2, with a flows array');
+if (catalog.schemaVersion !== 1 || catalog.catalogVersion !== 3 || !Array.isArray(catalog.flows)) {
+ throw new Error('recommended-flow catalog must be schemaVersion 1, catalogVersion 3, with a flows array');
}
for (const flow of catalog.flows) {
@@ -69,3 +70,6 @@ for (const flow of catalog.flows) {
assertRecommendedFlowSourceContract(flow, Buffer.concat(chunks).toString('utf8'));
console.log(`${flow.id}: verified ${source.release} (${source.ref}) sha256:${actualHash}`);
}
+
+const plugins = JSON.parse(await readFile(new URL('../data/flow-plugin-catalog.v1.json', import.meta.url), 'utf8'));
+for (const plugin of plugins.plugins) await verifyPluginArtifact(plugin);