From 3ef74cfe9b905a20cb4fde404e20c9b67aacfb85 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:13:04 +0000 Subject: [PATCH 1/2] fix(automation): a map item or subflow child carries its refusal to the run Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- .../src/builtin/map-node.ts | 5 + .../src/builtin/subflow-node.ts | 4 + .../src/flow-inner-refusal.test.ts | 141 +++++++++++++++++- .../service-automation/src/inner-refusal.ts | 20 ++- 4 files changed, 163 insertions(+), 7 deletions(-) diff --git a/packages/services/service-automation/src/builtin/map-node.ts b/packages/services/service-automation/src/builtin/map-node.ts index f8f96477eb7..f6d0f4838fc 100644 --- a/packages/services/service-automation/src/builtin/map-node.ts +++ b/packages/services/service-automation/src/builtin/map-node.ts @@ -6,6 +6,7 @@ import type { MapConfigParsed } from '@objectstack/spec/automation'; import type { AutomationContext } from '@objectstack/spec/contracts'; import type { AutomationEngine } from '../engine.js'; import { refuseNode } from '../guard-refusal.js'; +import { carriedRefusalOfResult } from '../inner-refusal.js'; import { parseNodeConfig } from './parse-config.js'; import { collectionNotAList, resolveCollectionSlot, resolveValueSlotMap } from './value-slot-map.js'; @@ -220,6 +221,10 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v return { success: false, error: `map '${node.id}': item ${idx} (subflow '${flowName}') failed: ${child.error ?? 'unknown error'}`, + // The failing item's refusal, carried beside the re-worded `error` + // (`inner-refusal.ts`); `{}` for a child that met none. The batch + // stops here, so it is this — the first failing — item's. + ...carriedRefusalOfResult(child), // Items that already succeeded wrote real rows; a later item's // failure must not erase them from the run's totals — and the // failing item's own writes count too, for the same reason. diff --git a/packages/services/service-automation/src/builtin/subflow-node.ts b/packages/services/service-automation/src/builtin/subflow-node.ts index 0cb1eda3724..25b7805ad1d 100644 --- a/packages/services/service-automation/src/builtin/subflow-node.ts +++ b/packages/services/service-automation/src/builtin/subflow-node.ts @@ -7,6 +7,7 @@ import type { AutomationContext } from '@objectstack/spec/contracts'; import type { AutomationEngine } from '../engine.js'; import { resolveValueSlotMap } from './value-slot-map.js'; import { refuseNode } from '../guard-refusal.js'; +import { carriedRefusalOfResult } from '../inner-refusal.js'; import { parseNodeConfig } from './parse-config.js'; /** Hard cap on subflow nesting — turns an accidental cycle into a clean error. */ @@ -169,6 +170,9 @@ export function registerSubflowNode(engine: AutomationEngine, ctx: PluginContext return { success: false, error: `subflow '${flowName}' failed: ${child.error ?? 'unknown error'}`, + // The child's refusal, carried beside the re-worded `error` + // (`inner-refusal.ts`); `{}` for a child that met none. + ...carriedRefusalOfResult(child), ...(rolled ? { metrics: rolled } : {}), }; } diff --git a/packages/services/service-automation/src/flow-inner-refusal.test.ts b/packages/services/service-automation/src/flow-inner-refusal.test.ts index 5cc8bc20f85..272cd9681ac 100644 --- a/packages/services/service-automation/src/flow-inner-refusal.test.ts +++ b/packages/services/service-automation/src/flow-inner-refusal.test.ts @@ -23,7 +23,9 @@ * `resumeInternal` stranded exit (a write behind a screen — the card's * measured case) and the retry exits; a `loop` frame the failure unwinds * through does not lose them; a `fault` edge that handles the failure - * leaves the run with none; + * leaves the run with none; a `map` item's or a `subflow` child's + * refusal crosses that container (nested too), and a child that met no + * refusal carries none; * 4. ⛔ `error` is byte-identical to the composition it always had — the * diagnostic channel does not move. * @@ -39,6 +41,8 @@ import { AutomationEngine } from './engine.js'; import type { NodeExecutor } from './engine.js'; import { registerCrudNodes } from './builtin/crud-nodes.js'; import { registerLoopNode } from './builtin/loop-node.js'; +import { registerMapNode } from './builtin/map-node.js'; +import { registerSubflowNode } from './builtin/subflow-node.js'; import { DuplicateRecordError, ValidationError } from '@objectstack/objectql'; import { PermissionDeniedError } from '@objectstack/plugin-security'; import { defineActionDescriptor } from '@objectstack/spec/automation'; @@ -359,3 +363,138 @@ describe('#24914 — every terminal exit the ruling names carries the members', expect('userMessage' in result).toBe(false); }); }); + +describe('a `map` item and a `subflow` child carry the refusal across the container', () => { + /** start → mk (create_record) → end — the per-item / called flow that meets the refusal. */ + const WRITER = 'write_one'; + + /** start → seed (rows) → each (map over `flowName`) → end. */ + function mapFlow(name: string, flowName: string): any { + return { + name, label: name, type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'query', type: 'seed', label: 'Seed' }, + { + id: 'each', type: 'map', label: 'Each', + config: { collection: { dialect: 'cel', source: 'rows' }, flowName, iteratorVariable: 'row' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'query' }, + { id: 'e2', source: 'query', target: 'each' }, + { id: 'e3', source: 'each', target: 'end' }, + ], + }; + } + + /** start → call (subflow `flowName`) → end. */ + function subflowFlow(name: string, flowName: string): any { + return { + name, label: name, type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'call', type: 'subflow', label: 'Call', config: { flowName } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + }; + } + + function containerEngine(data: any): AutomationEngine { + const engine = new AutomationEngine(makeLogger()); + const ctx = ctxWith(data); + registerCrudNodes(engine, ctx); + registerMapNode(engine, ctx); + registerSubflowNode(engine, ctx); + engine.registerNodeExecutor({ + type: 'seed', + async execute(_node, variables) { + variables.set('rows', [{ n: 1 }, { n: 2 }]); + return { success: true }; + }, + } as NodeExecutor); + engine.registerFlow(WRITER, createFlow(WRITER)); + return engine; + } + + const childText = (err: Error) => `Node 'mk' failed: create_record(crm_account) failed: ${err.message}`; + + it('map: the failing item\'s refusal reaches the run — code and userMessage — and the text keeps its chain', async () => { + const err = markedHook(); + const engine = containerEngine(refusingData(err)); + engine.registerFlow('recalc', mapFlow('recalc', WRITER)); + + const result = await engine.execute('recalc', { userId: 'u1' }); + + expect(result.status).toBe('failed'); + expect(result.innerRefusal).toEqual({ code: 'RESOURCE_CONFLICT', message: err.message }); + expect(result.userMessage).toBe(HOOK_USER_TEXT); + expect(result.error).toBe(`Node 'each' failed: map 'each': item 0 (subflow '${WRITER}') failed: ${childText(err)}`); + }); + + it('map: the batch stops at the first failing item, so the run carries THAT item\'s refusal', async () => { + const first = validation(); + const second = permission(); + let calls = 0; + const data = refusingData(undefined); + data.insert = async () => { calls += 1; throw calls === 1 ? first : second; }; + const engine = containerEngine(data); + engine.registerFlow('recalc', mapFlow('recalc', WRITER)); + + const result = await engine.execute('recalc', { userId: 'u1' }); + + expect(calls).toBe(1); + expect(result.innerRefusal).toEqual({ code: 'VALIDATION_FAILED', message: 'Amount must be positive' }); + }); + + it('subflow: the child\'s refusal reaches the parent run — code and userMessage', async () => { + const err = markedHook(); + const engine = containerEngine(refusingData(err)); + engine.registerFlow('outer', subflowFlow('outer', WRITER)); + + const result = await engine.execute('outer', { userId: 'u1' }); + + expect(result.status).toBe('failed'); + expect(result.innerRefusal).toEqual({ code: 'RESOURCE_CONFLICT', message: err.message }); + expect(result.userMessage).toBe(HOOK_USER_TEXT); + expect(result.error).toBe(`Node 'call' failed: subflow '${WRITER}' failed: ${childText(err)}`); + }); + + it('map → subflow nested: the refusal two containers deep reaches the top run', async () => { + const err = permission(); + const engine = containerEngine(refusingData(err)); + engine.registerFlow('item_flow', subflowFlow('item_flow', WRITER)); + engine.registerFlow('recalc', mapFlow('recalc', 'item_flow')); + + const result = await engine.execute('recalc', { userId: 'u1' }); + + expect(result.status).toBe('failed'); + expect(result.innerRefusal).toEqual({ code: 'PERMISSION_DENIED', message: err.message }); + expect(result.error).toBe( + `Node 'each' failed: map 'each': item 0 (subflow 'item_flow') failed: ` + + `Node 'call' failed: subflow '${WRITER}' failed: ${childText(err)}`, + ); + }); + + it('control: a child that failed with NO refusal (a fault) leaves both containers carrying neither member', async () => { + const fault = new Error('ECONNREFUSED: could not reach the store'); + const engine = containerEngine(refusingData(fault)); + engine.registerFlow('item_flow', subflowFlow('item_flow', WRITER)); + engine.registerFlow('recalc', mapFlow('recalc', 'item_flow')); + engine.registerFlow('outer', subflowFlow('outer', WRITER)); + + for (const name of ['recalc', 'outer']) { + const result = await engine.execute(name, { userId: 'u1' }); + expect(result.status).toBe('failed'); + expect('innerRefusal' in result).toBe(false); + expect('userMessage' in result).toBe(false); + } + const outer = await engine.execute('outer', { userId: 'u1' }); + expect(outer.error).toBe(`Node 'call' failed: subflow '${WRITER}' failed: ${childText(fault)}`); + }); +}); diff --git a/packages/services/service-automation/src/inner-refusal.ts b/packages/services/service-automation/src/inner-refusal.ts index df57944e6c4..5c32cdeb170 100644 --- a/packages/services/service-automation/src/inner-refusal.ts +++ b/packages/services/service-automation/src/inner-refusal.ts @@ -117,11 +117,17 @@ export function refusalCarriedBy(err: unknown): CarriedRefusal { * through rethrows the SAME object, so the brand reaches the terminal exit * unchanged. * - * A container that re-words a child failure into a NEW returned failure of - * its own (`try_catch` with no catch region, `subflow`) builds a new error, - * and the brand does not cross it; the run's result then carries no member, - * which the spec's "present only when a refusal was met" allows and which - * never invents one. + * A container that runs a CHILD flow and re-words its failure into a NEW + * returned failure of its own — `subflow`, and `map` for its failing item — + * carries the child run's members onto that returned failure + * (`carriedRefusalOfResult(child)`), so the engine brands its throw like any + * data node's and the refusal reaches the top run however deep it was met. + * The child's result is the only source: a child that met no refusal spreads + * `{}`, and a `map` stops at its first failing item, so it carries that + * item's. A `try_catch` with no catch region still builds a new error the + * brand does not cross; the run's result then carries no member, which the + * spec's "present only when a refusal was met" allows and which never + * invents one. */ export const CARRIED_REFUSAL: unique symbol = Symbol.for( 'objectstack.automation.carriedRefusal', @@ -158,7 +164,9 @@ export function readCarriedRefusal(err: unknown): CarriedRefusal { /** * The members an attempt's RESULT carries — for `retryExecution`, whose * exhausted exit reports the last attempt's failure and must report that - * attempt's refusal beside it, never an earlier attempt's. + * attempt's refusal beside it, never an earlier attempt's — and for the + * `subflow` / `map` failure arms, which carry their child run's refusal onto + * the failure they return. */ export function carriedRefusalOfResult(result: AutomationResult): CarriedRefusal { return pickCarried(result); From 57664f326296e3022c5c0731aad3e8ba3a25dfa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:14:28 +0000 Subject: [PATCH 2/2] chore: changeset for the container refusal carriage Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- .changeset/27601-container-carries-refusal.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/27601-container-carries-refusal.md diff --git a/.changeset/27601-container-carries-refusal.md b/.changeset/27601-container-carries-refusal.md new file mode 100644 index 00000000000..b5133cc8450 --- /dev/null +++ b/.changeset/27601-container-carries-refusal.md @@ -0,0 +1,10 @@ +--- +'@objectstack/service-automation': patch +--- + +fix(service-automation): a refused write inside a `map` item or a `subflow` reaches the flow's caller as the structured refusal + +Clause-②: no +Metadata-protocol: none + +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.