# API Error Code Registry

Every `code` the platform can return. **Codes are permanent**: once listed here
they are never renamed, never removed, and never change meaning — clients
branch on them, and a renamed code is a breaking change requiring a new API
version. Casing is `lower_snake`, normalized platform-wide on 2026-08-02,
before any code shipped in a stable version.

`tests/Architecture/ApiPlatformTest.php` fails the build if a code in use is
missing from this file.

## Platform

| Code | Status | Meaning |
|---|---|---|
| `ok` | 2xx | Generic success |
| `validation_failed` | 422 | Field errors in `errors` |
| `authentication_required` | 401 | No or invalid token |
| `forbidden` | 403 | Policy refused the action |
| `not_found` | 404 | Unknown record — identical for cross-branch and nonexistent |
| `method_not_allowed` | 405 | |
| `rate_limited` | 429 | Back off per `Retry-After` |
| `http_error` | varies | Other HTTP-level failure |
| `server_error` | 500 | Defect; quote `X-Request-Id` when reporting |
| `branch_forbidden` | 403 | `X-Branch` names a branch the caller may not work in |

## Auth

| Code | Status | Meaning |
|---|---|---|
| `token_issued` | 201 | |
| `token_revoked` | 200 | |
| `invalid_credentials` | 401 | Covers unknown email, wrong password, locked and inactive alike — deliberately |
| `two_factor_required` | 401 | Resubmit with `otp` |
| `two_factor_invalid` | 401 | |
| `last_super_admin` | 422 | |
| `password_reused` | 422 | |
| `privilege_escalation_denied` | 422 | |
| `protected_role` | 422 | |
| `role_in_use` | 422 | |
| `self_action_denied` | 422 | |

## Patients

| Code | Status | Meaning |
|---|---|---|
| `patient_already_registered` | 422 | Hard duplicate on the national identifier |
| `patient_possible_duplicate` | 422 | Probable duplicate; candidates in `meta.context.matches` |
| `unsupported_document_type` | 422 | |

## Appointments

| Code | Status | Meaning |
|---|---|---|
| `appointment_booked` | 201 | |
| `appointment_status_changed` | 200 | |
| `walk_in_admitted` | 201 | |
| `appointment_slot_unavailable` | 409 | Slot taken, outside the rota, or on leave — detail in `meta.context`. Conflict, not validation: the input was fine, the world changed; refresh the slot list |
| `appointment_invalid_transition` | 422 | Status machine refused; `from`/`to` in `meta.context` |

## Consultations

| Code | Status | Meaning |
|---|---|---|
| `encounter_opened` | 201 | Booking (if any) moved to "in consultation" in the same transaction |
| `encounter_completed` | 200 | Closed with a disposition; booking (if any) completed |
| `encounter_cancelled` | 200 | Abandoned with a reason — the record stays |
| `note_saved` | 201/200 | Draft created (201) or draft edited (200) |
| `note_signed` | 200 | Text now fixed; author attested |
| `note_amended` | 201 | New version written — the original stays byte-for-byte intact |
| `note_retracted` | 200 | Marked `entered_in_error`; the row stays |
| `vitals_recorded` | 201 | BMI derived server-side, once |
| `vitals_empty` | 422 | Every field blank — a mis-click, not an observation |
| `diagnosis_recorded` | 201 | A new primary displaces the previous one |
| `diagnosis_retracted` | 200 | |
| `encounter_already_open` | 409 | Patient already has an open consultation; its ulid in `meta.context.encounter_ulid` — open that one instead |
| `encounter_closed` | 422 | Domain backstop for writes into a closed encounter. Rarely seen: the policies require an open encounter, so the usual refusal is 403 `forbidden` |
| `note_not_amendable` | 422 | Wrong status, or this version was already superseded — amend the current version |

## Prescriptions

| Code | Status | Meaning |
|---|---|---|
| `prescription_started` | 201 | Draft opened, optionally attached to an encounter |
| `prescription_issued` | 200 | Immutable from here; `valid_until` fixed on the row |
| `prescription_cancelled` | 200 | With a reason; the record stays |
| `prescription_reissued` | 201 | Original superseded; new draft returned with every line copied — `meta.supersedes` names the original |
| `prescription_item_added` | 201 | Formulary values copied onto the line at write time |
| `prescription_item_removed` | 200 | Draft only — an edit, not a clinical erasure |
| `allergy_override_required` | 409 | Conflicts with a recorded allergy and no reason was given. Not a refusal: resubmit with `allergy_override_reason` (min 10 chars). Warnings in `meta.context.warnings` |
| `prescription_not_editable` | 422 | Issued/cancelled/superseded, or the draft is empty (`meta.context.reason: empty`) |
| `prescription_encounter_mismatch` | 422 | The named encounter belongs to a different patient |

## Billing

