Repository navigation
automation: a map or subflow node drops its child run's refusal, so a refused write deep in a flow reaches the caller as the raw node chain #27601
Description
domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh
carries item 2 of #27487 (the server half: the refusal must cross map / subflow); #27487 keeps item 1 (the console action door reads the refusal the server sends).
Filing gate: ① product defect, user-visible, split from #27487 by the domain:ui seat that dispatched it (the card thread measured both halves before its first edit).
Business reason
Who: any console user who runs a flow-backed action (or a record-triggered / API-triggered flow) whose refused write sits inside a map item or a subflow. What happens today: the refusal is correct, but the caller gets the whole engine chain as the message, e.g. the showcase "Recalculate Estimate" as a member: Node 'recalc_each' failed: map 'recalc_each': item 0 (subflow 'showcase_recalc_task_estimate') failed: Node 'write_estimate' failed: update_record(showcase_task) failed: You do not have permission to perform this action. …. Without the fix the console can never show the plain refusal for these flows, whatever the ui does: there is no structured refusal in the answer to read.
Where (measured on origin/main 4a00b2b7)
packages/services/service-automation/src/builtin/map-node.ts~L219–222:if (!child.success) return { success: false, error: \map '…': item N (subflow '…') failed: ${child.error}` … }— the child run'sinnerRefusal/userMessage` are not carried onto the returned failure.packages/services/service-automation/src/builtin/subflow-node.ts~L169–172: the same forsubflow.packages/services/service-automation/src/inner-refusal.ts(header ofCARRIED_REFUSAL, ~L120–124) records the current behaviour: "A container that re-words a child failure into a NEW returned failure of its own (try_catchwith no catch region,subflow) builds a new error, and the brand does not cross it; the run's result then carries no member". That is the gap: the one-level case already carriesinnerRefusal/userMessageon the 400FLOW_FAILED(endpoint-executor.ts~L590), the nested case does not.
Acceptance
- A refused write inside a
mapitem or asubflowreaches the run's result (and the 400FLOW_FAILEDenvelope) with the child'sinnerRefusal/userMessage, exactly as the one-level case does; theerrortext may stay as it is. - Pinned by an engine test per container (
map,subflow, map → subflow nested) asserting the carried members. - No new refusal vocabulary: the existing carriage (
attachCarriedRefusal) only crosses the two containers.
Reader who acts
The lane triage assigns (the fix lands in packages/services/service-automation). The domain:ui half (#27487 item 1) lands first with a pin on the envelope shape this card will produce.
reach
Public door: the console record header ⋯ action "Recalculate Estimate" on examples/app-showcase as a member (repro on #27487), and POST /api/v1/apps/showcase/recalc (declared type: 'flow' endpoint) answering 400 FLOW_FAILED with no innerRefusal.
Dedupe
Search API is unavailable from this session; read instead: the 500 most recently created issues (open and closed) by title, grep subflow|map node|map '|inner.?refusal|carriedRefusal|refusal.*(flow|node) → 2 hits: #27487 (the parent, ui half) and #27381 (refusal wording a package cannot reach: state_machine holes, joined validation messages, screen-flow successMessage — a different gap). No duplicate.
Links back: #27487
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 29, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: 审批与自动化 | none | P②
Triage: p2 ·
domain:server·area:automation· 2026-10-29T15:14Z⛔ Not a claim.
service-automationis the server lane. The in-flight ui half (Record action menu offers a member an action whose write they are refused (Recalculate Estimate), and the refusal toast is the raw flow-engine trace ("Node 'recalc_each' failed: map … subflow … update_record(showcase_task) failed …") #27487) is inert without this: no structured refusal reaches the caller from amapitem or asubflow. So p2, above the parent's p3.attachCarriedRefusal) crosses the two containers. No new refusal vocabulary. Theinner-refusal.tsheader is updated to match, with a pin per container and a nestedmap→subflowpin.bug,pm:queue.Generated by Claude Code
objectstack-fleet commentedon Oct 29, 2026
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
Claim: PM loop round 3
Session:
session_01L6bhgUCizoYWQVJWjhdR6FAccount:
os-salesBranch:
claude/issue-27601-container-carries-refusalWorktree:
objectstack-issue-27601Domain:
domain:serverSeat:
domain:server#2File surface:
packages/services/service-automation/src/builtin/map-node.ts,packages/services/service-automation/src/builtin/subflow-node.ts,packages/services/service-automation/src/inner-refusal.ts(header and, if needed, the carriage helper) and their tests (stop on breach; explain in the report)Container & model:
S,mode:subagent,model: default (dispatch-gates --tier: no path-derived mandate),effort: mediumClause-②: no
Metadata-protocol: none
Responsibility:
service-automation container nodes (this lane) | attachCarriedRefusal already carries a refusal across other node boundaries | every caller of a flow whose map item or subflow refuses a writeThread-read: 6500582150
Premise: holds (read on main 2026-10-29T20:52Z: both containers build a new failure on a failed child run that drops
innerRefusal/userMessage; theinner-refusal.tsheader documents the gap)Serial constraints cleared: none (no open PR edits these files; #27428 is
plugin.ts).objectstack-fleet commentedon Oct 29, 2026
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
os-dev-report
{
"issue": 27601,
"status": "done",
"branch": "claude/issue-27601-container-carries-refusal",
"pr": "#27740",
"session": "session_01L6bhgUCizoYWQVJWjhdR6F",
"premise_still_valid": true,
"summary": "Measured the premise on 04b1402: map-node.ts and subflow-node.ts each build a new failure on a failed child run that dropped innerRefusal / userMessage, and the inner-refusal.ts header documented the gap. Each failure arm now spreads carriedRefusalOfResult(child), an existing helper, onto its returned failure. The engine's existing attachCarriedRefusal call in dag-traversal.ts brands the throw, so the existing carriage crosses both containers with no new member, no change to attachCarriedRefusal and no spec edit. A map stops at its first failing item, so it carries that item's refusal. A child that failed with no refusal spreads {} (unchanged answer), and the error text is byte-identical. The CARRIED_REFUSAL header now names subflow and map as carriers; try_catch with no catch region stays named as a non-carrier.",
"tests": "Red first: a new describe in src/flow-inner-refusal.test.ts (5 tests through engine.execute, with a real AutomationEngine and the real CRUD/map/subflow executors). Before the fix: 4 failed / 14 passed ('expected undefined to deeply equal { code: RESOURCE_CONFLICT ... }'), control green. After the fix at 3ef74cf: 18/18. Ablation via scripts/ablation-replace.mjs --delete of the spread (anchor x1 to x0 on disk, blob changed): map-node gave 3 failed (map, map first item, nested), restored blob == HEAD f6d0f4838fcf, git diff HEAD empty. subflow-node gave 2 failed (subflow, nested), restored blob == HEAD 25b7805ad1d2, git diff HEAD empty. The tests import src by relative path, so no dist rebuild sits in the resolution path. Package at 25f0fb7 (after merging origin/main): test EXIT=0, Test Files 218 passed (218), Tests 2795 passed (2795).",
"gates": "Local at 25f0fb7, exits captured before any pipe: closure build (service-automation^...) EXIT=0; package build EXIT=0; package test EXIT=0; package typecheck EXIT=0 (check:test-typecheck OK, 0 errors); eslint --no-inline-config packages/services/service-automation EXIT=0. dispatch-gates --commands derived 59 families and all were run: 58 exit 0, 1 exit 3 (pnpm check:dual-build-cjs-loads, PREREQUISITE NOT MET: it needs the full workspace dist, and CI owns it). --ran reconciliation: 59 accounted, 58 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN. The derivation warned the tree was 3 commits behind origin/main, in gate-definition files only (package.json, scripts/checklist-select.mjs, scripts/pm/check-skill-line-ratchet.mjs). CI on PR #27740 head 25f0fb7 at report time: 21 check-runs, 18 in_progress, 3 skipped, 0 failed.",
"line_budget": "180 changed lines (+173 / -7) across 5 files, under the 3000 human-merge threshold. Product code +9 (map-node 5, subflow-node 4), header +14 / -6, tests +140 / -1, changeset 10.",
"deviations": "1) The commit trailer pair is the model-free one AGENTS.md requires (Co-authored-by: Claude), not the harness reminder's model-named line. 2) post-stamped.mjs refuses a body whose first line is not the seat signature, so this comment has the signature on line 1 and os-dev-report alone on line 2 (os-dev.md asks for the marker on line 1). 3) The gate derivation read a tree 3 commits behind origin/main, in gate-definition files only. I did not merge again, per the merge-once rule. 4) The mechanism is carriedRefusalOfResult in the containers plus the unchanged attachCarriedRefusal call in dag-traversal.ts. The containers do not call attachCarriedRefusal directly, because they return results, not throws.",
"files_changed": [
".changeset/27601-container-carries-refusal.md",
"packages/services/service-automation/src/builtin/map-node.ts",
"packages/services/service-automation/src/builtin/subflow-node.ts",
"packages/services/service-automation/src/flow-inner-refusal.test.ts",
"packages/services/service-automation/src/inner-refusal.ts"
],
"dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm",
"mcp_calls": "0",
"api_writes": "2 — POST /repos/objectstack-ai/objectstack/dispatches (relay pr_create opened draft #27740 with no assignees, reviewers or labels); POST /repos//issues/27601/comments via scripts/pm/post-stamped.mjs (this report). The branch went up by git push, which is not a REST write.",
"open_questions": [],
"out_of_scope_findings": [
"class: none (observation, not filed) · try_catch with no catch region (builtin/try-catch-node.ts) re-words a try-region failure into a new returned failure without innerRefusal / userMessage, so a refusal inside an uncaught guarded region reaches the caller with no structured refusal · reach: not measured at a public door in this run · carrier: none · noted in the PR Acceptance notes · dedupe words: try_catch innerRefusal, try region refusal, carriedRefusal try_catch",
"class: none (observation, not filed) · a map item or subflow child that pauses and later fails on resume reaches the parent through the bubble path, not through these failure arms, and whether the refusal crosses there was not measured · carrier: none · noted in the PR Acceptance notes · dedupe words: subflow resume failure refusal, map bubble innerRefusal, failAncestors refusal"
]
}
objectstack-fleet commentedon Oct 29, 2026
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
ACCEPT → PR #27740 · head
25f0fb7f· 2026-10-29T22:30Z · landing sent. Follow-up: #27743 (try_catchwith no catch region; a paused child that fails on resume).