@nanobpm/nano-workforce 0.201.0 → 0.201.2

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 (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +153 -0
  3. package/app/agentActivationLedger.test.ts +109 -0
  4. package/app/agentActivationLedger.ts +144 -0
  5. package/app/agentActivationsPoll.test.ts +180 -0
  6. package/app/agentCompletion.test.ts +52 -0
  7. package/app/agentCompletion.ts +20 -5
  8. package/app/agentSlaEscalation.test.ts +137 -0
  9. package/app/agentSlaEscalation.ts +169 -0
  10. package/app/agentSlaMarkerGuard.test.ts +172 -0
  11. package/app/contracts.ts +9 -0
  12. package/app/deliveryGraph.test.ts +251 -0
  13. package/app/deliveryGraph.ts +334 -1
  14. package/app/deliveryGraphCompiler.test.ts +1345 -30
  15. package/app/deliveryGraphCompiler.ts +882 -240
  16. package/app/deliveryGraphDeploy.test.ts +472 -3
  17. package/app/deliveryGraphRun.ts +2 -2
  18. package/app/deliveryHuman.test.ts +59 -5
  19. package/app/deliveryHuman.ts +115 -3
  20. package/app/deliveryRunner.test.ts +3 -3
  21. package/app/mergeLoopBehaviour.test.ts +46 -1
  22. package/app/redactText.ts +88 -0
  23. package/app/service.ts +110 -2
  24. package/db/migrations/119_agent_job_activations.sql +50 -0
  25. package/e2e/agent-sla-boundary.e2e.ts +51 -8
  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 +11 -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/resources/processes/convergence-loop.bpmn +10 -0
  33. package/resources/processes/implement-cell.bpmn +4 -1
  34. package/resources/processes/merge-cell.bpmn +4 -1
  35. package/resources/processes/merge-loop.bpmn +7 -0
  36. package/resources/processes/plan-fanout.bpmn +5 -0
  37. package/resources/processes/retro.bpmn +5 -2
  38. package/test/derivation-parity/flows.ts +5 -3
  39. package/workers/conformance-record/worker.ts +15 -1
  40. package/workers/persist-escalation/worker.ts +22 -3
  41. package/workers/record-feature-escalation/worker.ts +28 -12
  42. package/workers/record-trial-merge/worker.ts +16 -1
@@ -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
@@ -161,8 +174,12 @@ function humanTaskElement(element: string): string {
161
174
  /** The BPMN element id a service node's bounded-timeout escalation user task carries — same
162
175
  * human-completable convention as a human node, so a stalled `agent`/`wait`/`connector` escalates onto
163
176
  * the Tasks inbox and is answerable by a human OR an agent (ADR 0046). */
164
- function escalationTaskElement(element: string): string {
165
- 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}`;
166
183
  }
167
184
 
168
185
  /** The BPMN element id an `agent` node's PRODUCER-CONTRACT escalation user task carries (issue #731) —
@@ -172,7 +189,35 @@ function escalationTaskElement(element: string): string {
172
189
  * a producer that finishes without doing its job escalates AT that node and is answerable by a human
173
190
  * OR an agent. */
174
191
  function contractEscalationTaskElement(element: string): string {
175
- 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)})`;
176
221
  }
177
222
 
178
223
  /** A distinctive infix stamped into the compiler-generated resume-validity flag variable ({@link
@@ -1100,82 +1145,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
1100
1145
  * in `deliveryGraph.ts`) so the DISPLAY path here and `validateDeliveryGraph`'s reject/error path share
1101
1146
  * ONE redactor — no drift surface (issue #778 review). */
1102
1147
 
1103
- /** Redact credential-bearing pieces of any URL embedded in FREE-FORM prose (a node's authored
1104
- * `prompt`), IN PLACE. Unlike {@link redactString} — tuned for a single opaque target string, where it
1105
- * truncates from the first `?`/`#` to end-of-string — this finds each URL-shaped token *within*
1106
- * surrounding prose (`scheme://…` or a scheme-relative `//…`) and strips only that token's
1107
- * `user:pass@` userinfo and `?query`/`#fragment` via `redactString`, leaving the prose (and ordinary
1108
- * punctuation such as a `?` ending a sentence) intact. Applied to the DISPLAY name/documentation only —
1109
- * a prompt is up to 20 000 chars of arbitrary text that can embed a bearer token or private URL, and
1110
- * the deployed `<bpmn:documentation>`/name is visible to modeler/explorer readers; the RAW prompt still
1111
- * reaches the runtime job input (`appendPrompt`/`prompt`) unmodified (issue #778 review). Deterministic
1112
- * and total. */
1113
- export function redactFreeText(value: string): string {
1114
- // Strip XML-invalid display characters BEFORE tokenizing/redacting so every scan runs on the exact
1115
- // string the renderer emits. Otherwise a control char embedded in a URL (`//us\x0Ber:pass@…`) breaks
1116
- // the `//[^\s]+` token match, escapes redaction, then reconstructs the credential once `escapeXml`/
1117
- // `stripXmlInvalidChars` drops the control at render time (issue #778 review — same class as the
1118
- // connector/probe strip-before-classify fix).
1119
- const cleaned = stripXmlInvalidChars(value);
1120
- // Embedded credential userinfo: DERIVE from the ONE canonical redactor {@link redactEmbeddedCredential}
1121
- // (`EMBEDDED_CREDENTIAL_SRC` = `//[^/?#]*@`) rather than a bespoke belt heuristic. Its `[^/?#]` class
1122
- // spans spaces/TABs/newlines up to the LAST `@` before a `/`/`?`/`#`, so EVERY `//<userinfo>@` shape
1123
- // collapses uniformly to `//***@` — a colon-prefix `//user:pass@`, a colon-LESS bearer separated from
1124
- // its `@host` by a word or space (`//token part@host`, `//token @host`), a userinfo split by a raw
1125
- // CR/LF/TAB, and a malformed multi-`@` authority (`//user:pass@ss@host`) — with NO second "what is a
1126
- // credential" implementation that can drift from the canonical redactor/validator (`hasEmbeddedCredential`)
1127
- // (issue #783 review — thread deliveryGraphCompiler.ts:1140; the earlier bespoke colon-less bridge
1128
- // stopped on the first non-whitespace char after the space and leaked `//token part@host`). A `//…@`
1129
- // span is UNAMBIGUOUSLY a credential wherever it sits, so redacting a prose `//word …@host` too is the
1130
- // SAFE direction: the RAW prompt still reaches the runtime job input unmodified — only the operator-
1131
- // visible display doc loses the span. The `[^/?#]` bound also keeps the userinfo from swallowing a `?`
1132
- // marker (`//host?token=secret@tail` has no userinfo — its `@` rides the query), leaving that tail to
1133
- // the query/fragment belt below.
1134
- const credStripped = redactEmbeddedCredential(cleaned);
1135
- // Query/fragment across a whitespace BREAK: the primary `//[^\s]+` token below stops at the break, so a
1136
- // `//host/?\nTOKEN=secret` would leave the value visible in the XML-preserved doc. The linear belt walks
1137
- // each SPACE-bounded `//`-run (crossing an embedded CR/LF/TAB the primary token stopped at) and
1138
- // CONSERVATIVELY redacts its `?query`/`#fragment` tail through the span's space boundary — a continuation
1139
- // past the break is indistinguishable from a split value, so we never keep the far side (issue #778
1140
- // review — thread deliveryGraphCompiler.ts:1115).
1141
- const belted = redactQueryFragmentSpans(credStripped);
1142
- return belted.replace(/(?:[a-z][a-z0-9+.-]*:)?\/\/[^\s]+/gi, (m) => redactString(m));
1143
- }
1144
-
1145
- /** Linear (backtracking-free) newline-aware belt companion to {@link redactFreeText}'s primary
1146
- * whitespace-bounded pass. Each `//`-run is bounded by a literal SPACE (0x20) — so a single span may
1147
- * cross a CR/LF/TAB *inside* the URL that the primary `//[^\s]+` token stopped at. Credential userinfo is
1148
- * already collapsed to `//***@` by the canonical {@link redactEmbeddedCredential} before this runs, so the
1149
- * belt's SOLE remaining job is a `?query`/`#fragment` tail: it redacts CONSERVATIVELY from the first
1150
- * `?`/`#` marker through the span's space boundary — a continuation past an embedded break is
1151
- * indistinguishable from a split value, so the far side is never kept. A span with no `?`/`#` (ordinary
1152
- * prose — a `//comment` reference, an already-collapsed `//***@host`) is returned untouched. The scan is a
1153
- * single left-to-right walk using `indexOf`/`charCodeAt` over spans bounded by the next SPACE, with no
1154
- * regex backtracking, so a 20 000-char adversarial prompt cannot trigger catastrophic backtracking (issue
1155
- * #778 review). Deterministic and total. */
1156
- function redactQueryFragmentSpans(text: string): string {
1157
- let out = "";
1158
- let i = 0;
1159
- for (;;) {
1160
- const start = text.indexOf("//", i);
1161
- if (start < 0) return out + text.slice(i);
1162
- out += text.slice(i, start);
1163
- let end = start;
1164
- while (end < text.length && text.charCodeAt(end) !== 0x20 /* SPACE */) end++;
1165
- const span = text.slice(start, end);
1166
- const qMark = span.indexOf("?");
1167
- const hMark = span.indexOf("#");
1168
- const qi = qMark < 0 ? hMark : hMark < 0 ? qMark : Math.min(qMark, hMark);
1169
- // CONSERVATIVE: keep everything up to and including the `?`/`#` marker, then collapse the whole tail
1170
- // (any continuation past an embedded CR/LF/TAB) to `***`. A value/prose resuming after the break is
1171
- // INDISTINGUISHABLE from a split-credential continuation, so we never keep the post-break side; the RAW
1172
- // prompt still reaches the runtime job input unmodified (issue #778 review — thread
1173
- // deliveryGraphCompiler.ts:1115).
1174
- out += qi < 0 ? span : `${span.slice(0, qi + 1)}***`;
1175
- i = end;
1176
- }
1177
- }
1178
-
1179
1148
  /** A human-readable label for a connector node's `target`. The converge-enrollment vocabulary
1180
1149
  * (`convergeTargets.ts`) maps to intent-revealing phrases; any other (forward-declared) target is shown
1181
1150
  * through {@link redactConnectorValue} — an opaque identifier (`slack:#releases`) survives unchanged, but
@@ -1824,6 +1793,75 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
1824
1793
  return assertNever(node, "ioMappingLines");
1825
1794
  }
1826
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
+
1827
1865
  // Late-binding: a deterministic FEEL list literal of the upstream producers' emitted facts, keyed
1828
1866
  // exactly as the edge references them (`<producerNode>.<fact>`), read from the flat parent variable
1829
1867
  // each producer publishes. Guarded so an as-yet-unobserved fact threads as null, not a FEEL error.
@@ -1836,8 +1874,24 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
1836
1874
  }
1837
1875
 
1838
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;
1839
1891
  for (const fact of normaliseEmits(node)) {
1840
- 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}` });
1841
1895
  }
1842
1896
 
1843
1897
  // Stage 0 transcript correlation (#543): propagate the completing worker's `transcriptUrl` (built
@@ -1870,38 +1924,71 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>, displ
1870
1924
  // self-reported `status` AND a non-null value for every declared emit a downstream consumer binds
1871
1925
  // as a required data dependency. A broken producer (returns `in_progress`, or omits a required
1872
1926
  // emit) escalates AT this node instead of threading an incomplete result onward.
1873
- const contractGate = { requiredEmits: normaliseEmits(node).filter((f) => requiredEmits.has(f.name)) };
1874
- // The subProcess timeout `__esc` twin resumes with the SAME required emits the contract gate
1875
- // uses — the facts a downstream node binds, which a null (lost) result poisons (#872).
1876
- const timeoutResume = { kind: node.kind, emits: contractGate.requiredEmits };
1877
- // The source var of EVERY declared emit (required + non-required), so the resume-valid flag on
1878
- // this node's escalations dodges all of them — not only the resumed required emit (PR #876 review).
1879
- const allEmitTargets = normaliseEmits(node).map((f) => factSourceVar(node.kind, f));
1927
+ const nodeEmits = normaliseEmits(node);
1928
+ const contractGate = { requiredEmits: nodeEmits.filter((f) => requiredEmits.has(f.name)), emits: nodeEmits };
1880
1929
  // The executable `<zeebe:taskDefinition type=…>` MUST carry the raw `jobType` verbatim for worker
1881
1930
  // routing (validateDeliveryGraph rejects a URL-shaped/credential-bearing jobType, so it can never
1882
1931
  // be a leak here), but the `descriptor` is embedded by `serviceBodyLines` into the operator-visible
1883
1932
  // timeout / producer-contract escalation FEEL `prompt` — so it takes the SAME display redaction
1884
1933
  // `nodeDisplay` applies, never the raw value (issue #778 review — thread deliveryGraphCompiler.ts:1606).
1885
- return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName, timeoutResume, allEmitTargets);
1934
+ return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName, "agent", nodeEmits);
1886
1935
  }
1887
1936
  case "connector": {
1888
1937
  // TRIM the target before building the escalation descriptor — `nodeDisplay` and the connector
1889
1938
  // worker both key on the trimmed value, so an untrimmed `" converge-merge "` would fork the
1890
1939
  // `semanticBpmn`/digest from the trimmed-equivalent graph that dispatches identically (issue #778
1891
1940
  // review — thread deliveryGraphCompiler.ts:1741).
1892
- // A connector has no producer-contract gate, but its timeout `__esc` is still made resumable with
1893
- // the facts a downstream node binds — so a human/agent who unsticks a timed-out connector can
1894
- // supply the lost emit value rather than thread a null onward (#872).
1895
- const connectorResume = { kind: node.kind, emits: normaliseEmits(node).filter((f) => requiredEmits.has(f.name)) };
1896
- // Every declared emit's source var, so the flag dodges a non-required (routing/unconsumed) emit
1897
- // named exactly the flag, not only the resumed required one (PR #876 review).
1898
- const allEmitTargets = normaliseEmits(node).map((f) => factSourceVar(node.kind, f));
1899
- return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${redactConnectorValue(node.connector.target.trim())}`, undefined, [], displayName, connectorResume, allEmitTargets);
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);
1900
1950
  }
1901
1951
  case "wait":
1902
1952
  return waitBodyLines(el, node, displayName);
1903
- case "human":
1904
- 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
+ }
1905
1992
  default:
1906
1993
  return assertNever(node, "innerBodyLines");
1907
1994
  }
@@ -1946,11 +2033,12 @@ function agentContractProceedCondition(requiredEmits: readonly DeliveryFact[]):
1946
2033
  * so the human/agent unsticking it sees WHY it parked — the node, its job type, the actual reported
1947
2034
  * status, and, per required emit, whether it arrived. Turns the instance-10746 failure (a silent null
1948
2035
  * thread + two mis-attributed CONSUMER incidents) into one correctly-attributed PRODUCER escalation. */
1949
- function agentContractContextFeel(nodeId: string, descriptor: string, requiredEmits: readonly DeliveryFact[]): string {
2036
+ function agentContractContextFeel(nodeId: string, descriptor: string, requiredEmits: readonly DeliveryFact[], nodeEmits: readonly DeliveryFact[]): string {
1950
2037
  const statuses = AGENT_TERMINAL_SUCCESS_STATUSES.join("/");
1951
2038
  const head = feelStr(
1952
- `Node ${nodeId} (${descriptor}) completed but did not satisfy its producer contract — a producer must ` +
1953
- `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 ` +
1954
2042
  "before its result routes onward. Reported status=",
1955
2043
  );
1956
2044
  let feel = `=${head} + (if (is defined(status) and status != null) then string(status) else "(none)") + "."`;
@@ -1958,6 +2046,18 @@ function agentContractContextFeel(nodeId: string, descriptor: string, requiredEm
1958
2046
  const present = `(is defined(${f.name}) and ${f.name} != null)`;
1959
2047
  feel += ` + " Required emit '${f.name}': " + (if ${present} then "present" else "MISSING (null)") + "."`;
1960
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))}`;
1961
2061
  return feel;
1962
2062
  }
1963
2063
 
@@ -1979,51 +2079,95 @@ function serviceBodyLines(
1979
2079
  taskDefAttr: string,
1980
2080
  taskProps: readonly string[],
1981
2081
  descriptor: string,
1982
- contractGate?: { requiredEmits: readonly DeliveryFact[] },
2082
+ contractGate?: { requiredEmits: readonly DeliveryFact[]; emits: readonly DeliveryFact[] },
1983
2083
  taskHeaders: readonly string[] = [],
1984
2084
  taskName: string = nodeId,
1985
- // The node's REQUIRED emits (the facts a downstream node binds as a data dependency), used to make
1986
- // the `__esc` TIMEOUT escalation RESUMABLE (issue #872): when a timed-out node is unstuck by a
1987
- // human/agent supplying `value`, that value must bind the node's declared emit exactly as the
1988
- // agent's own result would — otherwise the subProcess publishes `<el>_<fact> = null` and the
1989
- // downstream reference resolves to null (the production failure: a completed `__esc` returned
1990
- // `ok:true` yet `n3_pr` stayed null). Empty for a node with no required emit (the escalation stays
1991
- // an inert "click done" acknowledgement, `emitMode = "none"`).
1992
- timeoutResume?: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[] },
1993
- // The source var ({@link factSourceVar}) of EVERY declared emit of this node — required AND
1994
- // non-required — so the resume-valid flag on BOTH the timeout `__esc` and the producer-contract
1995
- // `__contract` escalations is grown collision-free against all of them, not only the resumed required
1996
- // emit (PR #876 review). Both escalations live in this node's subProcess scope, which republishes
1997
- // every declared emit, so both must dodge the same full target set.
1998
- reservedFlagTargets: readonly string[] = [],
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 ?? [],
1999
2098
  ): string[] {
2000
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);
2001
2113
  const taskExt = [
2002
2114
  " <bpmn:extensionElements>",
2003
2115
  ` <zeebe:taskDefinition ${taskDefAttr} />`,
2004
2116
  ...(taskProps.length > 0 ? [" <zeebe:properties>", ...taskProps, " </zeebe:properties>"] : []),
2005
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>",
2006
2143
  " </bpmn:extensionElements>",
2007
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 ?? [];
2008
2153
  const timeoutEscalation = escalationTaskLines(
2009
2154
  esc,
2010
2155
  nodeId,
2011
2156
  [`${el}_i2`],
2012
2157
  `${el}_i3`,
2013
- escalationContextFeel(
2158
+ `${escalationContextFeel(
2014
2159
  nodeId,
2015
2160
  descriptor,
2016
2161
  "nodeTimeout",
2017
2162
  "; in-flight work may already exist — check for a draft PR or partial state before retrying or reassigning.",
2018
- ),
2163
+ )} + ${feelStr(escalationResolutionHint(nodeEmits, resumeEmits, isAgent))}`,
2019
2164
  {
2020
- // Bind the operator-supplied `value` onto the node's emit-source var so a resumed timeout
2021
- // escalation publishes the SAME `<el>_<fact>` shape a normally-completing node does (#872) —
2022
- // VALIDATED by the post-escalation gate (`validTarget`), so an omitted/malformed value re-parks
2023
- // instead of threading null downstream (PR #876 review). Only when the node owes a required emit
2024
- // — a no-emit node keeps its inert acknowledgement form.
2025
- ...(timeoutResume !== undefined && timeoutResume.emits.length > 0 ? { resume: timeoutResume, validTarget: `${el}_end` } : {}),
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` } : {}),
2026
2169
  displayName: taskName,
