Skip to content

Commit 53cf263

Browse files
huangyiireneclaude
andauthored
feat(spec)!: FlowSchema refuses a flow whose top-level nodes[] declares the same id twice (#15713) (#16155)
* feat(spec)!: FlowSchema refuses a flow whose top-level nodes[] declares the same id twice The existing superRefine on FlowSchema gains a pass over the flow's own top-level nodes[], the same shape as the edges[] pass: a later occurrence of an already-declared id raises one custom issue anchored on the LATER node, naming the id and both positions, so the formatted error points at the node to rename. Region bodies stay analyzeRegion's to judge; whether the two id spaces are one is not decided here. Pins: the duplicate refused (issue shape, formatZodError line, one issue per later occurrence naming the first declaration), both card controls (missing label, unknown key) still refused with no duplicate issue beside them, a unique-id flow accepted in authored order, defineFlow refuses, node+edge duplicates in one flow raise one issue each in the same shape, and the scope boundary (a region node reusing a top-level id is outside this rule). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno * docs(spec): name the deferred id-space decision (#16134) at the rule's scope boundary Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 021a735 commit 53cf263

3 files changed

Lines changed: 279 additions & 9 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `FlowSchema` refuses a flow whose top-level `nodes[]` declares the same id twice (#15713)
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, and every flow whose top-level node ids are unique parses byte-identically. The only newly refused shape is two top-level nodes sharing one id — 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 repo at `1f2a02ba` (997 literal `nodes[]` arrays, 2,186 nodes, 2,011 literal ids under `packages/**` and `examples/**` including tests; 79 arrays / 256 nodes excluding tests; AST scan with a planted-duplicate control that reads 1) found zero instances, so there is no in-repo file to name. -->
8+
9+
**BREAKING** accept-set narrowing on `FlowSchema` — a flow whose top-level
10+
`nodes[]` carries two nodes with the same `id` is now **refused at parse time**
11+
— by `FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that
12+
validates a flow through the schema (`objectstack validate`, the runtime
13+
publish gate, a stack's `flows[]`) — where it used to parse on green. Shipped
14+
as `minor` under the repo's launch-window convention for breaking changes. The
15+
exact parallel of #14964 (edge ids, maintainer ruling 2026-09-05, option A —
16+
an `error`, not a `warning`; no opt-out, no transition window), applied to the
17+
other hand-authored id space in the same schema.
18+
19+
Every edge's `source` / `target` names a node by id, and the engine's traversal
20+
picks out-edges by `source` — with two nodes sharing an id, every edge from
21+
that id is ambiguous and whichever node wins is decided by array order,
22+
silently. A designer, a BPMN export and a flow diff key on node ids the same
23+
way they key on edge ids. Only region bodies (`loop` / `try_catch` / `parallel`
24+
sub-graphs) were checked, by `analyzeRegion` at `registerFlow()`; the flow's
25+
own top-level `nodes[]` parsed with the collision intact — measured on
26+
`origin/main` `1f2a02ba` with two lit controls on the same schema instance (a
27+
node missing its `label` → refused at `nodes.1.label`; an unknown key on a node
28+
`unrecognized_keys`).
29+
30+
**What changes** (`packages/spec/src/automation/flow.zod.ts`): the existing
31+
`superRefine` on `FlowSchema` gains a pass over the top-level `nodes[]`, the
32+
same shape as the `edges[]` pass. Each later occurrence of an already-declared
33+
id raises one `custom` issue, anchored at `nodes[N].id` of the *later* node and
34+
naming both positions, so the formatted error points at the node to rename:
35+
36+
```text
37+
✗ nodes.2.id: Duplicate node id `n` — `nodes[2]` reuses the id already declared by `nodes[1]`; every node id in a flow must be unique. Rename one of them: …
38+
```
39+
40+
**What does NOT change:** `nodes[].id` keeps its name, type and describe; the
41+
open node-type vocabulary (ADR-0018), the region rules (`analyzeRegion`) and
42+
every other refusal are untouched; a flow with unique top-level node ids
43+
parses exactly as before. The rule judges the flow's **own top-level**
44+
`nodes[]` only — a region body's nodes remain `analyzeRegion`'s to judge, and
45+
whether a region node may reuse a top-level node id (one id space or two) is a
46+
separate decision (#16134) this change neither takes nor pre-empts.
47+
48+
The shape that is refused, and what the author does about it — a four-node
49+
excerpt, the later node renamed and its edge re-pointed:
50+
51+
```ts
52+
// before — parsed on green, two nodes keyed 'n'
53+
nodes: [
54+
{ id: 'start', type: 'start', label: 'Start' },
55+
{ id: 'n', type: 'assignment', label: 'Assign A' },
56+
{ id: 'n', type: 'assignment', label: 'Assign B' },
57+
{ id: 'end', type: 'end', label: 'End' },
58+
]
59+
60+
// after — refused at parse (nodes.2.id: Duplicate node id `n` …); rename the later one
61+
// and point the edges that meant it at the new id:
62+
nodes: [
63+
{ id: 'start', type: 'start', label: 'Start' },
64+
{ id: 'n', type: 'assignment', label: 'Assign A' },
65+
{ id: 'n2', type: 'assignment', label: 'Assign B' },
66+
{ id: 'end', type: 'end', label: 'End' },
67+
]
68+
```
69+
70+
**Remedy.** Rename the later node to an id no other top-level node in that
71+
flow carries, then re-point at the new id the edges whose `source` / `target`
72+
meant that node; nothing else in the flow needs to move. The census over this
73+
repository found no flow to migrate, so this is a release note, not a
74+
migration: no shipped example, fixture or seed in `packages/**` or
75+
`examples/**` declares a duplicate top-level node id.

packages/spec/src/automation/flow.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1981,3 +1981,169 @@ describe('FlowSchema — edge ids are unique (#14964)', () => {
19811981
expect(issues![0].message).toContain('Duplicate edge id `dup`');
19821982
});
19831983
});
1984+
1985+
describe('FlowSchema — top-level node ids are unique (#15713)', () => {
1986+
// The card's probe, reproduced: a four-node flow with `nodes[1].id ===
1987+
// nodes[2].id === 'n'`, parsed on green (`origin/main` 1f2a02ba re-measured
1988+
// before this rule landed). The two controls beside it — a node missing its
1989+
// required `label`, and an unknown key on a node, on the SAME schema instance
1990+
// — are what proved the acceptance was a missing rule rather than a disabled
1991+
// validator, so both are pinned here too. An invalid node `type` is NOT a
1992+
// control on this schema: the node-type vocabulary is open by contract
1993+
// (ADR-0018), so that lever never fires — recorded so nobody reuses it.
1994+
const edges: Flow['edges'] = [
1995+
{ id: 'e1', source: 'start', target: 'n' },
1996+
{ id: 'e2', source: 'n', target: 'end' },
1997+
];
1998+
const flowWith = (nodes: Flow['nodes']): Flow => ({
1999+
name: 'node_id_flow',
2000+
label: 'Node id flow',
2001+
type: 'autolaunched',
2002+
nodes,
2003+
edges,
2004+
});
2005+
const duplicate = flowWith([
2006+
{ id: 'start', type: 'start', label: 'Start' },
2007+
{ id: 'n', type: 'assignment', label: 'Assign A' },
2008+
{ id: 'n', type: 'assignment', label: 'Assign B' },
2009+
{ id: 'end', type: 'end', label: 'End' },
2010+
]);
2011+
2012+
it('refuses a flow whose top-level nodes[] declares one id twice — the issue names the id and BOTH positions, anchored on the later node', () => {
2013+
const result = FlowSchema.safeParse(duplicate);
2014+
expect(result.success).toBe(false);
2015+
if (result.success) return;
2016+
expect(result.error.issues).toHaveLength(1);
2017+
const [issue] = result.error.issues;
2018+
expect(issue.code).toBe('custom');
2019+
expect(issue.path).toEqual(['nodes', 2, 'id']);
2020+
expect(issue.message).toContain('Duplicate node id `n`');
2021+
expect(issue.message).toContain('`nodes[2]` reuses the id already declared by `nodes[1]`');
2022+
});
2023+
2024+
it('renders through formatZodError as a line that points at the node to rename', () => {
2025+
const result = FlowSchema.safeParse(duplicate);
2026+
expect(result.success).toBe(false);
2027+
if (result.success) return;
2028+
const rendered = formatZodError(result.error);
2029+
expect(rendered).toContain('Validation failed (1 issue):');
2030+
expect(rendered).toContain(
2031+
'✗ nodes.2.id: Duplicate node id `n` — `nodes[2]` reuses the id already declared by `nodes[1]`',
2032+
);
2033+
});
2034+
2035+
it('raises one issue per later occurrence, each naming the FIRST declaration of that id', () => {
2036+
const result = FlowSchema.safeParse(flowWith([
2037+
{ id: 'a', type: 'start', label: 'Start' },
2038+
{ id: 'b', type: 'assignment', label: 'B' },
2039+
{ id: 'a', type: 'assignment', label: 'A again' },
2040+
{ id: 'b', type: 'assignment', label: 'B again' },
2041+
{ id: 'a', type: 'end', label: 'A once more' },
2042+
]));
2043+
expect(result.success).toBe(false);
2044+
if (result.success) return;
2045+
expect(result.error.issues.map((i) => i.path)).toEqual([
2046+
['nodes', 2, 'id'],
2047+
['nodes', 3, 'id'],
2048+
['nodes', 4, 'id'],
2049+
]);
2050+
expect(result.error.issues[0].message).toContain('`nodes[2]` reuses the id already declared by `nodes[0]`');
2051+
expect(result.error.issues[1].message).toContain('`nodes[3]` reuses the id already declared by `nodes[1]`');
2052+
expect(result.error.issues[2].message).toContain('`nodes[4]` reuses the id already declared by `nodes[0]`');
2053+
});
2054+
2055+
it('a duplicate node id and a duplicate edge id in one flow raise one issue each, nodes first, same shape', () => {
2056+
const result = FlowSchema.safeParse({
2057+
...duplicate,
2058+
edges: [
2059+
{ id: 'dup', source: 'start', target: 'n' },
2060+
{ id: 'dup', source: 'n', target: 'end' },
2061+
],
2062+
});
2063+
expect(result.success).toBe(false);
2064+
if (result.success) return;
2065+
expect(result.error.issues.map((i) => [i.code, i.path])).toEqual([
2066+
['custom', ['nodes', 2, 'id']],
2067+
['custom', ['edges', 1, 'id']],
2068+
]);
2069+
expect(result.error.issues[0].message).toMatch(/^Duplicate node id `n` `nodes\[2\]` reuses the id already declared by `nodes\[1\]`; every node id in a flow must be unique\. /);
2070+
expect(result.error.issues[1].message).toMatch(/^Duplicate edge id `dup` `edges\[1\]` reuses the id already declared by `edges\[0\]`; every edge id in a flow must be unique\. /);
2071+
});
2072+
2073+
it('still refuses card control A — a node missing its required `label` — so the refusal above is not a vacuous pass', () => {
2074+
const result = FlowSchema.safeParse(flowWith([
2075+
{ id: 'start', type: 'start', label: 'Start' },
2076+
{ id: 'n', type: 'assignment' } as never,
2077+
{ id: 'end', type: 'end', label: 'End' },
2078+
]));
2079+
expect(result.success).toBe(false);
2080+
if (result.success) return;
2081+
expect(result.error.issues.map((i) => [i.code, i.path])).toEqual([['invalid_type', ['nodes', 1, 'label']]]);
2082+
expect(result.error.issues.some((i) => i.message.includes('Duplicate node id'))).toBe(false);
2083+
});
2084+
2085+
it('still refuses card control B — an unknown key on a node — with `unrecognized_keys`', () => {
2086+
const result = FlowSchema.safeParse(flowWith([
2087+
{ id: 'start', type: 'start', label: 'Start' },
2088+
{ id: 'n', type: 'assignment', label: 'Assign', bogusKey: 1 } as never,
2089+
{ id: 'end', type: 'end', label: 'End' },
2090+
]));
2091+
expect(result.success).toBe(false);
2092+
if (result.success) return;
2093+
expect(result.error.issues.map((i) => i.code)).toEqual(['unrecognized_keys']);
2094+
expect(result.error.issues.some((i) => i.message.includes('Duplicate node id'))).toBe(false);
2095+
});
2096+
2097+
it('accepts the same flow once the ids are unique, keeping the nodes in authored order', () => {
2098+
const unique = flowWith([
2099+
{ id: 'start', type: 'start', label: 'Start' },
2100+
{ id: 'n', type: 'assignment', label: 'Assign A' },
2101+
{ id: 'n2', type: 'assignment', label: 'Assign B' },
2102+
{ id: 'end', type: 'end', label: 'End' },
2103+
]);
2104+
const result = FlowSchema.safeParse(unique);
2105+
expect(result.success).toBe(true);
2106+
if (!result.success) return;
2107+
expect(result.data.nodes.map((n) => n.id)).toEqual(['start', 'n', 'n2', 'end']);
2108+
expect(defineFlow(unique).nodes.map((n) => n.id)).toEqual(['start', 'n', 'n2', 'end']);
2109+
});
2110+
2111+
it('defineFlow refuses the duplicate with the same anchored issue', () => {
2112+
let caught: unknown;
2113+
try {
2114+
defineFlow(duplicate);
2115+
} catch (error) {
2116+
caught = error;
2117+
}
2118+
const issues = (caught as { issues?: Array<{ code: string; path: PropertyKey[]; message: string }> })?.issues;
2119+
expect(issues).toBeDefined();
2120+
expect(issues!.map((i) => [i.code, i.path])).toEqual([['custom', ['nodes', 2, 'id']]]);
2121+
expect(issues![0].message).toContain('Duplicate node id `n`');
2122+
});
2123+
2124+
// Scope boundary, pinned so the rule cannot silently widen: it judges the
2125+
// flow's OWN top-level `nodes[]`. A region body (`loop.config.body.nodes`) is
2126+
// `analyzeRegion`'s to judge, at `registerFlow()`, and whether a region node
2127+
// may reuse a top-level id — one id space or two — is an open decision
2128+
// (#16134) that this rule neither takes nor pre-empts. This pin records
2129+
// today's accept set at that boundary; the decision, when taken, moves it
2130+
// deliberately.
2131+
it('judges the top-level nodes[] only — a region node reusing a top-level id is outside this rule', () => {
2132+
const result = FlowSchema.safeParse(flowWith([
2133+
{ id: 'start', type: 'start', label: 'Start' },
2134+
{
2135+
id: 'n', type: 'loop', label: 'Loop',
2136+
config: {
2137+
collection: '{items}',
2138+
body: {
2139+
nodes: [{ id: 'start', type: 'assignment', label: 'Body step (reuses a top-level id)' }],
2140+
},
2141+
},
2142+
},
2143+
{ id: 'end', type: 'end', label: 'End' },
2144+
]));
2145+
expect(result.success).toBe(true);
2146+
if (!result.success) return;
2147+
expect(result.data.nodes.map((n) => n.id)).toEqual(['start', 'n', 'end']);
2148+
});
2149+
});

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

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -922,15 +922,44 @@ export const FlowSchema = lazySchema(() => strictObject(
922922
...MetadataProtectionFields,
923923

924924
}).superRefine((flow, ctx) => {
925-
// Every reader of `edges[].id` assumes the ids are unique — a designer, a
926-
// BPMN export, a flow diff, any traversal that dedupes by id — while nothing
927-
// enforced it: two edges carrying one id parsed, shipped through green CI
928-
// twice, and were inert only because the engine keys out-edges by `source`
929-
// (#14964). The id space is hand-authored, so the next author picking a
930-
// "free" id from the sequence cannot tell it is taken. Refuse the collision
931-
// here, at parse time, naming the id and BOTH positions; the issue is
932-
// anchored on the later occurrence so the formatted error points at the
933-
// edge to renumber.
925+
// Two hand-authored id spaces, one rule each, one shape — a later occurrence
926+
// of an already-declared id raises one `custom` issue anchored on the LATER
927+
// element and naming BOTH positions, so the formatted error points at the
928+
// one to rename.
929+
//
930+
// Nodes (#15713): every edge's `source` / `target` names a node by id and
931+
// the engine picks out-edges by `source`, so two top-level nodes sharing an
932+
// id make every edge from that id ambiguous — whichever node wins is decided
933+
// by array order, silently. Only region bodies were checked (`analyzeRegion`
934+
// in `control-flow.zod.ts`, at `registerFlow()`); the flow's own top-level
935+
// `nodes[]` parsed with the collision intact. This pass judges the top-level
936+
// array ALONE: a region's nodes are judged by `analyzeRegion`, and whether the
937+
// two spaces are one is a separate decision (#16134), not taken here.
938+
const firstNodeIndexById = new Map<string, number>();
939+
flow.nodes.forEach((node, index) => {
940+
const first = firstNodeIndexById.get(node.id);
941+
if (first === undefined) {
942+
firstNodeIndexById.set(node.id, index);
943+
return;
944+
}
945+
ctx.addIssue({
946+
code: 'custom',
947+
path: ['nodes', index, 'id'],
948+
message:
949+
`Duplicate node id \`${node.id}\` — \`nodes[${index}]\` reuses the id already declared by ` +
950+
`\`nodes[${first}]\`; every node id in a flow must be unique. Rename one of them: a ` +
951+
"node id is the handle every edge's `source`/`target` resolves and a designer, a BPMN " +
952+
'export or a flow diff keys on, so a collision routes edges by array order silently ' +
953+
'rather than failing loudly.',
954+
});
955+
});
956+
957+
// Edges (#14964): every reader of `edges[].id` assumes the ids are unique —
958+
// a designer, a BPMN export, a flow diff, any traversal that dedupes by id —
959+
// while nothing enforced it: two edges carrying one id parsed, shipped
960+
// through green CI twice, and were inert only because the engine keys
961+
// out-edges by `source`. The id space is hand-authored, so the next author
962+
// picking a "free" id from the sequence cannot tell it is taken.
934963
const firstIndexById = new Map<string, number>();
935964
flow.edges.forEach((edge, index) => {
936965
const first = firstIndexById.get(edge.id);

0 commit comments

Comments
 (0)