Skip to content

fix(automation): a map item or subflow child carries its refusal to the flow's caller - #27740

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-27601-container-carries-refusal
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-27601-container-carries-refusal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

Requested by Sales · project thread

Fixes #27601

Clause-②: no
Metadata-protocol: none
Skills-docs: none — no authorable metadata changes; this is runtime carriage of members the spec already declares

维护者速读

  • 改了什么:map 的失败子项和 subflow 的失败子流程,现在把子运行的结构化拒绝(innerRefusal 与 userMessage)带到父运行结果上,嵌套多层也能到顶。
  • 为什么改:成员没有身份时点「Recalculate Estimate」,看到的是整串引擎报错链;服务端没有结构化拒绝可读,界面那半(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)再怎么改也显示不出简洁提示。
  • 风险与回滚:很低。只在两处失败分支各加一行展开,错误文字一字不变;子流程没遇到拒绝时展开的是空对象,行为不变。回滚即还原这一个 commit。
  • 先看:packages/services/service-automation/src/builtin/map-node.ts(失败分支的展开)、builtin/subflow-node.ts(同一处)、src/inner-refusal.ts(头注释改为两种容器都是承载者)。
  • 两分钟验证:pnpm --filter @objectstack/service-automation exec vitest run src/flow-inner-refusal.test.ts,预期 18 个测试全绿,其中新增 5 个(map、map 首个失败项、subflow、map→subflow 嵌套、无拒绝对照)。
  • 需要你做的:无,席位落地。

What changes

Both containers build a NEW failure when their child run fails, and that new failure dropped the child run's innerRefusal / userMessage. Each failure arm now spreads carriedRefusalOfResult(child) (an existing helper in inner-refusal.ts) onto the result it returns. The engine's existing attachCarriedRefusal call in dag-traversal.ts then brands the throw exactly as it does for a data node's failure, and the terminal exit reads it with readCarriedRefusal. So the existing carriage crosses the two containers: no new refusal vocabulary, no new member, no change to attachCarriedRefusal, no packages/spec edit.

  • map with several failing items: the batch returns on its first failing item (unchanged behaviour), so it carries THAT item's refusal. That matches how the other carriers choose: one failure, the one that ended the run.
  • Child failed with no refusal: carriedRefusalOfResult returns {}, so nothing is added (unchanged answer, pinned by the control).
  • error text: byte-identical; the new tests pin the full chain.
  • inner-refusal.ts header: the CARRIED_REFUSAL paragraph now names subflow and map as carriers and says what a map carries. try_catch with no catch region is still named as a non-carrier.

Tests