| Code | Status | Meaning |
|---|---|---|
| `invoice_started` | 201/200 | Draft opened (201) or the visit's existing draft reused (200) — one bill per visit |
| `invoice_item_added` | 201 | Catalogue values copied onto the line at write time |
| `invoice_item_removed` | 200 | Draft only |
| `invoice_discount_applied` | 200 | Whole-bill discount set or cleared; totals rewritten |
| `invoice_issued` | 200 | Number allocated from the gapless sequence; figures fixed |
| `invoice_voided` | 200 | Only before money moved; the row and its number stay |
| `payment_taken` | 201 | Honours the `Idempotency-Key` header — a retry returns the first payment |
| `payment_reversed` | 201 | Negative row pointing at the original; the original stays |
| `payment_refunded` | 201 | Money correctly taken, given back |
| `credit_note_issued` | 201 | The only correction once money has moved |
| `cash_session_opened` | 201 | |
| `cash_session_closed` | 200 | Expected/counted/variance fixed at close |
| `invoice_not_editable` | 422 | Issued/voided, empty draft (`meta.context.reason: empty`), or not voidable (`not_voidable`) |
| `payment_refused` | 422 | Machine-readable why in `meta.context.reason`: `exceeds_balance`, `invoice_not_issued`, `not_positive`, `already_reversed`, `not_reversible`, `cash_requires_open_session` |
| `credit_note_refused` | 422 | `meta.context.reason`: `invoice_not_issued`, `exceeds_creditable`, `nothing_to_credit` |
| `cash_session_error` | 422 | `meta.context.reason`: `already_open`, `not_open`, `negative_count` |
| `invoice_context_mismatch` | 422 | The named encounter or appointment belongs to a different patient |

## Laboratory

| Code | Status | Meaning |
|---|---|---|
| `lab_order_placed` | 201 | Panels expanded to one item per analyte; charge raised via the billing port |
| `lab_order_cancelled` | 200 | Open orders only; the row stays |
| `lab_specimen_collected` | 201 | Accession number allocated |
| `lab_specimen_received` | 200 | At the bench |
| `lab_specimen_rejected` | 200 | The order stays open for a re-draw; the rejection stays visible |
| `lab_result_entered` | 201 | Flagged against the patient's own sex/age reference range, copied onto the row. `preliminary` until verified — not yet a result |
| `lab_result_verified` | 200 | Released to the clinician |
| `lab_result_amended` | 201 | New version; the original stays — what the clinician saw is the record |
| `lab_result_retracted` | 200 | `entered_in_error`; wrong patient/specimen |
| `lab_critical_acknowledged` | 200 | Who was told, recorded verbatim |
| `lab_order_error` | 422 | `meta.context.reason`: `no_tests`, `not_editable`, `finished`, `specimen_not_accepted`, `specimen_resolved` |
| `lab_result_error` | 422 | `meta.context.reason`: `no_value`, `already_verified`, `not_verified`, `superseded`, `not_critical`, `verifier_must_differ` |
| `lab_order_encounter_mismatch` | 422 | The named encounter belongs to a different patient |
| `lab_catalogue_error` | 422 | `meta.context.reason`: `not_ready_to_activate`. `meta.context.blockers` lists what is missing — a test with no unit or no reference range cannot be offered, whoever asks |

## Foundation

| Code | Status | Meaning |
|---|---|---|
| `business_rule_violation` | 422 | Generic domain refusal |
| `entity_not_found` | 404 | |
| `feature_not_licensed` | 403 | The clinic's package does not include this capability. `meta.context.feature` names it |
| `license_limit_reached` | 402 | A countable cap in the package is full. `meta.context` carries `resource`, `limit`, `current` — enough to render an upgrade prompt rather than a dead end |

## Licensing

Licensing errors describe this installation's relationship with the licence
server. They never appear on a clinical endpoint: an unreachable or lapsed
licence does not stand between a clinician and a patient's record.

| Code | Status | Meaning |
|---|---|---|
| `license_server_error` | 502 | The licence server refused or could not be reached. `meta.context.reason` carries the server's own reason (`revoked`, `suspended`, `expired`, `domain_taken`, `domain_mismatch`, `invalid_key`) or `unreachable`/`not_configured` when no answer was obtained; `meta.context.reachable` distinguishes the two, because a refusal is an answer and a timeout is not |
| `license_read_only` | 402 | A write was refused because the licence has lapsed. Only ever returned for administrative routes — clinical routes are on the always-available list and never produce this |

## Updates

| Code | Status | Meaning |
|---|---|---|
| `update_failed` | 422 | An update could not proceed, or was rolled back. `meta.context.reason` is the stable discriminator. Verification: `signature_invalid`, `checksum_mismatch`, `size_mismatch`, `no_public_key`, `unsafe_package_entry`, `package_not_a_release`. Preflight: `license_not_active`, `business_hours`, `already_installing`, `not_writable`, `insufficient_disk_space`, `zip_unavailable`. Transport: `unreachable`, `not_activated`, `incomplete_release`. Outcome: `install_rolled_back` (previous version running) and `rollback_failed` (restore from backup before use) — those two are the ones that decide whether a clinic is currently working |

## Pharmacy

| Code | Status | Meaning |
|---|---|---|
| `insufficient_stock` | 422 | Not enough usable stock to dispense the quantity asked for. `meta.context.available` is how many there actually are, so a caller can offer a partial dispensing rather than sending somebody to count the shelf. Expired and quarantined batches are never counted as available |
| `dispensing_not_reversible` | 422 | This dispensing has already been reversed, or was itself a reversal. Reversals are compensating rows, never edits, so the same issue cannot be undone twice |
