@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.
@@ -36,16 +36,73 @@ import type {
36
36
  ResolvedDeliveryNode,
37
37
  } from "../nano-generated/api-io.d.ts";
38
38
  import { TRANSCRIPT_URL_BASE_VAR, TRANSCRIPT_URL_VAR } from "./agentic/transcript-url.ts";
39
+ import { CONVERGE_MERGE_TARGET, CONVERGE_TARGET, isConvergeTarget, MERGE_MAIN_TARGET } from "./convergeTargets.ts";
39
40
  import { DELIVERY_CONNECTOR_TASK_TYPE } from "./deliveryConnector.ts";
40
41
  import {
41
42
  analyzeExclusiveTopology,
43
+ canonicalJson,
42
44
  type DeliveryGraphError,
43
45
  deliveryNodeFacts,
46
+ hasXmlInvalidChars,
47
+ redactConnectorValue,
44
48
  resolveDeliveryFrom,
49
+ stripXmlInvalidChars,
45
50
  validateDeliveryGraph,
46
51
  } from "./deliveryGraph.ts";
47
52
  import { DELIVERY_HUMAN_ELEMENT, GENERIC_HUMAN_FORM } from "./deliveryHuman.ts";
53
+ import { DEFAULT_BACKOFF, DEFAULT_EVERY_MS, DEFAULT_ON_TIMEOUT, DEFAULT_TIMEOUT_MS, isProbeKind, normalizePoll, redactEmbeddedCredential, redactString } from "./readiness.ts";
48
54
  import { AGENT_TASK_NS } from "./repoEnvelope.ts";
55
+ import { isoDuration } from "./reviewWait.ts";
56
+
57
+ /** A display-safe rendering of a `wait` probe's target for user-visible BPMN name/documentation
58
+ * (issue #778 review): a `command` target is an arbitrary shell snippet that can embed a secret, so it
59
+ * is never surfaced — only a fixed placeholder; an `http` target can carry a credential in its
60
+ * `user:pass@` userinfo or `?query`/`#fragment`, so it goes through the same {@link redactString} log
61
+ * redaction. A structured, non-credential identifier (`owner/repo#42`, `pkg@version`, `owner/repo@ref`,
62
+ * a `<node>.<fact>` ref) is shown VERBATIM — but because the graph validator only requires a non-empty
63
+ * target (and `parsePrTarget` accepts any prefix before `#<digits>`), a URL-SHAPED value smuggled into
64
+ * one of those kinds is still routed through the same URL-only {@link redactConnectorValue}, so a
65
+ * `//user:pass@host/repo#42` has its credential stripped while a legitimate `#`/`@` in a structured ref
66
+ * (which is not URL-shaped) is left untouched. The RAW target is retained only in runtime variables (the
67
+ * probe config + the escalation diagnostics), never in the deployed documentation the explorer/modeler
68
+ * shows. */
69
+ function redactProbeTargetForDisplay(probe: Extract<DeliveryNode, { kind: "wait" }>["wait"]): string {
70
+ // NORMALIZE the kind first — `parseProbe` (`readiness.ts`) trims `wait.kind` before the worker keys
71
+ // on it, and the semantic validator only requires a non-empty string, so an internal/direct graph
72
+ // with `kind: " command "` runs as a COMMAND probe at runtime. Comparing the RAW (padded) kind here
73
+ // would miss that, route its arbitrary shell target through the URL-only `redactConnectorValue`, and
74
+ // leak it into the deployed BPMN documentation instead of `<redacted>` (issue #778 review — thread
75
+ // deliveryGraphCompiler.ts:70). Classify on the TRIMMED-RAW kind — the SAME `str(raw.kind).trim()`
76
+ // the runtime `parseProbe` keys on — and do NOT `stripXmlInvalidChars` it first: an XML-invalid char
77
+ // is not whitespace `parseProbe` trims, so a control-char-smuggled kind (`kind: "command\x01"`,
78
+ // `kind: "pr\x01"`) is REJECTED at runtime and never runs. Stripping the char before classifying
79
+ // would sanitise `"pr\x01"` toward a genuine `pr`, take the verbatim structured-target path, and leak
80
+ // an arbitrary secret-bearing target into the staged BPMN documentation for a probe that can never run
81
+ // (issue #778 review — thread deliveryGraphCompiler.ts:81, over :78). Any kind that is not an exact
82
+ // recognised `isProbeKind` after trimming falls to the unconditional `<redacted>` below.
83
+ const kind = probe.kind.trim();
84
+ if (kind === "command") return "<redacted>";
85
+ // TRIM the target first — `parseProbe` (`readiness.ts`) trims `target` for EVERY kind before the worker
86
+ // keys on it, so a padded ` owner/repo#1 ` and `owner/repo#1` are the SAME runtime probe. Rendering the
87
+ // raw (padded) value would leave the whitespace in `nodeDisplay`/`semanticBpmn`, forking the graph digest
88
+ // (and run key) from the trimmed-equivalent graph even though both probe the same value (issue #778
89
+ // review — thread deliveryGraphCompiler.ts:75).
90
+ const target = probe.target.trim();
91
+ // `stripXmlInvalidChars` BEFORE `redactString`: an XML-forbidden control (e.g. `\x0B`) hidden inside
92
+ // the `user:pass@` userinfo would otherwise split `redactString`'s `//…@` match, escape redaction, and
93
+ // be re-joined into a live credential once the renderer strips that control (issue #778 review).
94
+ if (kind === "http") return redactString(stripXmlInvalidChars(target));
95
+ // A kind that is not a recognised {@link isProbeKind} is MALFORMED — `parseProbe` rejects it at
96
+ // dispatch so it never runs, but the compiler still renders its target into the STAGED BPMN
97
+ // documentation/preview at compile time. That target could be an arbitrary command-like/secret-bearing
98
+ // snippet smuggled under a not-quite-known kind (`kind:"command\x01"`, `kind:"pr\x01"`, `kind:"cmd"`),
99
+ // so routing it through the URL-only `redactConnectorValue` would leak it verbatim. Redact it
100
+ // unconditionally, exactly like a `command` target — only a genuinely structured kind (`pr`/`epic`/
101
+ // `npm`/`github-check`/`capability`) shows its target (issue #778 review — thread
102
+ // deliveryGraphCompiler.ts:81, over :78).
103
+ if (!isProbeKind(kind)) return "<redacted>";
104
+ return redactConnectorValue(target);
105
+ }
49
106
 
