# Laboratory API — v1

Base: `/api/v1/lab` · Auth: Sanctum bearer token · Branch: session if
present, else `X-Branch` header. Every endpoint is policy-gated.

**No DELETE for an order, a specimen or a result** — an order is cancelled, a
specimen is rejected, a result is amended or retracted, and the row always
survives, because each is evidence of a clinical decision. Catalogue
management (tests, reference ranges) and printing are web-only in v1.

## Endpoints

| Method | Path | Ability | Purpose |
|---|---|---|---|
| GET | `/lab/catalogue/search` | `lab_catalogue.view` or `lab.order` | Ordering picker (`q`, min 2 chars) |
| GET | `/lab/orders` | `lab.view` | The worklist — defaults to open orders |
| POST | `/lab/orders` | `lab.order` | Raise an order |
| GET | `/lab/orders/{ulid}` | `lab.view` | Full read: items, results (all versions), specimens |
| POST | `/lab/orders/{ulid}/cancel` | `lab.cancel` | Open orders; reported analytes stay reported |
| POST | `/lab/orders/{ulid}/specimens` | `lab.collect` | Collect — accession number allocated |
| POST | `/lab/specimens/{ulid}/receive` | `lab.collect` | At the bench |
| POST | `/lab/specimens/{ulid}/reject` | `lab.collect` | Order stays open for a re-draw |
| POST | `/lab/items/{ulid}/result` | `lab.result` | Enter a value against an analyte |
| POST | `/lab/results/{ulid}/verify` | `lab.verify` | Release to the clinician |
| POST | `/lab/results/{ulid}/amend` | `lab.amend` | New version; original intact |
| POST | `/lab/results/{ulid}/retract` | `lab.amend` | `entered_in_error`; back to be reported again |
| POST | `/lab/results/{ulid}/acknowledge` | `lab.view` (critical results only) | Record who was told |

### POST /lab/orders

Body: `patient` (ULID, required), `tests[]` (catalogue ULIDs, required — a
**panel expands to one item per analyte**, because the analyte is what gets
measured, ranged and resulted), `encounter` (ULID, optional — must belong to
the same patient, else `422 lab_order_encounter_mismatch`), `priority`
(`routine|urgent|stat`), `clinical_details`, `fasting`. Charges are raised
through the billing port; a clinic without the Billing module simply gets
results and no invoice. `201 lab_order_placed`.

### The worklist — GET /lab/orders

Defaults to **open orders** (what a laboratory opens the screen to see);
naming a `status` or passing `open=0` widens it. `reference` matches the
order reference **or a specimen's accession number** — what the bench
actually has in hand. Also filterable by `patient`, `encounter`, `priority`,
`from`/`to`.

### Specimens

Collection allocates the accession number and moves the order on;
`collected_at` accepts the real draw time (a potassium rises in a tube left
standing). A rejected specimen (`reason` required) reopens the order for a
re-draw and **stays visible** — a laboratory that cannot count its
rejections cannot find their cause.

### Results — the release gate

`POST /lab/items/{ulid}/result` with `{value_numeric | value_text, method?,
analyser?, comment?, measured_at?, specimen? (ULID, defaults to the order's
usable specimen), mark_abnormal?}`.

The value is flagged against the reference range matched to **this
patient's sex and age**, and that range is copied onto the row — the
catalogue's later edits cannot change what this number meant. The result is
`preliminary`: a number typed at a bench is not a result, and **a client
must not show it to a clinician**. An unverified entry is typed over in
place; re-entering over a verified one is `422 lab_result_error`
(`already_verified`).

`verify` releases it (`final`). Amending a released value writes version
n+1, re-flagged, released in the same act (`201 lab_result_amended`); the
original keeps its text and status and gains only the forward pointer — the
question after an adverse event is what the clinician saw when they decided.

Critical flags (`critical_low`/`critical_high`) carry an acknowledgement
step: `POST …/acknowledge` with `notified_to` records who was told, in the
caller's own words. Acknowledging a non-critical result is refused.

Decimal values serialize as clean strings (`"5.9"`, never `5.9000` and
never a float) — nothing is lost to binary representation.

## Error codes

Registered permanently in [errors.md](errors.md) — see the Laboratory
section. Cross-branch reads are `404 not_found`, byte-identical to a
nonexistent ULID.
