Skip to content

Commit d66cbd6

Browse files
committed
docs(service-automation): scope the refusal claim to the synchronous path, and caveat the refuse/suspend precedence
Prose only -- zero code lines change, and the engine.ts diff is entirely comment-continuation lines. The changeset headline claimed the fix "stops its parent, in subflow and in map alike" without qualification, and that becomes the release note. It is not true on the RESUMED leg, which this change does not touch: a child that durably pauses first and refuses only when resumed reaches its parent through the resume machinery, which reads the child's outcome at seams that do not consult `status: 'refused'`. Neither seam is a regression here -- both pre-date this change -- but the resumed leg is the one a screen flow actually takes, so the note now names the synchronous `engine.execute` return path and carries an explicit scope paragraph. The `refuse` docblock said a result carrying both `refuse` and `suspend` "REFUSES", with no caveat. The #6667 undeclared-suspension guard runs ahead of that and reads `suspend` alone, so a node type whose descriptor does not declare `supportsPause: true` has its result replaced by a guard refusal and the run ends `failed` -- the refusal is never read. That is the right end for a declaration defect, so the ordering is untouched and only the docblock gained the caveat. Claude-Session: https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP Co-authored-by: Claude <noreply@anthropic.com>
1 parent af0edd8 commit d66cbd6

2 files changed

Lines changed: 16 additions & 2 deletions

File tree

‎.changeset/18110-subflow-map-refused-rollup.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22
"@objectstack/service-automation": minor
33
---
44

5-
fix(service-automation): a child run that REFUSES stops its parent, in `subflow` and in `map` alike (#18110, #18555)
5+
fix(service-automation): on the synchronous path, a child run that REFUSES stops its parent, in `subflow` and in `map` alike (#18110, #18555)
66

77
**Clause-②: yes (widening)** — `NodeExecutionResult` is barrel-exported from this package's single entry point, and it gains two new optional members. Nothing previously accepted is refused and nothing is retired, so this is a widening of the published executor contract, not a narrowing. Contract-review tier.
88

9-
A child flow ending on an `end` node that declares `outcome: 'refused'` used to roll up to its parent as an ordinary success. `subflow-node.ts` branched only on `child.status === 'paused'` and `!child.success`; a refused child is neither (`{ success: true, status: 'refused' }` — *a refusal is a successful evaluation that says no*), so it fell through the success exit. The parent walked the node's out-edges, recorded `completed` and fired its **own** `successMessage` over the child's refusal — the author got the exact opposite of what they wrote, fail-open. `map-node.ts` had the identical branch set and the identical hole: a refusing row let every row after it through.
9+
A child flow that runs to completion in one go and ends on an `end` node declaring `outcome: 'refused'` used to roll up to its parent as an ordinary success. `subflow-node.ts` branched only on `child.status === 'paused'` and `!child.success`; a refused child is neither (`{ success: true, status: 'refused' }` — *a refusal is a successful evaluation that says no*), so it fell through the success exit. The parent walked the node's out-edges, recorded `completed` and fired its **own** `successMessage` over the child's refusal — the author got the exact opposite of what they wrote, fail-open. `map-node.ts` had the identical branch set and the identical hole: a refusing row let every row after it through.
1010

1111
- **New on `NodeExecutionResult`: `refuse?: boolean` and `refusalMessage?: string`.** The executor-facing half of the unwinding protocol `suspend?: boolean` already uses. A node that sets `refuse` terminates its run as `refused` — a terminal status this package has published since #15788, so **no new status value** and nothing authorable changes.
1212
- **`subflow` and `map` both set it** when their child run returns `status: 'refused'`. One channel, two call sites.
1313
- **The child's `selected` / `acted` / `unmeasuredEffect` rollup (#4354) survives the refusal**, because the engine throws the refusal signal from the same position it throws the suspend signal: after the node's success step is pushed, after its `childSteps` are folded and after its output is written back. A child that refused really can have written rows before it said no.
1414
- ⛔ **A refusal is still not a failure.** It does not consume retry budget, is not routable by a `fault` edge, and is not counted in `nodes[].failures`.
1515
- **Region-boundary diagnostic, text only**: the message a structured region raises when a refusal tries to cross it now names whichever node carried the refusal, instead of asserting it was an `end` node — which, for a refusing `subflow`/`map` inside a region, sent the author looking for a node that was not in their region. Region **semantics** are unchanged.
1616

17+
**Scope — the RESUMED leg is not covered.** This fixes the path where the child run finishes inside the parent's own `engine.execute` call and its outcome is read from that return value. A child that durably PAUSES first — a nested `approval` / `screen` / `wait` — and only refuses when it is later resumed still reaches its parent through the resume machinery, which reads the child's outcome at different seams and does not consult `status: 'refused'` at any of them. Both of those seams pre-date this change and neither is a regression of it, but neither is closed by it either, and the resumed leg is the one a screen flow actually takes. A follow-up card covers it.
18+
1719
For third-party node executors this is additive: an executor that never sets `refuse` behaves exactly as before.

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,18 @@ export interface NodeExecutionResult {
427427
* to close. No first-party executor sets both (`subflow` / `map` read one
428428
* child status); one that does has declared a contradiction.
429429
*
430+
* ⚠️ With ONE exception, and it is FAIL-CLOSED. The #6667
431+
* undeclared-suspension guard runs ahead of all of this and reads
432+
* {@link NodeExecutionResult.suspend} alone: when the node type resolves to
433+
* an action descriptor that does not declare `supportsPause: true`, that
434+
* guard REPLACES the whole result with a guard refusal, the failure arm
435+
* answers it, and `refuse` is never read at all — the run ends `failed`,
436+
* not `refused`. That is the correct end for a declaration defect (⛔ no
437+
* `fault` edge may route it, and re-running the flow unchanged can never
438+
* fix it), so ⛔ do not reorder the guard to let this member through. The
439+
* paragraph above describes the case the guard has nothing to say about:
440+
* a type with no descriptor, or one that declares the pause it uses.
441+
*
430442
* Set today by `subflow` (#18110) and `map` (#18555) when their child run
431443
* returned `status: 'refused'`: a refusal an author wrote inside a child
432444
* flow must not roll up to the parent as an ordinary success.

0 commit comments

Comments
 (0)