# Consultations API — v1

Base: `/api/v1/encounters` · Auth: Sanctum bearer token · Branch: session if
present, else `X-Branch` header. Every endpoint is policy-gated. All instants
are ISO-8601 Zulu; `follow_up_on` is a plain date.

There is **no DELETE anywhere on this surface**. Clinical records are
retracted by status — an encounter is cancelled with a reason, a note or a
diagnosis is marked `entered_in_error` — and the row stays.

## Endpoints

| Method | Path | Ability | Purpose |
|---|---|---|---|
| GET | `/encounters` | `encounters.view` | Paginated list, filterable |
| POST | `/encounters` | `encounters.open` | Open a consultation |
| GET | `/encounters/{ulid}` | `encounters.view` | Full clinical read (logged as a record access) |
| POST | `/encounters/{ulid}/close` | `encounters.close` | Close with a disposition |
| POST | `/encounters/{ulid}/cancel` | `encounters.close` | Abandon with a mandatory reason |
| POST | `/encounters/{ulid}/notes` | `encounters.write_notes` | Draft a note (optionally sign at once) |
| PUT | `/encounters/{ulid}/notes/{note}` | `encounters.write_notes` + author | Edit an unsigned draft |
| POST | `/encounters/{ulid}/notes/{note}/sign` | `encounters.sign_notes` + author | Sign — text fixed from here |
| POST | `/encounters/{ulid}/notes/{note}/amend` | `encounters.amend_notes` | New version; original intact |
| POST | `/encounters/{ulid}/notes/{note}/retract` | `encounters.amend_notes` | Mark `entered_in_error` |
| POST | `/encounters/{ulid}/vitals` | `encounters.record_vitals` | Record observations |
| POST | `/encounters/{ulid}/diagnoses` | `encounters.diagnose` | Record a diagnosis |
| POST | `/encounters/{ulid}/diagnoses/{ulid}/retract` | `encounters.diagnose` | Mark `entered_in_error` |

Note routes are **scope-bound**: `{note}` resolves through the bound
encounter's own notes relation, so a note identifier from a different
consultation is a 404, not a reachable record.

### POST /encounters

Body: `patient` (ULID, required), `appointment` (ULID, optional), `type`
(`consultation|follow_up|walk_in|telemedicine|procedure`, default
consultation), `chief_complaint` (optional; falls back to the booking's
stated reason).

Opening from a booking moves that booking to `in_progress` in the same
transaction; the clinician is the one on the booking, otherwise the caller.
`201 encounter_opened`.

`409 encounter_already_open` when the patient already has an open
consultation — `meta.context.encounter_ulid` names it; open that one instead
of retrying.

### GET /encounters/{ulid}

Embeds `patient`, `doctor`, `notes` (current versions only — drafts and
signed; amended and retracted versions stay in the history), `vitals` and
`diagnoses`. Each nested entry carries its author/recorder as `{ulid, name}`.
Every read is logged as a patient-record access.

### Notes — the amendment chain

`POST …/notes` with `{section: subjective|objective|assessment|plan|general,
body, sign: bool}` → `201 note_saved`, `data.status` `draft` or `signed`.

A draft may be edited (`PUT`, author only) and signed (author only — no
permission grants signing in somebody else's name). From signature the text
is fixed: `POST …/amend` with `{body, reason (min 10 chars)}` writes version
n+1 (`201 note_amended`) and the original stays byte-for-byte intact. An
already-superseded version cannot be amended again (403 by policy; the
service's own fork guard renders `422 note_not_amendable` if reached).

### POST /encounters/{ulid}/vitals

All readings optional (`height_cm`, `weight_kg`, `temperature_c`,
`pulse_bpm`, `respiratory_rate`, `systolic`, `diastolic`, `spo2`,
`pain_score`, `notes`) with wide, transposition-catching bounds; `diastolic`
must be below `systolic`. An entirely blank set is `422 vitals_empty`.
`201 vitals_recorded` returns the row with `bmi` (derived server-side, once),
`blood_pressure`, and the coarse `outside_usual_range` cue.

### POST /encounters/{ulid}/diagnoses

`{name, certainty: suspected|confirmed|ruled_out, is_primary?, notes?}` →
`201 diagnosis_recorded`. A new primary displaces the previous one — there is
never more than one. Retraction keeps the row as `entered_in_error`.

`icd10_code` was accepted and returned until 2026-08-10 and is now neither.
Codes recorded before that date remain in the database; they are simply no
longer published.

### Closing

`POST …/close` with `{disposition: discharged|follow_up|referred|admitted,
follow_up_on}` — the date is required exactly when the disposition is
`follow_up`. `200 encounter_completed`; the booking behind it completes in
the same transaction. A closed encounter accepts nothing new: policy-gated
callers see `403 forbidden`, and the service backstop renders
`422 encounter_closed` for anything that bypasses policies (e.g. Super
Admin).

## Error codes

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