@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.
package/app/readiness.ts CHANGED
@@ -63,6 +63,11 @@ export type Backoff = "fixed" | "exponential";
63
63
  // is added here without a matching vocabulary entry (AGENTS.md: no drift surfaces).
64
64
  export const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability", "pr", "epic"];
65
65
  export const ON_TIMEOUTS: readonly OnTimeout[] = ["escalate", "fail", "continue"];
66
+ // The DEFAULT `onTimeout` an omitted value takes — the single source of truth `parseProbe` fills in and
67
+ // the compiler's display drops as an effective default (writing it explicitly is behaviourally identical
68
+ // to omitting it, so surfacing it would fork `semanticBpmn`/the digest from the omitted-equivalent
69
+ // graph; issue #778 review — thread deliveryGraphCompiler.ts:1398).
70
+ export const DEFAULT_ON_TIMEOUT: OnTimeout = "escalate";
66
71
  export const BACKOFFS: readonly Backoff[] = ["fixed", "exponential"];
67
72
  export const PR_CONDITIONS: readonly PrCondition[] = ["ready", "merged", "mergeable", "checks-green"];
68
73
  export const EPIC_CONDITIONS: readonly EpicCondition[] = ["merged", "done"];
@@ -237,7 +242,7 @@ export function parseProbe(raw: unknown, opts?: { allowLateBoundTarget?: boolean
237
242
  if (onTimeoutRaw !== "" && !isOnTimeout(onTimeoutRaw)) {
238
243
  throw new Error(`readiness probe: invalid onTimeout '${onTimeoutRaw}' (expected ${ON_TIMEOUTS.join(", ")})`);
239
244
  }
240
- const onTimeout: OnTimeout = onTimeoutRaw === "" ? "escalate" : onTimeoutRaw;
245
+ const onTimeout: OnTimeout = onTimeoutRaw === "" ? DEFAULT_ON_TIMEOUT : onTimeoutRaw;
241
246
 
242
247
  const match = isRecord(raw.match) ? parseMatch(raw.match) : undefined;
243
248
  // A capability edge whose ref or package is blank can never resolve — fail loudly here rather than
@@ -307,7 +312,7 @@ export function parseProbe(raw: unknown, opts?: { allowLateBoundTarget?: boolean
307
312
 
308
313
  // Membership guards that narrow a validated string to its union without a type assertion (the
309
314
  // `no-unsafe-type-assertion` gate bans `as`).
310
- function isProbeKind(v: string): v is ProbeKind {
315
+ export function isProbeKind(v: string): v is ProbeKind {
311
316
  for (const k of PROBE_KINDS) if (k === v) return true;
312
317
  return false;
313
318
  }
@@ -318,7 +323,7 @@ function isOnTimeout(v: string): v is OnTimeout {
318
323
 
319
324
  // `isBackoff` narrows a validated string to its union without a type assertion (the
320
325
  // `no-unsafe-type-assertion` gate bans `as`).
321
- function isBackoff(v: string): v is Backoff {
326
+ export function isBackoff(v: string): v is Backoff {
322
327
  for (const b of BACKOFFS) if (b === v) return true;
323
328
  return false;
324
329
  }
@@ -414,7 +419,17 @@ export function normalizePoll(poll: ProbePoll | undefined): Required<ProbePoll>
414
419
  const everyMs = typeof everyRaw === "number" && everyRaw >= 1 ? Math.trunc(everyRaw) : DEFAULT_EVERY_MS;
415
420
  const timeoutMs =
416
421
  typeof timeoutRaw === "number" && timeoutRaw >= 1 ? Math.trunc(timeoutRaw) : DEFAULT_TIMEOUT_MS;
417
- return { everyMs: Math.min(everyMs, MAX_EVERY_MS), timeoutMs, backoff: poll?.backoff ?? DEFAULT_BACKOFF };
422
+ // TRIM `backoff` to match `parseProbe` (`parsePoll` does `str(raw.backoff).trim()`): a padded
423
+ // `" exponential "` runs as the SAME `exponential` policy, so leaving the whitespace here would make
424
+ // the compiler's display path (`normalizePoll(p.poll)`) emit a spurious non-default `Poll:` line for a
425
+ // runtime-default poll — forking `semanticBpmn`/the digest from the omitted-equivalent graph and
426
+ // letting a re-stage bypass the idempotency fence to duplicate the run (issue #778 review — thread
427
+ // deliveryGraphCompiler.ts:1414). A whitespace-only / unrecognised value falls back to the default
428
+ // (the compiler display path takes raw JSON; a genuinely invalid backoff fails loudly later in
429
+ // `parseProbe`, so defaulting the DISPLAY is safe and keeps the effective-policy render honest).
430
+ const backoffTrimmed = typeof poll?.backoff === "string" ? poll.backoff.trim() : undefined;
431
+ const backoff = backoffTrimmed && isBackoff(backoffTrimmed) ? backoffTrimmed : DEFAULT_BACKOFF;
432
+ return { everyMs: Math.min(everyMs, MAX_EVERY_MS), timeoutMs, backoff };
418
433
  }
419
434
 
420
435
  /** The delay (ms) before the `attempt`-th retry (1-based). Fixed backoff returns `everyMs`;
@@ -1157,10 +1172,221 @@ export function redactTarget(probe: ReadinessProbe): string {
1157
1172
  return `${probe.kind}:${redactString(probe.target)}`;
1158
1173
  }
1159
1174
 
1175
+ /** The ONE canonical embedded-`//<userinfo>@` credential-userinfo span, shared by the redactor
1176
+ * ({@link redactString}, which REWRITES the span to `//***@`) and the semantic reject path
1177
+ * ({@link hasEmbeddedCredential}, used by `validateDeliveryGraph` to REJECT a credential-bearing
1178
+ * `agent.jobType`). Keeping ONE source string means the "what counts as an embedded credential" rule
1179
+ * can never drift between the two — the redactor must strip exactly what the validator rejects, or a
1180
+ * credential the validator misses would be echoed un-redacted. The userinfo class is `[^/?#]` (NOT
1181
+ * `[^/@\s]`, and NOT the earlier `[^/@]`/`[^/]`): it deliberately spans WHITESPACE up to the `@`, so a
1182
+ * malformed-but-operator-authored `//user:secret pass@host` (a literal space in the userinfo) is
1183
+ * caught, not left to leak verbatim into the executable `<zeebe:taskDefinition type=…>` / compiled BPMN
1184
+ * (issue #778 review — thread deliveryGraph.ts:570). The userinfo colon is DELIBERATELY OPTIONAL
1185
+ * (`[^/?#]*@`, not `[^/?#]*:[^/?#]*@`):
1186
+ * a passwordless, username-only `//token@host` is a bearer/OAuth token riding the userinfo and MUST be
1187
+ * caught too — a plain worker-routing job type never contains a `//…@` span at all (with or without a
1188
+ * colon), so requiring a colon would only re-open a real leak for no legitimate gain (issue #778 review
1189
+ * — thread readiness.ts:1170). `[^/?#]*@` is a single-quantifier match — linear, no catastrophic
1190
+ * backtracking.
1191
+ *
1192
+ * The userinfo class is `[^/?#]` (NOT `[^/@]`): per RFC 3986 the userinfo runs to the LAST `@` before the
1193
+ * authority's path, so a malformed-but-accepted multi-`@` value (`//user:pass@ss@host`) must collapse
1194
+ * through EVERY `@` up to the path — an earlier `[^/@]*@` stopped at the FIRST `@`, rewriting only
1195
+ * `//user:pass@` and leaking the `ss@host` suffix into the display artifact (issue #778 review — thread
1196
+ * readiness.ts:1175).
1197
+ *
1198
+ * The class also EXCLUDES `?` and `#` (NOT the earlier `[^/]`): per RFC 3986 a raw `?`/`#` starts the
1199
+ * query/fragment and so ENDS the authority — a `//host?token=secret@tail` carries its `@` INSIDE the
1200
+ * query, with no userinfo at all. An unbounded `[^/]*@` greedily consumed the `?` marker
1201
+ * (`//host?token=secret@` → `//***@`), so the follow-on `?query`/`#fragment` strip in {@link redactString}
1202
+ * found no marker and left `tail` visible. Bounding the userinfo before `?`/`#` keeps the marker in
1203
+ * place so the whole query/fragment (incl. any `@tail`) is redacted, while a genuine `//user:pass@host?…`
1204
+ * still collapses its userinfo AND has its query stripped (issue #778 review — thread readiness.ts:1199).
1205
+ * `[^/?#]` still excludes `/` (so it never crosses into the path), and the trailing `@` is anchored
1206
+ * greedily to the last one before a `/`/`?`/`#`; `[^/?#]*@` remains a single-quantifier match, so it is
1207
+ * linear with no catastrophic backtracking. */
1208
+ export const EMBEDDED_CREDENTIAL_SRC = "\\/\\/[^/?#]*@";
1209
+
1160
1210
  /** Strip credential-bearing pieces from a free-form target string for logging: any `user:pass@`
1161
- * userinfo and any `?query`/`#fragment` (a token often rides the query). */
1211
+ * userinfo and any `?query`/`#fragment` (a token often rides the query). The query/fragment strip uses
1212
+ * `[\s\S]*` (NOT `.*$`, which cannot cross a line break) so an embedded CR/LF after the `?`/`#` — e.g.
1213
+ * `https://host/?token=secret\nnext` — cannot leave the token un-redacted; everything from the first
1214
+ * `?`/`#` to end-of-string is consumed regardless of intervening newlines. The userinfo class is
1215
+ * `[^/?#]` (NOT `[^/@\s]`, NOT the earlier `[^/@ ]`/`[^/@ \t]`/`[^/@]`, and NOT the later `[^/]`): ANY character smuggled
1216
+ * INSIDE the
1217
+ * userinfo up to the `@` — a raw CR/LF (`https://user:pa\nss@host`), an embedded TAB
1218
+ * (`https://user:pa\tss@host`), a literal SPACE (`https://user:secret pass@host`, a malformed but
1219
+ * operator-authored value), OR an extra raw `@` (`https://user:pass@ss@host`, whose userinfo per RFC
1220
+ * 3986 runs to the LAST `@`) — must not break the `//…@` match and leave the credential tail visible.
1221
+ * An earlier `[^/@ ]` bounded the userinfo at a SPACE to avoid over-matching prose, and `[^/@]` stopped
1222
+ * at the FIRST `@` (leaking a multi-`@` suffix), but both let a credential escape into an
1223
+ * operator-visible display artifact; since the RAW value
1224
+ * still reaches runtime unmodified and only the DISPLAY doc is affected, redacting more (through the
1225
+ * last `@` before the path) is the safe direction (issue #778 review). The `?`/`#` exclusion is the
1226
+ * INVERSE correction: a `[^/]` userinfo crossed a raw query/fragment delimiter — a `?token=…@tail` query
1227
+ * (no userinfo; the `@` rides the query) collapsed to `//***@tail`, consuming the `?` marker so the
1228
+ * follow-on `?query`/`#fragment` strip below found nothing and LEAKED `tail`. Bounding the userinfo at
1229
+ * `?`/`#` keeps the marker so the query strip redacts the whole tail (issue #778 review — thread
1230
+ * readiness.ts:1199). `[^/?#]*@` remains a
1231
+ * single-quantifier match, so it is
1232
+ * linear with no catastrophic backtracking. Callers that surface the result in a display artifact must
1233
+ * first pass it through `stripXmlInvalidChars` so an XML-forbidden control (e.g. U+000B) inside the
1234
+ * userinfo cannot split the `//…@` match and be re-joined at render. */
1162
1235
  export function redactString(s: string): string {
1163
- return s
1164
- .replace(/\/\/[^/@\s]*@/g, "//***@")
1165
- .replace(/[?#].*$/, (m) => `${m[0]}***`);
1236
+ return redactEmbeddedCredential(s).replace(/[?#][\s\S]*$/, (m) => `${m[0]}***`);
1237
+ }
1238
+
1239
+ /** Strip ONLY the embedded `//<userinfo>@` credential span(s) from a string, collapsing each to
1240
+ * `//***@` and leaving everything else (including any `?query`/`#fragment`, which may be a MEANINGFUL
1241
+ * opaque-token character rather than URL syntax) untouched. Shares the ONE canonical
1242
+ * {@link EMBEDDED_CREDENTIAL_SRC} span so "what counts as an embedded credential" can never drift from
1243
+ * the redactor ({@link redactString}) or the validator ({@link hasEmbeddedCredential}). A `//<userinfo>@`
1244
+ * span is UNAMBIGUOUSLY a credential wherever it sits — a plain opaque identifier
1245
+ * (`slack:#releases`, `owner/repo#42`, `pkg@version`) never contains one — so it is safe to strip
1246
+ * regardless of the value's URL-shape or the span's position, which is why {@link redactConnectorValue}
1247
+ * runs it on a NON-URL-shaped free-form value (a probe target / PR ref) whose credential rides AFTER a
1248
+ * prefix (`prefix //user:pass@host#42`) that the anchored {@link isUrlShaped} check would miss (issue
1249
+ * #778 review — thread deliveryGraph.ts:162/566). `[^/]*@` is a single-quantifier match — linear, no
1250
+ * catastrophic backtracking. */
1251
+ export function redactEmbeddedCredential(s: string): string {
1252
+ return s.replace(new RegExp(EMBEDDED_CREDENTIAL_SRC, "g"), "//***@");
1253
+ }
1254
+
1255
+ /** The ONE canonical embedded SCHEME-RELATIVE credential-URL token: a `//<userinfo>@authority…` run
1256
+ * (bounded by the next literal space) whose `//` carries userinfo. The `@` UNAMBIGUOUSLY marks the run a
1257
+ * URL — an opaque ref (`//host#42`, `slack:#releases`, `owner/repo#42`) never carries `//user@` — so its
1258
+ * `?query`/`#fragment` IS URL syntax and may hide a token, exactly like an explicit-scheme
1259
+ * {@link EMBEDDED_SCHEME_URL_SRC}. Distinct from the userinfo-ONLY {@link EMBEDDED_CREDENTIAL_SRC}, which
1260
+ * strips just the `//user:pass@` span and (correctly, for an opaque `//host#42`) LEAVES the tail: a
1261
+ * userinfo-bearing `//user:pass@host?token=secret` embedded after a non-URL prefix is scheme-relative,
1262
+ * so the anchored whole-value {@link isUrlShaped} check misses it (non-URL prefix) and the userinfo-only
1263
+ * strip leaks the `?token=secret` tail (issue #778 review — thread deliveryGraph.ts:188). Requiring the
1264
+ * `@` keeps genuine opaque `//host#42` refs (no userinfo) untouched. The token runs to the next literal
1265
+ * SPACE (`[^ ]*`), NOT the wider `\s` class, so it CROSSES an XML-valid internal TAB/LF/CR and still
1266
+ * redacts a `?query` secret sitting past it. The userinfo class is `[^/?#]` (NOT the earlier `[^/]`):
1267
+ * bounding it before a raw `?`/`#` stops the userinfo span crossing the query/fragment delimiter — a
1268
+ * `//host?token=secret@tail` (no userinfo; the `@` rides the query) no longer collapses to `//***@tail`
1269
+ * (leaking `tail`); it instead falls through to {@link redactEmbeddedSchemeRelativeUrl}, which strips the
1270
+ * whole `?query` (issue #778 review — thread readiness.ts:1199, mirroring the {@link EMBEDDED_CREDENTIAL_SRC}
1271
+ * fix). `[^/?#]*@` and `[^ ]*` are separated by the literal `@`
1272
+ * anchor, so the match stays linear with no catastrophic backtracking. */
1273
+ const EMBEDDED_CREDENTIAL_URL_SRC = "\\/\\/[^/?#]*@[^ ]*";
1274
+
1275
+ /** Redact each embedded SCHEME-RELATIVE credential-URL (`//<userinfo>@authority…`) token IN PLACE via
1276
+ * {@link redactString} — stripping its `//user:pass@` userinfo AND any `?query`/`#fragment` — while
1277
+ * leaving opaque `//host#42` refs (no userinfo) untouched. Lets {@link redactConnectorValue} catch a
1278
+ * query/fragment secret riding a credential-bearing scheme-relative URL after a non-URL prefix
1279
+ * (`prefix //user:pass@host?token=secret`) that the anchored {@link isUrlShaped} whole-value check AND
1280
+ * the userinfo-only {@link redactEmbeddedCredential} both miss (issue #778 review — thread
1281
+ * deliveryGraph.ts:188). Deterministic and total. */
1282
+ export function redactEmbeddedCredentialUrl(s: string): string {
1283
+ return s.replace(new RegExp(EMBEDDED_CREDENTIAL_URL_SRC, "g"), (m) => redactString(m));
1284
+ }
1285
+
1286
+ /** The ONE canonical embedded ABSOLUTE-URL token: an EXPLICIT-scheme `scheme://authority…` run bounded
1287
+ * by whitespace. Deliberately requires an explicit `scheme:` before the `//` (NOT the scheme-relative
1288
+ * `//authority` {@link isUrlShaped} also accepts): an absolute URL's `?query`/`#fragment` are
1289
+ * UNAMBIGUOUSLY URL syntax and may hide a token, so they are safe to strip; a scheme-relative
1290
+ * `//host#42` (or an opaque `slack:#releases`, `owner/repo#42`) instead carries a MEANINGFUL opaque
1291
+ * `#`/`?` (a `parsePrTarget` PR ref) that must survive, so it is left to the userinfo-only
1292
+ * {@link redactEmbeddedCredential}. The scheme may be followed by XML-attribute whitespace
1293
+ * (`\s*` — TAB/LF/CR/space) BEFORE the `//` authority, exactly like {@link isUrlShaped}: those are valid
1294
+ * XML `Char`s that `stripXmlInvalidChars` does NOT remove, so an embedded `scheme<whitespace>//authority`
1295
+ * after a non-URL prefix (`prefix https:\t//user:pass@host?token=secret`) is not whole-value URL-shaped,
1296
+ * escapes the anchored {@link isUrlShaped} check, and — without this `\s*` — was missed here too, leaving
1297
+ * the fallback {@link redactEmbeddedCredential} to strip only the `//…@` userinfo and LEAK the
1298
+ * `?token=secret` tail. Keeping this token's `scheme:` + `\s*` + `//` shape aligned with `isUrlShaped`
1299
+ * closes that gap (issue #778 review — thread readiness.ts:1239). The token then runs to the next literal
1300
+ * (`[^ ]+`), NOT the wider `\s` class, so it CROSSES an XML-valid internal TAB/LF/CR (0x09/0x0A/0x0D —
1301
+ * the only whitespace `stripXmlInvalidChars` keeps) and still redacts a `?query`/`#fragment` secret
1302
+ * sitting past it (`https://host/pa\tth?token=secret`); a `\s`-bounded token stopped at that internal
1303
+ * whitespace and leaked the tail (#778 review — thread deliveryGraph.ts:188). Over-redacting across an
1304
+ * internal newline is the safe direction. `\s*` and `[^ ]+` are separated by the literal `:`/`//`
1305
+ * anchors, so the match stays linear with no catastrophic backtracking. */
1306
+ const EMBEDDED_SCHEME_URL_SRC = "[a-z][a-z0-9+.-]*:\\s*\\/\\/[^ ]+";
1307
+
1308
+ /** Redact each embedded ABSOLUTE-URL (explicit `scheme://…`) token in a free-form value IN PLACE via
1309
+ * {@link redactString} — stripping that token's `user:pass@` userinfo AND `?query`/`#fragment` — while
1310
+ * leaving surrounding prose and any scheme-relative/opaque `//…`/`#`/`?` run untouched (those are
1311
+ * handled, credential-only, by {@link redactEmbeddedCredential}). Lets {@link redactConnectorValue}
1312
+ * catch a query/fragment secret riding an embedded absolute URL after a non-URL prefix
1313
+ * (`prefix https://host/path?token=secret`) that the anchored {@link isUrlShaped} whole-value check
1314
+ * misses (issue #778 review — thread deliveryGraph.ts:181). Deterministic and total. */
1315
+ export function redactEmbeddedUrl(s: string): string {
1316
+ return s.replace(new RegExp(EMBEDDED_SCHEME_URL_SRC, "gi"), (m) => redactString(m));
1317
+ }
1318
+
1319
+ /** The ONE canonical embedded SCHEME-RELATIVE URL token that carries a `?query`: a userinfo-LESS
1320
+ * `//authority…?query…` run (bounded by the next literal space). Distinct from the userinfo-bearing
1321
+ * {@link EMBEDDED_CREDENTIAL_URL_SRC} (which the `@` marks a URL) and the explicit-scheme
1322
+ * {@link EMBEDDED_SCHEME_URL_SRC}: a `senior:feature //host?token=secret` embedded after a non-URL prefix
1323
+ * has NO userinfo `@` and NO explicit `scheme:`, so the anchored {@link isUrlShaped} whole-value check,
1324
+ * {@link redactEmbeddedCredentialUrl}, and {@link redactEmbeddedUrl} ALL miss it — leaking the
1325
+ * `?token=secret` tail (issue #778 review — thread deliveryGraph.ts:633). A scheme-relative `//authority`
1326
+ * whose `?` is present is UNAMBIGUOUSLY URL syntax — an opaque ref (`owner/repo#42`, `//host#42`) uses a
1327
+ * `#<digits>` fragment (`parsePrTarget`), never a `//…?…` query — so its query is safe to strip while a
1328
+ * userinfo-less opaque `//host#42` (no `?`) is left to keep its meaningful `#42`. The token runs to the
1329
+ * next literal SPACE (`[^ ]*`), NOT the wider `\s` class, so it CROSSES an XML-valid internal TAB/LF/CR
1330
+ * and still redacts a `?query` secret sitting past it. `[^ ]*` before/after the literal `?` anchor keeps
1331
+ * the match linear with no catastrophic backtracking. */
1332
+ const EMBEDDED_SCHEME_RELATIVE_URL_SRC = "\\/\\/[^ ]*\\?[^ ]*";
1333
+
1334
+ /** Redact each embedded SCHEME-RELATIVE URL (`//authority…?query…`, no userinfo, no explicit scheme)
1335
+ * token IN PLACE via {@link redactString} — stripping its `?query`/`#fragment` — while leaving a
1336
+ * userinfo-less opaque `//host#42` PR ref (no `?`) untouched. Lets {@link redactConnectorValue} catch a
1337
+ * query secret riding a scheme-relative URL after a non-URL prefix (`prefix //host?token=secret`) that
1338
+ * the anchored {@link isUrlShaped} check, {@link redactEmbeddedCredentialUrl}, and {@link redactEmbeddedUrl}
1339
+ * all miss (issue #778 review — thread deliveryGraph.ts:633). Deterministic and total. */
1340
+ export function redactEmbeddedSchemeRelativeUrl(s: string): string {
1341
+ return s.replace(new RegExp(EMBEDDED_SCHEME_RELATIVE_URL_SRC, "g"), (m) => redactString(m));
1342
+ }
1343
+
1344
+ /** The canonical scheme-relative `//` authority marker — the `//` that opens an authority in ANY URL
1345
+ * form (absolute `scheme://`, scheme-relative `//`, credential `//user@`). A worker-routing job type
1346
+ * never legitimately contains it. */
1347
+ const SCHEME_RELATIVE_AUTHORITY_SRC = "\\/\\/";
1348
+
1349
+ /** True when `value` embeds a scheme-relative `//authority` token (see {@link SCHEME_RELATIVE_AUTHORITY_SRC}).
1350
+ * A plain worker-routing job type / opaque id NEVER contains `//` — so a match (whether it carries
1351
+ * userinfo, an explicit scheme, a `?query`, a `#fragment`, or nothing) is illegitimate in an
1352
+ * `agent.jobType` and must be rejected, closing the userinfo-less scheme-relative gap
1353
+ * (`senior:feature //host?token=secret`) that {@link hasEmbeddedCredential} (needs `@`),
1354
+ * {@link hasEmbeddedUrl} (needs an explicit scheme), and the anchored {@link isUrlShaped} (whole-value
1355
+ * only) all miss (issue #778 review — thread deliveryGraph.ts:633). */
1356
+ export function hasSchemeRelativeAuthority(value: string): boolean {
1357
+ return new RegExp(SCHEME_RELATIVE_AUTHORITY_SRC).test(value);
1358
+ }
1359
+
1360
+ /** True when `value` embeds a `//<userinfo>@host` credential token (see {@link EMBEDDED_CREDENTIAL_SRC};
1361
+ * the userinfo colon is optional, so a passwordless `//token@host` bearer token also matches).
1362
+ * A plain worker-routing job type / opaque id never contains one, so a match is a credential leak to reject. */
1363
+ export function hasEmbeddedCredential(value: string): boolean {
1364
+ return new RegExp(EMBEDDED_CREDENTIAL_SRC).test(value);
1365
+ }
1366
+
1367
+ /** True when `value` embeds an absolute-URL token (`scheme://authority…`; see
1368
+ * {@link EMBEDDED_SCHEME_URL_SRC}). A plain worker-routing job type / opaque id never contains one, so a
1369
+ * match is a URL whose `?query`/`#fragment` can hide a token and which — since the executable
1370
+ * `<zeebe:taskDefinition type=…>` carries the job type VERBATIM — would leak into the compiled BPMN.
1371
+ * Distinct from {@link hasEmbeddedCredential}: that catches only a `//<userinfo>@` credential span and
1372
+ * misses a userinfo-less `senior:feature https://host?token=secret` (issue #778 review — thread
1373
+ * deliveryGraph.ts:602). */
1374
+ export function hasEmbeddedUrl(value: string): boolean {
1375
+ return new RegExp(EMBEDDED_SCHEME_URL_SRC, "i").test(value);
1376
+ }
1377
+
1378
+ /** The ONE canonical "is this value a URL?" classifier — a `scheme://authority` OR scheme-relative
1379
+ * `//authority` form, either of which can hide a credential in userinfo/query/fragment. It is the single
1380
+ * source of truth for URL-shape detection shared by the connector/jobType display redactor
1381
+ * ({@link redactString} callers in the compiler) AND the semantic validation boundary
1382
+ * (`validateDeliveryGraph`), so the "what counts as credential-bearing/URL-shaped" rule can never drift
1383
+ * between the redact path and the reject path (issue #778 review — thread deliveryGraphCompiler.ts:1606).
1384
+ * Classify on the TRIMMED value so leading whitespace (` //user:pass@host` — the OpenAPI edge caps
1385
+ * length but does not trim) cannot bypass the anchored check. The scheme may be followed by
1386
+ * XML-attribute whitespace (TAB/LF/CR/space) before the `//` authority: those characters are valid XML
1387
+ * `Char`s that `stripXmlInvalidChars` does NOT remove, so `https:\t//user:pass@host` must still classify
1388
+ * as URL-shaped or its credential escapes redaction as "not a URL" (issue #778 review — thread
1389
+ * readiness.ts:1191). Deterministic and total. */
1390
+ export function isUrlShaped(value: string): boolean {
1391
+ return /^([a-z][a-z0-9+.-]*:\s*)?\/\//i.test(value.trim());
1166
1392
  }
@@ -0,0 +1,37 @@
1
+ -- Delivery Graphs: persist a LOSSLESS graph-identity fingerprint per run in a dedicated SIDE TABLE
2
+ -- (issue #778 review — thread dispatchDeliveryGraph.ts:332).
3
+ --
4
+ -- A run is keyed by `run_key` (an explicit operator `idempotencyKey`, else the content `digest`). The
5
+ -- `digest` is content-addressed over the REDACTED `semanticBpmn`, so two graphs differing ONLY in a
6
+ -- redacted-away secret (a URL credential, a `command` target, a match secret, a free-form connector
7
+ -- `payload`, …) SHARE one digest. When an operator supplies an explicit `idempotencyKey`, a re-dispatch
8
+ -- short-circuits onto the existing running row — but the lossy `digest` on that row cannot prove the
9
+ -- short-circuited run is THIS exact secret-bearing graph vs. a credential-different graph re-staged under
10
+ -- the same key. The dispatch door therefore refused EVERY such short-circuit with 409, which also broke
11
+ -- the idempotency contract for a legitimate SAME-payload retry (a lost response / double-click of the
12
+ -- identical proposal): it received 409 and lingered `staged` instead of short-circuiting successfully.
13
+ --
14
+ -- `graph_fingerprint` stores the run's lossless content identity — `sha256(digest \0
15
+ -- canonicalJson(digestInvisibleRawValues(graph)))` (the SAME identity `stableProposalRunKey` folds into
16
+ -- the keyless run key) — captured at launch. On an explicit-key short-circuit the door compares the
17
+ -- incoming graph's fingerprint against the stored one: a MATCH is a same-payload retry (allow the
18
+ -- short-circuit + consume the proposal); a MISMATCH — or a MISSING row (a run that predates this table)
19
+ -- — is unprovable, so the door refuses (the safe pre-existing 409 behaviour) and it self-heals once the
20
+ -- run turns over.
21
+ --
22
+ -- WHY A SIDE TABLE, not a column on `delivery_graph_runs`: that base table is projected COLUMN-FOR-COLUMN
23
+ -- by several drift-guarded surfaces — the `delivery_graph_runs__units` compat VIEW served from the
24
+ -- `delivery_units` aggregate (091/098) and the `delivery_graph_read_model`/`__tracking` VIEWs (087/096),
25
+ -- each of which asserts full base-column pass-through parity. This identity fingerprint is an INTERNAL
26
+ -- dispatch concern surfaced to no read model, so it belongs off to the side rather than threaded through
27
+ -- (and re-drift-guarded across) every projection.
28
+ --
29
+ -- Forward-only, additive (expand). The runner wraps each file in its own transaction — no BEGIN/COMMIT.
30
+ -- Numbered after 112 (the current highest on origin/main; this branch forked before 103–112 and must not
31
+ -- reuse a prefix main already occupies).
32
+
33
+ CREATE TABLE IF NOT EXISTS delivery_graph_run_identity (
34
+ run_key TEXT PRIMARY KEY,
35
+ graph_fingerprint TEXT NOT NULL,
36
+ created_at TEXT NOT NULL
37
+ );
@@ -0,0 +1,21 @@
1
+ -- A MONOTONIC per-write stage revision for delivery-graph proposals (issue #778 review, thread
2
+ -- app/deliveryGraphProposals.ts:245). The supersede reconcile ("exactly one live proposal per logical
3
+ -- graph, the LATEST stage wins") breaks a same-millisecond `updated_at` tie between two staged siblings
4
+ -- with a deterministic secondary key. That key used to be SQLite's `rowid` (insertion order) — but a
5
+ -- RE-STAGE of an existing digest is an `UPDATE` that bumps `updated_at` while LEAVING the `rowid`
6
+ -- unchanged, so a re-stage that lands in the same millisecond as a different digest's fresh stage could
7
+ -- be selected as the OLDER row (its stale, lower rowid) and be immediately superseded, inverting the
8
+ -- "latest stage wins" invariant this tie-break exists to uphold.
9
+ --
10
+ -- `stage_seq` fixes that: it is reassigned to `MAX(stage_seq)+1` on EVERY stage write — a fresh insert
11
+ -- AND a re-stage update alike — so the most-recently-written row always holds the strictly-highest value
12
+ -- regardless of its rowid or a coincident `updated_at`. Additive/expand (nullable-equivalent `DEFAULT 0`,
13
+ -- no data rewrite): existing rows read as `0` and are re-sequenced the next time they are staged.
14
+ ALTER TABLE delivery_graph_proposals ADD COLUMN stage_seq INTEGER NOT NULL DEFAULT 0;
15
+
16
+ -- Index `stage_seq` so the atomic `MAX(stage_seq)+1` allocation in `stageProposal` (issue #778 review,
17
+ -- thread app/deliveryGraphProposals.ts:237) is an O(1) reverse-index seek of the single highest value
18
+ -- rather than a full scan of the retained proposal history (staged + terminal rows accumulate; a bare
19
+ -- aggregate would grow linearly with them).
20
+ CREATE INDEX IF NOT EXISTS idx_delivery_graph_proposals_stage_seq
21
+ ON delivery_graph_proposals (stage_seq);
@@ -13,8 +13,10 @@ import { join, resolve } from "node:path";
13
13
  import { after, describe, test } from "node:test";
14
14
  import assert from "node:assert/strict";
15
15
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
16
+ import { redactFreeText } from "../app/deliveryGraphCompiler.ts";
16
17
  import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
17
18
  import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
19
+ import { isEquivalentReStage, stableProposalRunKey } from "./dispatchDeliveryGraph.ts";
18
20
 
19
21
  const APP_ROOT = resolve(import.meta.dirname, "..");
20
22
  const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
@@ -32,6 +34,74 @@ const SIDE_EFFECTING = {
32
34
  edges: [{ from: "open-b", to: "publish" }],
33
35
  };
34
36
 
37
+ test("stableProposalRunKey: digest identity + secret disambiguation (issue #778 review)", () => {
38
+ // The run key is derived from the proposal's semantic `digest` (which the compiler already normalises
39
+ // — collapsing omitted-vs-default fields and top-level node/edge REORDER) PLUS a fingerprint of the
40
+ // digest-invisible raw values. So a re-stage of the SAME logical graph in ANY encoding sharing the
41
+ // digest short-circuits, while a credential-differing graph (same digest, different secret) diverges.
42
+ const D1 = "sha-aaa";
43
+ const D2 = "sha-bbb";
44
+ const credA = {
45
+ name: "g",
46
+ nodes: [{ id: "n", kind: "connector", connector: { target: "//user:pass@host" } }],
47
+ edges: [],
48
+ };
49
+ // Same digest + identical graph → same key (a double-click / re-dispatch short-circuits).
50
+ assert.equal(stableProposalRunKey(D1, credA), stableProposalRunKey(D1, credA));
51
+ // Same digest but a DIFFERENT redacted-away credential — both redact to the same `//***@host` display
52
+ // so they SHARE the digest — must yield a DIFFERENT key, else the second dispatch collapses onto the
53
+ // first's still-running instance.
54
+ const credB = { name: "g", nodes: [{ id: "n", kind: "connector", connector: { target: "//other:secret@host" } }], edges: [] };
55
+ assert.notEqual(stableProposalRunKey(D1, credA), stableProposalRunKey(D1, credB));
56
+ // A genuinely different semantic graph carries a DIFFERENT digest → a different key even with no secrets.
57
+ const plain = { name: "g", nodes: [{ id: "n", kind: "human", human: { prompt: "x" } }], edges: [] };
58
+ assert.notEqual(stableProposalRunKey(D1, plain), stableProposalRunKey(D2, plain));
59
+ // A graph with NO digest-invisible content has an EMPTY fingerprint, so its key is a pure function of
60
+ // the digest: a re-stage in a DIFFERENT object-key encoding (same digest) short-circuits.
61
+ const plainReordered = { edges: [], name: "g", nodes: [{ human: { prompt: "x" }, kind: "human", id: "n" }] };
62
+ assert.equal(stableProposalRunKey(D1, plain), stableProposalRunKey(D1, plainReordered));
63
+ assert.ok(stableProposalRunKey(D1, credA).startsWith("staged-"), "the key is self-describing");
64
+ });
65
+
66
+ test("stableProposalRunKey: node-ORDER of digest-invisible content does not fork the key (issue #778 review)", () => {
67
+ // The compiler SORTS nodes before emitting `semanticBpmn`, so two graphs differing ONLY in top-level
68
+ // node order share one digest. Their digest-invisible raw values (`digestInvisibleRawValues`) must
69
+ // therefore fingerprint identically too — else a re-stage of the same logical graph in a different
70
+ // node encoding forks a DISTINCT run-key and double-launches instead of short-circuiting. The
71
+ // fingerprint is code-unit sorted at its source, making it reorder-invariant like the digest.
72
+ const D = "sha-order";
73
+ const ab = {
74
+ name: "g",
75
+ nodes: [
76
+ { id: "a", kind: "connector", connector: { target: "//user:pass@host" } },
77
+ { id: "b", kind: "connector", connector: { target: "//other:secret@host" } },
78
+ ],
79
+ edges: [],
80
+ };
81
+ const ba = {
82
+ name: "g",
83
+ nodes: [
84
+ { id: "b", kind: "connector", connector: { target: "//other:secret@host" } },
85
+ { id: "a", kind: "connector", connector: { target: "//user:pass@host" } },
86
+ ],
87
+ edges: [],
88
+ };
89
+ assert.equal(stableProposalRunKey(D, ab), stableProposalRunKey(D, ba), "a top-level node reorder must not fork the run-key");
90
+ });
91
+
92
+ test("isEquivalentReStage: only a live staged row whose RAW graph JSON is byte-identical to the launched graph is an equivalent re-stage — a credential-different re-stage (same digest) is NOT, and a retired/consumed row (null) is NOT (issue #778 review — thread dispatchDeliveryGraph.ts:320)", () => {
93
+ const launched = JSON.stringify({ name: "g", nodes: [{ id: "n", kind: "connector", connector: { target: "//user:pass@host" } }], edges: [] });
94
+ // A byte-identical re-stage IS equivalent — the door consumes the duplicate at its bumped revision.
95
+ assert.equal(isEquivalentReStage({ graph: launched }, launched), true);
96
+ // A credential-different re-stage shares the redacted digest but its RAW graph differs, so it is a
97
+ // newer, never-launched revision the door must leave `staged`.
98
+ const credDifferent = JSON.stringify({ name: "g", nodes: [{ id: "n", kind: "connector", connector: { target: "//other:secret@host" } }], edges: [] });
99
+ assert.equal(isEquivalentReStage({ graph: credDifferent }, launched), false);
100
+ // No live staged row (consumed/dismissed/superseded/expired) → not equivalent, nothing to consume.
101
+ assert.equal(isEquivalentReStage(null, launched), false);
102
+ assert.equal(isEquivalentReStage(undefined, launched), false);
103
+ });
104
+
35
105
  describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest", () => {
36
106
  const dirs: string[] = [];
37
107
  const apps: TestApp[] = [];
@@ -165,6 +235,138 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
165
235
  assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
166
236
  });
167
237
 
238
+ test("a SAME-payload retry of a secret-bearing graph under the SAME idempotencyKey (a lost response / double-click) → 202 short-circuit, the proposal IS consumed and NO second run launches — the persisted identity fingerprint PROVES the running run is this exact graph, so the idempotency contract holds instead of a spurious 409 (issue #778 review — thread dispatchDeliveryGraph.ts:332)", async () => {
239
+ const app = await boot();
240
+ assert.ok(app.api);
241
+ const api = app.api;
242
+ let agentFired = 0;
243
+ await app.engine.registerWorker("senior:demo", async () => {
244
+ agentFired++;
245
+ return {};
246
+ });
247
+
248
+ // Stage + dispatch a SECRET-BEARING graph (SIDE_EFFECTING carries redacted-away secret material —
249
+ // the same graph the cross-graph 409 case above refuses) under an explicit idempotencyKey. It
250
+ // launches and parks on its human node, so its run stays `running`.
251
+ const first = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
252
+ const dispatched = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
253
+ body: { digest: first.body.digest, idempotencyKey: "retry-key", repoless: true },
254
+ });
255
+ assert.equal(dispatched.status, 202);
256
+ await app.settle();
257
+ assert.equal(agentFired, 1, "the side effect fired exactly once on the first dispatch");
258
+ assert.equal((await deliveryGraphProposals(app.db).get(first.body.digest))?.status, "dispatched");
259
+
260
+ // Re-stage the IDENTICAL graph (byte-for-byte the same → same digest) and re-dispatch under the
261
+ // SAME key — the caller retrying a lost/duplicated request. The running run's persisted fingerprint
262
+ // matches this graph, so the door short-circuits onto it (202, alreadyRunning) and CONSUMES the
263
+ // re-staged proposal — it does NOT 409 as a cross-graph collision would.
264
+ const restaged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
265
+ assert.equal(restaged.body.digest, first.body.digest);
266
+ assert.equal((await deliveryGraphProposals(app.db).get(restaged.body.digest))?.status, "staged");
267
+ const retry = await api.call<{ ok: boolean; status: string; alreadyRunning?: boolean }>("dispatchDeliveryGraph", {
268
+ body: { digest: restaged.body.digest, idempotencyKey: "retry-key", repoless: true },
269
+ });
270
+ assert.equal(retry.status, 202);
271
+ assert.equal(retry.body.ok, true);
272
+ assert.equal(retry.body.alreadyRunning, true);
273
+ await app.settle();
274
+ // The retry short-circuited onto the existing run — the side effect did NOT fire a second time and
275
+ // no second run launched.
276
+ assert.equal(agentFired, 1, "a same-payload retry does not re-fire the side effect");
277
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
278
+ // The re-staged proposal was consumed (marked dispatched), not left lingering.
279
+ assert.equal((await deliveryGraphProposals(app.db).get(restaged.body.digest))?.status, "dispatched");
280
+ });
281
+
282
+ test("an explicit idempotencyKey short-circuit onto a running SECRET-BEARING run, re-dispatched from a graph authored AS the redacted form (graphCarriesRedactedSecrets === false) that SHARES the digest → 409; the proposal is NOT consumed (the refusal must not be gated on the INCOMING graph's redaction side — identity is unprovable whenever !identityConfirmed) (thread dispatchDeliveryGraph.ts:320)", async () => {
283
+ const app = await boot();
284
+ assert.ok(app.api);
285
+ const api = app.api;
286
+
287
+ // Graph 1 CARRIES a redacted-away credential in its human prompt — its content-addressed digest is
288
+ // over the REDACTED display, so it is NOT a faithful identity. It parks on its human node and stays
289
+ // running under an explicit idempotencyKey.
290
+ const secretPrompt = "click done: https://user:pass@host";
291
+ const SECRET = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: secretPrompt } }] };
292
+ // Graph 2 is authored LITERALLY as the redacted form of graph 1's prompt. Redaction is a no-op on it,
293
+ // so `graphCarriesRedactedSecrets` is FALSE, yet it SHARES graph 1's digest (both digest over the same
294
+ // redacted display). It is a DIFFERENT graph from the running secret-bearing run.
295
+ const redactedTwin = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: redactFreeText(secretPrompt) } }] };
296
+
297
+ // Stage + dispatch the SECRET-bearing graph FIRST (before the twin re-stages over its same-digest
298
+ // row) under an explicit key — it launches and parks (running), stamping its LOSSLESS fingerprint.
299
+ const g1 = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET });
300
+ const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
301
+ body: { digest: g1.body.digest, idempotencyKey: "shared-key", repoless: true },
302
+ });
303
+ assert.equal(first.status, 202);
304
+ await app.settle();
305
+ assert.equal((await deliveryGraphProposals(app.db).get(g1.body.digest))?.status, "dispatched");
306
+
307
+ // Now re-stage the redacted-twin at the SAME digest (a DIFFERENT graph re-staged over the consumed
308
+ // row) and dispatch it under the SAME key. It short-circuits onto the still-running SECRET run — but
309
+ // that run is a different graph, so its persisted fingerprint does NOT match (identityConfirmed ===
310
+ // false). The digest COLLISION is the crux: the twin shares graph 1's digest yet carries no redacted
311
+ // secret. The refusal must fire even though the INCOMING graph carries no redacted secret; otherwise
312
+ // the twin's proposal is marked `dispatched` while it never launched.
313
+ const twin = await api.call<{ digest: string }>("compileDeliveryGraph", { body: redactedTwin });
314
+ assert.equal(twin.body.digest, g1.body.digest);
315
+ assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
316
+ const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
317
+ body: { digest: twin.body.digest, idempotencyKey: "shared-key", repoless: true },
318
+ });
319
+ assert.equal(second.status, 409);
320
+ assert.equal(second.body.ok, false);
321
+ await app.settle();
322
+ // The twin proposal is still staged — it was never launched (the running run is the secret graph).
323
+ assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
324
+ // Only the single secret run exists — the twin dispatch launched nothing.
325
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
326
+ });
327
+
328
+ test("a KEYLESS short-circuit onto a running graph whose run key equals the shared digest (an earlier explicit `idempotencyKey === digest`), re-dispatched from a redacted-twin that SHARES the digest → 409; the proposal is NOT consumed. The ambiguous-identity refusal must gate EVERY `alreadyRunning && !identityConfirmed`, not only explicit-key requests — a keyless faithful-digest dispatch collides on the digest run key just the same (thread dispatchDeliveryGraph.ts:325)", async () => {
329
+ const app = await boot();
330
+ assert.ok(app.api);
331
+ const api = app.api;
332
+
333
+ // Graph 1 CARRIES a redacted-away credential — its digest is over the REDACTED display, NOT a faithful
334
+ // identity. Dispatch it under an explicit `idempotencyKey` set to its OWN digest, so the run is keyed
335
+ // by the digest itself and parks running, stamping graph 1's LOSSLESS fingerprint.
336
+ const secretPrompt = "click done: https://user:pass@host";
337
+ const SECRET = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: secretPrompt } }] };
338
+ // Graph 2 is authored LITERALLY as the redacted form — redaction is a no-op on it, so
339
+ // `graphCarriesRedactedSecrets` is FALSE. Because it is faithful, a KEYLESS dispatch leaves the run key
340
+ // undefined and the core falls back to the digest — which it SHARES with graph 1, colliding onto that
341
+ // still-running secret run even though NO explicit key was supplied.
342
+ const redactedTwin = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: redactFreeText(secretPrompt) } }] };
343
+
344
+ const g1 = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET });
345
+ const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
346
+ body: { digest: g1.body.digest, idempotencyKey: g1.body.digest, repoless: true },
347
+ });
348
+ assert.equal(first.status, 202);
349
+ await app.settle();
350
+ assert.equal((await deliveryGraphProposals(app.db).get(g1.body.digest))?.status, "dispatched");
351
+
352
+ // Re-stage the twin at the SAME digest and dispatch it WITHOUT a key. It short-circuits onto the
353
+ // running secret run (same digest run key), but that run is a different graph → identityConfirmed is
354
+ // false. The refusal must fire despite the request being keyless; otherwise the twin is marked
355
+ // `dispatched` while it never launched.
356
+ const twin = await api.call<{ digest: string }>("compileDeliveryGraph", { body: redactedTwin });
357
+ assert.equal(twin.body.digest, g1.body.digest);
358
+ assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
359
+ const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
360
+ body: { digest: twin.body.digest, repoless: true },
361
+ });
362
+ assert.equal(second.status, 409);
363
+ assert.equal(second.body.ok, false);
364
+ await app.settle();
365
+ // The twin proposal is still staged — nothing new launched.
366
+ assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
367
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
368
+ });
369
+
168
370
  test("a proposal whose stored graph is corrupt JSON → 400 AND the proposal is retired (expired), never lingering staged", async () => {
169
371
  const app = await boot();
170
372
  assert.ok(app.api);
@@ -251,6 +453,59 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
251
453
  assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
252
454
  });