Added to src/flow-inner-refusal.test.ts (the harness the existing inner-refusal pins use: a real AutomationEngine, the real CRUD / map / subflow executors, a data engine throwing the platform's real error classes), all through engine.execute:

  1. map: a refused write in a map item reaches the run as innerRefusal { code: 'RESOURCE_CONFLICT', message } plus userMessage, and the error chain is unchanged.
  2. map: the batch stops at the first failing item and carries that item's refusal (VALIDATION_FAILED, one write call).
  3. subflow: the child's refusal reaches the parent run (code plus userMessage).
  4. map → subflow nested: a PERMISSION_DENIED two containers deep reaches the top run.
  5. Control: a child failing with a fault (no declared answer) leaves both containers carrying neither member, with the same error text as before.
Red before the fix (3ef74cf minus the fix lines)
× map: the failing item's refusal reaches the run — code and userMessage — and the text keeps its chain
× map: the batch stops at the first failing item, so the run carries THAT item's refusal
× subflow: the child's refusal reaches the parent run — code and userMessage
× map → subflow nested: the refusal two containers deep reaches the top run
AssertionError: expected undefined to deeply equal { code: 'RESOURCE_CONFLICT', …(1) }
Tests  4 failed | 14 passed (18)
Ablation through scripts/ablation-replace.mjs (fix committed first, at 3ef74cf)

The tests import the executors from src/ by relative path, so no dist/ rebuild is in the resolution path.

### ABLATE map-node  (anchor "...carriedRefusalOfResult(child),"  x1 -> x0, blob f6d0f483 -> 30ca6486)
× map: the failing item's refusal reaches the run …
× map: the batch stops at the first failing item …
× map → subflow nested: the refusal two containers deep reaches the top run
Tests  3 failed | 15 passed (18)
ok restored: blob == HEAD (f6d0f4838fcf) and `git diff HEAD` is empty

### ABLATE subflow-node  (anchor x1 -> x0, blob 25b7805a -> 40e8b4b8)
× subflow: the child's refusal reaches the parent run …
× map → subflow nested: the refusal two containers deep reaches the top run
Tests  2 failed | 16 passed (18)
ok restored: blob == HEAD (25b7805ad1d2) and `git diff HEAD` is empty

The control stays green under both ablations, as it should: it asserts the absence the ablation produces.

Package gates at 25f0fb7 (after merging origin/main)
closure build ('@objectstack/service-automation^...')  EXIT=0
pnpm --filter @objectstack/service-automation build      EXIT=0
pnpm --filter @objectstack/service-automation test       EXIT=0   Test Files 218 passed (218) · Tests 2795 passed (2795)
pnpm --filter @objectstack/service-automation typecheck  EXIT=0   check:test-typecheck: OK, 0 errors
eslint --no-inline-config packages/services/service-automation  EXIT=0

Acceptance notes

  • try_catch with no catch region (builtin/try-catch-node.ts) still re-words a try-region failure into a new returned failure without the members, so a refusal inside a guarded region that is not caught reaches the caller without innerRefusal. Outside this card's file surface; the header keeps naming it as a non-carrier. Not filed: no public-door measurement in this run.
  • 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. That path is not covered here and was not measured.

Verification

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 59 families derived at 25f0fb7; every command run with its exit captured; --ran reconciliation: 59 accounted, 58 run (exit 0), 1 NOT-MEASURED (pnpm check:dual-build-cjs-loads, exit 3 PREREQUISITE NOT MET: needs the full workspace dist/; CI runs it).
  • The derivation warned the tree was 3 commits behind origin/main at that moment, in package.json, scripts/checklist-select.mjs and scripts/pm/check-skill-line-ratchet.mjs (gate definitions, none of them in this diff). CI runs the families from the merge commit.
  • No open PR touches these four files (all 16 open PRs' file lists read), so there was nothing to merge-tree against.
  • Dogfood battery: not owed (no ! in the title, no (narrowing) arm).

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-automation/src/inner-refusal.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-automation/src/inner-refusal.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 81d00a4ed938eb896859630498fc59d91183cf71 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 43a9a7bc5b0affdf65c8989608b5885c2e06fc0d — the merge of head 25f0fb7f39ea96999e577e952680a0defbaea099 into base 81d00a4ed938eb896859630498fc59d91183cf71, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 43a9a7bc5b0affdf65c8989608b5885c2e06fc0d && git checkout 43a9a7bc5b0affdf65c8989608b5885c2e06fc0d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 81d00a4ed938eb896859630498fc59d91183cf71 25f0fb7f39ea96999e577e952680a0defbaea099 && git checkout -B drift-repro 81d00a4ed938eb896859630498fc59d91183cf71 && git merge --no-ff 25f0fb7f39ea96999e577e952680a0defbaea099

node scripts/docs-audit/affected-docs.mjs --json 81d00a4ed938eb896859630498fc59d91183cf71

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

维护者速读(席位终稿)

  • 改了什么:流程里 map 的某一项或 subflow 子流程中的数据写入被拒(权限、校验规则、重复、应用钩子声明的拒绝)时,调用方现在拿到结构化拒绝(innerRefusal 的 code 和 userMessage),不再只是一长串节点报错文本;嵌套 map → subflow 也能带到顶层。
  • 为什么改:两个容器节点各自新建失败结果,把子运行已带的拒绝成员丢了。
  • 风险与回滚:只把子运行已有的成员带过来,复用现有的 carriedRefusalOfResult;error 文本逐字不变;没遇到拒绝的子失败不加任何成员。回滚 = revert 本 PR。
  • 先看:packages/services/service-automation/src/builtin/map-node.ts 和 subflow-node.ts 各一处展开;inner-refusal.ts 头注释。
  • 两分钟验证:pnpm --filter @objectstack/service-automation exec vitest run src/flow-inner-refusal.test.ts,预期 18/18。
  • 需要你做的:无。非受管面,席位落地。

席位意见: ACCEPT。复用现有搬运,无新词汇、无 spec 改动;try_catch 无 catch 区和暂停后恢复失败两条路径另开卡。

席位核对

ACCEPT · head 25f0fb7f39ea96999e577e952680a0defbaea099 · 2026-10-29T22:29Z

  • 形态: 5 files, +173/−7: two one-line spreads in the containers, the inner-refusal.ts header, 5 pins, one changeset. No packages/spec, no governed path; Tier S.
  • Clause-②: no, Metadata-protocol: none, patch. The members carried are the ones the spec already declares ("present only when a refusal was met"); nothing is refused that was accepted. No contract review owed.
  • 前提实测: 4 of the 5 new pins red before the fix, control green; two ablation legs (map, subflow) red, restored to the HEAD blob.
  • 抽查读数: the seat read both hunks: the spread is the child run's result through carriedRefusalOfResult, {} when none; a map stops at its first failing item, so it carries that item's.
  • 门禁 check-runs: 52 on this head: 37 success, 15 skipped, 0 other (read 2026-10-29T22:29Z); mergeable_state: clean. Derived gates 59 (58 exit 0, check:dual-build-cjs-loads carried by CI Lint & Repo Gates, green); package 218 files / 2795 tests.
  • 接受的偏差: the dev's four (model-free trailers, report signature line, gates derived 3 commits behind main on gate-definition files only, the containers hand the members to the engine's existing attachCarriedRefusal instead of calling it).
  • 范围外去向: try_catch with no catch region, and a paused child that fails on resume → service-automation: a refusal met inside a try_catch with no catch region, or by a map item / subflow child that pauses and fails on resume, may not reach the flow's caller as the structured refusal #27743.
  • 散文面: changeset and header checked against the diff by the seat.
  • 落地条款: relay pr_ready + automerge_enable through the merge queue.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 22:30
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 29, 2026 22:30
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 79d78b4 Oct 29, 2026
53 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-27601-container-carries-refusal branch October 29, 2026 22:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant