@nanobpm/nano-workforce 0.189.3 → 0.191.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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);
@@ -113,7 +113,7 @@ The closed set (extensible only by a deliberate ADR/PR, never by graph authors):
113
113
  > PR back to `converging`. `submitPr`'s own `prKey` idempotency additionally makes a resumed re-perform
114
114
  > double-safe on a still-live row. This retires the manual `land-*` human gate whose only job was "go run convergence
115
115
  > yourself" — the canonical shape is now `agent (opens PR) → connector[converge-merge] →
116
- > wait[pr, merged]` with no human node. The payload is `{ pr, convergeOnly?, dependsOn? }`; the `pr`
116
+ > wait[pr, merged]` with no human node. The payload is `{ pr, autoMerge?, dependsOn? }`; the `pr`
117
117
  > may be a literal `owner/repo#N`, a `<node>.pr` fact reference late-bound from an upstream `agent`
118
118
  > node's emitted `pr` fact, or omitted to auto-bind the single incoming `pr` fact (issue #548 —
119
119
  > shipped: the connector resolves it via `resolveConvergePr`, and the `wait[pr]` target is late-bound
@@ -73,7 +73,7 @@ The body is flat. Fields:
73
73
  | field | type | meaning |
74
74
  |---|---|---|
75
75
  | `pr` (or `url`) | string | the PR — `owner/repo#123` or a full PR URL. Required. |
76
- | `convergeOnly` | boolean | **`true` = review only.** The PR stops at `converged` and is **never** handed to the merge loop, even when `NANO_PR_AUTO_MERGE` is on. Omit / `false` = converge **then merge**. |
76
+ | `autoMerge` | boolean | **Positive per-request setting.** `false` = review only, stopping at `converged`, regardless of the global default; `true` = converge **then merge** when `NANO_PR_AUTO_MERGE` is enabled. |
77
77
  | `maxRounds` | integer | per-submit cap before escalating (clamped 1–100; default from `NANO_PR_MAX_ROUNDS`, 20). |
78
78
  | `dependsOn` | string[] | other `prKey`s that must land before this one merges (merge-loop barrier). |
79
79
 
@@ -84,12 +84,12 @@ The body is flat. Fields:
84
84
  # or is only after a review pass):
85
85
  curl -sS -X POST __BASE__/actions/start/convergence-loop \
86
86
  -H 'content-type: application/json' \
87
- -d '{ "pr": "owner/repo#123", "convergeOnly": true }'
87
+ -d '{ "pr": "owner/repo#123", "autoMerge": false }'
88
88
 
89
89
  # Converge then merge, with a dependency barrier and a tighter round cap:
90
90
  curl -sS -X POST __BASE__/actions/start/convergence-loop \
91
91
  -H 'content-type: application/json' \
92
- -d '{ "pr": "owner/repo#42", "maxRounds": 8, "dependsOn": ["owner/repo#40"] }'
92
+ -d '{ "pr": "owner/repo#42", "autoMerge": true, "maxRounds": 8, "dependsOn": ["owner/repo#40"] }'
93
93
  ```
94
94
 
95
95
  Submitting is **idempotent on the PR key** — re-POSTing the same PR refreshes the
@@ -311,7 +311,7 @@ the answer becomes the agent's next-round context.
311
311
  ```
312
312
  submit ──► convergence-loop
313
313
  round (senior:pr-review) ──► addressed ──► wait review-ready ─┐
314
- ├─ converged ──► finalize ──► merge-loop (unless convergeOnly)
314
+ ├─ converged ──► finalize ──► merge-loop (when autoMerge and NANO_PR_AUTO_MERGE are enabled)
315
315
  └─ needs_input/blocked ──► escalate ──► wait-answer userTask (task inbox)
316
316
  merge-loop: wait deps ─► arm merge ─► (queue-aware) merge / land
317
317
  blocked (CI red) ─► senior:fix-ci ─► retry conflict ─► senior:rebase ─► retry
@@ -774,13 +774,13 @@ only job was "go run convergence yourself".
774
774
  - **`converge-merge`** — the **unit-level** land: drive review convergence **and then the merge
775
775
  loop**, landing the PR onto **its own base branch** (for a unit inside an epic that base is the
776
776
  epic integration branch, never `main` directly — ADR 0003 base-branch admission). Equivalent to a
777
- submit with `convergeOnly: false`.
777
+ submit with `autoMerge: true`.
778
778
  - **`merge-main`** — the **graph-level** top-level land: the second level of the two-level merge
779
779
  (ADR 0006 §3), landing the graph/epic **integration** PR onto **`main`**. Dispatch-identical to
780
780
  `converge-merge` (both enroll + merge); the distinction is the *level*, kept a first-class literal
781
781
  so the two levels are authored explicitly rather than left emergent.