50
107
  /** The task-header key that carries an `agent` node's DECLARED per-node repository spec (#739) into the
51
108
  * compiled BPMN. It is a DIGEST-STABLE, env-free marker — pure graph content — so two graphs differing
@@ -132,9 +189,22 @@ export function assertNever(value: never, context: string): never {
132
189
  throw new Error(`${context}: unreachable — non-allowlisted delivery node kind ${JSON.stringify(value)}`);
133
190
  }
134
191
 
135
- /** Escape a string for use as XML text / attribute content. Deterministic and total. */
192
+ /** XML 1.0's `Char` production forbids, anywhere in a document: the C0 control characters (except tab
193
+ * `#x9`, LF `#xA`, CR `#xD`), the noncharacters U+FFFE/U+FFFF, and unpaired UTF-16 surrogates — none can
194
+ * be represented by an entity, so any of them in an element `name`/`documentation` makes
195
+ * `layoutBpmn`/deployment reject the whole semantic BPMN. The character-class filter is
196
+ * {@link stripXmlInvalidChars}, canonical in `./deliveryGraph.ts` so the validator (which REJECTS such a
197
+ * character in executable FEEL) and this compiler (which strips it from DISPLAY text) share one
198
+ * definition. User-authored display strings (a node's free-form `prompt`, a probe `target`, an emit
199
+ * name) only impose length limits at the OpenAPI edge, so such a character can reach the renderer — and
200
+ * a code-unit truncation elsewhere can even manufacture a lone surrogate from a valid astral character.
201
+ * Strip all of these before emitting any XML text / attribute content (VALID astral pairs are
202
+ * preserved) — dropping an unrepresentable character is the only well-formed rendering. */
203
+
204
+ /** Escape a string for use as XML text / attribute content, first stripping XML-1.0-forbidden control
205
+ * characters (see {@link stripXmlInvalidChars}). Deterministic and total. */
136
206
  function escapeXml(value: string): string {
137
- return value
207
+ return stripXmlInvalidChars(value)
138
208
  .replace(/&/g, "&amp;")
139
209
  .replace(/</g, "&lt;")
140
210
  .replace(/>/g, "&gt;")
@@ -148,7 +218,7 @@ function escapeXml(value: string): string {
148
218
  * literal survives to the engine. Safe because the compiler grafts DI onto its own semantic XML without
149
219
  * re-serializing it, so these text nodes are never round-tripped/normalized. Deterministic and total. */
150
220
  function escapeXmlText(value: string): string {
151
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
221
+ return stripXmlInvalidChars(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
152
222
  }
153
223
 
154
224
  /** Render a `name=value` XML attribute, choosing the delimiter so FEEL string literals survive the
@@ -159,16 +229,27 @@ function escapeXmlText(value: string): string {
159
229
  * escaping) is used. Deterministic. */
160
230
  function attr(name: string, value: string): string {
161
231
  if (value.includes('"')) {
162
- const inner = value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/'/g, "&apos;");
232
+ const inner = stripXmlInvalidChars(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/'/g, "&apos;");
163
233
  return `${name}='${inner}'`;
164
234
  }
165
235
  return `${name}="${escapeXml(value)}"`;
166
236
  }
167
237
 
168
- /** Escape a string for use inside a mermaid quoted label. Mermaid uses `#` HTML-entity escapes; a
169
- * double quote inside a `"…"` label must become `#quot;` so the label stays well-formed. */
238
+ /** Escape a string for use inside a mermaid quoted label. First strips XML-1.0-forbidden control
239
+ * characters (see {@link stripXmlInvalidChars}) — the same free-form node labels feed the Mermaid path,
240
+ * so a stray control char (e.g. `\x01` in a prompt) would otherwise make the preview unparsable. Mermaid
241
+ * then uses `#` HTML-entity escapes: `&`, `<`, `>` and `"` are encoded (`#amp;`/`#lt;`/`#gt;`/`#quot;`)
242
+ * so free-form prompt/target text can't be interpreted as markup and make a label render wrong or vanish
243
+ * (`&` is encoded FIRST so its `#amp;` isn't re-encoded). Any line break — LF **or** a bare/`\r\n` CR
244
+ * (a valid char `stripXmlInvalidChars` preserves) — is folded to a space, since a raw break inside the
245
+ * line-oriented Mermaid source would make the preview unparsable. */
170
246
  function escapeMermaid(value: string): string {
171
- return value.replace(/"/g, "#quot;").replace(/\n/g, " ");
247
+ return stripXmlInvalidChars(value)
248
+ .replace(/&/g, "#amp;")
249
+ .replace(/</g, "#lt;")
250
+ .replace(/>/g, "#gt;")
251
+ .replace(/"/g, "#quot;")
252
+ .replace(/\r\n?|\n/g, " ");
172
253
  }
173
254
 
174
255
  /** A node's typed emits, normalised to a stable array (absent → `[]`). */
@@ -711,15 +792,51 @@ function delegateTarget(node: DeliveryNode, element: string): string {
711
792
  }
712
793
  }
713
794
 
795
+ /** Redact any credential-bearing URL inside each emit's free-form `description` for the operator-facing
796
+ * preview projection, mirroring the `redactFreeText(trimmedOrEmpty(description))` the BPMN label embeds
797
+ * (`nodeDisplay`). Returns fresh `DeliveryFact` copies (never mutates the source), so the RAW descriptions
798
+ * on the runtime path are untouched (issue #778 review). */
799
+ function redactEmitsForPreview(facts: readonly DeliveryFact[]): DeliveryFact[] {
800
+ return facts.map((f) =>
801
+ typeof f.description === "string" ? { ...f, description: redactFreeText(f.description) } : { ...f },
802
+ );
803
+ }
804
+
714
805
  /** Extract the human STOP-points (sorted by id) — where the graph pauses for a person/agent, with the
715
806
  * instruction, optional attached form, and the typed facts the node will emit. */
716
807
  function buildHumanNodes(nodes: readonly DeliveryNode[]): DeliveryHumanStop[] {
717
808
  const stops: DeliveryHumanStop[] = [];
718
809
  for (const node of nodes) {
719
810
  if (node.kind !== "human") continue;
720
- const stop: DeliveryHumanStop = { nodeId: node.id, emits: normaliseEmits(node) };
721
- const withPrompt = node.human?.prompt !== undefined ? { ...stop, prompt: node.human.prompt } : stop;
722
- stops.push(node.human?.formKey !== undefined ? { ...withPrompt, formKey: node.human.formKey } : withPrompt);
811
+ // Redact a credential-bearing URL inside each emit's free-form `description` at its SOURCE, the SAME
812
+ // way `nodeDisplay` embeds it (`redactFreeText(trimmedOrEmpty(description))`): `humanNodes[]` — emits
813
+ // included — is persisted into the staged proposal `preview` and rendered on the Delivery Graphs page
814
+ // (and denormalised into parked-node labels), so a `//user:pass@…` in a fact description would leak
815
+ // unredacted through this operator-facing projection even though the BPMN label path redacts it. The
816
+ // RAW descriptions still reach the runtime `appendPrompt` (`renderEmitContract`) unmodified (#778).
817
+ const stop: DeliveryHumanStop = { nodeId: node.id, emits: redactEmitsForPreview(normaliseEmits(node)) };
818
+ // Redact the operator-facing preview `prompt` at its SOURCE with the SAME display-safe helper
819
+ // `nodeDisplay` renders with: this projection is persisted into the staged proposal `preview` and
820
+ // rendered verbatim on the Delivery Graphs page (and denormalised into the run's parked-node
821
+ // labels), so a URL credential in a human prompt (`//user:pass@…`) must be stripped here too — else
822
+ // it leaks unredacted through the preview even though the BPMN display path redacts it. The runtime
823
+ // user-task prompt seeded via the compiled BPMN `nodeInputs` (`deliveryRunner.buildNodeInput`,
824
+ // `case "human"`) is ALSO redacted the same way — it renders in the parked task's read-only form
825
+ // field, another display surface — so no leak path remains (issue #778 review). `redactFreeText` is
826
+ // a no-op for a credential-free prompt.
827
+ const withPrompt =
828
+ typeof node.human?.prompt === "string" ? { ...stop, prompt: redactFreeText(node.human.prompt) } : stop;
829
+ // The `formKey` is an opaque identifier a modeler/explorer reads verbatim off the staged proposal
830
+ // preview + Delivery Graphs page (and denormalised into parked-node labels), so a credential-bearing
831
+ // formKey (`//user:pass@…`) must be stripped here with the SAME `redactConnectorValue` the BPMN
832
+ // `Form:` documentation (nodeDisplay) and the digest-invisible traversal use — else it leaks
833
+ // unredacted through the preview. The RAW formKey still drives runtime form resolution
834
+ // (`deliveryHuman.ts` reads `node.human.formKey` directly), unmodified (issue #778 review).
835
+ stops.push(
836
+ typeof node.human?.formKey === "string"
837
+ ? { ...withPrompt, formKey: redactConnectorValue(node.human.formKey) }
838
+ : withPrompt,
839
+ );
723
840
  }
724
841
  return stops;
725
842
  }
@@ -734,16 +851,28 @@ function buildSideEffects(nodes: readonly DeliveryNode[]): DeliverySideEffect[]
734
851
  effects.push({
735
852
  nodeId: node.id,
736
853
  kind: "agent",
737
- description: `runs agent job \`${node.agent.jobType}\``,
854
+ // Redact a credential-bearing `jobType` at its SOURCE with the display-safe helper `nodeDisplay`
855
+ // uses — this side-effect projection is persisted into the staged proposal preview, an
856
+ // operator-visible surface, so a URL-shaped jobType carrying a secret must not echo verbatim here
857
+ // any more than in the node label/documentation (issue #778 review — thread :1202).
858
+ description: `runs agent job \`${redactConnectorValue(node.agent.jobType)}\``,
738
859
  });
739
860
  } else if (node.kind === "connector") {
861
+ // Redact the operator-facing `target`/`dedupeKey` at their SOURCE with the SAME display-safe
862
+ // helper `nodeDisplay` renders with: this projection is persisted into the staged proposal
863
+ // `preview` and rendered on the Delivery Graphs page, so a URL credential in a connector target
864
+ // or dedupe key (`//user:pass@…`) must be stripped here too — else it leaks unredacted through
865
+ // the preview even though the BPMN display path redacts it. The RAW values still reach the
866
+ // runtime connector via the compiled BPMN `nodeInputs`, unmodified (issue #778 review).
740
867
  const effect: DeliverySideEffect = {
741
868
  nodeId: node.id,
742
869
  kind: "connector",
743
- description: `invokes connector target \`${node.connector.target}\``,
870
+ description: `invokes connector target \`${redactConnectorValue(node.connector.target)}\``,
744
871
  };
745
872
  effects.push(
746
- node.connector.dedupeKey !== undefined ? { ...effect, dedupeKey: node.connector.dedupeKey } : effect,
873
+ typeof node.connector.dedupeKey === "string"
874
+ ? { ...effect, dedupeKey: redactConnectorValue(node.connector.dedupeKey) }
875
+ : effect,
747
876
  );
748
877
  }
749
878
  }
@@ -886,6 +1015,633 @@ function renderBpmn(
886
1015
  return `${lines.filter((l) => l.length > 0).join("\n")}\n`;
887
1016
  }
888
1017
 
1018
+ /** The first non-empty line of a (possibly multi-line) string, trimmed and length-capped for use as a
1019
+ * concise element label. Returns `""` for a blank/undefined input; a line longer than `cap` is
1020
+ * truncated with an ellipsis. Truncation is by Unicode CODE POINT (`Array.from`), not UTF-16 code unit,
1021
+ * so slicing never splits an astral character (emoji etc.) into an unpaired surrogate — an unpaired
1022
+ * surrogate survives {@link stripXmlInvalidChars} and would make the emitted BPMN not well-formed.
1023
+ * The first-non-empty test is on each line's SANITIZED content ({@link stripXmlInvalidChars}), so a line
1024
+ * that is ONLY XML-forbidden control characters (which the renderer strips to nothing) is skipped rather
1025
+ * than selected — otherwise a prompt of only `\x01` would be chosen and render as a blank ` · <id>`
1026
+ * label instead of falling back to the job type / `Human decision`. The RAW (unsanitised) line is
1027
+ * returned for mixed text — the renderer sanitises it (issue #778 review). Deterministic. */
1028
+ function firstLine(value: string | undefined | null, cap = 72): string {
1029
+ if (typeof value !== "string") return "";
1030
+ const line = value.split(/\r?\n/).map((l) => l.trim()).find((l) => stripXmlInvalidChars(l).trim().length > 0) ?? "";
1031
+ const points = Array.from(line);
1032
+ return points.length > cap ? `${Array.from(points.slice(0, cap - 1)).join("").trimEnd()}…` : line;
1033
+ }
1034
+
1035
+ /** Trim an optional value to a non-blank string, or `""` when absent/blank. */
1036
+ function trimmedOrEmpty(value: unknown): string {
1037
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : "";
1038
+ }
1039
+
1040
+ /** The runtime-effective node `timeout` fingerprint: the SAME normalisation the runtime applies
1041
+ * (`isoDuration` in `deliveryRunner` — trim + upper-case a valid ISO-8601 duration, else fall back to
1042
+ * the run-level default). A valid duration collapses whitespace/case variants that drive the identical
1043
+ * SLA (`"PT1H"`/`"PT1H "`/`"pt1h"` → `"PT1H"`); a malformed one (including an XML-invalid-char variant
1044
+ * like `"PT1H\x01"`) collapses to the `""` fallback sentinel — genuinely different from a valid value.
1045
+ * A non-string stays as-is so the caller's `typeof raw === "string"` guard skips an absent timeout
1046
+ * (issue #778 review — thread :1307). */
1047
+ function normaliseNodeTimeout(value: string | undefined | null): string | undefined | null {
1048
+ return typeof value === "string" ? isoDuration(value, "") : value;
1049
+ }
1050
+
1051
+ /** A runtime plain-object narrowing used where a statically-typed field may LIE at runtime because no
1052
+ * validator constrained its shape — notably a connector `payload`, whose schema is a forward-declared
1053
+ * `additionalProperties:true` stub the semantic validator does NOT shape-check (issue #778 review). A
1054
+ * bare primitive (`42`) or an array is NOT a record, so callers must gate on this before an `in`/`Object.keys`
1055
+ * probe that would otherwise THROW on a primitive. */
1056
+ function isRecord(value: unknown): value is Record<string, unknown> {
1057
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1058
+ }
1059
+
1060
+ /** URL-only credential redaction relocated to the low-level graph module ({@link redactConnectorValue}
1061
+ * in `deliveryGraph.ts`) so the DISPLAY path here and `validateDeliveryGraph`'s reject/error path share
1062
+ * ONE redactor — no drift surface (issue #778 review). */
1063
+
1064
+ /** Redact credential-bearing pieces of any URL embedded in FREE-FORM prose (a node's authored
1065
+ * `prompt`), IN PLACE. Unlike {@link redactString} — tuned for a single opaque target string, where it
1066
+ * truncates from the first `?`/`#` to end-of-string — this finds each URL-shaped token *within*
1067
+ * surrounding prose (`scheme://…` or a scheme-relative `//…`) and strips only that token's
1068
+ * `user:pass@` userinfo and `?query`/`#fragment` via `redactString`, leaving the prose (and ordinary
1069
+ * punctuation such as a `?` ending a sentence) intact. Applied to the DISPLAY name/documentation only —
1070
+ * a prompt is up to 20 000 chars of arbitrary text that can embed a bearer token or private URL, and
1071
+ * the deployed `<bpmn:documentation>`/name is visible to modeler/explorer readers; the RAW prompt still
1072
+ * reaches the runtime job input (`appendPrompt`/`prompt`) unmodified (issue #778 review). Deterministic
1073
+ * and total. */
1074
+ export function redactFreeText(value: string): string {
1075
+ // Strip XML-invalid display characters BEFORE tokenizing/redacting so every scan runs on the exact
1076
+ // string the renderer emits. Otherwise a control char embedded in a URL (`//us\x0Ber:pass@…`) breaks
1077
+ // the `//[^\s]+` token match, escapes redaction, then reconstructs the credential once `escapeXml`/
1078
+ // `stripXmlInvalidChars` drops the control at render time (issue #778 review — same class as the
1079
+ // connector/probe strip-before-classify fix).
1080
+ const cleaned = stripXmlInvalidChars(value);
1081
+ // Embedded credential userinfo: DERIVE from the ONE canonical redactor {@link redactEmbeddedCredential}
1082
+ // (`EMBEDDED_CREDENTIAL_SRC` = `//[^/?#]*@`) rather than a bespoke belt heuristic. Its `[^/?#]` class
1083
+ // spans spaces/TABs/newlines up to the LAST `@` before a `/`/`?`/`#`, so EVERY `//<userinfo>@` shape
1084
+ // collapses uniformly to `//***@` — a colon-prefix `//user:pass@`, a colon-LESS bearer separated from
1085
+ // its `@host` by a word or space (`//token part@host`, `//token @host`), a userinfo split by a raw
1086
+ // CR/LF/TAB, and a malformed multi-`@` authority (`//user:pass@ss@host`) — with NO second "what is a
1087
+ // credential" implementation that can drift from the canonical redactor/validator (`hasEmbeddedCredential`)
1088
+ // (issue #783 review — thread deliveryGraphCompiler.ts:1140; the earlier bespoke colon-less bridge
1089
+ // stopped on the first non-whitespace char after the space and leaked `//token part@host`). A `//…@`
1090
+ // span is UNAMBIGUOUSLY a credential wherever it sits, so redacting a prose `//word …@host` too is the
1091
+ // SAFE direction: the RAW prompt still reaches the runtime job input unmodified — only the operator-
1092
+ // visible display doc loses the span. The `[^/?#]` bound also keeps the userinfo from swallowing a `?`
1093
+ // marker (`//host?token=secret@tail` has no userinfo — its `@` rides the query), leaving that tail to
1094
+ // the query/fragment belt below.
1095
+ const credStripped = redactEmbeddedCredential(cleaned);
1096
+ // Query/fragment across a whitespace BREAK: the primary `//[^\s]+` token below stops at the break, so a
1097
+ // `//host/?\nTOKEN=secret` would leave the value visible in the XML-preserved doc. The linear belt walks
1098
+ // each SPACE-bounded `//`-run (crossing an embedded CR/LF/TAB the primary token stopped at) and
1099
+ // CONSERVATIVELY redacts its `?query`/`#fragment` tail through the span's space boundary — a continuation
1100
+ // past the break is indistinguishable from a split value, so we never keep the far side (issue #778
1101
+ // review — thread deliveryGraphCompiler.ts:1115).
1102
+ const belted = redactQueryFragmentSpans(credStripped);
1103
+ return belted.replace(/(?:[a-z][a-z0-9+.-]*:)?\/\/[^\s]+/gi, (m) => redactString(m));
1104
+ }
1105
+
1106
+ /** Linear (backtracking-free) newline-aware belt companion to {@link redactFreeText}'s primary
1107
+ * whitespace-bounded pass. Each `//`-run is bounded by a literal SPACE (0x20) — so a single span may
1108
+ * cross a CR/LF/TAB *inside* the URL that the primary `//[^\s]+` token stopped at. Credential userinfo is
1109
+ * already collapsed to `//***@` by the canonical {@link redactEmbeddedCredential} before this runs, so the
1110
+ * belt's SOLE remaining job is a `?query`/`#fragment` tail: it redacts CONSERVATIVELY from the first
1111
+ * `?`/`#` marker through the span's space boundary — a continuation past an embedded break is
1112
+ * indistinguishable from a split value, so the far side is never kept. A span with no `?`/`#` (ordinary
1113
+ * prose — a `//comment` reference, an already-collapsed `//***@host`) is returned untouched. The scan is a
1114
+ * single left-to-right walk using `indexOf`/`charCodeAt` over spans bounded by the next SPACE, with no
1115
+ * regex backtracking, so a 20 000-char adversarial prompt cannot trigger catastrophic backtracking (issue
1116
+ * #778 review). Deterministic and total. */
1117
+ function redactQueryFragmentSpans(text: string): string {
1118
+ let out = "";
1119
+ let i = 0;
1120
+ for (;;) {
1121
+ const start = text.indexOf("//", i);
1122
+ if (start < 0) return out + text.slice(i);
1123
+ out += text.slice(i, start);
1124
+ let end = start;
1125
+ while (end < text.length && text.charCodeAt(end) !== 0x20 /* SPACE */) end++;
1126
+ const span = text.slice(start, end);
1127
+ const qMark = span.indexOf("?");
1128
+ const hMark = span.indexOf("#");
1129
+ const qi = qMark < 0 ? hMark : hMark < 0 ? qMark : Math.min(qMark, hMark);
1130
+ // CONSERVATIVE: keep everything up to and including the `?`/`#` marker, then collapse the whole tail
1131
+ // (any continuation past an embedded CR/LF/TAB) to `***`. A value/prose resuming after the break is
1132
+ // INDISTINGUISHABLE from a split-credential continuation, so we never keep the post-break side; the RAW
1133
+ // prompt still reaches the runtime job input unmodified (issue #778 review — thread
1134
+ // deliveryGraphCompiler.ts:1115).
1135
+ out += qi < 0 ? span : `${span.slice(0, qi + 1)}***`;
1136
+ i = end;
1137
+ }
1138
+ }
1139
+
1140
+ /** A human-readable label for a connector node's `target`. The converge-enrollment vocabulary
1141
+ * (`convergeTargets.ts`) maps to intent-revealing phrases; any other (forward-declared) target is shown
1142
+ * through {@link redactConnectorValue} — an opaque identifier (`slack:#releases`) survives unchanged, but
1143
+ * a credential-bearing URL (`//user:pass@…`, `?token=…`) has its secret components stripped so it is
1144
+ * never persisted into user-visible BPMN documentation/modeler. Deterministic. */
1145
+ function humanizeConnectorTarget(target: string): string {
1146
+ switch (target) {
1147
+ case CONVERGE_TARGET:
1148
+ return "Converge PR (review only)";
1149
+ case CONVERGE_MERGE_TARGET:
1150
+ return "Converge & merge PR";
1151
+ case MERGE_MAIN_TARGET:
1152
+ return "Merge to main";
1153
+ default:
1154
+ return `Connector: ${redactConnectorValue(target)}`;
1155
+ }
1156
+ }
1157
+
1158
+ /** Render a probe's `match` predicate as a compact `k=v, k=v` description (declared fields only).
1159
+ * Three fields are FREE-FORM, user-supplied secret-bearing values — `verifyCommand` (an arbitrary shell
1160
+ * command the capability probe runs at the gate boundary) and `bodyIncludes`/`stdoutIncludes` (arbitrary
1161
+ * response-body / stdout substrings that can carry response tokens) — so they are never surfaced in the
1162
+ * user-visible name/documentation; only a fixed `<redacted>` placeholder appears while the raw value
1163
+ * survives in the runtime probe config. Every other declared field is a structured/enumerable predicate,
1164
+ * but not all are safe to interpolate verbatim — a free-form string predicate (`capabilityRef`,
1165
+ * `package`, `checkName`, …) can carry a credential-bearing URL (`//user:pass@host#274`, which
1166
+ * `parseProbe` accepts for its trailing id), so each is run through {@link redactConnectorValue} (a
1167
+ * URL-only redactor: an ordinary `status=200`/`checkName=build` passes through untouched, a URL has its
1168
+ * credential stripped). The raw value still survives in the runtime probe config; any value the display
1169
+ * redacts is fingerprinted by `digestInvisibleRawValues` so the digest/run-key stays faithful. Fields are
1170
+ * emitted in a STABLE code-unit key order (not the caller's JSON insertion order) so two semantically
1171
+ * identical graphs render byte-identically — preserving the compiler's determinism/digest guarantee.
1172
+ * (Issue #778 review.) */
1173
+ const REDACTED_MATCH_FIELDS: ReadonlySet<string> = new Set(["verifyCommand", "bodyIncludes", "stdoutIncludes"]);
1174
+ /** The DECLARED `ProbeMatch` fields (app/readiness.ts). `validateDeliveryGraph` does NOT reject an
1175
+ * unknown extra `wait.match` key, so a text-ingress graph can smuggle an arbitrary attacker-named key
1176
+ * whose value would otherwise be rendered VERBATIM into the user-visible preview/documentation here (a
1177
+ * credential-leak channel — an unknown key bypasses the `REDACTED_MATCH_FIELDS` set entirely). Only these
1178
+ * declared, enumerable predicate fields are rendered; any other key is dropped from the display (the raw
1179
+ * value still survives untouched in the runtime probe config). Keep in sync with `ProbeMatch`. */
1180
+ const DECLARED_MATCH_FIELDS: ReadonlySet<string> = new Set([
1181
+ "status",
1182
+ "bodyIncludes",
1183
+ "exitCode",
1184
+ "stdoutIncludes",
1185
+ "version",
1186
+ "conclusion",
1187
+ "checkName",
1188
+ "capabilityRef",
1189
+ "package",
1190
+ "verifyCommand",
1191
+ "prState",
1192
+ "epicState",
1193
+ ]);
1194
+
1195
+ /** The declared `wait.match` fields `parseMatch` (`app/readiness.ts`) reads through `num()` — a value of
1196
+ * any OTHER type coerces to `undefined` (the predicate is treated as UNSET). Every other declared field is
1197
+ * read through `str(v).trim()`, so a numeric `conclusion:1` and a string `conclusion:"1"` coerce to the
1198
+ * SAME probe value (`"1"`) and parse IDENTICALLY. `describeProbeMatch` renders `String(v)`, so both also
1199
+ * render `conclusion=1` — genuinely one identity, NOT a fork. Only a `num()`-backed field forks
1200
+ * invisibly: a numeric `status:200` and a string `status:"200"` render IDENTICALLY (`status=200`) yet
1201
+ * parse DIFFERENTLY (`200` vs. unset/any-2xx). `matchValueTypeMismatch` flags EXACTLY that numeric-field
1202
+ * cross-type case so it is fingerprinted; a string-field cross-type value is runtime-equivalent and must
1203
+ * NOT be flagged, or two identical graphs fork their stable run key and double-dispatch (issue #778
1204
+ * review — thread deliveryGraphCompiler.ts:1251). */
1205
+ const NUMERIC_MATCH_FIELDS: ReadonlySet<string> = new Set(["status", "exitCode"]);
1206
+ function matchValueTypeMismatch(key: string, value: unknown): boolean {
1207
+ // ONLY `num()`-backed fields are type-sensitive: a non-number coerces to "unset" (any-2xx), a
1208
+ // runtime-distinct meaning that renders identically. Every string field runs through `str(v).trim()`,
1209
+ // so a number and its string twin are the SAME probe — never a fork. Unknown keys are `parseMatch`-inert.
1210
+ return NUMERIC_MATCH_FIELDS.has(key) && typeof value !== "number";
1211
+ }
1212
+ /** The per-kind `match` field whose AUTHORED value equals the runtime matcher's DEFAULT — i.e. writing
1213
+ * it explicitly is behaviourally identical to OMITTING it. `matchPr` defaults `prState` to `"merged"`,
1214
+ * `matchEpic` defaults `epicState` to `"merged"`, `matchGithubCheck` defaults `conclusion` to
1215
+ * `"success"`, and `matchCommand` defaults `exitCode` to `0` (`readiness.ts`). Rendering an authored
1216
+ * default into the display doc forks `semanticBpmn`/the digest from the omitted-but-equivalent graph, so
1217
+ * two runtime-identical encodings get DISTINCT digests/run keys and dispatch TWICE instead of colliding
1218
+ * as `alreadyRunning`. {@link describeProbeMatch} drops a field equal to its kind's default so the two
1219
+ * collapse (issue #778 review — thread deliveryGraphCompiler.ts:1267). Only the fields whose matcher
1220
+ * reads a SCALAR default belong here: `http`'s `status` default is "any 2xx" (not a value), and `npm`'s
1221
+ * `version` default is the target's own `pkg@version` (not a constant), so neither has an
1222
+ * omitted-equivalent scalar. */
1223
+ const EFFECTIVE_MATCH_DEFAULT: ReadonlyMap<string, { field: string; value: string | number }> = new Map([
1224
+ ["pr", { field: "prState", value: "merged" }],
1225
+ ["epic", { field: "epicState", value: "merged" }],
1226
+ ["github-check", { field: "conclusion", value: "success" }],
1227
+ ["command", { field: "exitCode", value: 0 }],
1228
+ ]);
1229
+ /** True when `[key,value]` is `kind`'s defaulted match field carrying exactly the runtime default — so
1230
+ * rendering it would spuriously fork the digest from the omitted-equivalent graph. String defaults are
1231
+ * compared trimmed (the matcher/`parseMatch` trim), numeric defaults by strict number equality (a
1232
+ * type-mismatched `"0"`/`"5"` is NOT collapsed here — {@link matchValueTypeMismatch} already fingerprints
1233
+ * it as digest-invisible). */
1234
+ function matchFieldIsEffectiveDefault(kind: string, key: string, value: unknown): boolean {
1235
+ const def = EFFECTIVE_MATCH_DEFAULT.get(kind);
1236
+ if (!def || def.field !== key) return false;
1237
+ if (typeof def.value === "number") return typeof value === "number" && value === def.value;
1238
+ return typeof value === "string" && value.trim() === def.value;
1239
+ }
1240
+ /** True when `value` is a string whose runtime-normalized (trimmed) form is EMPTY — `parseMatch`
1241
+ * (`app/readiness.ts`) reads every string predicate through `str(v).trim() || undefined`, so a `""` or
1242
+ * whitespace-only value coerces to `undefined`, i.e. it is runtime-equivalent to OMITTING the field.
1243
+ * Rendering it anyway (e.g. `bodyIncludes=<redacted>` for a redacted field, or `version=` for a plain
1244
+ * one) would fork `semanticBpmn`/the run key from the omitted-equivalent graph and double-dispatch, so
1245
+ * {@link describeProbeMatch} drops it (issue #778 review — thread deliveryGraphCompiler.ts:1254). Numbers/
1246
+ * booleans are never "empty" and pass through untouched. */
1247
+ function matchValueRuntimeEmpty(value: unknown): boolean {
1248
+ return typeof value === "string" && value.trim() === "";
1249
+ }
1250
+ function describeProbeMatch(kind: string, match: Extract<DeliveryNode, { kind: "wait" }>["wait"]["match"]): string {
1251
+ if (match === undefined || match === null) return "";
1252
+ // Render the NORMALISED (trimmed) string value — `parseMatch` (`readiness.ts`) trims each string
1253
+ // predicate before the worker uses it, so a padded `version:" 1.2.3 "` and `"1.2.3"` probe the same
1254
+ // value. Rendering the raw value would leave the whitespace in `semanticBpmn`, forking the digest/run
1255
+ // key from the trimmed-equivalent graph while both run the identical match (issue #778 review — thread
1256
+ // deliveryGraphCompiler.ts:1206). Non-string values (numbers/booleans) are shown as-is. A field whose
1257
+ // authored value equals its kind's runtime DEFAULT ({@link matchFieldIsEffectiveDefault}) is DROPPED —
1258
+ // writing e.g. `prState:"merged"` explicitly is identical to omitting it, so surfacing it would fork the
1259
+ // digest from the omitted-equivalent graph and double-dispatch (issue #778 review — thread :1267). A
1260
+ // string predicate whose trimmed value is EMPTY ({@link matchValueRuntimeEmpty}) is likewise DROPPED —
1261
+ // `parseMatch` coerces it to `undefined`, so it too is omitted-equivalent (issue #778 review — thread :1254).
1262
+ return Object.entries(match)
1263
+ .filter(([k, v]) => v !== undefined && v !== null && DECLARED_MATCH_FIELDS.has(k) && !matchValueRuntimeEmpty(v) && !matchFieldIsEffectiveDefault(kind, k, v))
1264
+ .sort(([a], [b]) => byCodeUnit(a, b))
1265
+ .map(([k, v]) => `${k}=${REDACTED_MATCH_FIELDS.has(k) ? "<redacted>" : redactConnectorValue(typeof v === "string" ? v.trim() : String(v))}`)
1266
+ .join(", ");
1267
+ }
1268
+
1269
+ /** The SINGLE source of a compiled node's human-readable display (issue #778): a concise `name` label
1270
+ * derived from the node's typed config — with the node `id` retained as a stable ` · <id>` suffix for
1271
+ * correlation (edges/logs/DI reference ids) — plus a longer `documentation` string rendered as the
1272
+ * flow element's `<bpmn:documentation>` child. BOTH the subProcess wrapper and its inner task read
1273
+ * their name from here (they must NOT compute names independently — derivation over duplication).
1274
+ * Deterministic and total over the closed kind union. */
1275
+ export function nodeDisplay(node: DeliveryNode): { name: string; documentation: string } {
1276
+ const id = node.id;
1277
+ const emitsLabel = normaliseEmits(node)
1278
+ // Include a display-safe (URL-credential-redacted) `description`: the runtime `appendPrompt`
1279
+ // (`renderEmitContract`) embeds each fact's `description`, so two graphs differing ONLY in an emit
1280
+ // description dispatch DIFFERENT instructions — omitting it here would collapse them to one
1281
+ // `semanticBpmn`/digest and let keyless dispatch reuse the wrong prompt. A credential-bearing
1282
+ // description is redacted (and additionally flagged lossy by {@link graphCarriesRedactedSecrets})
1283
+ // (issue #778 review). */
1284
+ .map((e) => {
1285
+ const desc = redactFreeText(trimmedOrEmpty(e.description));
1286
+ return `${e.name} (${e.type})${desc ? ` — ${desc}` : ""}`;
1287
+ })
1288
+ .join(", ");
1289
+ const withId = (label: string): string => `${label} · ${id}`;
1290
+ switch (node.kind) {
1291
+ case "agent": {
1292
+ const a = node.agent;
1293
+ const policy = a.merge ? "converge+merge" : a.converge ? "converge" : "";
1294
+ const base = firstLine(typeof a.prompt === "string" ? redactFreeText(a.prompt) : a.prompt) || redactConnectorValue(a.jobType);
1295
+ const label = policy ? `${base} & ${policy}` : base;
1296
+ const doc: string[] = [`Agent job: ${redactConnectorValue(a.jobType)}`];
1297
+ const repo = trimmedOrEmpty(a.repository);
1298
+ const branch = trimmedOrEmpty(a.baseBranch);
1299
+ if (repo || branch) doc.push(`Target: ${repo || "(run repo)"}${branch ? `@${branch}` : ""}`);
1300
+ if (policy) doc.push(`Policy: ${policy}`);
1301
+ if (emitsLabel) doc.push(`Emits: ${emitsLabel}`);
1302
+ const agentTimeout = normaliseNodeTimeout(a.timeout);
1303
+ if (agentTimeout) doc.push(`Timeout: ${agentTimeout}`);
1304
+ const prompt = trimmedOrEmpty(a.prompt);
1305
+ if (prompt) doc.push(`Prompt: ${redactFreeText(prompt)}`);
1306
+ return { name: withId(label), documentation: doc.join("\n") };
1307
+ }
1308
+ case "connector": {
1309
+ const c = node.connector;
1310
+ // TRIM the target — the connector worker (`workers/delivery-connector/worker.ts`) trims `vars.target`
1311
+ // before matching the reserved converge/merge vocabulary AND before dispatch, so a padded
1312
+ // `" converge-merge "` executes as `converge-merge`. Humanising/redacting the RAW value would miss
1313
+ // the reserved-target switch (rendering the generic `Connector: converge-merge` instead of the
1314
+ // descriptive `Converge & merge PR`) and fork the `semanticBpmn`/digest from the trimmed-equivalent
1315
+ // graph that dispatches identically (issue #778 review — thread deliveryGraphCompiler.ts:1277).
1316
+ const target = c.target.trim();
1317
+ const doc: string[] = [`Connector target: ${redactConnectorValue(target)}`];
1318
+ if (trimmedOrEmpty(c.dedupeKey)) doc.push(`Dedupe key: ${redactConnectorValue(trimmedOrEmpty(c.dedupeKey))}`);
1319
+ // TRIM the bound `payload.pr` — but ONLY for a CONVERGE target. `resolveConvergePr`/`parsePr`
1320
+ // (`deliveryConnector`/`readiness`) trim `payload.pr` before matching, so for a converge/converge-merge
1321
+ // connector a padded `" impl.pr "` and `"impl.pr"` drive the SAME dispatch and MUST collapse to one
1322
+ // display/digest. A GENERIC (forward-declared) connector forwards `payload` UNCHANGED to its worker
1323
+ // (`workers/delivery-connector/worker.ts` runs `readConvergeInput` only for `isConvergeTarget(target)`),
1324
+ // so `{pr:" x "}` and `{pr:"x"}` are DIFFERENT runtime payloads — trimming them for display would
1325
+ // collapse two distinct dispatches into one digest and let the keyless dispatch fence reuse the wrong
1326
+ // payload (issue #783 review — thread deliveryGraphCompiler.ts:1376). So preserve the RAW `pr` unless
1327
+ // this is a converge target.
1328
+ const rawPr = c.payload && typeof c.payload.pr === "string" ? c.payload.pr : "";
1329
+ const boundPr = isConvergeTarget(target) ? rawPr.trim() : rawPr;
1330
+ if (boundPr) doc.push(`PR: ${redactConnectorValue(boundPr)}`);
1331
+ if (emitsLabel) doc.push(`Emits: ${emitsLabel}`);
1332
+ // Render the CANONICAL timeout (`isoDuration` — trim + upper-case, else the run default), the SAME
1333
+ // normalisation the runtime applies, so `pt1h`/`PT1H`/`PT1H ` collapse to one `semanticBpmn`/digest
1334
+ // instead of forking the run key on authored casing/whitespace (issue #778 review — thread
1335
+ // deliveryGraphCompiler.ts:1243).
1336
+ const connectorTimeout = normaliseNodeTimeout(c.timeout);
1337
+ if (connectorTimeout) doc.push(`Timeout: ${connectorTimeout}`);
1338
+ return { name: withId(humanizeConnectorTarget(target)), documentation: doc.join("\n") };
1339
+ }
1340
+ case "wait": {
1341
+ const p = node.wait;
1342
+ // NORMALISE the kind — `parseProbe` trims `wait.kind` before the worker runs the probe, and
1343
+ // `digestInvisibleRawValues` fingerprints the trimmed kind, but the label/documentation must trim it
1344
+ // too: a padded `" http "` runs the SAME probe as `"http"`, so rendering the raw kind would fork the
1345
+ // `semanticBpmn`/digest+name and let a re-stage bypass the idempotency fence and duplicate the run
1346
+ // (issue #778 review — thread deliveryGraphCompiler.ts:1296). `redactProbeTargetForDisplay` already
1347
+ // trims the kind internally for its command/http branch decision.
1348
+ const kind = p.kind.trim();
1349
+ const safeTarget = redactProbeTargetForDisplay(p);
1350
+ const doc: string[] = [`Readiness probe: ${kind}`, `Target: ${safeTarget}`];
1351
+ const match = describeProbeMatch(kind, p.match);
1352
+ if (match) doc.push(`Match: ${match}`);
1353
+ // `credentialEnv` names a DECLARED env-contract key (validated `isEnvKey`, never a secret value —
1354
+ // the secret is read from the ambient env at execution time), yet it is carried raw into the
1355
+ // runtime probe config where it selects the HTTP Authorization credential. Surfacing its safe
1356
+ // env-key NAME here makes it part of `semanticBpmn`, so two graphs differing only in `credentialEnv`
1357
+ // get DISTINCT digests instead of colliding — the content-address stays a faithful identity and
1358
+ // keyless dispatch cannot reuse the wrong credential's running instance (issue #778 review).
1359
+ if (trimmedOrEmpty(p.credentialEnv)) doc.push(`Credential env: ${trimmedOrEmpty(p.credentialEnv)}`);
1360
+ // `parseProbe` defaults an OMITTED `onTimeout` to `escalate` ({@link DEFAULT_ON_TIMEOUT}) and
1361
+ // `waitBodyLines` only changes topology for `continue`, so an EXPLICIT `escalate` is behaviourally
1362
+ // identical to omitting it. Surfacing it in the doc would fork `semanticBpmn`/the content digest
1363
+ // from the omitted-equivalent graph (which `digestInvisibleRawValues` does NOT fingerprint —
1364
+ // `trimmedOrEmpty("")` == its display, so it is not pushed), giving two runtime-identical encodings
1365
+ // DISTINCT run keys that dispatch TWICE instead of colliding. DROP the effective default after
1366
+ // trimming, exactly as {@link matchFieldIsEffectiveDefault} does for default match fields — emit the
1367
+ // line only for a non-default (`continue`) routing (issue #778 review — thread
1368
+ // deliveryGraphCompiler.ts:1398).
1369
+ const onTimeout = trimmedOrEmpty(p.onTimeout);
1370
+ if (onTimeout && onTimeout !== DEFAULT_ON_TIMEOUT) doc.push(`On timeout: ${onTimeout}`);
1371
+ if (p.poll) {
1372
+ // Render the CANONICAL EFFECTIVE poll policy — the SAME `normalizePoll` the runtime applies —
1373
+ // NOT the authored fields, so two runtime-EQUIVALENT graphs share one `semanticBpmn`/digest
1374
+ // instead of forking the content address on an encoding difference the runtime collapses. The
1375
+ // runtime `normalizePoll` (a) falls a sub-1ms / non-numeric `everyMs`/`timeoutMs` back to its
1376
+ // default, (b) truncates a fractional value, (c) clamps `everyMs` to `MAX_EVERY_MS`, and (d)
1377
+ // defaults an omitted `backoff` to `exponential`. So `poll:{everyMs:0, backoff:"exponential"}`
1378
+ // runs IDENTICALLY to an omitted/default poll, yet rendering the raw `every 0ms, exponential
1379
+ // backoff` forked the digest — letting a re-stage bypass the idempotency fence and duplicate the
1380
+ // run (issue #778 review — thread deliveryGraphCompiler.ts:1414). SUPPRESS each field that equals
1381
+ // its effective default (matching the omitted-poll rendering, which shows no line at all), so a
1382
+ // graph whose poll normalises entirely to the defaults renders identically to one with no poll.
1383
+ const effective = normalizePoll(p.poll);
1384
+ const budget: string[] = [];
1385
+ if (effective.everyMs !== DEFAULT_EVERY_MS) budget.push(`every ${effective.everyMs}ms`);
1386
+ if (effective.timeoutMs !== DEFAULT_TIMEOUT_MS) budget.push(`timeout ${effective.timeoutMs}ms`);
1387
+ if (effective.backoff !== DEFAULT_BACKOFF) budget.push(`${effective.backoff} backoff`);
1388
+ if (budget.length > 0) doc.push(`Poll: ${budget.join(", ")}`);
1389
+ }
1390
+ if (emitsLabel) doc.push(`Emits: ${emitsLabel}`);
1391
+ return { name: withId(`Wait: ${kind} ${safeTarget}`), documentation: doc.join("\n") };
1392
+ }
1393
+ case "human": {
1394
+ const h = node.human;
1395
+ const base = firstLine(typeof h?.prompt === "string" ? redactFreeText(h.prompt) : h?.prompt) || "Human decision";
1396
+ const doc: string[] = [];
1397
+ const prompt = trimmedOrEmpty(h?.prompt);
1398
+ doc.push(prompt ? `Prompt: ${redactFreeText(prompt)}` : "Human decision step");
1399
+ if (trimmedOrEmpty(h?.formKey)) doc.push(`Form: ${redactConnectorValue(trimmedOrEmpty(h?.formKey))}`);
1400
+ if (emitsLabel) doc.push(`Emits: ${emitsLabel}`);
1401
+ return { name: withId(base), documentation: doc.join("\n") };
1402
+ }
1403
+ default:
1404
+ return assertNever(node, "nodeDisplay");
1405
+ }
1406
+ }
1407
+
1408
+ /** The raw field values a graph carries that the redacted `semanticBpmn` DROPS or COLLAPSES — i.e. the
1409
+ * content that reaches the runtime `nodeInputs` but is NOT faithfully represented in the digest, so two
1410
+ * graphs differing ONLY here compile to IDENTICAL `semanticBpmn` → identical digest (issue #778 review,
1411
+ * issue #716 content-address). Each entry is namespaced `nodeId\0field\0rawValue` so it also captures
1412
+ * WHICH node/field differs. This ONE traversal is the single source of truth for BOTH:
1413
+ * • {@link graphCarriesRedactedSecrets} — non-empty ⇒ the digest is not a faithful identity, so a
1414
+ * keyless dispatch must be disambiguated (Option C), and
1415
+ * • the dispatch run-key ({@link stableProposalRunKey}) — which fingerprints this list ALONGSIDE the
1416
+ * semantic digest, so credential-differing graphs get distinct run-keys while the digest collapses
1417
+ * every compiler-normalised default/reorder (no per-field enumeration to drift).
1418
+ * Digest-invisible content is: `user:pass@`/`?query`/`#fragment` URL credentials redacted from a
1419
+ * `target`/`prompt`/`dedupeKey`/`formKey`/emit-`description`; a `command`-probe `target`, the free-form
1420
+ * `verifyCommand`/`bodyIncludes`/`stdoutIncludes` match secrets (all shown only as `<redacted>`); a
1421
+ * non-redacted `match` value whose raw form loses characters to XML-1.0 sanitisation
1422
+ * ({@link hasXmlInvalidChars}) at serialisation; and the free-form connector `payload` (only a safe
1423
+ * `pr` string is surfaced). Deterministic; reuses the SAME redaction helpers `nodeDisplay` renders
1424
+ * with, so the set can never drift from what is actually stripped. */
1425
+ export function digestInvisibleRawValues(graph: DeliveryGraph): string[] {
1426
+ const out: string[] = [];
1427
+ // `raw` is digest-invisible when a present string does NOT match the EXACT `display` form `nodeDisplay`
1428
+ // embeds in `semanticBpmn` — apply the SAME normalisation (`trimmedOrEmpty`/redaction) the display does,
1429
+ // or a difference the display collapses (redaction OR trimmed whitespace) escapes while still reaching
1430
+ // the runtime raw.
1431
+ const push = (nodeId: string, field: string, raw: string | undefined | null, display: string | undefined | null): void => {
1432
+ if (typeof raw === "string" && raw !== display) out.push(`${nodeId}\u0000${field}\u0000${raw}`);
1433
+ };
1434
+ for (const node of graph.nodes) {
1435
+ const id = node.id;
1436
+ // Emit `description` is embedded in `emitsLabel` as `redactFreeText(trimmedOrEmpty(description))`
1437
+ // (see `nodeDisplay`) AND in the runtime `appendPrompt` (`renderEmitContract`) raw, so a
1438
+ // credential-bearing description whose redaction drops content is digest-invisible.
1439
+ for (const fact of normaliseEmits(node)) {
1440
+ push(id, `emit.${fact.name}.description`, fact.description, redactFreeText(trimmedOrEmpty(fact.description)));
1441
+ }
1442
+ switch (node.kind) {
1443
+ case "agent":
1444
+ // Display embeds `redactFreeText(trimmedOrEmpty(prompt))`; the RAW, untrimmed prompt reaches the
1445
+ // runtime (`buildNodeInput`), so a leading/trailing-whitespace-only difference is invisible too.
1446
+ push(id, "agent.prompt", node.agent.prompt, redactFreeText(trimmedOrEmpty(node.agent.prompt)));
1447
+ // `jobType` is a required non-empty string but only shape-validated, so it can smuggle a
1448
+ // credential-bearing URL (`//user:pass@host`). The display now redacts it through the SAME
1449
+ // `redactConnectorValue` (URL-only) rule, so a redacted jobType whose raw form the digest cannot
1450
+ // see reaches the worker verbatim (`cfg("jobType")`) — fingerprint the raw whenever it differs
1451
+ // from the redacted display (issue #778 review — thread :1202). An ordinary `senior:feature`
1452
+ // is not URL-shaped, so display == raw and nothing is pushed.
1453
+ push(id, "agent.jobType", node.agent.jobType, redactConnectorValue(node.agent.jobType));
1454
+ // The node `timeout` is embedded in the display doc as `trimmedOrEmpty(timeout)` then XML-
1455
+ // serialised (`escapeXml` STRIPS XML-1.0-invalid chars), yet the runtime reads it through
1456
+ // `isoDuration(timeout, nodeTimeout)` (deliveryRunner) — which TRIMS + upper-cases a valid value
1457
+ // and falls back to the run default on a malformed one. So `"PT1H"`, `"PT1H "`, and `"pt1h"` all
1458
+ // drive the SAME runtime SLA and must NOT be distinguished (else a whitespace-only OR case-only
1459
+ // re-stage launches a second run), while `"PT1H\x01"` (invalid ⇒ falls back) genuinely differs.
1460
+ // Fingerprint the ISO-normalised raw timeout AND compare it against the ISO-normalised form of what
1461
+ // the digest actually sees (the XML-sanitised trimmed display value) — normalising BOTH sides, so a
1462
+ // lowercase `"pt1h"` (digest sees `"pt1h"`, runtime canonicalises to `"PT1H"`) is NOT falsely marked
1463
+ // digest-invisible, while an XML-invalid variant (digest sees `"PT1H"` after the strip but the raw
1464
+ // falls back to the default) still is (issue #778 review — thread :1331, over :1307/:1251).
1465
+ push(id, "agent.timeout", normaliseNodeTimeout(node.agent.timeout), normaliseNodeTimeout(stripXmlInvalidChars(trimmedOrEmpty(node.agent.timeout))));
1466
+ break;
1467
+ case "human":
1468
+ push(id, "human.prompt", node.human?.prompt, redactFreeText(trimmedOrEmpty(node.human?.prompt)));
1469
+ // Runtime form resolution TRIMS an explicit formKey (`resolveHumanForm` in `deliveryHuman.ts`),
1470
+ // so a whitespace-only formKey variant has identical compiled form + runtime behaviour. Fingerprint
1471
+ // the NORMALISED (trimmed) value — the same value form resolution keys on — so a leading/trailing-
1472
+ // whitespace difference does NOT falsely mark the node digest-invisible and re-launch the whole
1473
+ // graph; only a genuine redaction difference (a real credential) survives (issue #778 review).
1474
+ push(id, "human.formKey", trimmedOrEmpty(node.human?.formKey), redactConnectorValue(trimmedOrEmpty(node.human?.formKey)));
1475
+ break;
1476
+ case "connector": {
1477
+ const c = node.connector;
1478
+ // The connector worker TRIMS `vars.target` before reserved-vocab matching AND dispatch, so a
1479
+ // whitespace-padded target has identical routing + runtime behaviour. Fingerprint the NORMALISED
1480
+ // (trimmed) value the worker keys on — matching the trimmed display — so a padding-only difference
1481
+ // does not fork the digest-invisible fingerprint (which would force an idempotencyKey and
1482
+ // double-launch a graph the runtime treats identically), while a real credential difference in the
1483
+ // trimmed value still survives (issue #778 review — thread deliveryGraphCompiler.ts:1277).
1484
+ push(id, "connector.target", trimmedOrEmpty(c.target), redactConnectorValue(trimmedOrEmpty(c.target)));
1485
+ // The connector worker TRIMS the authored dedupeKey (`connectorDedupeKey` in `deliveryConnector.ts`,
1486
+ // the SINGLE dedupe-key derivation site), so a whitespace-only variant has identical dedupe identity
1487
+ // + runtime behaviour. Fingerprint the NORMALISED (trimmed) value the worker keys on, so a trimmed-
1488
+ // whitespace difference does NOT falsely mark the node digest-invisible (which would force an
1489
+ // idempotencyKey and double-launch a graph the runtime treats identically); only a genuine redaction
1490
+ // difference (a real credential) survives (issue #778 review).
1491
+ push(id, "connector.dedupeKey", trimmedOrEmpty(c.dedupeKey), redactConnectorValue(trimmedOrEmpty(c.dedupeKey)));
1492
+ // The connector `timeout` is digest-invisible the same way an agent's is: displayed as
1493
+ // `trimmedOrEmpty(timeout)` then XML-sanitised, but read through `isoDuration(timeout, nodeTimeout)`
1494
+ // into the runtime SLA. Fingerprint the ISO-normalised value, compared with the ISO-normalised form
1495
+ // of the XML-sanitised display value (normalise BOTH sides) so a whitespace- OR case-only variant
1496
+ // collapses while an invalid one stays disambiguated (issue #778 review — thread :1331, over :1307).
1497
+ push(id, "connector.timeout", normaliseNodeTimeout(c.timeout), normaliseNodeTimeout(stripXmlInvalidChars(trimmedOrEmpty(c.timeout))));
1498
+ // The free-form connector `payload` survives raw into runtime `nodeInputs`, but `nodeDisplay`
1499
+ // surfaces at most a single NON-EMPTY string `payload.pr` (redacted). So the display FAITHFULLY
1500
+ // represents the payload ONLY when it is exactly `{ pr: <non-empty string> }` whose redaction is
1501
+ // a no-op; EVERY other shape leaves content the digest cannot see and is therefore invisible:
1502
+ // (a) a non-plain-object payload (a bare `42`/array — on which `"pr" in payload` would THROW, so
1503
+ // it MUST be gated before that probe), (b) a present-but-empty object or any extra key beyond
1504
+ // `pr`, (c) an omitted / non-string / empty `pr`, or (d) a string `pr` whose redaction drops
1505
+ // content. The whole raw payload (canonicalised so key order is not spuriously distinguishing)
1506
+ // is the disambiguator — but NORMALISE `pr` (trim) ONLY for a CONVERGE target: `nodeDisplay` and
1507
+ // the runtime both read `payload.pr.trim()` there (`resolveConvergePr`/`parsePr`), so a padded
1508
+ // credential-bearing `pr` and its trimmed twin are ONE runtime identity and MUST collapse
1509
+ // (fingerprinting the untrimmed payload forked their stable run key and double-launched the
1510
+ // connector side effect — issue #778 review — thread deliveryGraphCompiler.ts:1484). A GENERIC
1511
+ // (forward-declared) connector instead forwards `payload` UNCHANGED to its worker (`readConvergeInput`
1512
+ // in `workers/delivery-connector/worker.ts` runs only for `isConvergeTarget`), so `{pr:" x "}` and
1513
+ // `{pr:"x"}` are DISTINCT runtime payloads that MUST stay disambiguated — trimming them here would
1514
+ // collapse two different dispatches into one run key and let the keyless dispatch fence reuse the
1515
+ // wrong payload (issue #783 review — thread deliveryGraphCompiler.ts:1376). So trim only when the
1516
+ // target is a converge target.
1517
+ if (c.payload !== undefined && c.payload !== null) {
1518
+ const normalisedPayload =
1519
+ isConvergeTarget(trimmedOrEmpty(c.target)) && isRecord(c.payload) && typeof c.payload.pr === "string"
1520
+ ? { ...c.payload, pr: c.payload.pr.trim() }
1521
+ : c.payload;
1522
+ let invisible = true;
1523
+ if (isRecord(normalisedPayload)) {
1524
+ const keys = Object.keys(normalisedPayload);
1525
+ const pr = normalisedPayload.pr;
1526
+ invisible = keys.length !== 1 || keys[0] !== "pr" || typeof pr !== "string" || pr === "" || pr !== redactConnectorValue(pr);
1527
+ }
1528
+ if (invisible) out.push(`${id}\u0000connector.payload\u0000${canonicalJson(normalisedPayload)}`);
1529
+ }
1530
+ break;
1531
+ }
1532
+ case "wait": {
1533
+ const p = node.wait;
1534
+ // `parseProbe` (`readiness.ts`) TRIMS `target` for EVERY kind before the worker keys on it, so a
1535
+ // leading/trailing-whitespace-only variant (` run-task ` vs `run-task`) is the SAME runtime probe.
1536
+ // For a `command` probe the display is a constant `<redacted>`, so without trimming the fingerprint
1537
+ // the digest would fork on that whitespace — a distinct staged run key for an identical runtime
1538
+ // probe, bypassing the idempotency fence. Fingerprint the TRIMMED target (all kinds) so it matches
1539
+ // the runtime-normalised value (issue #778 review — thread :1372).
1540
+ push(id, "wait.target", p.target.trim(), redactProbeTargetForDisplay(p));
1541
+ // `credentialEnv` names a DECLARED env-contract key, shown in the doc as `trimmedOrEmpty(...)` then
1542
+ // XML-sanitised at serialisation, while the runtime `parseProbe` reads it as `.trim()`. A value
1543
+ // carrying an XML-invalid char (`"GITHUB_TOKEN\x01"`) sanitises to the SAME display as valid
1544
+ // `"GITHUB_TOKEN"`, so without a fingerprint the malformed graph shares the valid graph's digest and
1545
+ // `graphCarriesRedactedSecrets` stays false — letting keyless dispatch short-circuit the malformed
1546
+ // proposal onto the valid running instance (and mark it dispatched) even though `parseProbe` would
1547
+ // reject the raw value. Fingerprint the trimmed raw against its XML-sanitised form so a whitespace-
1548
+ // only variant collapses (runtime trims) while an invalid-char one stays disambiguated (issue #778
1549
+ // review — thread :1355).
1550
+ push(id, "wait.credentialEnv", trimmedOrEmpty(p.credentialEnv), stripXmlInvalidChars(trimmedOrEmpty(p.credentialEnv)));
1551
+ // `kind`/`onTimeout`/`poll.backoff` are shown verbatim in the doc (`Readiness probe: <kind>`,
1552
+ // `On timeout: <onTimeout>`, `<backoff> backoff`) then XML-sanitised at serialisation, while the
1553
+ // runtime `parseProbe` reads their RAW values. Exactly like `credentialEnv` above, a value carrying
1554
+ // an XML-invalid char (`"http\x01"`) sanitises to the SAME display as the valid form, so without a
1555
+ // fingerprint the malformed probe shares a valid graph's digest and `graphCarriesRedactedSecrets`
1556
+ // stays false — letting keyless dispatch short-circuit the malformed proposal onto the valid
1557
+ // running instance (and mark it dispatched) even though `parseProbe` would reject the raw value.
1558
+ // Fingerprint each trimmed raw against its XML-sanitised form so a whitespace-only variant collapses
1559
+ // (runtime trims) while an invalid-char one stays disambiguated (issue #778 review — thread :1366,
1560
+ // same class as the credentialEnv :1355 fingerprint above).
1561
+ push(id, "wait.kind", trimmedOrEmpty(p.kind), stripXmlInvalidChars(trimmedOrEmpty(p.kind)));
1562
+ push(id, "wait.onTimeout", trimmedOrEmpty(p.onTimeout), stripXmlInvalidChars(trimmedOrEmpty(p.onTimeout)));
1563
+ push(id, "wait.poll.backoff", trimmedOrEmpty(p.poll?.backoff), stripXmlInvalidChars(trimmedOrEmpty(p.poll?.backoff)));
1564
+ if (p.match) {
1565
+ for (const [k, v] of Object.entries(p.match)) {
1566
+ if (v === undefined || v === null) continue;
1567
+ // A string predicate whose trimmed value is EMPTY is runtime-unset — `parseMatch` coerces a
1568
+ // string field through `str(v).trim() || undefined` and a numeric field through `num(v)` (a
1569
+ // non-number → undefined), so `""`/`" "` is omitted-equivalent on EITHER. `describeProbeMatch`
1570
+ // now drops it from the display (same digest as the omitted graph), so the run-key fingerprint
1571
+ // MUST drop it too — otherwise an empty variant pushes a token the omitted graph does not, forking
1572
+ // `stableProposalRunKey` under a shared digest and letting a keyless re-stage double-dispatch
1573
+ // (issue #778 review — thread deliveryGraphCompiler.ts:1254).
1574
+ if (matchValueRuntimeEmpty(v)) continue;
1575
+ // `verifyCommand`/`bodyIncludes`/`stdoutIncludes` are shown only as `<redacted>`; every other
1576
+ // match value is shown as `redactConnectorValue(String(v))`, which (a) XML-1.0 sanitisation
1577
+ // (`escapeXml`) later STRIPS invalid characters from — so `"1\x01"` and `"1"` share a digest —
1578
+ // AND (b) URL-redacts a credential-bearing free-form predicate (`capabilityRef`/`package`/
1579
+ // `checkName`) so `//user:pass@host#274` no longer surfaces the secret in the display. Either
1580
+ // transform leaves content the digest cannot see while the raw probe config differs; both cases
1581
+ // are digest-invisible and the raw value is the disambiguator (issue #778 review — thread :1169).
1582
+ if (REDACTED_MATCH_FIELDS.has(k)) {
1583
+ // A redacted field is ALWAYS invisible (display is `<redacted>`). `parseMatch` coerces these
1584
+ // free-form predicates through `str(v).trim()`, so fingerprint the SAME coercion — a
1585
+ // whitespace-only variant AND a number/string cross-type twin (`1` vs `"1"`) both collapse to
1586
+ // the one runtime match (an internal invalid char still distinguishes) instead of forking the
1587
+ // server-derived run key (issue #778 review — threads :1363 / deliveryGraphCompiler.ts:1251).
1588
+ // `v` is guaranteed non-null here; `str` is not exported, so inline `String(v).trim()`.
1589
+ out.push(`${id}\u0000wait.match.${k}\u0000${canonicalJson(String(v).trim())}`);
1590
+ } else if (matchValueTypeMismatch(k, v)) {
1591
+ // A numeric field (`status`/`exitCode`) coerces through `num()`: a real number is a
1592
+ // distinct runtime match, but EVERY non-number (`"200"`, `" 200 "`, `"foo"`, `true`)
1593
+ // collapses to the SAME `undefined` (unset / any-2xx) match. Fingerprint that CANONICAL
1594
+ // coerced value — a constant `null` unset marker — NOT the raw `v`: otherwise two
1595
+ // runtime-equivalent non-number twins (`"200"` vs `" 200 "`, which also DISPLAY identically)
1596
+ // fork distinct `stableProposalRunKey`s and double-dispatch. The number/string cross-type
1597
+ // twin (`200` vs `"200"`) still forks correctly because the number takes the else-branch and
1598
+ // pushes nothing, while a genuinely different-DISPLAY string (`"foo"`) already forks via
1599
+ // `semanticBpmn` (issue #778 review — thread deliveryGraphCompiler.ts:1616).
1600
+ out.push(`${id}\u0000wait.match.${k}\u0000${canonicalJson(null)}`);
1601
+ } else {
1602
+ // A non-redacted field is shown as `redactConnectorValue(String(v).trim())` — `describeProbeMatch`
1603
+ // trims a string predicate before display, and `parseMatch` likewise coerces via
1604
+ // `str(v).trim()` — so fingerprint the SAME trimmed form. Comparing/fingerprinting the RAW
1605
+ // `String(v)` instead forks the run key for a whitespace-only variant (`" //user:pass@host#1 "`
1606
+ // vs its trimmed twin) that parses to the ONE runtime match, so a keyless re-stage double-
1607
+ // dispatches an identical graph (issue #778 review — thread deliveryGraphCompiler.ts:1591).
1608
+ const tv = typeof v === "string" ? v.trim() : v;
1609
+ if (hasXmlInvalidChars(String(tv)) || redactConnectorValue(String(tv)) !== String(tv)) {
1610
+ out.push(`${id}\u0000wait.match.${k}\u0000${canonicalJson(tv)}`);
1611
+ }
1612
+ }
1613
+ }
1614
+ }
1615
+ break;
1616
+ }
1617
+ default:
1618
+ return assertNever(node, "digestInvisibleRawValues");
1619
+ }
1620
+ }
1621
+ // Canonicalise the order so this list is a content IDENTITY, not an ENCODING one. Each entry is fully
1622
+ // self-identifying (`nodeId\0field\0raw`), so its position in `graph.nodes` iteration order carries no
1623
+ // information — but the run-key fingerprint (`stableProposalRunKey`) canonicalises this array with
1624
+ // `canonicalJson`, which PRESERVES array order. The compiler SORTS nodes before emitting `semanticBpmn`,
1625
+ // so two graphs differing ONLY in top-level node order share one digest; without this sort their
1626
+ // invisible-value lists would differ in order alone, yielding DISTINCT run-keys that double-launch the
1627
+ // same logical graph instead of short-circuiting as `alreadyRunning`. Sort by code unit (locale-
1628
+ // independent) so the fingerprint is reorder-invariant, matching the digest's node-reorder collapse
1629
+ // (issue #778 review). Sorting cannot drop or merge entries, so the emptiness check
1630
+ // (`graphCarriesRedactedSecrets`) and the credential-disambiguation are unaffected.
1631
+ out.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1632
+ return out;
1633
+ }
1634
+
1635
+ /** Whether a graph's raw content carries values that redaction/normalisation DROPS from `semanticBpmn`
1636
+ * (issue #778 review — Option C): non-empty {@link digestInvisibleRawValues}. Two graphs differing ONLY
1637
+ * in such a value compile to IDENTICAL `semanticBpmn` → identical digest, so the digest is NOT a faithful
1638
+ * fingerprint for them; a dispatch must then REQUIRE an explicit `idempotencyKey` (or the stable
1639
+ * server-side run-key) to disambiguate, instead of silently collapsing the second onto the first's
1640
+ * still-running instance. `false` ⇒ the digest is a complete identity and no key is required. */
1641
+ export function graphCarriesRedactedSecrets(graph: DeliveryGraph): boolean {
1642
+ return digestInvisibleRawValues(graph).length > 0;
1643
+ }
1644
+
889
1645
  /** Render one node as an EMBEDDED `bpmn:subProcess` — the engine-native delegation unit (Decision 2).
890
1646
  * Call activities are a no-op on the pinned WASM engine (the child is never instantiated), so — like
891
1647
  * `plan-fanout`'s `readiness-preflight` — every node inlines a subProcess that shares the parent
@@ -904,15 +1660,25 @@ function renderNodeElement(
904
1660
  requiredEmits: ReadonlySet<string>,
905
1661
  ): string {
906
1662
  const el = w.element;
907
- const name = escapeXml(`${w.node.kind}: ${w.node.id}`);
1663
+ const display = nodeDisplay(w.node);
1664
+ const name = escapeXml(display.name);
908
1665
  const flowRefs = [
909
1666
  ...incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`),
910
1667
  ...outgoing.map((id) => ` <bpmn:outgoing>${id}</bpmn:outgoing>`),
911
1668
  ];
912
1669
  const io = ioMappingLines(w, boundInputs);
913
- const inner = innerBodyLines(w, requiredEmits);
1670
+ const inner = innerBodyLines(w, requiredEmits, display.name);
1671
+ // `<bpmn:documentation>` is the BPMN-native per-node description (a tooltip/details panel in the
1672
+ // explorer/modeler). It is a SEMANTIC child, so it survives the `layoutBpmn` DI graft untouched
1673
+ // (unlike a hand-edited DI block). Schema-wise it must be the flow element's FIRST child (before
1674
+ // `<extensionElements>`/`<incoming>`), so it slots ahead of the flow refs and io. Omitted when empty.
1675
+ const documentation =
1676
+ display.documentation.trim() !== ""
1677
+ ? [` <bpmn:documentation>${escapeXml(display.documentation)}</bpmn:documentation>`]
1678
+ : [];
914
1679
  const lines = [
915
1680
  ` <bpmn:subProcess id="${el}" name="${name}">`,
1681
+ ...documentation,
916
1682
  ...flowRefs,
917
1683
  " <bpmn:extensionElements>",
918
1684
  ...io,
@@ -956,7 +1722,13 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
956
1722
  // target (a real `owner/repo#N` is never `<node>.<fact>`-shaped) can't match a bound ref, so it
957
1723
  // passes through unchanged. Guarded (`is defined`) so an as-yet-unobserved fact keeps the
958
1724
  // authored value rather than raising a FEEL error.
959
- const boundTarget = boundInputs.find((b) => `${b.fromNode}.${b.fact}` === node.wait.target);
1725
+ // TRIM the target before the bind match — `parseProbe` (`readiness.ts`) trims `wait.target` before
1726
+ // the worker keys on it, and the display/digest render the trimmed form (`p.target.trim()`,
1727
+ // deliveryGraphCompiler.ts:1476). Matching the RAW `node.wait.target` here let a padded fact
1728
+ // reference (`" open.pr "`) render/digest like the trimmed ref yet SKIP this binding, leaving the
1729
+ // runtime with the literal `open.pr` the wait can never resolve (issue #778 review — thread
1730
+ // deliveryGraphCompiler.ts:1476). Mirrors the connector's `payload.pr.trim()` normalisation.
1731
+ const boundTarget = boundInputs.find((b) => `${b.fromNode}.${b.fact}` === node.wait.target.trim());
960
1732
  if (boundTarget) {
961
1733
  const varName = `${boundTarget.producerElement}_${boundTarget.fact}`;
962
1734
  const probeRef = cfg("probe").slice(1);
@@ -1032,7 +1804,7 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
1032
1804
  * the S3 scheduled user-task + generic form + SLA. Each is a single-entry / single-exit subgraph with
1033
1805
  * a bounded timeout that escalates onto a human-completable user task (or, for `human`, records an
1034
1806
  * escalated outcome). */
1035
- function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): string[] {
1807
+ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>, displayName: string): string[] {
1036
1808
  const el = w.element;
1037
1809
  const node = w.node;
1038
1810
  switch (node.kind) {
@@ -1042,14 +1814,23 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): stri
1042
1814
  // as a required data dependency. A broken producer (returns `in_progress`, or omits a required
1043
1815
  // emit) escalates AT this node instead of threading an incomplete result onward.
1044
1816
  const contractGate = { requiredEmits: normaliseEmits(node).filter((f) => requiredEmits.has(f.name)) };
1045
- return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], node.agent.jobType, contractGate, agentRepoSpecHeaderLines(node));
1817
+ // The executable `<zeebe:taskDefinition type=…>` MUST carry the raw `jobType` verbatim for worker
1818
+ // routing (validateDeliveryGraph rejects a URL-shaped/credential-bearing jobType, so it can never
1819
+ // be a leak here), but the `descriptor` is embedded by `serviceBodyLines` into the operator-visible
1820
+ // timeout / producer-contract escalation FEEL `prompt` — so it takes the SAME display redaction
1821
+ // `nodeDisplay` applies, never the raw value (issue #778 review — thread deliveryGraphCompiler.ts:1606).
1822
+ return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName);
1046
1823
  }
