@nanobpm/nano-workforce 0.200.9 → 0.201.1

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/SPEC.md +188 -0
  3. package/app/agentCompletion.test.ts +52 -0
  4. package/app/agentCompletion.ts +20 -5
  5. package/app/contracts.ts +9 -0
  6. package/app/deliveryGraph.test.ts +261 -0
  7. package/app/deliveryGraph.ts +334 -1
  8. package/app/deliveryGraphCompiler.test.ts +1698 -39
  9. package/app/deliveryGraphCompiler.ts +1047 -149
  10. package/app/deliveryGraphDeploy.test.ts +472 -3
  11. package/app/deliveryGraphRun.ts +2 -2
  12. package/app/deliveryHuman.test.ts +59 -5
  13. package/app/deliveryHuman.ts +115 -3
  14. package/app/deliveryRunner.test.ts +3 -3
  15. package/app/deliveryRunner.ts +14 -5
  16. package/app/pullRequestReadModel.test.ts +1 -1
  17. package/app/redactText.ts +88 -0
  18. package/app/roundChurn.test.ts +388 -0
  19. package/app/roundChurn.ts +265 -0
  20. package/app/roundProgress.test.ts +224 -1
  21. package/app/service.test.ts +7 -0
  22. package/app/service.ts +28 -2
  23. package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
  24. package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
  25. package/docs/agent-guide.md +10 -7
  26. package/e2e/delivery-graph-dispatch.e2e.ts +2 -1
  27. package/e2e/delivery-graph-node-scope.e2e.ts +358 -0
  28. package/e2e/delivery-graph.e2e.ts +580 -7
  29. package/package.json +1 -1
  30. package/resources/forms/delivery-escalation.form +41 -0
  31. package/resources/forms/delivery-human-generic.form +4 -3
  32. package/workers/progress-check/worker.ts +98 -1
  33. package/workers/readiness-probe/worker.test.ts +72 -1
  34. package/workers/readiness-probe/worker.ts +39 -0
@@ -38,22 +38,35 @@ import { TRANSCRIPT_URL_BASE_VAR, TRANSCRIPT_URL_VAR } from "./agentic/transcrip
38
38
  import { CONVERGE_MERGE_TARGET, CONVERGE_TARGET, isConvergeTarget, MERGE_MAIN_TARGET } from "./convergeTargets.ts";
39
39
  import { DELIVERY_CONNECTOR_TASK_TYPE } from "./deliveryConnector.ts";
40
40
  import {
41
+ AGENT_RESULT_LOCAL_VARS,
41
42
  analyzeExclusiveTopology,
43
+ CONNECTOR_RESULT_LOCAL_VARS,
42
44
  canonicalJson,
43
45
  type DeliveryGraphError,
44
46
  deliveryNodeFacts,
47
+ ESCALATION_DECISION_RETRY,
48
+ ESCALATION_DECISION_VAR,
49
+ ESCALATION_LOCAL_VARS,
50
+ ESCALATION_NOTE_VAR,
51
+ HUMAN_RESULT_LOCAL_VARS,
45
52
  hasXmlInvalidChars,
46
53
  redactConnectorValue,
47
54
  resolveDeliveryFrom,
48
55
  stripXmlInvalidChars,
49
56
  validateDeliveryGraph,
50
57
  } from "./deliveryGraph.ts";
51
- import { DELIVERY_HUMAN_ELEMENT, GENERIC_HUMAN_FORM } from "./deliveryHuman.ts";
58
+ import { DELIVERY_CONTRACT_TWIN_SUFFIX, DELIVERY_ESCALATION_TWIN_SUFFIX, DELIVERY_HUMAN_ELEMENT, DELIVERY_WAIT_ESCALATION_TWIN_SUFFIX, ESCALATION_FORM, GENERIC_HUMAN_FORM, resolveHumanForm } from "./deliveryHuman.ts";
52
59
  import { layoutBpmnOffThread } from "./layoutOffThread.ts";
53
- import { DEFAULT_BACKOFF, DEFAULT_EVERY_MS, DEFAULT_ON_TIMEOUT, DEFAULT_TIMEOUT_MS, isProbeKind, normalizePoll, redactEmbeddedCredential, redactString } from "./readiness.ts";
60
+ import { DEFAULT_BACKOFF, DEFAULT_EVERY_MS, DEFAULT_ON_TIMEOUT, DEFAULT_TIMEOUT_MS, isProbeKind, normalizePoll, redactString } from "./readiness.ts";
61
+ // `redactFreeText` now lives in the low-level `redactText.ts` helper (both this compiler and
62
+ // `deliveryHuman.ts` import it there) to break the former `compiler ⇄ deliveryHuman` import cycle
63
+ // (PR #863 review). Re-exported here so existing `./deliveryGraphCompiler.ts` consumers keep resolving it.
64
+ import { redactFreeText } from "./redactText.ts";
54
65
  import { AGENT_TASK_NS } from "./repoEnvelope.ts";
55
66
  import { isoDuration } from "./reviewWait.ts";
56
67
 
68
+ export { redactFreeText };
69
+
57
70
  /** A display-safe rendering of a `wait` probe's target for user-visible BPMN name/documentation
58
71
  * (issue #778 review): a `command` target is an arbitrary shell snippet that can embed a secret, so it
59
72
  * is never surfaced — only a fixed placeholder; an `http` target can carry a credential in its
@@ -144,6 +157,12 @@ const DELEGATE_TASK_TYPE: Record<Exclude<DeliveryNode["kind"], "agent" | "human"
144
157
  * truth so the runner never hardcodes the literal it substitutes. */
145
158
  export const DELIVERY_GRAPH_PROCESS_ID = "delivery-graph";
146
159
 
160
+ /** The run-level node SLA fallback (`PT1H`) — the SINGLE SOURCE OF TRUTH shared by the compiler's
161
+ * bounded-timeout ioMapping and the runner's `DEFAULTS.nodeTimeout` (PR #876 review). A node released
162
+ * with no per-node and no run-level timeout falls back to THIS one constant, so the two code paths can
163
+ * never drift to two different "default SLA" values (derivation-over-duplication). */
164
+ export const DELIVERY_NODE_DEFAULT_TIMEOUT = "PT1H";
165
+
147
166
  /** The BPMN element id a `human` node's inlined user task carries. One user task per human node (the
148
167
  * compiled one-shot inlines each), so the id is per-node (`delivery-human-task__<element>`) — the
149
168
  * `isDeliveryHumanElement` convention (single source of truth in `deliveryHuman.ts`) is what keeps it
@@ -155,8 +174,12 @@ function humanTaskElement(element: string): string {
155
174
  /** The BPMN element id a service node's bounded-timeout escalation user task carries — same
156
175
  * human-completable convention as a human node, so a stalled `agent`/`wait`/`connector` escalates onto
157
176
  * the Tasks inbox and is answerable by a human OR an agent (ADR 0046). */
