Skip to content

feat(spec)!: FlowSchema refuses a flow whose top-level nodes[] declares the same id twice (#15713) - #16155

Queued
huangyiirene wants to merge 2 commits into
mainfrom
claude/issue-15713-flow-top-level-node-id-uniqueness
Queued

feat(spec)!: FlowSchema refuses a flow whose top-level nodes[] declares the same id twice (#15713)#16155
huangyiirene wants to merge 2 commits into
mainfrom
claude/issue-15713-flow-top-level-node-id-uniqueness

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #15713

FlowSchema gains the top-level nodes[] half of the rule #14964 landed for edges[] (PR #15716, 52804cdb4): a flow whose own top-level nodes[] declares one id twice is refused at parse time, error level, one custom issue per later occurrence, anchored on the later node and naming both positions — the same shape, extended inside the same superRefine, not a second spelling.

✗ 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: …

Premise re-measured on origin/main 1f2a02ba before editing (premise_still_valid: true)

FlowSchema imported from source, the card's four-node flow with nodes[1].id === nodes[2].id === 'n', two lit controls on the same schema instance, plus the landed sibling as a third:

duplicate node ids → success: true   parsed node ids: ["start","n","n","end"]
control A (node missing label)       → success: false  path nodes.1.label
control B (unknown key on a node)    → success: false  code unrecognized_keys
sibling (duplicate edge ids, #14964) → success: false  path edges.1.id

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 grep over packages/** and examples/** excluding dist: one hit; lint-flow-patterns.ts builds nodeIds only to check edge endpoints), and nothing enforces it for the flow's top-level nodes[]. flow.zod.ts already carries superRefine blocks (errorHandling at :890, the #14964 edge pass at :924), so the node pass extends the existing idiom. After the fix, the same probe reads duplicate node ids → success: false [custom, nodes.2.id], both controls unchanged.

Scope (Zone 1 rulings, followed as ruled)

Census (Zone 3 lead, taken; tree 1f2a02ba, AST over packages/** + examples/**, TS/JS literals)

key arrays elements literal ids duplicates
nodes: [ … ], incl. tests 997 2,186 2,011 0
nodes: [ … ], excl. tests 79 256 255 0
edges: [ … ], incl. tests (calibration) 789 1,138 1,106 0

Lit controls on the same instrument: a planted fixture with nodes[2].id === nodes[1].id scanned 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 at flow.test.ts:2060 (the combined node+edge fixture written as an edges: 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 (changeset Remedy paragraph, as #15716 did).

Changes

Clause-②: yes — a published schema's accept set narrows ⇒ needs:contract-review expected 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.tsTest 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 typechecktsc --noEmit + check:scripts-typecheck + check:test-typecheck all green (check:test-typecheck: OK — 54 file(s) / 261 error(s) / 145 pinned signature(s) unchanged ledger).
  • pnpm --filter @objectstack/spec build then check:generated✓ All 15 generated artifacts are up to date (no artifact moved: no describe, export or authorable key changed).
  • Ablation (source-level; the suite imports ./flow.zod, so source is the resolution path): fix committed first; node pass deleted from flow.zod.ts (on-disk proof: marker Duplicate node id count 1 → 0, edge marker still 1, git diff --stat = 1 file / 19 deletions, blob 6f074146 ≠ HEAD blob 174bff41); suite → Tests 5 failed | 106 passed — exactly the five node-refusal pins red, controls and accepts green; restore git checkout HEAD -- ABS_PATH (absolute path, REPO_ROOT from git rev-parse --show-toplevel) proven by git hash-object = HEAD blob 174bff41, git diff HEAD empty, git status --porcelain empty; trap-guarded. Dist leg: node scripts/ablation-dist-preflight.mjs @objectstack/spec 'Duplicate node id' → marker present in 20 built files; a runtime probe against dist/automation/index.mjs refuses the duplicate at nodes.2.id and accepts the unique flow.
  • Derived gates (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, real diff, 3 paths): 73 commands, run twice (once at d8f9861d, again at the final head e9d66a1c after a fresh spec build), --ran reconciliation ✓ 73 derived famil(ies) accounted for — 73 run, 0 NOT-MEASURED both times. 71 exit 0 (@objectstack/lint check:doc-formula-expressions refused with exit 3 until @objectstack/formula was built, then measured green). Two remain exit 3 = PREREQUISITE NOT MET, not measured (they need every package's dist/, i.e. a whole-tree pnpm build): check:dual-build-cjs-loads, check:type-check-debt — declared to CI.
  • Consumers (turbo ls --affected against 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

🤖 Generated with Claude Code

https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno


Generated by Claude Code

…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via FlowSchema (symbol, a top-level const))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via FlowSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 130 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f7ffbd65eaac1919e85dcb597c996d1177b54dbbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from b97ac82792f79cfca03ea7af72585fcc5a869f01 — the merge of head e9d66a1ce3a39ce2276a92248e052de67a9f1358 into base f7ffbd65eaac1919e85dcb597c996d1177b54dbb, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f7ffbd65eaac1919e85dcb597c996d1177b54dbb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — PASS — PR #16155 at head e9d66a1c (Fixes #15713)

Director seat, summon #15, session_01TezFG8ZMrNH6n5VTNpPpdH (os-zhuang), 2026-09-06T03:40Z, batch review under the maintainer's 「按批次执行完所有的契约复审」. Tier fuse: get_session this session reads session_context.model = last_served_model = CONTRACT_REVIEW_TIER. Readings from the PR diff (3 files), card #15713 + triage 5549499021, the seat's claim 5556380947; dev report 5556642058 read afterwards as cross-check. The #14964 sibling ruling (option A, error level, no opt-out) is the precedent this extends.

Implemented-by: session_01T6HeZvT9wdSJD1ZxJb5Eno os-dev round (branch claude/issue-15713-flow-top-level-node-id-uniqueness)
Reviewed-by: session_01TezFG8ZMrNH6n5VTNpPpdH

Clause ② standing — yes (limb 2, accept-set narrowing), correctly declared

FlowSchema refuses a flow whose top-level nodes[] declares one id twice. Limb 1: no exported symbol moves; check:generated reports all 15 artifacts unchanged (no describe, export or authorable key moved).

① Derived judgments

# 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

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

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

3 participants