@nanobpm/nano-workforce 0.123.2 → 0.124.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +9 -5
  3. package/app/deliveryGraphDispatch.test.ts +143 -0
  4. package/app/deliveryGraphDispatch.ts +168 -0
  5. package/app/deliveryGraphProposals.test.ts +267 -0
  6. package/app/deliveryGraphProposals.ts +269 -0
  7. package/app/deliveryGraphRun.test.ts +6 -52
  8. package/app/deliveryGraphRun.ts +21 -76
  9. package/app/deliveryGraphText.ts +3 -3
  10. package/app/deliveryRunner.ts +4 -3
  11. package/app/service.ts +15 -0
  12. package/db/migrations/075_delivery_graph_proposals.sql +48 -0
  13. package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
  14. package/docs/adr/0006-delivery-units-one-representation.md +221 -0
  15. package/docs/agent-guide.md +50 -58
  16. package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
  17. package/openapi.yaml +118 -161
  18. package/operations/compileDeliveryGraph.test.ts +100 -37
  19. package/operations/compileDeliveryGraph.ts +64 -18
  20. package/operations/dispatchDeliveryGraph.test.ts +171 -152
  21. package/operations/dispatchDeliveryGraph.ts +79 -99
  22. package/operations/getAgentInstructions.test.ts +10 -6
  23. package/operations/previewDeliveryGraph.test.ts +90 -51
  24. package/operations/previewDeliveryGraph.ts +45 -18
  25. package/package.json +1 -1
  26. package/pages/cockpit/mount.js +19 -12
  27. package/pages/delivery-graphs/mount.js +37 -137
  28. package/pages/delivery-graphs.page.json +50 -3
  29. package/scripts/check-migrations.test.ts +9 -0
  30. package/scripts/check-migrations.ts +11 -1
  31. package/test/cockpit-embed-endpoints.test.ts +59 -36
  32. package/test/delivery-graphs-embed.test.ts +36 -34
  33. package/e2e/delivery-graph-start.e2e.ts +0 -145
  34. package/operations/startDeliveryGraph.integration.test.ts +0 -316
  35. package/operations/startDeliveryGraph.ts +0 -222
@@ -1,35 +1,81 @@
1
- // POST /app/api/actions/compile-delivery-graph → operationId `compileDeliveryGraph` (ADR 0005,
2
- // slice S1). The PURE, side-effect-free compile door: the fast, safe inner loop a co-designing agent
3
- // hammers while authoring a `DeliveryGraph`. It VALIDATES (the pure `validateDeliveryGraph` semantic
4
- // check, run inside the compiler) and COMPILES the graph into a preview the compiled one-shot BPMN
5
- // (compile-to-native), a mermaid diagram, the resolved/normalised graph, and the extracted human
6
- // stop-points + side effects but NEVER deploys, dispatches, or mutates anything (Decision 5/6:
7
- // `compile` and `start` are SEPARATE doors, and there is deliberately no `dryRun` flag on the start
8
- // door). Because it has zero side effects, an agent may call it repeatedly: JSON → compile → fix.
1
+ // POST /app/api/actions/compile-delivery-graph → operationId `compileDeliveryGraph` (ADR 0005
2
+ // Decision 7, issue #460). The agent-facing delivery-graph door and the END of the agent's surface.
3
+ // It VALIDATES (the pure `validateDeliveryGraph` semantic check, run inside the compiler) and COMPILES
4
+ // the graph, and when valid PERSISTS the compiled graph as a `staged` proposal (content-addressed by
5
+ // its `digest`). It returns a PREVIEW plus a navigational `reviewUrl` and NOTHING that can trigger a
6
+ // run: no run key, no token, no process-instance key.
9
7
  //
10
- // A well-formed graph is `200 { ok:true, diagram, bpmn, resolved, humanNodes, sideEffects }`; a
11
- // malformed one is `400 { ok:false, errors:[{ path, message }] }`, every error path-qualified so the
12
- // author can fix the exact offending input. The compiler is the single source of both truths this
13
- // delegate just maps its discriminated result onto the HTTP status.
8
+ // This is capability-by-absence (issue #460): the old two-step submit token re-submit flow made
9
+ // the "approval" a REPLAYABLE content digest handed back to the same caller, so any holder of the API
10
+ // credential self-approved. By removing the dispatch affordance from the agent surface entirelythere
11
+ // is no `start` endpoint there is nothing to replay. Dispatch is an OPERATOR action performed in the
12
+ // cockpit; the response tells the agent its role ends here, turning the boundary into a self-documenting
13
+ // protocol. A malformed graph is a 400 carrying path-qualified errors; nothing is staged.
14
14
 
15
15
  import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
16
+ import {
17
+ buildProposalPreview,
18
+ buildProposalRow,
19
+ proposalLogicalKey,
20
+ proposalReviewUrl,
21
+ stageProposal,
22
+ } from "../app/deliveryGraphProposals.ts";
23
+ import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
16
24
  import { defineOperation } from "../nano-generated/operations.ts";
17
25
 
