@nanobpm/nano-workforce 0.109.0 → 0.110.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,10 @@
1
+ # [0.110.0](https://github.com/nanobpm/nano-workforce/compare/v0.109.0...v0.110.0) (2026-08-20)
2
+
3
+
4
+ ### Features
5
+
6
+ * DeliveryGraph JSON contract + pure validator (ADR 0005 S0) ([#384](https://github.com/nanobpm/nano-workforce/issues/384)) ([ae85e1a](https://github.com/nanobpm/nano-workforce/commit/ae85e1af220844f961f6161d4f676aa289d86693)), closes [#375](https://github.com/nanobpm/nano-workforce/issues/375) [#227](https://github.com/nanobpm/nano-workforce/issues/227)
7
+
1
8
  # [0.109.0](https://github.com/nanobpm/nano-workforce/compare/v0.108.0...v0.109.0) (2026-08-20)
2
9
 
3
10
 
package/app/contracts.ts CHANGED
@@ -375,6 +375,15 @@ export const WIRE_CONTRACTS = {
375
375
  "The mind/world checkpoint JOIN shape (issue #324, ADR 0062 Slice 4/5, the WORLD half). At each push the app derives ONE `{commitSha, effectLedger}` and records it in the durable world store (`world_checkpoints`/`world_effects`) AND passes the SAME object to the mind's `session.checkpoint(commitSha, effectLedger)` (Slice 1, `@nanobpm/agentic/session`), so mind + world commit at the SAME per-PR monotonic offset — closing the divergence failure (harness thinks it hasn't pushed but the push landed, or vice-versa). `effectLedger` entries carry a fence idempotency key (push→commit SHA, PR comment→comment id, `gh merge`→merge key); on a re-lease `restoreWorld` inverts the push (`git fetch && git checkout <commitSha>`) then fence-replays the tail so an already-applied effect is skipped, not repeated. Consume this ONE shape from app/world — do not re-declare a synonym.",
376
376
  shape: '{ commitSha: string, effectLedger: Array<{ kind: "push"|"pr-comment"|"merge", idempotencyKey: string, description?: string }> }',
377
377
  },
378
+ DeliveryGraph: {
379
+ category: "wire",
380
+ name: "DeliveryGraph",
381
+ owner: "app/deliveryGraph.ts",
382
+ semantics:
383
+ "The agent-authored delivery graph (ADR 0005, slice S0) — the SINGLE agent-facing artifact for a heterogeneous, partly-human, cross-repo delivery runbook, crossing the ingest boundary as DATA (a JSON DAG, never an executable artifact). Declared in openapi.yaml as `DeliveryGraph`; ingest validates the SHAPE there and the SEMANTICS (acyclicity, edge integrity, fact resolution) in the pure `validateDeliveryGraph` (`app/deliveryGraph.ts`). Nodes each name a `kind` from a CLOSED allowlist (`agent`/`wait`/`human`/`connector` — Decision 1/2, the trust boundary) plus their typed `emits[]`; edges name DISCOVERED facts (Decision 3) — `from` is a bare `<nodeId>` or a qualified `<nodeId>.<fact>`. Later slices (compiler/dispatch/execution) build on this ONE shape — consume it, do not re-declare a synonym.",
384
+ shape:
385
+ '{ name?: string, nodes: Array<{ id: string, kind: "agent"|"wait"|"human"|"connector", emits?: Array<{ name: string, type: "string"|"number"|"boolean"|"artifact"|"version"|"url", description?: string }>, agent?: { jobType: string, prompt?: string }, wait?: ReadinessProbe, human?: { formKey?: string, prompt?: string }, connector?: { target: string, dedupeKey?: string, payload?: object } }>, edges?: Array<{ from: string, to: string }> }',
386
+ },
378
387
  } as const satisfies Record<string, WireContract>;
379
388
 
380
389
  export const TYPE_CONTRACTS = {
@@ -0,0 +1,357 @@
1
+ // Unit coverage for the pure delivery-graph validator `validateDeliveryGraph` (ADR 0005, slice S0).
2
+ // It exercises the SEMANTIC rules the openapi schema cannot express — the closed-kind allowlist,
3
+ // node-id uniqueness, edge integrity (dangling / self), typed-fact resolution (`from: <node>.<fact>`),
4
+ // and acyclicity — directly, with no HTTP and no side effects, mirroring how app/epicSetValidation
5
+ // unit-tests `validateEpicSet`. Each error class (unknown-kind / dangling / bad-`from` / cycle) has a
6
+ // dedicated case, and a fully-worked well-formed graph proves the happy path returns no errors.
7
+ import { test } from "node:test";
8
+ import { assert, assertEquals } from "#test-assert";
9
+ import {
10
+ DELIVERY_NODE_KINDS,
11
+ type DeliveryGraphError,
12
+ type DeliveryGraphErrorCode,
13
+ validateDeliveryGraph,
14
+ } from "./deliveryGraph.ts";
15
+
16
+ /** The single error in the result, asserting there is exactly one. */
17
+ function only(errors: DeliveryGraphError[]): DeliveryGraphError {
18
+ assertEquals(errors.length, 1, `expected exactly one error, got ${JSON.stringify(errors)}`);
19
+ return errors[0];
20
+ }
21
+
22
+ /** Assert the result contains at least one error of the given code. */
23
+ function hasCode(errors: DeliveryGraphError[], code: DeliveryGraphErrorCode): DeliveryGraphError {
24
+ const found = errors.find((e) => e.code === code);
25
+ assert(found !== undefined, `expected an error with code "${code}", got ${JSON.stringify(errors)}`);
26
+ return found;
27
+ }
28
+
29
+ // A realistic, fully-worked graph mirroring the ADR's motivating case: an agent opens PR #B, a `pr`
30
+ // wait node (S2's kind, referenced by shape only here) watches it merge and emits `mergedSha`, a
31
+ // human does the manual OTP publish emitting `resolvedArtifact`, and a downstream wait consumes that
32
+ // published artifact. Proves nodes, per-kind config, typed emits, and both edge shapes validate.
33
+ const WELL_FORMED = {
34
+ name: "release runbook",
35
+ nodes: [
36
+ { id: "open-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "un-draft + merge #B" } },
37
+ {
38
+ id: "watch-b",
39
+ kind: "wait",
40
+ wait: { kind: "github-check", target: "owner/repo@main" },
41
+ emits: [{ name: "mergedSha", type: "string" }],
42
+ },
43
+ {
44
+ id: "manual-publish",
45
+ kind: "human",
46
+ human: { prompt: "do the manual OTP publish + set up OIDC" },
47
+ emits: [{ name: "resolvedArtifact", type: "artifact" }],
48
+ },
49
+ {
50
+ id: "consume-c",
51
+ kind: "wait",
52
+ wait: { kind: "capability", target: "github-releases:owner/repo" },
53
+ },
54
+ { id: "notify", kind: "connector", connector: { target: "slack:#releases", dedupeKey: "notify-1" } },
55
+ ],
56
+ edges: [
57
+ { from: "open-b", to: "watch-b" },
58
+ { from: "watch-b.mergedSha", to: "manual-publish" },
59
+ { from: "manual-publish.resolvedArtifact", to: "consume-c" },
60
+ { from: "consume-c", to: "notify" },
61
+ ],
62
+ };
63
+
64
+ test("a well-formed delivery graph produces no errors", () => {
65
+ assertEquals(validateDeliveryGraph(WELL_FORMED), []);
66
+ });
67
+
68
+ test("an empty node set is rejected", () => {
69
+ const err = only(validateDeliveryGraph({ nodes: [] }));
70
+ assertEquals(err.code, "empty-graph");
71
+ });
72
+
73
+ test("a non-object graph is rejected without throwing", () => {
74
+ assertEquals(validateDeliveryGraph(null).length, 1);
75
+ assertEquals(validateDeliveryGraph(undefined)[0].code, "empty-graph");
76
+ assertEquals(validateDeliveryGraph({ nodes: "nope" })[0].code, "empty-graph");
77
+ });
78
+
79
+ test("unknown-kind: a node kind outside the closed allowlist is rejected, path-qualified", () => {
80
+ const errors = validateDeliveryGraph({
81
+ nodes: [{ id: "x", kind: "script", script: { run: "rm -rf /" } }],
82
+ });
83
+ const err = hasCode(errors, "unknown-kind");
84
+ assertEquals(err.path, "nodes[0].kind");
85
+ assert(err.message.includes(DELIVERY_NODE_KINDS.join(", ")), "message should list the allowlist");
86
+ });
87
+
88
+ test("unknown-kind: the closed allowlist is exactly the four ADR-0005 kinds", () => {
89
+ assertEquals([...DELIVERY_NODE_KINDS], ["agent", "wait", "human", "connector"]);
90
+ });
91
+
92
+ test("dangling edge: a `to` that names no node is rejected, path-qualified", () => {
93
+ const errors = validateDeliveryGraph({
94
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
95
+ edges: [{ from: "a", to: "ghost" }],
96
+ });
97
+ const err = hasCode(errors, "dangling-edge");
98
+ assertEquals(err.path, "edges[0].to");
99
+ });
100
+
101
+ test("dangling edge: a `from` that names no node is rejected, path-qualified", () => {
102
+ const errors = validateDeliveryGraph({
103
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
104
+ edges: [{ from: "ghost", to: "a" }],
105
+ });
106
+ const err = hasCode(errors, "dangling-edge");
107
+ assertEquals(err.path, "edges[0].from");
108
+ });
109
+
110
+ test("bad-from: a `<node>.<fact>` reference to an undeclared fact is rejected, path-qualified", () => {
111
+ const errors = validateDeliveryGraph({
112
+ nodes: [
113
+ { id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "version", type: "version" }] },
114
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
115
+ ],
116
+ edges: [{ from: "a.sha", to: "b" }],
117
+ });
118
+ const err = hasCode(errors, "bad-from");
119
+ assertEquals(err.path, "edges[0].from");
120
+ assert(err.message.includes("sha"), "message should name the missing fact");
121
+ });
122
+
123
+ test("bad-from: a declared fact reference resolves cleanly", () => {
124
+ const errors = validateDeliveryGraph({
125
+ nodes: [
126
+ { id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "version", type: "version" }] },
127
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
128
+ ],
129
+ edges: [{ from: "a.version", to: "b" }],
130
+ });
131
+ assertEquals(errors, []);
132
+ });
133
+
134
+ test("a node id containing dots resolves as a whole node, not a fact split", () => {
135
+ const errors = validateDeliveryGraph({
136
+ nodes: [
137
+ { id: "repo.owner.a", kind: "agent", agent: { jobType: "j" } },
138
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
139
+ ],
140
+ edges: [{ from: "repo.owner.a", to: "b" }],
141
+ });
142
+ assertEquals(errors, []);
143
+ });
144
+
145
+ test("bad-from: an edge that resolves as both a whole node id and a `<node>.<fact>` reference is rejected as ambiguous", () => {
146
+ const errors = validateDeliveryGraph({
147
+ nodes: [
148
+ { id: "a.b", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "c", type: "version" }] },
149
+ { id: "a.b.c", kind: "agent", agent: { jobType: "j" } },
150
+ { id: "d", kind: "agent", agent: { jobType: "j" } },
151
+ ],
152
+ edges: [{ from: "a.b.c", to: "d" }],
153
+ });
154
+ const err = hasCode(errors, "bad-from");
155
+ assertEquals(err.path, "edges[0].from");
156
+ assert(err.message.includes("ambiguous"), "message should call out the ambiguity");
157
+ });
158
+
159
+ test("cycle: a self-edge is rejected", () => {
160
+ const errors = validateDeliveryGraph({
161
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
162
+ edges: [{ from: "a", to: "a" }],
163
+ });
164
+ const err = hasCode(errors, "self-edge");
165
+ assertEquals(err.path, "edges[0]");
166
+ });
167
+
168
+ test("cycle: a multi-node dependency cycle is rejected, naming the cycle", () => {
169
+ const errors = validateDeliveryGraph({
170
+ nodes: [
171
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
172
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
173
+ { id: "c", kind: "agent", agent: { jobType: "j" } },
174
+ ],
175
+ edges: [
176
+ { from: "a", to: "b" },
177
+ { from: "b", to: "c" },
178
+ { from: "c", to: "a" },
179
+ ],
180
+ });
181
+ const err = hasCode(errors, "cycle");
182
+ assertEquals(err.path, "edges");
183
+ assert(err.message.includes("→"), "cycle message should render the cycle path");
184
+ });
185
+
186
+ test("duplicate-id: two nodes sharing an id is rejected", () => {
187
+ const errors = validateDeliveryGraph({
188
+ nodes: [
189
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
190
+ { id: "a", kind: "human" },
191
+ ],
192
+ });
193
+ const err = hasCode(errors, "duplicate-id");
194
+ assertEquals(err.path, "nodes[1].id");
195
+ });
196
+
197
+ test("missing-config: a non-human node without its per-kind config is rejected", () => {
198
+ const errors = validateDeliveryGraph({ nodes: [{ id: "a", kind: "wait" }] });
199
+ const err = hasCode(errors, "missing-config");
200
+ assertEquals(err.path, "nodes[0].wait");
201
+ });
202
+
203
+ test("missing-required-field: an agent node whose `agent` config omits `jobType` is rejected", () => {
204
+ const errors = validateDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: {} }] });
205
+ const err = hasCode(errors, "missing-required-field");
206
+ assertEquals(err.path, "nodes[0].agent.jobType");
207
+ });
208
+
209
+ test("missing-required-field: a wait node whose probe omits `kind`/`target` is rejected per field", () => {
210
+ const errors = validateDeliveryGraph({ nodes: [{ id: "a", kind: "wait", wait: {} }] });
211
+ hasCode(errors, "missing-required-field");
212
+ assertEquals(
213
+ errors.filter((e) => e.code === "missing-required-field").map((e) => e.path).sort(),
214
+ ["nodes[0].wait.kind", "nodes[0].wait.target"],
215
+ );
216
+ });
217
+
218
+ test("missing-required-field: a connector node whose config has an empty `target` is rejected", () => {
219
+ const errors = validateDeliveryGraph({
220
+ nodes: [{ id: "a", kind: "connector", connector: { target: "" } }],
221
+ });
222
+ const err = hasCode(errors, "missing-required-field");
223
+ assertEquals(err.path, "nodes[0].connector.target");
224
+ });
225
+
226
+ test("a human node may omit its config (generic-fallback resolution lands in S3)", () => {
227
+ assertEquals(validateDeliveryGraph({ nodes: [{ id: "done", kind: "human" }] }), []);
228
+ });
229
+
230
+ test("duplicate-fact: two emits sharing a name on one node is rejected", () => {
231
+ const errors = validateDeliveryGraph({
232
+ nodes: [
233
+ {
234
+ id: "a",
235
+ kind: "agent",
236
+ agent: { jobType: "j" },
237
+ emits: [{ name: "v", type: "version" }, { name: "v", type: "string" }],
238
+ },
239
+ ],
240
+ });
241
+ const err = hasCode(errors, "duplicate-fact");
242
+ assertEquals(err.path, "nodes[0].emits[1].name");
243
+ });
244
+
245
+ test("all errors are collected in one pass, not just the first", () => {
246
+ const errors = validateDeliveryGraph({
247
+ nodes: [
248
+ { id: "a", kind: "bogus" },
249
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
250
+ ],
251
+ edges: [{ from: "ghost", to: "a" }],
252
+ });
253
+ hasCode(errors, "unknown-kind");
254
+ hasCode(errors, "duplicate-id");
255
+ hasCode(errors, "dangling-edge");
256
+ assert(errors.length >= 3, `expected the pass to collect every error, got ${errors.length}`);
257
+ });
258
+
259
+ test("a graph with no edges (independent roots) is valid", () => {
260
+ assertEquals(
261
+ validateDeliveryGraph({
262
+ nodes: [
263
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
264
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
265
+ ],
266
+ }),
267
+ [],
268
+ );
269
+ });
270
+
271
+ test("a non-array `edges` is rejected as a shape error (`invalid-edges`), not silently treated as no edges", () => {
272
+ const errors = validateDeliveryGraph({
273
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
274
+ edges: "nope",
275
+ });
276
+ const err = hasCode(errors, "invalid-edges");
277
+ assertEquals(err.path, "edges");
278
+ });
279
+
280
+ test("a non-object edge entry is a shape error (`invalid-edges`), not `dangling-edge`", () => {
281
+ const errors = validateDeliveryGraph({
282
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
283
+ edges: ["nope"],
284
+ });
285
+ const err = hasCode(errors, "invalid-edges");
286
+ assertEquals(err.path, "edges[0]");
287
+ });
288
+
289
+ test("an edge missing string `from`/`to` is a shape error (`invalid-edges`), not `dangling-edge`", () => {
290
+ const errors = validateDeliveryGraph({
291
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
292
+ edges: [{ from: "", to: 3 }],
293
+ });
294
+ const fromErr = hasCode(errors, "invalid-edges");
295
+ assertEquals(fromErr.path, "edges[0].from");
296
+ assert(
297
+ errors.some((e) => e.code === "invalid-edges" && e.path === "edges[0].to"),
298
+ "expected the missing `to` to also be an invalid-edges shape error",
299
+ );
300
+ });
301
+
302
+ test("invalid-id: a node id violating the openapi id pattern is rejected so downstream id use stays safe", () => {
303
+ const errors = validateDeliveryGraph({
304
+ nodes: [{ id: "1 bad id", kind: "agent", agent: { jobType: "j" } }],
305
+ });
306
+ const err = hasCode(errors, "invalid-id");
307
+ assertEquals(err.path, "nodes[0].id");
308
+ });
309
+
310
+ test("a `human` node whose `human` config is not an object is rejected, path-qualified", () => {
311
+ const errors = validateDeliveryGraph({
312
+ nodes: [{ id: "done", kind: "human", human: "just do it" }],
313
+ });
314
+ const err = hasCode(errors, "missing-config");
315
+ assertEquals(err.path, "nodes[0].human");
316
+ });
317
+
318
+ test("invalid-fact-name: an emitted fact name containing a dot is rejected so qualified `from` stays unambiguous", () => {
319
+ const errors = validateDeliveryGraph({
320
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "sha.short", type: "string" }] }],
321
+ });
322
+ const err = hasCode(errors, "invalid-fact-name");
323
+ assertEquals(err.path, "nodes[0].emits[0].name");
324
+ });
325
+
326
+ test("invalid-fact-name: an emitted fact name over the openapi 128-char cap is rejected so a length-trusting consumer can't be overrun", () => {
327
+ const errors = validateDeliveryGraph({
328
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "f".repeat(129), type: "string" }] }],
329
+ });
330
+ const err = hasCode(errors, "invalid-fact-name");
331
+ assertEquals(err.path, "nodes[0].emits[0].name");
332
+ });
333
+
334
+ test("invalid-fact-type: an emitted fact with a type outside the allowlist is rejected, path-qualified", () => {
335
+ const errors = validateDeliveryGraph({
336
+ nodes: [
337
+ {
338
+ id: "a",
339
+ kind: "agent",
340
+ agent: { jobType: "j" },
341
+ emits: [{ name: "sha", type: "bogus" }],
342
+ },
343
+ ],
344
+ });
345
+ const err = hasCode(errors, "invalid-fact-type");
346
+ assertEquals(err.path, "nodes[0].emits[0].type");
347
+ });
348
+
349
+ test("invalid-fact-type: an emitted fact missing its `type` is rejected", () => {
350
+ const errors = validateDeliveryGraph({
351
+ nodes: [
352
+ { id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "sha" }] },
353
+ ],
354
+ });
355
+ const err = hasCode(errors, "invalid-fact-type");
356
+ assertEquals(err.path, "nodes[0].emits[0].type");
357
+ });
@@ -0,0 +1,463 @@
1
+ // nano-workforce — the pure, side-effect-free SEMANTIC validator for an agent-authored delivery
2
+ // graph (ADR 0005, slice S0). The `DeliveryGraph` SHAPE is validated at the edge by the openapi
3
+ // schema (`openapi.yaml` → generated `DeliveryGraph` contract); this module validates the semantics
4
+ // the JSON Schema CANNOT express and that a compiler/runner must be able to trust before it does
5
+ // anything:
6
+ //
7
+ // • unknown `kind` — a node whose `kind` is not in the CLOSED allowlist (the trust boundary,
8
+ // Decision 1/2). Defensive because the body arrives untyped from a request.
9
+ // • duplicate node id — two nodes sharing an id, which would make every edge to it ambiguous.
10
+ // • dangling edge — an edge endpoint (`from`/`to`) that names no node in the graph.
11
+ // • bad `from` reference — a qualified `<nodeId>.<fact>` whose fact is not declared in that node's
12
+ // typed `emits[]` (Decision 3/4 — binds are validated, not stringly).
13
+ // • cycle — the edge set must be a DAG (discovered-fact dependencies flow forward only).
14
+ //
15
+ // It is modelled on the epic-set validator `validateEpicSet` (app/plan.ts): a PURE in-memory walk
16
+ // that runs BEFORE any side effect. Unlike `validateEpicSet` (which throws at the first offending
17
+ // edge), this COLLECTS every error and returns them, so a co-designing agent gets ONE actionable,
18
+ // path-qualified list per compile attempt (the S1 compiler surfaces them as `{ ok:false, errors }`).
19
+ // Every error carries a JSON-path-qualified `path` (`nodes[2].kind`, `edges[1].from`, …) so the
20
+ // caller can point the author straight at the offending input.
21
+
22
+ /** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
23
+ * deliberate ADR/PR (add the openapi variant + a case here), never by a graph author. Kept as the
24
+ * single source of truth for "which kinds are legal" so the validator and any future compiler agree. */
25
+ export const DELIVERY_NODE_KINDS = ["agent", "wait", "human", "connector"] as const;
26
+
27
+ /** A node's `kind`, narrowed to the closed allowlist. */
28
+ export type DeliveryNodeKind = (typeof DELIVERY_NODE_KINDS)[number];
29
+
30
+ /** The CLOSED emitted-fact type allowlist (ADR 0005 Decision 3/4) — mirrors the `DeliveryFact.type`
31
+ * enum in `openapi.yaml`. Kept as the single source of truth so the semantic validator rejects an
32
+ * untyped/unknown fact type even when the OpenAPI shape validator is bypassed (a directly-invoked
33
+ * delegate), since later compilation/execution steps rely on this allowlist. */
34
+ export const DELIVERY_FACT_TYPES = ["string", "number", "boolean", "artifact", "version", "url"] as const;
35
+
36
+ /** An emitted fact's declared `type`, narrowed to the closed allowlist. */
37
+ export type DeliveryFactType = (typeof DELIVERY_FACT_TYPES)[number];
38
+
39
+ /** A machine-readable classification of a semantic failure, so a caller can branch on the error
40
+ * class (unknown-kind / dangling / cycle / bad-`from`) without string-matching the message. */
41
+ export type DeliveryGraphErrorCode =
42
+ | "empty-graph"
43
+ | "missing-id"
44
+ | "invalid-id"
45
+ | "duplicate-id"
46
+ | "unknown-kind"
47
+ | "missing-config"
48
+ | "missing-required-field"
49
+ | "duplicate-fact"
50
+ | "invalid-fact-name"
51
+ | "invalid-fact-type"
52
+ | "invalid-edges"
53
+ | "dangling-edge"
54
+ | "bad-from"
55
+ | "self-edge"
56
+ | "cycle";
57
+
58
+ /** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
59
+ * input (`nodes[2].kind`, `edges[1].from`, `nodes[0].emits[1].name`), `message` is human-actionable,
60
+ * and `code` is the stable error class. Shaped so the S1 compiler can forward it verbatim as one of
61
+ * its `{ ok:false, errors:[{ path, message }] }` entries. */
62
+ export interface DeliveryGraphError {
63
+ readonly path: string;
64
+ readonly message: string;
65
+ readonly code: DeliveryGraphErrorCode;
66
+ }
67
+
68
+ /** Narrow an untyped value to a plain object so its fields can be read as `unknown`. */
69
+ function isRecord(value: unknown): value is Record<string, unknown> {
70
+ return typeof value === "object" && value !== null && !Array.isArray(value);
71
+ }
72
+
73
+ /** True when `kind` is a member of the closed allowlist. */
74
+ function isDeliveryNodeKind(kind: unknown): kind is DeliveryNodeKind {
75
+ if (typeof kind !== "string") return false;
76
+ for (const k of DELIVERY_NODE_KINDS) if (k === kind) return true;
77
+ return false;
78
+ }
79
+
80
+ /** True when `type` is a member of the closed emitted-fact type allowlist. */
81
+ function isDeliveryFactType(type: unknown): type is DeliveryFactType {
82
+ if (typeof type !== "string") return false;
83
+ for (const t of DELIVERY_FACT_TYPES) if (t === type) return true;
84
+ return false;
85
+ }
86
+
87
+ /** A fact `name` must be a bare identifier (no dots) — mirrors openapi's `DeliveryFact.name`
88
+ * `^[A-Za-z_][A-Za-z0-9_]*$`. `resolveFrom` RELIES on fact names being dot-free (a node id MAY
89
+ * contain dots) to disambiguate a qualified edge `from`, so the semantic validator re-enforces the
90
+ * pattern INDEPENDENTLY of the OpenAPI shape gate: if that gate is bypassed (a direct delegate call,
91
+ * a test, a future internal use), a dotted fact name could otherwise make `<nodeId>.<fact>` resolution
92
+ * ambiguous and quietly build the wrong DAG — undermining the trust boundary this validator exists to
93
+ * hold. */
94
+ const FACT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
95
+ const FACT_NAME_MAX_LENGTH = 128;
96
+
97
+ /** A node `id` must match openapi's `DeliveryNodeCommon.id` `^[A-Za-z_][A-Za-z0-9_.-]*$` and stay
98
+ * within its 128-char cap. Re-enforced here INDEPENDENTLY of the OpenAPI shape gate because later
99
+ * compile/render steps trust these ids: an id with whitespace, a leading digit, or an over-long value
100
+ * could otherwise pass semantic validation (a bypassed shape gate — a direct delegate call, a test)
101
+ * and then break id-based compilation/rendering downstream. Unlike a fact name, an id MAY contain
102
+ * dots/hyphens — `resolveFrom` splits a qualified `from` on the LAST dot, so a dotted id stays
103
+ * resolvable while dot-free fact names keep `<nodeId>.<fact>` unambiguous. */
104
+ const NODE_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
105
+ const NODE_ID_MAX_LENGTH = 128;
106
+
107
+ /** The per-kind config key a node of the given kind must carry (`agent` → `agent`, etc.). */
108
+ const CONFIG_KEY: Record<DeliveryNodeKind, string> = {
109
+ agent: "agent",
110
+ wait: "wait",
111
+ human: "human",
112
+ connector: "connector",
113
+ };
114
+
115
+ /** The REQUIRED non-empty-string fields inside each kind's per-kind config object, mirroring the
116
+ * `required` lists in openapi (`DeliveryNodeAgent.agent.jobType`, the `ReadinessProbe.kind`/`target`
117
+ * a `wait` reuses, `DeliveryNodeConnector.connector.target`). Re-enforced here INDEPENDENTLY of the
118
+ * OpenAPI shape gate so that, when that gate is bypassed (a direct delegate call, a test, a future
119
+ * internal use), a config object present-but-missing its required fields (e.g. `{ kind:"agent",
120
+ * agent:{} }`) is rejected with an actionable error rather than passing semantic validation and
121
+ * crashing a downstream compiler/runner that assumes those fields exist. `human` has no required
122
+ * config field (its config is optional). Kept as the single source of truth so this list and openapi
123
+ * agree. NOTE: field PRESENCE + non-emptiness is enforced here, not the `ReadinessProbe.kind` enum —
124
+ * that enum evolves per slice (S2 adds `pr`), so enumerating it here would drift; the enum stays
125
+ * owned by the shape gate / `app/readiness.ts`. */
126
+ const REQUIRED_CONFIG_FIELDS: Record<DeliveryNodeKind, readonly string[]> = {
127
+ agent: ["jobType"],
128
+ wait: ["kind", "target"],
129
+ human: [],
130
+ connector: ["target"],
131
+ };
132
+
133
+ /** Resolve an edge `from` endpoint against the known node set. A node id MAY itself contain dots (the
134
+ * openapi id pattern allows them) while a fact name (an identifier) cannot, so resolution is
135
+ * disambiguated by the node set rather than by naive splitting: (1) if the WHOLE string is a node id
136
+ * it is a bare completion-fact reference (`nodeId`, no fact); (2) else split at the LAST dot and, if
137
+ * the prefix is a node id, it is a qualified `<nodeId>.<fact>` reference; (3) else it is dangling —
138
+ * return the whole string as the (unresolvable) node id so the caller reports it against `from`.
139
+ * When BOTH interpretations resolve — the whole string is a node id AND its last-dot prefix is a
140
+ * node that emits the suffix as a fact — the reference is genuinely ambiguous; surface it via
141
+ * `ambiguousWith` so the caller rejects it (`bad-from`) rather than silently choosing the whole-node
142
+ * reading and producing an unintended DAG. */
143
+ function resolveFrom(
144
+ from: string,
145
+ nodeFacts: ReadonlyMap<string, ReadonlySet<string>>,
146
+ ): { nodeId: string; fact?: string; ambiguousWith?: { nodeId: string; fact: string } } {
147
+ const dot = from.lastIndexOf(".");
148
+ const split =
149
+ dot > 0 && dot < from.length - 1 ? { prefix: from.slice(0, dot), suffix: from.slice(dot + 1) } : undefined;
150
+ if (nodeFacts.has(from)) {
151
+ if (split !== undefined && nodeFacts.get(split.prefix)?.has(split.suffix)) {
152
+ return { nodeId: from, ambiguousWith: { nodeId: split.prefix, fact: split.suffix } };
153
+ }
154
+ return { nodeId: from };
155
+ }
156
+ if (split !== undefined && nodeFacts.has(split.prefix)) return { nodeId: split.prefix, fact: split.suffix };
157
+ return { nodeId: from };
158
+ }
159
+
160
+ /**
161
+ * Pure, side-effect-free SEMANTIC validation of a delivery graph (ADR 0005 slice S0). Accepts the
162
+ * graph as `unknown` because it arrives from an untyped request body — every field is read
163
+ * defensively, so a malformed input maps to a clean {@link DeliveryGraphError} (never an uncaught
164
+ * TypeError). Returns every error found (empty array ⇒ the graph is semantically valid), each
165
+ * path-qualified — one entry per offending node/edge/fact, except cycle detection, which reports at
166
+ * most ONE cycle per call to keep the output actionable (fix it and re-validate to surface the next).
167
+ * Run this BEFORE any compile/deploy so a cycle, dangling edge, unknown kind, or unresolvable fact
168
+ * reference is rejected with nothing started.
169
+ */
170
+ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
171
+ const errors: DeliveryGraphError[] = [];
172
+
173
+ if (!isRecord(graph) || !Array.isArray(graph.nodes)) {
174
+ return [
175
+ {
176
+ path: "nodes",
177
+ message: "delivery graph must be an object with a `nodes` array",
178
+ code: "empty-graph",
179
+ },
180
+ ];
181
+ }
182
+ const nodes = graph.nodes;
183
+ if (nodes.length === 0) {
184
+ errors.push({
185
+ path: "nodes",
186
+ message: "delivery graph is empty — declare at least one node",
187
+ code: "empty-graph",
188
+ });
189
+ }
190
+
191
+ // Pass 1: node ids + kinds + per-kind config + declared facts. Build the id → declared-facts map
192
+ // used to resolve edge `from` references in pass 2.
193
+ const nodeFacts = new Map<string, Set<string>>();
194
+ nodes.forEach((rawNode, i) => {
195
+ const path = `nodes[${i}]`;
196
+ if (!isRecord(rawNode)) {
197
+ errors.push({ path, message: "each node must be an object", code: "missing-config" });
198
+ return;
199
+ }
200
+ const id = rawNode.id;
201
+ if (typeof id !== "string" || id.length === 0) {
202
+ errors.push({ path: `${path}.id`, message: "node is missing a string `id`", code: "missing-id" });
203
+ } else {
204
+ if (id.length > NODE_ID_MAX_LENGTH || !NODE_ID_PATTERN.test(id)) {
205
+ // Mirror openapi's `DeliveryNodeCommon.id` pattern/length so an invalid id can't slip past a
206
+ // bypassed shape gate and break id-based compilation/rendering in a later slice.
207
+ errors.push({
208
+ path: `${path}.id`,
209
+ message:
210
+ `node id "${id}" must be a bare identifier (\`^[A-Za-z_][A-Za-z0-9_.-]*$\`, ` +
211
+ `\u2264 ${NODE_ID_MAX_LENGTH} chars) so downstream id-based compilation stays safe`,
212
+ code: "invalid-id",
213
+ });
214
+ }
215
+ if (nodeFacts.has(id)) {
216
+ errors.push({
217
+ path: `${path}.id`,
218
+ message: `duplicate node id "${id}" — every node id must be unique in the graph`,
219
+ code: "duplicate-id",
220
+ });
221
+ }
222
+ }
223
+
224
+ const kind = rawNode.kind;
225
+ if (!isDeliveryNodeKind(kind)) {
226
+ errors.push({
227
+ path: `${path}.kind`,
228
+ message:
229
+ `unknown node kind ${JSON.stringify(kind)} — must be one of ` +
230
+ `${DELIVERY_NODE_KINDS.join(", ")} (the closed vocabulary is the trust boundary)`,
231
+ code: "unknown-kind",
232
+ });
233
+ } else if (kind !== "human") {
234
+ // Every kind but `human` REQUIRES its per-kind config object.
235
+ const configKey = CONFIG_KEY[kind];
236
+ const config = rawNode[configKey];
237
+ if (!isRecord(config)) {
238
+ errors.push({
239
+ path: `${path}.${configKey}`,
240
+ message: `${kind} node is missing its required \`${configKey}\` config`,
241
+ code: "missing-config",
242
+ });
243
+ } else {
244
+ // The config object is present — re-enforce the fields openapi marks REQUIRED (a bypassed
245
+ // shape gate could otherwise let `{ kind:"agent", agent:{} }` through and crash a downstream
246
+ // compiler/runner that trusts those fields exist).
247
+ for (const field of REQUIRED_CONFIG_FIELDS[kind]) {
248
+ const value = config[field];
249
+ if (typeof value !== "string" || value.length === 0) {
250
+ errors.push({
251
+ path: `${path}.${configKey}.${field}`,
252
+ message: `${kind} node's \`${configKey}.${field}\` is required and must be a non-empty string`,
253
+ code: "missing-required-field",
254
+ });
255
+ }
256
+ }
257
+ }
258
+ } else if (rawNode.human !== undefined && !isRecord(rawNode.human)) {
259
+ // `human` config is OPTIONAL (formKey/prompt both resolve to a generic fallback in S3), but
260
+ // when PRESENT it must be a plain object so later slices can safely read `human.formKey` /
261
+ // `human.prompt` — a string/array/null `human` would crash them downstream.
262
+ errors.push({
263
+ path: `${path}.human`,
264
+ message: "`human` config, when present, must be an object",
265
+ code: "missing-config",
266
+ });
267
+ }
268
+
269
+ // Collect + validate this node's typed emitted facts (uniqueness within the node). Registered
270
+ // under the id even when other fields are invalid, so downstream edge resolution is best-effort.
271
+ const facts = new Set<string>();
272
+ if (rawNode.emits !== undefined) {
273
+ if (!Array.isArray(rawNode.emits)) {
274
+ errors.push({
275
+ path: `${path}.emits`,
276
+ message: "`emits` must be an array of typed fact declarations",
277
+ code: "missing-config",
278
+ });
279
+ } else {
280
+ rawNode.emits.forEach((rawFact, j) => {
281
+ if (!isRecord(rawFact) || typeof rawFact.name !== "string" || rawFact.name.length === 0) {
282
+ errors.push({
283
+ path: `${path}.emits[${j}].name`,
284
+ message: "each emitted fact needs a non-empty string `name`",
285
+ code: "missing-config",
286
+ });
287
+ return;
288
+ }
289
+ if (facts.has(rawFact.name)) {
290
+ errors.push({
291
+ path: `${path}.emits[${j}].name`,
292
+ message: `duplicate emitted fact "${rawFact.name}" on node "${String(id)}"`,
293
+ code: "duplicate-fact",
294
+ });
295
+ return;
296
+ }
297
+ if (rawFact.name.length > FACT_NAME_MAX_LENGTH || !FACT_NAME_PATTERN.test(rawFact.name)) {
298
+ // A fact name must be a dot-free identifier within openapi's 128-char cap (openapi's
299
+ // `DeliveryFact.name` `pattern` + `maxLength`) so a qualified edge `from`
300
+ // "<nodeId>.<fact>" resolves unambiguously and a later step trusting the cap can't be
301
+ // overrun — enforced here too, in case the OpenAPI shape gate is bypassed.
302
+ errors.push({
303
+ path: `${path}.emits[${j}].name`,
304
+ message:
305
+ `emitted fact name "${rawFact.name}" must be a bare identifier ` +
306
+ "(`^[A-Za-z_][A-Za-z0-9_]*$`, no dots) of " +
307
+ `\u2264 ${FACT_NAME_MAX_LENGTH} chars so qualified edge \`from\` references stay unambiguous`,
308
+ code: "invalid-fact-name",
309
+ });
310
+ return;
311
+ }
312
+ if (!isDeliveryFactType(rawFact.type)) {
313
+ // emits are TYPED (Decision 3/4). An invalid/missing `type` must be rejected even when the
314
+ // OpenAPI shape validator is bypassed, or a later step reading the type allowlist breaks.
315
+ errors.push({
316
+ path: `${path}.emits[${j}].type`,
317
+ message:
318
+ `emitted fact "${rawFact.name}" has an invalid \`type\` — must be one of ` +
319
+ `${DELIVERY_FACT_TYPES.join(", ")}`,
320
+ code: "invalid-fact-type",
321
+ });
322
+ }
323
+ facts.add(rawFact.name);
324
+ });
325
+ }
326
+ }
327
+ if (typeof id === "string" && id.length > 0 && !nodeFacts.has(id)) {
328
+ nodeFacts.set(id, facts);
329
+ }
330
+ });
331
+
332
+ // Pass 2: edges. Resolve each endpoint against the node set and each qualified `from` against the
333
+ // upstream node's declared facts, and build the adjacency for the cycle check.
334
+ const edges: readonly unknown[] = Array.isArray(graph.edges) ? graph.edges : [];
335
+ if (graph.edges !== undefined && !Array.isArray(graph.edges)) {
336
+ // A non-array `edges` must not be silently treated as "no edges" — that would let a malformed
337
+ // body pass semantic validation when the OpenAPI shape validator is bypassed. This is a
338
+ // shape/type error (not an endpoint-resolution failure), so it carries `invalid-edges` — callers
339
+ // branching on error codes must distinguish "edges isn't a list" from a genuine dangling endpoint.
340
+ errors.push({
341
+ path: "edges",
342
+ message: "`edges`, when present, must be an array of `{ from, to }` dependency edges",
343
+ code: "invalid-edges",
344
+ });
345
+ }
346
+ // consumer (`to`) → set of upstream node ids (`from`'s node) — the dependency direction.
347
+ const adjacency = new Map<string, Set<string>>();
348
+ edges.forEach((rawEdge, i) => {
349
+ const path = `edges[${i}]`;
350
+ // A non-object entry or a missing/empty `from`/`to` is an edge *shape* error, not an
351
+ // endpoint-resolution failure — so it carries `invalid-edges` (like the non-array `edges` case
352
+ // above), reserving `dangling-edge` for a well-formed endpoint that names no node/fact.
353
+ if (!isRecord(rawEdge)) {
354
+ errors.push({ path, message: "each edge must be an object with `from` and `to`", code: "invalid-edges" });
355
+ return;
356
+ }
357
+ const from = rawEdge.from;
358
+ const to = rawEdge.to;
359
+ if (typeof from !== "string" || from.length === 0) {
360
+ errors.push({ path: `${path}.from`, message: "edge is missing a string `from`", code: "invalid-edges" });
361
+ }
362
+ if (typeof to !== "string" || to.length === 0) {
363
+ errors.push({ path: `${path}.to`, message: "edge is missing a string `to`", code: "invalid-edges" });
364
+ }
365
+ if (typeof from !== "string" || typeof to !== "string" || from.length === 0 || to.length === 0) {
366
+ return;
367
+ }
368
+
369
+ if (!nodeFacts.has(to)) {
370
+ errors.push({
371
+ path: `${path}.to`,
372
+ message: `edge \`to\` "${to}" names no node in the graph`,
373
+ code: "dangling-edge",
374
+ });
375
+ }
376
+
377
+ const { nodeId, fact, ambiguousWith } = resolveFrom(from, nodeFacts);
378
+ if (ambiguousWith !== undefined) {
379
+ errors.push({
380
+ path: `${path}.from`,
381
+ message:
382
+ `edge \`from\` "${from}" is ambiguous — it names both node "${from}" (a completion ` +
383
+ `dependency) and fact "${ambiguousWith.fact}" of node "${ambiguousWith.nodeId}"; rename ` +
384
+ "a node id or choose a different fact to disambiguate",
385
+ code: "bad-from",
386
+ });
387
+ }
388
+ const upstreamFacts = nodeFacts.get(nodeId);
389
+ if (upstreamFacts === undefined) {
390
+ errors.push({
391
+ path: `${path}.from`,
392
+ message: `edge \`from\` "${from}" names no node in the graph`,
393
+ code: "dangling-edge",
394
+ });
395
+ } else if (fact !== undefined && !upstreamFacts.has(fact)) {
396
+ errors.push({
397
+ path: `${path}.from`,
398
+ message:
399
+ `edge \`from\` "${from}" references fact "${fact}" that node "${nodeId}" does not ` +
400
+ "declare in its `emits[]`",
401
+ code: "bad-from",
402
+ });
403
+ }
404
+
405
+ if (nodeId === to) {
406
+ errors.push({
407
+ path,
408
+ message: `node "${to}" cannot depend on itself`,
409
+ code: "self-edge",
410
+ });
411
+ return;
412
+ }
413
+
414
+ // Only wire the cycle graph for edges whose endpoints both resolve — a dangling edge is already
415
+ // reported and must not crash the walk.
416
+ if (nodeFacts.has(to) && upstreamFacts !== undefined) {
417
+ const ups = adjacency.get(to) ?? new Set<string>();
418
+ ups.add(nodeId);
419
+ adjacency.set(to, ups);
420
+ }
421
+ });
422
+
423
+ collectCycle(adjacency, errors);
424
+ return errors;
425
+ }
426
+
427
+ /** Depth-first cycle detection over the consumer(`to`)→producer(`from`) graph. Pushes ONE
428
+ * {@link DeliveryGraphError} naming the offending cycle (the "reject at the offending edge"
429
+ * guarantee) — a pure in-memory walk, no I/O. Reports at most one cycle so the message stays
430
+ * actionable; the author fixes it and re-validates to surface any next one. */
431
+ function collectCycle(adjacency: Map<string, Set<string>>, errors: DeliveryGraphError[]): void {
432
+ const VISITING = 1;
433
+ const DONE = 2;
434
+ const state = new Map<string, number>();
435
+ let reported = false;
436
+ const visit = (node: string, stack: string[]): void => {
437
+ if (reported) return;
438
+ state.set(node, VISITING);
439
+ stack.push(node);
440
+ for (const next of adjacency.get(node) ?? []) {
441
+ if (reported) break;
442
+ const s = state.get(next);
443
+ if (s === VISITING) {
444
+ const cycleStart = stack.indexOf(next);
445
+ const cycle = [...stack.slice(cycleStart), next];
446
+ errors.push({
447
+ path: "edges",
448
+ message: `dependency cycle detected: ${cycle.join(" → ")} — the graph must be a DAG`,
449
+ code: "cycle",
450
+ });
451
+ reported = true;
452
+ return;
453
+ }
454
+ if (s !== DONE) visit(next, stack);
455
+ }
456
+ stack.pop();
457
+ state.set(node, DONE);
458
+ };
459
+ for (const node of adjacency.keys()) {
460
+ if (reported) break;
461
+ if (state.get(node) !== DONE) visit(node, []);
462
+ }
463
+ }
package/openapi.yaml CHANGED
@@ -1253,6 +1253,250 @@ components:
1253
1253
  everyMs: { type: integer, description: Interval between poll attempts (ms). }
