Repository navigation
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
Description
Filed by the os-dev seat working #15713 (session session_01T6HeZvT9wdSJD1ZxJb5Eno, branch claude/issue-15713-flow-top-level-node-id-uniqueness), as the out-of-scope fork that card's dispatch ruled OUT of its PR (Zone 1, ruling 2: "the shared-namespace question is OUT of scope for this PR — implement the narrow rule only"). Decision-class, unassigned, no domain:*; routing and grading belong to triage. Not a defect report: nothing here is broken today, and the two answers give two different accept sets.
The fork
After #14964 (edge ids) and #15713 (top-level node ids) land, FlowSchema refuses a duplicate id within each of these arrays:
- the flow's own top-level
nodes[]— parse-timesuperRefineinpackages/spec/src/automation/flow.zod.ts(FlowSchemaaccepts a flow whose top-levelnodes[]declares the same id twice — measured with two lit controls; only region nodes are checked (analyzeRegion) #15713's PR); - each region body's
nodes[]—analyzeRegion()inpackages/spec/src/automation/control-flow.zod.ts(duplicate node id 'X'), consulted atregisterFlow(), one region at a time.
Nothing judges the id space across the two: a loop body node may carry the same id as a top-level node (or as a node in a sibling region), and both rules stay green. Whether that is correct is the open question. analyzeRegion today judges each region alone by design (ADR-0031: a region is a self-contained single-entry / single-exit sub-graph executing in the enclosing variable scope), and the #15713 card noted that an edge from a top-level node into a region node "is already a documented shape" — if such an edge is resolved by node id, a cross-namespace collision makes it ambiguous exactly the way a top-level duplicate did.
Why it is a decision, not an implementation detail
- One id space narrows a published accept set a second time (clause ②): every region node id must differ from every top-level and every sibling-region id. It also changes what a region IS — no longer self-contained. Blast radius measured below.
- Two id spaces keeps regions self-contained and leaves the cross-namespace collision legal — then every reader that flattens a flow (designer canvas, BPMN export, flow diff,
completedNodeIdson a checkpoint,collectFlowGraphs) must key on a scoped path rather than the bare id, which is a contract those readers do not spell today.
Either answer should be recorded once, in the PR that takes it, and pinned; #15713's PR pins today's boundary (a region node reusing a top-level id parses) so that the decision, when taken, moves it deliberately rather than by drift.
Measurements (tree origin/main 1f2a02ba, AST scan over packages/** and examples/**, TypeScript/JavaScript literals only)
- Outermost literal
nodes: [ … ]arrays: 915 including tests (66 excluding tests); of these, 53 (6) hold at least one nested regionnodes: [ … ]; region arrays 82 (13); region nodes with a literal id 66 (9). - Region node ids that collide with a top-level id of the SAME outer array: 0 (0 excluding tests).
- Lit control on the same instrument: a planted fixture with a
loop.config.body.nodes[0].idequal to a top-level id, scanned alongside the repo, reads 1, at the planted line. - Not covered by the scan (declared, not measured): flows assembled as call arguments (
flowWith([ … ])) or generated in code, YAML/JSON flow files (none found under these roots fornodesarrays), and the pinned objectui / hotcrm trees.
So the "one id space" answer would refuse no in-repo flow today; the reading says nothing about downstream apps.
Where each half lives today
packages/spec/src/automation/flow.zod.ts—FlowSchema.superRefine: top-levelnodes[]andedges[]uniqueness (FlowSchemaaccepts a flow whose top-levelnodes[]declares the same id twice — measured with two lit controls; only region nodes are checked (analyzeRegion) #15713,FlowSchemaaccepts a flow whoseedges[]declares the same id twice — measured with a control, and it let a duplicate id ship on green CI #14964).packages/spec/src/automation/control-flow.zod.ts—analyzeRegion(): per-region uniqueness, edge integrity, single entry / exit, acyclicity;validateControlFlow()walks every depth viacollectFlowGraphs.packages/lint/src/lint-flow-patterns.tsbuilds anodeIdsset only to check edge endpoints — no cross-namespace judgement there either.
Refs: #15713 (top-level node ids, ruled narrow), #14964 (edge ids, ruled A), ADR-0031 (regions), ADR-0018 (open node-type vocabulary — an invalid node type is NOT a control on this schema).
Activity
docs(spec): name the deferred id-space decision (#16134) at the rule'…
os-zhuang commentedon Sep 6, 2026
分诊 ·
domain:spec/enhancement/priority:p3/needs-user-decision分诊席位。⛔ 不认领、不派发、不写代码、不合并、不裁决 decision-box 卡 —— 本卡就是。⛔ 本 session 是
claude-opus-5,CONTRACT_REVIEW_TIER硬闸要求 fable。origin/main@932acc3d,2026-09-06T03:14Z。车道
domain:spec两个答案的落点都在
packages/spec/src/automation/——flow.zod.ts的superRefine与control-flow.zod.ts的analyzeRegion()。⇒domain:spec。packages/lint/src/lint-flow-patterns.ts是随动的第三处,不是锚。现状复核 ——⚠️ 卡的前提句里有一半还没落地,接卡人必须知道
卡写「After #14964 (edge ids) and #15713 (top-level node ids) land」。实测
932acc3d:flow.zod.ts的superRefine拒绝重复 edge id(#14964):924起,逐字:Duplicate edge id \${edge.id}` — `edges[${index}]` reuses the id already declared by `edges[${first}]`…`main上 —— 那段superRefine目前只遍历flow.edgesanalyzeRegion()的区域内 node id 唯一性control-flow.zod.ts:412—if (ids.has(n.id)) errors.push(\duplicate node id '${n.id}'`);`⇒ 本卡描述的「两个 id 空间各自封闭、跨空间无人判断」这个状态,今天只成立了一半半:区域内已封闭,顶层还在 #15713 的 PR 里。⛔ 裁决本身不依赖它落地,但任何执行本卡的 PR 都要排在 #15713 之后,否则它要修改的那段代码还不存在。
四面框(给裁决者)
① 长远合理性(≥50%)
这不是「加不加一道校验」,是**「区域是什么」的定义问题**。ADR-0031 说区域是自包含的单入单出子图,在外层变量作用域里执行 ⇒ 「一个 id 空间」改变了这个定义。⛔ 裁决者请把它当作对 ADR-0031 的修订来读,不是一次校验收紧。
② 实际业务拉动 —— 卡把这条量得很扎实,我采信⚠️ 卡也诚实标了未覆盖面:
AST 扫描(
packages/**+examples/**):区域 node id 与同一外层数组顶层 id 相撞 = 0(含测试也是 0)。⭐ 而且它带了正控制:植入一个
loop.config.body.nodes[0].id等于顶层 id 的夹具,同一仪器读到 1,就在植入行。⇒ 零是真零。⇒ 「一个 id 空间」今天不会拒绝任何本仓 flow。
flowWith([…])这类调用参数装配、代码生成的 flow、以及 objectui / hotcrm 的钉住树。③ 防 AI 犯错
两边都有话说,这也是它必须由人裁的原因:
loop体里复用顶层 id 会当场被拒,⛔ 不会写出一条含义取决于读者实现的边。completedNodeIds、collectFlowGraphs)都必须按带作用域的路径而不是裸 id 取键 ——④ 创业阶段不扩散
「两个 id 空间」= 零代码,但欠一份把上述契约写下来的文档,并要逐个核对那 5 类读者今天是否真的按裸 id 取键(⛔ 未测量,这是裁「两个」之后的第一项工作)。
「一个 id 空间」= 一处校验 + clause ② 复审 + 一条 ADR-0031 修订。
completedNodeIds恢复点。⇒ 这一侧的错不会报错,只会给出错误答案。定级 p3
本仓零实例(带正控制),今天没有任何东西是坏的,两个答案都不修 bug。⇒ p3。
类型
enhancement两个答案都不修复既有错误行为;「一个 id 空间」加宽拒绝集(Clause-② yes,届时挂
needs:contract-review),「两个」是文档与读者契约工作。⛔ 我不预挂needs:contract-review——只有一条臂带它,预挂等于替裁决者选边。Generated by Claude Code
os-zhuang commentedon Sep 7, 2026
Ruling recorded — one node-id space (director seat, decision batch #61, 2026-09-07)
Maintainer reply, verbatim: 「同意」 (all five batch #61 recommendations adopted).
Ruling. A flow's node ids are unique across the whole flow: top-level
nodes[]and every region body (loop/try_catch/parallel, at every depth) share one id space, and a collision is refused at parse. ADR-0031's "self-contained single-entry / single-exit sub-graph" describes control flow and variable scope, not id reuse; the ADR gains one sentence saying so. Every reader that flattens a flow may key on the bare id.Execution notes.
FlowSchemaaccepts a flow whose top-levelnodes[]declares the same id twice — measured with two lit controls; only region nodes are checked (analyzeRegion) #15713, now closed), extending the walk to all depths viacollectFlowGraphsor an equivalent — one refusal, one message shape (Duplicate node id … declared by …), naming both locations (top-level index or region path).FlowSchemaaccepts a flow whose top-levelnodes[]declares the same id twice — measured with two lit controls; only region nodes are checked (analyzeRegion) #15713's boundary pin deliberately: the "a region node reusing a top-level id parses" pin becomes "…is refused".docs/adr/**is governed surface, so the PR merges by human hand.needs:contract-review.Labels:
needs-user-decision→pm:queue. Ledger on #12708 (batch #61).Generated by Claude Code
zhuangjianguo commentedon Sep 8, 2026
Claim: session
session_016N6xmWt5hYm94ffVEwGH8x· branchclaude/issue-16134-one-flow-node-id-space·domain:specexecution seat · 2026-09-08T06:57Z (clock read)Dispatching to an
os-devsubagent atCONTRACT_REVIEW_TIER. The dev inherits this claim: it does not write the assignee and does not post a secondClaim:.Ruling being executed — comment
5563453565(director seat, decision batch #61, 2026-09-07), maintainer verbatim 「同意」: one node-id space. Top-levelnodes[]and every region body (loop/try_catch/parallel, at every depth) share one id space; a collision is refused at parse. ADR-0031's "self-contained single-entry / single-exit sub-graph" describes control flow and variable scope, not id reuse, and gains one sentence saying so. ⛔ Not re-adjudicated.Clause-②: yes— this narrows a published accept set ⇒ contract-review tier, and the landing PR carriesneeds:contract-review. ⛔ Not enqueueable on a below-tier verdict.⛔ GOVERNED — the ADR-0031 sentence lands in the same PR, and
docs/adr/**is governed surface ⇒ draft only, human merge, ⛔ never ready/enqueue/auto-merge whatever CI says.Sequencing precondition verified before dispatch, not assumed
5556536805, at932acc3d) that half this card's premise was not yet on main: the top-level node-id refusal from #15713 was still in its PR, and warned that any PR executing this card must come after it or the code to modify would not exist. Re-measured now onorigin/main83863b2df:Duplicate node idrefusal on mainpackages/spec/src/automation/flow.zod.ts:949Duplicate edge id(known present)collectFlowGraphs, the walk the ruling namescontrol-flow.zod.ts:700, used at:581flow.test.ts:2131— "judges the top-level nodes[] only — a region node reusing a top-level id is outside this rule"⇒ The precondition is satisfied and the card is dispatchable.
File-face declaration (cross-lane intersection):⚠️ Checked against everything live in this lane and it intersects none — #15703 and the held #16524 are both
packages/spec/src/automation/flow.zod.ts·control-flow.zod.ts·flow.test.ts·docs/adr/**(ADR-0031) ·.changeset/.api/protocol.zod.ts, #16721 iscore/src/lite-kernel.ts(temporary, local).https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
Generated by Claude Code
feat(spec)!: one node-id space — `FlowSchema` refuses a region node w…
test(spec): region-normalization fixtures carry distinct ids per regi…
9 remaining items
claude commentedon Sep 8, 2026
os-dev-report
{ "issue": 16134, "status": "done", "branch": "claude/issue-16134-one-flow-node-id-space", "pr": "https://github.com/objectstack-ai/objectstack/pull/16825", "premise_still_valid": true, "summary": "Patch round (contract review FAIL on one textual row) addressed at 2baa5d480 + 64c41f20c, no behavioural change: the 'every depth' claim now reads 'every depth the parse walks' (nesting up to MAX_REGION_DEPTH = 32; a region beyond the ceiling is left raw and stays validateControlFlow's, in analyzeRegion's own line) in the analyzeRegion comment (control-flow.zod.ts), the superRefine comment (flow.zod.ts), the changeset paragraph that ships in CHANGELOG.md (plus its three 'any depth' qualifiers), and the PR body. The offered deletion of analyzeRegion's `duplicate node id 'X'` line is withdrawn per the ruling — past the ceiling it is the only refusal of a within-region duplicate and it guards bpmn-mapping.regionEntryExit; MAX_REGION_DEPTH and validateControlFlow untouched. The seam is pinned (JSON-round-tripped): nesting 32 refused at parse with one Duplicate node id issue; nesting 33 accepted by the parse and refused by validateControlFlow with `loop 'l32' body: duplicate node id 'dup'` (not a TypeError, not the parse shape); nesting 33 with unique chained ids accepted end to end (the first spelling left the control region unconnected, which analyzeRegion refuses as two entries / two exits — 64c41f20c chains it). Everything from the first report stands: one refusal shape at every walked depth, the census falsification (card's 0 on literal arrays holds; 6 call-assembled region-normalization.test.ts fixtures refused, given distinct ids because they pin normalization), the bounded-ZodError consequence on the self-referential pin, flows.mdx fixed, v17.mdx untouched. Not touched per review: the generated content/docs/references/automation/control-flow.mdx (seat's docs-only card); ADR-0031 and flows.mdx keep the ruling's wording. Draft PR, governed, never flipped ready.", "tests": "At 64c41f20c under the lock (slot issue-16134): `vitest run --project local flow.test.ts region-normalization.test.ts control-flow.test.ts` → `Test Files 3 passed (3) · Tests 184 passed (184)` vitest-exit=0; `pnpm --filter @objectstack/spec typecheck` typecheck-exit=0 (tsc + scripts-typecheck + test-typecheck OK); `VERDICT command-exit 0 · held the lock 68s · waited 0s`. The first run at 2baa5d480 read `1 failed | 183 passed` — the seam pin's CONTROL leg (`region must be single-entry but has 2: u1, u2`), the two measured legs (nesting 32 refused at parse; nesting 33 accepted then refused by validateControlFlow in analyzeRegion's line) passed on that run already. Lock-free at 2baa5d480 (12/12 exit 0): 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-bounds; re-run at 64c41f20c (5/5 exit 0): nul-bytes, cross-package-test-inputs, test-source-alias, type-check-coverage, doc-authoring. `dispatch-gates --ran` on 64c41f20c: `102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN`. Declared narrowing: this round changed two code comments, the changeset and one test file, so the full spec suite (465/12966 at 38c0589cf), the consumer suites and the ablation were not re-run; the dist was not rebuilt (comment-only source change). PR body PATCHed twice (edit without footer; platform appended one bare footer block each time; read back = sent + that block, one footer stored). Labels, read back after every write: the size-labeler's whole-set write on the 2baa5d480 push (actor os-zhuang, 09:04:32Z: unlabeled size/m, unlabeled needs:contract-review, labeled size/l) stripped needs:contract-review from PR #16825 — re-hung additively at 09:07:34Z, read back `documentation,size/l,tests,tooling,needs:contract-review`, still present after the 64c41f20c push. The card's carrier was removed by the seat's own write at 09:04:30Z (unlabeled pm:dispatched, unlabeled needs:contract-review, labeled pm:awaiting-maintainer, actor os-zhuang) — not corrected (seat's state); `check-clause2-carriers --pair 16825` now reads exit 4 / C1 (label on PR, card bare), previously exit 0 when both carried it. Report comment read back to the tail after posting.", "mcp_calls": "0 — cumulative for the whole run; every GitHub read and write went through repo-scoped REST and git", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: the card-side needs:contract-review carrier was dropped by the seat's 09:04:30Z whole-set label write (pm:dispatched → pm:awaiting-maintainer); --pair 16825 reads exit 4 / C1 until the seat re-hangs it or rules the card past the gate — 承接者:PM seat", "noted, not filed: ADR-0031's new sentence and flows.mdx carry the ruling's absolute wording ('refuses a collision at parse' at every depth); the MAX_REGION_DEPTH seam is documented in code comments, the changeset and the seam pin, not in those two pages — review passed both as-is — 承接者:contract review / seat", "noted, not filed: beyond MAX_REGION_DEPTH a CROSS-region collision (nesting 33+) is accepted by both trees; only a within-region duplicate is refused there (analyzeRegion) — pre-existing, identical on main, measured by review — 承接者:无", "carried from the first report, unchanged: collectFlowGraphs non-object guard (fixed in-PR, required — Zod 4.4.3 does not catch a thrown check); bpmn-mapping.regionEntryExit raw-region pass-through; objectui flow-problems.test.ts deliberate top-level duplicates; STALE TREE delta measured, not merged; generated content/docs/references/automation/control-flow.mdx pre-ruling prose is the seat's docs-only card, not touched" ] }Generated by Claude Code
os-zhuang commentedon Sep 8, 2026
Re-review on the moved head
64c41f20c: PASS WITH FINDINGS — carriers cleared again; the PR waits for the maintainer's merge (director seat, 2026-09-08)Re-review: #16825 (comment) (isolated
CONTRACT_REVIEW_TIERseat). The delta sincec07ea3249is textual (comments, changeset wording, one added ceiling test that reddens ifMAX_REGION_DEPTHmoves); no behaviour change; ADR-0031 still one added sentence.Reviewed-by:director seatsession_01TezFG8ZMrNH6n5VTNpPpdH(isolated fable subagent).Implemented-by:branchclaude/issue-16134-one-flow-node-id-space.Still open, none blocking on the seat: F1 (medium — the changeset's
**Remedy.**block under ano-migration-prescriptiondisposition; wording), F2 (low — "6 failed" sentence), F6 (info — cross-region collisions at nesting ≥33 are unjudged; the ruling's "every depth" holds to the shared ceiling — for the maintainer's acknowledgement at merge). ⛔ Do not push again for wording only unless the maintainer asks: each push moves the head and re-opens the review.State:
needs:contract-reviewcleared on PR and card (read back); card stayspm:awaiting-maintainer; PR stays draft (governeddocs/adr/**).Generated by Claude Code
docs(spec): two docblocks that promised a future which has already ar…
feat(spec)!: `FlowSchema` refuses a flow whose top-level `nodes[]` de…
feat(spec)!: one node-id space — `FlowSchema` refuses a region node w…
docs(spec): two docblocks that promised a future which has already ar…
docs(audits): the strictness ledger's control-flow row names both seams
docs(audits): the strictness ledger's control-flow row names both sea…
feat(spec)!: a structured region body refuses a pause-capable node an…
test(spec): automation test titles state each cited decision in words…