@nanobpm/nano-workforce 0.159.1 → 0.160.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.160.0](https://github.com/nanobpm/nano-workforce/compare/v0.159.1...v0.160.0) (2026-08-29)
2
+
3
+ ### Features
4
+
5
+ * **delivery-graph:** getDeliveryGraphVocabulary read tool — closed vocabulary + wait semantics as structured data (S3) ([#616](https://github.com/nanobpm/nano-workforce/issues/616)) ([8d3e90a](https://github.com/nanobpm/nano-workforce/commit/8d3e90a294f9ed195c5cc1b7a30cffb1799a053a)), closes [nanobpm/nano-workforce#605](https://github.com/nanobpm/nano-workforce/issues/605) [#609](https://github.com/nanobpm/nano-workforce/issues/609)
6
+
1
7
  ## [0.159.1](https://github.com/nanobpm/nano-workforce/compare/v0.159.0...v0.159.1) (2026-08-29)
2
8
 
3
9
  ### Bug Fixes
@@ -0,0 +1,102 @@
1
+ // app/deliveryGraphVocabulary.test.ts — the DRIFT GUARD for the delivery-graph vocabulary surface
2
+ // (epic nano-workforce#605, S3/#609). The vocabulary (`getDeliveryGraphVocabulary`) exists so agents
3
+ // discover the closed node/probe/connector vocabulary from the surface instead of reading source; if
4
+ // a new probe kind or connector target lands in the compiler WITHOUT a matching vocabulary entry, the
5
+ // surface silently lies. These tests fail the build in exactly that case: they assert the vocabulary's
6
+ // key sets are byte-identical to the closed sets in `app/deliveryGraph.ts` / `app/readiness.ts` /
7
+ // `app/convergeTargets.ts` (AGENTS.md — "no drift surfaces").
8
+ import assert from "node:assert/strict";
9
+ import { test } from "node:test";
10
+ import { CONVERGE_MERGE_TARGET, CONVERGE_TARGET, isConvergeTarget, MERGE_MAIN_TARGET } from "./convergeTargets.ts";
11
+ import { DELIVERY_FACT_TYPES, DELIVERY_GUARD_SCALAR_TYPES, DELIVERY_NODE_KINDS } from "./deliveryGraph.ts";
12
+ import { deliveryGraphVocabulary } from "./deliveryGraphVocabulary.ts";
13
+ import {
14
+ DEFAULT_EVERY_MS,
15
+ DEFAULT_TIMEOUT_MS,
16
+ EPIC_CONDITIONS,
17
+ ON_TIMEOUTS,
18
+ PR_CONDITIONS,
19
+ PROBE_KINDS,
20
+ } from "./readiness.ts";
21
+
22
+ const sorted = (xs: readonly string[]): string[] => [...xs].sort();
23
+
24
+ test("node kinds cover exactly DELIVERY_NODE_KINDS (add a kind to the compiler ⇒ must add a vocab entry)", () => {
25
+ const vocab = deliveryGraphVocabulary();
26
+ assert.deepEqual(
27
+ sorted(vocab.nodeKinds.map((n) => n.kind)),
28
+ sorted(DELIVERY_NODE_KINDS),
29
+ "vocabulary node kinds drifted from DELIVERY_NODE_KINDS — extend NODE_KIND_DETAIL",
30
+ );
31
+ });
32
+
33
+ test("wait probe kinds cover exactly PROBE_KINDS (add a probe kind ⇒ must add a vocab entry)", () => {
34
+ const vocab = deliveryGraphVocabulary();
35
+ assert.deepEqual(
36
+ sorted(vocab.waitProbeKinds.map((p) => p.kind)),
37
+ sorted(PROBE_KINDS),
38
+ "vocabulary wait probe kinds drifted from PROBE_KINDS — extend WAIT_PROBE_DETAIL",
39
+ );
40
+ });
41
+
42
+ test("pr / epic probe conditions match the closed PR_CONDITIONS / EPIC_CONDITIONS", () => {
43
+ const vocab = deliveryGraphVocabulary();
44
+ const pr = vocab.waitProbeKinds.find((p) => p.kind === "pr");
45
+ const epic = vocab.waitProbeKinds.find((p) => p.kind === "epic");
46
+ assert.ok(pr && epic, "pr and epic probe entries must exist");
47
+ assert.deepEqual(sorted(pr.conditions ?? []), sorted(PR_CONDITIONS), "pr conditions drifted from PR_CONDITIONS");
48
+ assert.deepEqual(sorted(epic.conditions ?? []), sorted(EPIC_CONDITIONS), "epic conditions drifted from EPIC_CONDITIONS");
49
+ });
50
+
51
+ test("every real converge-enrollment target has a real vocab entry (add a target ⇒ must add a vocab entry)", () => {
52
+ const vocab = deliveryGraphVocabulary();
53
+ const realTargets = vocab.connectorTargets.filter((t) => t.status === "real").map((t) => t.target);
54
+ for (const target of [CONVERGE_TARGET, CONVERGE_MERGE_TARGET, MERGE_MAIN_TARGET]) {
55
+ assert.ok(
56
+ realTargets.includes(target),
57
+ `converge target '${target}' is missing a 'real' vocabulary entry — extend REAL_CONNECTOR_TARGETS`,
58
+ );
59
+ // Guard the classification too: a target the compiler treats as converge-enrollment must be marked real.
60
+ assert.ok(isConvergeTarget(target), `sanity: '${target}' must be an isConvergeTarget`);
61
+ }
62
+ // Exactly the converge set is "real"; nothing else is claimed real, and the stub sentinel is present.
63
+ assert.deepEqual(sorted(realTargets), sorted([CONVERGE_TARGET, CONVERGE_MERGE_TARGET, MERGE_MAIN_TARGET]));
64
+ assert.ok(
65
+ vocab.connectorTargets.some((t) => t.status === "forward-declared"),
66
+ "the forward-declared stub sentinel must be present so agents learn the real-vs-stub split",
67
+ );
68
+ });
69
+
70
+ test("onTimeout options match the closed ON_TIMEOUTS", () => {
71
+ const vocab = deliveryGraphVocabulary();
72
+ assert.deepEqual(sorted(vocab.onTimeout.map((o) => o.value)), sorted(ON_TIMEOUTS), "onTimeout options drifted from ON_TIMEOUTS");
73
+ });
74
+
75
+ test("fact types + guard scalar types are derived verbatim", () => {
76
+ const vocab = deliveryGraphVocabulary();
77
+ assert.deepEqual(vocab.factTypes, [...DELIVERY_FACT_TYPES]);
78
+ assert.deepEqual(vocab.guardScalarTypes, [...DELIVERY_GUARD_SCALAR_TYPES]);
79
+ });
80
+
81
+ test("poll-budget carries the real defaults and names the 30-minute trap", () => {
82
+ const vocab = deliveryGraphVocabulary();
83
+ assert.equal(vocab.pollBudget.defaultTimeoutMs, DEFAULT_TIMEOUT_MS);
84
+ assert.equal(vocab.pollBudget.defaultEveryMs, DEFAULT_EVERY_MS);
85
+ assert.match(vocab.pollBudget.rule, /poll\.timeoutMs/);
86
+ assert.match(vocab.pollBudget.rule, /30 minutes|1800000/);
87
+ });
88
+
89
+ test("the epic probe states the FEATURE-RUN observation semantics (the #605 evidence gap)", () => {
90
+ const vocab = deliveryGraphVocabulary();
91
+ const epic = vocab.waitProbeKinds.find((p) => p.kind === "epic");
92
+ assert.ok(epic, "epic probe entry must exist");
93
+ assert.match(epic.observes, /rootRequestKey/i);
94
+ assert.match(epic.observes, /regardless of/i);
95
+ assert.match(epic.observes, /feature/i);
96
+ assert.match(epic.ready, /stage:"merged"|stage:\\"merged\\"|merged.*active:false/);
97
+ });
98
+
99
+ test("fact-threading rule names the unbound-pr rejection", () => {
100
+ const vocab = deliveryGraphVocabulary();
101
+ assert.match(vocab.factThreading.rule, /unbound-pr/);
102
+ });
@@ -0,0 +1,319 @@
1
+ // app/deliveryGraphVocabulary.ts — the delivery-graph vocabulary + wait-probe semantics as
2
+ // STRUCTURED DATA (epic nano-workforce#605, S3/#609). Served by GET /app/api/delivery-graph/vocabulary
3
+ // (operationId `getDeliveryGraphVocabulary`, a read tool projected onto the MCP surface like
4
+ // `getAgentInstructions`), so an agent can DISCOVER the closed node/probe/connector vocabulary and the
5
+ // non-obvious wait semantics from the surface instead of reading source (the evidence session in #605:
6
+ // an agent had to grep `app/readiness.ts` to learn `wait[epic]` also gates a feature run).
7
+ //
8
+ // Derivation over duplication (AGENTS.md — "no drift surfaces"). Everything that has a closed,
9
+ // compiler-enforced source of truth is DERIVED from it, never re-typed:
10
+ // • node kinds ← `DELIVERY_NODE_KINDS` (app/deliveryGraph.ts — the trust boundary)
11
+ // • fact types ← `DELIVERY_FACT_TYPES` (app/deliveryGraph.ts)
12
+ // • guardable scalars ← `DELIVERY_GUARD_SCALAR_TYPES`
13
+ // • wait probe kinds ← `PROBE_KINDS` (app/readiness.ts — what `parseProbe` accepts)
14
+ // • pr conditions ← `PR_CONDITIONS` (app/readiness.ts)
15
+ // • epic conditions ← `EPIC_CONDITIONS` (app/readiness.ts)
16
+ // • onTimeout options ← `ON_TIMEOUTS` (app/readiness.ts)
17
+ // • poll defaults ← `DEFAULT_TIMEOUT_MS`/`DEFAULT_EVERY_MS`/`DEFAULT_READINESS_TIMEOUT`
18
+ // • real connector targets ← `converge`/`converge-merge`/`merge-main` (app/convergeTargets.ts)
19
+ // The prose (body contracts, what each probe OBSERVES, the poll-budget trap, fact-threading) is
20
+ // co-located here; `app/deliveryGraphVocabulary.test.ts` is the drift guard — it fails the build if a
21
+ // probe kind / connector target / node kind is added to the compiler without a vocabulary entry.
22
+ import {
23
+ CONVERGE_MERGE_TARGET,
24
+ CONVERGE_TARGET,
25
+ convergeOnlyForTarget,
26
+ MERGE_MAIN_TARGET,
27
+ } from "./convergeTargets.ts";
28
+ import { DELIVERY_FACT_TYPES, DELIVERY_GUARD_SCALAR_TYPES, DELIVERY_NODE_KINDS } from "./deliveryGraph.ts";
29
+ import {
30
+ DEFAULT_EVERY_MS,
31
+ DEFAULT_READINESS_TIMEOUT,
32
+ DEFAULT_TIMEOUT_MS,
33
+ EPIC_CONDITIONS,
34
+ ON_TIMEOUTS,
35
+ PR_CONDITIONS,
36
+ PROBE_KINDS,
37
+ } from "./readiness.ts";
38
+
39
+ /** A node-kind entry: the closed `kind`, its per-kind config key + required/optional body fields, and
40
+ * whether it is side-effecting / may emit facts. `body` names the executable engine-native surface the
41
+ * graph layer schedules onto (the graph layer does NOT re-implement execution). */
42
+ export interface NodeKindEntry {
43
+ kind: string;
44
+ configKey: string;
45
+ requiredFields: string[];
46
+ optionalFields: string[];
47
+ sideEffecting: boolean;
48
+ mayEmit: boolean;
49
+ summary: string;
50
+ }
51
+
52
+ /** A wait-probe entry: the closed `kind`, the `match` fields it reads, and — crucially — WHAT it
53
+ * OBSERVES (the read that decides readiness) and WHEN it is ready. */
54
+ export interface WaitProbeEntry {
55
+ kind: string;
56
+ target: string;
57
+ matchFields: string[];
58
+ conditions?: string[];
59
+ observes: string;
60
+ ready: string;
61
+ binds?: string[];
62
+ }
63
+
64
+ /** A connector target: whether it is a REAL side-effecting target (a converge-enrollment target that
65
+ * dispatches through `submitPr`) or a FORWARD-DECLARED stub (the connector I/O surface is an ADR 0005
66
+ * non-goal — an unrecognised target returns a deterministic acknowledgement and performs no I/O). */
67
+ export interface ConnectorTargetEntry {
68
+ target: string;
69
+ status: "real" | "forward-declared";
70
+ convergeOnlyDefault?: boolean;
71
+ summary: string;
72
+ }
73
+
74
+ /** An `onTimeout` option: what the bounded wait does when the engine timer arm fires. */
75
+ export interface OnTimeoutEntry {
76
+ value: string;
77
+ meaning: string;
78
+ }
79
+
80
+ /** The whole structured vocabulary the read tool returns. */
81
+ export interface DeliveryGraphVocabulary {
82
+ adr: string;
83
+ summary: string;
84
+ nodeKinds: NodeKindEntry[];
85
+ factTypes: string[];
86
+ guardScalarTypes: string[];
87
+ waitProbeKinds: WaitProbeEntry[];
88
+ connectorTargets: ConnectorTargetEntry[];
89
+ onTimeout: OnTimeoutEntry[];
90
+ pollBudget: {
91
+ defaultTimeoutMs: number;
92
+ defaultTimeoutIso: string;
93
+ defaultEveryMs: number;
94
+ rule: string;
95
+ };
96
+ factThreading: {
97
+ rule: string;
98
+ details: string[];
99
+ };
100
+ guideSection: string;
101
+ }
102
+
103
+ // ── Node kinds (DERIVED from DELIVERY_NODE_KINDS — the closed allowlist / trust boundary) ─────────
104
+ const NODE_KIND_DETAIL: Record<string, Omit<NodeKindEntry, "kind">> = {
105
+ agent: {
106
+ configKey: "agent",
107
+ requiredFields: ["jobType"],
108
+ optionalFields: ["prompt", "converge", "merge"],
109
+ sideEffecting: true,
110
+ mayEmit: true,
111
+ summary:
112
+ "A worker runs an agent job type (the fan-out body, e.g. `senior:feature`). First-class " +
113
+ "`converge?`/`merge?` cell-policy flags declare review-convergence / landing intent (`merge` " +
114
+ "requires `converge`); a raw `senior:converge`/`senior:merge` jobType is rejected (`raw-converge-node`). " +
115
+ "An `agent` that opens a PR emits it as a `pr`-typed fact so downstream connector/wait nodes late-bind it.",
116
+ },
117
+ wait: {
118
+ configKey: "wait",
119
+ requiredFields: ["kind", "target"],
120
+ optionalFields: ["match", "poll", "onTimeout", "credentialEnv"],
121
+ sideEffecting: false,
122
+ mayEmit: true,
123
+ summary:
124
+ "A durable, bounded, read-only readiness probe (a `ReadinessProbe` verbatim). `wait.kind` selects " +
125
+ "the probe (see waitProbeKinds); `poll` is `{ everyMs?, timeoutMs?, backoff? }`. Binds observed " +
126
+ "facts (e.g. a merged `pr` binds `mergedSha`).",
127
+ },
128
+ human: {
129
+ configKey: "human",
130
+ requiredFields: [],
131
+ optionalFields: ["formKey", "prompt"],
132
+ sideEffecting: false,
133
+ mayEmit: true,
134
+ summary:
135
+ "A scheduled user task + form (the Tasks inbox). Blocks dependents, SLA-bounded, answerable by a " +
136
+ "human OR an agent. Config is optional (no required field).",
137
+ },
138
+ connector: {
139
+ configKey: "connector",
140
+ requiredFields: ["target"],
141
+ optionalFields: ["dedupeKey", "payload"],
142
+ sideEffecting: true,
143
+ mayEmit: true,
144
+ summary:
145
+ "An automated, side-effecting outbound action. `payload` for a converge target is " +
146
+ "`{ pr, convergeOnly?, dependsOn? }` (`pr` may be a literal `owner/repo#N`, a `<node>.pr` fact " +
147
+ "reference, or omitted to auto-bind the single incoming `pr` fact). Carries a `dedupeKey` " +
148
+ "(at-least-once safe). See connectorTargets for which targets are real vs. forward-declared.",
149
+ },
150
+ };
151
+
152
+ // ── Wait probe kinds (DERIVED from PROBE_KINDS — what `parseProbe` accepts) ───────────────────────
153
+ const WAIT_PROBE_DETAIL: Record<string, Omit<WaitProbeEntry, "kind">> = {
154
+ http: {
155
+ target: "a URL",
156
+ matchFields: ["status", "bodyIncludes"],
157
+ observes: "an HTTP GET against `target` (optional `credentialEnv` supplies an Authorization credential by env-key name).",
158
+ ready: "the response status matches `match.status` (default: any 2xx) and the body contains `match.bodyIncludes` if set.",
159
+ },
160
+ command: {
161
+ target: "a shell command",
162
+ matchFields: ["exitCode", "stdoutIncludes"],
163
+ observes: "running `target` as a subprocess (the escape hatch for the long tail — `gh`, `curl`, `docker manifest inspect`).",
164
+ ready: "the exit code matches `match.exitCode` (default 0) and stdout contains `match.stdoutIncludes` if set.",
165
+ },
166
+ npm: {
167
+ target: "a `pkg@version` (or bare `pkg`)",
168
+ matchFields: ["version", "stdoutIncludes"],
169
+ observes: "the npm registry for a published version of the package.",
170
+ ready: "`match.version` (default: the version in `pkg@version`) is published.",
171
+ },
172
+ "github-check": {
173
+ target: "an `owner/repo@ref`",
174
+ matchFields: ["conclusion", "checkName"],
175
+ observes: "the GitHub check runs on `ref`.",
176
+ ready: "the check run's conclusion matches `match.conclusion` (default `success`), restricted to `match.checkName` if set.",
177
+ },
178
+ capability: {
179
+ target: "a package/context handle",
180
+ matchFields: ["capabilityRef", "package", "verifyCommand"],
181
+ observes:
182
+ "the publish-provenance substrate: which published `package` version first carries the `capabilityRef` " +
183
+ "issue/PR — an optional `verifyCommand` runs once at the poll-budget boundary as a gated empirical fallback.",
184
+ ready: "a published version of `match.package` carries `match.capabilityRef` in its provenance.",
185
+ binds: ["resolvedArtifact"],
186
+ },
187
+ pr: {
188
+ target: "an `owner/repo#N` PR (or a `<node>.pr` fact reference the compiler late-binds at dispatch)",
189
+ matchFields: ["prState"],
190
+ conditions: [...PR_CONDITIONS],
191
+ observes:
192
+ "the live GitHub state of a single in-flight PR. The ACTION (landing it) stays in a connector/merge " +
193
+ "node body; this kind only OBSERVES, so it is level-triggered (no missed edge).",
194
+ ready: "the PR reaches `match.prState` (default `merged`; one of the pr conditions).",
195
+ binds: ["mergedSha"],
196
+ },
197
+ epic: {
198
+ target:
199
+ "the epic's durable `planKey` — `owner/repo#NN`, the epic ISSUE, not the engine processInstanceKey, " +
200
+ "so a resubmit/replay still resolves (may also be a `<node>.fact` late-binding reference)",
201
+ matchFields: ["epicState"],
202
+ conditions: [...EPIC_CONDITIONS],
203
+ observes:
204
+ "the app's OWN lineage read-model (`/lineage?root=<planKey>`), resolved by `parseEpicLineage` to the " +
205
+ "thread whose `rootRequestKey` matches the planKey — REGARDLESS OF the thread's `kind` (feature | epic | " +
206
+ "pr | delivery). Because `app/lineage.ts` lands a FEATURE thread on `stage:\"merged\"` once its PR merges, " +
207
+ "`wait[epic]` gates a single-PR FEATURE RUN just as well as a plan-fanout epic: point `target` at the " +
208
+ "feature/epic root issue and it observes that thread's aggregate frontier. A failed/abandoned/mixed epic " +
209
+ "settles on another terminal (`abandoned`/`resolved`/`converged`) and never reports merged, so it never " +
210
+ "falsely releases the gate — the bounded wait routes via `onTimeout` instead of hanging.",
211
+ ready: "the lineage thread reaches `stage:\"merged\" && active:false` (every opened slice/PR landed). `match.epicState` (default `merged`; `done` is a synonym) both mean \"fully merged\".",
212
+ binds: ["prCount"],
213
+ },
214
+ };
215
+
216
+ // ── Connector targets (real = the converge-enrollment set from convergeTargets.ts) ────────────────
217
+ const REAL_CONNECTOR_TARGETS: ConnectorTargetEntry[] = [
218
+ {
219
+ target: CONVERGE_TARGET,
220
+ status: "real",
221
+ convergeOnlyDefault: convergeOnlyForTarget(CONVERGE_TARGET),
222
+ summary: "Converge-only: drive review convergence and STOP at `converged`, never handing off to the merge loop.",
223
+ },
224
+ {
225
+ target: CONVERGE_MERGE_TARGET,
226
+ status: "real",
227
+ convergeOnlyDefault: convergeOnlyForTarget(CONVERGE_MERGE_TARGET),
228
+ summary:
229
+ "Unit-level land: drive review convergence AND the merge loop, landing the PR onto its OWN base branch " +
230
+ "(for a unit inside an epic that base is the epic integration branch, never `main` directly).",
231
+ },
232
+ {
233
+ target: MERGE_MAIN_TARGET,
234
+ status: "real",
235
+ convergeOnlyDefault: convergeOnlyForTarget(MERGE_MAIN_TARGET),
236
+ summary:
237
+ "Graph-level top-level land (two-level merge, ADR 0006 §3): land the graph/epic INTEGRATION PR onto `main`. " +
238
+ "Dispatch-identical to `converge-merge`; the distinction is the LEVEL, kept a first-class literal.",
239
+ },
240
+ ];
241
+
242
+ /** The sentinel that describes ANY non-converge target: the connector I/O surface is forward-declared
243
+ * (ADR 0005 non-goal), so an unrecognised `target` hits the default stub action and performs no real
244
+ * side effect. Included so a caller learns the real-vs-stub split without reading `deliveryConnector.ts`. */
245
+ const FORWARD_DECLARED_ENTRY: ConnectorTargetEntry = {
246
+ target: "<any other target>",
247
+ status: "forward-declared",
248
+ summary:
249
+ "Forward-declared stub: the concrete connector I/O scheme is an ADR 0005 non-goal. A target outside the " +
250
+ "converge-enrollment set returns a deterministic acknowledgement (`connector stub — I/O surface " +
251
+ "forward-declared`) and fires NO real side effect until a real action is injected.",
252
+ };
253
+
254
+ const ON_TIMEOUT_DETAIL: Record<string, string> = {
255
+ escalate: "park a human-in-the-loop escalation (the Tasks inbox) when the bounded wait elapses; a human/agent decides whether to extend the budget or abandon.",
256
+ fail: "terminate the gate as failed. NOTE: not yet supported on a `wait` node (blocked on engine terminate-end wiring); the compiler rejects `onTimeout: fail` on a wait.",
257
+ continue: "proceed as if ready when the wait elapses — use ONLY when downstream can tolerate a not-yet-ready upstream (a soft gate).",
258
+ };
259
+
260
+ /** Build the structured delivery-graph vocabulary. Pure — no I/O; every closed set is imported from
261
+ * its owning module so this can never silently drift from what the compiler/runner actually accept. */
262
+ export function deliveryGraphVocabulary(): DeliveryGraphVocabulary {
263
+ const nodeKinds: NodeKindEntry[] = DELIVERY_NODE_KINDS.map((kind) => {
264
+ const detail = NODE_KIND_DETAIL[kind];
265
+ if (!detail) throw new Error(`deliveryGraphVocabulary: no detail for node kind '${kind}' (drift — extend NODE_KIND_DETAIL)`);
266
+ return { kind, ...detail };
267
+ });
268
+
269
+ const waitProbeKinds: WaitProbeEntry[] = PROBE_KINDS.map((kind) => {
270
+ const detail = WAIT_PROBE_DETAIL[kind];
271
+ if (!detail) throw new Error(`deliveryGraphVocabulary: no detail for wait probe kind '${kind}' (drift — extend WAIT_PROBE_DETAIL)`);
272
+ return { kind, ...detail };
273
+ });
274
+
275
+ return {
276
+ adr: "ADR 0005 — agent-authored delivery graphs",
277
+ summary:
278
+ "A delivery graph is a JSON DAG `{ name?, nodes[], edges[] }` an agent authors as DATA (never BPMN/code — " +
279
+ "the closed node vocabulary is the trust boundary). The agent surface ends at propose → compile → stage; " +
280
+ "DISPATCH is an operator-only cockpit action. This tool surfaces the closed vocabulary + the non-obvious " +
281
+ "wait/poll/fact-threading semantics so they are discoverable, not source-only.",
282
+ nodeKinds,
283
+ factTypes: [...DELIVERY_FACT_TYPES],
284
+ guardScalarTypes: [...DELIVERY_GUARD_SCALAR_TYPES],
285
+ waitProbeKinds,
286
+ connectorTargets: [...REAL_CONNECTOR_TARGETS, FORWARD_DECLARED_ENTRY],
287
+ onTimeout: ON_TIMEOUTS.map((value) => {
288
+ const meaning = ON_TIMEOUT_DETAIL[value];
289
+ if (!meaning) throw new Error(`deliveryGraphVocabulary: no meaning for onTimeout '${value}' (drift — extend ON_TIMEOUT_DETAIL)`);
290
+ return { value, meaning };
291
+ }),
292
+ pollBudget: {
293
+ defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
294
+ defaultTimeoutIso: DEFAULT_READINESS_TIMEOUT,
295
+ defaultEveryMs: DEFAULT_EVERY_MS,
296
+ rule:
297
+ `POLL-BUDGET TRAP: an omitted \`poll\`/\`poll.timeoutMs\` inherits the built-in default budget of ` +
298
+ `${DEFAULT_READINESS_TIMEOUT} (${DEFAULT_TIMEOUT_MS} ms), re-probing every ${DEFAULT_EVERY_MS} ms. ` +
299
+ `That default is right for "is the package published yet" but badly wrong for \`wait[pr, merged]\` / ` +
300
+ `\`wait[epic]\`, which routinely wait hours or days — such a gate would escalate after 30 minutes for ` +
301
+ `no visible reason (neither compile nor preview surfaces the effective bound). ALWAYS set a realistic ` +
302
+ `\`poll.timeoutMs\` explicitly on any merge or epic gate.`,
303
+ },
304
+ factThreading: {
305
+ rule:
306
+ "A node's emitted `fact` is carried to a consumer ONLY by an EDGE. An edge `from` is either a bare " +
307
+ "`<nodeId>` (the node's completion fact) or a qualified `<nodeId>.<fact>` referencing a declared `emits`. " +
308
+ "A node that references a fact (e.g. a connector/`wait[pr]` late-binding `open.pr`) MUST have an incoming " +
309
+ "edge threading that fact from every producer — an unthreaded reference is rejected (`unbound-pr`).",
310
+ details: [
311
+ "The referenced fact must be declared in the producer's `emits[]` with the right `type` (a `pr` reference must be `pr`-typed).",
312
+ "A connector `payload` may OMIT `pr` to auto-bind the SINGLE incoming `pr` fact; with two `pr` facts flowing in you must name one.",
313
+ "Only scalar facts (`string`/`number`/`boolean`) may be referenced by an edge `when` guard; `artifact`/`version`/`url`/`pr` are not guardable.",
314
+ "The whole edge set must be a DAG; a self-edge or cycle is rejected.",
315
+ ],
316
+ },
317
+ guideSection: "docs/agent-guide.md §9 (Author and run a delivery graph)",
318
+ };
319
+ }
package/app/readiness.ts CHANGED
@@ -56,11 +56,16 @@ export type OnTimeout = "escalate" | "fail" | "continue";
56
56
  /** Backoff policy between poll attempts. */
57
57
  export type Backoff = "fixed" | "exponential";
58
58
 
59
- const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability", "pr", "epic"];
60
- const ON_TIMEOUTS: readonly OnTimeout[] = ["escalate", "fail", "continue"];
61
- const BACKOFFS: readonly Backoff[] = ["fixed", "exponential"];
62
- const PR_CONDITIONS: readonly PrCondition[] = ["ready", "merged", "mergeable", "checks-green"];
63
- const EPIC_CONDITIONS: readonly EpicCondition[] = ["merged", "done"];
59
+ // The CLOSED per-field vocabularies `parseProbe` validates against the single runtime source of
60
+ // truth for "which probe kinds / onTimeout options / conditions are legal". Exported so the
61
+ // delivery-graph vocabulary surface (`app/deliveryGraphVocabulary.ts`, S3/#609) DERIVES its
62
+ // structured description from these exact arrays and a drift test fails the build if a kind/condition
63
+ // is added here without a matching vocabulary entry (AGENTS.md: no drift surfaces).
64
+ export const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability", "pr", "epic"];
65
+ export const ON_TIMEOUTS: readonly OnTimeout[] = ["escalate", "fail", "continue"];
66
+ export const BACKOFFS: readonly Backoff[] = ["fixed", "exponential"];
67
+ export const PR_CONDITIONS: readonly PrCondition[] = ["ready", "merged", "mergeable", "checks-green"];
68
+ export const EPIC_CONDITIONS: readonly EpicCondition[] = ["merged", "done"];
64
69
 
65
70
  /** The per-kind readiness predicate. Every field is optional; each kind reads only the ones it
66
71
  * understands and applies a sensible default when a field is absent (see the matchers below). */
@@ -412,6 +412,15 @@ PR #202 → a human does a manual OTP publish → PR #303 consumes the just-publ
412
412
  **delivery graph** ([ADR 0005](https://github.com/nanobpm/nano-workforce/blob/main/docs/adr/0005-agent-authored-delivery-graphs.md))
413
413
  lets you compose exactly that as **data** and hand it to a generic runner.
414
414
 
415
+ > **Discover the vocabulary from the surface.** Everything this section describes — the four
416
+ > node kinds and their body contracts, every `wait` probe kind and **what it observes**, the
417
+ > real-vs-stub connector targets, the `onTimeout` options, the poll-budget trap, and the
418
+ > fact-threading rules — is also available as **structured JSON** from the read tool
419
+ > **`getDeliveryGraphVocabulary`** (`GET __BASE__/delivery-graph/vocabulary`). It is derived
420
+ > from the implementing code (a drift test fails the build if the two disagree), so prose and
421
+ > data can never drift. Fetch it to author against the live vocabulary; this section is the
422
+ > narrative companion.
423
+
415
424
  You author the graph as **JSON — never BPMN or code** (Decision 1: the agent must never
416
425
  author the executable artifact; the closed node vocabulary is the trust boundary). Your
417
426
  surface ends at **propose → compile → stage**: a single `compile` door validates the JSON,
@@ -741,3 +750,9 @@ Semantics:
741
750
  B before its dependency merged. Size `timeoutMs` to how long the epic realistically takes.
742
751
  - On a fully-merged match it binds **`prCount`** (how many slice PRs the epic landed) as an
743
752
  output fact, so a downstream node can consume it (parity with the `pr` kind's `mergedSha`).
753
+ - **It also gates a single-PR *feature run*, not just a plan-fanout epic.** The gate resolves the
754
+ lineage thread whose **`rootRequestKey`** matches `target` **regardless of the thread's `kind`**
755
+ (`feature` | `epic` | `pr` | `delivery`), and `app/lineage.ts` lands a *feature* thread on
756
+ `stage:"merged"` once its PR merges. So `wait[epic]` targeting a feature/epic **root issue**
757
+ observes that thread's aggregate frontier and releases on `stage:"merged" && active:false` either
758
+ way — see `getDeliveryGraphVocabulary` (the `epic` probe entry) for the structured contract.
package/openapi.yaml CHANGED
@@ -4064,6 +4064,262 @@ paths:
4064
4064
  application/json:
4065
4065
  schema:
4066
4066
  $ref: "#/components/schemas/ErrorBody"
4067
+ /delivery-graph/vocabulary:
4068
+ get:
4069
+ operationId: getDeliveryGraphVocabulary
4070
+ summary: The closed delivery-graph vocabulary + wait-probe semantics as structured JSON (ADR 0005).
4071
+ description: >-
4072
+ Read tool (projected onto the MCP surface like `getAgentInstructions`). Returns the CLOSED
4073
+ delivery-graph vocabulary and the non-obvious wait/poll/fact-threading semantics as structured
4074
+ JSON, so you can discover them from the surface instead of reading source. Covers: the four node
4075
+ kinds (`agent`/`wait`/`human`/`connector`) with their per-kind body contracts; every wait probe
4076
+ kind with its `match` fields and — crucially — WHAT it OBSERVES (e.g. `epic` resolves a lineage
4077
+ thread by `rootRequestKey` REGARDLESS of the thread's kind, so it gates plan-fanout epics AND
4078
+ single-PR feature runs alike, ready on `stage:"merged" && active:false`); which connector targets
4079
+ are real (`converge`/`converge-merge`/`merge-main`) vs. forward-declared stubs; the `onTimeout`
4080
+ options; the poll-budget rule (always set a realistic `poll.timeoutMs` on merge/epic gates — the
4081
+ 30-minute default is a trap); and the edge/fact-threading rules (a `node.fact` must be threaded by
4082
+ an edge to every consumer, else `unbound-pr`). Derived from the implementing code (a drift test
4083
+ fails the build if a probe kind / connector target is added without a vocabulary entry). Pure,
4084
+ read-only, idempotent — no side effects. Pairs with `compileDeliveryGraph`/`previewDeliveryGraph`:
4085
+ call this first to learn the vocabulary, then author a `DeliveryGraph` and compile it.
4086
+ security:
4087
+ - hookSecret: []
4088
+ - {}
4089
+ responses:
4090
+ "200":
4091
+ description: The full delivery-graph vocabulary.
4092
+ content:
4093
+ application/json:
4094
+ schema:
4095
+ type: object
4096
+ additionalProperties: false
4097
+ description: The closed delivery-graph vocabulary + wait-probe semantics, derived from the compiler/runner code.
4098
+ required:
4099
+ - adr
4100
+ - summary
4101
+ - nodeKinds
4102
+ - factTypes
4103
+ - guardScalarTypes
4104
+ - waitProbeKinds
4105
+ - connectorTargets
4106
+ - onTimeout
4107
+ - pollBudget
4108
+ - factThreading
4109
+ properties:
4110
+ adr:
4111
+ type: string
4112
+ description: The governing ADR (agent-authored delivery graphs).
4113
+ summary:
4114
+ type: string
4115
+ description: One-paragraph orientation on the graph shape and the propose→compile→stage surface.
4116
+ nodeKinds:
4117
+ type: array
4118
+ description: The closed node-kind allowlist with each kind's config key and body contract.
4119
+ items:
4120
+ type: object
4121
+ additionalProperties: false
4122
+ required: [kind, configKey, requiredFields, optionalFields, sideEffecting, mayEmit, summary]
4123
+ properties:
4124
+ kind:
4125
+ type: string
4126
+ description: The node kind (one of agent | wait | human | connector).
4127
+ configKey:
4128
+ type: string
4129
+ description: The per-kind config object key the node must carry.
4130
+ requiredFields:
4131
+ type: array
4132
+ items: { type: string }
4133
+ description: Required non-empty fields inside the per-kind config.
4134
+ optionalFields:
4135
+ type: array
4136
+ items: { type: string }
4137
+ description: Optional fields inside the per-kind config.
4138
+ sideEffecting:
4139
+ type: boolean
4140
+ description: Whether the node performs a side effect (agent/connector) vs. read-only (wait/human).
4141
+ mayEmit:
4142
+ type: boolean
4143
+ description: Whether the node may declare typed emits.
4144
+ summary:
4145
+ type: string
4146
+ description: The body contract / semantics of the kind.
4147
+ factTypes:
4148
+ type: array
4149
+ items: { type: string }
4150
+ description: The closed emitted-fact type allowlist.
4151
+ guardScalarTypes:
4152
+ type: array
4153
+ items: { type: string }
4154
+ description: The scalar fact types an edge `when` guard may reference.
4155
+ waitProbeKinds:
4156
+ type: array
4157
+ description: Every wait probe kind, its match fields, and what it observes / when it is ready.
4158
+ items:
4159
+ type: object
4160
+ additionalProperties: false
4161
+ required: [kind, target, matchFields, observes, ready]
4162
+ properties:
4163
+ kind:
4164
+ type: string
4165
+ description: The probe kind (http | command | npm | github-check | capability | pr | epic).
4166
+ target:
4167
+ type: string
4168
+ description: What the probe's `target` names.
4169
+ matchFields:
4170
+ type: array
4171
+ items: { type: string }
4172
+ description: The `match` fields this kind reads.
4173
+ conditions:
4174
+ type: array
4175
+ items: { type: string }
4176
+ description: The closed condition set for pr/epic kinds (else absent).
4177
+ observes:
4178
+ type: string
4179
+ description: The read that decides readiness (what the probe actually observes).
4180
+ ready:
4181
+ type: string
4182
+ description: The condition under which the probe reports ready.
4183
+ binds:
4184
+ type: array
4185
+ items: { type: string }
4186
+ description: Output facts the probe binds on a ready match.
4187
+ connectorTargets:
4188
+ type: array
4189
+ description: Which connector targets are real (converge-enrollment) vs. forward-declared stubs.
4190
+ items:
4191
+ type: object
4192
+ additionalProperties: false
4193
+ required: [target, status, summary]
4194
+ properties:
4195
+ target:
4196
+ type: string
4197
+ description: The connector target literal (or a sentinel for any other target).
4198
+ status:
4199
+ type: string
4200
+ enum: [real, forward-declared]
4201
+ description: real ⇒ dispatches a real side effect; forward-declared ⇒ a no-op stub.
4202
+ convergeOnlyDefault:
4203
+ type: boolean
4204
+ description: The default `convergeOnly` for a real converge target.
4205
+ summary:
4206
+ type: string
4207
+ description: What the target does.
4208
+ onTimeout:
4209
+ type: array
4210
+ description: The `onTimeout` options for a bounded wait and what each does.
4211
+ items:
4212
+ type: object
4213
+ additionalProperties: false
4214
+ required: [value, meaning]
4215
+ properties:
4216
+ value: { type: string }
4217
+ meaning: { type: string }
4218
+ pollBudget:
4219
+ type: object
4220
+ additionalProperties: false
4221
+ required: [defaultTimeoutMs, defaultTimeoutIso, defaultEveryMs, rule]
4222
+ description: The poll-budget defaults and the "always set poll.timeoutMs on merge/epic gates" rule.
4223
+ properties:
4224
+ defaultTimeoutMs: { type: number }
4225
+ defaultTimeoutIso: { type: string }
4226
+ defaultEveryMs: { type: number }
4227
+ rule: { type: string }
4228
+ factThreading:
4229
+ type: object
4230
+ additionalProperties: false
4231
+ required: [rule, details]
4232
+ description: The edge/fact-threading rules — a node.fact reaches a consumer only via an edge.
4233
+ properties:
4234
+ rule: { type: string }
4235
+ details:
4236
+ type: array
4237
+ items: { type: string }
4238
+ guideSection:
4239
+ type: string
4240
+ description: The operator-guide section this data mirrors (docs/agent-guide.md §9).
4241
+ example:
4242
+ adr: "ADR 0005 — agent-authored delivery graphs"
4243
+ summary: "A delivery graph is a JSON DAG an agent authors as DATA; the surface ends at propose → compile → stage."
4244
+ nodeKinds:
4245
+ - kind: agent
4246
+ configKey: agent
4247
+ requiredFields: [jobType]
4248
+ optionalFields: [prompt, converge, merge]
4249
+ sideEffecting: true
4250
+ mayEmit: true
4251
+ summary: "A worker runs an agent job type; an agent that opens a PR emits it as a `pr` fact."
4252
+ - kind: wait
4253
+ configKey: wait
4254
+ requiredFields: [kind, target]
4255
+ optionalFields: [match, poll, onTimeout, credentialEnv]
4256
+ sideEffecting: false
4257
+ mayEmit: true
4258
+ summary: "A durable, bounded, read-only readiness probe (a ReadinessProbe verbatim)."
4259
+ - kind: connector
4260
+ configKey: connector
4261
+ requiredFields: [target]
4262
+ optionalFields: [dedupeKey, payload]
4263
+ sideEffecting: true
4264
+ mayEmit: true
4265
+ summary: "An automated outbound action; payload for a converge target is { pr, convergeOnly?, dependsOn? }."
4266
+ factTypes: [string, number, boolean, artifact, version, url, pr]
4267
+ guardScalarTypes: [string, number, boolean]
4268
+ waitProbeKinds:
4269
+ - kind: pr
4270
+ target: "an owner/repo#N PR (or a <node>.pr fact reference)"
4271
+ matchFields: [prState]
4272
+ conditions: [ready, merged, mergeable, checks-green]
4273
+ observes: "the live GitHub state of one in-flight PR; only OBSERVES, level-triggered."
4274
+ ready: "the PR reaches match.prState (default merged)."
4275
+ binds: [mergedSha]
4276
+ - kind: epic
4277
+ target: "the epic's durable planKey (owner/repo#NN, the epic issue)"
4278
+ matchFields: [epicState]
4279
+ conditions: [merged, done]
4280
+ observes: "the app's lineage read-model, resolved by rootRequestKey REGARDLESS of thread kind (feature | epic | pr | delivery) — so it gates a single-PR FEATURE RUN just as well as a plan-fanout epic."
4281
+ ready: 'the lineage thread reaches stage:"merged" && active:false (every opened slice/PR landed).'
4282
+ binds: [prCount]
4283
+ connectorTargets:
4284
+ - target: converge
4285
+ status: real
4286
+ convergeOnlyDefault: true
4287
+ summary: "Converge-only: drive review convergence and stop at converged."
4288
+ - target: converge-merge
4289
+ status: real
4290
+ convergeOnlyDefault: false
4291
+ summary: "Unit-level land: converge AND merge onto the PR's own base branch."
4292
+ - target: merge-main
4293
+ status: real
4294
+ convergeOnlyDefault: false
4295
+ summary: "Graph-level top-level land onto main (two-level merge)."
4296
+ - target: "<any other target>"
4297
+ status: forward-declared
4298
+ summary: "Forward-declared stub — returns a deterministic acknowledgement, fires no real I/O."
4299
+ onTimeout:
4300
+ - value: escalate
4301
+ meaning: "park a human escalation when the bounded wait elapses."
4302
+ - value: fail
4303
+ meaning: "terminate the gate as failed (NOT yet supported on a wait node — rejected by the compiler)."
4304
+ - value: continue
4305
+ meaning: "proceed as if ready when the wait elapses (a soft gate)."
4306
+ pollBudget:
4307
+ defaultTimeoutMs: 1800000
4308
+ defaultTimeoutIso: PT30M
4309
+ defaultEveryMs: 15000
4310
+ rule: "An omitted poll.timeoutMs inherits the 30-minute default — a trap for wait[pr, merged]/wait[epic] which wait hours/days. Always set poll.timeoutMs explicitly on a merge/epic gate."
4311
+ factThreading:
4312
+ rule: "A node's emitted fact reaches a consumer ONLY via an edge (<nodeId>.<fact>); an unthreaded reference is rejected (unbound-pr)."
4313
+ details:
4314
+ - "The referenced fact must be declared in the producer's emits[] with the right type."
4315
+ - "A connector payload may omit pr to auto-bind the single incoming pr fact."
4316
+ guideSection: "docs/agent-guide.md §9 (Author and run a delivery graph)"
4317
+ "401":
4318
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
4319
+ content:
4320
+ application/json:
4321
+ schema:
4322
+ $ref: "#/components/schemas/ErrorBody"
4067
4323
  /actions/delivery-graph/library/save:
4068
4324
  post:
4069
4325
  operationId: saveToLibrary
@@ -0,0 +1,25 @@
1
+ // GET /app/api/delivery-graph/vocabulary → operationId `getDeliveryGraphVocabulary` (epic
2
+ // nano-workforce#605, S3/#609). A read tool — projected onto the MCP surface like
3
+ // `getAgentInstructions` — that returns the closed delivery-graph vocabulary + wait-probe semantics
4
+ // as STRUCTURED JSON, so an agent can discover the node/probe/connector vocabulary and the non-obvious
5
+ // wait/poll/fact-threading rules from the surface instead of reading source (ADR 0005).
6
+ //
7
+ // The payload is derived from the implementing code (`app/deliveryGraphVocabulary.ts`) — every closed
8
+ // set is imported from its owning module, and a drift test fails the build if a probe kind / connector
9
+ // target lands in the compiler without a vocabulary entry. Cross-linked from docs/agent-guide.md §9.
10
+ //
11
+ // Read-only. The optional shared-secret guard mirrors /agent and /version: enforced HERE only when
12
+ // NANO_PR_WEBHOOK_SECRET is set (the runtime does not enforce OpenAPI `security`).
13
+ import { deliveryGraphVocabulary } from "../app/deliveryGraphVocabulary.ts";
14
+ import { envVar } from "../app/version.ts";
15
+ import { defineOperation } from "../nano-generated/operations.ts";
16
+
17
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
18
+
19
+ export default defineOperation("getDeliveryGraphVocabulary", ({ req }, app) => {
20
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
21
+ app.log.warn("getDeliveryGraphVocabulary rejected: missing/invalid shared secret");
22
+ return { status: 401, body: { error: "unauthorized" } };
23
+ }
24
+ return { status: 200, body: deliveryGraphVocabulary() };
25
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.159.1",
3
+ "version": "0.160.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -0,0 +1,62 @@
1
+ // test/deliveryGraphVocabulary-mcp.test.ts — S3/#609 surface guard.
2
+ //
3
+ // Verifies the `getDeliveryGraphVocabulary` READ tool is actually VISIBLE to agents over MCP (the
4
+ // Urban runtime projects `openapi.yaml` into MCP tools, ADR 0067 — zero MCP server code in nwf) and
5
+ // that its operation conforms to S0's self-contained convention: a `$ref`-free `200` response schema
6
+ // with an explicit `type: object` and a worked `example`. Drives the REAL projector (`collectOperations`
7
+ // from `@nanobpm/urban/toolkit`) over the checked-in spec, exactly like `test/mcp-tool-schemas.test.ts`,
8
+ // so a regression (the op excluded, or a re-leaked `$ref`/dropped example) fails the build.
9
+ import { readFileSync } from "node:fs";
10
+ import { test } from "node:test";
11
+ import { collectOperations, parseSpec } from "@nanobpm/urban/toolkit";
12
+ import { parse as parseYaml } from "yaml";
13
+ import assert from "node:assert/strict";
14
+ import { deliveryGraphVocabulary } from "../app/deliveryGraphVocabulary.ts";
15
+
16
+ const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
17
+ const SPEC_TEXT = readFileSync(`${ROOT}openapi.yaml`, "utf8");
18
+ const SPEC = parseSpec(SPEC_TEXT);
19
+ const OP_ID = "getDeliveryGraphVocabulary";
20
+
21
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
22
+ typeof v === "object" && v !== null && !Array.isArray(v);
23
+
24
+ /** Every `$ref` reachable in a schema, JSON-path-qualified for the failure message. */
25
+ function findRefs(node: unknown, path: string, out: string[]): void {
26
+ if (Array.isArray(node)) {
27
+ node.forEach((n, i) => findRefs(n, `${path}[${i}]`, out));
28
+ return;
29
+ }
30
+ if (!isRecord(node)) return;
31
+ for (const [k, v] of Object.entries(node)) {
32
+ if (k === "$ref" && typeof v === "string") out.push(`${path}.$ref -> ${v}`);
33
+ else findRefs(v, `${path}.${k}`, out);
34
+ }
35
+ }
36
+
37
+ test("getDeliveryGraphVocabulary is projected onto the MCP tool surface (not excluded)", () => {
38
+ const op = collectOperations(SPEC).find((o) => o.operationId === OP_ID);
39
+ assert(op, `${OP_ID} must be a declared operation the projector can see`);
40
+ assert(!op!.mcpExcluded, `${OP_ID} must be visible over MCP (no x-mcp exclusion)`);
41
+ });
42
+
43
+ test("the getDeliveryGraphVocabulary 200 response schema is $ref-free with an explicit type + example", () => {
44
+ const doc = parseYaml(SPEC_TEXT) as Record<string, any>;
45
+ const schema = doc?.paths?.["/delivery-graph/vocabulary"]?.get?.responses?.["200"]?.content?.["application/json"]?.schema;
46
+ assert(isRecord(schema), "the 200 response must carry an inline application/json schema");
47
+ assert.equal(schema.type, "object", "the response schema must declare an explicit type: object");
48
+ assert("example" in schema, "the response schema must embed a worked example (S0 self-contained convention)");
49
+ const refs: string[] = [];
50
+ findRefs(schema, `${OP_ID}.responses.200`, refs);
51
+ assert(refs.length === 0, `${OP_ID}: 200 response schema leaks $ref(s): ${refs.join(", ")}`);
52
+ });
53
+
54
+ test("the served payload matches the response schema's required keys (data ⇄ contract)", () => {
55
+ const doc = parseYaml(SPEC_TEXT) as Record<string, any>;
56
+ const schema = doc.paths["/delivery-graph/vocabulary"].get.responses["200"].content["application/json"].schema;
57
+ const required: string[] = schema.required ?? [];
58
+ const payload = deliveryGraphVocabulary() as Record<string, unknown>;
59
+ for (const key of required) {
60
+ assert(key in payload, `served vocabulary is missing required schema key '${key}'`);
61
+ }
62
+ });