# 0017 — Reference ranges belong to populations; results are released, not saved

**Status:** Accepted · 2026-08-02

## Context

Two decisions in the laboratory module are load-bearing for patient safety, and
both are expensive to reverse once real results exist. A third — how a clinical
module charges for what it does — was forced by this phase and generalises
beyond it.

## Decision 1 — A reference range is a property of a test *and a population*

A "normal" haemoglobin is 13–17 g/dL for an adult man, 12–15 for an adult woman,
and different again for a newborn, a six-month-old and a ten-year-old. A single
low/high pair on the test row would mark half the healthy women in a clinic as
anaemic and would fail to mark a man whose 12.5 is genuinely low. Both errors are
silent.

So ranges are a table. Each carries the population it applies to — a sex, and an
age band **in days**, because the bands are narrowest in the first year of life
and year-granularity fails exactly there. Bands are inclusive at the bottom and
exclusive at the top, so adjacent bands meet without overlapping and without
leaving a day in the gap.

Matching takes the **most specific** range that applies: naming a sex beats not
naming one, a bounded age band beats an open one, ties break on the narrower band
and then on id so the choice is deterministic. Two clinicians reading the same
result on the same day must see the same normal.

**No match is an answer.** A test with no range for this patient produces a
result with no range and therefore **no flag** — not "normal". Falling back to
another population's range would produce a flag, and a flag is read as a
judgement.

**Critical bounds are separate and much wider.** Outside the reference range
means "a clinician should see this today". Outside the critical bounds means
"telephone somebody now". A laboratory that conflates them either makes a hundred
calls a day or none, and the second is what actually happens. Flagging checks
critical first: a potassium of 7.2 is both high and critically high, and only the
second gets somebody out of a chair.

**The matched range is copied onto the result.** Ranges are revised — a new
analyser, a new method, a change of units — and a result reinterpreted against a
range that did not exist when it was reported is a different result. Same
principle as the price on an invoice line and the drug name on a prescription.

**Comparison is exact.** Values come from `DECIMAL` columns as strings and are
compared through `DecimalValue`, which scales them to integers. `bccomp` was the
obvious choice and was rejected: bcmath is not among this product's required PHP
extensions, and discovering that on a clinic's shared host at the bench is not
acceptable. Casting to float would work for every value anybody will ever measure
and is still the wrong habit where a comparison decides whether somebody is
telephoned.

## Decision 2 — A result is released, not saved

A number typed at a bench is not a result. It becomes one when a competent person
has looked at it, decided it is plausible for this patient, and released it. A
transposed digit released automatically is indistinguishable from a real value
and gets acted on.

| State | Who sees it | How it changes |
| --- | --- | --- |
| preliminary | the laboratory only | typed over in place |
| final / amended | the clinician | a **new version**, never a rewrite |
| entered in error | kept, marked | the analyte returns to pending |

`verified_at` is the gate, and `entered_by` and `verified_by` are separate
columns — the same person may do both in a small clinic, but the record has to be
able to say whether they did. `laboratory.require_second_verifier` makes them
different people; it is **off by default**, because a single-scientist laboratory
would otherwise be unable to release anything.

An unverified value is typed over rather than versioned. Versioning every
keystroke at a bench would bury the one amendment that matters — the correction
of something a clinician already saw — under a hundred that nobody needs.

After release, a correction is an amendment: a new row with the next version
number, `supersedes_id` back, `superseded_by_id` forward, and a mandatory reason.
Enforced at the model, not only the service — `LabResult::save()` throws when a
verified row's value is dirty, and `delete()` throws unconditionally. This is the
same arrangement as signed clinical notes
([ADR 0013](0013-clinical-notes-are-versioned.md)) for the same reason: after an
adverse event the question is never what the record says now, it is what the
clinician saw when they decided.

**Critical values are acknowledged, not merely flagged.** `critical_notified_at`,
`_by`, `_to` and a note record who was told and when. An event is dispatched too,
but the event is not the safety mechanism — an event nobody subscribed to is
indistinguishable from one never dispatched, which is why acknowledgement is a
column and the outstanding ones sit at the top of the order screen in red.

## Decision 3 — Clinical modules charge through a port in core

Ordering a test should charge for it. The obvious implementation has Laboratory
importing `Modules\Billing\Contracts\InvoiceServiceInterface`, and then a clinic
that never bought billing cannot boot the laboratory it did buy. Pharmacy,
Radiology and Inventory all have the same problem coming.

So core owns `App\Foundation\Billing\ChargeCollector`, with `NullChargeCollector`
bound by default and Billing replacing the binding when installed — exactly the
arrangement the licence gates use. `ChargeRequest` carries ids and primitives
only.

Three rules make it safe:

- **It never throws.** A missing price, a closed invoice, no billing module —
  none of these may stop a specimen being accepted. The failure is logged and
  returned; the clinical work continues.
- **The price list wins.** When the caller names a catalogue code and the clinic
  has one, the clinic's price and tax are used and the caller's figure is
  discarded. A laboratory carrying its own idea of what a test costs would
  undercut the clinic the first time they raised their prices.
- **The default is silent.** A clinic running a laboratory without billing gets
  its results and no invoice, which is correct — they are presumably billing on
  paper — and a warning per test ordered would train people to ignore the log.

A test with no `billing_code` is not charged. That is how a clinic says "this one
is free".

## Consequences

**Good.** The same haemoglobin of 12.5 flags low for a man and normal for a
woman, and this is tested directly rather than assumed.

**Good.** Modules can now be independently installed in all four directions:
reads through directories, notifications through events, synchronous writes
through published contracts, screens through slots — and now *charges* through a
port in core, with no import crossing a module boundary in any of them.

**Bad.** More rows. Every amendment keeps its predecessor and nothing shrinks.
At clinic volumes this is nothing; the alternative loses information that cannot
be recovered at any price.

**Bad.** Range quality is the clinic's problem, and a wrong range is worse than
none. The shipped ranges are the conventional adult values; they belong to a
method and an analyser, not to a test name. The catalogue screen says so in a
warning that cannot be dismissed, because the failure mode — trusting a seeded
range against a different analyser — is silent.

**Honest limit.** Critical-value acknowledgement is one record per result. A
laboratory needing an escalation ladder with repeated attempts and timed
escalation needs a table, and this is not that.

**Honest limit.** There is no analyser interface. Results are typed in. A clinic
with an instrument that speaks HL7 or ASTM needs a driver, and the seam for it is
`LabResultService::enter()` — which is why that method takes a DTO and not a
form request.