782
782
  - **`converge`** — **converge-only**: drive review convergence and stop at `converged`, never
783
- handing off to the merge loop (equivalent to `convergeOnly: true`).
783
+ handing off to the merge loop (equivalent to `autoMerge: false`).
784
784
 
785
785
  > **converge/merge are cell POLICY, not raw nodes.** A raw `senior:converge` / `senior:merge`
786
786
  > **agent** job is **not expressible** — the compiler rejects it (`raw-converge-node`). Express
@@ -788,10 +788,10 @@ only job was "go run convergence yourself".
788
788
  > policy flags (`merge` requires `converge`), or, for enrolling an already-open PR, the
789
789
  > `connector` targets above (ADR 0006 §3 / S5).
790
790
 
791
- **Payload:** `{ pr: "owner/repo#123", convergeOnly?: boolean, dependsOn?: string[] }`. `pr` is
791
+ **Payload:** `{ pr: "owner/repo#123", autoMerge?: boolean, dependsOn?: string[] }`. `pr` is
792
792
  required (a literal `owner/repo#N`, identical to how a `wait: pr` node targets a known PR).
793
- `convergeOnly` defaults from the target and may be overridden per-node; `dependsOn` is unioned
794
- into the PR's merge-stage dependency set. The enrollment is idempotent (the connector's
793
+ `autoMerge` defaults from the target and may be overridden per-node; `dependsOn` is unioned into
794
+ the PR's merge-stage dependency set. The enrollment is idempotent (the connector's
795
795
  at-least-once dedupe fence **plus** `submitPr`'s own `prKey` idempotency), so a graph resume /
796
796
  redelivery never double-enrolls.
797
797
 
package/openapi.yaml CHANGED
@@ -1823,11 +1823,13 @@ components:
1823
1823
  type: integer
1824
1824
  minimum: 1
1825
1825
  description: Values above 100 are accepted and clamped to 100 by the delegate.
1826
+ autoMerge:
1827
+ type: boolean
1828
+ description: Preferred per-request setting. When false, stop at `converged`; when true, run the merge-loop after convergence only if `NANO_PR_AUTO_MERGE` is enabled. When omitted, the global default applies.
1826
1829
  convergeOnly:
1827
1830
  type: boolean
1828
- description: When true, run convergence only and stop at `converged` — the PR is never
1829
- handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A
1830
- per-request review-only override; defaults to false (the global auto-merge default applies).
1831
+ deprecated: true
1832
+ description: "Deprecated negative alias. If `autoMerge` is omitted, `true` means `autoMerge: false`; `false` means `autoMerge: true` (still gated by `NANO_PR_AUTO_MERGE`). Prefer `autoMerge`."
1831
1833
  ConvergenceStartByUrl:
1832
1834
  type: object
1833
1835
  additionalProperties: false
@@ -1845,11 +1847,13 @@ components:
1845
1847
  type: integer
1846
1848
  minimum: 1
1847
1849
  description: Values above 100 are accepted and clamped to 100 by the delegate.
1850
+ autoMerge:
1851
+ type: boolean
1852
+ description: Preferred per-request setting. When false, stop at `converged`; when true, run the merge-loop after convergence only if `NANO_PR_AUTO_MERGE` is enabled. When omitted, the global default applies.
1848
1853
  convergeOnly:
1849
1854
  type: boolean
1850
- description: When true, run convergence only and stop at `converged` — the PR is never
1851
- handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A
1852
- per-request review-only override; defaults to false (the global auto-merge default applies).
1855
+ deprecated: true
1856
+ description: "Deprecated negative alias. If `autoMerge` is omitted, `true` means `autoMerge: false`; `false` means `autoMerge: true` (still gated by `NANO_PR_AUTO_MERGE`). Prefer `autoMerge`."
1853
1857
  PlanStart:
1854
1858
  description: The start-plan-fanout request body. Names the target issue by EXACTLY ONE of
