Skip to content

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

Closed

Description

@huangyiirene

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:

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, completedNodeIds on 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 region nodes: [ … ]; 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].id equal 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 for nodes arrays), 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

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

  1. added a commit that references this issue on Sep 6, 2026
  2. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    分诊 · 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}]`…`
    同处拒绝重复顶层 node id(#15713) ⚠️ 不在 main 上 —— 那段 superRefine 目前只遍历 flow.edges
    analyzeRegion() 的区域内 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 犯错
    两边都有话说,这也是它必须由人裁的原因:

    • 「一个 id 空间」⇒ 一个 AI 在 loop 体里复用顶层 id 会当场被拒,⛔ 不会写出一条含义取决于读者实现的边。
    • 「两个 id 空间」⇒ 规则更简单(「区域内唯一」一句话),但每一个把 flow 摊平的读者(designer canvas、BPMN export、flow diff、checkpoint 的 completedNodeIds、collectFlowGraphs)都必须按带作用域的路径而不是裸 id 取键 —— ⚠️ 而这是一份今天没有任何地方写下来的契约。

    ④ 创业阶段不扩散
    「两个 id 空间」= 零代码,但欠一份把上述契约写下来的文档,并要逐个核对那 5 类读者今天是否真的按裸 id 取键(⛔ 未测量,这是裁「两个」之后的第一项工作)。
    「一个 id 空间」= 一处校验 + clause ② 复审 + 一条 ADR-0031 修订。

    ⚠️ 裁错的代价,不对称

    • 裁「一个」而应为「两个」:区域失去自包含性,未来的子流程复用变难;可回退(放宽比收紧便宜)。
    • 裁「两个」而应为「一个」:跨空间碰撞继续合法,而那 5 类摊平读者今天大概率按裸 id 取键 ⇒ 一条静默错误的边、一份错误的 diff、一个错误的 completedNodeIds 恢复点。⇒ 这一侧的错不会报错,只会给出错误答案。

    定级 p3

    本仓零实例(带正控制),今天没有任何东西是坏的,两个答案都不修 bug。⇒ p3。

    ⚠️ 升级条款,写明以便不必重新论证:若测得上面那 5 类摊平读者中任何一个确实按裸 id 取键、且存在一条从顶层指向区域节点的边(卡说这「已是有文档的形状」),则跨空间碰撞就是一条可静默给出错误答案的路径 ⇒ 按 p2 重定级。⛔ 本轮未测这一点。

    类型 enhancement

    两个答案都不修复既有错误行为;「一个 id 空间」加宽拒绝集(Clause-② yes,届时挂 needs:contract-review),「两个」是文档与读者契约工作。⛔ 我不预挂 needs:contract-review——只有一条臂带它,预挂等于替裁决者选边。


    Generated by Claude Code

  3. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    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.

    Labels: needs-user-decision → pm:queue. Ledger on #12708 (batch #61).


    Generated by Claude Code

  4. self-assigned this
    on Sep 8, 2026
  5. zhuangjianguo commented on Sep 8, 2026

    @zhuangjianguo
    Collaborator

    Claim: session session_016N6xmWt5hYm94ffVEwGH8x · branch claude/issue-16134-one-flow-node-id-space · domain:spec execution seat · 2026-09-08T06:57Z (clock read)

    Dispatching to an os-dev subagent at CONTRACT_REVIEW_TIER. The dev inherits this claim: it does not write the assignee and does not post a second Claim:.

    Ruling being executed — comment 5563453565 (director seat, decision batch #61, 2026-09-07), maintainer verbatim 「同意」: one node-id space. Top-level nodes[] 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 carries needs: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

    ⚠️ Triage recorded (5556536805, at 932acc3d) 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 on origin/main 83863b2df:

    premise reading
    #15713's top-level Duplicate node id refusal on main ✅ packages/spec/src/automation/flow.zod.ts:949
    control — #14964's Duplicate edge id (known present) ✅ present, so the probe answers "yes"
    collectFlowGraphs, the walk the ruling names ✅ exported at control-flow.zod.ts:700, used at :581
    the boundary pin the ruling says to MOVE ✅ flow.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): packages/spec/src/automation/flow.zod.ts · control-flow.zod.ts · flow.test.ts · docs/adr/** (ADR-0031) · .changeset/. ⚠️ Checked against everything live in this lane and it intersects none — #15703 and the held #16524 are both api/protocol.zod.ts, #16721 is core/src/lite-kernel.ts (temporary, local).

    ⚠️ Carried into the dispatch as an escalation clause, not silently dropped: triage's p3 grade rests on zero in-repo instances with a firing control. Its written escalation condition — if any of the five flattening readers keys on the bare id and a top-level→region edge exists — was ⛔ NOT measured. Under this ruling those readers need no change, so the clause does not gate the work; recorded so the next reader does not think it was tested.

    https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x


    Generated by Claude Code

  6. 9 remaining items

  7. claude commented on Sep 8, 2026

    @claude
    Contributor

    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

  8. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    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_TIER seat). The delta since c07ea3249 is textual (comments, changeset wording, one added ceiling test that reddens if MAX_REGION_DEPTH moves); no behaviour change; ADR-0031 still one added sentence. Reviewed-by: director seat session_01TezFG8ZMrNH6n5VTNpPpdH (isolated fable subagent). Implemented-by: branch claude/issue-16134-one-flow-node-id-space.

    Still open, none blocking on the seat: F1 (medium — the changeset's **Remedy.** block under a no-migration-prescription disposition; 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-review cleared on PR and card (read back); card stays pm:awaiting-maintainer; PR stays draft (governed docs/adr/**).


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions