You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 79d78b4
Browse filesBrowse the repository at this point in the historyBrowse files
fix(automation): a map item or subflow child carries its refusal to the flow's caller (#27740)
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
<!-- claude-projects-attribution: {"github_login":"os-sales"} -->
_Requested by **Sales** · [project
thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuPMZ8BHWtPf6nBTp51qSM64)_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」,看到的是整串引擎报错链;服务端没有结构化拒绝可读,界面那半(#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.
<details><summary>Red before the fix (3ef74cf minus the fix
lines)</summary>
```
× 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)
```
</details>
<details><summary>Ablation through scripts/ablation-replace.mjs (fix
committed first, at 3ef74cf)</summary>
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 f6d0f48 -> 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 (f6d0f48) and `git diff HEAD` is empty
### ABLATE subflow-node (anchor x1 -> x0, blob 25b7805 -> 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 (25b7805) and `git diff HEAD` is empty
```
The control stays green under both ablations, as it should: it asserts
the absence the ablation produces.
</details>
<details><summary>Package gates at 25f0fb7 (after merging
origin/main)</summary>
```
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
```
</details>
## 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](https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(service-automation): a refused write inside a `map` item or a `subflow` reaches the flow's caller as the structured refusal
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
A flow whose data node met a refusal (a permission, a validation rule, a duplicate, an app hook's declared refusal) already reported it on the run's result as `innerRefusal` (`{ code, message }`) and the producer-marked `userMessage`, and every flow door served them on the `FLOW_FAILED` details. When that write sat inside a `map` item or a `subflow`, the container built a new failure of its own and the two members were lost, so the caller got only the whole node chain as text (`Node 'recalc_each' failed: map 'recalc_each': item 0 (subflow '…') failed: …`). Both containers now carry the child run's members onto the failure they return, so the refusal reaches the top run however deep it was met, nested `map` → `subflow` included. A `map` stops at its first failing item and carries that item's refusal. A child that failed with no refusal carries none, as before, and the `error` text is unchanged.
0 commit comments