158
- function escalationTaskElement(element: string): string {
159
- return `${DELIVERY_HUMAN_ELEMENT}__${element}__esc`;
177
+ function escalationTaskElement(element: string, kind?: DeliveryNode["kind"]): string {
178
+ // A `wait` gate's escalation has no retry semantics, so it renders the select-less generic form and
179
+ // stamps the `__wait` kind marker into its id — letting `escalationFormId` DERIVE that form from the
180
+ // id rather than inferring the retry-capable form from the shared `__esc` suffix (PR #863 review).
181
+ const suffix = kind === "wait" ? DELIVERY_WAIT_ESCALATION_TWIN_SUFFIX : DELIVERY_ESCALATION_TWIN_SUFFIX;
182
+ return `${DELIVERY_HUMAN_ELEMENT}__${element}${suffix}`;
160
183
  }
161
184
 
162
185
  /** The BPMN element id an `agent` node's PRODUCER-CONTRACT escalation user task carries (issue #731) —
@@ -166,7 +189,62 @@ function escalationTaskElement(element: string): string {
166
189
  * a producer that finishes without doing its job escalates AT that node and is answerable by a human
167
190
  * OR an agent. */
168
191
  function contractEscalationTaskElement(element: string): string {
169
- return `${DELIVERY_HUMAN_ELEMENT}__${element}__contract`;
192
+ return `${DELIVERY_HUMAN_ELEMENT}__${element}${DELIVERY_CONTRACT_TWIN_SUFFIX}`;
193
+ }
194
+
195
+ // ESCALATION_DECISION_VAR / ESCALATION_DECISION_RETRY / ESCALATION_LOCAL_VARS / AGENT_RESULT_LOCAL_VARS /
196
+ // CONNECTOR_RESULT_LOCAL_VARS are imported from ./deliveryGraph.ts (their canonical home — the
197
+ // validator's reservedDeliveryFactNames derives from them there without an import cycle).
198
+ // ESCALATION_LOCAL_VARS is [ESCALATION_DECISION_VAR,"value",ESCALATION_NOTE_VAR]; AGENT_RESULT_LOCAL_VARS is the
199
+ // canonical node-local agent result contract (every resources/prompts/*.md output field);
200
+ // CONNECTOR_RESULT_LOCAL_VARS the connector's fixed result metadata. All three are declared node-local
201
+ // on the subProcess and cleared on retry so a node's report never leaks to the shared root.
202
+
203
+ /** The node-local boolean the preflight input mapping binds (see {@link nodeInputsPreflightFeel}). */
204
+ const NODE_INPUTS_PREFLIGHT_VAR = "nodeInputsPresent";
205
+
206
+ /** FEEL for the per-node preflight guard: `true` when the runner-seeded `nodeInputs.<el>` config is
207
+ * present, else a FEEL `assert` failure — an input-mapping incident on the node whose message names the
208
+ * cause and the fix, instead of a node that runs with null config and escalates with no explanation. */
209
+ export function nodeInputsPreflightFeel(el: string): string {
210
+ const present = `is defined(nodeInputs) and nodeInputs != null and is defined(nodeInputs.${el}) and nodeInputs.${el} != null`;
211
+ const cause =
212
+ `delivery-graph node ${el}: its runner-seeded config nodeInputs.${el} is missing from the process ` +
213
+ "instance (lost root variables?). Restore the instance's root variables (nodeInputs), then RE-ENTER " +
214
+ "the node's sub-process from OUTSIDE (relaunch/re-enter the node) so its subProcess input mappings " +
215
+ "re-evaluate against the restored config. Do NOT only resolve this incident, and do NOT use this " +
216
+ "node's in-subprocess \"Retry this step\" loop: resolving re-evaluates just this leaf's inputs, and " +
217
+ "the retry loop goes straight back to the inner service task — neither re-runs the subProcess entry " +
218
+ "mappings, so the subProcess-seeded config (prompt/appendPrompt/nodeTimeout, connector " +
219
+ "target/payload/dedupeKey) stays null and the job would run unconfigured.";
220
+ return `=assert(true, ${present}, ${feelStr(cause)})`;
221
+ }
222
+
223
+ /** A distinctive infix stamped into the compiler-generated resume-validity flag variable ({@link
224
+ * resumeValidVar}) to visually mark it as an internal engine variable rather than a user fact. It is
225
+ * NOT a reserved fact-name namespace — reserving it in the public `DeliveryFact.name` space would
226
+ * silently reject previously valid names (e.g. `my__flag__fact`) that openapi still advertises as legal
227
+ * and that durable library/proposal rows may already carry (PR #876 review). Collision-freedom is
228
+ * instead guaranteed structurally at the single bind site (see {@link escalationTaskLines}), so this
229
+ * infix is only a readability aid. */
230
+ const FLAG_VAR_INFIX = "__flag__";
231
+
232
+ /** The internal FEEL variable the resume-validation gateway routes on (PR #876 review). Derived from
233
+ * the escalation task's compiler-generated element id (sanitised to a FEEL-safe identifier) — NOT from
234
+ * the user fact-name space — so it can never collide with a user-declared emit fact bound into the same
235
+ * escalation subprocess scope (an `agent`/`connector` emit binds under its own `fact.name`). This
236
+ * removes the need to RESERVE a user-visible fact name (`resumeValid`) and the recompilation break that
237
+ * reserving it would impose on durable rows that already carry a fact of that name.
238
+ *
239
+ * The sanitised element id is still a legal FACT-NAME string (`^[A-Za-z_][A-Za-z0-9_]*$`), so a node
240
+ * could in principle declare an emit NAMED EXACTLY this flag — and for a single-emit `agent`/`connector`
241
+ * node that fact's emit-source var IS its own name ({@link factSourceVar}), which would map the
242
+ * recovered fact value AND this boolean flag onto the ONE variable. Rather than RESERVE part of the
243
+ * public fact-name space to forbid that (which would break previously valid names — PR #876 review),
244
+ * the single bind site in {@link escalationTaskLines} makes the generated flag name collision-free
245
+ * against this node's actual emit-source target by construction (a deterministic unused suffix). */
246
+ export function resumeValidVar(esc: string): string {
247
+ return `${esc.replace(/[^A-Za-z0-9_]/g, "_")}${FLAG_VAR_INFIX}resumeValid`;
170
248
  }
171
249
 
172
250
  /** The self-reported completion statuses an `agent` node's job may return that count as a TERMINAL
@@ -1067,82 +1145,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
1067
1145
  * in `deliveryGraph.ts`) so the DISPLAY path here and `validateDeliveryGraph`'s reject/error path share
1068
1146
  * ONE redactor — no drift surface (issue #778 review). */
1069
1147
 
1070
- /** Redact credential-bearing pieces of any URL embedded in FREE-FORM prose (a node's authored
1071
- * `prompt`), IN PLACE. Unlike {@link redactString} — tuned for a single opaque target string, where it
1072
- * truncates from the first `?`/`#` to end-of-string — this finds each URL-shaped token *within*
1073
- * surrounding prose (`scheme://…` or a scheme-relative `//…`) and strips only that token's
1074
- * `user:pass@` userinfo and `?query`/`#fragment` via `redactString`, leaving the prose (and ordinary
1075
- * punctuation such as a `?` ending a sentence) intact. Applied to the DISPLAY name/documentation only —
1076
- * a prompt is up to 20 000 chars of arbitrary text that can embed a bearer token or private URL, and
1077
- * the deployed `<bpmn:documentation>`/name is visible to modeler/explorer readers; the RAW prompt still
1078
- * reaches the runtime job input (`appendPrompt`/`prompt`) unmodified (issue #778 review). Deterministic
1079
- * and total. */
1080
- export function redactFreeText(value: string): string {
1081
- // Strip XML-invalid display characters BEFORE tokenizing/redacting so every scan runs on the exact
1082
- // string the renderer emits. Otherwise a control char embedded in a URL (`//us\x0Ber:pass@…`) breaks
1083
- // the `//[^\s]+` token match, escapes redaction, then reconstructs the credential once `escapeXml`/
1084
- // `stripXmlInvalidChars` drops the control at render time (issue #778 review — same class as the
1085
- // connector/probe strip-before-classify fix).
1086
- const cleaned = stripXmlInvalidChars(value);
1087
- // Embedded credential userinfo: DERIVE from the ONE canonical redactor {@link redactEmbeddedCredential}
1088
- // (`EMBEDDED_CREDENTIAL_SRC` = `//[^/?#]*@`) rather than a bespoke belt heuristic. Its `[^/?#]` class
1089
- // spans spaces/TABs/newlines up to the LAST `@` before a `/`/`?`/`#`, so EVERY `//<userinfo>@` shape
1090
- // collapses uniformly to `//***@` — a colon-prefix `//user:pass@`, a colon-LESS bearer separated from
1091
- // its `@host` by a word or space (`//token part@host`, `//token @host`), a userinfo split by a raw
1092
- // CR/LF/TAB, and a malformed multi-`@` authority (`//user:pass@ss@host`) — with NO second "what is a
1093
- // credential" implementation that can drift from the canonical redactor/validator (`hasEmbeddedCredential`)
1094
- // (issue #783 review — thread deliveryGraphCompiler.ts:1140; the earlier bespoke colon-less bridge
1095
- // stopped on the first non-whitespace char after the space and leaked `//token part@host`). A `//…@`
1096
- // span is UNAMBIGUOUSLY a credential wherever it sits, so redacting a prose `//word …@host` too is the
1097
- // SAFE direction: the RAW prompt still reaches the runtime job input unmodified — only the operator-
1098
- // visible display doc loses the span. The `[^/?#]` bound also keeps the userinfo from swallowing a `?`
1099
- // marker (`//host?token=secret@tail` has no userinfo — its `@` rides the query), leaving that tail to
1100
- // the query/fragment belt below.
1101
- const credStripped = redactEmbeddedCredential(cleaned);
1102
- // Query/fragment across a whitespace BREAK: the primary `//[^\s]+` token below stops at the break, so a
1103
- // `//host/?\nTOKEN=secret` would leave the value visible in the XML-preserved doc. The linear belt walks
1104
- // each SPACE-bounded `//`-run (crossing an embedded CR/LF/TAB the primary token stopped at) and
1105
- // CONSERVATIVELY redacts its `?query`/`#fragment` tail through the span's space boundary — a continuation
1106
- // past the break is indistinguishable from a split value, so we never keep the far side (issue #778
1107
- // review — thread deliveryGraphCompiler.ts:1115).
1108
- const belted = redactQueryFragmentSpans(credStripped);
1109
- return belted.replace(/(?:[a-z][a-z0-9+.-]*:)?\/\/[^\s]+/gi, (m) => redactString(m));
1110
- }
1111
-
1112
- /** Linear (backtracking-free) newline-aware belt companion to {@link redactFreeText}'s primary
1113
- * whitespace-bounded pass. Each `//`-run is bounded by a literal SPACE (0x20) — so a single span may
1114
- * cross a CR/LF/TAB *inside* the URL that the primary `//[^\s]+` token stopped at. Credential userinfo is
1115
- * already collapsed to `//***@` by the canonical {@link redactEmbeddedCredential} before this runs, so the
1116
- * belt's SOLE remaining job is a `?query`/`#fragment` tail: it redacts CONSERVATIVELY from the first
1117
- * `?`/`#` marker through the span's space boundary — a continuation past an embedded break is
1118
- * indistinguishable from a split value, so the far side is never kept. A span with no `?`/`#` (ordinary
1119
- * prose — a `//comment` reference, an already-collapsed `//***@host`) is returned untouched. The scan is a
1120
- * single left-to-right walk using `indexOf`/`charCodeAt` over spans bounded by the next SPACE, with no
1121
- * regex backtracking, so a 20 000-char adversarial prompt cannot trigger catastrophic backtracking (issue
1122
- * #778 review). Deterministic and total. */
1123
- function redactQueryFragmentSpans(text: string): string {
1124
- let out = "";
1125
- let i = 0;
1126
- for (;;) {
1127
- const start = text.indexOf("//", i);
1128
- if (start < 0) return out + text.slice(i);
1129
- out += text.slice(i, start);
1130
- let end = start;
1131
- while (end < text.length && text.charCodeAt(end) !== 0x20 /* SPACE */) end++;
1132
- const span = text.slice(start, end);
1133
- const qMark = span.indexOf("?");
1134
- const hMark = span.indexOf("#");
1135
- const qi = qMark < 0 ? hMark : hMark < 0 ? qMark : Math.min(qMark, hMark);
1136
- // CONSERVATIVE: keep everything up to and including the `?`/`#` marker, then collapse the whole tail
1137
- // (any continuation past an embedded CR/LF/TAB) to `***`. A value/prose resuming after the break is
1138
- // INDISTINGUISHABLE from a split-credential continuation, so we never keep the post-break side; the RAW
1139
- // prompt still reaches the runtime job input unmodified (issue #778 review — thread
1140
- // deliveryGraphCompiler.ts:1115).
1141
- out += qi < 0 ? span : `${span.slice(0, qi + 1)}***`;
1142
- i = end;
1143
- }
1144
- }
1145
-
1146
1148
  /** A human-readable label for a connector node's `target`. The converge-enrollment vocabulary
1147
1149
  * (`convergeTargets.ts`) maps to intent-revealing phrases; any other (forward-declared) target is shown
1148
1150
  * through {@link redactConnectorValue} — an opaque identifier (`slack:#releases`) survives unchanged, but
@@ -1707,12 +1709,22 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
1707
1709
  const outputs: { source: string; target: string }[] = [];
1708
1710
  const cfg = (field: string): string => `=nodeInputs.${el}.${field}`;
1709
1711
  const guarded = (src: string): string => `=if (is defined(${src})) then ${src} else null`;
1712
+ // Null-safe bounded-timeout source (issue #872): the node's boundary timer is `=nodeTimeout`, so a
1713
+ // null `nodeInputs.<el>.timeout` seeds a null duration and the engine fires the timer IMMEDIATELY
1714
+ // (a ~0.5s escalation that looks like an instant SLA breach). Fall back to the run-level
1715
+ // `runNodeTimeout` (seeded by the runner), then to the shared `DELIVERY_NODE_DEFAULT_TIMEOUT`
1716
+ // constant (`PT1H`) — the SAME default the runner seeds, so the two never drift (PR #876 review) —
1717
+ // so a node released with no per-node timeout always gets a real SLA instead of a zero-length timer.
1718
+ const timeoutSource = (): string => {
1719
+ const perNode = cfg("timeout").slice(1);
1720
+ return `=if (is defined(${perNode}) and ${perNode} != null) then ${perNode} else (if (is defined(runNodeTimeout) and runNodeTimeout != null) then runNodeTimeout else ${feelStr(DELIVERY_NODE_DEFAULT_TIMEOUT)})`;
1721
+ };
1710
1722
 
1711
1723
  switch (node.kind) {
1712
1724
  case "agent":
1713
1725
  inputs.push({ source: cfg("jobType"), target: "jobType" });
1714
1726
  inputs.push({ source: cfg("appendPrompt"), target: "appendPrompt" });
1715
- inputs.push({ source: cfg("timeout"), target: "nodeTimeout" });
1727
+ inputs.push({ source: timeoutSource(), target: "nodeTimeout" });
1716
1728
  // Stage 0 transcript correlation (#543): seed the transcript URL base so the completing fleet
1717
1729
  // worker can append its own jobKey-scoped stream and emit `transcriptUrl` (below). `transcriptUrlBase`
1718
1730
  // is a top-level launch variable (deliveryRunner) — guarded so a hand-seeded instance without it
@@ -1726,8 +1738,16 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
1726
1738
  // OBSERVED value via FEEL `context put`, so the canonical `agent → connector[converge-merge] →
1727
1739
  // wait[pr, merged]` shape polls the PR the agent opened with NO hardcoded literal. A plain literal
1728
1740
  // target (a real `owner/repo#N` is never `<node>.<fact>`-shaped) can't match a bound ref, so it
1729
- // passes through unchanged. Guarded (`is defined`) so an as-yet-unobserved fact keeps the
1730
- // authored value rather than raising a FEEL error.
1741
+ // passes through unchanged. Guarded (`is defined`) so an as-yet-unobserved fact binds NULL rather
1742
+ // than raising a FEEL error.
1743
+ //
1744
+ // The else-branch writes NULL — NOT the authored fact-ref literal (issue #872, PR #876 review).
1745
+ // The compiler is the ONLY component that knows whether the target was actually bound; leaving the
1746
+ // `<node>.<fact>` reference in place forced the readiness-probe worker to re-derive "unresolved"
1747
+ // from a SYNTAX test (`isFactRefTarget`), which cannot tell an unresolved fact-ref from a VALID
1748
+ // dotted literal command target (e.g. `check.sh`) and so parked that gate forever. Writing null
1749
+ // here is the fail-closed provenance: the worker guards null/blank ONLY, and a dotted literal
1750
+ // always reaches its probe verbatim.
1731
1751
  // TRIM the target before the bind match — `parseProbe` (`readiness.ts`) trims `wait.target` before
1732
1752
  // the worker keys on it, and the display/digest render the trimmed form (`p.target.trim()`,
1733
1753
  // deliveryGraphCompiler.ts:1476). Matching the RAW `node.wait.target` here let a padded fact
@@ -1739,7 +1759,7 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
1739
1759
  const varName = `${boundTarget.producerElement}_${boundTarget.fact}`;
1740
1760
  const probeRef = cfg("probe").slice(1);
1741
1761
  inputs.push({
1742
- source: `=context put(${probeRef}, "target", if (is defined(${varName})) then ${varName} else ${probeRef}.target)`,
1762
+ source: `=context put(${probeRef}, "target", if (is defined(${varName}) and ${varName} != null) then ${varName} else null)`,
1743
1763
  target: "probe",
1744
1764
  });
1745
1765
  } else {
@@ -1767,12 +1787,81 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
1767
1787
  inputs.push({ source: cfg("target"), target: "target" });
1768
1788
  inputs.push({ source: cfg("dedupeKey"), target: "dedupeKey" });
1769
1789
  inputs.push({ source: cfg("payload"), target: "payload" });
1770
- inputs.push({ source: cfg("timeout"), target: "nodeTimeout" });
1790
+ inputs.push({ source: timeoutSource(), target: "nodeTimeout" });
1771
1791
  break;
1772
1792
  default:
1773
1793
  return assertNever(node, "ioMappingLines");
1774
1794
  }
1775
1795
 
1796
+ // Node-local result scope (see AGENT_RESULT_LOCAL_VARS / ESCALATION_LOCAL_VARS): declare the variables a
1797
+ // job completion / escalation completion writes as `null` on THIS subProcess, so Nano's nearest-scope
1798
+ // propagation lands them here instead of the shared root. BOTH service-node kinds (agent AND connector)
1799
+ // also localise each declared emit's source variable (an agent/connector fact's source is the fact's
1800
+ // own name — see factSourceVar), so a sibling's same-named emit can never satisfy this node's contract
1801
+ // gate or publish through this node's `<el>_<fact>` output. A connector ALSO returns fixed result
1802
+ // metadata (`connectorOutcome`/`connectorDedupeKey`/`connectorDetail`); without localising those too,
1803
+ // parallel connectors overwrite each other's root-scoped result (Copilot review #863, thread
1804
+ // r4180856788) exactly as parallel agents once overwrote one shared root status/pr.
1805
+ // A `DeliveryNodeConnector` permits `emits` too, so WITHOUT this a timed-out connector could publish a
1806
+ // parallel sibling connector's root-scoped result through its own output mapping (Copilot review #863,
1807
+ // thread r4179717614).
1808
+ // A `human` node has no worker result, but its user task captures the operator's answer into fixed,
1809
+ // non-node-unique scratch (`humanEmitValue`/`humanEmitArtifact`/`humanOutcome`/`humanNote`) by SELECTING
1810
+ // from fixed form controls (`value`/`resolvedArtifact`/`note`, plus a bespoke form's fact-named control).
1811
+ // Left at the shared ROOT these cross-publish between parallel human nodes: a human that completes while
1812
+ // a sibling times out WITHOUT producing a value would have the sibling read its root `humanEmitValue`
1813
+ // and publish it as the sibling's own `<el>_<fact>` — the complete-one/timeout-the-other leak (Copilot
1814
+ // review #863, thread r4199949849). So a human subProcess ALSO localises its scratch + form controls
1815
+ // (HUMAN_RESULT_LOCAL_VARS) and its explicit fact-named control (the declared emit's own name, read first
1816
+ // by a bespoke single-emit form), fail-closing a value-less timeout to its OWN null instead of a
1817
+ // sibling's.
1818
+ // A `wait` node likewise has no worker result, but its SLA-timeout escalation parks on the SAME generic
1819
+ // form, whose completion writes the escalation controls (ESCALATION_LOCAL_VARS = decision/value/
1820
+ // escalationNote). On the escalate path the interrupting boundary timer CANCELS the probeLoop before its
1821
+ // output mapping runs, so the `_lastAttempt` probe result and any value-resume materialise the emit
1822
+ // SOURCE (`detail`/`resolvedArtifact`/`mergedSha`/`prCount` — factSourceVar) with NOTHING declaring them
1823
+ // in the wait subProcess. Left at the shared ROOT those cross-publish between parallel timed-out waits:
1824
+ // a value-less completion on one wait reads a sibling's root `value`/emit and resumes from the sibling's
1825
+ // answer (Copilot review #863, thread r4200608648). So a wait subProcess ALSO localises its escalation
1826
+ // controls + each declared emit source. None of these is read by a wait gateway (the `ready?` splits read
1827
+ // only `ready`, which stays un-seeded), so the null-seed cannot shadow a gateway (the multi-instance
1828
+ // `=null`-shadow gotcha). A wait has no retry escape, so there is no retry-reset to clear them.
1829
+ if (node.kind === "agent" || node.kind === "connector" || node.kind === "human" || node.kind === "wait") {
1830
+ const locals = new Set<string>();
1831
+ if (node.kind === "human") {
1832
+ for (const v of HUMAN_RESULT_LOCAL_VARS) locals.add(v);
1833
+ // A bespoke single-emit human form captures the value under the FACT'S OWN NAME (humanBodyLines'
1834
+ // `selectExpr` reads `<factName>` before the canonical control), so that control must be node-local
1835
+ // too — otherwise a blank completion reads a sibling's root value. factSourceVar maps a human emit to
1836
+ // the fixed `humanEmitValue`/`humanEmitArtifact` (already in the set), so seed the fact name directly.
1837
+ for (const fact of normaliseEmits(node)) locals.add(fact.name);
1838
+ } else {
1839
+ // agent / connector / wait: the escalation user task completes with a form's controls, and each
1840
+ // declared emit's SOURCE var (factSourceVar) must fail-close to this node's own null.
1841
+ for (const v of ESCALATION_LOCAL_VARS) locals.add(v);
1842
+ if (node.kind === "agent") {
1843
+ for (const v of AGENT_RESULT_LOCAL_VARS) locals.add(v);
1844
+ } else if (node.kind === "connector") {
1845
+ for (const v of CONNECTOR_RESULT_LOCAL_VARS) locals.add(v);
1846
+ } else {
1847
+ // `wait`: its SLA-timeout escalation renders the GENERIC human form (`delivery-human-generic`),
1848
+ // whose controls are `value` + `note` — NOT the retry-capable `ESCALATION_FORM`'s
1849
+ // `decision`/`value`/`escalationNote` (a wait has no retryElement, so `escalationTaskLines` picks
1850
+ // the generic form). `value` is already in ESCALATION_LOCAL_VARS, but the generic form's `note`
1851
+ // control is NOT — so completing a timed-out wait with an operator note would propagate `note` to
1852
+ // the shared ROOT, letting parallel waits overwrite one another's note and exposing a
1853
+ // wait-specific note to later jobs (Copilot review #863, thread r4200916073). Seed it node-local.
1854
+ locals.add("note");
1855
+ }
1856
+ // (a `wait` has no self-reported worker result metadata — only its escalation controls + emit sources)
1857
+ for (const fact of normaliseEmits(node)) locals.add(factSourceVar(node.kind, fact));
1858
+ }
1859
+ const taken = new Set(inputs.map((i) => i.target));
1860
+ for (const v of locals) {
1861
+ if (!taken.has(v)) inputs.push({ source: "=null", target: v });
1862
+ }
1863
+ }
1864
+
1776
1865
  // Late-binding: a deterministic FEEL list literal of the upstream producers' emitted facts, keyed
1777
1866
  // exactly as the edge references them (`<producerNode>.<fact>`), read from the flat parent variable
1778
1867
  // each producer publishes. Guarded so an as-yet-unobserved fact threads as null, not a FEEL error.
@@ -1785,8 +1874,24 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
1785
1874
  }
1786
1875
 
1787
1876
  // Outputs: publish each declared emit into `<element>_<fact>` for a downstream consumer to bind.
1877
+ // A `human` node's non-artifact emits ALL share the one captured `humanEmitValue` (the generic/publish
1878
+ // forms have a single value field — see humanBodyLines). Publishing that one value into SEVERAL
1879
+ // distinct facts would corrupt them (PR #863 Copilot Low, thread r4182488264). validateDeliveryGraph
1880
+ // now REJECTS every ≥2-emit human node (`human-unroutable-emits`, threads
1881
+ // deliveryGraphCompiler.ts:1859 / :1927) — no static form can capture several emits (an explicit
1882
+ // `human.formKey` is no exception: this ioMapping reads only the fixed value/resolvedArtifact/note
1883
+ // controls, never a bespoke form's per-fact fields), so that whole class is refused at authoring time
1884
+ // rather than silently publishing null. This discard stays as residual defence for a ≥2-emit node that
1885
+ // somehow reaches compile despite the validator: only a SINGLE non-artifact human emit is sourced from
1886
+ // `humanEmitValue`; with two or more, every non-artifact emit publishes null. Artifact emits are
1887
+ // unaffected (they read the distinct `humanEmitArtifact`).
1888
+ const humanNonArtifactEmits =
1889
+ node.kind === "human" ? normaliseEmits(node).filter((f) => f.type !== "artifact") : [];
1890
+ const humanValueEmit = humanNonArtifactEmits.length === 1 ? humanNonArtifactEmits[0] : undefined;
1788
1891
  for (const fact of normaliseEmits(node)) {
1789
- outputs.push({ source: guarded(factSourceVar(node.kind, fact)), target: `${el}_${fact.name}` });
1892
+ const discardHumanValue =
1893
+ node.kind === "human" && fact.type !== "artifact" && humanValueEmit === undefined;
1894
+ outputs.push({ source: discardHumanValue ? "=null" : guarded(factSourceVar(node.kind, fact)), target: `${el}_${fact.name}` });
1790
1895
  }
1791
1896
 
1792
1897
  // Stage 0 transcript correlation (#543): propagate the completing worker's `transcriptUrl` (built
@@ -1819,24 +1924,71 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>, displ
1819
1924
  // self-reported `status` AND a non-null value for every declared emit a downstream consumer binds
1820
1925
  // as a required data dependency. A broken producer (returns `in_progress`, or omits a required
1821
1926
  // emit) escalates AT this node instead of threading an incomplete result onward.
1822
- const contractGate = { requiredEmits: normaliseEmits(node).filter((f) => requiredEmits.has(f.name)) };
1927
+ const nodeEmits = normaliseEmits(node);
1928
+ const contractGate = { requiredEmits: nodeEmits.filter((f) => requiredEmits.has(f.name)), emits: nodeEmits };
1823
1929
  // The executable `<zeebe:taskDefinition type=…>` MUST carry the raw `jobType` verbatim for worker
1824
1930
  // routing (validateDeliveryGraph rejects a URL-shaped/credential-bearing jobType, so it can never
1825
1931
  // be a leak here), but the `descriptor` is embedded by `serviceBodyLines` into the operator-visible
1826
1932
  // timeout / producer-contract escalation FEEL `prompt` — so it takes the SAME display redaction
1827
1933
  // `nodeDisplay` applies, never the raw value (issue #778 review — thread deliveryGraphCompiler.ts:1606).
1828
- return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName);
1934
+ return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName, "agent", nodeEmits);
1829
1935
  }
1830
- case "connector":
1936
+ case "connector": {
1831
1937
  // TRIM the target before building the escalation descriptor — `nodeDisplay` and the connector
1832
1938
  // worker both key on the trimmed value, so an untrimmed `" converge-merge "` would fork the
1833
1939
  // `semanticBpmn`/digest from the trimmed-equivalent graph that dispatches identically (issue #778
1834
1940
  // review — thread deliveryGraphCompiler.ts:1741).
1835
- return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${redactConnectorValue(node.connector.target.trim())}`, undefined, [], displayName);
1941
+ // A connector declares `emits` (its emit source is the fact's own name) but has NO
1942
+ // producer-contract gate, so pass them explicitly as `nodeEmits` — otherwise the timeout
1943
+ // escalation could not resume a connector's emit on Continue, nor clear it on Retry (#863).
1944
+ // The resume/validation path (Continue) uses only the REQUIRED emits a downstream node binds —
1945
+ // the facts a null (lost) result poisons (#872/#876) — while `nodeEmits` (all declared emits)
1946
+ // drive the Retry clear and the resume-valid flag's collision-avoidance targets.
1947
+ const connectorEmits = normaliseEmits(node);
1948
+ const connectorRequired = connectorEmits.filter((f) => requiredEmits.has(f.name));
1949
+ return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${redactConnectorValue(node.connector.target.trim())}`, undefined, [], displayName, "connector", connectorEmits, connectorRequired);
1950
+ }
1836
1951
  case "wait":
1837
1952
  return waitBodyLines(el, node, displayName);
1838
- case "human":
1839
- return humanBodyLines(el, displayName);
1953
+ case "human": {
1954
+ // Select the human node's form by DERIVING it from the canonical resolver (resolveHumanForm),
1955
+ // never hardcoding the generic form: a 0-emit node uses the acknowledgement form, a single-artifact
1956
+ // node uses the manual-publish form (which carries the `resolvedArtifact` control the generic form
1957
+ // lacks, so the artifact is actually captured instead of published null), and a single-value node
1958
+ // uses the generic typed-emit form. A ≥2-emit node resolves to the agent-router (formKey null) and
1959
+ // is REJECTED upstream by validateDeliveryGraph (`human-unroutable-emits` — a bespoke formKey is no
1960
+ // rescue for ≥2 emits, since one form value cannot satisfy several typed facts), so the
1961
+ // `?? GENERIC_HUMAN_FORM` fallback here is unreachable defensive cover (PR #863 threads
1962
+ // deliveryGraphCompiler.ts:1859 / :1927).
1963
+ // For a SINGLE emit the node MAY carry an EXPLICIT bespoke form whose field is named after the
1964
+ // emitted fact (the canonical binding contract `bindHumanEmits` reads the fact's own key first,
1965
+ // then the generic `value`/`resolvedArtifact` capture keys — deliveryHuman.ts) — so thread the one
1966
+ // emit through so the userTask ioMapping reads that fact-named field too. Without it a valid
1967
+ // `{ approval: "yes" }` custom form for an `approval` emit renders fine but publishes NULL (the
1968
+ // ioMapping only read the fixed `value`/`resolvedArtifact` controls) — PR #863 review, thread
1969
+ // deliveryGraphCompiler.ts:1929. SCOPE the fact-NAMED read to `source === "explicit"`: the built-in
1970
+ // generic/publish forms capture under the FIXED `value`/`resolvedArtifact` controls, and a fact
1971
+ // happening to be named after one of those built-in forms' OTHER fields (e.g. a single emit named
1972
+ // `note`) must not make the mapping read that unrelated field — only a bespoke form keys its control
1973
+ // on the fact.
1974
+ // TYPE COERCION is a WIDER contract than the fact-named read: the SAME class of bug (a captured
1975
+ // TEXT value published verbatim for a typed fact) also bites the GENERIC single-value form — a lone
1976
+ // `boolean`/`number` emit answered through the generic textfield would publish the string
1977
+ // `"true"`/`"1"` that a downstream guarded split (`= true`/`= 1`) never matches. So coerce by the
1978
+ // one declared emit's type for EVERY form source (explicit AND built-in), while preferring the
1979
+ // fact-named field ONLY for an explicit bespoke form (PR #863 thread r4189815989).
1980
+ const humanForm = resolveHumanForm(node);
1981
+ const singleEmit = humanForm.emits.length === 1 ? humanForm.emits[0] : undefined;
1982
+ const preferFactName = humanForm.source === "explicit";
1983
+ // A human node has no worker self-report to gate on, but its single emit CAN be a downstream-REQUIRED
1984
+ // fact — and invalid typed input coerces to null (fail-closed on the value, coerceFactValueFeel), so
1985
+ // WITHOUT a completion gate the node ends and the unconditional edge activates the consumer with a
1986
+ // null required fact (PR #863 review, thread r4198662345). Thread whether that single emit is
1987
+ // required so humanBodyLines grows a fail-closed completion gate over it (re-enter on an invalid
1988
+ // value) instead of publishing null onward.
1989
+ const requiredEmit = singleEmit !== undefined && requiredEmits.has(singleEmit.name);
1990
+ return humanBodyLines(el, displayName, humanForm.formKey ?? GENERIC_HUMAN_FORM, singleEmit, preferFactName, requiredEmit);
1991
+ }
1840
1992
  default:
1841
1993
  return assertNever(node, "innerBodyLines");
1842
1994
  }
@@ -1881,11 +2033,12 @@ function agentContractProceedCondition(requiredEmits: readonly DeliveryFact[]):
1881
2033
  * so the human/agent unsticking it sees WHY it parked — the node, its job type, the actual reported
1882
2034
  * status, and, per required emit, whether it arrived. Turns the instance-10746 failure (a silent null
1883
2035
  * thread + two mis-attributed CONSUMER incidents) into one correctly-attributed PRODUCER escalation. */
1884
- function agentContractContextFeel(nodeId: string, descriptor: string, requiredEmits: readonly DeliveryFact[]): string {
2036
+ function agentContractContextFeel(nodeId: string, descriptor: string, requiredEmits: readonly DeliveryFact[], nodeEmits: readonly DeliveryFact[]): string {
1885
2037
  const statuses = AGENT_TERMINAL_SUCCESS_STATUSES.join("/");
1886
2038
  const head = feelStr(
1887
- `Node ${nodeId} (${descriptor}) completed but did not satisfy its producer contract — a producer must ` +
1888
- `self-report a terminal-success status (${statuses}) and populate every emit a downstream node requires ` +
2039
+ `Node ${nodeId} (${descriptor}) completed but did not satisfy its producer contract — when a producer ` +
2040
+ `reports a status it must be a terminal-success status (${statuses}; an absent/null status is accepted), ` +
2041
+ `and it must populate every emit a downstream node requires ` +
1889
2042
  "before its result routes onward. Reported status=",
1890
2043
  );
1891
2044
  let feel = `=${head} + (if (is defined(status) and status != null) then string(status) else "(none)") + "."`;
@@ -1893,6 +2046,18 @@ function agentContractContextFeel(nodeId: string, descriptor: string, requiredEm
1893
2046
  const present = `(is defined(${f.name}) and ${f.name} != null)`;
1894
2047
  feel += ` + " Required emit '${f.name}': " + (if ${present} then "present" else "MISSING (null)") + "."`;
1895
2048
  }
2049
+ // The node's OWN agent report (node-local since the result vars are declared on the subProcess): what
2050
+ // the agent said it did / why it stopped, its question for the operator, and the transcript link — the
2051
+ // actionable content the bare status line above never carried (171774's three report-less escalations).
2052
+ const text = (v: string): string => `(is defined(${v}) and ${v} != null and string(${v}) != "")`;
2053
+ // Each fragment reads as its own sentence on the plain-text "Decision context" (terminal period added
2054
+ // unless the agent's text already ends in punctuation).
2055
+ const sentence = (v: string): string => `string(${v}) + (if matches(string(${v}), "[.!?]\\s*$") then "" else ".")`;
2056
+ feel += ` + (if ${text("summary")} then " Agent report: " + ${sentence("summary")} else " Agent report: (the agent returned no summary).")`;
2057
+ feel += ` + (if ${text("question")} and (not(${text("summary")}) or string(question) != string(summary)) then " Agent question: " + ${sentence("question")} else "")`;
2058
+ feel += ` + (if ${text("error")} then " Error: " + ${sentence("error")} else "")`;
2059
+ feel += ` + (if ${text("transcriptUrl")} then " Transcript: " + string(transcriptUrl) else "")`;
2060
+ feel += ` + ${feelStr(escalationResolutionHint(nodeEmits, requiredEmits, true))}`;
1896
2061
  return feel;
1897
2062
  }
1898
2063
 
@@ -1914,36 +2079,104 @@ function serviceBodyLines(
1914
2079
  taskDefAttr: string,
1915
2080
  taskProps: readonly string[],
1916
2081
  descriptor: string,
1917
- contractGate?: { requiredEmits: readonly DeliveryFact[] },
2082
+ contractGate?: { requiredEmits: readonly DeliveryFact[]; emits: readonly DeliveryFact[] },
1918
2083
  taskHeaders: readonly string[] = [],
1919
2084
  taskName: string = nodeId,
2085
+ // The node's kind (`agent`/`connector`) and its OWN declared emits. A connector passes
2086
+ // `contractGate = undefined` (no self-reported status contract) yet still declares `emits`, so the
2087
+ // escalation resume / retry-clear below key off the node's OWN emits, not the agent-only gate — and
2088
+ // map them through `factSourceVar(kind, …)` (issue #863 review — thread "Connector emits are lost
2089
+ // during timeout escalation recovery").
2090
+ kind: "agent" | "connector" = "agent",
2091
+ nodeEmits: readonly DeliveryFact[] = contractGate?.emits ?? [],
2092
+ // The node's REQUIRED emits (the facts a downstream node binds as a data dependency). The #863 Retry
2093
+ // path clears ALL `nodeEmits`; the #876 Continue/resume path is gated through fail-closed validation
2094
+ // on exactly these REQUIRED emits — a null/invalid operator value re-parks instead of threading null
2095
+ // onto a consumed fact (issue #872). Defaults to the contract gate's required emits (an agent); a
2096
+ // connector passes its own required subset explicitly (it has no gate to derive from).
2097
+ resumeEmits: readonly DeliveryFact[] = contractGate?.requiredEmits ?? [],
1920
2098
  ): string[] {
1921
2099
  const esc = escalationTaskElement(el);
2100
+ const isAgent = contractGate !== undefined;
2101
+ // Gateway decisions are computed INSIDE the node's scope and published as a node-UNIQUE root boolean,
2102
+ // because Nano evaluates exclusive-gateway conditions against the ROOT variables only (not the
2103
+ // gateway's scope, as Zeebe does). The node's result vars are node-local (see ioMappingLines), so a
2104
+ // gateway reading `status`/`<emit>` directly would never see them; reading `<el>_contractMet` /
2105
+ // `<el>_retryRequested` works on either engine semantics and can never collide ACROSS nodes. The
2106
+ // source var ({@link factSourceVar}) of EVERY declared emit (required + non-required) is reserved so
2107
+ // BOTH the resume-valid flag (PR #876) AND these control vars are grown collision-free against all of
2108
+ // them — an emit whose source var equals the bare control name (e.g. an agent emit literally named
2109
+ // `<el>_contractMet`) would otherwise be null-seeded node-local and SHADOW the root control the outer
2110
+ // gateway reads, so the producer could never pass (issue #863 review, thread r4199435326).
2111
+ const reservedFlagTargets = nodeEmits.map((f) => factSourceVar(kind, f));
2112
+ const contractMetVar = nodeControlVar(el, "contractMet", reservedFlagTargets);
1922
2113
  const taskExt = [
1923
2114
  " <bpmn:extensionElements>",
1924
2115
  ` <zeebe:taskDefinition ${taskDefAttr} />`,
1925
2116
  ...(taskProps.length > 0 ? [" <zeebe:properties>", ...taskProps, " </zeebe:properties>"] : []),
1926
2117
  ...taskHeaders,
2118
+ " <zeebe:ioMapping>",
2119
+ // Preflight guard: the node body is configured from the runner-seeded root `nodeInputs.<el>`. If that
2120
+ // seed is missing (lost root variables — nano-bpm#1331 wiped them on 171774/171309), every `cfg(...)`
2121
+ // on the subProcess silently evaluated to null and the agent ran BLIND (no prompt/timeout), reporting
2122
+ // `blocked` into an unexplained producer-contract escalation. Fail LOUD instead: a FEEL `assert` makes
2123
+ // this input raise an incident naming the cause, BEFORE a job exists; once the root variables are
2124
+ // restored, resolving the incident re-applies the inputs and the job is created. It sits on the inner
2125
+ // LEAF task, not the subProcess: a leaf input incident parks the token on every engine (#946), whereas
2126
+ // a subProcess input incident was completed past its body by the drain sweep (nano-bpm#1334).
2127
+ // KNOWN LIMITATION (fail-loud-only, nano-workforce#866): resolving this incident re-evaluates ONLY
2128
+ // this leaf's inputs — the subProcess-level config mappings (prompt/appendPrompt/nodeTimeout,
2129
+ // connector target/payload/dedupeKey) ran ONCE at subProcess entry and are NOT re-mapped on resolve.
2130
+ // The in-subprocess "Retry this step" loop does NOT help either: it loops directly back to the inner
2131
+ // service task (to reset the node-local scratch), bypassing the subProcess entry mappings, so it too
2132
+ // leaves the config null. Recovery therefore requires re-entering the sub-process from OUTSIDE —
2133
+ // relaunch/re-enter the node so its subProcess input mappings re-evaluate against the restored
2134
+ // `nodeInputs` — not merely resolving the incident or using "Retry this step". The incident message
2135
+ // says so. The correct fix is to run this check at sub-process ENTRY — Camunda parity, where a failed
2136
+ // sub-process input mapping parks in `activating` and resolving re-evaluates ALL inputs — which
2137
+ // nano-bpm#1336 restores; until then we fail loud here rather than risk the #1334 drain-past.
2138
+ ` <zeebe:input ${attr("source", nodeInputsPreflightFeel(el))} target="${NODE_INPUTS_PREFLIGHT_VAR}" />`,
2139
+ ...(contractGate !== undefined
2140
+ ? [` <zeebe:output ${attr("source", agentContractProceedCondition(contractGate.requiredEmits))} target="${contractMetVar}" />`]
2141
+ : []),
2142
+ " </zeebe:ioMapping>",
1927
2143
  " </bpmn:extensionElements>",
1928
2144
  ];
2145
+ // The timeout escalation is resumable with the node's declared emits too: work finished out of band
2146
+ // (or a draft PR the stalled agent already opened) can be handed onward instead of threading null.
2147
+ // `nodeEmits` is the node's OWN declared emits (for an agent, the contract gate's emits; for a
2148
+ // connector, its own — a connector declares `emits` and its emit source is the fact's own name, so
2149
+ // keying off the absent agent-only gate would drop them and map no `value` on a Continue resume).
2150
+ const allEmits = nodeEmits;
2151
+ const contractEsc = contractEscalationTaskElement(el);
2152
+ const contractEmits = contractGate?.requiredEmits ?? [];
1929
2153
  const timeoutEscalation = escalationTaskLines(
1930
2154
  esc,
1931
2155
  nodeId,
1932
2156
  [`${el}_i2`],
1933
2157
  `${el}_i3`,
1934
- escalationContextFeel(
2158
+ `${escalationContextFeel(
1935
2159
  nodeId,
1936
2160
  descriptor,
1937
2161
  "nodeTimeout",
1938
2162
  "; in-flight work may already exist — check for a draft PR or partial state before retrying or reassigning.",
1939
- ),
1940
- { displayName: taskName },
2163
+ )} + ${feelStr(escalationResolutionHint(nodeEmits, resumeEmits, isAgent))}`,
2164
+ {
2165
+ // #863 Retry + #876 Continue/validate, nested: the timeout escalation routes through a retry gate
2166
+ // (`retryElement`) and, on Continue with a required emit, a fail-closed validation gate
2167
+ // (`validTarget`) that re-parks a null/invalid value instead of threading it downstream.
2168
+ ...(resumeEmits.length > 0 ? { resume: { kind, emits: resumeEmits, declaredEmits: nodeEmits }, validTarget: `${el}_end` } : {}),
2169
+ displayName: taskName,
2170
+ retryElement: el,
2171
+ reservedTargets: reservedFlagTargets,
2172
+ },
1941
2173
  );
1942
2174
  const head = [
1943
2175
  ` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
1944
2176
  ` <bpmn:serviceTask id="${el}_task" name="${escapeXml(taskName)}">`,
1945
2177
  ...taskExt,
1946
2178
  ` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
2179
+ ` <bpmn:incoming>${el}_r1</bpmn:incoming>`,
1947
2180
  ` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
1948
2181
  " </bpmn:serviceTask>",
1949
2182
  ` <bpmn:boundaryEvent id="${el}_be" name="Node timed out" attachedToRef="${el}_task">`,
@@ -1952,32 +2185,48 @@ function serviceBodyLines(
1952
2185
  " </bpmn:boundaryEvent>",
1953
2186
  ...timeoutEscalation,
1954
2187
  ];
2188
+ const timeoutResumable = resumeEmits.length > 0;
2189
+ const contractResumable = isAgent && contractEmits.length > 0;
2190
+ // #863 shared reset: both escalations' retry branches (`${esc}Rr` / `${contractEsc}Rr`) loop into ONE
2191
+ // reset-throw that clears the node-local scratch (including each escalation's resume-valid flag) then
2192
+ // re-enters the task. Per-escalation retry gates (built in `escalationTaskLines`) feed it, so a Continue
2193
+ // re-park knows WHICH escalation to return to while Retry is funnelled through this single reset.
2194
+ const resetFlagVars: string[] = [];
2195
+ if (timeoutResumable) resetFlagVars.push(escalationResumeFlagVar(esc, { kind, emits: resumeEmits }, reservedFlagTargets));
2196
+ if (contractResumable) resetFlagVars.push(escalationResumeFlagVar(contractEsc, { kind: "agent", emits: contractEmits }, reservedFlagTargets));
2197
+ const retryIncoming = [`${esc}Rr`, ...(isAgent ? [`${contractEsc}Rr`] : [])];
2198
+ const reset = retryResetLines(el, kind, allEmits, resetFlagVars, retryIncoming);
1955
2199
 
1956
2200
  if (contractGate === undefined) {
1957
2201
  return [
1958
2202
  ...head,
1959
- ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i3</bpmn:incoming></bpmn:endEvent>`,
2203
+ ...reset,
2204
+ ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${timeoutResumable ? `${esc}Vok` : `${esc}Rc`}</bpmn:incoming></bpmn:endEvent>`,
1960
2205
  flow(`${el}_i0`, `${el}_start`, `${el}_task`),
1961
2206
  flow(`${el}_i1`, `${el}_task`, `${el}_end`),
1962
2207
  flow(`${el}_i2`, `${el}_be`, esc),
1963
- flow(`${el}_i3`, esc, `${el}_end`),
2208
+ flow(`${el}_i3`, esc, `${esc}Rg`),
1964
2209
  ];
1965
2210
  }
1966
2211
 
1967
- // Producer-contract gate (issue #731): task → gate → (proceed | contract-escalation) → end.
1968
- const contractEsc = contractEscalationTaskElement(el);
1969
- const emits = contractGate.requiredEmits;
1970
- const proceedCondition = agentContractProceedCondition(emits);
2212
+ // Producer-contract gate (issue #731): task → gate → (proceed | contract-escalation) → end. When the
2213
+ // contract escalation is RESUMABLE (it grew a validation gate), its return flow `${el}_g2` routes to
2214
+ // the GATE (not straight to the end), and the gate's valid branch flows to the end (PR #876 review).
2215
+ const emits = contractEmits;
2216
+ const proceedCondition = `=${contractMetVar} = true`;
1971
2217
  const contractEscalation = escalationTaskLines(
1972
2218
  contractEsc,
1973
2219
  nodeId,
1974
2220
  [`${el}_g1`],
1975
2221
  `${el}_g2`,
1976
- agentContractContextFeel(nodeId, descriptor, emits),
2222
+ agentContractContextFeel(nodeId, descriptor, emits, nodeEmits),
1977
2223
  // Resumable when the producer owes a required emit: a human/agent supplies the missing fact, which
1978
2224
  // the subProcess output ioMapping then publishes as `<el>_<fact>` (agent emit source = fact name),
1979
- // so the downstream consumer late-binds a real value instead of the null that poisoned it (#731).
1980
- { ...(emits.length > 0 ? { resume: { kind: "agent" as const, emits } } : {}), displayName: taskName },
2225
+ // so the downstream consumer late-binds a real value instead of the null that poisoned it (#731) —
2226
+ // VALIDATED by the post-escalation gate (`validTarget`) so an omitted/malformed value re-parks
2227
+ // instead of threading null downstream (PR #876 review), and routed through the retry gate
2228
+ // (`retryElement`) so Retry resets+reruns the node (#863).
2229
+ { ...(contractResumable ? { resume: { kind: "agent" as const, emits, declaredEmits: nodeEmits }, validTarget: `${el}_end` } : {}), displayName: taskName, retryElement: el, reservedTargets: reservedFlagTargets },
1981
2230
  );
1982
2231
  return [
1983
2232
  ...head,
@@ -1987,24 +2236,189 @@ function serviceBodyLines(
1987
2236
  ` <bpmn:outgoing>${el}_g1</bpmn:outgoing>`,
1988
2237
  " </bpmn:exclusiveGateway>",
1989
2238
  ...contractEscalation,
1990
- ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_g0</bpmn:incoming><bpmn:incoming>${el}_i3</bpmn:incoming><bpmn:incoming>${el}_g2</bpmn:incoming></bpmn:endEvent>`,
2239
+ ...reset,
2240
+ ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_g0</bpmn:incoming><bpmn:incoming>${timeoutResumable ? `${esc}Vok` : `${esc}Rc`}</bpmn:incoming><bpmn:incoming>${contractResumable ? `${contractEsc}Vok` : `${contractEsc}Rc`}</bpmn:incoming></bpmn:endEvent>`,
1991
2241
  flow(`${el}_i0`, `${el}_start`, `${el}_task`),
1992
2242
  flow(`${el}_i1`, `${el}_task`, `${el}_gate`),
1993
2243
  ` <bpmn:sequenceFlow id="${el}_g0" name="contract met" sourceRef="${el}_gate" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${proceedCondition}</bpmn:conditionExpression></bpmn:sequenceFlow>`,
1994
2244
  ` <bpmn:sequenceFlow id="${el}_g1" name="contract broken" sourceRef="${el}_gate" targetRef="${contractEsc}" />`,
1995
- flow(`${el}_g2`, contractEsc, `${el}_end`),
2245
+ flow(`${el}_g2`, contractEsc, `${contractEsc}Rg`),
1996
2246
  flow(`${el}_i2`, `${el}_be`, esc),
1997
- flow(`${el}_i3`, esc, `${el}_end`),
2247
+ flow(`${el}_i3`, esc, `${esc}Rg`),
2248
+ ];
2249
+ }
2250
+
2251
+ /** The operator-facing "how do I resolve this" sentence appended to a service node's escalation
2252
+ * prompt (retry-node resolution): every escalation names its two exits so the task is actionable on
2253
+ * its own, without reading the BPMN. */
2254
+ function escalationResolutionHint(nodeEmits: readonly DeliveryFact[], resumeEmits: readonly DeliveryFact[], isAgent: boolean): string {
2255
+ const retry =
2256
+ ` To resolve: choose Resolution "Retry this step" (${ESCALATION_DECISION_VAR}="${ESCALATION_DECISION_RETRY}") to re-run this node` +
2257
+ (isAgent ? " — the note is passed to the agent as guidance for the retry" : "");
2258
+ // Mirror the compiled topology (escalationTaskLines): a value-resumable Continue is offered ONLY when
2259
+ // the node declares EXACTLY ONE emit AND that emit is the required resume target. Gating on the
2260
+ // required count alone would promise a value field a node declaring two facts (one required) never
2261
+ // renders (issue #863 review, thread r4193902690).
2262
+ const names = (fs: readonly DeliveryFact[]): string => fs.map((e) => `'${e.name}'`).join("/");
2263
+ let proceed: string;
2264
+ if (nodeEmits.length === 1 && resumeEmits.length === 1) {
2265
+ proceed = `; or choose "Continue" and put the '${resumeEmits[0].name}' value in the value field (e.g. work finished out of band)`;
2266
+ } else if (nodeEmits.length > 1 && resumeEmits.length >= 1) {
2267
+ // A multi-declared node that still owes ≥1 REQUIRED emit: the resume-valid flag is hard-`false`, so
2268
+ // the post-escalation validation gate ALWAYS re-parks — Continue loops back HERE, it does NOT write
2269
+ // nulls or advance to a fallback branch (deliveryGraphCompiler.ts fail-closed loop; #863 review
2270
+ // r4194186490/r4194186441). Only "Retry this step" can produce the facts.
2271
+ proceed = `; "Continue" CANNOT resume this node and does NOT advance — the single value field cannot supply its ${names(nodeEmits)} facts, so it re-parks HERE until you choose "Retry this step" to actually produce them`;
2272
+ } else if (nodeEmits.length > 1) {
2273
+ // Multi-declared but NO required resume target downstream: the escalation is not validation-gated, so
2274
+ // Continue genuinely proceeds past the node to its default (fallback) branch.
2275
+ proceed = `; or choose "Continue" to proceed past this node to its default (fallback) branch — no downstream node requires its ${names(nodeEmits)} facts, and the single value field cannot supply them anyway`;
2276
+ } else {
2277
+ proceed = '; or choose "Continue" to proceed past this node';
2278
+ }
2279
+ return `${retry}${proceed}.`;
2280
+ }
2281
+
2282
+ /** Grow a node-unique ROOT control var (`<el>_contractMet` / `<el>_retryRequested`) collision-free
2283
+ * (deterministic trailing `_`) against the node-local emit SOURCE vars ({@link factSourceVar}) that
2284
+ * share this node's flat subProcess scope. These controls are decided INSIDE the node's scope yet must
2285
+ * be READ by the OUTER gateways (Nano evaluates exclusive-gateway conditions against the ROOT vars
2286
+ * only), so they are published at root. But an emit whose source var equals the bare control name
2287
+ * (e.g. an agent emit literally named `n0_contractMet` on the first node `n0`, whose source var IS its
2288
+ * own name) is ALSO null-seeded node-local by {@link ioMappingLines} — and Nano's nearest-scope
2289
+ * propagation then writes the gate/escalation output into that LOCAL shadow instead of the root the
2290
+ * outer gateway reads, so the gateway never sees `true` and the producer can never pass (issue #863
2291
+ * review, thread r4199435326). Growing the control name clear of every emit source var removes the
2292
+ * whole class, exactly as {@link escalationResumeFlagVar} grows the resume-valid flag. */
2293
+ function nodeControlVar(el: string, suffix: string, reservedTargets: readonly string[]): string {
2294
+ const reserved = new Set<string>(reservedTargets);
2295
+ let name = `${el}_${suffix}`;
2296
+ while (reserved.has(name)) name = `${name}_`;
2297
+ return name;
2298
+ }
2299
+
2300
+ /** The node-unique ROOT boolean an escalation completion publishes for the retry gateway (see the
2301
+ * gateway-scope note in {@link serviceBodyLines}), grown collision-free ({@link nodeControlVar})
2302
+ * against the node's emit source vars so a same-named emit can neither shadow nor collide with it. The
2303
+ * SAME `reservedTargets` must be passed at the write and read sites so the grown name agrees. */
2304
+ function retryRequestedVar(el: string, reservedTargets: readonly string[] = []): string {
2305
+ return nodeControlVar(el, "retryRequested", reservedTargets);
2306
+ }
2307
+
2308
+ /** The retry-node resolution RESET shared by a service node's escalations (`__esc`, and an agent's
2309
+ * `__contract`): their per-escalation retry gates (built in {@link escalationTaskLines}) each loop
2310
+ * `─retry→` this single reset, which clears the node-local scratch then re-enters the task (`_r1`). The
2311
+ * reset (a none intermediate throw event carrying output mappings only — the compiler never emits a
2312
+ * scriptTask) clears the node-local decision and the previous attempt's emits (a rerun that succeeds
2313
+ * may report no status at all; a stale emit would otherwise be republished downstream) — plus, for an
2314
+ * agent, its FULL declared result set (every {@link AGENT_RESULT_LOCAL_VARS} field, so a stale
2315
+ * `blocked` status can't fail the contract gate again and a stale `transcriptUrl`/PR alias/other
2316
+ * optional field can't republish downstream) — plus each escalation's resume-valid flag
2317
+ * (`resumeFlagVars`, so a Retry can never leave a stale `true` that the next Continue's validation gate
2318
+ * would read) — and appends the operator's {@link ESCALATION_NOTE_VAR} to the agent prompt as retry
2319
+ * guidance. All targets are node-local (declared on the subProcess by `ioMappingLines`), so nothing
2320
+ * leaks to the root. `incoming` is each feeding escalation's retry branch (`${esc}Rr`). */
2321
+ function retryResetLines(el: string, kind: "agent" | "connector", emits: readonly DeliveryFact[], resumeFlagVars: readonly string[], incoming: readonly string[]): string[] {
2322
+ const isAgent = kind === "agent";
2323
+ const outputs: { source: string; target: string }[] = [];
2324
+ // Clear the node's declared emits for ANY emitting kind (agent or connector) so a retry never
2325
+ // republishes a stale value; for an agent, also clear the FULL declared result set
2326
+ // (AGENT_RESULT_LOCAL_VARS — every field declared node-local, not just the five self-reported status
2327
+ // fields) so a retried worker that omits an optional field (transcriptUrl, agentCheckpoint, a PR
2328
+ // alias, exitCode, …) cannot let the previous attempt's value republish downstream or surface in the
2329
+ // next escalation. For a connector, clear its fixed result metadata (CONNECTOR_RESULT_LOCAL_VARS) for
2330
+ // the same reason. The status/summary fields and prompt-guidance are agent-specific (a connector has
2331
+ // no self-reported status contract or prompt).
2332
+ const cleared = new Set<string>(isAgent ? AGENT_RESULT_LOCAL_VARS : CONNECTOR_RESULT_LOCAL_VARS);
2333
+ for (const f of emits) cleared.add(factSourceVar(kind, f));
2334
+ // Clear each escalation's resume-valid flag too (PR #876 flag × #863 retry): a Retry resets the
2335
+ // node-local scratch, so a stale `true` can never survive into the next Continue's validation gate.
2336
+ for (const v of resumeFlagVars) cleared.add(v);
2337
+ for (const v of cleared) outputs.push({ source: "=null", target: v });
2338
+ if (isAgent) {
2339
+ // Read the OPERATOR's note from the escalation-specific `escalationNote` control — NEVER the plain
2340
+ // `note`: `note` is a documented WORKER RESULT field (resources/prompts/plan.md returns one) that
2341
+ // nearest-scope propagation lands in this same subProcess scope, so reading it here would feed a
2342
+ // worker-produced note back to the agent as "Operator guidance" whenever the operator retries
2343
+ // WITHOUT a note (the form permits omitting it) — PR #863 Copilot "Previously missed".
2344
+ const hasNote = `(is defined(${ESCALATION_NOTE_VAR}) and ${ESCALATION_NOTE_VAR} != null and string(${ESCALATION_NOTE_VAR}) != "")`;
2345
+ // Re-derive from the RUNNER-SEEDED BASELINE (`nodeInputs.<el>.appendPrompt`), never the live
2346
+ // `appendPrompt`: the subProcess input seeds `appendPrompt` from `nodeInputs` only at subProcess
2347
+ // ENTRY, so on a retry re-entry the task still reads this var as the last reset left it — building
2348
+ // on the live value would carry the previous retry's note forward and ACCUMULATE one stale
2349
+ // "Operator guidance…" paragraph per consecutive retry.
2350
+ const seeded = `nodeInputs.${el}.appendPrompt`;
2351
+ const base = `(if (is defined(${seeded}) and ${seeded} != null) then ${seeded} + "\n\n" else "")`;
2352
+ outputs.push({
2353
+ source: `=if ${hasNote} then ${base} + "Operator guidance for this retry: " + string(${ESCALATION_NOTE_VAR}) else (if (is defined(${seeded})) then ${seeded} else null)`,
2354
+ target: "appendPrompt",
2355
+ });
2356
+ }
2357
+ // Clear the escalation controls (ESCALATION_LOCAL_VARS = decision/value/escalationNote), derived from
2358
+ // the ONE source so a renamed control can't drift a second hardcoded list. These are declared
2359
+ // node-local by ioMappingLines for BOTH kinds (agent and connector), so each clear lands in this
2360
+ // subProcess scope, never the shared root.
2361
+ //
2362
+ // Do NOT clear the plain `note` here. `note` is a WORKER RESULT field that is node-local ONLY for an
2363
+ // agent (it is in AGENT_RESULT_LOCAL_VARS, so the `cleared` result-set loop above already resets it on
2364
+ // an agent retry). A connector does NOT declare `note` node-local (CONNECTOR_RESULT_LOCAL_VARS omits
2365
+ // it, and it is neither an escalation control nor a connector emit), so an unconditional `note = null`
2366
+ // here would land at the SHARED ROOT — leaking null across the node-isolation boundary and clobbering a
2367
+ // parallel node's `note` — while for an agent it merely duplicates the `cleared` loop's reset
2368
+ // (deliveryGraphCompiler.ts:2181, PR #863 Copilot "Previously missed").
2369
+ for (const v of ESCALATION_LOCAL_VARS) outputs.push({ source: "=null", target: v });
2370
+ return [
2371
+ ` <bpmn:intermediateThrowEvent id="${el}_retry" name="Reset for retry">`,
2372
+ " <bpmn:extensionElements>",
2373
+ " <zeebe:ioMapping>",
2374
+ ...outputs.map((o) => ` <zeebe:output ${attr("source", o.source)} target="${o.target}" />`),
2375
+ " </zeebe:ioMapping>",
2376
+ " </bpmn:extensionElements>",
2377
+ ...incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`),
2378
+ ` <bpmn:outgoing>${el}_r1</bpmn:outgoing>`,
2379
+ " </bpmn:intermediateThrowEvent>",
2380
+ flow(`${el}_r1`, `${el}_retry`, `${el}_task`),
1998
2381
  ];
1999
2382
  }
2000
2383
 
2384
+ /** The node-unique internal resume-valid flag ({@link resumeValidVar}) an escalation publishes for its
2385
+ * post-escalation validation gate, GROWN collision-free (deterministic trailing `_`) against every
2386
+ * declared emit's source var (`reservedTargets`) plus the resumed required emits (PR #876 review). The
2387
+ * SAME computation must run in both {@link escalationTaskLines} (writer + gate condition) and the
2388
+ * {@link retryResetLines} reset (clear), so it lives in one shared helper. Returns `""` for a
2389
+ * non-resumable escalation (no flag). */
2390
+ function escalationResumeFlagVar(
2391
+ esc: string,
2392
+ resume: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[] } | undefined,
2393
+ reservedTargets: readonly string[],
2394
+ ): string {
2395
+ if (resume === undefined || resume.emits.length === 0) return "";
2396
+ const reserved = new Set<string>(reservedTargets);
2397
+ for (const f of resume.emits) reserved.add(factSourceVar(resume.kind, f));
2398
+ return growFlagClear(resumeValidVar(esc), reserved);
2399
+ }
2400
+
2401
+ /** Grow a base internal-flag var ({@link resumeValidVar}) clear of a reserved set with a deterministic
2402
+ * trailing `_`, so a user-declared fact/control whose name happens to EQUAL the generated flag can never
2403
+ * shadow it. Shared by {@link escalationResumeFlagVar} (service-node emit-source vars) and the `human`
2404
+ * node's completion gate (whose declared emit fact NAME is null-seeded node-local by {@link
2405
+ * ioMappingLines} — a bare {@link resumeValidVar} would otherwise land in that null-seeded var and the
2406
+ * immediately-following completion gateway would read the stale `null`, re-parking a valid answer
2407
+ * forever; Copilot review #863, thread r4201222762). Returns the base unchanged when no collision, so
2408
+ * the common case emits byte-identical output. */
2409
+ function growFlagClear(base: string, reserved: ReadonlySet<string>): string {
2410
+ let flag = base;
2411
+ while (reserved.has(flag)) flag = `${flag}_`;
2412
+ return flag;
2413
+ }
2414
+
2001
2415
  /** `wait` body: `start → pr.readiness-probe (poll) → ready? → end`, escalating on not-ready or on the
2002
2416
  * `=probeTimeout` engine bound. The probe polls its OWN target, so an unrelated upstream event can
2003
2417
  * never flip it to ready (#274/S2 concurrency-correctness); the `pr` kind (S2) binds `mergedSha`. */
2004
2418
  function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>, displayName: string): string[] {
2005
2419
  const nodeId = node.id;
2006
2420
  const name = escapeXml(displayName);
2007
- const esc = escalationTaskElement(el);
2421
+ const esc = escalationTaskElement(el, "wait");
2008
2422
  const emits = normaliseEmits(node);
2009
2423
  // `onTimeout` routing (#462): `escalate` (default) parks the not-ready-at-boundary token on a
2010
2424
  // human-completable escalation task; `continue` proceeds past the gate as not-ready WITHOUT a human
@@ -2019,6 +2433,14 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
2019
2433
  // topology diverging from the requested (and digested) behaviour (issue #778 review — thread
2020
2434
  // deliveryGraphCompiler.ts:2012).
2021
2435
  const continueOnTimeout = trimmedOrEmpty(node.wait?.onTimeout) === "continue";
2436
+ // When the escalation is RESUMABLE (it grew a validation gate), its return flow `_i5` routes to the
2437
+ // GATE (not straight to the end) and the gate's valid branch flows to the end (PR #876 review).
2438
+ // Only a SINGLE-emit wait is genuinely value-resumable: the single escalation-form `value` field
2439
+ // cannot supply >1 distinct fact, and a wait gate has no Retry escape, so a MULTI-emit wait grows NO
2440
+ // validation gate (building one would wedge the token in an unresolvable loop — issue #863 review
2441
+ // r4198123619). A multi-emit (or no-emit) wait escalation therefore flows `_i5` straight to the node
2442
+ // end — an acknowledged, value-less continue — the same path as a no-emit wait.
2443
+ const waitResumed = !continueOnTimeout && emits.length === 1;
2022
2444
  // Defect A: read-only probe diagnostics seeded onto the escalation task so the operator/agent can
2023
2445
  // tell a genuine "not published yet" from a transient false-negative — the probe's last detail, the
2024
2446
  // resolved target/match, and a compact summary of the candidate releases the probe observed.
@@ -2104,19 +2526,25 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
2104
2526
  [`${el}_i4`],
2105
2527
  `${el}_i5`,
2106
2528
  waitEscalationContextFeel(nodeId),
2107
- { resume: { kind: node.kind, emits }, diagnosticInputs, displayName },
2529
+ // A resumed wait-gate escalation is VALIDATED by the post-escalation gate (`validTarget`) so
2530
+ // an omitted/malformed operator value re-parks instead of threading null onto the emit the
2531
+ // downstream consumer binds (PR #876 review). Only a SINGLE-emit wait is value-resumable, so
2532
+ // `validTarget` (and the gate it drives) is provided ONLY then — a multi-emit wait has no
2533
+ // escapable resume and so grows no gate (issue #863 review r4198123619).
2534
+ { resume: { kind: node.kind, emits }, diagnosticInputs, displayName, reservedTargets: emits.map((f) => factSourceVar(node.kind, f)), ...(emits.length === 1 ? { validTarget: `${el}_end` } : {}) },
2108
2535
  )),
