Skip to content

Commit 4748bac

Browse files
fix(service-automation)!: the subflow guards see a subflow or map call inside a region body (#28959)
domain:server#3 · os-project-manager · session_01FEkQ5omF1nCbQTGgW1nbRA Fixes #28846 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — `skills/**` does not teach the ADR-0126 §7.3 subflow guards, and this changes no authorable shape ## 维护者速读 - **BREAKING(收窄)**:子流程守卫(ADR-0126 §7.3)以前只看流程顶层的 `subflow` / `map` 节点,放在 `loop`、`parallel` 分支或 `try_catch` 里的调用看不到。现在所有区域层级都会读取,读法复用 spec 已有的 `collectFlowGraphs`,没有新写遍历器。 - 实测(改动前):6 种区域位置(loop、parallel、try_catch 各配 subflow 和 map),指向一个已停用的打包子流程时,调用方都照常启用,而顶层的同样写法会被拒绝启用。修复后行为和顶层一致:调用方不启用,`/_status` 显示 `disabled_subflow`;有已启用的调用方时,停用或删除子流程会被拒绝,返回 `DELETE_RESTRICTED` 409。 - 按席位裁定(6568462471),#28840 的 `save_notice` 判定仍然只看顶层:区域里的调用即使子流程会挂起,运行也会在区域边界直接失败,根本到不了 `save_notice`。这个失败另立了 #28897。 - 分诊建议项 1 已处理:`save-notice-placement.ts` 文件头和 `flow.zod.ts` 的注释改为说明,子流程这种情况由启用闸门(`notice_after_suspending_subflow`)处理,运行时按名字拒绝只是剩余情况。 - 已发布的流程里,唯一的区域内调用是 HotCRM 的 `campaign_enrollment`(loop 里调用两个已启用的子流程),仍然启用。以后它启用期间,停用或删除这两个子流程会被拒绝,席位已认可。 - 没有需要你拍板的事项。合并前由席位做契约复核。 ## What changes **`subflowTargets` reads every region depth** (`packages/services/service-automation/src/subflow-guards.ts`). It used to read `flow.nodes` only. It now reads every graph `collectFlowGraphs` (`@objectstack/spec/automation`) returns: the top level and each `loop` body, `parallel` branch and `try_catch` slot, at every depth the spec walks. That is the spec's one region walk, so no new walker exists. Every guard that reads `subflowTargets` now sees the same calls: - **The arming decline** (`declineOntoDisabledSubflows`, via `disabledPackagedSubflows`). A packaged caller whose region body calls a disabled packaged child registers but is not armed. It warns once, and `/_status` reports `unboundCause: 'disabled_subflow'` naming the child. - **The re-judge** (`rejudgeSubflowCallers`, via `packagedSubflowCallers`). A child republished `obsolete` under an armed caller disarms it, and republished `active` arms it again. - **The switch-off and removal refusals** (`refuseUnderReachingCallers`). Switching off or removing a packaged child that an enabled packaged caller calls from a region body is refused with `DELETE_RESTRICTED` / 409, with `subflowCallers` naming the caller. - **The enable refusal and the ledger-cycle exemption** (`refuseEnableOntoDisabledSubflow`, `ledgerDisabledChainReaches`) read the same set. - The engine's `subflowTargets` docblock says so. **The save-notice half of the arming gate stays top-level** (`subflow-notice-guard.ts`; only its header prose changes). A region body cannot carry a durable pause. Measured before this change: a region-nested `subflow` / `map` whose child suspends fails the run at the region boundary ("durable pause inside a structured region"), before anything after the container runs. So the call cannot strand a `save_notice` after it, and a child whose only suspending call sits in a region cannot suspend its caller. The seat ruled this in 6568462471, withdrew the order's container rule, and filed the region-boundary failure (with the child run it leaves suspended) as #28897. Two pins hold the gate there. **Spec prose, triage should-fix 1** (`6568019187`). The `save-notice-placement.ts` header and the comment at its `flow.zod.ts` wiring now say where a `save_notice` after a suspending `subflow` / `map` is met. It is met by the engine's arming gate, which declines the flow with `notice_after_suspending_subflow`, before any run. A region-nested call fails at the region first. The run-time refusal by name is the residue, for a plugin-registered pausing type. Comments only: nothing in `packages/spec/dist` changes, so the spec package carries no changeset. ## Why #28846, found on #28840: every §7.3 guard read only the top-level `nodes`. So a packaged caller with its call in a loop body armed onto a disabled child and failed mid-run at that node, the late, inexplicable failure §7.3 exists to prevent. The child could also be switched off or removed under an enabled caller. ## Verification - **Measured first** (a scratch test, not committed), each case against its top-level twin: - Disabled packaged child, with subflow and with map, inside loop, parallel and try_catch: all 6 cases armed with no `unboundCause`, while both top-level twins declined with `disabled_subflow`. - A suspending child plus a `save_notice` after the container: all 6 armed, but `execute()` answered `failed` with the region-boundary refusal. No notice was set, and the child run was left suspended. The top-level twins declined with `notice_after_suspending_subflow`. - **Red first.** `service-automation/src/subflow-guard-region-calls.test.ts` has 14 cases. On base, 10 failed and 4 passed (2 controls and the 2 notice-gate pins). On head, 14 of 14 pass. - The refusal cases assert `code: 'DELETE_RESTRICTED'`, `status: 409`, `subflowCallers` and the sentence. - **Ablations**, through `scripts/ablation-replace.mjs`, each red and then restored (`blob == HEAD`): - Top-level only (`collectFlowGraphs(flow).slice(0, 1)`): all 10 region pins went red. The controls and the `subflow-guard-every-door` / `subflow-notice-arming` suites stayed green (34 passed). - The notice gate follows a region call in the caller: the first notice pin went red. - A child counts a region call as suspending: the second notice pin went red. - **Shipped flows.** - `examples/**` and `packages/qa/**`: every `subflow` / `map` node is top-level. - HotCRM `origin/main` (`d4ffe99`): `campaign_enrollment` calls `campaign_lead_member_enroll` and `campaign_contact_member_enroll` from two loop bodies. Both children are `status: 'active'` and hold no suspending node, so it still arms. No other region-nested call. - **Gates.** `dispatch-gates.mjs --commands` derived 83. `--ran` reconciles 83 run, 0 NOT-MEASURED and 0 UNRUN, all exit 0. - **Suites.** Full `vitest run` exit 0: - `@objectstack/service-automation`: 256 files, 3295 tests. - `@objectstack/plugin-approvals`: 107 files. - `@objectstack/trigger-record-change`: 12 files. - `@objectstack/example-showcase`: 42 files. - `@objectstack/spec` `src/automation`: 45 files, 1745 tests. - **Typecheck.** The workspace `pnpm typecheck` (6 GB heap) passed every task except `@objectstack/spec`, whose test-layer `tsc` was killed without diagnostics (exit `null`) while the run shared the box. Run alone with an 8 GB heap, `@objectstack/spec` `pnpm typecheck` exits 0 and holds its pinned debt. - `eslint --no-inline-config` over `packages/services/service-automation` and `packages/spec/src/automation` exits 0. - **Dogfood battery** (owed by the `(narrowing)` arm), on head `87411da8` under `os-verify-lock.sh`: - Closure build: `VERDICT command-exit 0`. - `pnpm --filter @objectstack/dogfood test`: `VERDICT command-exit 0`. 382 files passed and 3 skipped (385); 3156 tests passed and 28 skipped (3184). ## Acceptance notes - **`Metadata-protocol: none`.** Registration, the save door and `objectstack validate` accept the same flows. The runtime guards now read calls they should already have read. Only `@objectstack/service-automation` ships a change (`patch`, with the BREAKING banner and the ADR-0087 `no-migration-prescription` marker). - **`Clause-②: no (narrowing)`.** Nothing public widens: no export, type or served value is added. - **Unchanged:** `FLOW_PAUSE_CAPABLE_NODE_TYPES` (ruling D), the save-notice gate's walk, the region-boundary refusal and `ui/**`. No new gate. No hotcrm change. - Only a `subflow` / `map` node that `collectFlowGraphs` reaches is read. That covers regions up to `MAX_REGION_DEPTH`, where the spec's own walk stops. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched (comments only): no generated artifact moves (`check-generated` and the spec artifact gates pass on this head). - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5omF1nCbQTGgW1nbRA)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 00d0668 commit 4748bac

7 files changed

Lines changed: 296 additions & 15 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation)!: the subflow guards see a `subflow` / `map` call inside a region body (#28846)
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
**BREAKING** for a packaged flow that calls a packaged child from inside a `loop` body, a `parallel` branch or a `try_catch` slot.
11+
12+
- **Before.** The ADR-0126 §7.3 guards read a flow's calls from its top-level nodes only. A `subflow` or `map` node inside a region body was invisible to them. A packaged caller was armed onto a disabled packaged child and failed mid-run at that node, and the child could be switched off or removed while an enabled caller still called it.
13+
- **Now.** The guards read calls at every region depth, through the spec's region walk (`collectFlowGraphs`). A packaged caller whose region body calls a disabled packaged child registers but is not armed: it warns, and `/_status` reports `unboundCause: 'disabled_subflow'` naming the child. Switching off or removing a packaged child that an enabled packaged caller calls from a region body is refused with `DELETE_RESTRICTED` (409), naming the caller. Re-arming when the child is enabled or republished `active` works as it does for a top-level call.
14+
- **Not changed.** The save-notice half of the arming gate (#28840) still reads top-level calls only. A region cannot carry a durable pause, so a region-nested call whose child suspends fails the run at the region boundary before anything after it runs. Registration, the save door and `objectstack validate` accept the same flows as before.
15+
- **The fix.** Enable the child, or publish it `active`, and the caller arms. To switch off or remove a child, switch off its callers first, as the refusal names.
16+
- **Who moves.** HotCRM's `campaign_enrollment` calls two active children from loop bodies. It still arms. Switching off or removing those children while it is enabled is now refused, as it already was for a top-level call. Showcase and dogfood calls are all top-level.
17+
18+
<!-- adr-0087: not-required (no-migration-prescription) no metadata key is added, removed, renamed or re-shaped and the save door accepts the same flows: the runtime guards now read calls they already should have read, the remedy is an administrator's enable or a republish named in the warning and the refusal, and no migration could rewrite that choice -->

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5215,6 +5215,13 @@ export class AutomationEngine implements IAutomationService {
52155215
* `engine.execute`. Scanning only `subflow` would let a `map` caller break
52165216
* exactly the way §7.3 exists to prevent.
52175217
*
5218+
* [#28846] At EVERY region depth: a `subflow` / `map` node inside a
5219+
* `loop` body, a `parallel` branch or a `try_catch` slot calls its child
5220+
* on each pass exactly as a top-level one does, so a guard that read only
5221+
* the top-level `nodes` armed a caller onto a disabled child and let that
5222+
* child be switched off under it. The regions are read through
5223+
* `collectFlowGraphs`, the spec's one region walk, never a second walker.
5224+
*
52185225
* ⛔ The historical `flow` alias for `config.flowName` is NOT read here.
52195226
* ADR-0087 D2's conversion `flow-node-subflow-flow-alias` canonicalizes it
52205227
* at load, so only the canonical key reaches a registered definition;
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#28846] The subflow guards see a `subflow` / `map` node inside a region
4+
// body (a `loop` body, a `parallel` branch, a `try_catch` slot).
5+
//
6+
// ADR-0126 §7.3 keeps a packaged caller from being armed onto a disabled
7+
// packaged child, and keeps that child from being switched off or removed
8+
// while an armed packaged caller still calls it. Each guard reads the caller's
9+
// calls through `subflowTargets`, which read only the top-level `nodes`: a
10+
// call inside a region body was invisible, so the caller armed onto a disabled
11+
// child and failed mid-run at that node, the late, inexplicable failure §7.3
12+
// refuses to cause. `subflowTargets` now reads every region depth through
13+
// `collectFlowGraphs`, the spec's one region walk, so every guard sees the
14+
// same calls.
15+
//
16+
// The save-notice half of the arming gate (#28840) is deliberately NOT moved
17+
// by this: a region body cannot carry a durable pause, so a region-nested call
18+
// whose child suspends fails the run at the region boundary before anything
19+
// after the container runs. That walk keeps reading top-level calls only, and
20+
// two pins below hold it there.
21+
22+
import { describe, it, expect, vi } from 'vitest';
23+
import { AutomationEngine } from './engine.js';
24+
import type { FlowTrigger, FlowTriggerBinding } from './engine.js';
25+
import { InMemoryFlowActivationStore } from './flow-activation-store.js';
26+
import { registerSubflowNode } from './builtin/subflow-node.js';
27+
import { registerMapNode } from './builtin/map-node.js';
28+
import type { AutomationContext } from '@objectstack/spec/contracts';
29+
import { withLoaderSetFromPull } from './loader-set.test-support.js';
30+
31+
function createTestLogger(): any {
32+
const l: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
33+
l.child = () => l;
34+
return l;
35+
}
36+
37+
const declineWarnings = (logger: any, caller: string): string[] =>
38+
logger.warn.mock.calls
39+
.map((c: unknown[]) => String(c[0]))
40+
.filter((m: string) => m.includes(`Flow '${caller}'`) && m.includes('NOT armed'));
41+
42+
function recordingTrigger() {
43+
const bound = new Set<string>();
44+
const trigger: FlowTrigger = {
45+
type: 'record_change',
46+
start(binding: FlowTriggerBinding, _cb: (ctx: AutomationContext) => Promise<void>) {
47+
bound.add(binding.flowName);
48+
},
49+
stop(flowName: string) {
50+
bound.delete(flowName);
51+
},
52+
};
53+
return { trigger, isBound: (n: string) => bound.has(n) };
54+
}
55+
56+
const nodeCtx = { logger: createTestLogger(), getService: () => undefined } as any;
57+
58+
function engineWithLedger() {
59+
const logger = createTestLogger();
60+
const engine = withLoaderSetFromPull(new AutomationEngine(logger));
61+
engine.setFlowActivationStore(new InMemoryFlowActivationStore());
62+
registerSubflowNode(engine, nodeCtx);
63+
registerMapNode(engine, nodeCtx);
64+
const trigger = recordingTrigger();
65+
engine.registerTrigger(trigger.trigger);
66+
return { engine, logger, isBound: trigger.isBound };
67+
}
68+
69+
const stateOf = (engine: AutomationEngine, name: string) =>
70+
engine.getFlowRuntimeStates().find((s) => s.name === name);
71+
72+
type Node = Record<string, unknown>;
73+
type Region = 'loop' | 'parallel' | 'try_catch';
74+
type CallType = 'subflow' | 'map';
75+
76+
const END: Node = { id: 'end', type: 'end', label: 'End' };
77+
const start = (trigger: boolean): Node => ({
78+
id: 'start', type: 'start', label: 'Start',
79+
config: trigger ? { objectName: 'lead', triggerType: 'record-after-create' } : {},
80+
});
81+
const call = (flowName: string, type: CallType = 'subflow'): Node => ({
82+
id: 'step', type, label: `Call ${flowName}`,
83+
config: { flowName, ...(type === 'map' ? { collection: [1] } : {}) },
84+
});
85+
const NOOP: Node = { id: 'noop', type: 'assignment', label: 'Nothing', config: {} };
86+
87+
/** `inner` as the one node of a region body of `kind`, in a container node `id`. */
88+
function region(kind: Region, inner: Node, id = 'box'): Node {
89+
const body = { nodes: [inner], edges: [] };
90+
if (kind === 'loop') {
91+
return { id, type: 'loop', label: 'Box', config: { collection: [1], iteratorVariable: 'item', body } };
92+
}
93+
if (kind === 'parallel') {
94+
return {
95+
id, type: 'parallel', label: 'Box',
96+
config: { branches: [{ name: 'a', ...body }, { name: 'b', nodes: [{ ...NOOP }], edges: [] }] },
97+
};
98+
}
99+
return { id, type: 'try_catch', label: 'Box', config: { try: body, catch: { nodes: [{ ...NOOP }], edges: [] } } };
100+
}
101+
102+
function chain(name: string, nodes: Node[], extra: Record<string, unknown> = {}) {
103+
return {
104+
name,
105+
label: name,
106+
type: 'autolaunched',
107+
nodes,
108+
edges: nodes.slice(1).map((n, i) => ({ id: `e${i}`, source: nodes[i].id as string, target: n.id as string })),
109+
...extra,
110+
};
111+
}
112+
113+
/** Shipped by a code package (ADR-0029 D9.6 provenance). */
114+
const PACKAGED = { _packageId: 'crm' };
115+
116+
/** A packaged child flow `start → end`, active unless `status` says otherwise. */
117+
const child = (name: string, status?: string) =>
118+
chain(name, [start(false), END], { ...PACKAGED, ...(status !== undefined ? { status } : {}) });
119+
120+
/** A packaged record-triggered caller whose only call to `target` sits inside a region body. */
121+
const regionCaller = (name: string, target: string, kind: Region, type: CallType = 'subflow') =>
122+
chain(name, [start(true), region(kind, call(target, type)), END], PACKAGED);
123+
124+
const CASES: Array<[Region, CallType]> = [
125+
['loop', 'subflow'], ['loop', 'map'],
126+
['parallel', 'subflow'], ['parallel', 'map'],
127+
['try_catch', 'subflow'], ['try_catch', 'map'],
128+
];
129+
130+
describe('the subflow guards read a call inside a region body (#28846)', () => {
131+
it.each(CASES)('arming: a caller whose %s body holds a `%s` call to a disabled packaged child is declined, naming the child', (kind, type) => {
132+
const { engine, logger, isBound } = engineWithLedger();
133+
engine.registerFlow('shared_step', child('shared_step', 'obsolete'));
134+
engine.registerFlow('vendor_process', regionCaller('vendor_process', 'shared_step', kind, type));
135+
136+
expect(isBound('vendor_process')).toBe(false);
137+
const row = stateOf(engine, 'vendor_process');
138+
expect(row).toMatchObject({ enabled: true, bound: false, unboundCause: 'disabled_subflow' });
139+
expect(row?.reason).toContain("'shared_step' (its definition's status is 'obsolete')");
140+
expect(declineWarnings(logger, 'vendor_process')).toHaveLength(1);
141+
});
142+
143+
it('arming: a child republished `obsolete` under an armed caller that calls it from a loop body disarms the caller; republished `active` arms it again', () => {
144+
const { engine, isBound } = engineWithLedger();
145+
engine.registerFlow('shared_step', child('shared_step'));
146+
engine.registerFlow('vendor_process', regionCaller('vendor_process', 'shared_step', 'loop'));
147+
expect(isBound('vendor_process')).toBe(true);
148+
149+
engine.registerFlow('shared_step', child('shared_step', 'obsolete'));
150+
expect(isBound('vendor_process')).toBe(false);
151+
expect(stateOf(engine, 'vendor_process')?.unboundCause).toBe('disabled_subflow');
152+
153+
engine.registerFlow('shared_step', child('shared_step'));
154+
expect(isBound('vendor_process')).toBe(true);
155+
expect(stateOf(engine, 'vendor_process')?.unboundCause).toBeUndefined();
156+
});
157+
158+
it('arming: a call two regions deep (a `parallel` branch inside a `loop` body) is read too', () => {
159+
const { engine, isBound } = engineWithLedger();
160+
engine.registerFlow('shared_step', child('shared_step', 'obsolete'));
161+
engine.registerFlow(
162+
'vendor_process',
163+
chain('vendor_process', [start(true), region('loop', region('parallel', call('shared_step'), 'fan')), END], PACKAGED),
164+
);
165+
expect(isBound('vendor_process')).toBe(false);
166+
expect(stateOf(engine, 'vendor_process')?.unboundCause).toBe('disabled_subflow');
167+
});
168+
169+
it('removal: a child an armed packaged caller calls from a try_catch slot is refused with DELETE_RESTRICTED / 409, naming the caller', () => {
170+
const { engine } = engineWithLedger();
171+
engine.registerFlow('shared_step', child('shared_step'));
172+
engine.registerFlow('vendor_process', regionCaller('vendor_process', 'shared_step', 'try_catch'));
173+
174+
let thrown: any;
175+
try {
176+
engine.unregisterFlow('shared_step');
177+
} catch (e) {
178+
thrown = e;
179+
}
180+
expect(thrown, 'removing the child was accepted').toBeDefined();
181+
expect(thrown.code).toBe('DELETE_RESTRICTED');
182+
expect(thrown.status).toBe(409);
183+
expect(thrown.subflowCallers).toEqual(['vendor_process']);
184+
expect(thrown.message).toContain("Flow 'shared_step' cannot be removed");
185+
expect(engine.getFlow('shared_step'), 'nothing was removed').toBeDefined();
186+
});
187+
188+
it('switch-off: a child an enabled packaged caller calls from a parallel branch is refused with DELETE_RESTRICTED / 409, naming the caller', async () => {
189+
const { engine } = engineWithLedger();
190+
engine.registerFlow('shared_step', child('shared_step'));
191+
engine.registerFlow('vendor_process', regionCaller('vendor_process', 'shared_step', 'parallel', 'map'));
192+
193+
const thrown: any = await engine.toggleFlow('shared_step', false).then(() => undefined, (e: unknown) => e);
194+
expect(thrown, 'switching the child off was accepted').toBeDefined();
195+
expect(thrown.code).toBe('DELETE_RESTRICTED');
196+
expect(thrown.status).toBe(409);
197+
expect(thrown.subflowCallers).toEqual(['vendor_process']);
198+
expect(thrown.message).toContain("Flow 'shared_step' cannot be disabled");
199+
});
200+
201+
it('CONTROL: a caller whose loop body calls an ACTIVE packaged child is armed, with no warning', () => {
202+
const { engine, logger, isBound } = engineWithLedger();
203+
engine.registerFlow('shared_step', child('shared_step'));
204+
engine.registerFlow('vendor_process', regionCaller('vendor_process', 'shared_step', 'loop'));
205+
expect(isBound('vendor_process')).toBe(true);
206+
expect(stateOf(engine, 'vendor_process')?.unboundCause).toBeUndefined();
207+
expect(declineWarnings(logger, 'vendor_process')).toEqual([]);
208+
});
209+
210+
it('CONTROL: a top-level call to a disabled packaged child is declined as before', () => {
211+
const { engine, isBound } = engineWithLedger();
212+
engine.registerFlow('shared_step', child('shared_step', 'obsolete'));
213+
engine.registerFlow('vendor_process', chain('vendor_process', [start(true), call('shared_step'), END], PACKAGED));
214+
expect(isBound('vendor_process')).toBe(false);
215+
expect(stateOf(engine, 'vendor_process')?.unboundCause).toBe('disabled_subflow');
216+
});
217+
});
218+
219+
describe('the save-notice half of the arming gate keeps reading top-level calls (#28840, #28846)', () => {
220+
const WAIT: Node = { id: 'hold', type: 'wait', label: 'Hold', waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H' } };
221+
const NOTICE: Node = { id: 'tell', type: 'save_notice', label: 'Tell the saver', config: { message: 'Saved' } };
222+
223+
it('a save_notice after a region whose body calls a suspending child is not declined on that call: the region cannot carry the pause, so the run fails at the region, before the notice', () => {
224+
const { engine, isBound } = engineWithLedger();
225+
engine.registerFlow('deal_review', chain('deal_review', [start(false), WAIT, END]));
226+
engine.registerFlow('deal_submit', chain('deal_submit', [start(true), region('loop', call('deal_review')), NOTICE, END]));
227+
expect(isBound('deal_submit')).toBe(true);
228+
expect(stateOf(engine, 'deal_submit')?.unboundCause).toBeUndefined();
229+
});
230+
231+
it('a child whose only call to a suspending flow sits in a region body does not count as suspending', () => {
232+
const { engine, isBound } = engineWithLedger();
233+
engine.registerFlow('deal_ask', chain('deal_ask', [start(false), WAIT, END]));
234+
engine.registerFlow('deal_review', chain('deal_review', [start(false), region('loop', call('deal_ask')), END]));
235+
engine.registerFlow('deal_submit', chain('deal_submit', [start(true), call('deal_review'), NOTICE, END]));
236+
expect(isBound('deal_submit')).toBe(true);
237+
expect(stateOf(engine, 'deal_submit')?.unboundCause).toBeUndefined();
238+
});
239+
});

‎packages/services/service-automation/src/subflow-guards.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* no longer `async`: it returns the promise the moved function makes.
1414
*/
1515

16+
import { collectFlowGraphs } from '@objectstack/spec/automation';
1617
import type { FlowParsed } from '@objectstack/spec/automation';
1718
import { describeTurnedOffPackagedItem } from '@objectstack/types';
1819
import type { AutomationEngine } from './engine.js';
@@ -57,15 +58,16 @@ export function describeDisabledFlow(this: Pick<SubflowGuardsHost, 'flowLedgerDi
5758
return `Flow '${flowName}' is disabled`;
5859
}
5960

60-
/** The body of {@link AutomationEngine.subflowTargets}, moved unedited; that method's docblock is its contract. */
61+
/** The body of {@link AutomationEngine.subflowTargets}; that method's docblock is its contract. */
6162
export function subflowTargets(flow: FlowParsed): string[] {
62-
const nodes = (flow as { nodes?: unknown }).nodes;
63-
if (!Array.isArray(nodes)) return [];
6463
const targets: string[] = [];
65-
for (const node of nodes) {
66-
const n = node as { type?: unknown; config?: { flowName?: unknown } } | null;
67-
if (!n || (n.type !== 'subflow' && n.type !== 'map')) continue;
68-
if (typeof n.config?.flowName === 'string') targets.push(n.config.flowName);
64+
// [#28846] The top-level graph and every region body, in walk order.
65+
for (const graph of collectFlowGraphs(flow)) {
66+
for (const node of graph.nodes) {
67+
const n = node as { type?: unknown; config?: { flowName?: unknown } } | null;
68+
if (!n || (n.type !== 'subflow' && n.type !== 'map')) continue;
69+
if (typeof n.config?.flowName === 'string') targets.push(n.config.flowName);
70+
}
6971
}
7072
return targets;
7173
}

‎packages/services/service-automation/src/subflow-notice-guard.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@
1919
* is repeated here rather than exported from `@objectstack/spec`, which keeps
2020
* no internal entry: the order prefers that to a new public helper.
2121
*
22-
* Only top-level `subflow` / `map` nodes are read, the ones
23-
* {@link AutomationEngine.subflowTargets} answers, so the gate and the
24-
* re-judge read the same calls.
22+
* Only top-level `subflow` / `map` nodes are read, here and in a child: a
23+
* call inside a region body cannot suspend the run, because a region cannot
24+
* carry a durable pause and the run fails at its boundary instead (#28846).
25+
* The re-judge finds its callers through
26+
* {@link AutomationEngine.subflowTargets}, which reads every region depth, so
27+
* it re-asks a superset of the flows this reading can move.
2528
*/
2629

2730
import { FLOW_PAUSE_CAPABLE_NODE_TYPES, collectFlowGraphs } from '@objectstack/spec/automation';

‎packages/spec/src/automation/flow.zod.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1640,7 +1640,11 @@ export const FlowSchema = lazySchema(() => strictObject(
16401640
// stands only in a flow a create or update record trigger starts — anywhere
16411641
// else no write waits on the run. Refused at each such node, at any region
16421642
// depth; the reasons are `save-notice-placement.ts`'s header. [#28806] Also
1643-
// refused where a node that always suspends the run reaches it.
1643+
// refused where a node that always suspends the run reaches it. A notice
1644+
// after a `subflow` / `map` whose child can suspend is not refused here: the
1645+
// engine's arming gate declines that flow (#28840,
1646+
// `notice_after_suspending_subflow`), and the run-time refusal by name is the
1647+
// residue.
16441648
for (const refusal of saveNoticePlacementRefusals(flow, FLOW_PAUSE_CAPABLE_NODE_TYPES)) {
16451649
ctx.addIssue({ code: 'custom', path: [...refusal.path], message: refusal.message });
16461650
}

0 commit comments

Comments
 (0)