26
+ const STAGED_MESSAGE =
27
+ "The graph compiled and is staged for operator review. Ask the operator to preview and approve — or request modifications — in the cockpit. Dispatch is an operator action; there is no start endpoint.";
28
+
18
29
  export default defineOperation("compileDeliveryGraph", async ({ body }, app) => {
19
30
  // The runtime validates the body's SHAPE against `DeliveryGraph` before we run; the compiler adds
20
- // the SEMANTIC checks (acyclicity, edge integrity, fact resolution) the schema cannot express. A
21
- // directly-invoked delegate could still pass `undefined` — the compiler reads its input as
22
- // `unknown` and maps that to a clean `ok:false`, never a 500.
31
+ // the SEMANTIC checks (acyclicity, edge integrity, fact resolution). A directly-invoked delegate
32
+ // could still pass `undefined` — the compiler reads its input as `unknown` and maps that to a clean
33
+ // `ok:false`, never a 500.
23
34
  const result = await compileDeliveryGraph(body);
24
35
  if (!result.ok) {
25
36
  app.log.warn("compile-delivery-graph rejected", { errors: result.errors.length });
26
37
  return { status: 400, body: result };
27
38
  }
28
- app.log.info("compile-delivery-graph compiled", {
39
+
40
+ // Persist the compiled graph as a `staged` proposal — the agent's surface ends here. Superseded by
41
+ // logical key + TTL inside `stageProposal`.
42
+ const digest = deliveryGraphDigest(result.bpmn);
43
+ const name =
44
+ typeof result.resolved.name === "string" && result.resolved.name.trim() !== ""
45
+ ? result.resolved.name.trim()
46
+ : null;
47
+ const preview = buildProposalPreview(result);
48
+ await stageProposal(
49
+ app.data,
50
+ buildProposalRow({
51
+ digest,
52
+ logicalKey: proposalLogicalKey(name, digest),
53
+ title: name,
54
+ graphJson: JSON.stringify(body),
55
+ preview,
56
+ nodeCount: result.resolved.nodes.length,
57
+ humanNodeCount: result.humanNodes.length,
58
+ sideEffectCount: result.sideEffects.length,
59
+ sideEffecting: result.sideEffects.length > 0,
60
+ }),
61
+ );
62
+
63
+ app.log.info("compile-delivery-graph staged", {
64
+ digest,
29
65
  nodes: result.resolved.nodes.length,
30
- edges: result.resolved.edges.length,
31
66
  humanNodes: result.humanNodes.length,
32
67
  sideEffects: result.sideEffects.length,
33
68
  });
34
- return { status: 200, body: result };
69
+
70
+ // The response carries a preview + a navigational pointer and NO dispatch handle (issue #460).
71
+ return {
72
+ status: 200,
73
+ body: {
74
+ status: "ready",
75
+ message: STAGED_MESSAGE,
76
+ digest,
77
+ preview,
78
+ reviewUrl: proposalReviewUrl(digest),
79
+ },
80
+ };
35
81
  });
@@ -1,166 +1,185 @@
1
1
  // Integration coverage for the POST /app/api/actions/delivery-graph/dispatch operation
2
- // `dispatchDeliveryGraph` (issue #386, ADR 0005 slice S5) — the human-facing UI JSON-paste DISPATCH
3
- // ingress. It parses the operator's pasted JSON STRING and DELEGATES to the SAME gated, idempotent
4
- // `startDeliveryGraph` handler (no parallel dispatch path), deriving the approval token from the graph
5
- // when the operator ticks `approve`. These tests drive the real delegate against an in-memory
6
- // app/data/engine (mirroring startDeliveryGraph.integration.test.ts) so the composed behaviour parse
7
- // approval-gate launch is proven, and assert the parse guards map to a 400 with a human error.
8
- import { test } from "node:test";
9
- import { assert, assertEquals } from "#test-assert";
10
- import type { AppApi } from "@nanobpm/urban";
11
- import { noopLog } from "../test/log.ts";
12
- import handler from "./dispatchDeliveryGraph.ts";
13
-
14
- // A compact in-memory app: a generic table over an array (get/find/insert/update/delete) faithful to
15
- // the run aggregate's PRIMARY KEY fence, plus the guarded raw UPDATE the door issues and a fake engine.
16
- function makeApp() {
17
- const tables = new Map<string, Record<string, unknown>[]>();
18
- const started: { processDefinitionId: string }[] = [];
19
- const table = (name: string, key: string) => {
20
- const rows = tables.get(name) ?? (() => {
21
- const fresh: Record<string, unknown>[] = [];
22
- tables.set(name, fresh);
23
- return fresh;
24
- })();
25
- return {
26
- get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
27
- find: (q: Record<string, unknown>) =>
28
- Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
29
- insert: (r: Record<string, unknown>) => {
30
- if (rows.some((existing) => existing[key] === r[key])) {
31
- return Promise.reject(new Error(`UNIQUE constraint failed: ${name}.${key}`));
32
- }
33
- rows.push(r);
34
- return Promise.resolve(r);
35
- },
36
- update: (k: unknown, patch: Record<string, unknown>) => {
37
- const row = rows.find((r) => r[key] === k);
38
- if (row) Object.assign(row, patch);
39
- return Promise.resolve(row);
40
- },
41
- delete: (k: unknown) => {
42
- const i = rows.findIndex((r) => r[key] === k);
43
- if (i >= 0) rows.splice(i, 1);
44
- return Promise.resolve();
45
- },
46
- };
47
- };
48
- const app = {
49
- data: {
50
- table,
51
- open: () => ({
52
- exec: (sql: string, params: unknown[]) =>
53
- Promise.resolve().then(() => {
54
- const cols = [...sql.matchAll(/"(\w+)"\s*=\s*\?/g)].map((m) => m[1]);
55
- const runKey = params[params.length - 1];
56
- const rows = tables.get("delivery_graph_runs") ?? [];
57
- const row = rows.find((r) => r["run_key"] === runKey);
58
- if (row && row["status"] !== "running") {
59
- for (let i = 0; i < cols.length - 1; i++) row[cols[i]] = params[i];
60
- return { changed: 1 };
61
- }
62
- return { changed: 0 };
63
- }),
64
- }),
65
- },
66
- engine: {
67
- deployResources: () => Promise.resolve([]),
68
- createInstance: (req: { processDefinitionId: string }) => {
69
- started.push(req);
70
- return Promise.resolve({ processInstanceKey: "PI-1", processDefinitionId: req.processDefinitionId });
71
- },
72
- },
73
- log: noopLog(),
74
- } as unknown as AppApi;
75
- return { app, started, runs: () => tables.get("delivery_graph_runs") ?? [] };
76
- }
77
-
78
- async function call(app: AppApi, body: unknown) {
79
- return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
80
- }
81
-
82
- const SIDE_EFFECTING = JSON.stringify({
2
+ // `dispatchDeliveryGraph` (ADR 0005 Decision 7, issue #460) — the OPERATOR-ONLY dispatch door. The
3
+ // cockpit's staged-proposals grid posts the `digest` of the proposal the operator picked; this door
4
+ // loads that live `staged` proposal, launches the retained S4 runner for its graph, and marks the
5
+ // proposal `dispatched`. There is no replayable token the operator clicking Dispatch IS the
6
+ // approval, and the door is reachable only from the cockpit (the agent compile door returns no
7
+ // dispatch handle). These tests drive the REAL door through `bootTestApp`'s api driver against the
8
+ // WASM engine: compile-to-stage, then dispatch by digest, asserting the digest is resolved, an
9
+ // unknown/consumed digest is refused, and the run launches engine-natively.
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join, resolve } from "node:path";
13
+ import { after, describe, test } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
16
+ import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
17
+ import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
18
+
19
+ const APP_ROOT = resolve(import.meta.dirname, "..");
20
+ const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
21
+
22
+ const HUMAN_ONLY = {
23
+ name: "manual gate",
24
+ nodes: [{ id: "ack", kind: "human", human: { prompt: "click done when the release is out" } }],
25
+ };
26
+ const SIDE_EFFECTING = {
83
27
  name: "release runbook",
84
28
  nodes: [
85
- { id: "open-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "un-draft + merge #B" } },
29
+ { id: "open-b", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
86
30
  { id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" } },
87
31
  ],
88
32
  edges: [{ from: "open-b", to: "publish" }],
89
- });
90
- const HUMAN_ONLY = JSON.stringify({
91
- name: "manual gate",
92
- nodes: [{ id: "ack", kind: "human", human: { prompt: "click done when the release is out" } }],
93
- });
33
+ };
94
34
 
95
- test("dispatch-delivery-graph: a non-JSON paste 400 with a human error, nothing launched", async () => {
96
- const { app, started } = makeApp();
97
- const res = await call(app, { graphJson: "{ not json" });
98
- assertEquals(res.status, 400);
99
- assertEquals(res.body.ok, false);
100
- assert(typeof res.body.error === "string" && res.body.error.includes("not valid JSON"));
101
- assertEquals(started.length, 0);
102
- });
35
+ describe("dispatchDeliveryGraph operator dispatch by staged-proposal digest", () => {
36
+ const dirs: string[] = [];
37
+ const apps: TestApp[] = [];
38
+ after(async () => {
39
+ for (const app of apps) await app.stop?.();
40
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
41
+ });
42
+ const boot = async (): Promise<TestApp> => {
43
+ const d = mkdtempSync(join(tmpdir(), "nwf-dispatch-"));
44
+ dirs.push(d);
45
+ const app = await bootTestApp(APP_ROOT, { env: { ...GITHUB_ENV, NANO_APP_DB_URL: `file:${join(d, "app.db")}` } });
46
+ apps.push(app);
47
+ return app;
48
+ };
103
49
 
104
- test("dispatch-delivery-graph: a blank paste → 400, never a 500", async () => {
105
- const { app } = makeApp();
106
- const res = await call(app, { graphJson: "" });
107
- assertEquals(res.status, 400);
108
- assertEquals(res.body.ok, false);
109
- });
50
+ test("a missing/blank digest → 400 with a human error, nothing launched", async () => {
51
+ const app = await boot();
52
+ assert.ok(app.api);
53
+ const res = await app.api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", { body: { digest: " " } });
54
+ assert.equal(res.status, 400);
55
+ assert.equal(res.body.ok, false);
56
+ assert.ok(typeof res.body.error === "string" && res.body.error.length > 0);
57
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
58
+ });
110
59
 
111
- test("dispatch-delivery-graph: a non-side-effecting graph dispatches straight away (202 running)", async () => {
112
- const { app, started, runs } = makeApp();
113
- const res = await call(app, { graphJson: HUMAN_ONLY });
114
- assertEquals(res.status, 202);
115
- assertEquals(res.body.ok, true);
116
- assertEquals(res.body.status, "running");
117
- assertEquals(started.length, 1);
118
- assertEquals(runs()[0].status, "running");
119
- });
60
+ test("an unknown / never-staged digest 400, nothing launched", async () => {
61
+ const app = await boot();
62
+ assert.ok(app.api);
63
+ const res = await app.api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", { body: { digest: "deadbeef0000" } });
64
+ assert.equal(res.status, 400);
65
+ assert.equal(res.body.ok, false);
66
+ assert.ok(/no staged proposal/.test(res.body.error ?? ""));
67
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
68
+ });
120
69
 
121
- test("dispatch-delivery-graph: a side-effecting graph WITHOUT approve is parked at approval (400), nothing launched", async () => {
122
- const { app, started, runs } = makeApp();
123
- const res = await call(app, { graphJson: SIDE_EFFECTING });
124
- assertEquals(res.status, 400);
125
- assertEquals(res.body.ok, false);
126
- assertEquals(res.body.status, "awaiting-approval");
127
- // The human banner is populated from the door's park message.
128
- assert(typeof res.body.error === "string" && res.body.error.length > 0);
129
- assertEquals(started.length, 0);
130
- assertEquals(runs()[0].status, "awaiting-approval");
131
- });
70
+ test("a human-only graph: stage via compile, then dispatch by digest 202 running; proposal marked dispatched", async () => {
71
+ const app = await boot();
72
+ assert.ok(app.api);
73
+ const api = app.api;
132
74
 
133
- test("dispatch-delivery-graph: a side-effecting graph WITH approve dispatches (202 running), token derived server-side", async () => {
134
- const { app, started, runs } = makeApp();
135
- const res = await call(app, { graphJson: SIDE_EFFECTING, approve: true });
136
- assertEquals(res.status, 202);
137
- assertEquals(res.body.ok, true);
138
- assertEquals(res.body.status, "running");
139
- assertEquals(res.body.sideEffecting, true);
140
- assertEquals(started.length, 1);
141
- assertEquals(runs()[0].status, "running");
142
- });
75
+ // Stage through the agent compile door it returns only a preview + digest (no dispatch handle).
76
+ const staged = await api.call<{ status: string; digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
77
+ assert.equal(staged.status, 200);
78
+ assert.equal(staged.body.status, "ready");
79
+ const digest = staged.body.digest;
80
+ assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "staged");
143
81
 
144
- test("dispatch-delivery-graph: re-dispatch of a running graph short-circuits (alreadyRunning), no second launch", async () => {
145
- const { app, started } = makeApp();
146
- await call(app, { graphJson: HUMAN_ONLY });
147
- const res = await call(app, { graphJson: HUMAN_ONLY });
148
- assertEquals(res.status, 202);
149
- assertEquals(res.body.alreadyRunning, true);
150
- assertEquals(started.length, 1);
151
- });
82
+ // The operator dispatches that digest.
83
+ const res = await api.call<{ ok: boolean; status: string; runKey: string; alreadyRunning?: boolean }>(
84
+ "dispatchDeliveryGraph",
85
+ { body: { digest } },
86
+ );
87
+ assert.equal(res.status, 202);
88
+ assert.equal(res.body.ok, true);
89
+ assert.equal(res.body.status, "running");
90
+ await app.settle();
91
+ const runs = await deliveryGraphRuns(app.db).all();
92
+ assert.equal(runs.length, 1);
93
+ assert.equal(runs[0].status, "running");
94
+ // The proposal drops out of the staged list — it is now dispatched.
95
+ assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "dispatched");
96
+ });
97
+
98
+ test("a side-effecting graph dispatches the agent side effect once the operator picks its digest", async () => {
99
+ const app = await boot();
100
+ assert.ok(app.api);
101
+ const api = app.api;
102
+ let agentFired = 0;
103
+ await app.engine.registerWorker("senior:demo", async () => {
104
+ agentFired++;
105
+ return {};
106
+ });
107
+
108
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
109
+ const res = await api.call<{ ok: boolean; status: string; sideEffecting: boolean }>("dispatchDeliveryGraph", {
110
+ body: { digest: staged.body.digest },
111
+ });
112
+ assert.equal(res.status, 202);
113
+ assert.equal(res.body.ok, true);
114
+ assert.equal(res.body.sideEffecting, true);
115
+ await app.settle();
116
+ assert.equal(agentFired, 1, "the side effect fired exactly once");
117
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
118
+ });
119
+
120
+ test("re-dispatching an ALREADY-dispatched digest → 400 (the proposal is consumed; the run shows in the in-flight grid)", async () => {
121
+ const app = await boot();
122
+ assert.ok(app.api);
123
+ const api = app.api;
124
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
125
+ await api.call("dispatchDeliveryGraph", { body: { digest: staged.body.digest } });
126
+ await app.settle();
127
+ const again = await api.call<{ ok: boolean }>("dispatchDeliveryGraph", { body: { digest: staged.body.digest } });
128
+ assert.equal(again.status, 400);
129
+ assert.equal(again.body.ok, false);
130
+ // Still exactly one run — the consumed proposal cannot re-launch.
131
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
132
+ });
133
+
134
+ test("an idempotencyKey already bound to a DIFFERENT running graph → 409; the proposal is NOT consumed and nothing new launches", async () => {
135
+ const app = await boot();
136
+ assert.ok(app.api);
137
+ const api = app.api;
138
+
139
+ // Stage two distinct graphs (different digests, different logical keys → neither supersedes).
140
+ const a = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
141
+ const b = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
142
+ assert.notEqual(a.body.digest, b.body.digest);
143
+
144
+ // Dispatch graph A under a shared idempotencyKey — it launches and stays running (parks on a human).
145
+ const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
146
+ body: { digest: a.body.digest, idempotencyKey: "shared-key" },
147
+ });
148
+ assert.equal(first.status, 202);
149
+ await app.settle();
150
+
151
+ // Dispatch graph B under the SAME idempotencyKey — it short-circuits onto A's run. B's graph was
152
+ // never launched, so B must NOT be consumed: refuse with 409 and leave B staged.
153
+ const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
154
+ body: { digest: b.body.digest, idempotencyKey: "shared-key" },
155
+ });
156
+ assert.equal(second.status, 409);
157
+ assert.equal(second.body.ok, false);
158
+ await app.settle();
159
+ // Proposal B is still staged (dispatchable) — it was never launched.
160
+ assert.equal((await deliveryGraphProposals(app.db).get(b.body.digest))?.status, "staged");
161
+ // Only A's single run exists — B did not launch anything.
162
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
163
+ });
164
+
165
+ test("a proposal whose stored graph is corrupt JSON → 400 AND the proposal is retired (expired), never lingering staged", async () => {
166
+ const app = await boot();
167
+ assert.ok(app.api);
168
+ const api = app.api;
169
+
170
+ // Stage a valid graph, then corrupt its stored `graph` payload directly (simulating on-disk
171
+ // corruption/tampering that passed stage-time validation).
172
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
173
+ const digest = staged.body.digest;
174
+ assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "staged");
175
+ await deliveryGraphProposals(app.db).update(digest, { graph: "{not-json" });
152
176
 
153
- test("dispatch-delivery-graph: a graph that fails validation 400 carries the start door's structured `errors` array, not just a summary banner", async () => {
154
- const { app, started } = makeApp();
155
- const res = await call(app, { graphJson: JSON.stringify({ name: "empty", nodes: [] }) });
156
- assertEquals(res.status, 400);
157
- assertEquals(res.body.ok, false);
158
- // The structured, path-qualified errors from startDeliveryGraph must survive the adapter's re-shape.
159
- assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
160
- for (const e of res.body.errors) {
161
- assert(typeof e.path === "string" && typeof e.message === "string");
162
- }
163
- // And the human banner is still derived from those errors.
164
- assert(typeof res.body.error === "string" && res.body.error.length > 0);
165
- assertEquals(started.length, 0);
177
+ const res = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", { body: { digest } });
178
+ assert.equal(res.status, 400);
179
+ assert.equal(res.body.ok, false);
180
+ assert.ok(/corrupt/.test(res.body.error ?? ""));
181
+ // Fail closed: the corrupt proposal is retired, not left as an undismissable `staged` row.
182
+ assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "expired");
183
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
184
+ });
166
185
  });