2109
2536
  // On `continue`, the not-ready-at-boundary branch (`_i4`) proceeds straight to the node end (no
2110
2537
  // human stop, no `_i5` escalation-return flow); on `escalate` it parks on the escalation task,
2111
- // which returns via `_i5`.
2112
- ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming>${continueOnTimeout ? `<bpmn:incoming>${el}_i4</bpmn:incoming>` : `<bpmn:incoming>${el}_i5</bpmn:incoming>`}<bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
2538
+ // which returns via `_i5`. A RESUMABLE escalation routes `_i5` to its validation gate (not straight
2539
+ // to the end), so a value-less/invalid resume can never bypass the gate (PR #876 review).
2540
+ ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming>${continueOnTimeout ? `<bpmn:incoming>${el}_i4</bpmn:incoming>` : waitResumed ? `<bpmn:incoming>${esc}Vok</bpmn:incoming>` : `<bpmn:incoming>${el}_i5</bpmn:incoming>`}<bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
2113
2541
  flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
2114
2542
  flow(`${el}_i1`, `${el}_probeLoop`, `${el}_end`),
2115
2543
  flow(`${el}_i2`, `${el}_be`, `${el}_lastAttempt`),
2116
2544
  flow(`${el}_i6`, `${el}_lastAttempt`, `${el}_lastGw`),
