Skip to content

Commit 93f267f

Browse files
os-zhuangclaude
andauthored
fix(automation): one chokepoint for the resume signal — output reopened the hole inputs had just closed (#3879) (#3880)
#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 { "output": { "$mapItemDone": true, "$mapItemOutput": … } } writes exactly the `<mapNodeId>.$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. The seam had three open-coded writers into one variable map (`output` prefixed, `variables` bare, the engine's own map handoff), so "guard the field that was exploited" was always going to invite the next field. Structural fix: - applyResumeSignal is the ONE place a resume signal reaches the variable map. Both fields become a single write list (already in final, prefixed form), checked, then applied — a new signal field is covered by construction. - All-or-nothing, and checked before the suspension is consumed: a rejected signal applies nothing and the run stays parked, so the real continuation still lands. - The engine owns the rule; the transport maps the verdict. resume returns code 'invalid_signal', the route answers 400. The SDK and any future adapter inherit it. This corrects #3853's placement argument: "strict at the untrusted boundary" is right about where a rule BINDS, not where it LIVES. - Engine-built signals (subflow output mapping, map item handoff) are exempt via a module-private symbol — deliberately not RESUME_AUTHORITY_SERVICE, which answers a different question and does not license writing internals. Claude-Session: https://claude.ai/code/session_013gvN32u1EiuvY9uQEMJiMR Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0fc6219 commit 93f267f

8 files changed

Lines changed: 328 additions & 74 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": patch
4+
"@objectstack/runtime": patch
5+
---
6+
7+
fix(automation): one chokepoint for the resume signal — `output` reopened the hole `inputs` had just closed (#3879)
8+
9+
#3853 guarded `signal.variables` at the route. That closed one of **two**
10+
equivalent paths into the same variable map and left the other open:
11+
`signal.output` keys are merged under `${run.nodeId}.${key}`, and for a run
12+
parked on a `map` node `run.nodeId` **is** the map node — so
13+
14+
```jsonc
15+
{ "output": { "$mapItemDone": true, "$mapItemOutput": { "result": "FORGED" } } }
16+
```
17+
18+
writes exactly the `<mapNodeId>.$mapItemDone` the `inputs` guard had refused,
19+
making the map record a result for an item nobody decided. Demonstrated with a
20+
repro, then fixed.
21+
22+
Scope: the #3853 map gate still held, so a batch whose pending item sits on an
23+
`approval` was refused before any of this — the **approval bypass stayed
24+
closed**. The residual was forging the recorded result of an item on an
25+
*ungated* pause.
26+
27+
Two escapes with one shape is a design signal, not two bugs, so the fix is
28+
structural rather than a third patch:
29+
30+
- **`applyResumeSignal` is the one place a resume signal reaches the variable
31+
map.** Both fields are collected into a single write list (already in final,
32+
prefixed form), checked, then applied — a new signal field is covered by
33+
construction rather than by remembering.
34+
- **All-or-nothing**, and checked *before* the suspension is consumed: a
35+
rejected signal applies nothing (not even legitimate keys sent alongside) and
36+
the run stays parked, so the real continuation still lands.
37+
- **The engine owns the rule; the transport maps the verdict.** `resume` returns
38+
`{ success: false, code: 'invalid_signal' }`; the route answers **400**. The
39+
SDK and any future adapter inherit it — implemented in one transport it
40+
protected exactly one transport, and one field of it.
41+
- Engine-built signals (the subflow output mapping, the map item handoff) are
42+
exempt via a module-private symbol. Deliberately *not*
43+
`RESUME_AUTHORITY_SERVICE`: that marker means "the owning service authorized
44+
this decision", and a service still has no business writing engine internals.
45+
46+
`AutomationResult.code` gains `'invalid_signal'` alongside `'forbidden'` — a
47+
`switch` over it needs a new arm; a plain read does not.
48+
49+
Nothing changes for authoring: ordinary variables pass, `$` mid-name (`price$`)
50+
and dotted names (`collect.note`) included. Only names the engine reserves —
51+
`$…` or a `.$` segment — are refused.

content/docs/automation/flows.mdx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -425,11 +425,16 @@ the item rather than the loop:
425425
decision is still open. Refused while that item is service-gated; the map
426426
moves on when the item completes through its owning service.
427427

428-
Two related rules on the same route: **resume `inputs` may not write the
429-
engine's `$` namespace** (`$runId`, `$record`, `$flowName`,
430-
`<nodeId>.$mapItemDone`, …) — those are the engine's own handoff variables, and
431-
a caller who could set them could forge a map item's recorded result. Ordinary
432-
author-declared variables are unaffected; a reserved name answers **400**.
428+
A second rule on the same seam: **a resume signal may not write the engine's
429+
`$` namespace** (`$runId`, `$record`, `$flowName`, `<nodeId>.$mapItemDone`, …) —
430+
those are the engine's own handoff variables, and a caller who could set them
431+
could forge a map item's recorded result or re-point the run id an `approval` /
432+
`wait` node correlates on. It covers **both** signal fields: `inputs` land under
433+
their plain names, and `output` keys land under the *suspended node's* id —
434+
which for a map-parked run is the map node itself, i.e. the very same reserved
435+
key. A reserved name answers **400**, nothing is applied (not even legitimate
436+
keys sent alongside it), and the run stays parked. Ordinary author variables are
437+
unaffected, `$` mid-name (`price$`) included.
433438

434439
Registering a pausing node of your own? Declare `resumeAuthority: 'service'` on
435440
its descriptor when the decision to continue belongs to your service rather

docs/adr/0019-approval-as-flow-node.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,43 @@ segment) with a 400. Deliberately at the transport and not in the engine: `bubbl
277277
writes those keys in-process, and this is the same trust split the gate itself uses — strict at the
278278
untrusted boundary, unrestricted for the code that already holds the authority. Refuse rather than
279279
silently strip, so a mis-authored screen input fails at the door instead of many nodes downstream.
280+
281+
> **Half of this was wrong — see the addendum below (#3879).** Guarding `inputs` at the transport left
282+
> `output` untouched, and `output` keys land under `${nodeId}.${key}`, which for a map-parked run is
283+
> the map node itself — the identical reserved key, forgeable through the other field. The *placement*
284+
> argument above was the error: "strict at the untrusted boundary" is right about where the rule
285+
> BINDS, not about where it LIVES. The rule moved into the engine, at the one place a signal touches
286+
> the variable map.
287+
288+
## Addendum (2026-07-28, #3879) — one chokepoint for the resume signal, because guarding a field at a time failed twice
289+
290+
The addendum above guarded `signal.variables` **at the route**. That closed one of two equivalent
291+
paths into the same variable map and left the other open: `signal.output` keys are merged under
292+
`${run.nodeId}.${key}`, and for a run parked on a `map` node `run.nodeId` **is** the map node — so
293+
`{ "output": { "$mapItemDone": true, "$mapItemOutput": … } }` writes exactly the
294+
`<mapNodeId>.$mapItemDone` the `inputs` guard had just refused. Demonstrated, then fixed.
295+
296+
Note what the map gate (#3853) still bought: a batch whose pending item sits on an `approval` is
297+
refused before any of this, so the **approval bypass stayed closed**. The residual was forging the
298+
recorded result of an item on an *ungated* pause — map-state corruption, not a decision bypass.
299+
300+
Two escapes with one shape is a design signal, not two bugs. The seam had **three** open-coded writers
301+
into one variable map (`output` prefixed, `variables` bare, and the engine's own map handoff), so
302+
"guard the field that was exploited" was always going to invite the next field. The fix is structural:
303+
304+
- **`applyResumeSignal` is the one place a resume signal reaches the variable map.** Both fields are
305+
collected into a single write list — already in final, prefixed form — checked, then applied. A new
306+
signal field is covered by construction rather than by remembering.
307+
- **All-or-nothing.** A rejected signal applies nothing, not even legitimate keys sent alongside, and
308+
the check runs *before* the suspension is consumed, so the run stays parked and the real
309+
continuation still lands.
310+
- **The engine owns the rule; the transport maps the verdict.** `resume` returns
311+
`{ success: false, code: 'invalid_signal' }` and the route answers 400. This corrects the placement
312+
argument in the previous addendum: "strict at the untrusted boundary" is right about where a rule
313+
BINDS, not where it LIVES — implemented in the transport it protected exactly one transport and one
314+
field of it, and the SDK, any future adapter, and `output` all sat outside it.
315+
- **Engine-built signals are exempt via a module-private symbol** (`ENGINE_BUILT_SIGNAL`), stamped by
316+
`bubbleToParent` and the subflow output mapping — the only writers that legitimately set the handoff
317+
keys, and unreachable from a transport. Deliberately *not* `RESUME_AUTHORITY_SERVICE`: that marker
318+
answers "the owning service authorized this decision", and a service still has no business writing
319+
engine internals. Two different questions, two different markers.

packages/runtime/src/domains/automation.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -216,38 +216,33 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
216216
// `ApprovalService`, which records the decision and enforces the slate —
217217
// on a SYMBOL-keyed marker. Assembling the signal field-wise (never
218218
// spreading the body) keeps that unforgeable even if a caller invents
219-
// extra keys; a refused resume comes back `code: 'forbidden'` and is
220-
// answered 403 rather than a 200 carrying `success: false`.
219+
// extra keys.
220+
//
221+
// Two REFUSAL codes come back from the engine and are answered as such
222+
// rather than a 200 carrying `success: false` (which reads as "your
223+
// resume ran and the flow failed"):
224+
// forbidden → 403, the suspension is service-owned (#3801)
225+
// invalid_signal → 400, the signal wrote the engine's `$` variable
226+
// namespace (#3853 follow-up)
227+
// Both are enforced in the ENGINE, at the one place a signal reaches the
228+
// variable map — deliberately not re-implemented here. Guarding a field
229+
// at a time in the transport is what let `output` reopen the hole
230+
// `inputs` had just closed; every transport now inherits one rule.
221231
if (parts[1] === 'runs' && parts[2] && parts[3] === 'resume' && m === 'POST') {
222232
if (typeof automationService.resume === 'function') {
223233
const b = (body && typeof body === 'object') ? body : {};
224234
const inputs = (b.inputs ?? b.variables);
225235
const signal: any = {};
226-
if (inputs && typeof inputs === 'object') {
227-
// #3853: `inputs` land as BARE flow variables, and `$` is the
228-
// engine's own variable namespace (`$runId`, `$record`,
229-
// `$flowName`, `<nodeId>.$mapItemDone`/`$mapItemOutput`/
230-
// `$mapState`, …). A caller who could write those could forge
231-
// the map node's item handoff — recording a per-item result
232-
// for an approval nobody made — or re-point `$runId`, which is
233-
// how approval/wait nodes correlate external state back to a
234-
// run. Author-declared variables never live in that namespace,
235-
// so refuse rather than silently drop: a screen whose input is
236-
// quietly discarded fails much further downstream.
237-
const reserved = Object.keys(inputs).filter(k => k.startsWith('$') || k.includes('.$'));
238-
if (reserved.length) {
239-
return { handled: true, response: deps.error(
240-
`Resume inputs may not set engine-internal variables (${reserved.join(', ')}) — ` +
241-
`names starting with '$' (or containing '.$') are reserved by the flow engine`, 400) };
242-
}
243-
signal.variables = inputs;
244-
}
236+
if (inputs && typeof inputs === 'object') signal.variables = inputs;
245237
if (b.output && typeof b.output === 'object') signal.output = b.output;
246238
if (typeof b.branchLabel === 'string') signal.branchLabel = b.branchLabel;
247239
const result = await automationService.resume(parts[2], signal);
248240
if (result?.success === false && result.code === 'forbidden') {
249241
return { handled: true, response: deps.error(result.error ?? 'Resume forbidden', 403) };
250242
}
243+
if (result?.success === false && result.code === 'invalid_signal') {
244+
return { handled: true, response: deps.error(result.error ?? 'Invalid resume signal', 400) };
245+
}
251246
return { handled: true, response: deps.success(result) };
252247
}
253248
return { handled: true, response: deps.error('Resume not supported', 501) };

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -358,33 +358,34 @@ describe('HttpDispatcher', () => {
358358
expect(result.response?.body?.data?.success).toBe(false);
359359
});
360360

361-
// #3853: `inputs` land as BARE flow variables, so a caller who could
362-
// write the engine's `$` namespace could forge the `map` node's item
363-
// handoff (recording a per-item result for an approval nobody made) or
364-
// re-point `$runId`, which is how approval/wait nodes correlate.
365-
it('should refuse resume inputs that write engine-internal variables', async () => {
366-
for (const inputs of [
367-
{ 'signoffs.$mapItemDone': true, 'signoffs.$mapItemOutput': { forged: true } },
368-
{ $runId: 'someone_elses_run' },
369-
{ $record: { id: 'other' } },
370-
]) {
371-
const result = await dispatcher.handleAutomation(
372-
'flow_a/runs/run_1/resume', 'POST', { inputs }, { request: {} },
373-
);
374-
expect(result.response?.status).toBe(400);
375-
expect(result.response?.body?.error?.message).toMatch(/reserved by the flow engine/);
376-
}
377-
// Refused at the door — the engine is never asked.
378-
expect(mockAutomationService.resume).not.toHaveBeenCalled();
361+
// #3853 follow-up: the reserved-name rule lives in the ENGINE, at the one
362+
// place a signal reaches the variable map — the route only maps its
363+
// verdict onto a status. (Guarding one body field at a time here is what
364+
// let `output` reopen the hole `inputs` had just closed.)
365+
it('should answer 400 when the engine rejects the signal as engine-internal', async () => {
366+
mockAutomationService.resume.mockResolvedValue({
367+
success: false, code: 'invalid_signal',
368+
error: "Resume signal may not set engine-internal variables (signoffs.$mapItemDone) — " +
369+
"names starting with '$' (or containing '.$') are reserved by the flow engine",
370+
});
371+
const result = await dispatcher.handleAutomation(
372+
'flow_a/runs/run_1/resume', 'POST',
373+
{ output: { $mapItemDone: true } }, { request: {} },
374+
);
375+
expect(result.response?.status).toBe(400);
376+
expect(result.response?.body?.error?.message).toMatch(/reserved by the flow engine/);
379377
});
380378

381-
it('should still accept ordinary screen inputs alongside the reserved-name guard', async () => {
379+
// Both body fields reach the engine verbatim — it, not the route, decides.
380+
it('should forward `output` and `inputs` unfiltered for the engine to judge', async () => {
382381
await dispatcher.handleAutomation(
383382
'flow_a/runs/run_1/resume', 'POST',
384-
{ inputs: { new_assignee: 'ada', 'collect.note': 'hi', price$: 3 } }, { request: {} },
383+
{ inputs: { new_assignee: 'ada', 'collect.note': 'hi', price$: 3 }, output: { decision: 'ok' } },
384+
{ request: {} },
385385
);
386386
expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', {
387387
variables: { new_assignee: 'ada', 'collect.note': 'hi', price$: 3 },
388+
output: { decision: 'ok' },
388389
});
389390
});
390391

0 commit comments

Comments
 (0)