1254
1254
  timeoutMs: { type: integer, description: Bounded budget (ms) before the gate escalates. }
1255
1255
  backoff: { type: string, enum: [fixed, exponential], description: Backoff shape between attempts. }
1256
+ DeliveryGraph:
1257
+ description: >-
1258
+ An agent-authored delivery graph (ADR 0005) — the SINGLE agent-facing artifact for a
1259
+ heterogeneous, partly-human, cross-repo delivery runbook. It is DATA, never an executable
1260
+ artifact: a JSON DAG whose nodes each name a `kind` from a CLOSED allowlist
1261
+ (`agent`/`wait`/`human`/`connector` — Decision 1/2, the trust boundary) and whose `edges`
1262
+ name DISCOVERED facts (Decision 3). Ingest validates the SHAPE here and the SEMANTICS
1263
+ (acyclicity, edge integrity, fact resolution) in the pure `validateDeliveryGraph`
1264
+ (`app/deliveryGraph.ts`). This slice (S0) defines the vocabulary + validation surface ONLY —
1265
+ no compiler, dispatch, or execution (those land in later slices).
1266
+ type: object
1267
+ additionalProperties: false
1268
+ required:
1269
+ - nodes
1270
+ properties:
1271
+ name:
1272
+ type: string
1273
+ maxLength: 255
1274
+ description: OPTIONAL human-readable label for the graph (shown in the rendered preview).
1275
+ nodes:
1276
+ type: array
1277
+ minItems: 1
1278
+ maxItems: 256
1279
+ items:
1280
+ $ref: "#/components/schemas/DeliveryNode"
1281
+ description: >-
1282
+ The graph's nodes. Each carries a unique `id` and a `kind` from the closed allowlist,
1283
+ plus its per-kind config and its typed `emits[]` declaration. Node ids must be unique
1284
+ across the graph (enforced by `validateDeliveryGraph`).
1285
+ edges:
1286
+ type: array
1287
+ maxItems: 1024
1288
+ items:
1289
+ $ref: "#/components/schemas/DeliveryEdge"
1290
+ description: >-
1291
+ The dependency edges — the graph's discovered-fact topology (Decision 3). Each edge means
1292
+ "`to` proceeds once fact `from` about the upstream node is observable". `from` is either a
1293
+ bare `<nodeId>` (the degenerate "wait for the upstream node's completion" fact) or a
1294
+ qualified `<nodeId>.<fact>` referencing one of that node's declared `emits`. Omit/`[]` for
1295
+ a set of independent (root) nodes. The edge set must be a DAG.
1296
+ DeliveryNode:
1297
+ description: >-
1298
+ One node in a delivery graph. A discriminated union on `kind` over the CLOSED allowlist; the
1299
+ matching per-kind config object (`agent`/`wait`/`connector`) is REQUIRED and names the
1300
+ engine-native body the node delegates to (Decision 2 — the graph schedules, it does not
1301
+ re-implement execution). The `human` config is the sole exception — it is OPTIONAL (a bare
1302
+ `human` node resolves to a generic emit-capturing form fallback in S3).
1303
+ oneOf:
1304
+ - $ref: "#/components/schemas/DeliveryNodeAgent"
1305
+ - $ref: "#/components/schemas/DeliveryNodeWait"
1306
+ - $ref: "#/components/schemas/DeliveryNodeHuman"
1307
+ - $ref: "#/components/schemas/DeliveryNodeConnector"
1308
+ discriminator:
1309
+ propertyName: kind
1310
+ mapping:
1311
+ agent: "#/components/schemas/DeliveryNodeAgent"
1312
+ wait: "#/components/schemas/DeliveryNodeWait"
1313
+ human: "#/components/schemas/DeliveryNodeHuman"
1314
+ connector: "#/components/schemas/DeliveryNodeConnector"
1315
+ DeliveryFact:
1316
+ description: >-
1317
+ A typed output a node declares it will EMIT (ADR 0005 Decision 3/4 — emitted-fact typing).
1318
+ A downstream edge references it as `from: "<nodeId>.<fact>"`, so a bind is validated against
1319
+ this declaration, not stringly. A "click done" human node or a pass-through node declares no
1320
+ facts (`emits` absent/empty) — the degenerate no-emit case.
1321
+ type: object
1322
+ additionalProperties: false
1323
+ required:
1324
+ - name
1325
+ - type
1326
+ properties:
1327
+ name:
1328
+ type: string
1329
+ minLength: 1
1330
+ maxLength: 128
1331
+ pattern: '^[A-Za-z_][A-Za-z0-9_]*$'
1332
+ description: The fact's identifier, referenced downstream as `<nodeId>.<name>`. Must be unique within the node.
1333
+ type:
1334
+ type: string
1335
+ enum: [string, number, boolean, artifact, version, url]
1336
+ description: >-
1337
+ The fact's declared type. `artifact` is a `pkg@version` handle, `version` a bare version
1338
+ string, `url` a location — mirrors the values `capability`/`pr` probes late-bind.
1339
+ description:
1340
+ type: string
1341
+ maxLength: 512
1342
+ description: OPTIONAL human note describing what the fact carries.
1343
+ DeliveryNodeCommon:
1344
+ type: object
1345
+ properties:
1346
+ id:
1347
+ type: string
1348
+ minLength: 1
1349
+ maxLength: 128
1350
+ pattern: '^[A-Za-z_][A-Za-z0-9_.-]*$'
1351
+ description: The node's identifier, unique within the graph and referenced by edges.
1352
+ emits:
1353
+ type: array
1354
+ maxItems: 32
1355
+ items:
1356
+ $ref: "#/components/schemas/DeliveryFact"
1357
+ description: >-
1358
+ The typed facts this node hands forward when it completes (Decision 3/4). Absent/empty for
1359
+ a node that emits nothing. Downstream edges bind these via `from: "<nodeId>.<fact>"`.
1360
+ DeliveryNodeAgent:
1361
+ description: >-
1362
+ An `agent` node — a worker executes an agent job type (the existing fan-out body). Bounded
1363
+ (timeout → escalate) and resumable like every node.
1364
+ allOf:
1365
+ - $ref: "#/components/schemas/DeliveryNodeCommon"
1366
+ - type: object
1367
+ additionalProperties: false
1368
+ required:
1369
+ - id
1370
+ - kind
1371
+ - agent
1372
+ properties:
1373
+ id: { type: string }
1374
+ kind: { type: string, enum: [agent] }
1375
+ emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
1376
+ agent:
1377
+ type: object
1378
+ additionalProperties: false
1379
+ required:
1380
+ - jobType
1381
+ properties:
1382
+ jobType:
1383
+ type: string
1384
+ minLength: 1
1385
+ description: The agent job type a worker executes for this node (e.g. `senior:feature`).
1386
+ prompt:
1387
+ type: string
1388
+ maxLength: 20000
1389
+ description: OPTIONAL steering prompt appended to the node's job brief.
1390
+ DeliveryNodeWait:
1391
+ description: >-
1392
+ A `wait` node — a durable `ReadinessProbe` (ADR 0001 §2) watching an external fact. Reuses the
1393
+ existing `ReadinessProbe` shape verbatim (Decision 3 — never a second wait loop); the `pr`
1394
+ merge-state kind is added to that shape by slice S2 and flows in here automatically.
1395
+ allOf:
1396
+ - $ref: "#/components/schemas/DeliveryNodeCommon"
1397
+ - type: object
1398
+ additionalProperties: false
1399
+ required:
1400
+ - id
1401
+ - kind
1402
+ - wait
1403
+ properties:
1404
+ id: { type: string }
1405
+ kind: { type: string, enum: [wait] }
1406
+ emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
1407
+ wait:
1408
+ $ref: "#/components/schemas/ReadinessProbe"
1409
+ DeliveryNodeHuman:
1410
+ description: >-
1411
+ A `human` node — a scheduled user task + form (ADR 0002 machinery promoted from exception to
1412
+ node, Decision 4). Surfaces "now do X" on the Tasks inbox, blocks dependents, is answerable by
1413
+ a human OR an agent, is SLA-bounded, and can EMIT a typed fact its form captures.
1414
+ allOf:
1415
+ - $ref: "#/components/schemas/DeliveryNodeCommon"
1416
+ - type: object
1417
+ additionalProperties: false
1418
+ required:
1419
+ - id
1420
+ - kind
1421
+ properties:
1422
+ id: { type: string }
1423
+ kind: { type: string, enum: [human] }
1424
+ emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
1425
+ human:
1426
+ type: object
1427
+ additionalProperties: false
1428
+ description: >-
1429
+ OPTIONAL human-node config. `formKey` explicitly attaches a form (else a form is
1430
+ selected by node category, else a generic emit-capturing fallback — resolved in S3).
1431
+ The node's typed output is declared via the node-level `emits[]`.
1432
+ properties:
1433
+ formKey:
1434
+ type: string
1435
+ minLength: 1
1436
+ description: OPTIONAL explicit form to attach at authoring time (specific-else-generic resolution, S3).
1437
+ prompt:
1438
+ type: string
1439
+ maxLength: 20000
1440
+ description: OPTIONAL instruction shown to the human/agent completing the task ("now do X").
1441
+ DeliveryNodeConnector:
1442
+ description: >-
1443
+ A `connector` node — an automated, side-effecting outbound action (the connector I/O surface).
1444
+ Side-effecting, so it carries a `dedupeKey` and tolerates at-least-once execution. The
1445
+ `payload` schema is a minimal forward-declared stub in this slice (ADR 0005 non-goal — the
1446
+ concrete connector I/O lands later).
1447
+ allOf:
1448
+ - $ref: "#/components/schemas/DeliveryNodeCommon"
1449
+ - type: object
1450
+ additionalProperties: false
1451
+ required:
1452
+ - id
1453
+ - kind
1454
+ - connector
1455
+ properties:
1456
+ id: { type: string }
1457
+ kind: { type: string, enum: [connector] }
1458
+ emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
1459
+ connector:
1460
+ type: object
1461
+ additionalProperties: false
1462
+ required:
1463
+ - target
1464
+ properties:
1465
+ target:
1466
+ type: string
1467
+ minLength: 1
1468
+ description: The connector action target (forward-declared — the concrete scheme lands in a later slice).
1469
+ dedupeKey:
1470
+ type: string
1471
+ minLength: 1
1472
+ description: >-
1473
+ OPTIONAL idempotency key so an at-least-once resume cannot double-fire this
1474
+ side-effecting node (ADR 0005 Decision 7). Author-supplied or graph-derived.
1475
+ payload:
1476
+ type: object
1477
+ additionalProperties: true
1478
+ description: Minimal forward-declared payload stub — the concrete connector payload schema is deferred (ADR non-goal).
1479
+ DeliveryEdge:
1480
+ description: >-
1481
+ A dependency edge — "`to` proceeds once fact `from` is observable" (ADR 0005 Decision 3).
1482
+ `from` is either a bare `<nodeId>` (wait for the upstream node's completion fact) or a
1483
+ qualified `<nodeId>.<fact>` referencing a declared `emits` fact of that node. Both endpoints
1484
+ must resolve to a node in the graph, the referenced fact must be declared, and the whole edge
1485
+ set must be a DAG — all enforced by `validateDeliveryGraph`.
1486
+ type: object
1487
+ additionalProperties: false
1488
+ required:
1489
+ - from
1490
+ - to
1491
+ properties:
1492
+ from:
1493
+ type: string
1494
+ minLength: 1
1495
+ description: The upstream endpoint — `<nodeId>` (completion) or `<nodeId>.<fact>` (a declared emitted fact).
1496
+ to:
1497
+ type: string
1498
+ minLength: 1
1499
+ description: The dependent node's id — proceeds once `from` is observed.
1256
1500
  FeatureStart:
1257
1501
  description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
1258
1502
  by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.109.0",
3
+ "version": "0.110.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",