2117
2545
  ` <bpmn:sequenceFlow id="${el}_i7" name="ready" sourceRef="${el}_lastGw" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
2118
2546
  ` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_lastGw" targetRef="${continueOnTimeout ? `${el}_end` : esc}" />`,
2119
- ...(continueOnTimeout ? [] : [flow(`${el}_i5`, esc, `${el}_end`)]),
2547
+ ...(continueOnTimeout ? [] : [flow(`${el}_i5`, esc, waitResumed ? `${esc}Vg` : `${el}_end`)]),
2120
2548
  ];
2121
2549
  }
2122
2550
 
@@ -2124,33 +2552,168 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
2124
2552
  * + SLA. On completion the form's captured typed value is output (`humanEmitValue`/`humanEmitArtifact`
2125
2553
  * — the subProcess ioMapping then publishes it as the node's fact); on SLA expiry the node records an
2126
2554
  * `escalated` outcome and settles (bounded — the graph cannot silently wedge). Mirrors the standalone
2127
- * `delivery-human.bpmn` shape, reusing the S3 form + emit-var contract (`deliveryHuman.ts`). */
2128
- function humanBodyLines(el: string, displayName: string): string[] {
2555
+ * `delivery-human.bpmn` shape, reusing the S3 form + emit-var contract (`deliveryHuman.ts`).
2556
+ *
2557
+ * `singleEmit` (the node's ONE declared fact, when it declares exactly one) lets a bespoke explicit
2558
+ * form capture its value under the FACT'S OWN NAME — the same precedence `bindHumanEmits` honours
2559
+ * (fact name first, then the canonical `value`/`resolvedArtifact` keys). The userTask output source
2560
+ * therefore prefers `<factName>` and falls back to the canonical control, so a custom form keyed on the
2561
+ * fact (`{ approval: "yes" }` for an `approval` emit) publishes its value instead of NULL, while the
2562
+ * generic/publish/ack forms (which capture `value`/`resolvedArtifact`) are unaffected (PR #863 review,
2563
+ * thread deliveryGraphCompiler.ts:1929). Fact names are `FACT_NAME_PATTERN`-constrained
2564
+ * (`[A-Za-z_][A-Za-z0-9_]*`), so the name embeds safely as a FEEL identifier. The selected value is then
2565
+ * COERCED/VALIDATED to the emit's declared type via {@link coerceFactValueFeel} (the same typed-binding
2566
+ * contract the escalation resume uses), so a bespoke `boolean`/`number` form publishes a real FEEL
2567
+ * boolean/number — not the raw string `"true"`/`"1"` that a downstream guarded split (`= true`/`= 1`)
2568
+ * would never match (PR #863 thread r4189815989). */
2569
+ function humanBodyLines(el: string, displayName: string, formId: string, singleEmit?: DeliveryFact, preferFactName = false, requiredEmit = false): string[] {
2129
2570
  const task = humanTaskElement(el);
2130
2571
  const assignee =
2131
2572
  '=if (is defined(escalationAssignee) and escalationAssignee != null and trim(string(escalationAssignee)) != "") then escalationAssignee else null';
2573
+ // The canonical capture key for the single emit's TYPE (artifact → the publish form's
2574
+ // `resolvedArtifact`; anything else → the generic form's `value`). `selectExpr(canonical)` is a bare
2575
+ // (no leading `=`) null-safe FEEL expression; when `preferFactName` (an EXPLICIT bespoke form only) it
2576
+ // reads `<factName>` first, then the canonical control — a built-in form's fixed control otherwise.
2577
+ // The fact-named read is STRINGABILITY-guarded (`string(<factName>) != null`) because `is defined(X)`
2578
+ // is TRUE and `X != null` holds for a FEEL BUILTIN function (`count`, `sum`, …) when a blank form
2579
+ // leaves no task variable of that name — so a bare presence guard would SELECT the builtin FUNCTION as
2580
+ // the `then` arm for the emit. The number/boolean coercers reject a non-stringable operand, but the text
2581
+ // types (`string`/`version`/`url`/`pr`) pass the selection through verbatim and the artifact source is
2582
+ // never coerced, so without this guard those fact types would select the builtin function rather than
2583
+ // fall back to the canonical control / null. On the pinned WASM engine a function-valued selection is
2584
+ // itself folded to null before it can be published (a FEEL variable binding cannot hold a function), so
2585
+ // the guard is defence-in-depth THERE — but it is NOT a no-op: it makes the null-fold explicit and
2586
+ // engine-independent (an engine that published the function instead would corrupt the fact or incident
2587
+ // on the downstream io-mapping), and it is what the compiler-level FEEL assertion in
2588
+ // `deliveryGraphCompiler.test.ts` pins red-before/green-after. `string(<builtin>)` folds to null, so the
2589
+ // guard falls back to the canonical control / null; a real captured TEXT value stringifies to itself and
2590
+ // is still selected. The guard sits ONLY on the fact-named candidate — the canonical `resolvedArtifact`
2591
+ // object in the else arm is untouched, so a genuine object handle still passes through (Copilot review
2592
+ // #863, "Guard fact names that shadow FEEL builtins").
2593
+ const selectExpr = (canonical: string): string =>
2594
+ singleEmit !== undefined && preferFactName
2595
+ ? `if (is defined(${singleEmit.name}) and ${singleEmit.name} != null and string(${singleEmit.name}) != null) then ${singleEmit.name} else if (is defined(${canonical})) then ${canonical} else null`
2596
+ : `if (is defined(${canonical})) then ${canonical} else null`;
2597
+ // A form captures the selected value as TEXT (a textfield, or an explicit form's fact-named control), so
2598
+ // the non-artifact single emit must be VALIDATED against the fact's declared type AND COERCED to it
2599
+ // before writing `humanEmitValue` — the SAME type-aware, fail-closed contract the escalation resume
2600
+ // enforces. That contract is absorbed into {@link coerceFactValueFeel}, whose per-type arm now VALIDATES
2601
+ // (not merely coerces) the selection: `boolean`/`number` already failed closed on an unparseable entry,
2602
+ // and `string`/`version`/`url`/`pr` now fail closed on a value that does not match the declared type's
2603
+ // grammar — so a generic/custom form can no longer publish a blank string, a malformed version/URL, or an
2604
+ // invalid PR ref directly downstream (PR #863 review 5430718031, "Validate human-task fact values before
2605
+ // publishing"). (The validity test is folded INTO the coercion's single `if` rather than wrapped in an
2606
+ // outer `if (resumeValueCondition(…)) then …` because the pinned engine mis-evaluates a nested
2607
+ // `if…then…else` selection inside that outer guard — verified engine-native: valid entries routed to the
2608
+ // default branch. Folding the check into the one `if` keeps the working single-level shape.)
2609
+ const valueSource =
2610
+ singleEmit !== undefined && singleEmit.type !== "artifact"
2611
+ ? `=${coerceFactValueFeel(singleEmit, selectExpr("value"))}`
2612
+ : "=if (is defined(value)) then value else null";
2613
+ // The artifact source is likewise VALIDATED before publish: the publish form's `required` rule checks
2614
+ // only PRESENCE, so a value like `not-an-artifact` was accepted and propagated downstream despite the
2615
+ // canonical `pkg@version` handle contract (PR #863 review 5430718031, "Validate human artifact handles
2616
+ // before publishing"). Gate it on the SAME artifact grammar the escalation resume enforces
2617
+ // (`resumeValueCondition`'s artifact arm — scoped-package aware), fail closed to null. An artifact handle
2618
+ // is a text reference, so the valid arm publishes the selection verbatim (no coercion). The guard is a
2619
+ // FLAT `matches(trim(string(…)))` on the selection — NOT `resumeValueCondition`'s full form, whose
2620
+ // `is defined(<nested if…else>)` presence conjunct the pinned engine mis-evaluates when the selection is
2621
+ // a compound `if…then…else` (verified engine-native: a valid handle routed to null). `string(…) != null`
2622
+ // is the null-safe presence/stringability test; `matches()` is null-safe (a non-match yields false).
2623
+ const artifactSource =
2624
+ singleEmit !== undefined && singleEmit.type === "artifact"
2625
+ ? (() => {
2626
+ const sel = selectExpr("resolvedArtifact");
2627
+ const s = `string(${sel})`;
2628
+ return `=if (${s} != null and matches(trim(${s}), "^@?[^@\\\\s]+@v?\\\\d[\\\\w.+-]*$") = true) then ${sel} else null`;
2629
+ })()
2630
+ : "=if (is defined(resolvedArtifact)) then resolvedArtifact else null";
2631
+ // FAIL-CLOSED COMPLETION GATE (PR #863 review, thread r4198662345): a human node whose single emit is a
2632
+ // downstream-REQUIRED fact must not publish an invalid (null-coerced) value onward — the edge to the
2633
+ // consumer is unconditional and a human node has no worker contract gate or Retry path, so a null here
2634
+ // activates the consumer with a null required fact. Mirror the escalation resume's fail-closed shape:
2635
+ // the task's OWN output mapping computes a validity flag from the SAME `selectExpr` SELECTION the emit
2636
+ // publishes (so an explicit bespoke form's fact-named control is validated, not a bare always-absent
2637
+ // field) via {@link resumeValueCondition} (the SAME per-type grammar the escalation resume enforces —
2638
+ // presence included, so a blank required entry is invalid too), and a post-task exclusive gateway routes
2639
+ // a VALID completion to the node end while an INVALID one loops back to the human task for re-entry (the
2640
+ // human analogue of a re-park — a human node has no separate escalation twin to re-park onto). The flag is
2641
+ // the node-unique {@link resumeValidVar} (the emit source is the fixed `humanEmitValue`/
2642
+ // `humanEmitArtifact`, never the fact's own name) — GROWN collision-free (via {@link growFlagClear})
2643
+ // against this node's node-local null-seeded vars (HUMAN_RESULT_LOCAL_VARS + the declared emit's fact
2644
+ // NAME, which `ioMappingLines` null-seeds node-local). A required single emit named EXACTLY this flag
2645
+ // would otherwise land the flag in that node-local null-seed and the completion gateway would read the
2646
+ // stale `null`, re-parking a valid answer forever (Copilot review #863, thread r4201222762 — the same
2647
+ // shadow class `escalationResumeFlagVar` already dodges for service-node emits).
2648
+ // Grown ONLY when the single emit is required — a routing-only/unconsumed emit keeps the direct
2649
+ // `task → end` flow (a routing-only null just takes a guarded split's deadlock-safe default, exactly
2650
+ // like the escalation resume's no-required-target case). The gateway routes on the SIMPLE
2651
+ // `<flag> = true` boolean (a gateway ioMapping is not visible downstream in the pinned engine —
2652
+ // verified — so the validity is computed here on the task output, not on the gateway).
2653
+ const gateRequired = requiredEmit && singleEmit !== undefined;
2654
+ const flagVar = growFlagClear(
2655
+ resumeValidVar(task),
2656
+ new Set<string>([...HUMAN_RESULT_LOCAL_VARS, ...(singleEmit !== undefined ? [singleEmit.name] : [])]),
2657
+ );
2658
+ // The completion-validity flag MUST validate the SAME selection the emit publishes (`selectExpr`), not
2659
+ // the BARE canonical control: for an EXPLICIT bespoke form (`preferFactName`) the emit reads the
2660
+ // fact-named control (`<singleEmit.name>`) FIRST, so a flag keyed on the bare `value`/`resolvedArtifact`
2661
+ // would read an always-undefined field and reject a GENUINELY VALID explicit-form completion forever —
2662
+ // the gateway's `_cbad` default re-parks the human every round until the SLA escalates (a correct human
2663
+ // answer could never complete an explicit-form required-emit node). Mirror the emit's selection and feed
2664
+ // it to `resumeValueCondition` with a FLAT `string(sel) != null` presence override — NOT the default
2665
+ // `is defined(<compound>)` presence, which the pinned engine mis-evaluates on a compound
2666
+ // `if…then…else` selection (the same reason `artifactSource` uses a flat `matches(trim(string(…)))`).
2667
+ const validitySource = (() => {
2668
+ if (singleEmit === undefined) return "=false";
2669
+ const sel = selectExpr(singleEmit.type === "artifact" ? "resolvedArtifact" : "value");
2670
+ return `=if ${resumeValueCondition(singleEmit, sel, `string(${sel}) != null`)} then true else false`;
2671
+ })();
2672
+ const endEvent = ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${gateRequired ? `${el}_cok` : `${el}_i1`}</bpmn:incoming></bpmn:endEvent>`;
2673
+ const completionGate: string[] = gateRequired
2674
+ ? [
2675
+ ` <bpmn:exclusiveGateway id="${el}_cg" name="required fact valid?" default="${el}_cbad">`,
2676
+ ` <bpmn:incoming>${el}_i1</bpmn:incoming>`,
2677
+ ` <bpmn:outgoing>${el}_cok</bpmn:outgoing>`,
2678
+ ` <bpmn:outgoing>${el}_cbad</bpmn:outgoing>`,
2679
+ " </bpmn:exclusiveGateway>",
2680
+ ` <bpmn:sequenceFlow id="${el}_cok" name="valid" sourceRef="${el}_cg" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=${flagVar} = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
2681
+ ` <bpmn:sequenceFlow id="${el}_cbad" name="invalid — re-enter" sourceRef="${el}_cg" targetRef="${task}" />`,
2682
+ ]
2683
+ : [];
2132
2684
  return [
2133
2685
  ` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
2134
2686
  ` <bpmn:userTask id="${task}" name="Delivery: human step — ${escapeXml(displayName)}">`,
2135
2687
  " <bpmn:extensionElements>",
2136
- ` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
2688
+ // `formId` is the node's RESOLVED form key, which for an explicit `human.formKey` is OPERATOR-SUPPLIED
2689
+ // text — render it through `attr()` (which switches to a single-quote delimiter when the value contains
2690
+ // a `"`) so a quote-bearing key can never break out of the attribute and inject BPMN. Authoring-time
2691
+ // validation (`deliveryGraph.ts`) additionally rejects a URL-/credential-/control-char-bearing formKey
2692
+ // before it ever reaches here, so this is defence in depth, not the only gate.
2693
+ ` <zeebe:formDefinition ${attr("formId", formId)} />`,
2137
2694
  " <zeebe:userTask />",
2138
2695
  ` <zeebe:assignmentDefinition candidateGroups="operators" ${attr("assignee", assignee)} />`,
2139
2696
  " <zeebe:ioMapping>",
2140
2697
  ` <zeebe:output ${attr("source", '="completed"')} target="humanOutcome" />`,
2141
- ` <zeebe:output ${attr("source", "=if (is defined(value)) then value else null")} target="humanEmitValue" />`,
2142
- ` <zeebe:output ${attr("source", "=if (is defined(resolvedArtifact)) then resolvedArtifact else null")} target="humanEmitArtifact" />`,
2698
+ ` <zeebe:output ${attr("source", valueSource)} target="humanEmitValue" />`,
2699
+ ` <zeebe:output ${attr("source", artifactSource)} target="humanEmitArtifact" />`,
2143
2700
  ` <zeebe:output ${attr("source", "=if (is defined(note)) then note else null")} target="humanNote" />`,
2701
+ // The completion-gate validity flag — computed from the SAME `selectExpr` selection the emit uses, so
2702
+ // it is independent of the (already fail-closed) coerced `humanEmitValue`/`humanEmitArtifact` above and
2703
+ // routes the gateway.
2704
+ ...(gateRequired ? [` <zeebe:output ${attr("source", validitySource)} target="${flagVar}" />`] : []),
2144
2705
  " </zeebe:ioMapping>",
2145
2706
  " </bpmn:extensionElements>",
2146
2707
  ` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
2708
+ // The invalid completion loops back to re-enter this task (re-park for a value correction).
2709
+ ...(gateRequired ? [` <bpmn:incoming>${el}_cbad</bpmn:incoming>`] : []),
2147
2710
  ` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
2148
2711
  " </bpmn:userTask>",
2149
2712
  ` <bpmn:boundaryEvent id="${el}_sla" name="SLA elapsed" attachedToRef="${task}">`,
2150
2713
  ` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
2151
2714
  ` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=escalationSlaTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
2152
2715
  " </bpmn:boundaryEvent>",
2153
- ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming></bpmn:endEvent>`,
2716
+ endEvent,
2154
2717
  ` <bpmn:endEvent id="${el}_escEnd" name="Escalated">`,
2155
2718
  " <bpmn:extensionElements>",
2156
2719
  ` <zeebe:ioMapping><zeebe:input ${attr("source", '="escalated"')} target="humanOutcome" /></zeebe:ioMapping>`,
@@ -2158,8 +2721,10 @@ function humanBodyLines(el: string, displayName: string): string[] {
2158
2721
  ` <bpmn:incoming>${el}_i2</bpmn:incoming>`,
2159
2722
  " </bpmn:endEvent>",
2160
2723
  flow(`${el}_i0`, `${el}_start`, task),
2161
- flow(`${el}_i1`, task, `${el}_end`),
2724
+ // Valid completion routes task → gate → end; the gate's default (invalid) loops back to the task.
2725
+ flow(`${el}_i1`, task, gateRequired ? `${el}_cg` : `${el}_end`),
2162
2726
  flow(`${el}_i2`, `${el}_sla`, `${el}_escEnd`),
2727
+ ...completionGate,
2163
2728
  ];
2164
2729
  }
2165
2730
 
@@ -2177,6 +2742,108 @@ function humanBodyLines(el: string, displayName: string): string[] {
2177
2742
  * mapping a naive resume publishes `<el>_<fact> = null`, silently starving the downstream consumer.
2178
2743
  * `opts.diagnosticInputs` seeds read-only probe context (issue #514 Defect A) onto the same task so the
2179
2744
  * operator can see WHY the gate escalated (its last probe detail + observed candidate releases). */
2745
+
2746
+ /** The ONE canonical runtime normalization a captured escalation/human-form text value is folded
2747
+ * through before a `boolean`/`number` gate or coercion reads it: `lower case(trim(string(...)))`. The
2748
+ * validity gate ({@link resumeValueCondition}) and the typed bind ({@link coerceFactValueFeel}) MUST
2749
+ * share it, or a value one accepts the other converts differently — e.g. `" true "` passes a trimming
2750
+ * gate but a non-trimming coercion publishes the wrong boolean, and `"TRUE"` a case-folding coercion
2751
+ * accepts but a non-folding gate rejects (#863 review r4194186383). Deriving both from this single
2752
+ * expression makes "gate accepts ⇒ coercion binds the same value" structurally true, not coincidental. */
2753
+ function normalizedBoolNumberFeel(rawExpr: string): string {
2754
+ return `lower case(trim(string(${rawExpr})))`;
2755
+ }
2756
+
2757
+ /** Coerce/validate a captured form value — a null-safe FEEL expression `rawExpr` yielding the operator's
2758
+ * entry (string-valued text, or null when absent) — to a single emit's declared {@link DeliveryFact.type},
2759
+ * so the published fact is a REAL FEEL value and not an untyped string. One source of truth shared by the
2760
+ * escalation-resume validation gate (the {@link escalationTaskLines} output mapping, which coerces the
2761
+ * resumed `value` when `resumeValueCondition` accepts it) and the bespoke single-emit human-form
2762
+ * source ({@link humanBodyLines}), so the two typed-publish paths cannot drift (PR #863 threads
2763
+ * r4182488193 / r4189815989). Returns a bare expression WITHOUT a leading `=`.
2764
+ * • `boolean` — accept the literal `"true"`/`"false"`, case-insensitively AND
2765
+ * surrounding-whitespace-trimmed via the shared {@link normalizedBoolNumberFeel}
2766
+ * (`lower case(trim(string(…)))`); anything else is invalid and yields null. (The pinned engine's
2767
+ * FEEL `matches` does not support an inline `(?i:…)` flag — verified against the WASM engine — so
2768
+ * case-insensitivity is done by lower-casing the input, not the pattern.) The validity gate
2769
+ * ({@link resumeValueCondition}) folds the SAME normalization, so a value it accepts coerces to the
2770
+ * SAME boolean here (#863 review r4194186383). An already-boolean control value round-trips
2771
+ * correctly: `string(true)` → `"true"` passes the same gate.
2772
+ * • `number` — `number(trim(string(value)))` parses the trimmed numeric text (matching the gate's
2773
+ * `trim(string(…))`); an unparseable entry yields null.
2774
+ * • every other type (`string`/`url`/`version`/`pr`/`artifact`) is text-valued already — pass through.
2775
+ * The defined FAILURE PATH for invalid input is `null`, so a required-emit producer gate escalates and a
2776
+ * guarded split takes its deadlock-safe default rather than routing on a mistyped value. `definedGuard`,
2777
+ * when given, wraps the passthrough/branch in an outer `is defined(...)` for a raw name that may be
2778
+ * entirely absent (the escalation `value` control); a `rawExpr` already null-safe (the human-form
2779
+ * selection expression) passes none. */
2780
+ function coerceFactValueFeel(fact: DeliveryFact, rawExpr: string, definedGuard?: string): string {
2781
+ const pre = definedGuard ? `${definedGuard} and ` : "";
2782
+ // Parenthesise rawExpr wherever it stands as a BARE infix operand (`(rawExpr) != null`): a caller may
2783
+ // pass a COMPOUND `if … then … else null` selection expression (the bespoke/generic human-form
2784
+ // `selectExpr`), and FEEL's `else` arm is GREEDY — an unparenthesised `if C then x else null != null
2785
+ // and matches(…)` parses as `if C then x else (null != null and matches(…))`, so when the field is
2786
+ // defined the whole coercion guard collapses to the raw entry and `if ("true") then … else null`
2787
+ // (a non-boolean condition) publishes NULL, silently defeating the coercion for every bespoke/generic
2788
+ // boolean & number human form (the escalation path passes the bare name `value`, so it was unaffected —
2789
+ // which is why the string-shape unit tests never caught it; empirically confirmed on the WASM engine).
2790
+ // The `string(rawExpr)`/`number(rawExpr)` sites are already bounded by their call parens, and the
2791
+ // passthrough `then rawExpr else null` has no trailing operator, so only the `!= null` operand needs it.
2792
+ const operand = `(${rawExpr})`;
2793
+ switch (fact.type) {
2794
+ case "boolean": {
2795
+ // Share the gate's EXACT normalization ({@link normalizedBoolNumberFeel}) so a value the validity
2796
+ // gate ({@link resumeValueCondition}) accepts coerces to the SAME boolean here — `" true "`/`"TRUE"`
2797
+ // can never be accepted-then-mis-published (#863 review r4194186383).
2798
+ const norm = normalizedBoolNumberFeel(rawExpr);
2799
+ // NULL-SAFE the normalization exactly like the number arm below: `operand != null` does NOT prove the
2800
+ // operand is STRINGABLE, so a fact name colliding with a FEEL builtin (`count`, …) left blank resolves
2801
+ // the selection operand to the builtin FUNCTION and `lower case(trim(string(<function>)))` throws a
2802
+ // runtime INCIDENT. Guard with `string(rawExpr) != null` (null-safe, short-circuits the normalization).
2803
+ return `if (${pre}${operand} != null and string(${rawExpr}) != null and matches(${norm}, "^(true|false)$")) then ${norm} = "true" else null`;
2804
+ }
2805
+ case "number":
2806
+ // TRIM/stringify to match the gate's `trim(string(...))` (#863 review r4194186383): the gate accepts
2807
+ // `" 42 "`, so the bind must parse the same trimmed text — a bare `number(" 42 ")` yields null,
2808
+ // breaking the "gate accepts ⇒ bind is non-null" invariant.
2809
+ // NULL-SAFE the trim: `string(rawExpr) != null` must guard it, because `operand != null` does NOT
2810
+ // prove the operand is STRINGABLE. When the fact name collides with a FEEL builtin (`count`, `sum`,
2811
+ // …), a blank human form leaves the variable unset, so the `is defined(count) and count != null`
2812
+ // selection operand resolves to the builtin FUNCTION (is-defined ⇒ true, function ≠ null), and
2813
+ // `trim(string(<function>))` throws `trim: expected a string, got null` — a runtime INCIDENT rather
2814
+ // than the intended null (the pre-round `number(rawExpr)` folded it to null; this restores that
2815
+ // null-safety while keeping the trim). `string(…)` itself is null-safe (yields null, no incident),
2816
+ // so the guard evaluates cleanly and short-circuits the trim away for any non-stringable operand.
2817
+ return `if (${pre}${operand} != null and string(${rawExpr}) != null) then number(trim(string(${rawExpr}))) else null`;
2818
+ case "string":
2819
+ case "version":
2820
+ case "url":
2821
+ case "pr": {
2822
+ // TEXT-VALUED types were previously passed through UNCHANGED — so a human form could publish a blank
2823
+ // string, a malformed version/URL, or an invalid PR ref directly downstream despite the declared
2824
+ // fact type (PR #863 review 5430718031, "Validate human-task fact values before publishing"). Fold
2825
+ // the SAME type-aware validity grammar the escalation resume gate ({@link resumeValueCondition})
2826
+ // enforces into this single `if`, failing CLOSED to null on a non-conforming entry. The grammar is
2827
+ // applied as a FLAT `matches(...)`/`trim(...)` on `string(rawExpr)` (null-safe, no
2828
+ // `is defined(<nested if…else>)` — the pinned engine mis-evaluates that compound inside an outer
2829
+ // guard, verified engine-native). The valid arm publishes the selection VERBATIM (these types need
2830
+ // no coercion), so a real captured value round-trips unchanged.
2831
+ const s = `string(${rawExpr})`;
2832
+ const grammar =
2833
+ fact.type === "string"
2834
+ ? `trim(${s}) != ""`
2835
+ : fact.type === "version"
2836
+ ? `matches(trim(${s}), "^v?\\\\d[\\\\w.+-]*$") = true`
2837
+ : fact.type === "url"
2838
+ ? `matches(trim(${s}), "^[A-Za-z][A-Za-z0-9+.-]*://") = true`
2839
+ : `matches(lower case(trim(${s})), "^(([^/#]+/[^/#]+)#(\\\\d+)|((https?://)?(www\\\\.)?github\\\\.com/[^/]+/[^/]+/pull/\\\\d+([/?#].*)?))$") = true`;
2840
+ return `if (${pre}${operand} != null and ${s} != null and ${grammar}) then ${rawExpr} else null`;
2841
+ }
2842
+ default:
2843
+ return definedGuard ? `if (${definedGuard}) then ${rawExpr} else null` : rawExpr;
2844
+ }
2845
+ }
2846
+
2180
2847
  function escalationTaskLines(
2181
2848
  esc: string,
2182
2849
  nodeId: string,
@@ -2184,52 +2851,133 @@ function escalationTaskLines(
2184
2851
  outgoing: string,
2185
2852
  contextFeel: string,
2186
2853
  opts?: {
2187
- resume?: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[] };
2854
+ /** `emits` are the escalation's RESUME targets (the facts a Continue value may publish); for a
2855
+ * `wait` node this is the node's full declared-emit set, but for an `agent`/`connector` it is only
2856
+ * the REQUIRED subset a downstream node binds. `declaredEmits` is the node's FULL declared-emit set
2857
+ * — it drives SPEC 13.3's hard cardinality rule (value-resume ONLY when exactly one emit is
2858
+ * declared), which the required-count alone cannot see (issue #863 review, thread r4193902690).
2859
+ * Defaults to `emits` for the `wait` case where the two sets coincide. */
2860
+ resume?: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[]; declaredEmits?: readonly DeliveryFact[] };
2188
2861
  diagnosticInputs?: readonly { source: string; target: string }[];
2189
2862
  displayName?: string;
2863
+ /** The node element whose retry gate this escalation feeds (#863): the completion publishes the
2864
+ * node-unique `<el>_retryRequested` boolean from the node-local `decision`, and the tail grows a
2865
+ * retry gate (`${esc}Rg`) routing Retry to the shared reset `${retryElement}_retry`. Absent for a
2866
+ * `wait`-gate escalation (resolution is "supply the awaited value", never "re-run the probe loop"). */
2867
+ retryElement?: string;
2868
+ /** The element the resume-validation gate's VALID branch flows to (the node end). Required iff
2869
+ * `resume` carries emits — a resumable escalation's Continue never routes straight to its end. */
2870
+ validTarget?: string;
2871
+ /** The emit-source var ({@link factSourceVar}) of EVERY declared emit of this node — required AND
2872
+ * non-required (a routing-only `when`-guard emit, or a declared-but-unconsumed one) — that shares
2873
+ * this escalation's flat subprocess scope. The resumed required emits are reserved regardless; this
2874
+ * reserves the REST so the generated resume-valid flag is grown collision-free against ALL of them,
2875
+ * never only the single resumed required emit (PR #876 review). */
2876
+ reservedTargets?: readonly string[];
2190
2877
  },
2191
2878
  ): string[] {
2192
- const emits = opts?.resume?.emits ?? [];
2193
- const emitMode = emits.length > 0 ? "typed" : "none";
2194
- const emitLabel = emits.map((e) => `${e.name} (${e.type})`).join(", ");
2879
+ const resume = opts?.resume;
2880
+ const emits = resume?.emits ?? [];
2881
+ // The node's FULL declared-emit set drives SPEC 13.3's hard cardinality rule. For a `wait` node
2882
+ // `resume.emits` already IS the declared set, so it defaults here; for an `agent`/`connector`
2883
+ // `resume.emits` is only the REQUIRED subset, so the declared set is threaded explicitly (issue #863
2884
+ // review, thread r4193902690).
2885
+ const declaredEmits = resume?.declaredEmits ?? emits;
2886
+ // The escalation form (`ESCALATION_FORM` / `GENERIC_HUMAN_FORM`) captures the operator's answer in a
2887
+ // SINGLE `value` field, so it can resume AT MOST ONE emit. With >1 required emit, one value cannot
2888
+ // satisfy multiple distinct typed facts — mapping it onto every emit-source var writes the SAME value
2889
+ // to each, corrupting all of them (and coercing one string into differently-typed facts; #863 review
2890
+ // r4180629319). So Continue-with-value is offered ONLY for a single emit; with multiple, the value
2891
+ // field is inert ("none"), the resume-valid flag is hard-`false` (#876 fail closed), and the
2892
+ // validation gate always re-parks — re-running the node ("Retry this step") stays the way to produce
2893
+ // the facts.
2894
+ //
2895
+ // The cardinality that gates this is the DECLARED-emit count, NOT the required-emit count: a node
2896
+ // declaring two facts where only one is required must STILL leave the value field inert (SPEC 13.3 —
2897
+ // "any node with multiple declared emits leaves the field inert"). Gating on `emits.length === 1`
2898
+ // (required) alone let such a node offer a value-resumable Continue that would publish the lone
2899
+ // required fact while silently dropping the sibling (issue #863 review, thread r4193902690).
2900
+ const resumableEmit = declaredEmits.length === 1 && emits.length === 1 ? emits[0] : undefined;
2901
+ const emitMode = resumableEmit !== undefined ? "typed" : "none";
2195
2902
  const inputs: string[] = [
2196
2903
  ` <zeebe:input ${attr("source", contextFeel)} target="prompt" />`,
2197
2904
  ` <zeebe:input ${attr("source", `=${feelStr(nodeId)}`)} target="nodeId" />`,
2198
2905
  ` <zeebe:input ${attr("source", `=${feelStr(emitMode)}`)} target="emitMode" />`,
2199
2906
  ];
2200
- if (emits.length > 0) {
2201
- inputs.push(` <zeebe:input ${attr("source", `=${feelStr(emitLabel)}`)} target="emitLabel" />`);
2907
+ if (resumableEmit !== undefined) {
2908
+ inputs.push(
2909
+ ` <zeebe:input ${attr("source", `=${feelStr(`${resumableEmit.name} (${resumableEmit.type})`)}`)} target="emitLabel" />`,
2910
+ );
2202
2911
  }
2203
2912
  for (const di of opts?.diagnosticInputs ?? []) {
2204
2913
  inputs.push(` <zeebe:input ${attr("source", di.source)} target="${di.target}" />`);
2205
2914
  }
2206
- // Defect B: map the operator's captured typed value onto the node's emit-source var, so the
2207
- // subProcess output ioMapping publishes the SAME `<el>_<fact>` shape a normally-completing node does.
2915
+ // Defect B + PR #876 review + #863 retry: the operator's captured typed value is VALIDATED on this
2916
+ // task's OWN output mapping (reading the form's `value` field), which the pinned engine evaluates
2917
+ // reliably — unlike a downstream gateway's ioMapping, whose inputs/outputs are NOT visible downstream
2918
+ // (verified engine-native). The required emit's emit-source var is bound from `value` — COERCED to
2919
+ // the fact's declared type ({@link coerceFactValueFeel}, #863) — ONLY when it is present and type-valid
2920
+ // ({@link resumeValueCondition}, #876), else null; a validity boolean flags the resume. The
2921
+ // post-escalation validation gateway routes on the SIMPLE `<resumeValidVar> = true` (a complex FEEL
2922
+ // condition on the gateway mis-evaluates — verified), re-parking an invalid resume (fail closed) so it
2923
+ // can never thread null/garbage onto the emit the subProcess output ioMapping republishes downstream.
2924
+ // On Continue (`decision != retry`) this validation gate runs; on Retry the per-escalation retry gate
2925
+ // funnels to the shared reset instead. The generic/service escalation form captures the answer in a
2926
+ // single `value` field — no `resolvedArtifact` field — so a single emit resumes from `value`,
2927
+ // validated per its fact type (artifact→resolvedArtifact, version→detail, …).
2928
+ //
2929
+ // MULTI-EMIT (PR #876 review): the form's SINGLE `value` cannot supply a distinct value per fact, so a
2930
+ // node owing more than one required emit CANNOT be resumed from it. Copying the one `value` into every
2931
+ // emit-source var would release duplicate/garbage facts downstream; instead we FAIL CLOSED — bind
2932
+ // nothing and hard-set the validity flag to `false`, so the validation gate always re-parks.
2208
2933
  const outputs: string[] = [];
2209
- if (opts?.resume) {
2210
- const seen = new Set<string>();
2211
- for (const fact of emits) {
2212
- const target = factSourceVar(opts.resume.kind, fact);
2213
- if (seen.has(target)) continue;
2214
- seen.add(target);
2215
- // The generic escalation form (`GENERIC_HUMAN_FORM`) captures the operator's answer in a single
2216
- // `value` field — it has NO `resolvedArtifact` field — so every emit type resumes from `value`,
2217
- // mapped onto that fact's emit-source var (artifact→resolvedArtifact, version→detail, …). Sourcing
2218
- // an artifact from a `resolvedArtifact` form field the form never sets would publish null and make
2219
- // an artifact wait-node escalation non-resumable via the UI.
2220
- outputs.push(
2221
- ` <zeebe:output ${attr("source", `=if (is defined(value)) then value else null`)} target="${target}" />`,
2222
- );
2223
- }
2934
+ // The resume-valid flag shares this escalation's flat engine scope with EVERY declared emit's source
2935
+ // var — not only the single resumed required emit — so it is grown collision-free against ALL of them
2936
+ // (shared with the reset via {@link escalationResumeFlagVar}). See that helper for the collision class.
2937
+ const flagVar = escalationResumeFlagVar(esc, resume, opts?.reservedTargets ?? []);
2938
+ if (resume !== undefined && resumableEmit !== undefined) {
2939
+ const fact = resumableEmit;
2940
+ const target = factSourceVar(resume.kind, fact);
2941
+ const valid = resumeValueCondition(fact, "value");
2942
+ outputs.push(
2943
+ // Bind the coerced typed value only when it passes the fail-closed validity test (so flag=true
2944
+ // always implies a non-null, correctly-typed bind — the two can never disagree).
2945
+ ` <zeebe:output ${attr("source", `=if ${valid} then ${coerceFactValueFeel(fact, "value")} else null`)} target="${target}" />`,
2946
+ );
2947
+ outputs.push(
2948
+ // The validity flag the post-escalation gateway routes on — an internal, compiler-generated
2949
+ // variable name ({@link resumeValidVar}), made collision-free against the emit-source var above
2950
+ // by construction, so it can neither collide with nor be shadowed by a declared emit.
2951
+ ` <zeebe:output ${attr("source", `=if ${valid} then true else false`)} target="${flagVar}" />`,
2952
+ );
2953
+ } else if (resume !== undefined && emits.length > 0) {
2954
+ outputs.push(
2955
+ // Fail closed: the escalation is not value-resumable (more than one required emit, OR more than
2956
+ // one DECLARED emit so the single value field is inert per SPEC 13.3) yet a required emit is owed,
2957
+ // so the resume is never valid and the validation gate always re-parks (see the comment above).
2958
+ ` <zeebe:output ${attr("source", "=false")} target="${flagVar}" />`,
2959
+ );
2960
+ }
2961
+ if (opts?.retryElement !== undefined) {
2962
+ outputs.push(
2963
+ // The node-unique `<el>_retryRequested` boolean the per-escalation retry gate routes on (#863),
2964
+ // derived from the node-local `decision` the operator chose on `ESCALATION_FORM`.
2965
+ ` <zeebe:output ${attr("source", `=is defined(${ESCALATION_DECISION_VAR}) and ${ESCALATION_DECISION_VAR} = ${feelStr(ESCALATION_DECISION_RETRY)}`)} target="${retryRequestedVar(opts.retryElement, opts.reservedTargets ?? [])}" />`,
2966
+ );
2224
2967
  }
2225
2968
  // The escalation user task shows the node's DESCRIPTIVE display name (issue #778 review) so a
2226
2969
  // timed-out / contract-broken node is legible in the explorer/inbox instead of an opaque bare id;
2227
2970
  // `nodeId` is still threaded as the `nodeId` input above for runtime correlation.
2228
2971
  const escLabel = trimmedOrEmpty(opts?.displayName) || nodeId;
2229
- return [
2972
+ // The retry-capable `decision` select lives ONLY on the service-escalation form (`ESCALATION_FORM`).
2973
+ // A wait-gate escalation carries no `retryElement` — its resolution is "supply the awaited value and
2974
+ // continue", never "re-run the probe loop" — so it keeps the select-less generic form rather than
2975
+ // render a "Retry this step" option that would be silently ignored.
2976
+ const form = opts?.retryElement !== undefined ? ESCALATION_FORM : GENERIC_HUMAN_FORM;
2977
+ const task = [
2230
2978
  ` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(escLabel)}">`,
2231
2979
  " <bpmn:extensionElements>",
2232
- ` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
2980
+ ` <zeebe:formDefinition formId="${form}" />`,
2233
2981
  " <zeebe:userTask />",
2234
2982
  ' <zeebe:assignmentDefinition candidateGroups="operators" />',
2235
2983
  " <zeebe:ioMapping>",
@@ -2241,6 +2989,156 @@ function escalationTaskLines(
2241
2989
  ` <bpmn:outgoing>${outgoing}</bpmn:outgoing>`,
2242
2990
  " </bpmn:userTask>",
2243
2991
  ];
2992
+ const retryEl = opts?.retryElement;
2993
+ // The resume-validation gate re-parks an invalid Continue back onto THIS escalation task (fail
2994
+ // closed). That loop is only escapable when the operator has a way out of it — EITHER a genuinely
2995
+ // value-resumable single emit (supply the value → flag true → proceed to the node end) OR a Retry
2996
+ // gate (service nodes). A multi-emit WAIT escalation has NEITHER: the single `value` field cannot
2997
+ // supply >1 distinct fact so the flag is hard-`false` and Continue ALWAYS re-parks, and a wait gate
2998
+ // carries no `retryElement`, so there is no Retry escape. Building the gate there wedges the timed-out
2999
+ // token in an unresolvable loop (issue #863 review, thread r4198123619 — "Prevent unresolvable loops
3000
+ // for multi-emit wait nodes"). So the gate is grown ONLY when there is an escape; otherwise the
3001
+ // escalation flows straight to the node end, exactly like a no-emit wait escalation (an acknowledged,
3002
+ // value-less continue). Service nodes KEEP the fail-closed re-park — their Retry gate is the escape.
3003
+ const resumable = resume !== undefined && emits.length > 0 && (resumableEmit !== undefined || retryEl !== undefined);
3004
+ // A wait-gate escalation with no escapable resume (no `retryElement`, not resumable) has no tail — the
3005
+ // caller flows `outgoing` straight to the node end.
3006
+ if (retryEl === undefined && !resumable) return task;
3007
+
3008
+ const tail: string[] = [];
3009
+ // #863 retry gate (service escalations only): the esc task's `outgoing` flows into this gate (built by
3010
+ // the caller). Retry (`decision == retry`) → the SHARED reset `${retryEl}_retry`; Continue (default) →
3011
+ // the validation gate (if resumable) else the node end. Checking Retry FIRST is essential: a Retry
3012
+ // supplies no `value`, so its resume-valid flag is false — a validation-first topology would wrongly
3013
+ // re-park a legitimate Retry.
3014
+ if (retryEl !== undefined) {
3015
+ const rg = `${esc}Rg`;
3016
+ const rr = `${esc}Rr`;
3017
+ const rc = `${esc}Rc`;
3018
+ const continueTarget = resumable ? `${esc}Vg` : `${retryEl}_end`;
3019
+ tail.push(
3020
+ ` <bpmn:exclusiveGateway id="${rg}" name="retry node?" default="${rc}">`,
3021
+ ` <bpmn:incoming>${outgoing}</bpmn:incoming>`,
3022
+ ` <bpmn:outgoing>${rr}</bpmn:outgoing>`,
3023
+ ` <bpmn:outgoing>${rc}</bpmn:outgoing>`,
3024
+ " </bpmn:exclusiveGateway>",
3025
+ ` <bpmn:sequenceFlow id="${rr}" name="retry" sourceRef="${rg}" targetRef="${retryEl}_retry"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=${retryRequestedVar(retryEl, opts?.reservedTargets ?? [])} = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
3026
+ ` <bpmn:sequenceFlow id="${rc}" name="continue" sourceRef="${rg}" targetRef="${continueTarget}" />`,
3027
+ );
3028
+ }
3029
+
3030
+ // Resume-validation gate (PR #876 review): a resumable Continue no longer routes straight to the node
3031
+ // end. It flows into an exclusive gateway whose VALID branch proceeds to the node end and whose
3032
+ // DEFAULT (invalid) branch loops back onto the escalation task — so a blank/malformed `value` re-parks
3033
+ // instead of releasing a null/invalid fact downstream (fail closed on the default). The gateway is a
3034
+ // PLAIN exclusive gateway (NO ioMapping — a gateway's ioMapping is not visible downstream in the
3035
+ // pinned engine, verified): the per-emit validation already ran on the escalation task's OWN output
3036
+ // mapping (binding the emit-source var + the validity flag), so the gateway only routes on the simple
3037
+ // `<resumeValidVar> = true` boolean. For a multi-emit node the flag is hard-`false` (unresumable via
3038
+ // the single-value form), so the gate always re-parks. The gate's incoming is the retry gate's
3039
+ // `continue` flow (service escalation) or the esc task's `outgoing` (wait escalation).
3040
+ if (resumable) {
3041
+ const validTarget = opts?.validTarget;
3042
+ if (validTarget === undefined) {
3043
+ throw new Error(`escalationTaskLines(${esc}): a resumable escalation requires opts.validTarget (the node end its valid resume flows to)`);
3044
+ }
3045
+ const vg = `${esc}Vg`;
3046
+ const vok = `${esc}Vok`;
3047
+ const vbad = `${esc}Vbad`;
3048
+ const vgIncoming = retryEl !== undefined ? `${esc}Rc` : outgoing;
3049
+ tail.push(
3050
+ ` <bpmn:exclusiveGateway id="${vg}" name="resume value valid?" default="${vbad}">`,
3051
+ ` <bpmn:incoming>${vgIncoming}</bpmn:incoming>`,
3052
+ ` <bpmn:outgoing>${vok}</bpmn:outgoing>`,
3053
+ ` <bpmn:outgoing>${vbad}</bpmn:outgoing>`,
3054
+ " </bpmn:exclusiveGateway>",
3055
+ ` <bpmn:sequenceFlow id="${vok}" name="valid" sourceRef="${vg}" targetRef="${validTarget}"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=${flagVar} = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
3056
+ ` <bpmn:sequenceFlow id="${vbad}" name="invalid" sourceRef="${vg}" targetRef="${esc}" />`,
3057
+ );
3058
+ }
3059
+ return [...task, ...tail];
3060
+ }
3061
+
3062
+ /** The per-required-emit FEEL condition an escalation resume value must satisfy to bind its
3063
+ * emit-source var (PR #876 review) — the runtime mirror of `coerceFactValue` (app/deliveryHuman.ts),
3064
+ * re-expressed in FEEL so the compiled model validates WITHOUT a worker round-trip. A missing/null
3065
+ * value fails every branch (fail closed). Type-aware: a `pr` must match the `owner/repo#N` shape, a
3066
+ * `version`/`artifact` the version grammar, a `number`/`boolean` coerce, a `url` carry a scheme, a
3067
+ * `string` be non-blank.
3068
+ *
3069
+ * The pinned WASM FEEL evaluator's null semantics drive the exact shape (each verified engine-native):
3070
+ * the type-test builtins `is string(x)` / `is number(x)` / `is boolean(x)` are UNSUPPORTED — they raise
3071
+ * "cannot call a null value" (an ioMapping incident) rather than returning a boolean — so they are
3072
+ * NEVER used. Instead every value is funnelled through `string(resumeValue)` (null-safe) and validated
3073
+ * by `matches(...)`/`trim(...)`, which ARE null-safe (a non-match yields false, never an error).
3074
+ * `is defined(x)` returns NULL (not false) for an absent variable, so the presence guard is anchored
3075
+ * `= true` (null-safe). The caller binds `if cond then <value> else null` on the escalation task's OWN
3076
+ * output mapping (verified: a valid value binds it, an invalid/absent one binds null — fail closed).
3077
+ *
3078
+ * CANONICAL-GRAMMAR ALIGNMENT (PR #876 round-4 "Previously missed"): the regexes below must accept
3079
+ * exactly what the canonical contracts accept, or a timed-out node cannot resume with a value that
3080
+ * works on its normal completion path. `artifact` mirrors `coerceFactValue`'s `pkg@version` split on
3081
+ * the LAST `@` — so a SCOPED package (`@nanobpm/urban@0.54.0`) is accepted: the name segment is
3082
+ * `[^@\s]+` (no `@`/space) preceded by an OPTIONAL leading scope `@`. `pr` mirrors `parsePr`
3083
+ * (app/prParse.ts), which accepts BOTH the `owner/repo#N` shorthand AND a canonical GitHub PR URL
3084
+ * (`https://github.com/owner/repo/pull/N`, optional scheme/`www.`, optional `/files`/`?query`/
3085
+ * `#fragment` suffix) — so the `pr` branch is the disjunction of the two anchored grammars. parsePr's
3086
+ * URL regex carries the `/i` flag, so the `pr` branch folds the tested value through `lower case(...)`
3087
+ * (an engine-native, null-safe builtin) before matching its lower-case literals — otherwise a mixed-
3088
+ * case host/scheme the normal completion path accepts (`https://GitHub.com/...`, `HTTPS://...`) would be
3089
+ * rejected here, re-parking a legitimate resume (fail-closed on a value the canonical path takes). Only
3090
+ * the accept/reject test folds; the bound `value` stays verbatim.
3091
+ *
3092
+ * KNOWN FAIL-CLOSED DIVERGENCES (PR #876 round-5 escalation — ACCEPTED tradeoff, do NOT redesign the
3093
+ * completion door): these FEEL regexes are a deliberate in-engine APPROXIMATION of the canonical
3094
+ * `coerceFactValue`/`new URL()`/`Number()` parsers, which are host-side and not FEEL-expressible. A few
3095
+ * exotic-but-canonically-valid inputs are therefore rejected HERE (re-parked) even though the normal
3096
+ * completion path accepts them — safe (fail-CLOSED, never fail-open), and the escalation form's `value`
3097
+ * is a textfield so no type is erased. The known cases, accepted as rare enough not to warrant a
3098
+ * worker/host round-trip:
3099
+ * • `number`: scientific/hex/octal/binary literals (`1e3`, `0x10`, `.5`) — `Number()` parses them,
3100
+ * the `^-?\d+(\.\d+)?$` regex does not.
3101
+ * • `url`: schemes WHATWG `URL` accepts without `://` (`mailto:user@example.com`, `urn:…`) — the
3102
+ * `scheme://` guard requires the authority form.
3103
+ * • `artifact`: a multi-`@` name whose non-final `@` is NOT a scope prefix — the single last-`@`
3104
+ * split differs from edge cases of the canonical parser.
3105
+ * If these ever become common, the fix is to route the resume through the canonical host-side coercer
3106
+ * (a completion-door redesign) rather than widening these regexes toward fail-OPEN. */
3107
+ function resumeValueCondition(fact: DeliveryFact, v: string, presentOverride?: string): string {
3108
+ // `presentOverride` lets a caller supply a FLAT null-safe presence test (`string(sel) != null`) when `v`
3109
+ // is a COMPOUND `if…then…else` selection (e.g. the human-form completion gate's `selectExpr`): the pinned
3110
+ // engine mis-evaluates `is defined(<compound if…then…else>)` — the same quirk `artifactSource` sidesteps
3111
+ // with a flat `matches(trim(string(…)))` — so the default `is defined(v)`-based presence is only safe for
3112
+ // a BARE variable name (the escalation resume `value`). Every per-type grammar arm below reads `v` only
3113
+ // through null-safe `string()`/`matches()`/`trim()` wrappers, so a flat presence override keeps the whole
3114
+ // condition engine-safe over a compound selection.
3115
+ const present = presentOverride ?? `((is defined(${v})) = true and (${v} != null))`;
3116
+ const s = `string(${v})`;
3117
+ switch (fact.type) {
3118
+ case "string":
3119
+ return `${present} and trim(${s}) != ""`;
3120
+ case "number":
3121
+ // Share the coercion's `number(trim(string(...)))` input ({@link normalizedBoolNumberFeel} folds an
3122
+ // extra harmless lower-case over the digits): gate and bind normalize identically (#863 r4194186383).
3123
+ return `${present} and (matches(${normalizedBoolNumberFeel(v)}, "^-?\\\\d+(\\\\.\\\\d+)?$") = true)`;
3124
+ case "boolean":
3125
+ // SAME normalization as the typed bind ({@link coerceFactValueFeel} → {@link normalizedBoolNumberFeel}),
3126
+ // so this gate accepts exactly the texts the coercion publishes correctly — `" true "`/`"TRUE"` are
3127
+ // accepted here AND bound as a real boolean, never accepted-then-mis-published (#863 r4194186383).
3128
+ return `${present} and (${normalizedBoolNumberFeel(v)} = "true" or ${normalizedBoolNumberFeel(v)} = "false")`;
3129
+ case "version":
3130
+ return `${present} and (matches(trim(${s}), "^v?\\\\d[\\\\w.+-]*$") = true)`;
3131
+ case "artifact":
3132
+ return `${present} and (matches(trim(${s}), "^@?[^@\\\\s]+@v?\\\\d[\\\\w.+-]*$") = true)`;
3133
+ case "url":
3134
+ return `${present} and (matches(trim(${s}), "^[A-Za-z][A-Za-z0-9+.-]*://") = true)`;
3135
+ case "pr":
3136
+ // Case-INSENSITIVE like the canonical parsePr (`/i`): fold the tested value to lower case so the
3137
+ // lower-case host/scheme literals below match a mixed-case input the normal path accepts.
3138
+ return `${present} and (matches(lower case(trim(${s})), "^(([^/#]+/[^/#]+)#(\\\\d+)|((https?://)?(www\\\\.)?github\\\\.com/[^/]+/[^/]+/pull/\\\\d+([/?#].*)?))$") = true)`;
3139
+ default:
3140
+ return assertNever(fact.type, "resumeValueCondition");
3141
+ }
2244
3142
  }
2245
3143
 
2246
3144
  /** Build the FEEL context line seeded onto an escalation task's read-only prompt field (issue #499).