WORK-582
ID:WORK-582Status:ready

Prevent duplicate plan IDs pre-merge, and resolve them when provable

Three collisions happened in two days on the SPEC-131 branch — SPEC-133, then BUG-015, then BUG-019. The BUG-019 case had already corrupted the rollups before a human noticed: SPEC-130 and SPEC-131 each claimed a different entity under the same ID, and plan status emitted the same no-milestone warning twice.

Priority:mediumComplexity:moderateMilestone:v0.36.0Source:SPEC-135
claude/refrakt-site-ideas-fctm4i View source

Criteria completion

Criteria completion: 0 of 6 (0%) checked; tracking started on Sep 21, no incremental history yet0%25%50%75%100%Sep 21Oct 11

Tracking started Sep 21 — check back for trends.

Branches 9
History 3
  1. db0a158
    Created (ready)by bjornolofandersson
  2. e8ce995
    Content editedby Claude
    docs(plan): correct v0.36.0 planning against the code, accept SPEC-135
  3. 2fd77d8
    Content editedby Claude
    docs(plan): decompose SPEC-135 into six work items under a v0.36.0 miles

Detection already ships — this item is the other two tiers

This item was originally scoped to add the detection. It is already there. checkDuplicateIds (plugins/plan/src/commands/validate.ts:87, wired at :729) reports at error severity and names the other file, and has since 2026-07-09 — two months before these collisions. Tests cover it at validate.test.ts:49 and unconditional-scan.test.ts:158. Verified by planting two files claiming WORK-999:

{"severity":"error","type":"duplicate-id","source":"WORK-999",
 "file":"work/WORK-999-probe-b.md","target":"work/WORK-999-probe-a.md"}

All three collisions were detectable, at error severity, by a command that already existed. Nobody ran it — which is why the fix for the observed case is WORK-580's CI job, not anything in this item. See SPEC-135 D7.

So the three tiers stand, but only two are work:

  • Detect always. ✅ Ships today. Nothing to build.
  • Prevent early. plan validate --against <ref> compares IDs against a base ref. On a branch, before a merge, every reference to the branch's own entity is unambiguously the branch's — the moment resolution is free, and the moment a PR check fires. This is the valuable tier, and the one detection cannot substitute for: detection tells you two files collide after the merge that made both reachable, when every reference to that ID is already ambiguous.
  • Resolve only when provable. plan migrate ids renumbers, and refuses, naming what it cannot resolve, rather than guessing.

Why resolution must refuse rather than guess

The mechanical half is cheap: extractRefs (scanner-core.ts:175) already finds every {% ref %} / {% xref %} ID because the rollups need them, and plan migrate is an established family — filenames, pr-attrs, dependencies — with --dry-run / --apply / --git, and a precedent of plan validate naming the migration that fixes a finding (validate.ts:548).

The hard part is knowing which entity a reference meant. Once two files share BUG-019, every reference to it is ambiguous, and getting it wrong repoints a reference at the wrong entity — silently, in a file nobody is looking at. A tool that guesses here reproduces the failure class SPEC-131 exists to remove.

Acceptance Criteria

  • plan validate --against <ref> reports IDs colliding with those on the given git ref
  • --against resolves the ref through git and fails clearly when it does not exist, rather than silently reporting no collisions
  • plan migrate ids renumbers a colliding entity to the next free ID, rewrites its filename to the {ID}-{slug} convention, and rewrites every {% ref %}, source, supersedes and ## Blocked by / ## Blocks entry pointing at it
  • It refuses, naming the references it cannot resolve, rather than guessing
  • It follows the family's conventions: dry-run by default, --apply writes, --git stages
  • The duplicate-ID finding names plan migrate ids as its fix, matching how the filename finding names its migration

Approach

Independent of everything else in SPEC-135, and smaller than first scoped now that detection is accounted for.

--against first — it is the piece that changes outcomes, and it is what WORK-580's job wants to run once it exists. migrate ids is the largest part and the least urgent, since prevention removes most of its occasions.

Check the assumption before building on it. This item was scoped against a belief about the code that turned out to be false, and the same trap is open for migrate ids: confirm what extractRefs actually returns (does it carry line numbers? does it cover source= / supersedes= attributes, or only {% ref %} / {% xref %} bodies?) before designing the rewrite around it. The AC below says "every {% ref %}, source, supersedes and dependency-section reference" — verify each of those four is reachable from the existing index rather than assuming it, and widen the index if not.

Do not build post-merge resolution. It is possible with line-level git blame on each ambiguous reference, and it is deliberately out of scope: more machinery, for the case prevention removes, with a silent-wrong failure mode when the heuristic is wrong. SPEC-135 D8.

Notes

plan/ids.json — an allocation ledger that makes git conflict on the ID itself — is recorded in SPEC-135 D9 as a rejected fallback, including the shape that would actually work (a sorted ID-keyed map; a counter merges cleanly on precisely the case that matters). Revisit only if collisions continue after --against has somewhere to run.

Notes on ordering

Deliberately not a dependency edge in either direction. WORK-580 ships with plain plan validate — which already catches duplicates — and gains --against origin/main whenever this item lands. Making the job wait on this item would delay the thing that actually fixes the observed case; making this item wait on the job would be false, since --against is testable on its own.

References

  • SPEC-135 — D7 (error severity, already shipped, and what its being unrun implies), D8 (detect, prevent, resolve-when-provable), D9 (the ledger, rejected, with its working shape)
  • WORK-580 — the CI job that makes the existing detection load-bearing; the actual fix for the three observed collisions
  • plugins/plan/src/commands/validate.ts — checkDuplicateIds at :87, the tier this item no longer has to build
  • plugins/plan/test/validate.test.ts, plugins/plan/test/unconditional-scan.test.ts — the tests that already cover detection
  • plugins/plan/src/scanner-core.ts — extractRefs, the reference index a renumber reuses
  • plugins/plan/src/commands/migrate.ts — the family this joins
  • plugins/plan/src/commands/validate.ts — the precedent of a finding naming its migration