@nanobpm/nano-workforce 0.190.0 → 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.
@@ -62,6 +62,15 @@ export interface DeliveryGraphProposal {
62
62
  created_at: string;
63
63
  updated_at: string;
64
64
  expires_at: string;
65
+ /** A strictly-monotonic per-write stage revision (migration 114), reassigned to `MAX(stage_seq)+1`
66
+ * on every stage write (insert AND re-stage). It is the supersede reconcile's same-millisecond
67
+ * tie-break, AND the version token the dispatch door threads through so `markProposalDispatched`
68
+ * can guard its terminal flip against a concurrent re-stage that overwrote this same-digest row with
69
+ * a newer (e.g. credential-differing) graph after this dispatch snapshotted it (issue #778 review —
70
+ * thread deliveryGraphProposals.ts:243). Persisted rows carry a value ≥ 1; a pre-migration row reads
71
+ * `0` until its next stage re-sequences it. Assigned atomically in the `stageProposal` SQL, never
72
+ * from a row object — a freshly-`buildProposalRow`'d row's `0` here is a never-persisted placeholder. */
73
+ stage_seq: number;
65
74
  }
66
75
 
67
76
  /** The rendered preview a staged proposal carries — the operator-facing view of WHAT the graph does
@@ -150,6 +159,9 @@ export function buildProposalRow(input: {
150
159
  created_at: createdAt,
151
160
  updated_at: at,
152
161
  expires_at: proposalExpiry(createdAt),
162
+ // Placeholder only: the persisted `stage_seq` is assigned atomically as `MAX(stage_seq)+1` inside
163
+ // the `stageProposal` INSERT/UPSERT (never bound from this object), so this `0` is never written.
164
+ stage_seq: 0,
153
165
  };
154
166
  }
155
167
 
@@ -190,55 +202,77 @@ export interface StageOutcome {
190
202
  * retire. */
