@nanobpm/nano-workforce 0.189.3 → 0.191.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.
@@ -6,15 +6,27 @@
6
6
  // graph's side effects. These tests drive it against an in-memory app/data/engine faithful to the run
7
7
  // aggregate's PRIMARY KEY fence and the guarded raw UPDATE the claim issues.
8
8
  import { test } from "node:test";
9
- import { assert, assertEquals } from "#test-assert";
9
+ import { assert, assertEquals, assertRejects } from "#test-assert";
10
10
  import type { AppApi } from "@nanobpm/urban";
11
11
  import { dispatchDeliveryGraphRun } from "./deliveryGraphDispatch.ts";
12
12
  import { noopLog } from "../test/log.ts";
13
13
 
14
- function makeApp() {
14
+ function makeApp(opts: { failIdentityWrite?: boolean } = {}) {
15
15
  const tables = new Map<string, Record<string, unknown>[]>();
16
16
  const started: { processDefinitionId: string }[] = [];
17
17
  const table = (name: string, key: string) => {
18
+ if (opts.failIdentityWrite && name === "delivery_graph_run_identity") {
19
+ // Simulate a transient side-table write error (e.g. a SQLite `disk I/O error`) — NOT a UNIQUE
20
+ // collision, so `upsertRunIdentity` rethrows it rather than folding it into an UPDATE.
21
+ return {
22
+ get: () => Promise.resolve(null),
23
+ find: () => Promise.resolve([]),
24
+ all: () => Promise.resolve([]),
25
+ insert: () => Promise.reject(new Error("disk I/O error")),
26
+ update: () => Promise.reject(new Error("disk I/O error")),
27
+ delete: () => Promise.resolve(),
28
+ };
29
+ }
18
30
  const rows =
19
31
  tables.get(name) ??
20
32
  (() => {
@@ -101,6 +113,39 @@ test("dispatchDeliveryGraphRun: a human-only graph launches straight away (runni
101
113
  assertEquals(runs()[0].status, "running");
102
114
  });
103
115
 
116
+ test("#778 dispatchDeliveryGraphRun: a post-claim identity-write failure flips the claimed run to `failed`, not a stranded null-process_key `running` — so a phantom run never short-circuits a later dispatch (thread deliveryGraphDispatch.ts:229)", async () => {
117
+ const { app, started, runs } = makeApp({ failIdentityWrite: true });
118
+ // The identity side-table write throws AFTER the durable `running` claim; dispatch must propagate it.
119
+ await assertRejects(() => dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true }), Error, "disk I/O error");
120
+ // The claimed row was flipped to `failed` (never left `running` with a null process key), so a later
121
+ // same-key dispatch does NOT short-circuit onto a phantom run — it can retry cleanly.
122
+ assertEquals(runs().length, 1);
123
+ assertEquals(runs()[0].status, "failed");
124
+ assertEquals(runs()[0].process_key ?? null, null);
125
+ // The side effect never launched.
126
+ assertEquals(started.length, 0);
127
+ });
128
+
129
+ test("#778 dispatchDeliveryGraphRun: a still-running run whose identity row is MISSING (null, pre-migration) short-circuits with identityConfirmed:false instead of throwing (thread deliveryGraphDispatch.ts:175)", async () => {
130
+ const { app, started } = makeApp();
131
+ const first = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true });
132
+ assert(first.ok);
133
+ if (!first.ok) return;
134
+ assertEquals(first.status, "running");
135
+ // Simulate a run launched BEFORE the identity side-table existed: drop its identity row so
136
+ // `identities.get(runKey)` resolves to null (the test table — like the real `table.get()` — returns
137
+ // null, NOT undefined, for a missing row).
138
+ await app.data.table("delivery_graph_run_identity", "run_key").delete(first.runKey);
139
+ // A second same-key dispatch hits the already-running short-circuit; the null identity row must not
140
+ // throw while building the response — it is unprovable, so identityConfirmed is false.
141
+ const second = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true });
142
+ assert(second.ok);
143
+ if (!second.ok) return;
144
+ assertEquals(second.alreadyRunning, true);
145
+ assertEquals(second.identityConfirmed, false);
146
+ assertEquals(started.length, 1); // no double launch
147
+ });
148
+
104
149
  test("dispatchDeliveryGraphRun: a side-effecting graph dispatches with NO approval token — the operator seam IS the approval", async () => {
105
150
  const { app, started, runs } = makeApp();
106
151
  const res = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true });
@@ -130,6 +175,35 @@ test("dispatchDeliveryGraphRun: an explicit idempotency key forces a distinct ru
130
175
  assertEquals(started.length, 2);
131
176
  });
132
177
 
