feat(spec)!: one node-id space — FlowSchema refuses a region node whose id is declared elsewhere in the flow (#16134) - #16825
Conversation
…hose 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
…on 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 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 cf821dc6d14583d66a2f5803c025ab7cb3bf14b2 && git checkout cf821dc6d14583d66a2f5803c025ab7cb3bf14b2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7f96e1417e01d011884272b28278b6b400521415 64c41f20c80b6d8cbe93419b088071cf3e311466 && git checkout -B drift-repro 7f96e1417e01d011884272b28278b6b400521415 && git merge --no-ff 64c41f20c80b6d8cbe93419b088071cf3e311466
node scripts/docs-audit/affected-docs.mjs --json 7f96e1417e01d011884272b28278b6b400521415
|
…ce (#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
Contract review (
|
…t 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
…it (#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
Contract review (
|
| File | +/− | What changed | Behaviour change? |
|---|---|---|---|
packages/spec/src/automation/flow.zod.ts |
+6/−3 | superRefine comment only (the analyzeRegion line is reachable past MAX_REGION_DEPTH; kept) |
No — git diff filtered to non-comment lines is empty |
packages/spec/src/automation/control-flow.zod.ts |
+9/−4 | analyzeRegion comment only ("last line of defence" past the ceiling) |
No — same filter empty; MAX_REGION_DEPTH = 32 (:565) untouched, validateControlFlow (:589-614) untouched, collectFlowGraphs untouched |
.changeset/flow-one-node-id-space.md |
+12/−8 | ADR-0087 marker + BREAKING paragraph + "What changes" + the "one refusal" paragraph now name the ceiling (MAX_REGION_DEPTH = 32) |
n/a |
packages/spec/src/automation/flow.test.ts |
+56/−2 | describe-block comment; one it renamed ("walks every depth" → "walks nested depth"); one test added (the seam pin) |
n/a |
Not in the delta: docs/adr/**, content/docs/**, region-normalization.test.ts. Whole PR vs merge-base 83863b2df is still 7 files, now +499/−58 (433 + 83 − 17 ✓).
Numbered verification
-
No code change beyond comments — confirmed by
git diff c07ea3249..64c41f20c -- flow.zod.ts control-flow.zod.ts | grep '^[+-]' | grep -v '^[+-]\s*//'→ empty.MAX_REGION_DEPTH,parseFlowNodeRegions(:660ceiling),collectFlowGraphs(:731ceiling),validateControlFlow,analyzeRegion— all byte-identical toc07ea3249. No finding. -
The added seam test (
flow.test.ts:2323-2373) — read against the code, not run (this seat's boundary):- Fixture geometry:
loopsNestedTo(nesting, …)wrapsinnermostinl{nesting−1} … l1, thenn; sol_ksits at nestingk, its body atk+1, andinnermostat nesting =nesting.flowWithedges arestart → n → end, matching the outer idn. Idsdup/u1/u2collide with nothing else (n,l1..l32,start,end). ✅ - JSON-round-tripped, no cycle:
roundTripped()doesJSON.parse(JSON.stringify(flowWith([...]))); the fixture is also built with fresh objects per level, so there is no object cycle either way — this is the parsed-JSON shape an author's file would produce, unlike the#4415self-referential pin. ✅ - Nesting 32 refused at parse:
collectFlowGraphs.visitpushes the graph atdepthand returns after pushing whendepth >= 32, so graphs for nesting 0..32 are walked; theduppair at nesting 32 is seen and refused with onecustomissue anchored[…, 'nodes', 1, 'id']. Asserted:success=false,issues.length === 1, message containsDuplicate node id \dup`,path.slice(-3)=['nodes', 1, 'id']`. ✅ - Nesting 33 accepted by parse, refused by
validateControlFlow: the walk never pushes nesting 33 (returns at depth 32), andparseFlowNodeRegionsreturnsl32raw atregionParseDepth === 32, sol32.config.bodyis left raw —safeParsesucceeds.validateControlFlowthen iterates the same 0..32 graphs; at the nesting-32 graph it meetsl32, runsslot.schema.safeParseon its raw body, thenanalyzeRegion→duplicate node id 'dup'→throw new Error(\${where}: …`)wherewhereends→ loop 'l32' body. Asserted:caught instanceof Error,not TypeError, message containsloop 'l32' body: duplicate node id 'dup'and does **not** contain the parse shapeDuplicate node id`. ✅ Matches the PR body's "base tree identical" claim (base had the same two ceilings). - Control: nesting 33 with
u1 → u2chained (ue) — single entry / single exit foranalyzeRegion, every intermediate body a lone loop node —safeParsesucceeds andvalidateControlFlowdoes not throw. ✅ (The64c41f20cfix-up chaining the control is the right one; an unconnected pair is two entries / two exits.) - Does it pin the ceiling honestly? Yes.
MAX_REGION_DEPTHis a non-exportedconst, so the test necessarily uses literals32/33. Raise the constant (e.g. 40) → nesting-33dupbecomes a parse refusal →pastCeiling.successfalse → red. Lower it (e.g. 20) → nesting-32dupparses →atCeiling.successtrue → red. Either move reddens the test, and its title names the number, so the seam moves deliberately. ✅
- Fixture geometry:
-
Changeset text — the BREAKING paragraph now reads "nested to any depth the parse walks — up to
MAX_REGION_DEPTH= 32 levels"; "What changes" adds "down toMAX_REGION_DEPTH(32)"; the "one refusal" paragraph states plainly what lives past the ceiling (within-region duplicate →analyzeRegion's own line; cross-region collision past the ceiling not judged). The contract sentence ("a flow has one node-id space … refused at parse time … by every door") is intact; the ceiling is disclosed as the implementation seam rather than hidden behind "any depth". Honest and not weakened — with one consequence noted as F6 below. The ADR-0087 marker keepsnot-required (no-migration-prescription)with the same reasoning, now also naming the ceiling.Check Changeset(which runscheck-adr-0087-registration.mjs --base) is success on the new head.- F1 (ADR-0087 category held by a detector miss) — still open, unchanged.
.changeset/flow-one-node-id-space.mdstill ships the// before/// afterrewrite block and the**Remedy.**paragraph ("Rename the later node … re-point … the edges"); the delta did not touch those lines and the PR body's "Patch round" section does not mention F1. Same disposition as before: substance sound, category spelling weak; maintainer accepts the marker's reasoning explicitly at merge or the author trims Remedy/before-after to one line. Non-blocking. - F2 ("6 failed" vs 5 re-spelled tests) — still open, unchanged. The PR body still says "179 passed | 6 failed — the 6 are the assembled-fixture collisions", and the "Patch round" section does not name a 6th test or restate the count as collision sites. Non-blocking; no code change implied.
- F1 (ADR-0087 category held by a detector miss) — still open, unchanged.
-
Governed path —
git diff --stat 83863b2df..64c41f20c -- docs/adr/→1 file changed, 4 insertions(+):docs/adr/0031-advanced-flow-node-executors-and-dag.mdis still exactly the one sentence (+4/−0, wrapped), unchanged sincec07ea3249; the ADR keeps the ruling's "at every depth" wording (the contract), per the seat's declared choice. No other governed path (.claude/**,skills/**,AGENTS.md,CLAUDE.md,content/docs/releases/**) in the diff.Governed Surface Queue Guard: success. ⇒ draft, human merge. -
CI on
64c41f20c(34 check runs at review time):- success (16): Check Changeset, Check PR Size, Auto Label, Governed Surface Queue Guard, Spec property liveness, Check Documentation Links, Flag docs affected by code changes, Build Docs, Type Check · source gates, filter, No other open PR may claim the same issue (×2), No other open PR may claim the same single-writer path (×2), Part-of PR must not also close its card (×2).
- skipped (2): Console Pin Gate, Packed-tarball smoke (opt-in).
- in_progress (16): Build Core, Test Core 1–6, Dogfood Regression Gate 1–3, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Type Check · workspace / consumer gates / debt ledger, Lint & Repo Gates.
- failure: 0.
mergeable_state: blocked(draft + required checks unfinished + governed surface). Head is 11 commits behindorigin/main(was 9 at the prior review); the two new commits on main are not in this diff's file set by inspection — CI on the merge ref (cf821dc6d, per the Docs Drift bot) is the check. Labels read back:documentation, size/l, tests, tooling, needs:contract-review— the clause-② carrier is re-hung after the size-labeler's whole-set write, as the PR body declares.
-
Prior findings, disposition on the moved head
- F1 — open (see 3). F2 — open (see 3).
- F3 (info,
FlowGraph.pathrequired member on an exported interface) — unchanged, informational, no action expected. - F4 (info,
#4415pin assertion changed not lost) — unchanged, no action. - F5 (info, second message shape kept in
analyzeRegion) — discharged: the line is now kept on purpose with a stated reason (only refusal of a within-region duplicate past the ceiling, plusbpmn-mapping's raw-region caller), documented in both code comments and the changeset, and pinned by the seam test. The offered deletion is withdrawn in the PR body; nothing left to decide.
Findings
F1 — medium — carried forward, still open. ADR-0087 not-required (no-migration-prescription) is held by a detector miss: the changeset body still carries a **Remedy.** paragraph and a // before / // after rewrite block (.changeset/flow-one-node-id-space.md, unchanged lines), which findMigrationPrescription does not see only for want of ## Migration / FROM → TO / | Wrote | Write instead | framing. Substance of the marker is sound (which node to rename is authoring intent no migrate meta rewrite can choose). Expectation unchanged: maintainer accepts the reasoning explicitly at merge, or the author trims Remedy/before-after to a one-line "rename one of the two". Non-blocking.
F2 — low — carried forward, still open. PR body "179 passed | 6 failed — the 6 are the assembled-fixture collisions" vs 5 re-spelled tests holding 6 collision sites in region-normalization.test.ts. Name the 6th test or reword to "6 collision sites across 5 tests". Non-blocking, text only.
F3 — info — carried forward. FlowGraph.path is a required member on an exported interface (control-flow.zod.ts:696); in-repo additive, compile break for an external FlowGraph literal implementer. No action.
F4 — info — carried forward. #4415 self-referential pin now asserts a bounded ZodError, never RangeError. No action.
F6 — info — new. With the ceiling now stated, the residual gap is explicit: a cross-region collision where the later node sits at nesting ≥ 33 is refused by no door (the parse walk stops at 32; analyzeRegion judges one region at a time), so the ruling's "at every depth" holds up to the shared MAX_REGION_DEPTH and the ADR/flows.mdx wording is one level stronger than the code. Pre-existing ceiling, identical on base, 33-deep nesting is not an authored shape, and the changeset now says exactly this — so this is a maintainer-acknowledgement at merge, not a change request. If the maintainer wants "every depth" literal, it is a follow-up card (walk the id pass unbounded, or raise the ceiling), not this PR. The = 32 literal in the CHANGELOG will go stale if the constant ever moves; acceptable.
Discharged since c07ea3249: F5 (see 6).
Generated by Claude Code
CI condition on the re-review is DISCHARGED — the verdict now stands unconditionally
The re-review at
维护者速读
Generated by Claude Code |
Fixes #16134
Clause-②: yes— this narrows a published accept set (FlowSchema), dispatched atCONTRACT_REVIEW_TIER; carriesneeds:contract-review. Governed (docs/adr/**): draft, human merge.Ruling executed (comment
5563453565, director seat, decision batch #61, 2026-09-07, maintainer 「同意」)What changes
packages/spec/src/automation/flow.zod.ts— thesuperRefinenode pass (FlowSchemaaccepts a flow whose top-levelnodes[]declares the same id twice — measured with two lit controls; only region nodes are checked (analyzeRegion) #15713) now walks every graph the parse reaches viacollectFlowGraphs— top-level first, then each region in document order, depth first, down toMAX_REGION_DEPTH(32) — with one map of first declarations. A later occurrence raises the same singlecustomissue, anchored at the later node's ownidinside its region (nodes.1.config.body.nodes.0.id) and naming both locations as a top-level index (nodes[1]) or a region path (loop 'sweep' body → nodes[0]). One refusal, one message shape.packages/spec/src/automation/control-flow.zod.ts—collectFlowGraphsgainspathbesidescope(the key path to each graph, so the issue is anchored where the author wrote the node — the "equivalent" the ruling allows, additive on the export), and skips a non-object element of a region its own schema refused.analyzeRegionis untouched except for a comment: itsduplicate node id 'X'line stays as an invariant for direct raw-region callers (bpmn-mapping), unreachable through the schema.docs/adr/0031-…— the one sentence..changeset/flow-one-node-id-space.md—@objectstack/specminor,feat(spec)!:, ADR-0087not-required (no-migration-prescription); states the narrowing.Where the single refusal lives, and why
FlowSchema.superRefine, notanalyzeRegion. Every door an author reaches parses first (FlowSchema.parse/safeParse,defineFlow,objectstack validate,registerFlow()= parse →detectCycles→validateControlFlow), so at every depth the parse walks —collectFlowGraphsdescends toMAX_REGION_DEPTH= 32, the ceilingparseFlowNodeRegionsshares — a flow with a collision never reachesanalyzeRegion, and a flow that parses never carries one: an author sees exactly one issue for one collision (pinned, including the within-one-region case). Beyond the ceiling (nesting 33+, measured by contract review and now pinned) the region is left raw, the parse accepts, andvalidateControlFlowrefuses a within-region duplicate inanalyzeRegion's own lowercase line — the base tree behaves identically there, and a cross-region collision past the ceiling is not judged by either tree. That line is therefore kept on purpose: past the ceiling it is the only refusal of a within-region duplicate (deleting it would let the degree maps de-duplicate the collision silently), and it also guardsbpmn-mapping.regionEntryExit's raw-region call.Measurements
AST census, re-run (scratchpad
census.mjs, TypeScript compiler API over literalnodes: […]arrays; ids read only where they are string literals) — treeobjectstack-ai/objectstackat83863b2df(this branch's base; the diff adds no literal fixture), rootspackages/**+examples/**, 6,067 files:nodes[]arrays 972 (66 excluding tests); with at least one regionnodes[]62 (6); region arrays 102 (15); region nodes with a literal id 86 (11); top-level nodes with a literal id 2,078.start) scanned alongside reads 1, kindregion-vs-top, at the planted line 7. Bare run exit 0, control run exit 0.objectuicheckout at61e649a(its branchclaude/pm-dispatch-spec-2d08tz— NOT the pinned.objectui-sha), rootspackages,apps,examples, 4,211 files: 74 outermost (4), 12 region arrays (5), cross-namespace 0, control 1; twotop-vs-tophits inpackages/app-shell/src/views/metadata-admin/previews/flow-problems.test.ts, a preview fixture that declares a duplicate top-level id on purpose —FlowSchemaaccepts a flow whose top-levelnodes[]declares the same id twice — measured with two lit controls; only region nodes are checked (analyzeRegion) #15713's class, not this rule's.flowWith([…]), helper-built regions), code-generated flows, the hotcrm tree (absent from this container). The dynamic reading over the assembled surface is the test suites, and the first targeted run found 6 such collisions in one file —packages/spec/src/automation/region-normalization.test.ts, whosegatedRegion()/loopWith()helpers reusedgate/write/loopacross sibling branches,try+catch, a loop nested three deep, and a self-referential region. Triaged as "change the spelling" (they pin normalization, not id reuse): distinct ids per region; the self-referential termination pin now reads "a bounded ZodError, never a RangeError". After that the full spec suite and the three consumer suites below are green, so no other assembled fixture in those suites collides. The changeset states the narrowing and names no in-repo shipped flow, because none is refused.Tests
All heavy runs through
scripts/pm/os-verify-lock.sh(slotissue-16134); exit codes captured before any pipe; wall-clock figures are shared-box readings. Code tree38c0589cfunless stated; the laterc07ea3249adds onlycontent/docs/automation/flows.mdx(docs families re-measured there, below).pnpm --filter @objectstack/spec build→build-exit=0; the dist carries the change (readonly pathindist/automation/index.d.ts: 1 hit;firstNodeLocationByIdin 20 dist files).pnpm --filter @objectstack/spec test(vitest projectlocal) →Test Files 465 passed (465) · Tests 12966 passed (12966),test-exit=0.pnpm --filter @objectstack/spec typecheck→typecheck-exit=0(tsc --noEmit,check:scripts-typecheck,check:test-typecheck: OK). Lock:VERDICT command-exit 0 · held the lock 530s (8m50s) · waited 157s.turbo run buildof their 31-package dependency closure (29 tasks, exit 0):@objectstack/service-automationvitest run→Test Files 126 passed (126) · Tests 1489 passed;@objectstack/lint→102 passed (102) · 3569 passed;@objectstack/runtime--project local src/domains/automation→18 passed (18) · 347 passed. Lock:VERDICT command-exit 0 · held 536s · waited 353s. A first attempt without the closure built was NOT MEASURED (Failed to resolve entry for package "@objectstack/core") and is not counted. Declared narrowing: runtime is run for its automation domain only, the runtime tests that parse flows; its other test files are CI's.flow.test.ts,region-normalization.test.ts,control-flow.test.ts,flow-region-cycle.test.ts→179 passed | 6 failed— the 6 are the assembled-fixture collisions above, every one aDuplicate node idissue (the dynamic census reading).pnpm --filter @objectstack/spec check:generated→✓ All 15 generated artifacts are up to date(check:api-surface✓ — it compares export listings and signatures, so the additiveFlowGraph.pathfield regenerates nothing).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack: 81 families. Batch at38c0589cf: 78 exit 0, 3 exit 3 (prerequisite refusals):check:doc-formula-expressions→ re-run after the closure build, exit 0;check:dual-build-cjs-loads→ needs all 82 package dists, NOT MEASURED locally — CI'sBuild Core;check:type-check-debt→ after building its last three prerequisites, tsc's re-measure OOMs at the 4096 MB heap (FATAL ERROR: … heap out of memoryafter 154 s) — re-run at a 6144 MB heap (reason: the 4096 MB run OOMed; spec's own DTS build already uses 6144 on this box) →--re-measure: OK — 5 ledger entries re-measured in 78.6s, 55 raw tsc errors total, none above its recorded number, exit 0. Reconciliation--ran:81 derived, 81 run, 0 NOT-MEASURED, 0 UNRUN(the deriver counts runs, not verdicts; the verdict-less ones are the two just named).pnpm check:nul-bytesOK (8,296 files);pnpm check:adr-anchorsOK.origin/main(d4401f75b) and 3 derivation files changed there. No file in that delta overlaps this diff.scripts/sdui-manifest.record.jsonis the objectui pin bump (sha and date only; this diff cites no objectui read-point). origin/main'sscripts/doc-authoring-prose-id.baseline.jsonlaid over this tree reds only onpackages/rest/src/rest-route-ledger.ts(an id fix(rest): the dispatcher's /packages domain is the one implementation of the package read and delete routes (#14503) #16628 removed on main; this diff does not touch the file). origin/main'sscripts/check-undeclared-dep-imports.mjslaid over this tree reds only onpackages/rest/src/package-routes.ts:44(a file fix(rest): the dispatcher's /packages domain is the one implementation of the package read and delete routes (#14503) #16628 changed on main; not in this diff). Both swaps restored to HEAD (porcelain empty). No merge oforigin/mainwas made; CI runs the PR on the merge ref.Ablation
Scratchpad
ablation.sh, run from the committed state38c0589cf;flow.test.tsimports./flow.zod(src, same package — no dist on the resolution path, so no build leg);trap restore EXIT INT TERMwith absolute paths.for (const graph of collectFlowGraphs(flow)) {→for (const graph of collectFlowGraphs(flow).slice(0, 1)) {— walk the top-level graph only, i.e. the pre-ruling rule. On-disk proof: anchor count1 → 0, mutant count0 → 1, blob6599fc30…→26512b13….vitest run src/automation/flow.test.ts→Tests 9 failed | 112 passed (121), exit 1. The 9 red are exactly the movedFlowSchemaaccepts a flow whose top-levelnodes[]declares the same id twice — measured with two lit controls; only region nodes are checked (analyzeRegion) #15713 boundary pin and the eight 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 region pins; the 112 green include everyFlowSchemaaccepts a flow whose top-levelnodes[]declares the same id twice — measured with two lit controls; only region nodes are checked (analyzeRegion) #15713 top-level pin, the non-object-guard pin and the accept-side pin (neither depends on the region half).git checkout HEAD -- ABS_PATH;git diff HEAD0 lines;git hash-objectequals the HEAD blob6599fc30…; porcelain empty.Docs drift (rows handed over by the PM)
content/docs/automation/flows.mdx(hand-written authoring guide) — falsified, fixed in this PR (c07ea3249): the Node Structure table describedidas a "Unique node identifier" with no scope, and the ADR-0031 section called a region "self-contained" with no id caveat — the two places an author would now meet the parse refusal unwarned. Both now state the one id space and the parse refusal. Docs gate families for that path (38 commands, derived) atc07ea3249: 0 nonzero;--ranreconciliation on the new head: all derived families accounted for.content/docs/releases/v17.mdx— read-only, not falsified: its only ADR-0031 line says regions are walked by conversions, validators and lint; it states nothing about node-id scope or reuse. Not edited; nothing for a docs-only card.Census falsification, stated plainly
The card claimed 0 in-repo collisions, measured over literal
nodes: […]arrays only, and declared call-assembled flows (flowWith([…]), helper-built regions) as uncovered. Re-run on the same instrument, the zero holds (above). Going into the declared blind spot, the first targeted test run found 6 in-repo flows the rule refuses — all inpackages/spec/src/automation/region-normalization.test.ts, assembled bygatedRegion()/loopWith(): sibling parallel branches both carryinggate/write,tryandcatchboth carrying them, a loop nested three deep reusing the outer loop'sloopid, and a self-referential region. They were given distinct ids rather than made to pin the collision because they pin normalization (condition envelopes, defaults, strictness at depth), not id reuse; pinning the collision belongs inflow.test.ts, where it now is.Behavioural consequence, not a test edit: a hand-built self-referential region (an object cycle — unreachable from parsed JSON) used to parse: the depth ceiling stopped the descent and nothing else objected. Under one id space the container's own id recurs as a body node at every depth, so
FlowSchema.parsenow refuses it with a boundedZodError(oneDuplicate node idissue per depth, fewer than 64) instead of returning. The termination pin reads exactly that: aZodError, never aRangeError.Patch round (contract review, textual)
Review measured the "every depth" claim as one nesting level too strong: the parse walk judges nesting 0–32; at nesting 33
safeParsesucceeds andvalidateControlFlowthrows… loop 'l32' body: duplicate node id 'dup'(base tree identical). Fixed in2baa5d480+64c41f20c, text only: the two code comments (control-flow.zod.tsanalyzeRegion,flow.zod.tssuperRefine), the changeset paragraph that ships inCHANGELOG.md(plus its three "any depth" qualifiers), and this body. One test added, JSON-round-tripped: nesting 32 refused at parse (oneDuplicate node idissue), nesting 33 accepted by the parse and refused byvalidateControlFlowinanalyzeRegion's line (not aTypeError, not the parse shape), nesting 33 with unique ids — chainedu1 → u2, so only the ids differ — accepted end to end (its first spelling left the control unconnected, whichanalyzeRegionrefuses as two entries / two exits;64c41f20cchains it). Verification at64c41f20cunder the lock:flow.test.ts+region-normalization.test.ts+control-flow.test.ts→Test Files 3 passed (3) · Tests 184 passed (184),pnpm --filter @objectstack/spec typecheckexit 0,VERDICT command-exit 0 · held 68s; lock-free on the same head:check:adr-0087-registration --base origin/main,check-changeset-no-major,check-empty-changeset,check:changeset-gate-self-tests,check:doc-authoring,check:nul-bytes,check:cross-package-test-inputs,check:test-source-alias,check:dispatcher-error-vocabulary,check:type-check-coverage,check-comment-mask-adoption,check-keyed-text-boundsall exit 0;--ranreconciliation102 derived, 102 run, 0 UNRUN. Declared narrowing: comments, a changeset and one test file changed, so the full spec suite and the consumer suites were not re-run past38c0589cf. Labels: the size-labeler's whole-set write on the2baa5d480push (size/m → size/l, actoros-zhuang, 09:04:32Z) strippedneeds:contract-reviewfrom this PR; re-hung additively at 09:07:34Z and read back (documentation,size/l,tests,tooling,needs:contract-review). The card's carrier was removed by the seat's own transition write at 09:04:30Z (pm:dispatched→pm:awaiting-maintainer), socheck-clause2-carriers --pair 16825now reads exit 4 / C1 (card bare) — left to the seat.MAX_REGION_DEPTHuntouched;validateControlFlowuntouched. Not touched, per review: the generatedcontent/docs/references/automation/control-flow.mdx(seat files a docs-only card); ADR-0031 andflows.mdxkeep the ruling's wording (the contract), the ceiling being an implementation seam documented in code and changeset.验收备注
analyzeRegion'sduplicate node id 'X'line — a second shape in the code — is kept deliberately: withinMAX_REGION_DEPTHit is unreachable from any door that parses, and past the ceiling it is the only refusal of a within-region duplicate (rationale above). Contract review ruled the deletion I had offered OUT; withdrawn.collectFlowGraphsthrewTypeError: Cannot read properties of null (reading 'config')on a non-object element of a region its own schema had refused (probe onorigin/main83863b2df:FlowSchema.safeParsesucceeded, thencollectFlowGraphsandvalidateControlFlowboth threw). The walk now runs at parse, where that throw would escapesafeParse, so the guard is part of this change;validateControlFlownow reportsloop 'n' body: invalid region — …for that input (pinned).bpmn-mapping.regionEntryExitreads onlyentryId/exitIdfromanalyzeRegion, so a raw region whoseerrorscarry only a duplicate id passes through; every parsed flow is refused earlier by this rule. 承接者:无.flow-problems.test.tsdeclares duplicate top-level ids on purpose (a preview fixture) —FlowSchemaaccepts a flow whose top-levelnodes[]declares the same id twice — measured with two lit controls; only region nodes are checked (analyzeRegion) #15713's class, no action. 承接者:无.FlowGraph.pathis the one additive export change (an interface field);check:api-surfaceis unchanged because it compares listings and signatures, not.d.tsbytes.@objectstack/specminor,feat(spec)!:, ADR-0087not-required (no-migration-prescription);check:adr-0087-registration --base origin/mainexit 0 in the gate batch.check-clause2-carriers --pair 16825read exit 4 (C1: label on the PR, not on the card) right after the PR opened; the ruling makes it a dual carrier (「两边都挂好」), soneeds:contract-reviewwas added to 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 additively as well (read back:enhancement,pm:dispatched,domain:spec,needs:contract-review,priority:p3) and the predicate now reads exit 0 — both carriers agree.维护者速读(草稿)
改了什么:流程(flow)里所有节点 id 现在是一个命名空间:顶层
nodes[]和任何深度的区域体(loop的 body、try_catch的 try/catch、parallel的每个分支)不能再出现相同的节点 id。冲突在解析(parse)时就被拒绝,错误信息只有一条、一种形状,同时指出两个位置(顶层序号或区域路径)。ADR-0031 加了一句话说明"自包含子图"指的是控制流和变量作用域,不是 id 可以重复。为什么改:每条边的
source/target、设计器画布、BPMN 导出、流程 diff、检查点里的completedNodeIds都按裸 id 索引节点。以前顶层和每个区域各自查重、互不相查,一个区域节点可以和顶层节点同 id,这些读者会静默指错节点。决策批次 #61 裁定"一个 id 空间",本 PR 落地。风险与代价(含回滚):这是一次接受集收窄(Clause-②):以前能解析的"区域节点复用顶层 id"的流程现在会被拒绝。仓库内 AST 普查(字面量数组)为零冲突;但普查看不见用辅助函数拼装的 fixture——本仓
packages/spec的一个测试文件就有这种拼装冲突,本 PR 已按"改拼写"处置。下游应用如有同类拼装流程,升级后会在解析时报错,修法是给冲突节点改名并改指向它的边。回滚:revert 本 PR 即可,无数据迁移。席位意见:(留空)
你要做的:确认裁决执行无误后人工合并(
docs/adr/**为受管面,不走队列)。Generated by Claude Code