---
title: "ADR 0015. Error codes are flat, not banded"
description: "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."
url: "https://opsinjs.pensievelabs.org/project/decisions/0015-error-codes-are-flat"
source: "https://opsinjs.pensievelabs.org/project/decisions/0015-error-codes-are-flat.md"
section: "Project"
kind: "project"
reviewed: "2026-09-03"
reviewer: "engineering"
aliases: ["error code ranges", "why not OPSIN-01xx", "flat error codes", "code numbering"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="project" />

## Status [#status]

**Accepted.** 2026-09-03. The losing scheme was removed from
[Error codes](../../handbook/error-codes.mdx) in the same commit, and the
warning channel was built against the winning one.

## Context [#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](../../handbook/error-codes.mdx) 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 [#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`. &#x2A;*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](../../handbook/error-codes.mdx). Per
  [ADR 0006](./0006-generated-not-authored.mdx), 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 [#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](../../handbook/error-codes.mdx), 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](./0002-shadcn-registry-distribution.mdx)), 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 [#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 [#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.

<LastUpdated />

<Reviewed />