178
+ test("#778 dispatchDeliveryGraphRun: a recompiled digest that drifts from `expectedDigest` is REFUSED before any launch — no run, no engine instance (thread :1605)", async () => {
179
+ // A staged proposal is keyed by its stage-time digest. If the compiler ships a digest-affecting change
180
+ // (this PR adds labels/`<bpmn:documentation>`), the stored graph recompiles to a DIFFERENT digest. The
181
+ // door hands the stage-time digest as `expectedDigest`; a mismatch must refuse cleanly BEFORE the
182
+ // durable launch claim, so we never strand a live run against a proposal left `staged`.
183
+ const { app, started, runs } = makeApp();
184
+ const res = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true, expectedDigest: "sha256-stale-address" });
185
+ assertEquals(res.ok, false);
186
+ if (res.ok) return;
187
+ assert(res.errors.some((e) => e.path === "digest"), "the refusal is a digest-address error");
188
+ assertEquals(started.length, 0); // nothing launched
189
+ assertEquals(runs().length, 0); // no run row claimed
190
+ });
191
+
192
+ test("#778 dispatchDeliveryGraphRun: a matching `expectedDigest` dispatches normally (the same-compiler no-op path)", async () => {
193
+ // Compute the graph's true digest first, then pass it as `expectedDigest` — the normal deterministic
194
+ // recompile matches, so the address check is a no-op and the run launches.
195
+ const { app, started } = makeApp();
196
+ const probe = await dispatchDeliveryGraphRun(app, HUMAN_ONLY, { repoless: true });
197
+ assert(probe.ok);
198
+ if (!probe.ok) return;
199
+ const { app: app2, started: started2 } = makeApp();
200
+ const res = await dispatchDeliveryGraphRun(app2, HUMAN_ONLY, { repoless: true, expectedDigest: probe.digest });
201
+ assertEquals(res.ok, true);
202
+ if (!res.ok) return;
203
+ assertEquals(res.status, "running");
204
+ assertEquals(started2.length, 1);
205
+ });
206
+
133
207
  test("dispatchDeliveryGraphRun: a malformed graph → ok:false with path-qualified errors, nothing launched", async () => {
134
208
  const { app, started } = makeApp();
135
209
  const res = await dispatchDeliveryGraphRun(app, { name: "empty", nodes: [] });
@@ -141,3 +215,45 @@ test("dispatchDeliveryGraphRun: a malformed graph → ok:false with path-qualifi
141
215
  }
142
216
  assertEquals(started.length, 0);
143
217
  });
