You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
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.
0 commit comments