253
455
 
456
+ test("a SECRET-BEARING staged graph dispatches through the cockpit UI WITHOUT an idempotencyKey — the door supplies a stable server-side key (#778)", async () => {
457
+ // Regression: Option C's dispatch gate refuses a keyless dispatch of a graph whose digest is lossy,
458
+ // but the cockpit staged-proposals UI posts no idempotency-key field. The door must therefore derive
459
+ // a stable server-side key from the stored proposal so these proposals remain launchable.
460
+ const app = await boot();
461
+ assert.ok(app.api);
462
+ const api = app.api;
463
+ const SECRET = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:pass@host.example/repo" } }] };
464
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET });
465
+ // No idempotencyKey in the body — exactly what the staged UI posts.
466
+ const res = await api.call<{ ok: boolean; status: string; runKey: string }>("dispatchDeliveryGraph", {
467
+ body: { digest: staged.body.digest, repoless: true },
468
+ });
469
+ assert.equal(res.status, 202);
470
+ assert.equal(res.body.ok, true);
471
+ assert.equal(res.body.status, "running");
472
+ assert.ok(res.body.runKey.startsWith("staged-"), `expected a server-side staged- key, got ${res.body.runKey}`);
473
+ await app.settle();
474
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
475
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
476
+ });
477
+
478
+ test("an explicit idempotencyKey re-used across two SECRET-differing graphs that SHARE a digest is refused at short-circuit → 409; the re-staged proposal is NOT consumed and nothing new launches (#778 review — thread dispatchDeliveryGraph.ts:282)", async () => {
479
+ const app = await boot();
480
+ assert.ok(app.api);
481
+ const api = app.api;
482
+ // Two graphs differing ONLY in a redacted-away credential redact to the SAME `semanticBpmn`, so they
483
+ // share ONE content digest and therefore ONE digest-keyed proposal row.
484
+ const SECRET_A = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:AAAsecret@host.example/repo" } }] };
485
+ const SECRET_B = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:BBBsecret@host.example/repo" } }] };
486
+ const a = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET_A });
487
+ // Dispatch A under an explicit shared key — it launches and parks (running).
488
+ const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
489
+ body: { digest: a.body.digest, idempotencyKey: "shared-key", repoless: true },
490
+ });
491
+ assert.equal(first.status, 202);
492
+ await app.settle();
493
+ // B re-stages the SAME digest-keyed proposal row (its credential differs, but the digest is identical).
494
+ const b = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET_B });
495
+ assert.equal(b.body.digest, a.body.digest, "the two secret-differing graphs must share one redacted digest");
496
+ // Dispatch B under the SAME shared key — it short-circuits onto A's still-running run. Because the
497
+ // digest cannot prove the running run is THIS (secret-bearing) graph, B must NOT be consumed: 409.
498
+ const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
499
+ body: { digest: b.body.digest, idempotencyKey: "shared-key", repoless: true },
500
+ });
501
+ assert.equal(second.status, 409);
502
+ assert.equal(second.body.ok, false);
503
+ await app.settle();
504
+ // The proposal is still staged (never falsely consumed) and only A's single run exists.
505
+ assert.equal((await deliveryGraphProposals(app.db).get(b.body.digest))?.status, "staged");
506
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
507
+ });
508
+
254
509
  test("a malformed `repository` is rejected at submit → 400, nothing launched (#684/#686)", async () => {
255
510
  const app = await boot();
256
511
  assert.ok(app.api);