218
+
219
+ // Option C (issue #778): a graph carrying redacted-away credential material has a LOSSY content digest
220
+ // (a sibling differing only in the secret would collide), so a keyless dispatch — which defaults the
221
+ // run identity to that digest — is refused; an explicit `idempotencyKey` disambiguates it.
222
+ const SECRET_BEARING = {
223
+ name: "deploy with a secret",
224
+ nodes: [{ id: "deploy", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:s3cr3t@host.example/repo" } }],
225
+ };
226
+
227
+ test("dispatchDeliveryGraphRun: a secret-bearing graph dispatched KEYLESS is refused, points at idempotencyKey, launches nothing", async () => {
228
+ const { app, started, runs } = makeApp();
229
+ const res = await dispatchDeliveryGraphRun(app, SECRET_BEARING, { repoless: true });
230
+ assertEquals(res.ok, false);
231
+ if (res.ok) return;
232
+ assertEquals(res.errors.length, 1);
233
+ assertEquals(res.errors[0].path, "idempotencyKey");
234
+ assert(res.errors[0].message.includes("idempotencyKey"));
235
+ assertEquals(started.length, 0);
236
+ assertEquals(runs().length, 0); // nothing claimed
237
+ });
238
+
239
+ test("dispatchDeliveryGraphRun: the SAME secret-bearing graph WITH an explicit idempotencyKey launches", async () => {
240
+ const { app, started, runs } = makeApp();
241
+ const res = await dispatchDeliveryGraphRun(app, SECRET_BEARING, { runKey: "deploy-2024-06-a", repoless: true });
242
+ assertEquals(res.ok, true);
243
+ if (!res.ok) return;
244
+ assertEquals(res.status, "running");
245
+ assertEquals(res.runKey, "deploy-2024-06-a");
246
+ assertEquals(started.length, 1);
247
+ assertEquals(runs()[0].run_key, "deploy-2024-06-a");
248
+ });
249
+
250
+ test("dispatchDeliveryGraphRun: two credential-differing secret graphs with DISTINCT keys get distinct runs (no collision)", async () => {
251
+ const { app, started, runs } = makeApp();
252
+ const graphA = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:AAA@host.example/repo" } }] };
253
+ const graphB = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:BBB@host.example/repo" } }] };
254
+ const a = await dispatchDeliveryGraphRun(app, graphA, { runKey: "run-a", repoless: true });
255
+ const b = await dispatchDeliveryGraphRun(app, graphB, { runKey: "run-b", repoless: true });
256
+ assert(a.ok && b.ok);
257
+ assertEquals(started.length, 2);
258
+ assertEquals(new Set(runs().map((r) => r.run_key)).size, 2);
259
+ });
@@ -11,16 +11,19 @@
11
11
  // idempotency short-circuit, so a double-click or a re-dispatch never double-launches a graph's side
12
12
  // effects.
13
13
 
14
+ import { createHash } from "node:crypto";
14
15
  import type { AppApi } from "@nanobpm/urban";
15
16
  import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
16
- import { validateDeliveryGraph } from "./deliveryGraph.ts";
17
- import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
17
+ import { canonicalJson, validateDeliveryGraph } from "./deliveryGraph.ts";
18
+ import { compileDeliveryGraph, digestInvisibleRawValues, graphCarriesRedactedSecrets } from "./deliveryGraphCompiler.ts";
18
19
  import {
19
20
  buildDeliveryGraphRunRow,
20
21
  buildHumanLabels,
21
22
  claimRunForLaunch,
22
23
  computeRunKey,
23
24
  DELIVERY_PHASE,
25
+ type DeliveryGraphRunIdentity,
26
+ deliveryGraphRunIdentities,
24
27
  deliveryGraphRuns,
25
28
  } from "./deliveryGraphRun.ts";
26
29
  import type { DeliveryRunTimeouts } from "./deliveryRunner.ts";
@@ -37,11 +40,47 @@ export type DispatchDeliveryGraphResult =
37
40
  digest: string;
38
41
  sideEffecting: boolean;
39
42
  alreadyRunning: boolean;
43
+ /** Whether the returned run is PROVABLY this exact graph. Always true for a fresh launch. For an
44
+ * `alreadyRunning` short-circuit it is true iff the running row's persisted lossless
45
+ * {@link deliveryGraphIdentityFingerprint} matches the incoming graph's — i.e. a same-payload
46
+ * retry, not a credential-different graph re-staged under the same explicit `idempotencyKey` (and
47
+ * false for a pre-migration-113 row whose fingerprint is NULL, an unprovable identity). The
48
+ * dispatch door refuses a secret-bearing short-circuit only when this is false (issue #778 review
49
+ * — thread dispatchDeliveryGraph.ts:332). */
50
+ identityConfirmed: boolean;
40
51
  processInstanceKey?: string;
41
52
  processDefinitionId?: string;
42
53
  }
43
54
  | { ok: false; errors: { path: string; message: string }[] };
44
55
 
56
+ /** The LOSSLESS content-identity fingerprint of a graph — `sha256(digest \0
57
+ * canonicalJson(digestInvisibleRawValues(graph)))`. The `digest` is content-addressed over the REDACTED
58
+ * `semanticBpmn`, so it alone collapses secret-differing graphs onto one identity; folding in
59
+ * {@link digestInvisibleRawValues} (the exact raw content the digest CANNOT see) restores a FULL identity
60
+ * that distinguishes them — the SAME `(digest, invisible-values)` pair `stableProposalRunKey` keys the
61
+ * keyless run key on. Persisted on the run row so an explicit-`idempotencyKey` short-circuit can prove
62
+ * the running run is THIS exact graph (issue #778 review — thread dispatchDeliveryGraph.ts:332). */
63
+ export function deliveryGraphIdentityFingerprint(graph: DeliveryGraph, digest: string): string {
64
+ const invisible = canonicalJson(digestInvisibleRawValues(graph));
65
+ return createHash("sha256").update(`${digest}\u0000${invisible}`).digest("hex");
66
+ }
67
+
68
+ /** Persist a run's lossless identity fingerprint (upsert on `run_key`). A relaunch off a persisted row
69
+ * under an explicit `idempotencyKey` may carry a DIFFERENT graph, so a PRIMARY-KEY fence collision folds
70
+ * into an UPDATE that overwrites the stored fingerprint rather than surfacing — the same intended
71
+ * outcome as the insert branch (mirrors `DurableResumeRegistry.recordEnrolment`). */
72
+ async function upsertRunIdentity(
73
+ identities: ReturnType<typeof deliveryGraphRunIdentities>,
74
+ row: DeliveryGraphRunIdentity,
75
+ ): Promise<void> {
76
+ try {
77
+ await identities.insert(row);
78
+ } catch (err) {
79
+ if (!(err instanceof Error) || !/UNIQUE constraint failed/i.test(err.message)) throw err;
80
+ await identities.update(row.run_key, { graph_fingerprint: row.graph_fingerprint });
81
+ }
82
+ }
83
+
45
84
  /** Dispatch a delivery graph as a running engine-native process — the operator action. Re-validates
46
85
  * and re-compiles the (already-staged) graph to derive its content digest + run-row shape, then
47
86
  * launches it through the durable at-most-once fence. Idempotent: a re-dispatch of an already-running
@@ -49,7 +88,7 @@ export type DispatchDeliveryGraphResult =
49
88
  export async function dispatchDeliveryGraphRun(
50
89
  app: Pick<AppApi, "data" | "engine" | "log">,
51
90
  graph: unknown,
52
- options: { runKey?: string | null; title?: string | null; repository?: string | null; baseBranch?: string | null; repoless?: boolean } & DeliveryRunTimeouts = {},
91
+ options: { runKey?: string | null; title?: string | null; repository?: string | null; baseBranch?: string | null; repoless?: boolean; expectedDigest?: string | null } & DeliveryRunTimeouts = {},
53
92
  ): Promise<DispatchDeliveryGraphResult> {
54
93
  const validationErrors = validateDeliveryGraph(graph);
55
94
  if (validationErrors.length > 0) {
@@ -64,12 +103,82 @@ export async function dispatchDeliveryGraphRun(
64
103
  }
65
104
 
66
105
  const digest = deliveryGraphDigest(compiled.semanticBpmn);
106
+ // ADDRESS-BEFORE-LAUNCH (issue #778 review — thread deliveryGraphCompiler.ts:1605): a staged proposal
107
+ // is keyed by the digest computed AT STAGE TIME. The labels/`<bpmn:documentation>` this PR adds are
108
+ // covered by that content digest, so after this compiler ships every proposal staged by the PREVIOUS
109
+ // compiler RECOMPILES to a different digest. The caller (the dispatch door) hands us that stage-time
110
+ // digest as `expectedDigest`; if the fresh recompile no longer matches it, the stored graph pre-dates
111
+ // this compiler and its content address has drifted. REFUSE here — BEFORE the durable launch claim —
112
+ // so we never launch the run and THEN report a post-launch 409 mismatch, which stranded a live run
113
+ // against a proposal left `staged`. A clean refusal leaves nothing running; the operator recompiles to
114
+ // re-stage under the current compiler. (A normal, same-compiler dispatch recompiles deterministically
115
+ // to the same digest, so this is a no-op there.)
116
+ const expectedDigest = typeof options.expectedDigest === "string" && options.expectedDigest.trim() !== "" ? options.expectedDigest.trim() : "";
117
+ if (expectedDigest !== "" && digest !== expectedDigest) {
118
+ app.log.warn("dispatch-delivery-graph refused: recompiled digest drifted from the staged address", { expectedDigest, digest });
119
+ return {
120
+ ok: false,
121
+ errors: [
122
+ {
123
+ path: "digest",
124
+ message:
125
+ `this staged proposal was compiled under a different compiler version — it now recompiles to digest ${digest}, ` +
126
+ `not the staged ${expectedDigest} its content address was pinned to, so it was NOT launched; recompile the graph to ` +
127
+ "re-stage it under the current compiler before dispatching (issue #778)",
128
+ },
129
+ ],
130
+ };
131
+ }
132
+ const explicitKey = typeof options.runKey === "string" && options.runKey.trim() !== "";
133
+ // Option C (issue #778): the graph's identity is content-addressed over the REDACTED `semanticBpmn`
134
+ // (issue #716), so two graphs differing ONLY in a redacted-away credential (a URL secret/`?query`/
135
+ // `#fragment`, a `command` target, a `verifyCommand`/`bodyIncludes`/`stdoutIncludes` match secret, or
136
+ // a free-form connector `payload`) share one digest — and a KEYLESS dispatch defaults `runKey` to that
137
+ // digest, silently collapsing the second onto the first's still-running instance and reusing its
138
+ // config. We accept the collision as by-design but REQUIRE an explicit `idempotencyKey` to
139
+ // disambiguate such a secret-bearing graph, rather than launch it under an ambiguous identity.
140
+ if (!explicitKey && graphCarriesRedactedSecrets(typedGraph)) {
141
+ app.log.warn("dispatch-delivery-graph refused: secret-bearing graph needs an explicit idempotencyKey", { digest });
142
+ return {
143
+ ok: false,
144
+ errors: [
145
+ {
146
+ path: "idempotencyKey",
147
+ message:
148
+ "this graph carries runtime values that redaction/normalisation strips from its content-addressed " +
149
+ "digest — a URL credential/`?query`/`#fragment`, a `command` target, a `verifyCommand`/`bodyIncludes`/" +
150
+ "`stdoutIncludes` match secret, a free-form connector `payload`, an untrimmed-whitespace difference in " +
151
+ "an agent/human `prompt`, or a value that loses characters to XML sanitisation — so its digest cannot " +
152
+ "distinguish it from another graph differing only in those values; supply an explicit `idempotencyKey` " +
153
+ "to dispatch it (issue #778)",
154
+ },
155
+ ],
156
+ };
157
+ }
67
158
  const runKey = computeRunKey(options.runKey, digest);
68
159
  const sideEffecting = compiled.sideEffects.length > 0;
69
160
  const explicitTitle = typeof options.title === "string" && options.title.trim() !== "" ? options.title.trim() : "";
70
161
  const graphName = typeof typedGraph.name === "string" && typedGraph.name.trim() !== "" ? typedGraph.name.trim() : "";
71
162
  const title = explicitTitle || graphName || runKey;
72
163
  const runs = deliveryGraphRuns(app.data);
164
+ const identities = deliveryGraphRunIdentities(app.data);
165
+ // The lossless identity of THIS graph — persisted at launch in the `delivery_graph_run_identity` side
166
+ // table, compared on a short-circuit so the door can distinguish a same-payload retry from a
167
+ // credential-different graph re-staged under the same explicit key (issue #778 review — thread
168
+ // dispatchDeliveryGraph.ts:332).
169
+ const graphFingerprint = deliveryGraphIdentityFingerprint(typedGraph, digest);
170
+ // Whether the run at `runKey` is PROVABLY this exact graph: a stored identity row that MATCHES ours. A
171
+ // MISSING row (a run launched before this table existed, or a not-yet-stamped concurrent claim) is
172
+ // unprovable → false, so the door refuses a secret-bearing short-circuit (the safe pre-existing 409).
173
+ const identityConfirmed = async (): Promise<boolean> => {
174
+ const identity = await identities.get(runKey);
175
+ // `table.get()` resolves to `null` (NOT `undefined`) for a MISSING row — a run launched before this
176
+ // side table existed, or a not-yet-stamped concurrent claim. A bare `!== undefined` admits that
177
+ // `null` and then throws on `.graph_fingerprint`; `!= null` treats both absences as unprovable →
178
+ // false, so the door reaches the safe pre-existing 409 instead of an incident (issue #778 review —
179
+ // thread deliveryGraphDispatch.ts:175).
180
+ return identity != null && identity.graph_fingerprint === graphFingerprint;
181
+ };
73
182
 
74
183
  // Idempotency short-circuit — a re-dispatch onto a still-running run does NOT double-launch.
75
184
  const existing = await runs.get(runKey);
@@ -82,6 +191,7 @@ export async function dispatchDeliveryGraphRun(
82
191
  digest: existing.digest,
83
192
  sideEffecting: existing.side_effecting === 1,
84
193
  alreadyRunning: true,
194
+ identityConfirmed: await identityConfirmed(),
85
195
  processInstanceKey: existing.process_key ?? undefined,
86
196
  processDefinitionId: existing.process_definition_id ?? undefined,
87
197
  };
@@ -113,22 +223,39 @@ export async function dispatchDeliveryGraphRun(
113
223
  digest: won?.digest ?? digest,
114
224
  sideEffecting: won ? won.side_effecting === 1 : sideEffecting,
115
225
  alreadyRunning: true,
226
+ identityConfirmed: await identityConfirmed(),
116
227
  processInstanceKey: won?.process_key ?? undefined,
117
228
  processDefinitionId: won?.process_definition_id ?? undefined,
118
229
  };
119
230
  }
120
- if (existing) {
121
- const { run_key, created_at, ...patch } = claim;
122
- await runs.update(runKey, patch);
123
- }
124
-
125
- // Launch — deploy + start the compiled definition. On ANY launch failure flip the claimed row to
126
- // `failed` so no null-process_key `running` row is ever stranded.
231
+ // On ANY post-claim failure — the identity/patch persistence just below OR the launch further down —
232
+ // flip the claimed row to `failed` so no null-process_key `running` row is ever stranded and later
233
+ // dispatches never short-circuit onto a phantom run.
127
234
  const markClaimFailed = async () => {
128
235
  const failed = buildDeliveryGraphRunRow({ ...rowBase, status: "failed", phase: DELIVERY_PHASE.FAILED, processKey: null });
129
236
  const { run_key, created_at, ...patch } = failed;
130
237
  await runs.update(runKey, patch);
131
238
  };
239
+ // The claim is ours — persist THIS run's lossless identity so a later same-key short-circuit can prove
240
+ // the running run is this exact graph. Upsert: a relaunch off a persisted (e.g. failed) row under an
241
+ // explicit key may carry a different graph, so overwrite the stored fingerprint. This runs AFTER the
242
+ // durable `running` claim but is a SEPARATE write, so a transient failure here would otherwise strand
243
+ // the claimed row as `running` with a null process key — later same-key dispatches would then
244
+ // short-circuit onto that phantom run. Route the failure through the same claim-failure cleanup before
245
+ // rethrowing so the phantom self-heals (issue #778 review — thread deliveryGraphDispatch.ts:229).
246
+ try {
247
+ await upsertRunIdentity(identities, { run_key: runKey, graph_fingerprint: graphFingerprint, created_at: claim.created_at });
248
+ if (existing) {
249
+ const { run_key, created_at, ...patch } = claim;
250
+ await runs.update(runKey, patch);
251
+ }
252
+ } catch (err) {
253
+ await markClaimFailed();
254
+ app.log.error("dispatch-delivery-graph identity persistence threw", { runKey });
255
+ throw err;
256
+ }
257
+
258
+ // Launch — deploy + start the compiled definition.
132
259
  let launched: Awaited<ReturnType<typeof runDeliveryGraph>>;
133
260
  try {
134
261
  // Thread the operator-supplied run-level timeouts (#505) so a submission override reaches every
@@ -180,6 +307,7 @@ export async function dispatchDeliveryGraphRun(
180
307
  digest,
181
308
  sideEffecting,
182
309
  alreadyRunning: false,
310
+ identityConfirmed: true,
183
311
  processInstanceKey: launched.handle.processInstanceKey,
184
312
  processDefinitionId: launched.handle.processDefinitionId,
185
313
  };
@@ -17,6 +17,7 @@ import {
17
17
  getStagedProposal,
18
18
  isLiveStaged,
19
19
  isProposalExpired,
20
+ listStagedProposals,
20
21
  markProposalDismissed,
21
22
  markProposalDispatched,
22
23
  markProposalExpired,
@@ -157,6 +158,35 @@ test("stageProposal: a re-stage of an EXPIRED digest RE-ANCHORS the TTL so it is
157
158
  });
158
159
  });
159
160
 
161
+ test("stageProposal: a re-stage derives the TTL from the row COMMITTED at write time, so a stale caller cannot clobber a concurrently re-anchored live proposal back to an expired horizon (issue #778 review — thread :247)", async () => {
162
+ await withData(async (data) => {
163
+ const table = deliveryGraphProposals(data);
164
+ // Model the committed state a CONCURRENT re-stage left behind: digest d1 is LIVE with a FRESH TTL
165
+ // (`expires_at` far in the future) but its `created_at` anchor is old — exactly the row a rival stage
166
+ // produced when it re-anchored the TTL. The stale caller below prepared its write from an EARLIER read
167
+ // and now commits LAST (grabbing the newest `stage_seq`). The pre-transaction snapshot bug recomputed
168
+ // `expires_at` from the stale `created_at` (`created_at + TTL`, long past), so the last writer resurrected
169
+ // an EXPIRED horizon over the fresh one and left the newest-`stage_seq` row non-dispatchable. The TTL must
170
+ // instead be derived from the row as committed AT WRITE TIME, inside the same atomic fence as `stage_seq`.
171
+ const seed = row({ digest: "d1", createdAt: "2020-01-01T00:00:00.000Z" });
172
+ seed.expires_at = "2999-01-01T00:00:00.000Z"; // fresh TTL a concurrent re-anchor committed
173
+ seed.stage_seq = 1;
174
+ await table.insert(seed);
175
+
176
+ await stageProposal(data, row({ digest: "d1", createdAt: "2020-01-01T00:00:00.000Z" }));
177
+
178
+ const after = await table.get("d1");
179
+ assert(after, "the re-staged row must still exist");
180
+ assert(
181
+ !isProposalExpired(after?.expires_at),
182
+ "a re-stage must not clobber a live proposal's fresh TTL with an expired horizon recomputed from a stale created_at snapshot",
183
+ );
184
+ const live = await getStagedProposal(data, "d1");
185
+ assert(live, "the re-staged live proposal remains dispatchable");
186
+ assertEquals(live?.status, "staged");
187
+ });
188
+ });
189
+
160
190
  test("stageProposal: a new digest for the SAME logical key supersedes the prior staged proposal", async () => {
161
191
  await withData(async (data) => {
162
192
  await stageProposal(data, row({ digest: "d1" }));
@@ -241,6 +271,60 @@ test("stageProposal: reconciles to EXACTLY ONE live proposal — an older stage
241
271
  });
242
272
  });
243
273
 
274
+ test("stageProposal: a RE-STAGE wins a same-millisecond `updated_at` tie over an untouched sibling — the monotonic `stage_seq` reflects the re-stage that a frozen `rowid` does not (issue #778 review — thread :245)", async (t) => {
275
+ await withData(async (data) => {
276
+ const table = deliveryGraphProposals(data);
277
+ // Freeze wall-clock time so every `now()` inside `stageProposal` stamps the SAME `updated_at`,
278
+ // reproducing the same-millisecond concurrent-stage race deterministically (only the `Date` API is
279
+ // mocked, so async DB I/O on real timers is unaffected).
280
+ t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2025-06-01T00:00:00.000Z") });
281
+ // Two staged siblings under logical_key "runbook", both stamped the frozen instant. d2 is staged
282
+ // AFTER d1, so it is (correctly) the live winner and d1 is superseded — `rowid` and `stage_seq` agree
283
+ // so far (insertion order == stage order).
284
+ await stageProposal(data, row({ digest: "d1" }));
285
+ await stageProposal(data, row({ digest: "d2" }));
286
+ assertEquals((await table.get("d1"))?.status, "superseded");
287
+ assertEquals((await table.get("d2"))?.status, "staged");
288
+ // Now RE-STAGE d1 (an UPDATE) at the SAME frozen instant. This is the newest write, so it MUST become
289
+ // the sole live proposal. But a re-stage is an `UPDATE`: d1's `rowid` stays frozen BELOW d2's, so the
290
+ // old `rowid` tie-break wrongly keeps d2 live and leaves the just-re-staged d1 superseded. The
291
+ // monotonic `stage_seq` (reassigned MAX+1 on every write, including this re-stage) advances past d2,
292
+ // so the reconcile correctly makes d1 live and supersedes d2.
293
+ await stageProposal(data, row({ digest: "d1" }));
294
+ assertEquals(
295
+ (await table.get("d1"))?.status,
296
+ "staged",
297
+ "the just-re-staged digest must be the live proposal, even against an untouched higher-rowid sibling",
298
+ );
299
+ assertEquals((await table.get("d2"))?.status, "superseded", "the untouched older sibling is superseded by the re-stage");
300
+ const live = (await table.all()).filter((r) => r.status === "staged" && r.logical_key === "runbook");
301
+ assertEquals(live.length, 1, "exactly one live proposal per logical graph after the re-stage");
302
+ assertEquals(live[0]?.digest, "d1", "the re-staged digest wins the same-`updated_at` tie");
303
+ });
304
+ });
305
+
306
+ test("stageProposal: assigns `stage_seq` ATOMICALLY in the write — a fresh stage is strictly > 0 and monotonic, so there is never a DEFAULT-0 window a concurrent reconcile could mis-order (issue #778 review — thread :237/:239)", async () => {
307
+ await withData(async (data) => {
308
+ const seqOf = async (digest: string): Promise<number> => {
309
+ const rows = await data.open().query<{ stage_seq: number }>(
310
+ `SELECT "stage_seq" FROM "delivery_graph_proposals" WHERE "digest" = ?`,
311
+ [digest],
312
+ );
313
+ return rows.length ? Number(rows[0].stage_seq) : -1;
314
+ };
315
+ // A first stage: the seq is assigned in the INSERT itself (MAX+1), never left at the column DEFAULT 0.
316
+ await stageProposal(data, row({ digest: "d1", logicalKey: "k-a" }));
317
+ const s1 = await seqOf("d1");
318
+ assert(s1 > 0, `a fresh stage carries a strictly-positive stage_seq (atomic, no 0 window): ${s1}`);
319
+ // A second, independent stage is strictly greater — the monotonic secondary ordering.
320
+ await stageProposal(data, row({ digest: "d2", logicalKey: "k-b" }));
321
+ assert((await seqOf("d2")) > s1, "a later stage's seq is strictly greater");
322
+ // A RE-STAGE (UPDATE) also advances the seq — it is reassigned on every write, not frozen like rowid.
323
+ await stageProposal(data, row({ digest: "d1", logicalKey: "k-a" }));
324
+ assert((await seqOf("d1")) > (await seqOf("d2")), "a re-stage advances stage_seq past every prior write");
325
+ });
326
+ });
327
+
244
328
  test("stageProposal outcome: `row` reflects the POST-reconcile status — a stage immediately superseded by a newer sibling reports its own row as `superseded`, not the pre-reconcile `staged`", async () => {
245
329
  await withData(async (data) => {
246
330
  const table = deliveryGraphProposals(data);
@@ -280,6 +364,88 @@ test("markProposalDispatched: a dispatched proposal is no longer live", async ()
280
364
  });
281
365
  });
282
366
 
367
+ test("markProposalDispatched: flipping a live `staged` row returns true; an already-terminal (dispatched) row returns false (guarded, mirrors markProposalDismissed/Expired)", async () => {
368
+ await withData(async (data) => {
369
+ await stageProposal(data, row());
370
+ assertEquals(await markProposalDispatched(data, "d1"), true);
371
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dispatched");
372
+ // A second consume of the same digest (already dispatched) must no-op and report the lost race.
373
+ assertEquals(await markProposalDispatched(data, "d1"), false);
374
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dispatched");
375
+ });
376
+ });
377
+
378
+ test("markProposalDispatched: a concurrent RE-STAGE (bumping stage_seq) after the dispatch snapshot makes the seq-guarded mark a no-op — the newer, never-launched graph stays `staged` (#778 review)", async () => {
379
+ await withData(async (data) => {
380
+ // Dispatch snapshots the proposal's stage_seq when it loads the row.
381
+ await stageProposal(data, row());
382
+ const snapshot = await getStagedProposal(data, "d1");
383
+ assert(snapshot, "the freshly staged proposal is live");
384
+ const snapSeq = snapshot.stage_seq;
385
+ // A concurrent re-stage overwrites the SAME digest in place with a newer graph (two credential-
386
+ // differing graphs share a semantic digest), bumping stage_seq past the dispatch's snapshot.
387
+ await stageProposal(data, row());
388
+ const reStaged = await deliveryGraphProposals(data).get("d1");
389
+ assert(reStaged && reStaged.stage_seq > snapSeq, "the re-stage bumped stage_seq past the snapshot");
390
+ // The dispatch marks with the STALE snapshot seq → the `stage_seq` guard fails → no row flips, so the
391
+ // newer graph is NOT retired to `dispatched`; it stays `staged` for its own dispatch.
392
+ assertEquals(await markProposalDispatched(data, "d1", snapSeq), false);
393
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "staged");
394
+ // Marking with the CURRENT seq consumes it as normal.
395
+ assertEquals(await markProposalDispatched(data, "d1", reStaged.stage_seq), true);
396
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dispatched");
397
+ });
398
+ });
399
+
400
+ test("stageProposal: two concurrent FIRST stages of the SAME digest do NOT primary-key-conflict — the raced insert collapses into an idempotent UPSERT (#778 review — thread :245)", async () => {
401
+ await withData(async (data) => {
402
+ // Reproduce the race deterministically: our stage reads `existing = null` (no row yet), then — in the
403
+ // window BEFORE our transaction commits — a COMPETING first-stage of the same digest lands its row.
404
+ // The old read-then-branch INSERT would make our write hit a UNIQUE(digest) conflict and THROW,
405
+ // dropping our reconcile; the `ON CONFLICT("digest") DO UPDATE` upsert must instead collapse it.
406
+ let injected = false;
407
+ const racyData = new Proxy(data, {
408
+ get(target, prop) {
409
+ if (prop === "open") {
410
+ return () => {
411
+ const src = target.open();
412
+ return new Proxy(src, {
413
+ get(s, p) {
414
+ if (p === "tx") {
415
+ return async (fn: (t: unknown) => Promise<void>) => {
416
+ if (!injected) {
417
+ injected = true;
418
+ // A competing first-stage commits its row before ours does (real data layer).
419
+ await stageProposal(data, row());
420
+ }
421
+ // @ts-expect-error the wrapped tx forwards the original callback unchanged
422
+ return s.tx(fn);
423
+ };
424
+ }
425
+ const v = Reflect.get(s, p, s);
426
+ return typeof v === "function" ? v.bind(s) : v;
427
+ },
428
+ });
429
+ };
430
+ }
431
+ const v = Reflect.get(target, prop, target);
432
+ return typeof v === "function" ? v.bind(target) : v;
433
+ },
434
+ });
435
+
436
+ // Must NOT throw despite our `existing = null` read racing the competitor's committed insert.
437
+ await stageProposal(racyData as DataLayer, row());
438
+ assert(injected, "the competing first-stage should have fired");
439
+ const finalRow = await deliveryGraphProposals(data).get("d1");
440
+ assert(finalRow, "exactly one row exists for the digest");
441
+ assertEquals(finalRow.status, "staged");
442
+ // The upsert collapsed onto the competitor's row (an UPDATE, not a second INSERT), so stage_seq
443
+ // advanced past the competitor's — proving no PK conflict and a single reconciled row.
444
+ assert(finalRow.stage_seq >= 2, `the raced insert collapsed into an upsert (stage_seq advanced): ${finalRow.stage_seq}`);
445
+ assertEquals((await listStagedProposals(data)).length, 1);
446
+ });
447
+ });
448
+
283
449
  test("sweepExpiredProposals: flips aged-out staged proposals to `expired` so they drop out of the cockpit grid", async () => {
284
450
  await withData(async (data) => {
285
451
  // Two staged proposals with different logical keys so neither supersedes the other.
@@ -351,6 +517,59 @@ test("sweepExpiredProposals: a dispatch racing between the read and the write is
351
517
  });
352
518
  });
353
519
 
520
+ test("sweepExpiredProposals: a re-stage racing between the read and the write is NOT expired — the `stage_seq` guard no-ops the stale flip so the fresh revision survives (#778 review — thread deliveryGraphProposals.ts:417)", async () => {
521
+ await withData(async (data) => {
522
+ const seqOf = async (digest: string): Promise<number> => {
523
+ const rows = await data.open().query<{ stage_seq: number }>(`SELECT "stage_seq" FROM "delivery_graph_proposals" WHERE "digest" = ?`, [digest]);
524
+ return rows.length ? Number(rows[0].stage_seq) : -1;
525
+ };
526
+ // One aged-out staged proposal — the sweep's `find()` will see it as `staged` at revision seq0.
527
+ await stageProposal(data, row({ digest: "d1", createdAt: "2024-01-01T00:00:00.000Z" }));
528
+ const seq0 = await seqOf("d1");
529
+ const at = new Date(Date.parse("2024-01-01T00:00:00.000Z") + DELIVERY_PROPOSAL_TTL_MS + 1000);
530
+
531
+ // Wrap the data layer so that, in the window between the sweep's `find()` and its per-row guarded
532
+ // `exec`, a concurrent RE-STAGE overwrites d1 in place with a fresh, future-TTL revision (bumping
533
+ // stage_seq past seq0). A blind `WHERE digest=? AND status='staged'` flip would expire that never-swept
534
+ // fresh revision; the `stage_seq`-guarded UPDATE must instead no-op and leave the row live `staged`.
535
+ let raced = false;
536
+ const racyData = new Proxy(data, {
537
+ get(target, prop, receiver) {
538
+ if (prop === "open") {
539
+ return () => {
540
+ const src = target.open();
541
+ return new Proxy(src, {
542
+ get(s, p) {
543
+ if (p === "exec") {
544
+ return async (sql: string, params?: unknown[]) => {
545
+ if (!raced) {
546
+ raced = true;
547
+ await stageProposal(data, row({ digest: "d1", createdAt: "2030-01-01T00:00:00.000Z" }));
548
+ }
549
+ return s.exec(sql, params);
550
+ };
551
+ }
552
+ const v = Reflect.get(s, p, s);
553
+ return typeof v === "function" ? v.bind(s) : v;
554
+ },
555
+ });
556
+ };
557
+ }
558
+ const v = Reflect.get(target, prop, target);
559
+ return typeof v === "function" ? v.bind(target) : v;
560
+ },
561
+ });
562
+
563
+ const swept = await sweepExpiredProposals(racyData as DataLayer, at);
564
+ assert(raced, "the racing re-stage should have fired");
565
+ assertEquals(swept, 0);
566
+ assert((await seqOf("d1")) > seq0, "the injected re-stage bumped stage_seq past the sweep's snapshot");
567
+ const d1 = await deliveryGraphProposals(data).get("d1");
568
+ assertEquals(d1?.status, "staged");
569
+ assert(!isProposalExpired(d1?.expires_at ?? ""), "the fresh revision retains its future TTL — it was not expired by the stale sweep");
570
+ });
571
+ });
572
+
354
573
  test("markProposalDismissed: flipping a live `staged` row returns true and lands it `dismissed`", async () => {
355
574
  await withData(async (data) => {
356
575
  await stageProposal(data, row());
@@ -484,3 +703,27 @@ test("markProposalExpired: a dismiss racing between the dispatch door's liveness
484
703
  assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dismissed");
485
704
  });
486
705
  });
706
+
707
+ test("markProposalExpired: an `expectedStageSeq` guard NO-OPs when a concurrent re-stage bumped the revision (corrupt-graph retirement does not retire a newer graph)", async () => {
708
+ await withData(async (data) => {
709
+ const seqOf = async (digest: string): Promise<number> => {
710
+ const rows = await data.open().query<{ stage_seq: number }>(`SELECT "stage_seq" FROM "delivery_graph_proposals" WHERE "digest" = ?`, [digest]);
711
+ return rows.length ? Number(rows[0].stage_seq) : -1;
712
+ };
713
+ // The dispatch door reads a `staged` proposal at revision `seq0` (its graph is corrupt).
714
+ await stageProposal(data, row());
715
+ const seq0 = await seqOf("d1");
716
+ // Before the corrupt-graph retirement fires, a concurrent re-stage overwrites the SAME digest with a
717
+ // fresh (valid) graph, bumping `stage_seq`.
718
+ await stageProposal(data, row());
719
+ const seq1 = await seqOf("d1");
720
+ assert(seq1 > seq0, "the re-stage bumped stage_seq");
721
+ // The retirement guarded on the STALE revision must no-op (return false) and leave the newer graph staged.
722
+ const flipped = await markProposalExpired(data, "d1", seq0);
723
+ assertEquals(flipped, false);
724
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "staged");
725
+ // Guarded on the CURRENT revision, it retires as usual.
726
+ assertEquals(await markProposalExpired(data, "d1", seq1), true);
727
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "expired");
728
+ });
729
+ });