RUL-031 — Requirement ids have exactly one spelling

Ruling

A requirement id has one canonical spelling. The legacy story form is not a tolerated variant, not a ratchet to burn down over time, and not something to baseline — it fails (user ruling, 2026-08-19: "legacy must NOT BE ACCEPTED", "I want then fix so this never gets mentioned or is a problem again").

Canonical story-scoped ids are written:

REQ-<spec:3>-US-<story:3>-<seq:3>        e.g. REQ-001-US-002-009

The story segment is US, a hyphen, then three digits. The full grammar — including the non-story segments (FR, AC, NFR, SEC, PERF, INTG, SUCC, EXT), the subsystem-tag forms and the cluster/task forms — is docs/req-id-grammar.md in orchestrata-ultimate.

What "legacy" actually was

It was never one wrong spelling. The corpus carried three, and a fix aimed at only the obvious one would have left the other two in place:

Form Shape Example shape
1-digit US + one digit REQ-NNN-USn-MMM
2-digit US + two digits REQ-NNN-USnn-MMM
3-digit, no hyphen US + three digits REQ-NNN-USnnn-MMM

All three normalise to the same canonical id by zero-padding the story number to three digits and inserting the hyphen. Written above as placeholders, never as literal ids: a literal legacy id on this page would be a corpus violation of the very ruling it documents.

Two legacy spellings can normalise onto one canonical id. The migration therefore refuses to run on an unrecorded collision rather than silently merging two distinct requirements into one.

Why this needed a ruling rather than a cleanup

The two spellings coexisted for months because the grammar itself admitted both: the un-dashed indexed form (FR1, AC2) is legal for every other segment, so US4 looked equally legal. Every gate that read the grammar therefore blessed the legacy story form, and every cleanup was a one-off that the next commit undid.

The fix is at the grammar layer, not the janitorial layer: US is now excluded from the un-dashed indexed vocabulary. A re-introduced legacy story id is no longer a "legacy variant" to be counted and tolerated — it is non-canonical, and it trips the --max 0 ceiling like any other malformed id.

The migration

Run 2026-08-19 by scripts/migrate_legacy_story_ids.py: 47,715 substitutions across 388 files, taking the corpus to zero.

A rewrite that large is only trustworthy if it can prove it changed nothing else, so the codemod holds three invariants and raises ManglingError on any breach:

  1. Skeleton equality — blank every story id from the before and after text; the remainder must be byte-identical. This is what catches collateral damage the author never anticipated, because it does not depend on predicting the damage.
  2. Never shortens — a canonical id is always at least as long as the legacy one it replaces.
  3. Line count preserved — no re-wrapping, no reflowing.

Invariant 3 has a visible consequence worth recording: every id grew by two characters, so @traces(reqs=[...]) lists that previously fit in 100 columns no longer did, and black --check failed on 43 files immediately afterwards. That is the invariant working, not failing. A codemod that quietly re-wrapped would have made invariant 1 unable to distinguish "widened an id" from "rewrote your code". Migrate under strict invariants, then let the formatter reflow as a separate, independently-checkable step.

Enforcement

Three layers, of which the gate above is the one the rulings lint verifies:

  1. The grammar — scripts/check_req_format.py no longer admits the un-dashed story spelling, so a legacy id is non-canonical and fails the --max 0 ceiling. The gate reports the legacy count separately and fails on any non-zero value.
  2. The corpus guard — tests/scripts/test_migrate_legacy_story_ids.py scans the real tree and fails on any legacy id anywhere. Its docstring points at the migrator rather than inviting the ceiling to be raised.
  3. The migrator — scripts/migrate_legacy_story_ids.py --apply is the documented fix, so the response to a failure is to re-run a verified codemod rather than to hand-edit.

The one sanctioned exception

tests/scripts/test_migrate_legacy_story_ids.py must contain legacy ids — they are its fixtures, and a test that proves legacy ids are rejected cannot do so without holding one. It is named in LEGACY_FIXTURE_CARRIERS in the gate and in EXCLUDED_GLOBS in the migrator, so the exception is declared in both places that would otherwise trip over it.

That is the whole exception list. A file is either migrated or it is that file.

Verification

As of 2026-08-19, scripts/check_req_format.py:

PASS: requirement-id format within ceiling.
Requirement-id format gate: 0 distinct non-canonical ids (0 occurrences); ceiling --max 0.
Cross-spec REQ-id uniqueness: 0 unbaselined collision(s) across specs/*/requirements.md.
Story existence: 0 unbaselined req-id(s) naming a story that does not exist in their own spec's spec.md.
Legacy story-id form (US followed directly by digits, rather than the canonical US-NNN): 0 distinct id(s) -- must be 0.

Generated AST snapshots under plans/ast-*.json are outside the migration by design: they are regenerated from source rather than hand-edited, and plans/ast-orchestrata.json describes the frozen source monorepo, whose historical ids are correct for that codebase and must not be rewritten.