diff --git a/.changeset/resume-signal-chokepoint.md b/.changeset/resume-signal-chokepoint.md new file mode 100644 index 0000000000..244b8deb0b --- /dev/null +++ b/.changeset/resume-signal-chokepoint.md @@ -0,0 +1,51 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-automation": patch +"@objectstack/runtime": patch +--- + +fix(automation): one chokepoint for the resume signal — `output` reopened the hole `inputs` had just closed (#3879) + +#3853 guarded `signal.variables` at the route. That closed one of **two** +equivalent paths into the same variable map and left the other open: +`signal.output` keys are merged under `${run.nodeId}.${key}`, and for a run +parked on a `map` node `run.nodeId` **is** the map node — so + +```jsonc +{ "output": { "$mapItemDone": true, "$mapItemOutput": { "result": "FORGED" } } } +``` + +writes exactly the `.$mapItemDone` the `inputs` guard had refused, +making the map record a result for an item nobody decided. Demonstrated with a +repro, then fixed. + +Scope: the #3853 map gate still held, so a batch whose pending item sits on an +`approval` was refused before any of this — the **approval bypass stayed +closed**. The residual was forging the recorded result of an item on an +*ungated* pause. + +Two escapes with one shape is a design signal, not two bugs, so the fix is +structural rather than a third patch: + +- **`applyResumeSignal` is the one place a resume signal reaches the variable + map.** Both fields are collected into a single write list (already in final, + prefixed form), checked, then applied — a new signal field is covered by + construction rather than by remembering. +- **All-or-nothing**, and checked *before* the suspension is consumed: a + rejected signal applies nothing (not even legitimate keys sent alongside) and + the run stays parked, so the real continuation still lands. +- **The engine owns the rule; the transport maps the verdict.** `resume` returns + `{ success: false, code: 'invalid_signal' }`; the route answers **400**. The + SDK and any future adapter inherit it — implemented in one transport it + protected exactly one transport, and one field of it. +- Engine-built signals (the subflow output mapping, the map item handoff) are + exempt via a module-private symbol. Deliberately *not* + `RESUME_AUTHORITY_SERVICE`: that marker means "the owning service authorized + this decision", and a service still has no business writing engine internals. + +`AutomationResult.code` gains `'invalid_signal'` alongside `'forbidden'` — a +`switch` over it needs a new arm; a plain read does not. + +Nothing changes for authoring: ordinary variables pass, `$` mid-name (`price$`) +and dotted names (`collect.note`) included. Only names the engine reserves — +`$…` or a `.$` segment — are refused. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 2d9f53de66..7a993de963 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -424,11 +424,16 @@ the item rather than the loop: decision is still open. Refused while that item is service-gated; the map moves on when the item completes through its owning service. -Two related rules on the same route: **resume `inputs` may not write the -engine's `$` namespace** (`$runId`, `$record`, `$flowName`, -`.$mapItemDone`, …) — those are the engine's own handoff variables, and -a caller who could set them could forge a map item's recorded result. Ordinary -author-declared variables are unaffected; a reserved name answers **400**. +A second rule on the same seam: **a resume signal may not write the engine's +`$` namespace** (`$runId`, `$record`, `$flowName`, `.$mapItemDone`, …) — +those are the engine's own handoff variables, and a caller who could set them +could forge a map item's recorded result or re-point the run id an `approval` / +`wait` node correlates on. It covers **both** signal fields: `inputs` land under +their plain names, and `output` keys land under the *suspended node's* id — +which for a map-parked run is the map node itself, i.e. the very same reserved +key. A reserved name answers **400**, nothing is applied (not even legitimate +keys sent alongside it), and the run stays parked. Ordinary author variables are +unaffected, `$` mid-name (`price$`) included. Registering a pausing node of your own? Declare `resumeAuthority: 'service'` on its descriptor when the decision to continue belongs to your service rather diff --git a/docs/adr/0019-approval-as-flow-node.md b/docs/adr/0019-approval-as-flow-node.md index c2a7d54a56..bd28228ed9 100644 --- a/docs/adr/0019-approval-as-flow-node.md +++ b/docs/adr/0019-approval-as-flow-node.md @@ -277,3 +277,43 @@ segment) with a 400. Deliberately at the transport and not in the engine: `bubbl writes those keys in-process, and this is the same trust split the gate itself uses — strict at the untrusted boundary, unrestricted for the code that already holds the authority. Refuse rather than silently strip, so a mis-authored screen input fails at the door instead of many nodes downstream. + +> **Half of this was wrong — see the addendum below (#3879).** Guarding `inputs` at the transport left +> `output` untouched, and `output` keys land under `${nodeId}.${key}`, which for a map-parked run is +> the map node itself — the identical reserved key, forgeable through the other field. The *placement* +> argument above was the error: "strict at the untrusted boundary" is right about where the rule +> BINDS, not about where it LIVES. The rule moved into the engine, at the one place a signal touches +> the variable map. + +## Addendum (2026-07-28, #3879) — one chokepoint for the resume signal, because guarding a field at a time failed twice + +The addendum above guarded `signal.variables` **at the route**. That closed one of two equivalent +paths into the same variable map and left the other open: `signal.output` keys are merged under +`${run.nodeId}.${key}`, and for a run parked on a `map` node `run.nodeId` **is** the map node — so +`{ "output": { "$mapItemDone": true, "$mapItemOutput": … } }` writes exactly the +`.$mapItemDone` the `inputs` guard had just refused. Demonstrated, then fixed. + +Note what the map gate (#3853) still bought: a batch whose pending item sits on an `approval` is +refused before any of this, so the **approval bypass stayed closed**. The residual was forging the +recorded result of an item on an *ungated* pause — map-state corruption, not a decision bypass. + +Two escapes with one shape is a design signal, not two bugs. The seam had **three** open-coded writers +into one variable map (`output` prefixed, `variables` bare, and the engine's own map handoff), so +"guard the field that was exploited" was always going to invite the next field. The fix is structural: + +- **`applyResumeSignal` is the one place a resume signal reaches the variable map.** Both fields are + collected into a single write list — already in final, prefixed form — checked, then applied. A new + signal field is covered by construction rather than by remembering. +- **All-or-nothing.** A rejected signal applies nothing, not even legitimate keys sent alongside, and + the check runs *before* the suspension is consumed, so the run stays parked and the real + continuation still lands. +- **The engine owns the rule; the transport maps the verdict.** `resume` returns + `{ success: false, code: 'invalid_signal' }` and the route answers 400. This corrects the placement + argument in the previous addendum: "strict at the untrusted boundary" is right about where a rule + BINDS, not where it LIVES — implemented in the transport it protected exactly one transport and one + field of it, and the SDK, any future adapter, and `output` all sat outside it. +- **Engine-built signals are exempt via a module-private symbol** (`ENGINE_BUILT_SIGNAL`), stamped by + `bubbleToParent` and the subflow output mapping — the only writers that legitimately set the handoff + keys, and unreachable from a transport. Deliberately *not* `RESUME_AUTHORITY_SERVICE`: that marker + answers "the owning service authorized this decision", and a service still has no business writing + engine internals. Two different questions, two different markers. diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index a91a1f2a18..f17e1512b0 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -216,38 +216,33 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // `ApprovalService`, which records the decision and enforces the slate — // on a SYMBOL-keyed marker. Assembling the signal field-wise (never // spreading the body) keeps that unforgeable even if a caller invents - // extra keys; a refused resume comes back `code: 'forbidden'` and is - // answered 403 rather than a 200 carrying `success: false`. + // extra keys. + // + // Two REFUSAL codes come back from the engine and are answered as such + // rather than a 200 carrying `success: false` (which reads as "your + // resume ran and the flow failed"): + // forbidden → 403, the suspension is service-owned (#3801) + // invalid_signal → 400, the signal wrote the engine's `$` variable + // namespace (#3853 follow-up) + // Both are enforced in the ENGINE, at the one place a signal reaches the + // variable map — deliberately not re-implemented here. Guarding a field + // at a time in the transport is what let `output` reopen the hole + // `inputs` had just closed; every transport now inherits one rule. if (parts[1] === 'runs' && parts[2] && parts[3] === 'resume' && m === 'POST') { if (typeof automationService.resume === 'function') { const b = (body && typeof body === 'object') ? body : {}; const inputs = (b.inputs ?? b.variables); const signal: any = {}; - if (inputs && typeof inputs === 'object') { - // #3853: `inputs` land as BARE flow variables, and `$` is the - // engine's own variable namespace (`$runId`, `$record`, - // `$flowName`, `.$mapItemDone`/`$mapItemOutput`/ - // `$mapState`, …). A caller who could write those could forge - // the map node's item handoff — recording a per-item result - // for an approval nobody made — or re-point `$runId`, which is - // how approval/wait nodes correlate external state back to a - // run. Author-declared variables never live in that namespace, - // so refuse rather than silently drop: a screen whose input is - // quietly discarded fails much further downstream. - const reserved = Object.keys(inputs).filter(k => k.startsWith('$') || k.includes('.$')); - if (reserved.length) { - return { handled: true, response: deps.error( - `Resume inputs may not set engine-internal variables (${reserved.join(', ')}) — ` + - `names starting with '$' (or containing '.$') are reserved by the flow engine`, 400) }; - } - signal.variables = inputs; - } + if (inputs && typeof inputs === 'object') signal.variables = inputs; if (b.output && typeof b.output === 'object') signal.output = b.output; if (typeof b.branchLabel === 'string') signal.branchLabel = b.branchLabel; const result = await automationService.resume(parts[2], signal); if (result?.success === false && result.code === 'forbidden') { return { handled: true, response: deps.error(result.error ?? 'Resume forbidden', 403) }; } + if (result?.success === false && result.code === 'invalid_signal') { + return { handled: true, response: deps.error(result.error ?? 'Invalid resume signal', 400) }; + } return { handled: true, response: deps.success(result) }; } return { handled: true, response: deps.error('Resume not supported', 501) }; diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index b997143878..93032bb1d4 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -358,33 +358,34 @@ describe('HttpDispatcher', () => { expect(result.response?.body?.data?.success).toBe(false); }); - // #3853: `inputs` land as BARE flow variables, so a caller who could - // write the engine's `$` namespace could forge the `map` node's item - // handoff (recording a per-item result for an approval nobody made) or - // re-point `$runId`, which is how approval/wait nodes correlate. - it('should refuse resume inputs that write engine-internal variables', async () => { - for (const inputs of [ - { 'signoffs.$mapItemDone': true, 'signoffs.$mapItemOutput': { forged: true } }, - { $runId: 'someone_elses_run' }, - { $record: { id: 'other' } }, - ]) { - const result = await dispatcher.handleAutomation( - 'flow_a/runs/run_1/resume', 'POST', { inputs }, { request: {} }, - ); - expect(result.response?.status).toBe(400); - expect(result.response?.body?.error?.message).toMatch(/reserved by the flow engine/); - } - // Refused at the door — the engine is never asked. - expect(mockAutomationService.resume).not.toHaveBeenCalled(); + // #3853 follow-up: the reserved-name rule lives in the ENGINE, at the one + // place a signal reaches the variable map — the route only maps its + // verdict onto a status. (Guarding one body field at a time here is what + // let `output` reopen the hole `inputs` had just closed.) + it('should answer 400 when the engine rejects the signal as engine-internal', async () => { + mockAutomationService.resume.mockResolvedValue({ + success: false, code: 'invalid_signal', + error: "Resume signal may not set engine-internal variables (signoffs.$mapItemDone) — " + + "names starting with '$' (or containing '.$') are reserved by the flow engine", + }); + const result = await dispatcher.handleAutomation( + 'flow_a/runs/run_1/resume', 'POST', + { output: { $mapItemDone: true } }, { request: {} }, + ); + expect(result.response?.status).toBe(400); + expect(result.response?.body?.error?.message).toMatch(/reserved by the flow engine/); }); - it('should still accept ordinary screen inputs alongside the reserved-name guard', async () => { + // Both body fields reach the engine verbatim — it, not the route, decides. + it('should forward `output` and `inputs` unfiltered for the engine to judge', async () => { await dispatcher.handleAutomation( 'flow_a/runs/run_1/resume', 'POST', - { inputs: { new_assignee: 'ada', 'collect.note': 'hi', price$: 3 } }, { request: {} }, + { inputs: { new_assignee: 'ada', 'collect.note': 'hi', price$: 3 }, output: { decision: 'ok' } }, + { request: {} }, ); expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', { variables: { new_assignee: 'ada', 'collect.note': 'hi', price$: 3 }, + output: { decision: 'ok' }, }); }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 12f0c6ad1a..5127955d1f 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -544,6 +544,83 @@ function isSuspendSignal(err: unknown): err is FlowSuspendSignal { return typeof err === 'object' && err !== null && (err as FlowSuspendSignal).__flowSuspend === true; } +/** + * Marks a {@link ResumeSignal} the ENGINE built for its own continuations — + * the subflow output mapping and the `map` item handoff. Module-private and + * symbol-keyed, so it cannot arrive from a transport (no JSON body produces a + * symbol key) and no other package can mint one. + * + * Distinct from `RESUME_AUTHORITY_SERVICE`, which answers a different question: + * that marker says "the owning service authorized this decision" (#3801) and + * still may not write engine internals; this one says "the engine wrote this + * signal itself", which is the only case that may. + */ +const ENGINE_BUILT_SIGNAL = Symbol('objectstack.automation.resume.engineBuilt'); + +/** Tag a signal the engine constructed for its own continuation. */ +function engineBuilt(signal: ResumeSignal): ResumeSignal { + return Object.assign(signal, { [ENGINE_BUILT_SIGNAL]: true }); +} + +/** + * Variable names the flow engine owns: `$runId`, `$flowName`, `$flowLabel`, + * `$record`, `$error`, `$parentRunId`, `$parentMapNode`, `$parentOutputVariable`, + * and the node-scoped `.$mapState` / `$mapItemDone` / `$mapItemOutput`. + * Authors never write here. + */ +function isEngineVariable(name: string): boolean { + return name.startsWith('$') || name.includes('.$'); +} + +/** + * Fold a resume signal into a run's variable map — **the one place a signal + * reaches those variables** (#3853 follow-up). + * + * It exists as a chokepoint rather than three open-coded loops because the + * shape of this seam is what produced two separate escapes: the map item + * handoff was forgeable through `variables`, and — once that was guarded at the + * route — through `output`, which lands under `${nodeId}.${key}` and so reaches + * the very same `.$mapItemDone`. Guarding one field at a time + * invites the next field. Every caller-supplied write now passes here, and a + * new signal field is checked by construction. + * + * @returns the rejected key names (already in their final, prefixed form). + * Empty ⇒ every write was applied. An engine-built signal + * ({@link ENGINE_BUILT_SIGNAL}) is exempt: `bubbleToParent` legitimately + * writes the handoff keys, and it is not reachable from a transport. + */ +function applyResumeSignal( + variables: Map, + signal: ResumeSignal | undefined, + nodeId: string, +): string[] { + if (!signal) return []; + const trusted = (signal as Record)[ENGINE_BUILT_SIGNAL] === true; + const rejected: string[] = []; + const writes: Array<[string, unknown]> = []; + + // `output` is merged under the suspended node's id, so downstream edges + // branch on it exactly as for a normally-executed node. + for (const [key, value] of Object.entries(signal.output ?? {})) { + writes.push([`${nodeId}.${key}`, value]); + } + // Bare flow variables — a `screen` node's collected inputs land under their + // plain names so downstream `{var}` interpolation / conditions read them + // directly (e.g. `new_assignee` → update_record fields). + for (const [key, value] of Object.entries(signal.variables ?? {})) { + writes.push([key, value]); + } + + for (const [name] of writes) { + if (!trusted && isEngineVariable(name)) rejected.push(name); + } + // Reject as a whole — never a partial application. + if (rejected.length) return rejected; + + for (const [name, value] of writes) variables.set(name, value); + return []; +} + /** * A run paused at a node, awaiting {@link AutomationEngine.resume} (ADR-0019). * @@ -2203,28 +2280,32 @@ export class AutomationEngine implements IAutomationService { } } + // Restore the variable context and fold the signal in — the ONE + // place a resume signal reaches the variable map. Runs BEFORE the + // suspension is consumed, so a rejected signal changes nothing: + // the pause stays live and the legitimate continuation still lands. + const variables = new Map(Object.entries(run.variables)); + const rejected = applyResumeSignal(variables, signal, run.nodeId); + if (rejected.length) { + this.logger.warn( + `[automation] refused resume of run '${runId}': signal writes engine-internal ` + + `variable(s) ${rejected.join(', ')}`, + ); + return { + success: false, + code: 'invalid_signal', + error: + `Resume signal may not set engine-internal variables (${rejected.join(', ')}) — ` + + `names starting with '$' (or containing '.$') are reserved by the flow engine`, + }; + } + // Consume the suspension *before* running downstream work — a run // resumes exactly once per pause, and a duplicate resume after a - // partial restart must not double-run side effects. + // partial restart must not double-run side effects. (Folding the + // signal above is pure in-memory work, not downstream work.) await this.forgetSuspendedRun(runId); - // Restore variable context and apply the resume signal's output as if it - // were the node's output, so downstream edges branch on it. - const variables = new Map(Object.entries(run.variables)); - if (signal?.output) { - for (const [key, value] of Object.entries(signal.output)) { - variables.set(`${run.nodeId}.${key}`, value); - } - } - // Bare flow variables — a `screen` node's collected inputs land under - // their plain names so downstream `{var}` interpolation / conditions - // read them directly (e.g. `new_assignee` → update_record fields). - if (signal?.variables) { - for (const [key, value] of Object.entries(signal.variables)) { - variables.set(key, value); - } - } - const steps = run.steps; const context = run.context; @@ -2350,12 +2431,14 @@ export class AutomationEngine implements IAutomationService { */ private buildSubflowResumeSignal(childContext: AutomationContext | undefined, childOutput: unknown): ResumeSignal { const outVar = (childContext as Record | undefined)?.$parentOutputVariable; - return { + // Engine-built: `outVar` is the author's `config.outputVariable`, so the + // reserved-name check would be a false positive on an oddly-named one. + return engineBuilt({ output: { output: childOutput ?? null }, ...(typeof outVar === 'string' && outVar ? { variables: { [outVar]: childOutput ?? null } } : {}), - }; + }); } /** @@ -2375,7 +2458,9 @@ export class AutomationEngine implements IAutomationService { // and starts the next. A plain subflow child uses the 1:1 mapping. const mapNode = ctx?.$parentMapNode; const sig = typeof mapNode === 'string' && mapNode - ? { variables: { [`${mapNode}.$mapItemOutput`]: output ?? null, [`${mapNode}.$mapItemDone`]: true } } + // Engine-built: these ARE the reserved handoff keys, and this is + // the one writer allowed to set them (#3853 follow-up). + ? engineBuilt({ variables: { [`${mapNode}.$mapItemOutput`]: output ?? null, [`${mapNode}.$mapItemDone`]: true } }) : this.buildSubflowResumeSignal(run.context, output); const parentRes = await this.resumeInternal(parentRunId, sig, false); if (!parentRes.success) { diff --git a/packages/services/service-automation/src/resume-authority-gate.test.ts b/packages/services/service-automation/src/resume-authority-gate.test.ts index a2d35417db..7963028991 100644 --- a/packages/services/service-automation/src/resume-authority-gate.test.ts +++ b/packages/services/service-automation/src/resume-authority-gate.test.ts @@ -350,5 +350,76 @@ describe('resume authorization gate (#3801)', () => { expect(resumed.code).toBeUndefined(); expect(resumed.success).toBe(true); }); + + // ── the signal may not write the engine's own variables ──────────── + // + // The map's item handoff (`.$mapItemDone` / `$mapItemOutput`) is + // how a completed child tells the map to record its result and advance. + // A caller who can write those forges the outcome of an item nobody + // decided. Both signal fields reach the same variable map, and `output` is + // the subtle one: its keys are prefixed with the SUSPENDED NODE's id, which + // for a map-parked parent is the map node — so `output: { $mapItemDone }` + // lands on exactly the reserved key. Guarding one field is not a guard. + + it.each([ + ['variables', { variables: { 'signoffs.$mapItemDone': true, 'signoffs.$mapItemOutput': { result: 'FORGED' } } }], + ['output', { output: { $mapItemDone: true, $mapItemOutput: { result: 'FORGED' } } }], + ])('refuses a signal that forges the map item handoff via `%s`', async (_field, signal) => { + registerBatch(engine, 'open_pause'); // ungated items — the gate lets the parent through + const paused = await launch(engine); + + const refused = await engine.resume(paused.runId!, signal as any); + + expect(refused.code).toBe('invalid_signal'); + expect(refused.error).toMatch(/reserved by the flow engine/); + // Nothing applied, nothing consumed: the map has not advanced and the + // pause is still live. + expect(mapState(engine, paused.runId!)).toEqual({ started: 1, results: [] }); + expect(engine.listSuspendedRuns().some(r => r.runId === paused.runId)).toBe(true); + }); + + it('still lets the engine write the handoff on the legitimate bubble', async () => { + // The one writer allowed to set those keys — proves the guard did not + // break the mechanism it protects. + registerBatch(engine, 'open_pause'); + await launch(engine); + const item1 = engine.listSuspendedRuns().find(r => r.flowName === 'one_item')!; + + await engine.resume(item1.runId, {}); // item completes → bubbles to the map + + const parent = engine.listSuspendedRuns().find(r => r.flowName === 'batch_flow')!; + expect(mapState(engine, parent.runId).started).toBe(2); + expect(mapState(engine, parent.runId).results).toHaveLength(1); + }); + }); + + // ── reserved names outside the map shape ───────────────────────────── + + it('refuses a bare `$runId` rewrite on an ordinary screen resume', async () => { + engine.registerFlow('open_flow', pauseFlow('open_flow', 'open_pause') as never); + const paused = await engine.execute('open_flow'); + + const refused = await engine.resume(paused.runId!, { + variables: { new_assignee: 'ada', $runId: 'someone_elses_run' }, + }); + + expect(refused.code).toBe('invalid_signal'); + // All-or-nothing: the legitimate key was not applied either. + expect(downstream).toEqual([]); + expect(engine.listSuspendedRuns()).toHaveLength(1); + }); + + it('leaves ordinary author variable names alone, `$` mid-name included', async () => { + engine.registerFlow('open_flow', pauseFlow('open_flow', 'open_pause') as never); + const paused = await engine.execute('open_flow'); + + const ok = await engine.resume(paused.runId!, { + variables: { new_assignee: 'ada', 'collect.note': 'hi', price$: 3 }, + output: { decision: 'ok' }, + }); + + expect(ok.success).toBe(true); + expect(ok.code).toBeUndefined(); + expect(downstream).toEqual(['after']); }); }); diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 1a14a8b153..45da54befd 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -166,15 +166,21 @@ export interface AutomationResult { durationMs?: number; /** * Machine-readable failure classification, set alongside `error` when the - * caller must distinguish *why* it failed rather than just report it. + * caller must distinguish *why* it failed rather than just report it — + * without it a refusal is indistinguishable from "no such run". * - * Today the one value is `'forbidden'` — {@link IAutomationService.resume} - * refused because the run is parked on a node whose descriptor declares - * `resumeAuthority: 'service'` (#3801). A transport maps it to 403; without - * it a resume denied on authorization grounds is indistinguishable from - * "no such run". + * - `'forbidden'` — {@link IAutomationService.resume} refused because the + * run is parked on a node whose descriptor declares + * `resumeAuthority: 'service'` (#3801). A transport maps it to **403**. + * - `'invalid_signal'` — the resume signal tried to write variables the + * flow engine reserves for itself (a `$…` name, or one carrying a `.$` + * segment: `$runId`, `.$mapItemDone`, …). A transport maps it to + * **400**. + * + * Both refuse before consuming the suspension: the run stays parked and the + * legitimate continuation still lands. */ - code?: 'forbidden'; + code?: 'forbidden' | 'invalid_signal'; /** * Lifecycle status. `'paused'` means the run suspended at a node (e.g. * an Approval node awaiting a human decision, ADR-0019) and can be