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:
- 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.
- Never shortens — a canonical id is always at least as long as the legacy one it replaces.
- 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
- gate scripts/check_req_format.py RUL-031
Three layers, of which the gate above is the one the rulings lint verifies:
- The grammar —
scripts/check_req_format.pyno longer admits the un-dashed story spelling, so a legacy id is non-canonical and fails the--max 0ceiling. The gate reports the legacy count separately and fails on any non-zero value. - The corpus guard —
tests/scripts/test_migrate_legacy_story_ids.pyscans the real tree and fails on any legacy id anywhere. Its docstring points at the migrator rather than inviting the ceiling to be raised. - The migrator —
scripts/migrate_legacy_story_ids.py --applyis 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.
Related
- RUL-013 — Specs are authoritative — why an id is a contract rather than a label.
- RUL-028 — Traceability is a query — the tools that consume these ids; a dangling id breaks them silently.
- RUL-014 — A ruling that exists only in prose is incomplete work — why this page ships with a gate.
- RUL-024 — "Pre-existing" is not a disposition — the rule that made "the legacy ids predate me" not an answer.
- Starting context — where requirement ids sit in the wider traceability system.