ADR 0015. Error codes are flat, not banded
OPSIN-0001 upwards, allocated in sequence with no meaningful ranges. tokens/errors.json is the only place a code is authored, and the rest is generated from it.
Status
Accepted. 2026-09-03. The losing scheme was removed from Error codes in the same commit, and the warning channel was built against the winning one.
Context
Two incompatible numbering schemes for the same twenty warnings were in the tree at once, and each was written as though the other did not exist.
tokens/errors.json declared a flat scheme: OPSIN-0001 through
OPSIN-0020, each row carrying a severity, a title, a finished message and the
id of the one page that prevents the mistake. It was the file the build already
knew about, because build-tokens.mts folded it into the token source hash, and
it was the only one of the two with real content in it.
Error codes documented a banded scheme:
seven ranges from OPSIN-01xx for the colour axes to OPSIN-07xx for registry
and version problems, with a worked example numbered OPSIN-0104. No code in
that shape existed anywhere. The page also promised that the complete table was
generated into the reference section, which it was not, and its <Todo> said
that until the generator arrived "the ranges above are the specification".
Nothing compared the two, because nothing read either. The comment in
build-tokens.mts claimed that "the pages that document an error code import
the JSON directly"; a grep of the repository found no importer at all. So the
system had a specification with no implementation, an implementation source with
no consumer, and two mutually exclusive answers to "what will this code be
called".
That last question is the one that made this urgent rather than tidy. A code is
a permanent public identifier. tokens/errors.json says so itself: never
reused, never renumbered, never removed, because the code will outlive this
release in somebody's log aggregator. Every day the ambiguity stood was a day
closer to the first component emitting a number we would have to break.
Decision
The flat scheme wins, tokens/errors.json is the only place a code is
authored, and one script emits it everywhere it is needed.
- Codes are
OPSIN-NNNN, allocated in sequence fromOPSIN-0001. The digits carry no classification.OPSIN-0007sits next toOPSIN-0008because it was written first, and for no other reason. - Severity is the classification axis. Its values are
safety,correctnessandhygiene, and it is a column, which means it can be corrected. A number cannot. - Each code names exactly one documentation page, which is its permanent home.
scripts/build-tokens.mtsemits the file three ways, in one run, from one parse:OPSIN_ERROR_CODESandOPSIN_ERROR_POLICYinlib/generated/tokens.tsfor the site, theOPSIN_ERRORStable insidelib/opsinjs.tsfor the runtime, and the whole table on Error codes. Per ADR 0006, not one row of any of them is authored twice.
Banded numbering was rejected on its own merits as well as on the evidence. A band is a promise about a code's meaning that the number is not able to keep. A touch target below the floor on a status pill is an accessibility defect and a clinical one, and the first code that belongs to two bands like that is either filed arbitrarily, which makes the band a lie, or renumbered, which is the single thing the stability rule forbids. And the benefit the page claimed for bands, that an unfamiliar code is "approximately interpretable before you look it up", is worth very little: a reader holding a code is also holding the message it came with, and that message already names the page.
Consequences
- Nothing can be inferred from the digits. Do not group a log dashboard by
prefix and do not read
OPSIN-0019as more serious thanOPSIN-0002. Group by theseveritycolumn instead, which is published beside every code. - The published table has to be complete, because sorting by number no longer narrows anything. It is: all twenty rows render on Error codes, generated, with the page each code links to.
- The runtime carries its own copy of the table, and that copy is generated
into
lib/opsinjs.tsrather than intolib/generated/. Components ship as source (ADR 0002), and the two files that travel with them arelib/opsinjs.tsandlib/status.ts. AwarnOnce()that imported its messages fromlib/generated/tokens.tswould compile in this repository and fail in every project that installed a component, which is the worst class of defect this system can ship. - Two generated regions live inside otherwise hand-authored files, and both
had to be added to
check:generated's path list to be gated at all. The region insidelib/opsinjs.tsand the region on the handbook page are not covered bynode scripts/build-tokens.mts --check, and it is worth being precise about why, because the first version of this record claimed they were: CI runspnpm run generatebefore every--check, so by the time a--checkruns its inputs are always fresh and it cannot fail. The gate that can fail ischeck:generated, which regenerates and then diffs against the commit. The two paths are therefore named there, besidelib/generatedand the rest. Separately, losing a region marker fails the generator with a message naming the file, rather than silently appending a second copy of the table. - Editing a message rewrites the whole generated token layer.
tokens/errors.jsonis one of the inputs to the token source hash, so a typo fix in a warning changes--opsin-tokens-generatedand every file carrying it. That is the cost of having one provenance hash rather than several, and it is cheaper than the alternative of not knowing which sources a build came from. - A code whose severity is not declared in
policy.severitynow fails the build, naming the code. The severity union in the generated TypeScript is emitted from that block, so an undeclared value would otherwise produce code that does not typecheck, several steps away from the edit that caused it. - Adding a code is one edit and one
pnpm run generate. The next one isOPSIN-0021, whatever it turns out to be about.
Alternatives considered
Renumber tokens/errors.json into the seven bands. The page was written
first, so this was the obvious move. Rejected: it requires assigning twenty
existing codes to bands that were designed before the codes existed, at least
three of them sit in two bands at once, and it buys a mnemonic by spending a
renumbering, which is the one thing the scheme promises never to spend.
Keep both: flat codes, with the ranges as a documented grouping. Rejected. Two identifiers for one fact, joined by a mapping table that nobody would keep current, and a reader who found the mapping stale would have no way to tell which half was wrong.
Namespaced codes such as OPSIN-AXIS-01 and OPSIN-A11Y-03. Genuinely
tempting, because it puts the classification in the identifier where a human can
see it. Rejected for the same reason as bands, one step worse: the word is now
part of a permanent identifier, so renaming a category of mistake either breaks
every logged code or leaves the old word in place for ever.
Emit the table to lib/generated/errors.ts and import it from
lib/opsinjs.ts. Architecturally the cleanest. It would be one artefact,
inside the directory the drift gate already watches. Rejected because
shadcn add copies lib/opsinjs.ts and lib/status.ts and nothing else, so the
import would resolve here and nowhere else. Adding a third shared file would have
been a change to the distribution contract made in passing, to save a marker
comment.
Revisiting this
Revisit when a reader cannot find a code. The roster reaching some number is not the trigger, because the count is not the problem a banded scheme solves; searchability is, and searchability is a property of the published table and the message text rather than of the numbering.
Renumbering stays off the table whatever is decided. A future scheme, if there is one, begins above the highest code already allocated, and every code below it keeps its row.
Last read through against the system on 2026-09-03. Due for review every 12 months; expiry is reported by pnpm run check:freshness.
ADR 0014. The material rung names are the token names
Three documents name the six material rungs three ways. The names in tokens/material.json win, because a stylesheet resolves them and a drift gate checks them.
ADR 0016. The unit table ships spoken forms, not conversion factors
An authored token source that says what a number is measured in and how to say it aloud, and refuses by name the three conversions that are not definitions.