opsinjs
RoadmapDecisions

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 from OPSIN-0001. The digits carry no classification. OPSIN-0007 sits next to OPSIN-0008 because it was written first, and for no other reason.
  • Severity is the classification axis. Its values are safety, correctness and hygiene, 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.mts emits the file three ways, in one run, from one parse: OPSIN_ERROR_CODES and OPSIN_ERROR_POLICY in lib/generated/tokens.ts for the site, the OPSIN_ERRORS table inside lib/opsinjs.ts for 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-0019 as more serious than OPSIN-0002. Group by the severity column 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.ts rather than into lib/generated/. Components ship as source (ADR 0002), and the two files that travel with them are lib/opsinjs.ts and lib/status.ts. A warnOnce() that imported its messages from lib/generated/tokens.ts would 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 inside lib/opsinjs.ts and the region on the handbook page are not covered by node scripts/build-tokens.mts --check, and it is worth being precise about why, because the first version of this record claimed they were: CI runs pnpm run generate before every --check, so by the time a --check runs its inputs are always fresh and it cannot fail. The gate that can fail is check:generated, which regenerates and then diffs against the commit. The two paths are therefore named there, beside lib/generated and 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.json is one of the inputs to the token source hash, so a typo fix in a warning changes --opsin-tokens-generated and 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.severity now 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 is OPSIN-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.

Edit this page

Last read through against the system on 2026-09-03. Due for review every 12 months; expiry is reported by pnpm run check:freshness.

On this page