2170
+ retryElement: el,
2027
2171
  reservedTargets: reservedFlagTargets,
2028
2172
  },
2029
2173
  );
@@ -2032,6 +2176,7 @@ function serviceBodyLines(
2032
2176
  ` <bpmn:serviceTask id="${el}_task" name="${escapeXml(taskName)}">`,
2033
2177
  ...taskExt,
2034
2178
  ` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
2179
+ ` <bpmn:incoming>${el}_r1</bpmn:incoming>`,
2035
2180
  ` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
2036
2181
  " </bpmn:serviceTask>",
2037
2182
  ` <bpmn:boundaryEvent id="${el}_be" name="Node timed out" attachedToRef="${el}_task">`,
@@ -2040,44 +2185,48 @@ function serviceBodyLines(
2040
2185
  " </bpmn:boundaryEvent>",
2041
2186
  ...timeoutEscalation,
2042
2187
  ];
2043
-
2044
- // When the timeout escalation is RESUMABLE (it grew a validation gate), its return flow `${el}_i3`
2045
- // routes to the GATE (not straight to the end), and the gate's valid branch flows to the end — so a
2046
- // value-less/invalid resume can never bypass the gate (PR #876 review).
2047
- const timeoutResumed = timeoutResume !== undefined && timeoutResume.emits.length > 0;
2048
- const timeoutReturnTarget = timeoutResumed ? `${esc}Vg` : `${el}_end`;
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);
2049
2199
 
