fix(service-automation): on the synchronous path, a child run that refuses stops its parent, in subflow and in map alike - #18706
Conversation
…stead of rolling up as a success
`subflow-node.ts` branched only on `child.status === 'paused'` and
`!child.success`. A refused child is neither -- `finishRefusedRun` answers
`{ success: true, status: 'refused' }`, because a refusal is a successful
evaluation that says no -- so it fell through the ordinary success exit: the
parent walked the node's out-edges, recorded `completed` and fired its own
`successMessage` over the child's refusal. Fail-open, and finishing green.
Adds the executor-facing channel the fix needs, beside the one `suspend`
already uses:
- `NodeExecutionResult.refuse` / `.refusalMessage` -- a published widening of
the barrel-exported executor contract, additive for executors that never set
it.
- `executeNode` throws `FlowRefusalSignal` from the position the suspend signal
is thrown: after the success step is pushed, after the `childSteps` fold and
after output write-back, so the child's `selected` / `acted` /
`unmeasuredEffect` rollup survives the refusal unwind.
- The region-boundary diagnostic names whichever node carried the refusal
rather than asserting it was an `end` node. Text only; region semantics are
untouched.
Claude-Session: https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP
Co-authored-by: Claude <noreply@anthropic.com>
… parent too The identical branch set as `subflow-node.ts` and the identical missing arm: `child.status === 'paused'` and `!child.success`, no `refused` arm. A refusing row's output was pushed into `state.results`, the batch carried on to the next row, and the parent recorded `completed` -- the worked "approve each row" shape answering no on one row and approving every row after it. Second call site of the one channel added with the subflow arm: the refusal carries the batch's accumulated totals plus the refusing item's own, because a child that refused did not fail and nothing counts its work twice. The progress state is deliberately neither advanced nor deleted -- the run is terminating and `started` is the resume program counter. Claude-Session: https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP Co-authored-by: Claude <noreply@anthropic.com>
…y are about Without the `status === 'refused'` assertion in the same test, the totals are equally true of the unfixed engine, which rolled the same metrics up and then carried on. Found by ablating each arm: the rollup legs stayed green. Claude-Session: https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3219788dfa7abebb4aca86e3383998bec341f7ca && git checkout 3219788dfa7abebb4aca86e3383998bec341f7ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f6189a43f9ba09516f142fd7aca8ae15583d6c9d 62a1c19d1f3be5b4bef9de1b1d3a58300ff436fd && git checkout -B drift-repro f6189a43f9ba09516f142fd7aca8ae15583d6c9d && git merge --no-ff 62a1c19d1f3be5b4bef9de1b1d3a58300ff436fd
node scripts/docs-audit/affected-docs.mjs --json f6189a43f9ba09516f142fd7aca8ae15583d6c9d
|
Clause-② contract review — PASS, at tier, tier confirmed from the transcriptRecorded by the 🔬 Tier verification — the reviewer could not confirm its own tier, and was right to say so⭐ The reviewer opened its report by refusing to claim at-tier status: Verified by this seat against the reviewer's own transcript, ⛔ not against its self-report:
Verdict: PASS for clause-② — ⛔ no blocking defectsThe reviewer read the diff itself (merge-base
Advisories — disposition
⛔ Not enqueued yet
Generated by Claude Code |
…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>
subflow and in map alike
… in the changeset scope note The scope paragraph pointed at an unnumbered follow-up because the card did not exist when it was written. It does now: #18714, the resumed-leg fail-open and the up-bubble path that strands the parent `paused`. One line, one token. No rewording, no behaviour change. Claude-Session: https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP Co-authored-by: Claude <noreply@anthropic.com>
Contract review — clause ②, PASSReviewed head: Reviewed-by: Written at 2026-09-17T16:59Z. ⛔ What I got wrong, and what the guard caughtI cleared
The review, on THIS headThe at-tier review was performed on head ⇒ the intervening pushes are prose only — a narrowed changeset headline plus a scope paragraph, the Verdict basis (full per-item findings in
|
Contract reviewServed-tier: 148/148 ① Derived judgmentsClause ② is yes:
② Semver levelminor, on ③ Boundary flags
Implemented-by: VERDICT: PASS Written at 2026-09-17T17:00Z. The Generated by Claude Code |
Part of #18110
Part of #18555
⛔ Deliberately
Part of, notFixes, for both: the ruling on #18110 (director batch #145 item 4, letter A, maintainer 「同意,其他也同意」) states that the lane seat closes both cards at landing, after verifying the two arms independently.The defect
A child flow that ends on an
endnode declaringoutcome: 'refused'rolled up to its parent as an ordinary success.subflow-node.tsbranched only onchild.status === 'paused'and!child.success. A refused child is neither —finishRefusedRunanswers{ success: true, status: 'refused' }, because a refusal is a successful evaluation that says no — so it fell straight through the ordinary success exit. The parent walked the node's out-edges, recordedcompleted, and fired its ownsuccessMessageover the child's refusal.map-node.tshad the line-for-line identical branch set and the identical missing arm (#18555): a refusing row's output went intostate.resultsand every row after it was processed anyway.This is fail-open in the direction nobody notices. A refusing
endis most often a gate — an approval, an eligibility check, a precondition — and a gate whose "no" lets the run through finishes green.The change — one channel, two call sites
NodeExecutionResultrefuse?: booleanandrefusalMessage?: string, beside thesuspend?: booleanthat already exists for the pause half of the same unwinding protocol.executeNodenew FlowRefusalSignal(node.id, result.refusalMessage)at the position the suspend signal is thrown — after the success step is pushed, after thechildStepsfold, after output write-back.subflow-node.ts/map-node.tsrefusedarm that sets it.The throw position is the design, not a convenience: it is what keeps the child's
selected/acted/unmeasuredEffectrollup (#4354) in the run log and therefore in the run summary. A refusing child really can have written rows before it said no, and an unwind that began any earlier would drop exactly those counts. This is the property option B was rejected for losing, and it is pinned in both test files.⛔ A refusal is still not a failure: it does not consume retry budget, is not routable by a
faultedge, and is not counted innodes[].failures.What this change deliberately does not do
packages/specuntouched, no new status value (refusedhas been a published member ofTERMINAL_RUN_STATUSESsince service-automation: honouroutcome: 'refused'on the flowendnode — a terminalrefusedrun status (distinct fromfailed) with the interpolated message persisted on the run (lane 2 of the #14945 ruling 2′) #15788), no authorable edge semantics.endINSIDE a structured region propagate out and terminate the run, or stay a loud refusal at the region boundary? #18112's option B is not implemented and no container is taught to rethrow. Only the diagnostic's wording changed, per [Decision] service-automation: should a refusingendINSIDE a structured region propagate out and terminate the run, or stay a loud refusal at the region boundary? #18112's ruling, which assigns the runtime text to this PR and the authoring-time half to service-automation: a PAUSINGmapinside a contained region leaves its progress state behind — later loop iterations skip items and the exhausted map returnssuccesshaving run nothing #15646 / PR feat(spec)!: a structured region body refuses a pause-capable node and an 'end' node #18688.Clause-②: yes (widening)NodeExecutionResultis barrel-exported from this package's single entry point (src/index.ts:9), so two new optional members are a widening of the published executor contract. Changeset:@objectstack/service-automationminor. Contract review is owed on the review, per the ruling. Additive for third parties: an executor that never setsrefusebehaves exactly as before.Premise re-verification (every position re-taken, ⛔ none inherited)
engine.tsmoved the same day the ruling was written (99fcb4a, 2026-09-17T11:57:43Z), so every line number handed to me was stale. Re-taken withgit show origin/main:PATHat1bc22b3:NodeExecutionResult:332:332TERMINAL_RUN_STATUSES:1365:1365refusedalready publishednew FlowRefusalSignal:9319:9352node.type === 'end':9611:9648childStepsfold and output write-back — the ruling's prescribed position exists as described:9956:9991NodeExecutionResultbarrel-exportedsrc/index.ts:9yesholdsmap-node.tsbranch set:191/:207,refused0 hitschild= 31 hitsFlowRefusalSignalis still not exported and this change does not export it — callers still seestatus: 'refused'.Verification
src/builtin/subflow-refused-rollup.test.ts— 6 testssrc/builtin/map-refused-rollup.test.ts— 7 testsHEADwithgit diff HEADempty:subflowarm ⇒ 5 failed insubflow-refused-rollup.test.ts,map-refused-rollup.test.tsentirely green;maparm ⇒ 6 failed inmap-refused-rollup.test.ts,subflow-refused-rollup.test.tsentirely green.#4354 rolluplegs were originally green under ablation, because the totals are equally true of the unfixed engine, which rolled the same metrics up and then carried on. They now assertstatus === 'refused'in the same test.successMessage, walks its out-edges, and rolls up identical totals — on both thesubflowand themappath.pnpm --filter @objectstack/service-automation test— 138 files / 1652 tests passed, re-run after mergingorigin/main.pnpm --filter @objectstack/service-automation typecheck— exit 0, test layer included (0 files / 0 errors in the debt ledger).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths). 60 families derived, re-derived after the merge with zero delta. Reconciled with--ran: 60 accounted for, 58 run green, 2 NOT MEASURED.pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debtboth exited 3 —PREREQUISITE NOT MET, each printing "this is NOT a pass: nothing was measured". Both require a whole-repo build; that is CI's run, not this PR's. ⛔ Not recorded as a failed measurement.node scripts/check-plugin-teardown-shape.mjs --self-testfirst exited 3 because its positive control is pinned to a commit outside this shallow checkout; aftergit fetch --deepenit exits 0 with 48 cases.pnpm lint(eslint . --no-inline-config) — full repo, exit 0. ⛔ No narrowing claimed; the whole scan ran.Acceptance notes — found in passing, ⛔ not fixed here
resumeInternalreadschildRes.status === 'paused', then failure, then treats everything else as "child completed" — a refused child (success: true) falls through there exactly as it used to here. And on the up-bubble path the refusal arm returnsfinishRefusedRunfrom thecatchbeforebubbleToParentis reached on the completion path, so a child that refuses after a pause never wakes its parent at all. Read from source, ⛔ not driven. This is a separate mechanism (resume / bubble machinery), outside the ruling's prescription, and it is reported for the seat to file rather than ridden in. Dedupe words:paused child refused bubbleToParent·resumeInternal childRes status refused·subflow delegation refused child·map re-entry mapItemDone refused·parent stranded refused child.refusedrun status ("the flow reached anendnode declaringoutcome: 'refused'") now under-describes its producers. It is written inpackages/services/service-automation/src/sys-automation-run.object.ts:178and, identically, inpackages/spec(src/automation/execution.zod.ts,src/contracts/automation-service.ts), with a pin test and a generated reference page downstream. Correcting it needs apackages/specedit, which this card's ruling puts out of bounds (clause 5: that routes to thedomain:specseat). Successor: thedomain:specseat already working this boundary on service-automation: a PAUSINGmapinside a contained region leaves its progress state behind — later loop iterations skip items and the exhausted map returnssuccesshaving run nothing #15646 / PR feat(spec)!: a structured region body refuses a pause-capable node and an 'end' node #18688.endnode" attributions inside the files this PR already edits were generalised in place, because this change is what makes them false: theFlowRefusalSignaldocblock,finishRefusedRun's header, and its durabilityerrorlog line. Comments and one log string; no gate reads them.Generated by Claude Code