Skip to content

Commit 79d78b4

Browse 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>
1 parent d7625ea commit 79d78b4

5 files changed

Lines changed: 173 additions & 7 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
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.

‎packages/services/service-automation/src/builtin/map-node.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { MapConfigParsed } from '@objectstack/spec/automation';
66
import type { AutomationContext } from '@objectstack/spec/contracts';
77
import type { AutomationEngine } from '../engine.js';
88
import { refuseNode } from '../guard-refusal.js';
9+
import { carriedRefusalOfResult } from '../inner-refusal.js';
910
import { parseNodeConfig } from './parse-config.js';
1011
import { collectionNotAList, resolveCollectionSlot, resolveValueSlotMap } from './value-slot-map.js';
1112

@@ -220,6 +221,10 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
220221
return {
221222
success: false,
222223
error: `map '${node.id}': item ${idx} (subflow '${flowName}') failed: ${child.error ?? 'unknown error'}`,
224+
// The failing item's refusal, carried beside the re-worded `error`
225+
// (`inner-refusal.ts`); `{}` for a child that met none. The batch
226+
// stops here, so it is this — the first failing — item's.
227+
...carriedRefusalOfResult(child),
223228
// Items that already succeeded wrote real rows; a later item's
224229
// failure must not erase them from the run's totals — and the
225230
// failing item's own writes count too, for the same reason.

‎packages/services/service-automation/src/builtin/subflow-node.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { AutomationContext } from '@objectstack/spec/contracts';
77
import type { AutomationEngine } from '../engine.js';
88
import { resolveValueSlotMap } from './value-slot-map.js';
99
import { refuseNode } from '../guard-refusal.js';
10+
import { carriedRefusalOfResult } from '../inner-refusal.js';
1011
import { parseNodeConfig } from './parse-config.js';
1112

1213
/** Hard cap on subflow nesting — turns an accidental cycle into a clean error. */
@@ -169,6 +170,9 @@ export function registerSubflowNode(engine: AutomationEngine, ctx: PluginContext
169170
return {
170171
success: false,
171172
error: `subflow '${flowName}' failed: ${child.error ?? 'unknown error'}`,
173+
// The child's refusal, carried beside the re-worded `error`
174+
// (`inner-refusal.ts`); `{}` for a child that met none.
175+
...carriedRefusalOfResult(child),
172176
...(rolled ? { metrics: rolled } : {}),
173177
};
174178
}

‎packages/services/service-automation/src/flow-inner-refusal.test.ts‎

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
* `resumeInternal` stranded exit (a write behind a screen — the card's
2424
* measured case) and the retry exits; a `loop` frame the failure unwinds
2525
* through does not lose them; a `fault` edge that handles the failure
26-
* leaves the run with none;
26+
* leaves the run with none; a `map` item's or a `subflow` child's
27+
* refusal crosses that container (nested too), and a child that met no
28+
* refusal carries none;
2729
* 4. ⛔ `error` is byte-identical to the composition it always had — the
2830
* diagnostic channel does not move.
2931
*
@@ -39,6 +41,8 @@ import { AutomationEngine } from './engine.js';
3941
import type { NodeExecutor } from './engine.js';
4042
import { registerCrudNodes } from './builtin/crud-nodes.js';
4143
import { registerLoopNode } from './builtin/loop-node.js';
44+
import { registerMapNode } from './builtin/map-node.js';
45+
import { registerSubflowNode } from './builtin/subflow-node.js';
4246
import { DuplicateRecordError, ValidationError } from '@objectstack/objectql';
4347
import { PermissionDeniedError } from '@objectstack/plugin-security';
4448
import { defineActionDescriptor } from '@objectstack/spec/automation';
@@ -359,3 +363,138 @@ describe('#24914 — every terminal exit the ruling names carries the members',
359363
expect('userMessage' in result).toBe(false);
360364
});
361365
});
366+
367+
describe('a `map` item and a `subflow` child carry the refusal across the container', () => {
368+
/** start → mk (create_record) → end — the per-item / called flow that meets the refusal. */
369+
const WRITER = 'write_one';
370+
371+
/** start → seed (rows) → each (map over `flowName`) → end. */
372+
function mapFlow(name: string, flowName: string): any {
373+
return {
374+
name, label: name, type: 'autolaunched',
375+
nodes: [
376+
{ id: 'start', type: 'start', label: 'Start' },
377+
{ id: 'query', type: 'seed', label: 'Seed' },
378+
{
379+
id: 'each', type: 'map', label: 'Each',
380+
config: { collection: { dialect: 'cel', source: 'rows' }, flowName, iteratorVariable: 'row' },
381+
},
382+
{ id: 'end', type: 'end', label: 'End' },
383+
],
384+
edges: [
385+
{ id: 'e1', source: 'start', target: 'query' },
386+
{ id: 'e2', source: 'query', target: 'each' },
387+
{ id: 'e3', source: 'each', target: 'end' },
388+
],
389+
};
390+
}
391+
392+
/** start → call (subflow `flowName`) → end. */
393+
function subflowFlow(name: string, flowName: string): any {
394+
return {
395+
name, label: name, type: 'autolaunched',
396+
nodes: [
397+
{ id: 'start', type: 'start', label: 'Start' },
398+
{ id: 'call', type: 'subflow', label: 'Call', config: { flowName } },
399+
{ id: 'end', type: 'end', label: 'End' },
400+
],
401+
edges: [
402+
{ id: 'e1', source: 'start', target: 'call' },
403+
{ id: 'e2', source: 'call', target: 'end' },
404+
],
405+
};
406+
}
407+
408+
function containerEngine(data: any): AutomationEngine {
409+
const engine = new AutomationEngine(makeLogger());
410+
const ctx = ctxWith(data);
411+
registerCrudNodes(engine, ctx);
412+
registerMapNode(engine, ctx);
413+
registerSubflowNode(engine, ctx);
414+
engine.registerNodeExecutor({
415+
type: 'seed',
416+
async execute(_node, variables) {
417+
variables.set('rows', [{ n: 1 }, { n: 2 }]);
418+
return { success: true };
419+
},
420+
} as NodeExecutor);
421+
engine.registerFlow(WRITER, createFlow(WRITER));
422+
return engine;
423+
}
424+
425+
const childText = (err: Error) => `Node 'mk' failed: create_record(crm_account) failed: ${err.message}`;
426+
427+
it('map: the failing item\'s refusal reaches the run — code and userMessage — and the text keeps its chain', async () => {
428+
const err = markedHook();
429+
const engine = containerEngine(refusingData(err));
430+
engine.registerFlow('recalc', mapFlow('recalc', WRITER));
431+
432+
const result = await engine.execute('recalc', { userId: 'u1' });
433+
434+
expect(result.status).toBe('failed');
435+
expect(result.innerRefusal).toEqual({ code: 'RESOURCE_CONFLICT', message: err.message });
436+
expect(result.userMessage).toBe(HOOK_USER_TEXT);
437+
expect(result.error).toBe(`Node 'each' failed: map 'each': item 0 (subflow '${WRITER}') failed: ${childText(err)}`);
438+
});
439+
440+
it('map: the batch stops at the first failing item, so the run carries THAT item\'s refusal', async () => {
441+
const first = validation();
442+
const second = permission();
443+
let calls = 0;
444+
const data = refusingData(undefined);
445+
data.insert = async () => { calls += 1; throw calls === 1 ? first : second; };
446+
const engine = containerEngine(data);
447+
engine.registerFlow('recalc', mapFlow('recalc', WRITER));
448+
449+
const result = await engine.execute('recalc', { userId: 'u1' });
450+
451+
expect(calls).toBe(1);
452+
expect(result.innerRefusal).toEqual({ code: 'VALIDATION_FAILED', message: 'Amount must be positive' });
453+
});
454+
455+
it('subflow: the child\'s refusal reaches the parent run — code and userMessage', async () => {
456+
const err = markedHook();
457+
const engine = containerEngine(refusingData(err));
458+
engine.registerFlow('outer', subflowFlow('outer', WRITER));
459+
460+
const result = await engine.execute('outer', { userId: 'u1' });
461+
462+
expect(result.status).toBe('failed');
463+
expect(result.innerRefusal).toEqual({ code: 'RESOURCE_CONFLICT', message: err.message });
464+
expect(result.userMessage).toBe(HOOK_USER_TEXT);
465+
expect(result.error).toBe(`Node 'call' failed: subflow '${WRITER}' failed: ${childText(err)}`);
466+
});
467+
468+
it('map → subflow nested: the refusal two containers deep reaches the top run', async () => {
469+
const err = permission();
470+
const engine = containerEngine(refusingData(err));
471+
engine.registerFlow('item_flow', subflowFlow('item_flow', WRITER));
472+
engine.registerFlow('recalc', mapFlow('recalc', 'item_flow'));
473+
474+
const result = await engine.execute('recalc', { userId: 'u1' });
475+
476+
expect(result.status).toBe('failed');
477+
expect(result.innerRefusal).toEqual({ code: 'PERMISSION_DENIED', message: err.message });
478+
expect(result.error).toBe(
479+
`Node 'each' failed: map 'each': item 0 (subflow 'item_flow') failed: `
480+
+ `Node 'call' failed: subflow '${WRITER}' failed: ${childText(err)}`,
481+
);
482+
});
483+
484+
it('control: a child that failed with NO refusal (a fault) leaves both containers carrying neither member', async () => {
485+
const fault = new Error('ECONNREFUSED: could not reach the store');
486+
const engine = containerEngine(refusingData(fault));
487+
engine.registerFlow('item_flow', subflowFlow('item_flow', WRITER));
488+
engine.registerFlow('recalc', mapFlow('recalc', 'item_flow'));
489+
engine.registerFlow('outer', subflowFlow('outer', WRITER));
490+
491+
for (const name of ['recalc', 'outer']) {
492+
const result = await engine.execute(name, { userId: 'u1' });
493+
expect(result.status).toBe('failed');
494+
expect('innerRefusal' in result).toBe(false);
495+
expect('userMessage' in result).toBe(false);
496+
}
497+
const outer = await engine.execute('outer', { userId: 'u1' });
498+
expect(outer.error).toBe(`Node 'call' failed: subflow '${WRITER}' failed: ${childText(fault)}`);
499+
});
500+
});