2050
2200
  if (contractGate === undefined) {
2051
2201
  return [
2052
2202
  ...head,
2053
- ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming>${timeoutResumed ? `<bpmn:incoming>${esc}Vok</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>`,
2054
2205
  flow(`${el}_i0`, `${el}_start`, `${el}_task`),
2055
2206
  flow(`${el}_i1`, `${el}_task`, `${el}_end`),
2056
2207
  flow(`${el}_i2`, `${el}_be`, esc),
2057
- flow(`${el}_i3`, esc, timeoutReturnTarget),
2208
+ flow(`${el}_i3`, esc, `${esc}Rg`),
2058
2209
  ];
2059
2210
  }
2060
2211
 
2061
2212
  // Producer-contract gate (issue #731): task → gate → (proceed | contract-escalation) → end. When the
2062
2213
  // contract escalation is RESUMABLE (it grew a validation gate), its return flow `${el}_g2` routes to
2063
2214
  // the GATE (not straight to the end), and the gate's valid branch flows to the end (PR #876 review).
2064
- const contractEsc = contractEscalationTaskElement(el);
2065
- const emits = contractGate.requiredEmits;
2066
- const contractResumed = emits.length > 0;
2067
- const contractReturnTarget = contractResumed ? `${contractEsc}Vg` : `${el}_end`;
2068
- const proceedCondition = agentContractProceedCondition(emits);
2215
+ const emits = contractEmits;
2216
+ const proceedCondition = `=${contractMetVar} = true`;
2069
2217
  const contractEscalation = escalationTaskLines(
2070
2218
  contractEsc,
2071
2219
  nodeId,
2072
2220
  [`${el}_g1`],
2073
2221
  `${el}_g2`,
2074
- agentContractContextFeel(nodeId, descriptor, emits),
2222
+ agentContractContextFeel(nodeId, descriptor, emits, nodeEmits),
2075
2223
  // Resumable when the producer owes a required emit: a human/agent supplies the missing fact, which
2076
2224
  // the subProcess output ioMapping then publishes as `<el>_<fact>` (agent emit source = fact name),
2077
2225
  // so the downstream consumer late-binds a real value instead of the null that poisoned it (#731) —
2078
2226
  // VALIDATED by the post-escalation gate (`validTarget`) so an omitted/malformed value re-parks
2079
- // instead of threading null downstream (PR #876 review).
2080
- { ...(contractResumed ? { resume: { kind: "agent" as const, emits }, validTarget: `${el}_end` } : {}), displayName: taskName, reservedTargets: reservedFlagTargets },
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 },
2081
2230
  );
