Skip to content

Commit 21aabbc

Browse files
claude[bot]claude
andauthored
feat(spec)!: one node-id space — FlowSchema refuses a region node whose id is declared elsewhere in the flow (#16134) (#16825)
* feat(spec)!: one node-id space — `FlowSchema` refuses a region node whose id is declared elsewhere in the flow (#16134) The `superRefine` pass that refused a duplicate in the top-level `nodes[]` now walks every graph in the flow via `collectFlowGraphs` — top-level first, then each region in document order, depth first — keeping one map of first declarations. A later occurrence raises the same single `custom` issue, anchored at the later node's own `id` inside its region and naming both locations as a top-level index (`nodes[1]`) or a region path (`loop 'sweep' body → nodes[0]`). One refusal, one shape. `collectFlowGraphs` gains `path` beside `scope` (the key path to the graph, so the issue can be anchored where the author wrote it) and skips a non-object element of a region its own schema refused: that walk now runs inside the parse, where the TypeError it used to throw from `validateControlFlow` would escape `safeParse`. `analyzeRegion` keeps its per-region uniqueness line as an invariant for raw-region callers; a flow that parses never reaches it with a collision. The #15713 boundary pin moves deliberately from "parses" to "is refused"; the #16134 describe pins the region path, formatZodError line, sibling branches, nested depth, walk-order anchoring, one issue for a within-region duplicate, a raw region's authored ids still judged, the non-object guard, and the accept side. ADR-0031 gains the one sentence the ruling asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x * test(spec): region-normalization fixtures carry distinct ids per region under one node-id space (#16134) `gatedRegion()` / `loopWith()` reused `gate` / `write` / `loop` in sibling branches, in try + catch, and in a loop nested three deep — call-assembled fixtures the card's literal-array census declares blind, and the first in-repo flows the one-id-space rule refuses. They pin normalization, not id reuse, so they take a suffix / an id parameter rather than pin the collision. The self-referential termination pin now reads "a bounded ZodError, never a RangeError": the self-reference makes `l` its own body node at every depth, which the rule refuses once the walk stops at the ceiling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x * docs(automation): the flow authoring guide states the one node-id space (#16134) `flows.mdx` said `id` was a "Unique node identifier" with no scope and called a region "self-contained" with no id caveat — the two places an author would now meet the parse refusal without warning. Both now say node ids are one space across the top-level `nodes[]` and every region body, refused at parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x * docs(spec): the parse refuses a collision at every depth it walks, not at every depth — and pin the seam (#16134) Contract review measured the claim "a flow that parses never reaches analyzeRegion with a collision" as one nesting level too strong: collectFlowGraphs stops at MAX_REGION_DEPTH (32), so at nesting 33 the region is left raw, the parse accepts, and validateControlFlow refuses a within-region duplicate in analyzeRegion's own line — the base tree behaves the same there. Reword the two code comments, the changeset paragraph that ships in CHANGELOG.md, and pin the seam: nesting 32 refused at parse, nesting 33 accepted by the parse and refused by validateControlFlow, unique ids at 33 accepted end to end. analyzeRegion's line is kept on purpose — past the ceiling it is the only refusal of a within-region duplicate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x * test(spec): the seam pin's control region is single-entry / single-exit (#16134) The unique-id control at nesting 33 carried two unconnected nodes, which analyzeRegion refuses for a different reason (two entries, two exits); chain them so only the ids differ from the duplicate leg. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e0af1a8 commit 21aabbc

7 files changed

Lines changed: 499 additions & 58 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `FlowSchema` refuses a region node whose id is already declared elsewhere in the flow — one node-id space across the top-level `nodes[]` and every region body (#16134)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-typed: `nodes[].id` keeps its name, its type and its describe at every depth, and every flow whose node ids are unique across the whole flow parses byte-identically. The only newly refused shape is a region node (`loop.config.body`, `try_catch.config.try` / `.catch`, `parallel.config.branches[]`, at any depth the parse walks — nesting up to `MAX_REGION_DEPTH` = 32) carrying an id that a top-level node or a node in another region already declares — a collision, not a spelling — and its remedy is to rename one of the two (and re-point the edges that meant it), which is authoring intent no `objectstack migrate meta` rewrite can choose for the author. The census over this repository at `83863b2df` (AST scan of `packages/**` and `examples/**`: 972 outermost literal `nodes[]` arrays including tests, 66 excluding; 102 region arrays / 86 region nodes with a literal id, 15 / 11 excluding tests; a planted region-reuses-top-level-id control reads 1 at its planted line) found zero cross-region or region-vs-top-level collisions, so there is no in-repo file to name. -->
8+
9+
**BREAKING** accept-set narrowing on `FlowSchema` — a flow has **one node-id
10+
space**. A node inside an ADR-0031 region body (`loop.config.body`,
11+
`try_catch.config.try` / `.catch`, each `parallel.config.branches[]`, nested to
12+
any depth the parse walks — up to `MAX_REGION_DEPTH` = 32 levels) whose `id` is
13+
already declared by a top-level node, or by a node in
14+
any other region of the same flow, is now **refused at parse time** — by
15+
`FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that validates a
16+
flow through the schema (`objectstack validate`, the runtime publish gate, a
17+
stack's `flows[]`) — where it used to parse on green. Shipped as `minor` under
18+
the repo's launch-window convention for breaking changes. Maintainer ruling
19+
(director seat, decision batch #61, 2026-09-07, 「同意」): ADR-0031's
20+
"self-contained single-entry / single-exit sub-graph" describes control flow and
21+
variable scope, not id reuse; every reader that flattens a flow may key on the
22+
bare id. The ADR gains one sentence saying so in this same change.
23+
24+
Before this change uniqueness was enforced **inside** each array — the
25+
top-level `nodes[]` by `FlowSchema` (#15713) and each region body by
26+
`analyzeRegion` at `registerFlow()` — and never **across** them: a loop-body
27+
node could carry the same `id` as a top-level node, or as a node in a sibling
28+
branch, and both rules stayed green. Every edge's `source` / `target` names a
29+
node by id, and the designer canvas, the BPMN export, a flow diff and a
30+
checkpoint's `completedNodeIds` all key on the bare id, so such a collision was
31+
silently wrong wherever a flow is flattened.
32+
33+
**What changes** (`packages/spec/src/automation/flow.zod.ts`): the existing
34+
`superRefine` pass over `nodes[]` now walks every graph the parse reaches via
35+
`collectFlowGraphs` — the top-level graph first, then each region in document
36+
order, depth first, down to `MAX_REGION_DEPTH` (32) — keeping one map of first
37+
declarations. A later occurrence
38+
raises the same single `custom` issue as before, anchored at the later node's
39+
own `id` (inside the region, e.g. `nodes.1.config.body.nodes.0.id`) and naming
40+
both locations — a top-level index (`nodes[1]`) or a region path
41+
(`loop 'sweep' body → nodes[0]`):
42+
43+
```text
44+
✗ nodes.1.config.body.nodes.0.id: Duplicate node id `start` — `loop 'n' body → nodes[0]` reuses the id already declared by `nodes[0]`; every node id in a flow must be unique. Rename one of them: …
45+
```
46+
47+
One refusal, one message shape, at every depth the parse walks: within
48+
`MAX_REGION_DEPTH` an author never sees two issues for one collision. A region
49+
nested beyond that ceiling is left raw by the parse and stays
50+
`validateControlFlow`'s, in its own line — there `analyzeRegion`'s
51+
`duplicate node id 'X'` is the only refusal of a within-region duplicate (a
52+
cross-region collision past the ceiling is not judged), and the same line
53+
guards `bpmn-mapping`'s raw-region caller, so it is kept on purpose.
54+
`collectFlowGraphs` gains a `path` field beside `scope` — the same location as
55+
a key path — so the issue can be anchored where the author wrote the node; it
56+
also now skips a non-object element in a region its own schema refused (such a
57+
region is left raw for `validateControlFlow` to name), where it used to throw a
58+
`TypeError` from inside that validator.
59+
60+
**What does NOT change:** `nodes[].id` keeps its name, type and describe; the
61+
open node-type vocabulary (ADR-0018), the region rules (edge integrity,
62+
single-entry / single-exit, acyclicity) and every other refusal are untouched;
63+
a flow whose node ids are unique across the whole flow parses exactly as
64+
before, region nodes included, in authored order.
65+
66+
The shape that is refused, and what the author does about it — the region node
67+
renamed, and any region edge that meant it re-pointed:
68+
69+
```ts
70+
// before — parsed on green, `start` declared twice (top level + loop body)
71+
nodes: [
72+
{ id: 'start', type: 'start', label: 'Start' },
73+
{ id: 'sweep', type: 'loop', label: 'Sweep', config: { collection: '{items}', body: {
74+
nodes: [{ id: 'start', type: 'assignment', label: 'First step' }],
75+
} } },
76+
{ id: 'end', type: 'end', label: 'End' },
77+
]
78+
79+
// after — refused at parse (nodes.1.config.body.nodes.0.id: Duplicate node id `start` …);
80+
// rename the region node and point the region's edges that meant it at the new id:
81+
nodes: [
82+
{ id: 'start', type: 'start', label: 'Start' },
83+
{ id: 'sweep', type: 'loop', label: 'Sweep', config: { collection: '{items}', body: {
84+
nodes: [{ id: 'sweep_first', type: 'assignment', label: 'First step' }],
85+
} } },
86+
{ id: 'end', type: 'end', label: 'End' },
87+
]
88+
```
89+
90+
**Remedy.** Rename the later node to an id nothing else in that flow carries —
91+
no top-level node, no node in any region — then re-point at the new id the
92+
edges whose `source` / `target` meant it; nothing else in the flow needs to
93+
move. The census over this repository found no flow to migrate, so this is a
94+
release note, not a migration: no shipped example, fixture or seed in
95+
`packages/**` or `examples/**` declares a region node id that collides with a
96+
top-level or another region's node id.

‎content/docs/automation/flows.mdx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ Each node performs a specific action in the flow.
134134

135135
| Property | Type | Required | Description |
136136
| :--- | :--- | :--- | :--- |
137-
| `id` | `string` | ✅ | Unique node identifier |
137+
| `id` | `string` | ✅ | Unique node identifier — unique across the **whole flow**: the top-level `nodes[]` and every region body (`loop.body`, `parallel.branches[]`, `try_catch.try` / `.catch`, at any depth) share one id space, and `FlowSchema` refuses a reused id at parse (`Duplicate node id …`, naming both locations) |
138138
| `type` | `string` | ✅ | Node type — a built-in id from the table above **or** a plugin-registered one. Per ADR-0018 the spec does not gate this with a closed enum; it is checked against the live action registry once that registry is complete — plugins contribute node types while they start, so flows registered during boot are checked in one pass when the vocabulary closes (all plugins started), and anything registered after that (Studio publish, dev reload) is checked immediately. Unknown types warn, never reject; executing one fails with `NO_EXECUTOR` |
139139
| `label` | `string` | ✅ | Display label |
140140
| `config` | `object` | optional | Type-specific configuration — the registered executor's `configSchema` owns its shape. Keys that schema does not declare are rejected at `registerFlow()`, and the built-in executors `parse()` the value against their Zod contract before running (#4277) |
@@ -496,7 +496,11 @@ a malformed construct is rejected before the flow can run.
496496
A region runs in the **enclosing variable scope** (the iterator value and any
497497
body mutations are visible to the surrounding flow) — it is *not* a separate
498498
`subflow` invocation. The container node's ordinary out-edges are the
499-
"after-loop / after-block" continuation.
499+
"after-loop / after-block" continuation. "Self-contained" describes control
500+
flow and variable scope, not ids: node ids are **one space across the whole
501+
flow**, so a region node may not reuse an id declared at the top level or in
502+
any other region — the collision is refused at parse (see `id` under
503+
[Node Structure](#node-structure)).
500504

501505
### Loop container
502506

‎docs/adr/0031-advanced-flow-node-executors-and-dag.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,10 @@ self-contained region in `config` — `config.body` for `loop`,
139139
1. **Well-formed by construction.** A nested region is its *own* graph, so
140140
single-entry is intrinsic and there are no scope markers to balance or leak
141141
across — validation (`analyzeRegion`/`validateControlFlow`) is local.
142+
"Self-contained" describes control flow and variable scope, not id reuse: a
143+
flow's node ids are **one space** across its top-level `nodes[]` and every
144+
region body at every depth, and `FlowSchema` refuses a collision at parse
145+
(#16134).
142146
2. **The shared `engine.ts` traversal stays untouched.** The container executor
143147
runs its body via a scoped `AutomationEngine.runRegion()`; the main DAG
144148
`traverseNext` never learns about scope markers (deliberate, given the

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

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,16 @@ export function analyzeRegion(region: { nodes: FlowNodeParsed[]; edges?: FlowEdg
406406
return { errors: ['region has no nodes'] };
407407
}
408408

409-
// Unique ids.
409+
// Unique ids — an invariant this analysis needs (the degree maps below key
410+
// on id), and the author-facing rule's last line of defence. A flow has ONE
411+
// node-id space, judged by `FlowSchema` at parse over every depth
412+
// `collectFlowGraphs` walks — nesting up to `MAX_REGION_DEPTH` (#16134) — so
413+
// within that ceiling a parsed flow never arrives here carrying a collision.
414+
// Beyond it a region is left raw and reaches this line through
415+
// `validateControlFlow`, where this is the ONLY refusal of a within-region
416+
// duplicate: delete it and the degree maps would silently de-duplicate the
417+
// collision instead. It also guards direct callers that hand in a raw region
418+
// (`bpmn-mapping`).
410419
const ids = new Set<string>();
411420
for (const n of nodes) {
412421
if (ids.has(n.id)) errors.push(`duplicate node id '${n.id}'`);
@@ -681,6 +690,15 @@ export interface FlowGraph {
681690
* `loop 'sweep' body → try_catch 'guard' catch`.
682691
*/
683692
readonly scope: string;
693+
/**
694+
* The same location as {@link scope}, as the key path from the flow root to
695+
* the object holding this graph's `nodes` / `edges`: `[]` for the flow
696+
* itself, `['nodes', 1, 'config', 'body']` for a loop body,
697+
* `['nodes', 1, 'config', 'branches', 0]` for a parallel branch. A Zod issue
698+
* about a region node is anchored where the author wrote it —
699+
* `[...path, 'nodes', i, 'id']` — rather than described in prose (#16134).
700+
*/
701+
readonly path: readonly (string | number)[];
684702
readonly nodes: readonly FlowNodeParsed[];
685703
readonly edges: readonly FlowEdgeParsed[];
686704
}
@@ -706,23 +724,34 @@ export function collectFlowGraphs(
706724
nodes: readonly FlowNodeParsed[],
707725
edges: readonly FlowEdgeParsed[],
708726
scope: string,
727+
path: readonly (string | number)[],
709728
depth: number,
710729
): void => {
711-
graphs.push({ scope, nodes, edges });
730+
graphs.push({ scope, path, nodes, edges });
712731
if (depth >= MAX_REGION_DEPTH) return;
713-
for (const node of nodes) {
732+
nodes.forEach((node, index) => {
733+
// A region its own schema refused is left RAW by `parseFlowNodeRegions`
734+
// for `validateControlFlow` to name, so an element here can be whatever
735+
// the author typed — `null` included. Skip what is not a node object
736+
// rather than read `.config` off it: this walk runs inside `FlowSchema`'s
737+
// parse (#16134), where a thrown TypeError would escape `safeParse`. The
738+
// schema refusal that owns the malformed region still fires — reached now,
739+
// where the throw used to pre-empt it.
740+
const raw: unknown = node;
741+
if (raw === null || typeof raw !== 'object') return;
714742
for (const slot of regionSlotsOf(node)) {
715743
if (!isRegionDict(slot.raw) || !Array.isArray(slot.raw.nodes)) continue;
716744
visit(
717745
slot.raw.nodes as FlowNodeParsed[],
718746
Array.isArray(slot.raw.edges) ? (slot.raw.edges as FlowEdgeParsed[]) : [],
719747
scope ? `${scope} → ${slot.label}` : slot.label,
748+
[...path, 'nodes', index, 'config', slot.key, ...(slot.index === undefined ? [] : [slot.index])],
720749
depth + 1,
721750
);
722751
}
723-
}
752+
});
724753
};
725754

726-
visit(flow.nodes ?? [], flow.edges ?? [], '', 0);
755+
visit(flow.nodes ?? [], flow.edges ?? [], '', [], 0);
727756
return graphs;
728757
}

0 commit comments

Comments
 (0)