1047
1824
  case "connector":
1048
- return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${node.connector.target}`);
1825
+ // TRIM the target before building the escalation descriptor — `nodeDisplay` and the connector
1826
+ // worker both key on the trimmed value, so an untrimmed `" converge-merge "` would fork the
1827
+ // `semanticBpmn`/digest from the trimmed-equivalent graph that dispatches identically (issue #778
1828
+ // review — thread deliveryGraphCompiler.ts:1741).
1829
+ return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${redactConnectorValue(node.connector.target.trim())}`, undefined, [], displayName);
1049
1830
  case "wait":
1050
- return waitBodyLines(el, node);
1831
+ return waitBodyLines(el, node, displayName);
1051
1832
  case "human":
1052
- return humanBodyLines(el, node.id);
1833
+ return humanBodyLines(el, displayName);
1053
1834
  default:
1054
1835
  return assertNever(node, "innerBodyLines");
1055
1836
  }
@@ -1129,6 +1910,7 @@ function serviceBodyLines(
1129
1910
  descriptor: string,
1130
1911
  contractGate?: { requiredEmits: readonly DeliveryFact[] },
1131
1912
  taskHeaders: readonly string[] = [],
1913
+ taskName: string = nodeId,
1132
1914
  ): string[] {
1133
1915
  const esc = escalationTaskElement(el);
1134
1916
  const taskExt = [
@@ -1149,10 +1931,11 @@ function serviceBodyLines(
1149
1931
  "nodeTimeout",
1150
1932
  "; in-flight work may already exist — check for a draft PR or partial state before retrying or reassigning.",
1151
1933
  ),
1934
+ { displayName: taskName },
1152
1935
  );
1153
1936
  const head = [
1154
1937
  ` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
1155
- ` <bpmn:serviceTask id="${el}_task" name="${escapeXml(nodeId)}">`,
1938
+ ` <bpmn:serviceTask id="${el}_task" name="${escapeXml(taskName)}">`,
1156
1939
  ...taskExt,
1157
1940
  ` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
1158
1941
  ` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
@@ -1188,7 +1971,7 @@ function serviceBodyLines(
1188
1971
  // Resumable when the producer owes a required emit: a human/agent supplies the missing fact, which
1189
1972
  // the subProcess output ioMapping then publishes as `<el>_<fact>` (agent emit source = fact name),
1190
1973
  // so the downstream consumer late-binds a real value instead of the null that poisoned it (#731).
1191
- emits.length > 0 ? { resume: { kind: "agent" as const, emits } } : undefined,
1974
+ { ...(emits.length > 0 ? { resume: { kind: "agent" as const, emits } } : {}), displayName: taskName },
1192
1975
  );
1193
1976
  return [
1194
1977
  ...head,
@@ -1212,8 +1995,9 @@ function serviceBodyLines(
1212
1995
  /** `wait` body: `start → pr.readiness-probe (poll) → ready? → end`, escalating on not-ready or on the
1213
1996
  * `=probeTimeout` engine bound. The probe polls its OWN target, so an unrelated upstream event can
1214
1997
  * never flip it to ready (#274/S2 concurrency-correctness); the `pr` kind (S2) binds `mergedSha`. */
1215
- function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>): string[] {
1998
+ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>, displayName: string): string[] {
1216
1999
  const nodeId = node.id;
2000
+ const name = escapeXml(displayName);
1217
2001
  const esc = escalationTaskElement(el);
1218
2002
  const emits = normaliseEmits(node);
1219
2003
  // `onTimeout` routing (#462): `escalate` (default) parks the not-ready-at-boundary token on a
@@ -1221,7 +2005,14 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
1221
2005
  // stop (a documented sharp edge — the downstream side-effecting node then runs without the awaited
1222
2006
  // fact). `fail` is rejected earlier at validation (blocked on engine terminate-end, #978), so it
1223
2007
  // never reaches here.
1224
- const continueOnTimeout = node.wait?.onTimeout === "continue";
2008
+ //
2009
+ // NORMALISE (trim) `onTimeout` before the topology decision — `parseProbe` trims it
2010
+ // (`str(raw.onTimeout).trim()`) and the display/digest path uses `trimmedOrEmpty(p.onTimeout)`, so a
2011
+ // padded `" continue "` runs the SAME `continue` routing at the worker. Comparing the RAW value here
2012
+ // would emit the escalation branch for a `" continue "` the runtime treats as continue — the compiled
2013
+ // topology diverging from the requested (and digested) behaviour (issue #778 review — thread
2014
+ // deliveryGraphCompiler.ts:2012).
2015
+ const continueOnTimeout = trimmedOrEmpty(node.wait?.onTimeout) === "continue";
1225
2016
  // Defect A: read-only probe diagnostics seeded onto the escalation task so the operator/agent can
1226
2017
  // tell a genuine "not published yet" from a transient false-negative — the probe's last detail, the
1227
2018
  // resolved target/match, and a compact summary of the candidate releases the probe observed.
@@ -1233,7 +2024,7 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
1233
2024
  ];
1234
2025
  return [
1235
2026
  ` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
1236
- ` <bpmn:subProcess id="${el}_probeLoop" name="Probe readiness loop: ${escapeXml(nodeId)}">`,
2027
+ ` <bpmn:subProcess id="${el}_probeLoop" name="Probe readiness loop: ${name}">`,
1237
2028
  " <bpmn:extensionElements>",
1238
2029
  " <zeebe:ioMapping>",
1239
2030
  ' <zeebe:output source="=ready" target="ready" />',
@@ -1247,7 +2038,7 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
1247
2038
  ` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
1248
2039
  ` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
1249
2040
  ` <bpmn:startEvent id="${el}_loopStart"><bpmn:outgoing>${el}_li0</bpmn:outgoing></bpmn:startEvent>`,
1250
- ` <bpmn:serviceTask id="${el}_task" name="Probe readiness: ${escapeXml(nodeId)}">`,
2041
+ ` <bpmn:serviceTask id="${el}_task" name="Probe readiness: ${name}">`,
1251
2042
  " <bpmn:extensionElements>",
1252
2043
  ` <zeebe:taskDefinition type="${DELEGATE_TASK_TYPE.wait}" />`,
1253
2044
  " <zeebe:properties>",
@@ -1280,7 +2071,7 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
1280
2071
  ` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
1281
2072
  ` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=probeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
1282
2073
  " </bpmn:boundaryEvent>",
1283
- ` <bpmn:serviceTask id="${el}_lastAttempt" name="Probe readiness at boundary: ${escapeXml(nodeId)}">`,
2074
+ ` <bpmn:serviceTask id="${el}_lastAttempt" name="Probe readiness at boundary: ${name}">`,
1284
2075
  " <bpmn:extensionElements>",
1285
2076
  ` <zeebe:taskDefinition type="${DELEGATE_TASK_TYPE.wait}" />`,
1286
2077
  " <zeebe:properties>",
@@ -1307,7 +2098,7 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
1307
2098
  [`${el}_i4`],
1308
2099
  `${el}_i5`,
1309
2100
  waitEscalationContextFeel(nodeId),
1310
- { resume: { kind: node.kind, emits }, diagnosticInputs },
2101
+ { resume: { kind: node.kind, emits }, diagnosticInputs, displayName },
1311
2102
  )),
1312
2103
  // On `continue`, the not-ready-at-boundary branch (`_i4`) proceeds straight to the node end (no
1313
2104
  // human stop, no `_i5` escalation-return flow); on `escalate` it parks on the escalation task,
@@ -1328,13 +2119,13 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
1328
2119
  * — the subProcess ioMapping then publishes it as the node's fact); on SLA expiry the node records an
1329
2120
  * `escalated` outcome and settles (bounded — the graph cannot silently wedge). Mirrors the standalone
1330
2121
  * `delivery-human.bpmn` shape, reusing the S3 form + emit-var contract (`deliveryHuman.ts`). */
1331
- function humanBodyLines(el: string, nodeId: string): string[] {
2122
+ function humanBodyLines(el: string, displayName: string): string[] {
1332
2123
  const task = humanTaskElement(el);
1333
2124
  const assignee =
1334
2125
  '=if (is defined(escalationAssignee) and escalationAssignee != null and trim(string(escalationAssignee)) != "") then escalationAssignee else null';
1335
2126
  return [
1336
2127
  ` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
1337
- ` <bpmn:userTask id="${task}" name="Delivery: human step — ${escapeXml(nodeId)}">`,
2128
+ ` <bpmn:userTask id="${task}" name="Delivery: human step — ${escapeXml(displayName)}">`,
1338
2129
  " <bpmn:extensionElements>",
1339
2130
  ` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
1340
2131
  " <zeebe:userTask />",
@@ -1389,6 +2180,7 @@ function escalationTaskLines(
1389
2180
  opts?: {
1390
2181
  resume?: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[] };
1391
2182
  diagnosticInputs?: readonly { source: string; target: string }[];
2183
+ displayName?: string;
1392
2184
  },
1393
2185
  ): string[] {
1394
2186
  const emits = opts?.resume?.emits ?? [];
@@ -1424,8 +2216,12 @@ function escalationTaskLines(
1424
2216
  );
1425
2217
  }
1426
2218
  }
2219
+ // The escalation user task shows the node's DESCRIPTIVE display name (issue #778 review) so a
2220
+ // timed-out / contract-broken node is legible in the explorer/inbox instead of an opaque bare id;
2221
+ // `nodeId` is still threaded as the `nodeId` input above for runtime correlation.
2222
+ const escLabel = trimmedOrEmpty(opts?.displayName) || nodeId;
1427
2223
  return [
1428
- ` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(nodeId)}">`,
2224
+ ` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(escLabel)}">`,
1429
2225
  " <bpmn:extensionElements>",
1430
2226
  ` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
1431
2227
  " <zeebe:userTask />",
@@ -1472,9 +2268,10 @@ function flow(id: string, source: string, target: string): string {
1472
2268
  return ` <bpmn:sequenceFlow id="${id}" sourceRef="${source}" targetRef="${target}" />`;
1473
2269
  }
1474
2270
 
1475
- /** Render a human-readable mermaid `flowchart` of the resolved graph — one node per box labelled
1476
- * `<kind>: <id>`, one arrow per edge (labelled with the referenced fact when qualified). Deterministic
1477
- * (nodes/edges already sorted). */
2271
+ /** Render a human-readable mermaid `flowchart` of the resolved graph — one box per node labelled with
2272
+ * the SAME descriptive {@link nodeDisplay} name the compiled BPMN uses (issue #778: one display source,
2273
+ * no `kind: id` drift between the diagram and the deployed model), one arrow per edge (labelled with the
2274
+ * referenced fact when qualified). Deterministic (nodes/edges already sorted). */
1478
2275
  function renderMermaid(
1479
2276
  graph: DeliveryGraph,
1480
2277
  wirings: readonly NodeWiring[],
@@ -1484,7 +2281,7 @@ function renderMermaid(
1484
2281
  const lines: string[] = ["flowchart TD"];
1485
2282
  if (graph.name !== undefined) lines.push(` %% ${escapeMermaid(graph.name)}`);
1486
2283
  for (const w of wirings) {
1487
- lines.push(` ${w.element}["${escapeMermaid(`${w.node.kind}: ${w.node.id}`)}"]`);
2284
+ lines.push(` ${w.element}["${escapeMermaid(nodeDisplay(w.node).name)}"]`);
1488
2285
  }
1489
2286
  for (const edge of edges) {
1490
2287
  const from = mustGet(elementById, edge.fromNode);