1855
1859
  `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a REQUIRED
@@ -3435,8 +3439,8 @@ components:
3435
3439
  autoMerge:
3436
3440
  type: boolean
3437
3441
  description: >-
3438
- When true (and `converge` is true), drive the merge-loop after convergence — mirrors
3439
- `convergeOnly`/`NANO_PR_AUTO_MERGE` inverted. When false the run stops at `converged`.
3442
+ When true (and `converge` is true), drive the merge-loop after convergence only when
3443
+ `NANO_PR_AUTO_MERGE` is enabled. When false the run stops at `converged`.
3440
3444
  Moot when `converge` is false.
3441
3445
  confirmDefaultBase:
3442
3446
  type: boolean
@@ -4573,9 +4577,13 @@ paths:
4573
4577
  type: integer
4574
4578
  minimum: 1
4575
4579
  description: Values above 100 are accepted and clamped to 100 by the delegate.
4580
+ autoMerge:
4581
+ type: boolean
4582
+ description: Preferred per-request setting. When false, stop at `converged`; when true, run the merge-loop after convergence only if `NANO_PR_AUTO_MERGE` is enabled. When omitted, the global default applies.
4576
4583
  convergeOnly:
4577
4584
  type: boolean
4578
- description: When true, run convergence only and stop at `converged` — the PR is never handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A per-request review-only override; defaults to false (the global auto-merge default applies).
4585
+ deprecated: true
4586
+ description: 'Deprecated negative alias. If `autoMerge` is omitted, `true` means `autoMerge: false`; `false` means `autoMerge: true` (still gated by `NANO_PR_AUTO_MERGE`). Prefer `autoMerge`.'
4579
4587
  - type: object
4580
4588
  additionalProperties: false
4581
4589
  required:
@@ -4592,9 +4600,13 @@ paths:
4592
4600
  type: integer
4593
4601
  minimum: 1
4594
4602
  description: Values above 100 are accepted and clamped to 100 by the delegate.
4603
+ autoMerge:
4604
+ type: boolean
4605
+ description: Preferred per-request setting. When false, stop at `converged`; when true, run the merge-loop after convergence only if `NANO_PR_AUTO_MERGE` is enabled. When omitted, the global default applies.
4595
4606
  convergeOnly:
4596
4607
  type: boolean
4597
- description: When true, run convergence only and stop at `converged` — the PR is never handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A per-request review-only override; defaults to false (the global auto-merge default applies).
4608
+ deprecated: true
4609
+ description: 'Deprecated negative alias. If `autoMerge` is omitted, `true` means `autoMerge: false`; `false` means `autoMerge: true` (still gated by `NANO_PR_AUTO_MERGE`). Prefer `autoMerge`.'
4598
4610
  type: object
4599
4611
  # END generated:mcp-body
4600
4612
  responses:
@@ -5809,9 +5821,9 @@ paths:
5809
5821
  type: string
5810
5822
  enum: [real, forward-declared]
5811
5823
  description: real ⇒ dispatches a real side effect; forward-declared ⇒ a no-op stub.
5812
- convergeOnlyDefault:
5824
+ autoMergeDefault:
5813
5825
  type: boolean
5814
- description: The default `convergeOnly` for a real converge target.
5826
+ description: The default `autoMerge` for a real converge target.
5815
5827
  summary:
5816
5828
  type: string
5817
5829
  description: What the target does.
@@ -5872,7 +5884,7 @@ paths:
5872
5884
  optionalFields: [dedupeKey, payload]
5873
5885
  sideEffecting: true
5874
5886
  mayEmit: true
5875
- summary: "An automated outbound action; payload for a converge target is { pr, convergeOnly?, dependsOn? }."
5887
+ summary: "An automated outbound action; payload for a converge target is { pr, autoMerge?, dependsOn? }."
5876
5888
  factTypes: [string, number, boolean, artifact, version, url, pr]
5877
5889
  guardScalarTypes: [string, number, boolean]
5878
5890
  waitProbeKinds:
@@ -5893,15 +5905,15 @@ paths:
5893
5905
  connectorTargets:
5894
5906
  - target: converge
5895
5907
  status: real
5896
- convergeOnlyDefault: true
5908
+ autoMergeDefault: false
5897
5909
  summary: "Converge-only: drive review convergence and stop at converged."
5898
5910
  - target: converge-merge
5899
5911
  status: real
5900
- convergeOnlyDefault: false
5912
+ autoMergeDefault: true
5901
5913
  summary: "Unit-level land: converge AND merge onto the PR's own base branch."
5902
5914
  - target: merge-main
5903
5915
  status: real
5904
- convergeOnlyDefault: false
5916
+ autoMergeDefault: true
5905
5917
  summary: "Graph-level top-level land onto main (two-level merge)."
5906
5918
  - target: "<any other target>"
5907
5919
  status: forward-declared
@@ -6168,7 +6180,7 @@ paths:
6168
6180
  description: When true, hand the opened PR to the convergence loop (review rounds) as a follow-on step. When false (default) the run ends at "PR raised, not reviewed".
6169
6181
  autoMerge:
6170
6182
  type: boolean
6171
- description: When true (and `converge` is true), drive the merge-loop after convergence — mirrors `convergeOnly`/`NANO_PR_AUTO_MERGE` inverted. When false the run stops at `converged`. Moot when `converge` is false.
6183
+ description: When true (and `converge` is true), drive the merge-loop after convergence only when `NANO_PR_AUTO_MERGE` is enabled. When false the run stops at `converged`. Moot when `converge` is false.
6172
6184
  confirmDefaultBase:
6173
6185
  type: boolean
6174
6186
  description: Acknowledge that `baseBranch` is the repository default branch (the PR would target it directly). See `PlanStartByIssue.confirmDefaultBase`.