2082
2231
  return [
2083
2232
  ...head,
@@ -2087,24 +2236,189 @@ function serviceBodyLines(
2087
2236
  ` <bpmn:outgoing>${el}_g1</bpmn:outgoing>`,
2088
2237
  " </bpmn:exclusiveGateway>",
2089
2238
  ...contractEscalation,
2090
- ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_g0</bpmn:incoming>${timeoutResumed ? `<bpmn:incoming>${esc}Vok</bpmn:incoming>` : `<bpmn:incoming>${el}_i3</bpmn:incoming>`}${contractResumed ? `<bpmn:incoming>${contractEsc}Vok</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>`,
2091
2241
  flow(`${el}_i0`, `${el}_start`, `${el}_task`),
2092
2242
  flow(`${el}_i1`, `${el}_task`, `${el}_gate`),
2093
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>`,
2094
2244
  ` <bpmn:sequenceFlow id="${el}_g1" name="contract broken" sourceRef="${el}_gate" targetRef="${contractEsc}" />`,
2095
- flow(`${el}_g2`, contractEsc, contractReturnTarget),
2245
+ flow(`${el}_g2`, contractEsc, `${contractEsc}Rg`),
2096
2246
  flow(`${el}_i2`, `${el}_be`, esc),
2097
- flow(`${el}_i3`, esc, timeoutReturnTarget),
2247
+ flow(`${el}_i3`, esc, `${esc}Rg`),
2098
2248
  ];
2099
2249
  }
2100
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`),
2381
+ ];
2382
+ }
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
+
2101
2415
  /** `wait` body: `start → pr.readiness-probe (poll) → ready? → end`, escalating on not-ready or on the
2102
2416
  * `=probeTimeout` engine bound. The probe polls its OWN target, so an unrelated upstream event can
2103
2417
  * never flip it to ready (#274/S2 concurrency-correctness); the `pr` kind (S2) binds `mergedSha`. */
2104
2418
  function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>, displayName: string): string[] {
2105
2419
  const nodeId = node.id;
2106
2420
  const name = escapeXml(displayName);
2107
- const esc = escalationTaskElement(el);
2421
+ const esc = escalationTaskElement(el, "wait");
2108
2422
  const emits = normaliseEmits(node);
2109
2423
  // `onTimeout` routing (#462): `escalate` (default) parks the not-ready-at-boundary token on a
2110
2424
  // human-completable escalation task; `continue` proceeds past the gate as not-ready WITHOUT a human
@@ -2121,7 +2435,12 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
2121
2435
  const continueOnTimeout = trimmedOrEmpty(node.wait?.onTimeout) === "continue";
2122
2436
  // When the escalation is RESUMABLE (it grew a validation gate), its return flow `_i5` routes to the
2123
2437
  // GATE (not straight to the end) and the gate's valid branch flows to the end (PR #876 review).
2124
- const waitResumed = !continueOnTimeout && emits.length > 0;
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;
2125
2444
  // Defect A: read-only probe diagnostics seeded onto the escalation task so the operator/agent can
2126
2445
  // tell a genuine "not published yet" from a transient false-negative — the probe's last detail, the
2127
2446
  // resolved target/match, and a compact summary of the candidate releases the probe observed.
@@ -2209,8 +2528,10 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
2209
2528
  waitEscalationContextFeel(nodeId),
2210
2529
  // A resumed wait-gate escalation is VALIDATED by the post-escalation gate (`validTarget`) so
2211
2530
  // an omitted/malformed operator value re-parks instead of threading null onto the emit the
2212
- // downstream consumer binds (PR #876 review).
2213
- { resume: { kind: node.kind, emits }, diagnosticInputs, displayName, reservedTargets: emits.map((f) => factSourceVar(node.kind, f)), ...(emits.length > 0 ? { validTarget: `${el}_end` } : {}) },
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` } : {}) },
2214
2535
  )),
2215
2536
  // On `continue`, the not-ready-at-boundary branch (`_i4`) proceeds straight to the node end (no
2216
2537
  // human stop, no `_i5` escalation-return flow); on `escalate` it parks on the escalation task,
@@ -2231,33 +2552,168 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
2231
2552
  * + SLA. On completion the form's captured typed value is output (`humanEmitValue`/`humanEmitArtifact`
2232
2553
  * — the subProcess ioMapping then publishes it as the node's fact); on SLA expiry the node records an
2233
2554
  * `escalated` outcome and settles (bounded — the graph cannot silently wedge). Mirrors the standalone
2234
- * `delivery-human.bpmn` shape, reusing the S3 form + emit-var contract (`deliveryHuman.ts`). */
2235
- 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[] {
2236
2570
  const task = humanTaskElement(el);
2237
2571
  const assignee =
2238
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
+ : [];
2239
2684
  return [
2240
2685
  ` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
2241
2686
  ` <bpmn:userTask id="${task}" name="Delivery: human step — ${escapeXml(displayName)}">`,
2242
2687
  " <bpmn:extensionElements>",
2243
- ` <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)} />`,
2244
2694
  " <zeebe:userTask />",
2245
2695
  ` <zeebe:assignmentDefinition candidateGroups="operators" ${attr("assignee", assignee)} />`,
2246
2696
  " <zeebe:ioMapping>",
2247
2697
  ` <zeebe:output ${attr("source", '="completed"')} target="humanOutcome" />`,
2248
- ` <zeebe:output ${attr("source", "=if (is defined(value)) then value else null")} target="humanEmitValue" />`,
2249
- ` <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" />`,
2250
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}" />`] : []),
2251
2705
  " </zeebe:ioMapping>",