‎packages/services/service-automation/src/inner-refusal.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,17 @@ export function refusalCarriedBy(err: unknown): CarriedRefusal {
117117
* through rethrows the SAME object, so the brand reaches the terminal exit
118118
* unchanged.
119119
*
120-
* A container that re-words a child failure into a NEW returned failure of
121-
* its own (`try_catch` with no catch region, `subflow`) builds a new error,
122-
* and the brand does not cross it; the run's result then carries no member,
123-
* which the spec's "present only when a refusal was met" allows and which
124-
* never invents one.
120+
* A container that runs a CHILD flow and re-words its failure into a NEW
121+
* returned failure of its own — `subflow`, and `map` for its failing item —
122+
* carries the child run's members onto that returned failure
123+
* (`carriedRefusalOfResult(child)`), so the engine brands its throw like any
124+
* data node's and the refusal reaches the top run however deep it was met.
125+
* The child's result is the only source: a child that met no refusal spreads
126+
* `{}`, and a `map` stops at its first failing item, so it carries that
127+
* item's. A `try_catch` with no catch region still builds a new error the
128+
* brand does not cross; the run's result then carries no member, which the
129+
* spec's "present only when a refusal was met" allows and which never
130+
* invents one.
125131
*/
126132
export const CARRIED_REFUSAL: unique symbol = Symbol.for(
127133
'objectstack.automation.carriedRefusal',
@@ -158,7 +164,9 @@ export function readCarriedRefusal(err: unknown): CarriedRefusal {
158164
/**
159165
* The members an attempt's RESULT carries — for `retryExecution`, whose
160166
* exhausted exit reports the last attempt's failure and must report that
161-
* attempt's refusal beside it, never an earlier attempt's.
167+
* attempt's refusal beside it, never an earlier attempt's — and for the
168+
* `subflow` / `map` failure arms, which carry their child run's refusal onto
169+
* the failure they return.
162170
*/
163171
export function carriedRefusalOfResult(result: AutomationResult): CarriedRefusal {
164172
return pickCarried(result);

0 commit comments

Comments
 (0)