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.
Status
Accepted. 2026-09-03. apps/www/tokens/units.json is authored, emitted by
scripts/build-tokens.mts, and read by Value through @/lib/opsinjs.
Context
Two component specifications assume a unit table that did not exist.
Value resolves "the spoken unit form from the unit table";
ReadingInput needs mmol/L↔mg/dL, kg↔lb↔st and °C↔°F.
Nothing under tokens/ mentioned mmol or mmHg outside the glossary.
The reason a table is needed at all is narrower than it first looks, and it is not
conversion. It is speech. A screen reader handed mmHg improvises a pronunciation, and
the improvisation is rarely close: one twenty over eighty em em aitch gee is a failure.
The symbol is the part every product remembers to supply; the spoken form is the part
nobody has.
The instruction that produced this file asked for the three conversion families. The doctrine says the opposite, in Unit systems: "we do not ship conversion factors as clinical content… Publishing a table of factors here would invite someone to apply the glucose factor to cholesterol", and "opsinjs ships no factors and no thresholds". The implementer followed the doctrine over the instruction. That was the right call and this record exists to make it binding rather than a one-off.
Decision
tokens/units.json carries the identity of a unit and how to say it: id, symbol as
displayed, spoken form, plural, and what family it measures. It is an authored token
source with an emitter, like colour and motion and material. It is never values typed
into a component, because a conversion factor somebody typed into a .tsx file is a
clinical number with no provenance.
It carries exact DEFINITIONAL conversions and nothing else. °C to °F is nine fifths
plus thirty-two because the Fahrenheit scale is defined against Celsius. The
international pound is exactly 0.45359237 kg because a treaty says so. Each conversion
carries a basis field stating where the number comes from, and the emitter refuses a
conversion that has none.
It refuses three conversions by name, in a refusedConversions block that ships:
- mmol/L ↔ mg/dL. Molar concentration and mass concentration are related by the molar mass of the substance being measured. That is a property of the substance, not of either unit. Glucose and cholesterol are both reported in mmol/L, and a single factor in this table would be applied to both.
- HbA1c mmol/mol ↔ %. The two ways of reporting it are related by a fitted regression between two assay standardisations. A regression is a measurement with a residual, not a definition, and it belongs to whoever measured it.
- kcal ↔ kJ. There is more than one calorie. The thermochemical calorie is exactly 4.184 joules and the fifteen-degree calorie is not, and food-energy labelling picks one by regulation rather than by physics.
It carries no default precision, and the build gate refuses the keys precision and
decimals by name so that it cannot acquire one later. See
ADR 0017.
Consequences
- A product that needs mg/dL does the arithmetic itself, with the molar mass of the substance it is actually displaying. That is more work for the product and it is the correct amount of work: only the product knows what was measured.
- The refusals are published, not merely omitted.
REFUSED_CONVERSIONSis exported from the substrate and ships to consumers, so a developer who reaches forconvertUnit("mmol/L", "mg/dL")gets a named reason rather thanundefinedand a shrug. An omitted conversion renders an explicit "we do not have this"; it never falls back to a guess. - The spoken forms are British English, and that is a stated limit rather than a claim. A component asked for a non-English locale gets English words beside locale-formatted digits. Naming the gap is the honest state; inventing translations is not.
- A unit that is not in the table renders as written rather than being pronounced by guesswork. That is worse for a listener than a correct spoken form and better than a confident wrong one.
Alternatives considered
Ship the three factors with a warning. Rejected. The warning is read once and the factor is used forever, and the failure it guards against is the glucose factor applied to cholesterol, which produces a number that looks entirely reasonable. There is no error state; a reader simply sees the wrong figure for their own result.
Ship a per-substance conversion table. Rejected, and it is the tempting one because it solves the objection directly. It would make opsinjs the owner of a list of molar masses and of the decision about which substances are on it, which is clinical content with a clinical owner, and this system has neither.
Put the spoken forms in the component. Rejected on the same grounds as every other token: a value in a component is a value nothing regenerates, nothing diffs and nothing publishes. The table is emitted into the generated token module, into the substrate that ships, and into the reference pages, from one parse of one file.
Revisiting this
Revisit when a conversion in the refused list becomes a definition rather than a
measurement. That would mean a standards body had fixed it exactly, and the basis field
would then have something true to say.
Revisit the English-only spoken forms when there is a second locale with somebody to write them. The shape of the table does not need to change for that; the entries do.
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 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.
ADR 0017. precision means decimal places, everywhere
One name, one meaning, taken from the measurement rather than the unit. It is never significant figures, which would make one metric change shape as it moves.