feat(spec)!: FlowSchema refuses a flow whose top-level nodes[] declares the same id twice (#15713) - #16155
Conversation
…es 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
…s scope boundary Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 130 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b97ac82792f79cfca03ea7af72585fcc5a869f01 && git checkout b97ac82792f79cfca03ea7af72585fcc5a869f01
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f7ffbd65eaac1919e85dcb597c996d1177b54dbb e9d66a1ce3a39ce2276a92248e052de67a9f1358 && git checkout -B drift-repro f7ffbd65eaac1919e85dcb597c996d1177b54dbb && git merge --no-ff e9d66a1ce3a39ce2276a92248e052de67a9f1358
node scripts/docs-audit/affected-docs.mjs --json f7ffbd65eaac1919e85dcb597c996d1177b54dbb
|
Contract review (clause ②) — PASS — PR #16155 at head
|
| # | claim | reading | verdict |
|---|---|---|---|
| 1 | Premise re-measured on 1f2a02ba: duplicate node ids parse; two lit controls red on the same instance; the landed edge rule fires |
Dead-control discipline honoured (the card's own failed control — an open node-type vocabulary under ADR-0018 — recorded, not reused). | correct |
| 2 | The node pass extends the existing superRefine, same shape as the #14964 edge pass: one custom issue per later occurrence, anchored on the later node, naming both positions |
Diff read: firstNodeIndexById map, path: ['nodes', index, 'id'], message parallel to the edge one; placed before the edge pass. One family, one spelling. |
correct |
| 3 | Scope: top-level nodes[] only; the one-id-space-or-two question is not decided here and could be implemented around; pinned as today's boundary and filed as #16134 |
The pass never descends into region configs; control-flow.zod.ts untouched. The fork the card and triage named is answered by not pre-empting it — and the boundary is pinned so the later decision moves it deliberately. |
correct |
| 4 | Census with lit controls: 997 literal nodes[] arrays / 2,186 nodes / 0 duplicates (79 / 256 / 0 excluding tests); planted-duplicate control reads 1; edge calibration agrees with #14964 |
A zero with a firing control. Blind spots declared (call-argument fixtures, generated flows, sibling repos). | correct |
| 5 | Nine pins; ablation 5 red / 106 green (exactly the refusal pins); dist preflight probe refuses at nodes.2.id |
Discriminating direction. | correct |
| 6 | Consumers: service-automation, lint, metadata-protocol suites green against a rebuilt closure; the rest declared to CI | Declared narrowing; CI runs the farm. | accepted |
② semver
@objectstack/spec minor with the **BREAKING** banner and <!-- adr-0087: not-required (no-migration-prescription) --> carrying the census — the exact shape #15716 landed for edges. Correct.
③ Boundary flags
- Decision: do a flow's top-level
nodes[]and its region bodies (loop/try_catch/parallel) share ONE node-id space, or two? — uniqueness is now enforced inside each, never across #16134 (one node-id space or two) is a decision card in the maintainer's box; this PR neither takes nor pre-empts it. Not this review's to rule. - Message text is long by design (parallel to the edge one); fine.
Evidence and landing
Checks on e9d66a1c: 13 success / 6 skipped / 16 in progress at review time; check-governed-merges --test on the 3 paths: 0 hits. Clearing, same stroke: needs:contract-review off #15713 and PR #16155 with provenance (the card carried no label; nothing to strip there). Landing (ready + auto-merge SQUASH) from this seat once every check is green — at its next check-in if the dispatching seat has not.
Generated by Claude Code
Fixes #15713
FlowSchemagains the top-levelnodes[]half of the rule #14964 landed foredges[](PR #15716,52804cdb4): a flow whose own top-levelnodes[]declares oneidtwice is refused at parse time,errorlevel, onecustomissue per later occurrence, anchored on the later node and naming both positions — the same shape, extended inside the samesuperRefine, not a second spelling.Premise re-measured on
origin/main1f2a02babefore editing (premise_still_valid: true)FlowSchemaimported from source, the card's four-node flow withnodes[1].id === nodes[2].id === 'n', two lit controls on the same schema instance, plus the landed sibling as a third:Both halves of the dispatch's mechanism assumption hold on that tree:
control-flow.zod.ts:412(analyzeRegion,duplicate node id 'X') is the only node-id uniqueness check in the tree (git grepoverpackages/**andexamples/**excluding dist: one hit;lint-flow-patterns.tsbuildsnodeIdsonly to check edge endpoints), and nothing enforces it for the flow's top-levelnodes[].flow.zod.tsalready carriessuperRefineblocks (errorHandlingat:890, the #14964 edge pass at:924), so the node pass extends the existing idiom. After the fix, the same probe readsduplicate node ids → success: false [custom, nodes.2.id], both controls unchanged.Scope (Zone 1 rulings, followed as ruled)
nodes[]only. A region body's nodes stayanalyzeRegion's to judge atregisterFlow(), one region at a time, exactly as today.config.body/config.try/config.catch/config.branches[]), so there is no fork to report. The decision is filed as Decision: do a flow's top-levelnodes[]and its region bodies (loop/try_catch/parallel) share ONE node-id space, or two? — uniqueness is now enforced inside each, never across #16134 with its own measurements; this PR pins today's boundary (a region node reusing a top-level id still parses) so that decision moves it deliberately rather than by drift.control-flow.zod.tsis untouched.Census (Zone 3 lead, taken; tree
1f2a02ba, AST overpackages/**+examples/**, TS/JS literals)nodes: [ … ], incl. testsnodes: [ … ], excl. testsedges: [ … ], incl. tests (calibration)Lit controls on the same instrument: a planted fixture with
nodes[2].id === nodes[1].idscanned alongside the repo reads 1 at the planted line (edges likewise); the edge calibration agrees with #14964's reading (776 arrays / 1,098 edges, on its older tree); and after this PR's test file landed, the same scan reads 1 edge duplicate atflow.test.ts:2060(the combined node+edge fixture written as anedges:property) — the instrument sees a real duplicate when one exists. Declared blind spots: fixtures passed as call arguments (flowWith([ … ])), flows generated in code, and the pinned objectui / hotcrm trees. So the refusal rejects no shipped example, fixture or seed in this repository ⇒ release note, not migration (changesetRemedyparagraph, as #15716 did).Changes
packages/spec/src/automation/flow.zod.ts— node pass added to the existingFlowSchema.superRefine, before the edge pass; message shape mirrors the edge one.packages/spec/src/automation/flow.test.ts— nine pins: duplicate refused (issue shape);formatZodErrorline; one issue per later occurrence naming the FIRST declaration; node + edge duplicates in one flow → one issue each, nodes first, same shape; both card controls still refused with no duplicate issue beside them; unique flow accepted in authored order (safeParseanddefineFlow);defineFlowrefuses; scope boundary (region node reusing a top-level id parses — Decision: do a flow's top-levelnodes[]and its region bodies (loop/try_catch/parallel) share ONE node-id space, or two? — uniqueness is now enforced inside each, never across #16134)..changeset/flow-node-id-uniqueness.md—@objectstack/specminor with the BREAKING banner and the ADR-0087 dispositionnot-required (no-migration-prescription)carrying the census reading, the shape feat(spec)!:FlowSchemarefuses a flow whoseedges[]declares the same id twice (#14964) #15716 used. No migration registry entry (nothing renamed or retired).Clause-②: yes — a published schema's accept set narrows ⇒
needs:contract-reviewexpected on this draft.Verification (all on
e9d66a1c, the final head; exit codes captured before any pipe)pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/automation/flow.test.ts—Test Files 1 passed (1) · Tests 111 passed (111).pnpm --filter @objectstack/spec test(full package, under the shared verify lock) —Test Files 481 passed | 1 skipped (482) · Tests 12957 passed | 1 skipped (12958), 356s.pnpm --filter @objectstack/spec typecheck—tsc --noEmit+check:scripts-typecheck+check:test-typecheckall green (check:test-typecheck: OK — 54 file(s) / 261 error(s) / 145 pinned signature(s)unchanged ledger).pnpm --filter @objectstack/spec buildthencheck:generated—✓ All 15 generated artifacts are up to date(no artifact moved: no describe, export or authorable key changed)../flow.zod, so source is the resolution path): fix committed first; node pass deleted fromflow.zod.ts(on-disk proof: markerDuplicate node idcount 1 → 0, edge marker still 1,git diff --stat= 1 file / 19 deletions, blob6f074146≠ HEAD blob174bff41); suite →Tests 5 failed | 106 passed— exactly the five node-refusal pins red, controls and accepts green; restoregit checkout HEAD -- ABS_PATH(absolute path,REPO_ROOTfromgit rev-parse --show-toplevel) proven bygit hash-object= HEAD blob174bff41,git diff HEADempty,git status --porcelainempty; trap-guarded. Dist leg:node scripts/ablation-dist-preflight.mjs @objectstack/spec 'Duplicate node id'→ marker present in 20 built files; a runtime probe againstdist/automation/index.mjsrefuses the duplicate atnodes.2.idand accepts the unique flow.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, real diff, 3 paths): 73 commands, run twice (once atd8f9861d, again at the final heade9d66a1cafter a fresh spec build),--ranreconciliation✓ 73 derived famil(ies) accounted for — 73 run, 0 NOT-MEASUREDboth times. 71 exit 0 (@objectstack/lint check:doc-formula-expressionsrefused with exit 3 until@objectstack/formulawas built, then measured green). Two remain exit 3 = PREREQUISITE NOT MET, not measured (they need every package'sdist/, i.e. a whole-treepnpm build):check:dual-build-cjs-loads,check:type-check-debt— declared to CI.turbo ls --affectedagainst BASE = 75 packages — a spec change reaches the whole tree): the three consumers with the most literal flow fixtures in their tests, each run under the shared verify lock against a freshly built dependency closure (turbo run build --filter="@objectstack/service-automation^...", 20 tasks, 0 cached):@objectstack/service-automation(95 fixture files) —Test Files 116 passed (116) · Tests 1395 passed (1395);@objectstack/lint(19) —Test Files 98 passed (98) · Tests 3356 passed (3356);@objectstack/metadata-protocol(17) —Test Files 165 passed | 2 skipped (167) · Tests 2409 passed | 10 skipped (2419). The remaining affected packages are declared to CI (declared narrowing; the census above found no literal duplicate in any of their fixtures).Out of scope, filed
nodes[]and its region bodies (loop/try_catch/parallel) share ONE node-id space, or two? — uniqueness is now enforced inside each, never across #16134 — decision: one node-id space across top-levelnodes[]and region bodies, or two (cross-namespace census: 0 in-repo collisions with a lit control; 53 outer arrays hold regions).🤖 Generated with Claude Code
https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
Generated by Claude Code