191
203
  export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal): Promise<StageOutcome> {
192
204
  const table = deliveryGraphProposals(data);
193
- const existing = await table.get(row.digest);
194
205
  // Capture the same-logical-key staged siblings that exist BEFORE this stage writes (excluding our own
195
206
  // digest) — the supersede reconcile below normally flips them all to `superseded`; we re-read them
196
207
  // after to report exactly which digests this stage retired.
197
208
  const priorSameKey = (await table.find({ status: "staged" })).filter(
198
209
  (r) => r.logical_key === row.logical_key && r.digest !== row.digest && isLiveStaged(r),
199
210
  );
200
- const toWrite = existing
201
- ? buildProposalRow({
202
- digest: row.digest,
203
- logicalKey: row.logical_key,
204
- title: row.title,
205
- graphJson: row.graph,
206
- preview: JSON.parse(row.preview),
207
- nodeCount: row.node_count,
208
- humanNodeCount: row.human_node_count,
209
- sideEffectCount: row.side_effect_count,
210
- sideEffecting: row.side_effecting === 1,
211
- // Re-stage: if the existing row is STILL LIVE, preserve its original stage time so the TTL
212
- // stays anchored to the first stage. But if it has already aged out of its TTL (or was
213
- // dispatched/superseded long ago), reusing the stale `created_at` would yield a past
214
- // `expires_at`, leaving the "re-staged" row immediately non-dispatchable (`getStagedProposal`
215
- // rejects it as expired) while the preview claims it is staged. In that case re-anchor the TTL
216
- // to now (omit `createdAt`) so a re-proposed digest is genuinely live again.
217
- createdAt: isProposalExpired(existing.expires_at) ? undefined : existing.created_at,
218
- })
219
- : row;
220
- if (existing) {
221
- const { digest, ...patch } = toWrite;
222
- await table.update(row.digest, patch);
223
- } else {
224
- await table.insert(toWrite);
225
- }
226
- // Reconcile to EXACTLY ONE live proposal per logical graph: supersede every `staged` row for this
227
- // `logical_key` that has a strictly-NEWER staged sibling (by `updated_at`, with a deterministic
228
- // `digest` tie-breaker), leaving only the globally-newest live. This is ORDER-INDEPENDENT — it never
229
- // references the just-written digest, so it converges to a single live row regardless of the
230
- // interleaving of concurrent stages. A supersede pass keyed to "only flip rows older than the one *I*
231
- // just wrote" leaves TWO live proposals when an OLDER stage's pass runs AFTER a newer stage already
232
- // committed (the older pass won't flip the newer row, and the newer pass ran before the older row
233
- // existed) — and an unordered supersede-all leaves ZERO. Anchoring on the newest staged sibling avoids
234
- // both: the newest row is never superseded (no newer sibling), so it stays staged throughout the
235
- // statement and every older row's `EXISTS` is satisfied by it. Idempotent: a no-op once one row remains.
236
- await data
237
- .open()
238
- .exec(
239
- `UPDATE "delivery_graph_proposals" SET "status" = 'superseded', "updated_at" = ? WHERE "logical_key" = ? AND "status" = 'staged' AND EXISTS (SELECT 1 FROM "delivery_graph_proposals" AS "newer" WHERE "newer"."logical_key" = "delivery_graph_proposals"."logical_key" AND "newer"."status" = 'staged' AND ("newer"."updated_at" > "delivery_graph_proposals"."updated_at" OR ("newer"."updated_at" = "delivery_graph_proposals"."updated_at" AND "newer"."digest" > "delivery_graph_proposals"."digest")))`,
211
+ // Persist the write, its strictly-monotonic stage sequence, AND the supersede reconcile as ONE
212
+ // transaction (issue #778 review — threads deliveryGraphProposals.ts:237/:239). Two properties matter:
213
+ // • `stage_seq` is assigned ATOMICALLY inside the write itself — `MAX(stage_seq)+1` in the
214
+ // INSERT/UPDATE, NOT a separate stamp statement — so there is never a "written but unstamped
215
+ // (seq 0)" window in which a rival same-millisecond stage's reconcile could read the half-written
216
+ // sibling at its DEFAULT 0 and supersede it out of order (the inversion the old two-statement
217
+ // stamp+reconcile allowed). Assigning inside the serialized transaction keeps `MAX(...)` atomic, so
218
+ // `+1` is strictly greater than every committed seq and reflects true write order.
219
+ // • wrapping the reconcile in the SAME transaction means no concurrent stage observes an intermediate
220
+ // state between the write and the reconcile.
221
+ // `MAX(stage_seq)` reads the pre-write table via the `stage_seq` index (migration 114), an O(1)
222
+ // reverse-index seek rather than the full-history scan a bare aggregate over the retained terminal rows
223
+ // would cost.
224
+ const at = now();
225
+ const atExpiry = proposalExpiry(at);
226
+ const nextSeq = `(SELECT COALESCE(MAX("stage_seq"), 0) + 1 FROM "delivery_graph_proposals")`;
227
+ await data.open().tx(async (t) => {
228
+ // Single idempotent UPSERT rather than a read-`existing`-then-branch INSERT/UPDATE: reading the row
229
+ // BEFORE the transaction meant two concurrent FIRST stages of the same digest both saw `null` and
230
+ // would both take an INSERT — the second hitting a primary-key conflict that fails the stage and drops
231
+ // its reconciliation. `ON CONFLICT("digest") DO UPDATE` collapses the raced insert into an in-place
232
+ // update instead, so a same-digest stage is idempotent regardless of arrival order (issue #778 review —
233
+ // thread deliveryGraphProposals.ts:245). `stage_seq` is still assigned ATOMICALLY inside the write
234
+ // (`MAX(stage_seq)+1`, materialised once as `excluded."stage_seq"` so the insert and the on-conflict
235
+ // update use the exact same value), preserving the strictly-monotonic ordering the supersede reconcile
236
+ // below tie-breaks on.
237
+ //
238
+ // The TTL anchor (`created_at`) and horizon (`expires_at`) are ALSO derived inside this atomic write,
239
+ // from the row as it exists AT WRITE TIME — NOT from a pre-transaction `table.get` snapshot (issue #778
240
+ // review — thread deliveryGraphProposals.ts:247). A pre-read snapshot is a TOCTOU: a concurrent re-stage
241
+ // can commit a fresh TTL between the read and this write, and the stale caller — committing LAST, so it
242
+ // wins the newest `stage_seq` — would overwrite that fresh horizon with the one it computed from the OLD
243
+ // `created_at`, restoring an already-expired `expires_at` on the newest row and leaving the proposal
244
+ // non-dispatchable. On a fresh INSERT the caller's `created_at`/`expires_at` stand (first stage anchors
245
+ // the TTL). On a CONFLICT the `CASE` re-reads the CURRENT committed row: if it is still LIVE
246
+ // (`expires_at` in the future) the original anchor and horizon are PRESERVED (an idempotent re-stage
247
+ // keeps the TTL pinned to the first stage); once it has aged out (past, blank, or NULL `expires_at`,
248
+ // matching `isProposalExpired`'s fail-closed rule) the TTL is RE-ANCHORED to now so a re-proposed digest
249
+ // is genuinely live again. All timestamps are ISO-8601 UTC, so the lexical `>` comparison is a valid
250
+ // chronological test.
251
+ await t.exec(
252
+ `INSERT INTO "delivery_graph_proposals" ("digest", "logical_key", "title", "graph", "preview", "node_count", "human_node_count", "side_effect_count", "side_effecting", "status", "created_at", "updated_at", "expires_at", "stage_seq") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${nextSeq}) ON CONFLICT("digest") DO UPDATE SET "logical_key" = excluded."logical_key", "title" = excluded."title", "graph" = excluded."graph", "preview" = excluded."preview", "node_count" = excluded."node_count", "human_node_count" = excluded."human_node_count", "side_effect_count" = excluded."side_effect_count", "side_effecting" = excluded."side_effecting", "status" = excluded."status", "created_at" = CASE WHEN "delivery_graph_proposals"."expires_at" > ? THEN "delivery_graph_proposals"."created_at" ELSE ? END, "updated_at" = ?, "expires_at" = CASE WHEN "delivery_graph_proposals"."expires_at" > ? THEN "delivery_graph_proposals"."expires_at" ELSE ? END, "stage_seq" = excluded."stage_seq"`,
253
+ [row.digest, row.logical_key, row.title, row.graph, row.preview, row.node_count, row.human_node_count, row.side_effect_count, row.side_effecting, row.status, row.created_at, at, row.expires_at, at, at, at, at, atExpiry],
254
+ );
255
+ // Reconcile to EXACTLY ONE live proposal per logical graph: supersede every `staged` row for this
256
+ // `logical_key` that has a strictly-NEWER staged sibling (by `updated_at`, with a deterministic
257
+ // `stage_seq` tie-breaker), leaving only the globally-newest live. This is ORDER-INDEPENDENT
258
+ // — it never references the just-written digest, so it converges to a single live row regardless of the
259
+ // interleaving of concurrent stages. A supersede pass keyed to "only flip rows older than the one *I*
260
+ // just wrote" leaves TWO live proposals when an OLDER stage's pass runs AFTER a newer stage already
261
+ // committed (the older pass won't flip the newer row, and the newer pass ran before the older row
262
+ // existed) — and an unordered supersede-all leaves ZERO. Anchoring on the newest staged sibling avoids
263
+ // both: the newest row is never superseded (no newer sibling), so it stays staged throughout the
264
+ // statement and every older row's `EXISTS` is satisfied by it. Idempotent: a no-op once one row remains.
265
+ // The tie-break is the monotonic `stage_seq` (atomically assigned in the write above), NOT the content
266
+ // `digest` NOR the frozen `rowid`: two stages landing in the SAME millisecond tie on `updated_at`, and a
267
+ // `digest`-string tie-break is arbitrary w.r.t. submission order while `rowid` does not advance on a
268
+ // re-stage UPDATE — so either could pick the LATER-submitted graph as the one superseded, inverting
269
+ // "the latest stage wins". `stage_seq` advances on every stage (insert AND re-stage alike), so the
270
+ // most-recently-written row deterministically wins the tie.
271
+ await t.exec(
272
+ `UPDATE "delivery_graph_proposals" SET "status" = 'superseded', "updated_at" = ? WHERE "logical_key" = ? AND "status" = 'staged' AND EXISTS (SELECT 1 FROM "delivery_graph_proposals" AS "newer" WHERE "newer"."logical_key" = "delivery_graph_proposals"."logical_key" AND "newer"."status" = 'staged' AND ("newer"."updated_at" > "delivery_graph_proposals"."updated_at" OR ("newer"."updated_at" = "delivery_graph_proposals"."updated_at" AND "newer"."stage_seq" > "delivery_graph_proposals"."stage_seq")))`,
240
273
  [now(), row.logical_key],
241
274
  );
275
+ });
242
276
 
243
277
  // Report what the stage did to siblings (issue #740). `superseded` = the same-logical-key proposals
244
278
  // this stage retired (re-read post-reconcile so a concurrent newer stage that kept ITS row live —
@@ -259,12 +293,13 @@ export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal)
259
293
  }
