Skip to content
Merged
42 changes: 42 additions & 0 deletions .changeset/15646-structured-region-pause-and-end-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@objectstack/spec': minor
---

**BREAKING for authored metadata** — an ADR-0031 structured region body (`loop.config.body`, a `parallel` branch, `try_catch`'s `try` / `catch`) now refuses two node populations at parse: a node whose TYPE parks the run on every execution, and an `end` node (#15646, absorbing #18112).

Clause-②: yes

The flow accept set shrinks for five node types inside region bodies — shapes the runtime never honoured. Both refusals are the authoring-time enforcement of a limit the engine already holds at run time and #3267 ruled 禁: **a region body runs synchronously inside the enclosing run, so it can neither park that run nor terminate it.**

```
✗ nodes.1.config.body.nodes.0.type: A `approval` node may not sit inside a structured region —
`loop 'sweep' body → try_catch 'guard' try` is a region body and the `approval` node `sign_off`
is inside it. A region body runs synchronously and cannot durably pause …
```

**What is refused**

- **A node that pauses on EVERY execution** — `screen`, `wait`, `approval`, `approval_revise`.
- **An `end` node**, whatever its `outcome`. An `end` in a region was a no-op, and a refusing one was converted into a region error at the boundary; neither is what the author wrote.

**⛔ What is deliberately NOT refused: `subflow` and `map`.** Their shipped executors also declare `supportsPause: true`, but they pause exactly when the child flow their `config.flowName` names pauses — a **different metadata record**, not in hand while this flow is parsed. Refusing them by type would also refuse `loop { map(synchronous child) }`, a shape that runs correctly today and is covered by an existing regression suite. A parse-time rule refuses what is statically wrong; a region-contained node that actually suspends is a fact only the run holds. **Nothing an author wrote with a region-nested `map` or `subflow` needs editing for this release.**

**Why it was silent, measured.** The engine converts a suspension raised inside a region into an error — but the executor has already written its progress state into the ENCLOSING scope by then. Contain that error in a `try_catch` and the residue is read back as progress by the next entry to the same node. On a real `AutomationEngine`, `loop { try_catch { map(pausing child) } }` over 3 iterations × 2 items: not one item's subflow completed, only two of three iterations reached the catch, and iteration 3 read `started === collection.length`, ran nothing, and returned `success` with `summary.failed = 0`. ⚠️ Read that for the MECHANISM, not for this change's reach — the shape it was measured on is a `map`, and making that run's refusal loud is a separate change to the automation engine, not this one.

### Migration — FROM → TO

| You wrote | Write instead |
| --- | --- |
| `loop { body: [ …, end ] }` | `loop { body: [ … ] } → end` — give the region a normal exit and put the terminator, with its `outcome` / `message`, on the top-level graph |
| `loop { body: [ wait ] }` | a top-level `wait`, with the top-level graph as the repeating construct — a region body cannot park the run, so the nested form never waited |
| `parallel { branches: [ [ approval ] , … ] }` | put the `approval` on the top-level graph and fan out around it, or split the branch's pausing half into a `subflow` the top-level graph calls |

The one-line fix is always the same: **move the node onto the top-level graph and route the region's exit to it.** ⛔ Not mechanically convertible — hoisting a node out of a region is a graph rewrite (new edges, a changed exit, sometimes a deleted container) and which shape the author meant is an intent no artifact records, so this ships as an ADR-0087 D3 structured TODO rather than a D2 conversion.

<!-- adr-0087: registered structured-region-body-pause-and-end-refused -->

**⚠️ Two boundaries this refusal does not reach, stated rather than discovered.** A pausing node type contributed by a **plugin** is not refused: ADR-0018 left the node-type namespace open and a parse has no registry. A region nested past **`MAX_REGION_DEPTH` (32)** is not judged: the parse walk stops there, and unlike a duplicate node id there is no second spec refusal behind it. For both, the engine's run-time refusal is the only one — unchanged by this change, and not fixed by it.

⛔ No engine source is edited. What the refusal does to the run time is stated rather than left to be discovered: `AutomationEngine.registerFlow` and the ADR-0087 stored-row rehydration seam both go through `FlowSchema.parse` (`canonicalizeStoredFlow`), so a flow carrying a refused shape no longer registers or rehydrates — it is met at LOAD, not at the region boundary, and a stored row that carries one stops loading until it is rewritten. The engine's own run-time refusals for these shapes stay in place but are reachable only through the two boundaries above; for the `end` arm those are the only remaining path, because the refusal signal it answers is raised at exactly one site — an `end` node whose `outcome` is `refused`.

**Published surface.** `FLOW_PAUSE_CAPABLE_NODE_TYPES` is published with the four types above. ⚠️ Read its contents, not its name: it is the UNCONDITIONALLY pausing set, not every type that can pause — `subflow` and `map` declare `supportsPause: true` and are deliberately absent, for the reason above. The identifier is unchanged, so this release removes no export.
Original file line number Diff line number Diff line change
Expand Up @@ -384,48 +384,70 @@ describe('#15788 — the region boundary, made loud', () => {
* throw as the try region FAILING: the author's refusal would run the catch
* handler and the run would still record `completed`.
*
* ⛔ Nothing an author had is narrowed. Before #15788 an `end` inside a
* region was a no-op whatever its `outcome`, so this shape has never once
* been honoured; whether a refusal should instead propagate out of a region
* is a real question the #14945 ruling does not answer.
* ⚠️ #15646 moved that refusal one door EARLIER, and this case moved with
* it. `FlowSchema` now refuses an `end` node inside any ADR-0031 region
* body at parse, and `registerFlow` parses (`canonicalizeStoredFlow` →
* `FlowSchema.parse`), so the fixture below no longer registers at all and
* the run this case used to drive is unreachable. The fixture is
* deliberately unchanged, so this case still fails the day the shape
* becomes declarable again — a re-home, ⛔ not a deletion.
*
* ⚠️ What it no longer covers, stated rather than left to be discovered:
* `runRegion`'s own `isRefusalSignal` arm. `FlowRefusalSignal` is raised at
* exactly one site — an `end` node whose parsed `outcome` is `refused` —
* and the class is not exported, so with the parse refusal in place no
* authored flow reaches that arm except past `MAX_REGION_DEPTH` (32),
* where the spec's region walk stops. The engine code stays as the refusal
* for that one remaining seam, and nothing in this package exercises it.
*
* ⛔ Nothing an author had is narrowed by either door. Before #15788 an
* `end` inside a region was a no-op whatever its `outcome`, so this shape
* has never once been honoured; whether a refusal should instead propagate
* out of a region is a real question the #14945 ruling does not answer.
*/
it('a refusing `end` inside a `loop` body fails the run loudly instead of vanishing', async () => {
it('a refusing `end` inside a `loop` body is refused at REGISTRATION, before any run', () => {
const { engine } = engineWithStore();
installBuiltinNodes(engine, { logger: createTestLogger(), getService: () => undefined } as never);
engine.registerFlow('in_region', {
name: 'in_region', label: 'in_region', type: 'autolaunched',
successMessage: SUCCESS_TEXT,
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{
id: 'sweep', type: 'loop', label: 'Sweep',
config: {
collection: '{items}',
iteratorVariable: 'item',
body: {
nodes: [{ id: 'nope', type: 'end', label: 'Nope', config: { outcome: 'refused', message: REFUSAL_TEMPLATE } }],
edges: [],

let caught: unknown;
try {
engine.registerFlow('in_region', {
name: 'in_region', label: 'in_region', type: 'autolaunched',
successMessage: SUCCESS_TEXT,
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{
id: 'sweep', type: 'loop', label: 'Sweep',
config: {
collection: '{items}',
iteratorVariable: 'item',
body: {
nodes: [{ id: 'nope', type: 'end', label: 'Nope', config: { outcome: 'refused', message: REFUSAL_TEMPLATE } }],
edges: [],
},
},
},
},
],
edges: [{ id: 'e0', source: 'start', target: 'sweep' }],
} as never);

// `items` rides on the trigger record, which the engine flattens into
// the variable map — so `{items}` resolves without declaring an input.
const result = await engine.execute('in_region', {
event: 'manual', object: 'account', record: { ...ACME, items: [1] },
} as unknown as AutomationContext);
],
edges: [{ id: 'e0', source: 'start', target: 'sweep' }],
} as never);
} catch (err) {
caught = err;
}

expect(result.success).toBe(false);
expect(result.status).toBe('failed');
// Refused, and refused by the REGION rule specifically — a bare "it
// threw" would be satisfied by any unrelated parse error.
const issues = (caught as { issues?: Array<{ path: Array<string | number>; message: string }> } | undefined)?.issues ?? [];
const refusal = issues.find((i) => i.message.includes('may not sit inside a structured region'));
expect(refusal, `no region refusal among ${JSON.stringify(issues)}`).toBeDefined();
// Anchored where the author wrote the node, not on the container.
expect(refusal!.path).toEqual(['nodes', 1, 'config', 'body', 'nodes', 0, 'type']);
// The message names the shape and the one-line fix, per "absence must
// be loud" — ⛔ not a bare stringified sentinel.
expect(result.error).toContain('structured region');
expect(result.error).toContain("outcome: 'refused'");
// ⛔ And it is NOT recorded as a refusal: the run did not refuse, the
// engine declined to honour a shape it cannot express.
expect(result.refusalMessage).toBeUndefined();
expect(refusal!.message).toContain('An `end` node may not sit inside a structured region');
expect(refusal!.message).toContain('Put the `end` on the top-level graph');

// …and the refusal is terminal: nothing was registered, so no later
// call can reach the shape by another door.
expect(engine.getFlowVersionHistory('in_region')).toEqual([]);
});
});
11 changes: 7 additions & 4 deletions packages/spec/api-surface-declarations/automation.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
# excluded: documentation drift is `check:docs`'s axis, not this one.
#
# entry: ./automation
# exported names: 273
# declarations: 279
# exported names: 274
# declarations: 280
#
# GENERATED — ⛔ never hand-edited. Regenerate after a real build:
# pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec gen:api-surface-declarations
Expand Down Expand Up @@ -215,8 +215,8 @@ declare const ActionRefSchema: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{

// ── ApprovalDecision (const) ──
declare const ApprovalDecision: z.ZodEnum<{
reject: "reject";
approve: "approve";
reject: "reject";
}>;

// ── ApprovalDecision (type) ──
Expand Down Expand Up @@ -311,8 +311,8 @@ declare const ApprovalNodeConfigSchema: z.ZodObject<{
lockRecord: z.ZodDefault<z.ZodBoolean>;
approvalStatusField: z.ZodOptional<z.ZodString>;
onEmptyApprovers: z.ZodDefault<z.ZodEnum<{
fail: "fail";
fallback: "fallback";
fail: "fail";
auto_approve: "auto_approve";
admin_rescue: "admin_rescue";
}>>;
Expand Down Expand Up @@ -988,6 +988,9 @@ declare const FLOW_BUILTIN_NODE_TYPES: readonly string[];
// ── FLOW_NODE_EXPRESSION_PATHS (const) ──
declare const FLOW_NODE_EXPRESSION_PATHS: readonly FlowNodeExpressionPath[];

// ── FLOW_PAUSE_CAPABLE_NODE_TYPES (const) ──
declare const FLOW_PAUSE_CAPABLE_NODE_TYPES: readonly string[];

// ── FLOW_REGION_CONFIG_KEYS (const) ──
declare const FLOW_REGION_CONFIG_KEYS: ReadonlySet<string>;

Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/automation.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"ExecutionStepSkipReasonSchema (const)",
"FLOW_BUILTIN_NODE_TYPES (const)",
"FLOW_NODE_EXPRESSION_PATHS (const)",
"FLOW_PAUSE_CAPABLE_NODE_TYPES (const)",
"FLOW_REGION_CONFIG_KEYS (const)",
"FLOW_REGION_SLOTS (const)",
"FLOW_REGION_SLOTS_BY_TYPE (const)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/automation.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"ExecutionStepSkipReasonSchema": "src/automation/execution.zod.ts#ExecutionStepSkipReasonSchema (const)",
"FLOW_BUILTIN_NODE_TYPES": "src/automation/flow.zod.ts#FLOW_BUILTIN_NODE_TYPES (const)",
"FLOW_NODE_EXPRESSION_PATHS": "src/automation/flow-node-expression-paths.ts#FLOW_NODE_EXPRESSION_PATHS (const)",
"FLOW_PAUSE_CAPABLE_NODE_TYPES": "src/automation/flow.zod.ts#FLOW_PAUSE_CAPABLE_NODE_TYPES (const)",
"FLOW_REGION_CONFIG_KEYS": "src/automation/region-slots.ts#FLOW_REGION_CONFIG_KEYS (const)",
"FLOW_REGION_SLOTS": "src/automation/region-slots.ts#FLOW_REGION_SLOTS (const)",
"FLOW_REGION_SLOTS_BY_TYPE": "src/automation/region-slots.ts#FLOW_REGION_SLOTS_BY_TYPE (const)",
Expand Down
52 changes: 34 additions & 18 deletions packages/spec/src/automation/end-node-outcome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import { describe, it, expect } from 'vitest';
import { EndConfigSchema } from './builtin-node-config.zod';
import { FlowSchema, FlowNodeSchema, defineFlow, type Flow } from './flow.zod';
import { validateControlFlow } from './control-flow.zod';
import { ExecutionLogSchema, ExecutionStatus } from './execution.zod';
import { formatZodError } from '../shared/error-map.zod';

Expand Down Expand Up @@ -218,13 +217,15 @@ describe('FlowSchema applies the `end` contract — the structural node\'s only
expect(issues?.map((i) => [i.code, i.path])).toEqual([['custom', ['nodes', 1, 'config', 'message']]]);
});

it('a region-nested `end` is checked at the region door: the flow parse leaves the region raw, validateControlFlow refuses it by name', () => {
// `parseFlowNodeRegions` deliberately leaves a region it cannot parse
// untouched (the registration walk owns nested diagnostics, #4389), so the
// FLOW parse alone does not surface a nested refusal — the same boundary
// every other nested node key has. `validateControlFlow` re-parses the
// region through `FlowNodeSchema`, where this contract now lives, and
// throws with the same sentence.
it('a region-nested `end` is refused by the FLOW parse itself (#15646/#18112) — the region-door reading this test used to pin is unreachable, because the shape is gone', () => {
// ⚠️ REPLACED, not re-spelled. This case used to assert that the flow parse
// was GREEN here and that `validateControlFlow` was the door — a true
// reading of `parseFlowNodeRegions` leaving a refused region raw (#4389).
// #15646 removes its subject: an `end` node inside a structured region body
// is refused at parse, whatever its `config`, so there is no longer a
// region-nested `end` whose CONFIG can be judged one door later. Keeping the
// old assertion by weakening it would have pinned an `end`-in-region shape
// that is now undeclarable.
const nested: Flow = {
name: 'nested_refusal',
label: 'Nested refusal',
Expand All @@ -249,16 +250,31 @@ describe('FlowSchema applies the `end` contract — the structural node\'s only
],
};
const parsed = FlowSchema.safeParse(nested);
expect(parsed.success).toBe(true);
if (!parsed.success) return;
let message = '';
try {
validateControlFlow(parsed.data);
} catch (error) {
message = (error as Error).message;
}
expect(message).toContain("loop 'each' body");
expect(message).toContain("`outcome: 'refused'` requires a `message`");
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues.map((i) => [i.code, i.path])).toEqual([
['custom', ['nodes', 1, 'config', 'body', 'nodes', 0, 'type']],
]);
expect(parsed.error.issues[0].message).toContain(
"An `end` node may not sit inside a structured region — `loop 'each' body` is a region body and the `end` node `inner_end` is inside it",
);

// CONTROL — the identical `end` node, identical malformed config, on the
// TOP-LEVEL graph: refused by the `end` CONFIG contract instead, at
// `config.message`. So the reading above is the region rule firing, not this
// fixture being malformed in some way that would fail anywhere.
const topLevel = FlowSchema.safeParse({
...nested,
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'inner_end', type: 'end', label: 'Inner end', config: { outcome: 'refused' } },
],
edges: [{ id: 'e1', source: 'start', target: 'inner_end' }],
} as Flow);
expect(topLevel.success).toBe(false);
if (topLevel.success) return;
expect(topLevel.error.issues.map((i) => i.path)).toEqual([['nodes', 1, 'config', 'message']]);
expect(topLevel.error.issues[0].message).toContain("`outcome: 'refused'` requires a `message`");
});
});

Expand Down
Loading
Loading