2252
2706
  " </bpmn:extensionElements>",
2253
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>`] : []),
2254
2710
  ` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
2255
2711
  " </bpmn:userTask>",
2256
2712
  ` <bpmn:boundaryEvent id="${el}_sla" name="SLA elapsed" attachedToRef="${task}">`,
2257
2713
  ` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
2258
2714
  ` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=escalationSlaTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
2259
2715
  " </bpmn:boundaryEvent>",
2260
- ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming></bpmn:endEvent>`,
2716
+ endEvent,
2261
2717
  ` <bpmn:endEvent id="${el}_escEnd" name="Escalated">`,
2262
2718
  " <bpmn:extensionElements>",
2263
2719
  ` <zeebe:ioMapping><zeebe:input ${attr("source", '="escalated"')} target="humanOutcome" /></zeebe:ioMapping>`,
@@ -2265,8 +2721,10 @@ function humanBodyLines(el: string, displayName: string): string[] {
2265
2721
  ` <bpmn:incoming>${el}_i2</bpmn:incoming>`,
2266
2722
  " </bpmn:endEvent>",
2267
2723
  flow(`${el}_i0`, `${el}_start`, task),
2268
- 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`),
2269
2726
  flow(`${el}_i2`, `${el}_sla`, `${el}_escEnd`),
2727
+ ...completionGate,
2270
2728
  ];
2271
2729
  }
2272
2730
 