260
294
  const siblingsStaged = (await listStagedProposals(data)).filter((r) => r.logical_key !== row.logical_key).length;
261
295
 
262
- // Re-read the just-written row AFTER the reconcile so `row` reports the ACTUALLY-persisted state, not
263
- // the pre-reconcile `toWrite`: a concurrent newer stage of a different digest can flip THIS digest to
264
- // `superseded` in the reconcile above, so returning `toWrite` (still `staged`) would report a status
265
- // that doesn't match the DB. Fall back to `toWrite` only if the row somehow vanished (it shouldn't —
266
- // we just wrote it), keeping the return non-null.
267
- const persisted = (await table.get(toWrite.digest)) ?? toWrite;
296
+ // Re-read the just-written row AFTER the reconcile so the returned row reports the ACTUALLY-persisted
297
+ // state: a concurrent newer stage of a different digest can flip THIS digest to `superseded` in the
298
+ // reconcile above, so returning the pre-reconcile input `row` (still `staged`) would report a status
299
+ // that doesn't match the DB — and the persisted `created_at`/`expires_at`/`stage_seq` are derived inside
300
+ // the write, not on `row`. Fall back to the input `row` only if the persisted row somehow vanished (it
301
+ // shouldn't — we just wrote it), keeping the return non-null.
302
+ const persisted = (await table.get(row.digest)) ?? row;
268
303
 
269
304
  return { row: persisted, superseded, siblingsStaged };
270
305
  }
@@ -321,9 +356,36 @@ export async function listStagedProposals(
321
356
  }
322
357
 
323
358
  /** Mark a staged proposal `dispatched` once the operator launches it — it drops out of the cockpit's
324
- * staged list (the run then shows in the in-flight grid). */
325
- export async function markProposalDispatched(data: DataLayer, digest: string): Promise<void> {
326
- await deliveryGraphProposals(data).update(digest, { status: "dispatched", updated_at: now() });
359
+ * staged list (the run then shows in the in-flight grid).
360
+ *
361
+ * Like `markProposalExpired`/`markProposalDismissed`/`sweepExpiredProposals`, this is a GUARDED UPDATE
362
+ * (`... WHERE digest=? AND status='staged'`), not a blind update-by-key. The dispatch door's
363
+ * `getStagedProposal` liveness read, the launch, and this terminal flip are separate statements, so the
364
+ * row can leave `staged` in the window between them (a concurrent dispatch consumed it, or a
365
+ * dismiss/supersede/expiry sweep retired it); a blind `table.update(digest, …)` would clobber that
366
+ * newer terminal status back to `dispatched`, breaking monotonic lifecycle transitions.
367
+ *
368
+ * When `expectedStageSeq` is supplied the guard ALSO pins `stage_seq` to the value the dispatch door
369
+ * snapshotted when it loaded the proposal (issue #778 review — thread deliveryGraphProposals.ts:243). A
370
+ * concurrent RE-STAGE of the SAME digest overwrites the row in place with a newer graph (two
371
+ * credential-differing graphs share a semantic digest) and bumps `stage_seq`; without the seq pin, this
372
+ * flip would retire that newer, never-launched graph — the dispatch launched the STALE snapshot it read
373
+ * yet marks the fresh row consumed. Pinning `stage_seq` makes the flip a no-op in that race, leaving the
374
+ * re-staged graph `staged` for its own dispatch. Omit `expectedStageSeq` (e.g. a pre-migration row whose
375
+ * seq is `0`) to fall back to the `status='staged'`-only guard.
376
+ *
377
+ * Returns whether the guarded UPDATE actually flipped a row (`res.changed > 0`), mirroring
378
+ * `markProposalDismissed`/`sweepExpiredProposals`. A `false` return means the row was no longer the
379
+ * `staged` revision this dispatch snapshotted (a concurrent dispatch/re-stage/retire won) — the launched
380
+ * run still stands, but the mark was intentionally skipped so the newer row is not clobbered. */
381
+ export async function markProposalDispatched(data: DataLayer, digest: string, expectedStageSeq?: number): Promise<boolean> {
382
+ const db = data.open();
383
+ const guardSeq = typeof expectedStageSeq === "number";
384
+ const res = await db.exec(
385
+ `UPDATE "delivery_graph_proposals" SET "status" = 'dispatched', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'${guardSeq ? ` AND "stage_seq" = ?` : ""}`,
386
+ guardSeq ? [now(), digest, expectedStageSeq] : [now(), digest],
387
+ );
388
+ return res.changed > 0;
327
389
  }
328
390
 
329
391
  /** Retire a proposal by flipping it to `expired` — its `graph` payload is unusable (e.g. corrupt JSON
@@ -342,12 +404,18 @@ export async function markProposalDispatched(data: DataLayer, digest: string): P
342
404
  * Returns whether the guarded UPDATE actually flipped a row (`res.changed > 0`), mirroring
343
405
  * `markProposalDismissed`/`sweepExpiredProposals`. A `false` return means the row was no longer `staged`
344
406
  * at write time (a dismiss/supersede/dispatch race won), so the caller must not treat the retirement as
345
- * having happened. */
346
- export async function markProposalExpired(data: DataLayer, digest: string): Promise<boolean> {
407
+ * having happened.
408
+ *
409
+ * When an `expectedStageSeq` is supplied (the dispatch door's corrupt-graph retirement), the flip is
410
+ * ADDITIONALLY guarded on `stage_seq = ?`, exactly like `markProposalDispatched`: a concurrent re-stage
411
+ * of the same digest bumps `stage_seq`, so guarding on the revision we inspected keeps the expiry from
412
+ * retiring a newer graph that landed in the read→write window (issue #778 review). */
413
+ export async function markProposalExpired(data: DataLayer, digest: string, expectedStageSeq?: number): Promise<boolean> {
347
414
  const db = data.open();
415
+ const guardSeq = typeof expectedStageSeq === "number";
348
416
  const res = await db.exec(
349
- `UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'`,
350
- [now(), digest],
417
+ `UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'${guardSeq ? ` AND "stage_seq" = ?` : ""}`,
418
+ guardSeq ? [now(), digest, expectedStageSeq] : [now(), digest],
351
419
  );
352
420
  return res.changed > 0;
353
421
  }
@@ -404,9 +472,15 @@ export async function sweepExpiredProposals(data: DataLayer, at: Date = new Date
404
472
  let swept = 0;
405
473
  for (const row of staged) {
406
474
  if (isProposalExpired(row.expires_at, at)) {
475
+ // Pin `stage_seq` (like `markProposalDispatched`/`markProposalExpired`): the `find` liveness read and
476
+ // this flip are separate statements, so a concurrent RE-STAGE can overwrite the row in place with a
477
+ // newer graph — bumping `stage_seq` and REFRESHING `expires_at` — in the window between them. A blind
478
+ // `digest + status='staged'` update would then expire that fresh, un-expired revision. Guarding on the
479
+ // `stage_seq` we inspected makes the flip a no-op in that race, leaving the re-staged graph `staged`
480
+ // (issue #778 review — thread deliveryGraphProposals.ts:417).
407
481
  const res = await db.exec(
408
- `UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'`,
409
- [ts, row.digest],
482
+ `UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged' AND "stage_seq" = ?`,
483
+ [ts, row.digest, row.stage_seq],
410
484
  );
411
485
  swept += res.changed;
412
486
  }
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join, resolve } from "node:path";
10
10
  import { test } from "node:test";
11
- import { assertEquals } from "#test-assert";
11
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
12
12
  import type { DataLayer } from "@nanobpm/urban";
13
13
  import { bootTestApp } from "@nanobpm/urban-testkit";
14
14
  import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
@@ -19,6 +19,7 @@ import {
19
19
  computeRunKey,
20
20
  deriveDeliveryPhase,
21
21
  DELIVERY_PHASE,
22
+ deliveryGraphRunIdentities,
22
23
  deliveryGraphRuns,
23
24
  humanTaskElementId,
24
25
  parseHumanLabels,
@@ -107,6 +108,25 @@ test("claimRunForLaunch: re-running a terminal row clears the PRIOR instance key
107
108
  });
108
109
  });
109
110
 
111
+ test("claimRunForLaunch: winning a re-run claim ATOMICALLY invalidates the prior run's identity side-row — a concurrent loser's short-circuit read sees a MISSING (unprovable) fingerprint, not the stale prior one (issue #778 review — thread deliveryGraphDispatch.ts:180)", async () => {
112
+ await withData(async (data) => {
113
+ const runs = deliveryGraphRuns(data);
114
+ const identities = deliveryGraphRunIdentities(data);
115
+ // A terminal prior run still carrying its stamped lossless identity fingerprint.
116
+ await runs.insert({ ...claimRow("running"), status: "failed" });
117
+ await identities.insert({ run_key: "rk", graph_fingerprint: "PRIOR-FP", created_at: "2020-01-01T00:00:00.000Z" });
118
+ // The winner re-runs (conceptually a DIFFERENT graph that shares the digest-derived run_key but not
119
+ // the credential-sensitive fingerprint). The CAS flips the row to running…
120
+ assertEquals(await claimRunForLaunch(data, true, claimRow("running")), true);
121
+ // …and the stale identity row must be GONE the instant the row is `running`. Otherwise a racing loser
122
+ // reading between this flip and the winner's post-launch `upsertRunIdentity` re-stamp would read
123
+ // "PRIOR-FP" and, if that matched ITS graph, wrongly confirm identity for a credential-different run.
124
+ // Missing → dispatch's identityConfirmed is false (the safe 409) throughout the window.
125
+ const after = await identities.get("rk");
126
+ assert(after == null, `the stale identity row must be gone, got: ${JSON.stringify(after)}`);
127
+ });
128
+ });
129
+
110
130
  // ── pollDeliveryGraphPhase: engine-key coercion ───────────────────────────────
111
131
  test("pollDeliveryGraphPhase: a numeric engine processInstanceKey still matches the string process_key, so a COMPLETED instance reconciles to done", async () => {
112
132
  await withData(async (data) => {
@@ -200,12 +220,32 @@ test("buildHumanLabels: maps each human node's compiled user-task element id →
200
220
  assertEquals(labels[humanTaskElementId(ackEl)], "ack"); // fallback to node id
201
221
  });
202
222
 
223
+ test("buildHumanLabels: redacts a credential-bearing human prompt before it lands in human_labels (issue #778 review)", async () => {
224
+ const graph = {
225
+ nodes: [
226
+ { id: "publish", kind: "human", human: { prompt: "deploy via https://user:s3cr3t@registry.example.com then confirm" } },
227
+ ],
228
+ edges: [],
229
+ };
230
+ const compiled = await compileDeliveryGraph(graph);
231
+ assertEquals(compiled.ok, true);
232
+ if (!compiled.ok) return;
233
+ const labels = buildHumanLabels(compiled);
234
+ const publishEl = compiled.resolved.nodes.find((n) => n.id === "publish")?.element ?? "";
235
+ const label = labels[humanTaskElementId(publishEl)];
236
+ assert(!label.includes("s3cr3t") && !label.includes("user:s3cr3t"), "the credential must not survive into the denormalised inbox label");
237
+ assertStringIncludes(label, "//***@");
238
+ });
239
+
203
240
  test("parseHumanLabels: round-trips a stored map and tolerates null/blank/corrupt", () => {
204
- assertEquals(parseHumanLabels(JSON.stringify({ a: "x" })), { a: "x" });
205
241
  assertEquals(parseHumanLabels(null), {});
206
242
  assertEquals(parseHumanLabels(""), {});
207
243
  assertEquals(parseHumanLabels(" "), {});
208
244
  assertEquals(parseHumanLabels("{not json"), {});
245
+ assertEquals(parseHumanLabels(JSON.stringify({ n1: "manual OTP publish", n2: "confirm deploy" })), {
246
+ n1: "manual OTP publish",
247
+ n2: "confirm deploy",
248
+ }); // round-trips a valid string→string map
209
249
  assertEquals(parseHumanLabels(JSON.stringify(["a"])), {}); // non-object
210
250
  assertEquals(parseHumanLabels(JSON.stringify({ a: 1, b: "y" })), { b: "y" }); // drops non-string values
211
251
  });
@@ -26,6 +26,7 @@
26
26
  import type { DataLayer, ProcessInstanceState } from "@nanobpm/urban";
27
27
  import type { CompileDeliveryGraphResult } from "../nano-generated/api-io.d.ts";
28
28
  import { isUniqueConstraintFence } from "./dbFence.ts";
29
+ import { redactFreeText } from "./deliveryGraphCompiler.ts";
29
30
  import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
30
31
 
31
32
  const now = () => new Date().toISOString();
@@ -97,6 +98,24 @@ export const DELIVERY_PHASE = {
97
98
  export const deliveryGraphRuns = (data: DataLayer) =>
98
99
  data.table<DeliveryGraphRun>("delivery_graph_runs", "run_key");
99
100
 
101
+ /** One row of the `delivery_graph_run_identity` SIDE table (migration 113) — the run's LOSSLESS
102
+ * content-identity fingerprint, kept OFF the heavily-projected `delivery_graph_runs` base row so no
103
+ * drift-guarded read-model/compat view has to carry it. Keyed by `run_key`. */
104
+ export interface DeliveryGraphRunIdentity {
105
+ run_key: string;
106
+ /** `sha256(digest \0 canonicalJson(digestInvisibleRawValues(graph)))`, captured at launch — the same
107
+ * lossless identity `stableProposalRunKey` folds into the keyless run key. An explicit-`idempotencyKey`
108
+ * short-circuit compares this to prove the running run is THIS exact graph (a same-payload retry) vs. a
109
+ * credential-different graph re-staged under the same key. */
110
+ graph_fingerprint: string;
111
+ created_at: string;
112
+ }
113
+
114
+ /** The `delivery_graph_run_identity` side-table accessor — the run's lossless identity fingerprint,
115
+ * keyed by `run_key` (issue #778 review — thread dispatchDeliveryGraph.ts:332). */
116
+ export const deliveryGraphRunIdentities = (data: DataLayer) =>
117
+ data.table<DeliveryGraphRunIdentity>("delivery_graph_run_identity", "run_key");
118
+
100
119
  /** Atomically claim a run for LAUNCH — the at-most-once dispatch fence. Returns `true` iff THIS caller
101
120
  * won the claim and must proceed to `runDeliveryGraph`; `false` iff a concurrent submit already claimed
102
121
  * it (the caller must short-circuit as `alreadyRunning` instead of double-launching). Two fences, one
@@ -119,7 +138,9 @@ export const deliveryGraphRuns = (data: DataLayer) =>
119
138
  * the row briefly visible as `running` while still pointing at the OLD instance key, so the
120
139
  * `process_key`-keyed instance-tracking reconciler / poller could act on (and mis-reconcile against)
121
140
  * the stale instance before the winner's follow-up metadata write lands. Clearing them atomically with
122
- * the flip means a claimed `running` row can never be observed with a stale instance key. */
141
+ * the flip means a claimed `running` row can never be observed with a stale instance key. A winning
142
+ * relaunch also DELETES the run's prior `delivery_graph_run_identity` side-row in the same transaction,
143
+ * for the same reason — see the inline note (issue #778 review). */
123
144
  export async function claimRunForLaunch(
124
145
  data: DataLayer,
125
146
  existing: boolean,
@@ -134,9 +155,8 @@ export async function claimRunForLaunch(
134
155
  return false;
135
156
  }
136
157
  }
137
- const res = await data
138
- .open()
139
- .exec(
158
+ const res = await data.open().tx(async (t) => {
159
+ const flip = await t.exec(
140
160
  `UPDATE "delivery_graph_runs" SET "status" = ?, "process_key" = ?, "process_definition_id" = ?, "phase" = ?, "phase_node_id" = ?, "updated_at" = ? WHERE "run_key" = ? AND "status" <> 'running'`,
141
161
  [
142
162
  claim.status,
@@ -148,7 +168,21 @@ export async function claimRunForLaunch(
148
168
  claim.run_key,
149
169
  ],
150
170
  );
151
- return res.changed === 1;
171
+ if (flip.changed !== 1) return false;
172
+ // Invalidate the PRIOR run's lossless identity fingerprint IN THE SAME transaction as the flip, so a
173
+ // claimed `running` row can never be observed alongside the previous run's stale `graph_fingerprint`.
174
+ // dispatch's `identityConfirmed` short-circuit reads this side-row to prove an already-running run is
175
+ // THIS exact graph (a same-payload retry) vs. a credential-different graph re-staged under the same
176
+ // key; between this flip and the winner's post-launch re-stamp (`upsertRunIdentity`) the prior row
177
+ // would otherwise still match a relaunch that shares only the digest, WRONGLY confirming identity for
178
+ // a different-credential run. Deleting it here makes the fingerprint UNPROVABLE (missing →
179
+ // identityConfirmed:false, the safe pre-existing 409) throughout that window, atomically with the
180
+ // status flip — the same "no stale side-state observable on a claimed row" invariant the instance-key
181
+ // clear above enforces (issue #778 review — thread deliveryGraphDispatch.ts:180).
182
+ await t.exec(`DELETE FROM "delivery_graph_run_identity" WHERE "run_key" = ?`, [claim.run_key]);
183
+ return true;
184
+ });
185
+ return res;
152
186
  }
153
187
 
154
188
  /** The idempotency key for a submitted graph: a caller-supplied `idempotencyKey` (trimmed) when
@@ -181,7 +215,13 @@ export function buildHumanLabels(compiled: CompileDeliveryGraphResult): Record<s
181
215
  for (const stop of compiled.humanNodes) {
182
216
  const element = elementByNodeId.get(stop.nodeId);
183
217
  if (element === undefined) continue;
184
- labels[humanTaskElementId(element)] = firstLine(stop.prompt) || stop.nodeId;
218
+ // Redact the prompt with the SAME display-safe helper `nodeDisplay` uses BEFORE stamping it: this
219
+ // label is denormalised into the run row's `human_labels` and surfaced to the operator as the parked
220
+ // task's Decision context in the Tasks inbox, so a URL credential in a human prompt (`//user:pass@…`)
221
+ // must be stripped here too — else it is persisted and shown unredacted even though the BPMN display
222
+ // path redacts it. The RAW prompt still reaches the runtime user task unmodified (issue #778 review).
223
+ const safePrompt = typeof stop.prompt === "string" ? redactFreeText(stop.prompt) : "";
224
+ labels[humanTaskElementId(element)] = firstLine(safePrompt) || stop.nodeId;
185
225
  }
186
226
  return labels;
187
227
  }
@@ -128,6 +128,27 @@ test("the human node seeds prompt/nodeId/emits; a click-done (no-emit, no-prompt
128
128
  assertEquals(ack?.nodeId, "ack");
129
129
  });
130
130
 
131
+ test("the human node's seeded prompt REDACTS an embedded credential (issue #778 review): the read-only user-task form field never renders a `//user:pass@host` secret", async () => {
132
+ // The compiler already redacts the human prompt for the operator-facing PREVIEW (buildHumanNodes),
133
+ // but the runtime user-task seeds `nodeInputs.<el>.prompt` — rendered read-only in the delivery-human
134
+ // form (`delivery-human-{ack,publish}.form`, key "prompt", `readonly:true`). That display path must be
135
+ // redacted too, or a credential-bearing instruction leaks its secret to whoever opens the task. The
136
+ // prompt is DISPLAY-ONLY (the functional outputs are the emit/resolvedArtifact fields), so redacting it
137
+ // is safe. `redactFreeText` is a no-op for a credential-free prompt, so the plain-prompt tests above
138
+ // stay green.
139
+ const graph: DeliveryGraph = {
140
+ nodes: [
141
+ { id: "publish", kind: "human", human: { prompt: "clone https://user:s3cr3t@github.com/o/r then run the manual OTP publish" }, emits: [{ name: "resolvedArtifact", type: "artifact" }] },
142
+ ],
143
+ edges: [],
144
+ };
145
+ const p = await prepareOk(graph);
146
+ const human = Object.values(p.nodeInputs).find((v) => "escalationSlaTimeout" in (v as Record<string, unknown>)) as Record<string, unknown>;
147
+ const prompt = String(human.prompt);
148
+ assert(!prompt.includes("s3cr3t"), `the human-task form prompt must not expose the URL credential, got: ${prompt}`);
149
+ assert(prompt.includes("***@github.com"), `the credential userinfo is redacted in place, got: ${prompt}`);
150
+ });
151
+
131
152
  test("agent node classifier-emit contract (#506): a declared `emits` threads the emit instruction into appendPrompt; a no-emit node leaves it untouched", async () => {
132
153
  // #506: a guarded split (S7) routes on a producer's emitted scalar, published from the engine
133
154
  // variable named exactly after the fact. A real `senior:*` agent completes with the Output-contract
@@ -19,7 +19,7 @@ import type { EngineClient } from "@nanobpm/urban";
19
19
  import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
20
20
  import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
21
21
  import { isPlausibleBranchName } from "./baseBranch.ts";
22
- import { AGENT_REPO_SPEC_HEADER, AGENT_TERMINAL_SUCCESS_STATUSES, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
22
+ import { AGENT_REPO_SPEC_HEADER, AGENT_TERMINAL_SUCCESS_STATUSES, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID, redactFreeText } from "./deliveryGraphCompiler.ts";
23
23
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
24
24
  import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, isResolvableRepo, RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
25
25
  import { isoDuration } from "./reviewWait.ts";
@@ -585,7 +585,14 @@ function buildNodeInput(
585
585
  // form can render its "now do X" prompt, name the parked node, and label/hide its emit field
586
586
  // (issue #499 — the generic form otherwise renders contextless). `emits` stays the single
587
587
  // source of truth: the compiled ioMapping derives the emit label/mode from it in FEEL.
588
- prompt: node.human?.prompt ?? "",
588
+ // REDACT the prompt: this seeds `nodeInputs.<el>.prompt`, rendered in the delivery-human form's
589
+ // read-only `prompt` textarea (`delivery-human-{ack,publish}.form`) to whoever opens the task —
590
+ // a DISPLAY surface, not a functional value (the functional outputs are the emit/resolvedArtifact
591
+ // fields). So it gets the SAME embedded-credential redaction the operator-facing compile PREVIEW
592
+ // already applies (buildHumanNodes), closing the runtime leak where a credential-bearing
593
+ // instruction would expose its secret in the parked task's form (issue #778 review — thread
594
+ // deliveryGraphCompiler.ts:826). `redactFreeText` is a no-op for a credential-free prompt.
595
+ prompt: redactFreeText(node.human?.prompt ?? ""),
589
596
  nodeId: node.id ?? ctx.element,
590
597
  emits: Array.isArray(node.emits) ? node.emits.map((f) => ({ ...f })) : [],
591
598
  };
@@ -48,6 +48,7 @@ import {
48
48
  readinessTimeoutMs,
49
49
  redactString,
50
50
  redactTarget,
51
+ isUrlShaped,
51
52
  } from "./readiness.ts";
52
53
 
53
54
  // A ProbeExec stub: canned http/command responses, capturing the last command it was asked to run.
@@ -862,6 +863,20 @@ test("probeBudgetMs: prefers the seeded probeTimeout (the gate timer's bound), f
862
863
  });
863
864
 
864
865
  // ── repo/ref parse + redaction ──────────────────────────────────────────────────────────────
866
+ test("isUrlShaped: tolerates XML-attribute whitespace (TAB/LF/CR/space) between the scheme and `//` — `https:\\t//host` still classifies as a URL so its credential is redacted, not passed through as a non-URL (#778 review — thread readiness.ts:1191)", () => {
867
+ assert(isUrlShaped("https://host"), "a plain URL");
868
+ assert(isUrlShaped("//host"), "scheme-relative");
869
+ assert(isUrlShaped(" https://host"), "leading whitespace (trimmed first)");
870
+ // The XML-valid whitespace `stripXmlInvalidChars` keeps must not break the scheme→authority match:
871
+ assert(isUrlShaped("https:\t//user:pass@host"), "TAB between scheme and //");
872
+ assert(isUrlShaped("https:\n//host"), "LF between scheme and //");
873
+ assert(isUrlShaped("https:\r//host"), "CR between scheme and //");
874
+ assert(isUrlShaped("https: //host"), "spaces between scheme and //");
875
+ // A plain routing token / non-URL is still NOT url-shaped.
876
+ assert(!isUrlShaped("senior:feature"), "a plain job-type token is not url-shaped");
877
+ assert(!isUrlShaped("owner/repo#1"), "a PR ref is not url-shaped");
878
+ });
879
+
865
880
  test("parseRepoRef: splits owner/repo@ref and defaults the ref to HEAD", () => {
866
881
  assertEquals(parseRepoRef("o/r@abc123"), { repo: "o/r", ref: "abc123" });
867
882
  assertEquals(parseRepoRef("o/r"), { repo: "o/r", ref: "HEAD" });
@@ -874,6 +889,63 @@ test("redactString/redactTarget: strip userinfo and query (a token often rides e
874
889
  assert(!t.includes("s3cr3t"), "the secret must not survive redaction");
875
890
  });
876
891
 
892
+ test("redactString: a `//user:pass@` userinfo that embeds a raw line break is still stripped (newline-safe)", () => {
893
+ // The userinfo class is `[^/@]` (NOT `[^/@\s]`): a CR/LF smuggled inside the userinfo must not
894
+ // break the `//…@` match and leave the credential tail visible.
895
+ const r = redactString("https://user:pa\nss@host/path");
896
+ assert(!r.includes("ss@host") && !r.includes("user:pa"), "the newline-split userinfo is redacted");
897
+ assertStringIncludes(r, "//***@");
898
+ });
899
+
900
+ test("redactString: a `//user:pass@` userinfo that embeds a raw TAB is still stripped (tab-safe)", () => {
901
+ // A TAB is a VALID XML character `stripXmlInvalidChars` does not remove, so an earlier `[^/@ \t]`
902
+ // userinfo class (excluding TAB) let `https://user:pa\tss@host` split before the `@` — leaving
903
+ // `ss@host` visible in the display artifact. The class is now `[^/@]` (issue #778 review).
904
+ const r = redactString("https://user:pa\tss@host/path?token=s3cr3t");
905
+ assert(!r.includes("ss@host") && !r.includes("user:pa"), "the tab-split userinfo is redacted");
906
+ assert(!r.includes("s3cr3t"), "the query token is redacted");
907
+ assertStringIncludes(r, "//***@");
908
+ });
909
+
910
+ test("redactString: a `//user:secret pass@` userinfo that embeds a literal SPACE is still stripped (space-safe)", () => {
911
+ // An earlier `[^/@ ]` class bounded the userinfo at a SPACE to avoid over-matching prose, but that let
912
+ // a malformed but operator-authored `https://user:secret pass@host` leave `user:secret` visible in the
913
+ // display artifact. The class is now `[^/@]` (space-tolerant), so the whole userinfo through the `@`
914
+ // collapses; the RAW value still reaches runtime unmodified (issue #778 review — thread readiness.ts:1177).
915
+ const r = redactString("https://user:secret pass@host/path?token=s3cr3t");
916
+ assert(!r.includes("secret") && !r.includes("user:"), `the space-split userinfo is redacted: ${r}`);
917
+ assert(!r.includes("s3cr3t"), "the query token is redacted");
918
+ assertStringIncludes(r, "//***@");
919
+ });
920
+
921
+ test("redactString: userinfo carrying MULTIPLE raw `@` collapses the WHOLE authority credential, not just the prefix (multi-@ suffix-leak, #778 review — thread readiness.ts:1175)", () => {
922
+ // Per RFC 3986 the userinfo runs to the LAST `@` before the authority's path. An earlier `[^/@]*@`
923
+ // span stopped at the FIRST `@`, so `https://user:pass@ss@host` rendered as `https://***@ss@host` —
924
+ // leaking `ss@host`, the userinfo suffix, into the display artifact. The class is now `[^/]` so the
925
+ // span reaches the final `@` and the entire credential authority collapses to `//***@` (leaving the
926
+ // real host `host`).
927
+ const r = redactString("https://user:pass@ss@host/path?token=s3cr3t");
928
+ assert(!r.includes("ss@host"), `the multi-@ userinfo suffix must not leak: ${r}`);
929
+ assert(!r.includes("user:pass"), "the userinfo prefix is redacted");
930
+ assert(!r.includes("s3cr3t"), "the query token is redacted");
931
+ assertStringIncludes(r, "//***@host");
932
+ });
933
+
934
+ test("redactString: a `?query`/`#fragment` value containing a literal `@` does NOT let the userinfo span cross the delimiter and leak the tail (#778 review — thread readiness.ts:1199)", () => {
935
+ // `//host?token=secret@tail` has NO userinfo — the `@` sits INSIDE the query. An unbounded `[^/]*@`
936
+ // userinfo class consumed the `?` marker (`//host?token=secret@` → `//***@`), so the follow-on
937
+ // query strip found no `?` and left `tail` visible. Bounding the userinfo class before `?`/`#`
938
+ // (`[^/?#]*@`) keeps the `?` in place so the whole `?query` (incl. the `@tail`) is redacted.
939
+ const r = redactString("https://host?token=secret@tail");
940
+ assert(!r.includes("tail"), `the query tail past the @ must not leak: ${r}`);
941
+ assert(!r.includes("secret"), "the query token is redacted");
942
+ assertEquals(r, "https://host?***");
943
+ // A GENUINE userinfo credential still collapses AND its query is still stripped.
944
+ const c = redactString("https://user:pass@host?token=s3cr3t@x");
945
+ assert(!c.includes("user:pass") && !c.includes("s3cr3t"), `userinfo + query both redacted: ${c}`);
946
+ assertEquals(c, "https://***@host?***");
947
+ });
948
+
877
949
  test("redactTarget: a command target is never logged — only the kind + a fixed placeholder", () => {
878
950
  const ct = redactTarget(parseProbe({ kind: "command", target: "curl -H 'Authorization: Bearer s3cr3t' https://h/p" }));
879
951
  assertEquals(ct, "command:<redacted>");