@@ -2284,6 +2742,108 @@ function humanBodyLines(el: string, displayName: string): string[] {
2284
2742
  * mapping a naive resume publishes `<el>_<fact> = null`, silently starving the downstream consumer.
2285
2743
  * `opts.diagnosticInputs` seeds read-only probe context (issue #514 Defect A) onto the same task so the
2286
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
+
2287
2847
  function escalationTaskLines(
2288
2848
  esc: string,
2289
2849
  nodeId: string,
@@ -2291,11 +2851,22 @@ function escalationTaskLines(
2291
2851
  outgoing: string,
2292
2852
  contextFeel: string,
2293
2853
  opts?: {
2294
- 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[] };
2295
2861
  diagnosticInputs?: readonly { source: string; target: string }[];
2296
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;
2297
2868
  /** The element the resume-validation gate's VALID branch flows to (the node end). Required iff
2298
- * `resume` carries emits — a resumable escalation never routes straight to its end. */
2869
+ * `resume` carries emits — a resumable escalation's Continue never routes straight to its end. */
2299
2870
  validTarget?: string;
2300
2871
  /** The emit-source var ({@link factSourceVar}) of EVERY declared emit of this node — required AND
2301
2872
  * non-required (a routing-only `when`-guard emit, or a declared-but-unconsumed one) — that shares
@@ -2307,86 +2878,106 @@ function escalationTaskLines(
2307
2878
  ): string[] {
2308
2879
  const resume = opts?.resume;
2309
2880
  const emits = resume?.emits ?? [];
2310
- const emitMode = emits.length > 0 ? "typed" : "none";
2311
- const emitLabel = emits.map((e) => `${e.name} (${e.type})`).join(", ");
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";
2312
2902
  const inputs: string[] = [
2313
2903
  ` <zeebe:input ${attr("source", contextFeel)} target="prompt" />`,
2314
2904
  ` <zeebe:input ${attr("source", `=${feelStr(nodeId)}`)} target="nodeId" />`,
2315
2905
  ` <zeebe:input ${attr("source", `=${feelStr(emitMode)}`)} target="emitMode" />`,
2316
2906
  ];
2317
- if (emits.length > 0) {
2318
- 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
+ );
2319
2911
  }
2320
2912
  for (const di of opts?.diagnosticInputs ?? []) {
2321
2913
  inputs.push(` <zeebe:input ${attr("source", di.source)} target="${di.target}" />`);
2322
2914
  }
2323
- // Defect B + PR #876 review: the operator's captured typed value is VALIDATED on this task's OWN
2324
- // output mapping (reading the form's `value` field), which the pinned engine evaluates reliably —
2325
- // unlike a downstream gateway's ioMapping, whose inputs/outputs are NOT visible downstream (verified
2326
- // engine-native). The required emit's emit-source var is bound from `value` ONLY when it is present
2327
- // and type-valid (`resumeValueCondition`), else null; a validity boolean flags the resume.
2328
- // The post-escalation gateway then routes on the SIMPLE `<resumeValidVar> = true` (a complex FEEL
2329
- // condition on the gateway mis-evaluates — verified), looping an invalid resume back onto a fresh
2330
- // escalation task (fail closed) so it can never thread null/garbage onto the emit the subProcess
2331
- // output ioMapping republishes downstream. The generic escalation form (`GENERIC_HUMAN_FORM`)
2332
- // captures the operator's answer in a single `value` field — it has NO `resolvedArtifact` field — so
2333
- // a single emit resumes from `value`, validated per its fact type (artifact→resolvedArtifact,
2334
- // version→detail, …). Sourcing an artifact from a `resolvedArtifact` form field the form never sets
2335
- // would publish null and make an artifact wait-node escalation non-resumable via the UI.
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, …).
2336
2928
  //
2337
- // MULTI-EMIT (PR #876 review): the generic form's SINGLE `value` cannot supply a distinct value per
2338
- // fact, so a node owing more than one required emit CANNOT be resumed from it. Copying the one
2339
- // `value` into every emit-source var would release duplicate/garbage facts downstream; instead we
2340
- // FAIL CLOSED — bind nothing and hard-set the validity flag to `false`, so the post-escalation gate
2341
- // always loops back (the operator re-parks) and no single value is ever threaded onto several
2342
- // distinct emits. (Per-fact resume would need a per-fact form, which the generic form does not have.)
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.
2343
2933
  const outputs: string[] = [];
2344
- // The resume-valid flag shares this escalation's flat engine scope with EVERY declared emit's
2345
- // source var — not only the single resumed required emit. For a multi-emit node nothing is bound by
2346
- // the gate, and for `wait`/`human` kinds `factSourceVar` is a fixed internal name; but for an
2347
- // `agent`/`connector` a declared emit's source var IS its own fact name, which the user controls.
2348
- // That emit need NOT be the resumed required one: a routing-only `when`-guard emit, or a declared-
2349
- // but-unconsumed emit, is absent from `resume.emits` yet is STILL republished by the subProcess
2350
- // output mapping from its own source var (see the `normaliseEmits(node)` output loop). So a node can
2351
- // legally declare such an emit named EXACTLY the generated flag; left colliding, the escalation would
2352
- // write its boolean validity flag into that emit's var and the subProcess would publish `true`/`false`
2353
- // for it downstream (potentially selecting a guarded branch — PR #876 review). Reserve the source var
2354
- // of every declared emit (callers thread them via `reservedTargets`) AND the resumed required emits,
2355
- // then grow the flag with a deterministic unused `_` suffix until it differs from ALL of them —
2356
- // closing the whole collision class structurally WITHOUT reserving any part of the public fact-name
2357
- // space. `_` keeps it a legal FEEL identifier; the loop terminates because each step lengthens the
2358
- // flag past every fixed-length reserved target.
2359
- const reservedTargets = new Set<string>(opts?.reservedTargets ?? []);
2360
- if (resume !== undefined) for (const f of emits) reservedTargets.add(factSourceVar(resume.kind, f));
2361
- let flagVar = resume !== undefined && emits.length > 0 ? resumeValidVar(esc) : "";
2362
- while (flagVar !== "" && reservedTargets.has(flagVar)) flagVar = `${flagVar}_`;
2363
- if (resume !== undefined && emits.length === 1) {
2364
- const fact = emits[0];
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;
2365
2940
  const target = factSourceVar(resume.kind, fact);
2941
+ const valid = resumeValueCondition(fact, "value");
2366
2942
  outputs.push(
2367
- ` <zeebe:output ${attr("source", `=if ${resumeValueCondition(fact, "value")} then value else null`)} target="${target}" />`,
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}" />`,
2368
2946
  );
2369
2947
  outputs.push(
2370
2948
  // The validity flag the post-escalation gateway routes on — an internal, compiler-generated
2371
2949
  // variable name ({@link resumeValidVar}), made collision-free against the emit-source var above
2372
2950
  // by construction, so it can neither collide with nor be shadowed by a declared emit.
2373
- ` <zeebe:output ${attr("source", `=if ${resumeValueCondition(fact, "value")} then true else false`)} target="${flagVar}" />`,
2951
+ ` <zeebe:output ${attr("source", `=if ${valid} then true else false`)} target="${flagVar}" />`,
2374
2952
  );
2375
- } else if (resume !== undefined && emits.length > 1) {
2953
+ } else if (resume !== undefined && emits.length > 0) {
2376
2954
  outputs.push(
2377
- // Fail closed: more than one required emit cannot be resumed from the generic form's single
2378
- // `value`, so the resume is never valid and the gate always re-parks (see the comment above).
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).
2379
2958
  ` <zeebe:output ${attr("source", "=false")} target="${flagVar}" />`,
2380
2959
  );
2381
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
+ );
2967
+ }
2382
2968
  // The escalation user task shows the node's DESCRIPTIVE display name (issue #778 review) so a
2383
2969
  // timed-out / contract-broken node is legible in the explorer/inbox instead of an opaque bare id;
2384
2970
  // `nodeId` is still threaded as the `nodeId` input above for runtime correlation.
2385
2971
  const escLabel = trimmedOrEmpty(opts?.displayName) || nodeId;
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;
2386
2977
  const task = [
2387
2978
  ` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(escLabel)}">`,
2388
2979
  " <bpmn:extensionElements>",
2389
- ` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
2980
+ ` <zeebe:formDefinition formId="${form}" />`,
2390
2981
  " <zeebe:userTask />",
2391
2982
  ' <zeebe:assignmentDefinition candidateGroups="operators" />',
2392
2983
  " <zeebe:ioMapping>",
@@ -2398,35 +2989,74 @@ function escalationTaskLines(
2398
2989
  ` <bpmn:outgoing>${outgoing}</bpmn:outgoing>`,
2399
2990
  " </bpmn:userTask>",
2400
2991
  ];
2401
- if (resume === undefined || emits.length === 0) return task;
2402
-
2403
- // Resume-validation gate (PR #876 review): a resumable escalation no longer routes straight to the
2404
- // node end. It flows into an exclusive gateway whose VALID branch proceeds to the node end and whose
2405
- // DEFAULT (invalid) branch loops back onto the escalation task — so a blank/malformed `value`
2406
- // re-parks with an explanation instead of releasing a null/invalid fact downstream (fail closed on
2407
- // the default). The gateway is a PLAIN exclusive gateway (NO ioMapping — a gateway's ioMapping is not
2408
- // visible downstream in the pinned engine, verified): the per-emit validation already ran on the
2409
- // escalation task's OWN output mapping (binding the emit-source var + the validity flag), so
2410
- // the gateway only routes on the simple `<resumeValidVar> = true` boolean. For a multi-emit node the
2411
- // flag is hard-`false` (unresumable via the single-value form — see the outputs comment above), so
2412
- // the gate always takes its default (invalid) branch and re-parks.
2413
- const validTarget = opts?.validTarget;
2414
- if (validTarget === undefined) {
2415
- throw new Error(`escalationTaskLines(${esc}): a resumable escalation requires opts.validTarget (the node end its valid resume flows to)`);
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
+ );
2416
3028
  }
2417
- const vg = `${esc}Vg`;
2418
- const vok = `${esc}Vok`;
2419
- const vbad = `${esc}Vbad`;
2420
- return [
2421
- ...task,
2422
- ` <bpmn:exclusiveGateway id="${vg}" name="resume value valid?" default="${vbad}">`,
2423
- ` <bpmn:incoming>${outgoing}</bpmn:incoming>`,
2424
- ` <bpmn:outgoing>${vok}</bpmn:outgoing>`,
2425
- ` <bpmn:outgoing>${vbad}</bpmn:outgoing>`,
2426
- " </bpmn:exclusiveGateway>",
2427
- ` <bpmn:sequenceFlow id="${vok}" name="valid" sourceRef="${vg}" targetRef="${validTarget}"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=${flagVar} = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
2428
- ` <bpmn:sequenceFlow id="${vbad}" name="invalid" sourceRef="${vg}" targetRef="${esc}" />`,
2429
- ];
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];
2430
3060
  }
2431
3061
 
2432
3062
  /** The per-required-emit FEEL condition an escalation resume value must satisfy to bind its
@@ -2474,16 +3104,28 @@ function escalationTaskLines(
2474
3104
  * split differs from edge cases of the canonical parser.
2475
3105
  * If these ever become common, the fix is to route the resume through the canonical host-side coercer
2476
3106
  * (a completion-door redesign) rather than widening these regexes toward fail-OPEN. */
2477
- function resumeValueCondition(fact: DeliveryFact, v: string): string {
2478
- const present = `((is defined(${v})) = true and (${v} != null))`;
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))`;
2479
3116
  const s = `string(${v})`;
2480
3117
  switch (fact.type) {
2481
3118
  case "string":
2482
3119
  return `${present} and trim(${s}) != ""`;
2483
3120
  case "number":
2484
- return `${present} and (matches(trim(${s}), "^-?\\\\d+(\\\\.\\\\d+)?$") = true)`;
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)`;
2485
3124
  case "boolean":
2486
- return `${present} and (trim(${s}) = "true" or trim(${s}) = "false")`;
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")`;
2487
3129
  case "version":
2488
3130
  return `${present} and (matches(trim(${s}), "^v?\\\\d[\\\\w.+-]*$") = true)`;
2489
3131
  case "artifact":