# API Platform

**Status:** 2026-08-02 · **approved; foundation and Patients read vertical implemented
and verified live.** Build-order steps 1–6 are done (§12); module-by-module
expansion (step 8) awaits go-ahead.

## 0. What this document is

The API is the application's public platform, not a mobile afterthought. It must
eventually expose every business capability the web UI exposes, through exactly
the same service layer, so that mobile apps, desktop clients, partner systems
and a future public API are all consumers of one set of rules.

This document specifies the architecture: authentication, versioning, routing,
resources, the response standard, exception rendering, rate limiting, OpenAPI
readiness, and the tests that keep all of it honest. **Endpoints are not
implemented until this is approved.**

As with every other design document in this repository, it starts from what is
already built rather than pretending a blank page.

## 1. What already exists

| Piece | State |
|---|---|
| Sanctum | Installed (`laravel/sanctum ^4.0`), `personal_access_tokens` migrated |
| Module API routing | **Already wired.** The kernel loads `app/Modules/<X>/Routes/api.php` under prefix `api/v1`, middleware `['api', 'auth:sanctum']`, route names `api.v1.*`. No module ships such a file yet |
| Core `routes/api.php` | One endpoint: `GET /api/v1/me` |
| Exception duality | `DomainException` already renders JSON for `expectsJson()` requests and a flash-redirect for browsers — "one throw, two presentations" is in place |
| Error codes | Every `DomainException` carries `errorCode()`, `httpStatus()`, `context()`, `userMessage()` — the raw material for a stable machine-readable error surface |
| Audit + request id | `EstablishClinicContext` is appended to the `api` group; every response carries `X-Request-Id` |
| Rate limiters | `login`, `two-factor`, `passkeys` exist. **No `api` limiter yet** |

### 1.1 Two bugs the foundation work fixed — one predicted, one found

**The session bug (predicted in the design).** `EstablishClinicContext`
resolved the working branch from `$request->session()` for any authenticated
user; a stateless token request has no session, so the first authenticated API
call was a guaranteed 500. Fixed: the middleware is transport-aware
(`hasSession()`), and `tests/Feature/Api/StatelessContextTest.php` pins it with
real bearer tokens through the full middleware stack.

**The binding-order hole (found by the new tests, and it affected the web
too).** `SubstituteBindings` sits in the framework's middleware priority list;
an unprioritized middleware appended to a group runs *after* it. Route model
binding therefore resolved `{patient}` while `BranchContext` was still null —
and the branch scope treats null as "no constraint" — so a crafted URL carrying
another branch's ULID bound the record instead of 404ing, **on both
transports**, for any user holding the view permission. Fixed by pinning
`EstablishClinicContext` into the priority list between `Authenticate` and
`SubstituteBindings`; pinned by tests on both doors
(`ApiPatientsTest`: "cross-branch record and a nonexistent one the same 404" /
"branch scope before route binding on the web door too").

---

## 2. Authentication strategy

### 2.1 Sanctum personal access tokens

```
POST   /api/v1/auth/tokens          issue a token        (guest, rate-limited)
GET    /api/v1/auth/tokens          list own tokens      (auth)
DELETE /api/v1/auth/tokens/current  revoke this token    (auth)
DELETE /api/v1/auth/tokens/{ulid}   revoke another       (auth)
GET    /api/v1/me                   who am I             (auth, exists)
```

Issuance takes `email`, `password`, `device_name`, and `otp` when the account
has two-factor enrolled. It reuses the **same** `login_attempts` lockout and
`PasswordPolicy` the web login uses — an API that bypasses the brute-force
lockout is a side door, not a platform.

- **Two-factor is enforced at issuance.** An account with 2FA enrolled cannot
  mint a token with password alone. Without this, enrolling 2FA on the web
  would protect one door of two.
- **Token expiry** comes from `sanctum.expiration`, surfaced as a clinic
  setting (`api.token_expiry_days`, default 30, `0` = never for kiosk devices).
  Expiry is a policy the clinic owns, not a constant.
- **`device_name` is required**, so the token list a user sees reads
  "Reception iPad", not "token 3" — revoking the right one depends on knowing
  which one it is.
- Issuance and revocation are **audited**, exactly like web sessions.

### 2.2 The `/me` contract (decided 2026-08-02, permanent)

```json
"roles": ["super-admin"],
"is_super_admin": true,
"permissions": []
```

Three rules every client must follow:

1. **`permissions` is explicit grants only.** A Super Admin legitimately shows
   an empty list, because the server answers for them through `Gate::before`
   rather than through granted rows.
2. **`is_super_admin: true` means treat the user as unrestricted** — the
   client-side mirror of that server rule.
3. **Clients must never reconstruct effective permissions by expanding the
   list themselves.** That would re-implement `Gate::before` in every client
   and go stale the first time a release introduces a permission.

Pinned per role — regular user, staff, administrator, super admin — in
`tests/Feature/Api/MeContractTest.php`.

### 2.3 Authorization: policies, not token abilities

Sanctum token *abilities* are *not* used for authorization in v1. The Spatie
permission set and the existing policies are the **single authority**, for the
same user, on both doors. `PrescriptionPolicy::issue()` answers the question
identically whether the request arrived by Blade or by JSON.

Duplicating permissions into token abilities would create two places where "may
this user verify a lab result" is decided, and they would disagree within a
release. Abilities are reserved for a future need they actually fit:
**scoping third-party integration tokens** to a subset of the owning user's
permissions (a partner system that may read appointments and nothing else).
The token model supports this today; nothing consumes it yet.

---

## 3. Request context

### 3.1 Branch — the session-free fix

`EstablishClinicContext` becomes transport-aware without the services knowing:

1. **No session available** (token request): branch = the user's own
   `branch_id`. A multi-branch user may send **`X-Branch: <branch ulid>`**;
   it is honoured only if they hold `branches.switch` or a `branch_users`
   membership for it, and a disallowed value is a 403, not a silent fallback —
   a client that believes it is writing into Branch B must never silently write
   into Branch A.
2. **Session available** (web): unchanged behaviour.

Branch resolution is audited in both transports. The `X-Branch` header is
declared in the OpenAPI conventions (§8) as a global optional header.

### 3.2 Locale

`SetLocale` currently runs only on `web`. The API honours **`Accept-Language`**
(restricted to `config('clinic.locales')`) so the `message` field of every
envelope arrives in the caller's language — Arabic clients get Arabic
validation messages, which is not cosmetic in a two-locale product.

### 3.3 Request id

Already present: every response carries `X-Request-Id`, and the audit trail
records it. API clients are told (in the error envelope docs) to quote it when
reporting a problem.

---

## 4. Versioning strategy

**URI versioning.** `/api/v1/` now; `/api/v2/` when a breaking change forces it.

What "a version" physically is:

| Versioned (per version) | Not versioned (shared) |
|---|---|
| `Http/Controllers/Api/V1/` | Services, repositories, queries |
| `Http/Resources/V1/` | Models, policies, events |
| `Routes/api.php` (v1) / `Routes/api_v2.php` | Form Request *rules* may be shared where identical |

A v2 is therefore **new controllers and resources over the same services** —
the transformation changes, the business rules cannot. The kernel's route
loader gains a version loop when (and only when) a second version exists;
`config('modules.api_versions')` lists active versions, so retiring v1 someday
is configuration, not surgery.

**What counts as breaking** (new version required): removing/renaming a field,
changing a type or semantic, tightening validation on an existing field.
**Not breaking** (allowed in-place): new endpoints, new optional fields, new
enum *values* documented as open sets.

Deprecation is signalled per-endpoint with `Deprecation` and `Sunset` headers
before any removal — clinics run old mobile builds for a long time.

---

## 5. Routing strategy

Already the kernel's behaviour, restated as the contract:

- **A module's API routes live in `app/Modules/<X>/Routes/api.php` and nowhere
  else.** The kernel wraps them in `api/v1` + `auth:sanctum` + names `api.v1.*`.
  A module cannot forget the auth middleware, because it never writes it.
- **Core `routes/api.php`** carries only cross-cutting endpoints: `auth/tokens`,
  `me`. Core importing a module controller is already a build failure.
- **No closure routes.** Every endpoint is a controller method — closures
  cannot be reflected by the OpenAPI generator or the architecture tests.
- Naming: `api.v1.<module>.<resource>.<action>` (`api.v1.patients.index`),
  paths kebab-case, resource-plural: `GET /api/v1/lab/orders/{order}`.
- Route model binding by **ULID only** — already the only public identifier.

Unauthenticated endpoints (token issuance alone, for now) are declared in core
routes with an explicit `guest`-appropriate limiter, never in module files.

---

## 6. Resource strategy

**No Eloquent model, paginator, or array ever leaves a controller.** Every
response body is built by a named `JsonResource`:

```
app/Modules/Patients/Http/Resources/V1/PatientResource.php
app/Modules/Patients/Http/Resources/V1/PatientSummaryResource.php
```

Rules:

- A resource exposes an **allow-list**. `$this->resource->toArray()` is
  forbidden by test — a column added by migration must never leak by default.
  The blind-index columns, `national_id`, internal `id`s and foreign keys stay
  out unless deliberately placed in.
- **`ulid` is the only identifier serialized.** Internal integer ids never
  appear in a payload.
- **Money** serializes through the existing `Money::jsonSerialize()` —
  `{"amount": minor units, "currency": "USD", "formatted": "12.50"}`. No API
  client does arithmetic on a formatted string.
- **Dates** are ISO-8601 UTC (`2026-08-02T14:30:00Z`); date-only fields are
  `Y-m-d`. The client localizes; the appointment's stored timezone is included
  where it is clinically meaningful.
- **Enums** serialize as their backed string value plus, where the UI needs it,
  a localized `*_label` sibling.
- Resources may compose other resources and read DTOs; they may not query.
  Relations they serialize must be eager-loaded — `preventLazyLoading` turns a
  violation into a test failure, which is exactly how the catalogue N+1 was
  caught on the web side.

---

## 7. Response standard

### 7.1 The envelope

Exactly the approved shape, with one **additive** field, `code` — a stable
machine-readable slug on failures. Clients must branch on codes, not on parsing
English sentences; `DomainException::errorCode()` already supplies them.

**Success:**

```json
{
  "success": true,
  "message": "Prescription RX-MAIN-2026-00042 issued.",
  "data": { "ulid": "01J…", "reference": "RX-MAIN-2026-00042", "status": "issued" },
  "meta": null,
  "errors": null
}
```

**Paginated collection:**

```json
{
  "success": true,
  "message": null,
  "data": [ { }, { } ],
  "meta": {
    "pagination": { "page": 2, "per_page": 25, "total": 92, "last_page": 4 }
  },
  "errors": null
}
```

**Validation failure (422):**

```json
{
  "success": false,
  "message": "The given data was invalid.",
  "code": "validation_failed",
  "data": null,
  "meta": null,
  "errors": {
    "value_numeric": ["Enter a number such as 5.4."]
  }
}
```

**Domain rule rejection (409/422 per the exception):**

```json
{
  "success": false,
  "message": "This prescription conflicts with a recorded allergy. Give a reason to proceed, or change the medicine.",
  "code": "allergy_override_required",
  "data": null,
  "meta": { "context": { "warnings": [ { "allergen": "Penicillin", "match": "ingredient" } ] } },
  "errors": null
}
```

`errors` is reserved for the field→messages map; structured domain context
travels in `meta.context`, mirroring what the web flash already receives.

The envelope is produced by one builder — `App\Foundation\Api\ApiResponse` —
plus a thin `ApiController` base class every module extends. One place to
change, one place to test.

### 7.2 One envelope, both doors (completed 2026-08-03)

Web-AJAX originally kept its own `{error: {code, message, details}}` shape so
the API could ship without rewriting a working AJAX layer. That deferral is
now closed: **both doors return the same envelope**.

The application previously spoke two JSON error dialects, so any consumer had
to know which door it had knocked on — and nothing tested either shape, which
is how it stayed that way.

What changed:

- `BaseFormRequest::failedValidation` returns
  `{success, code: 'validation_failed', …, errors}` for web-AJAX instead of
  `{error: {code: 'VALIDATION_FAILED', details}}`. Note the code is now
  `lower_snake`, matching the registry.
- The web `DomainException` handler in `bootstrap/app.php` returns
  `ApiResponse::failure(...)`, so structured detail travels in `meta.context`
  on both doors.
- `resources/js/app.js` reads **either** shape (`body.errors ?? body.error.details`,
  `body.message ?? body.error.message`), so the two sides could be deployed in
  any order and a browser holding a cached bundle does not lose its error
  messages.

Unchanged deliberately: a **full page load** still redirects back with the
errors bag and the flash `error_code`/`error_details`, because a browser form
post is not an AJAX call. That distinction is pinned by a test.

Covered by `tests/Feature/WebAjaxEnvelopeTest.php` — validation failure,
domain refusal, the redirect path, and a check that the API door is
undisturbed.

---

## 8. Exception rendering

Centralized in `bootstrap/app.php`, extending what is there. For `api/*`
requests, every failure renders the envelope:

| Condition | Status | `code` |
|---|---|---|
| Validation | 422 | `validation_failed` |
| Unauthenticated | 401 | `unauthenticated` |
| Forbidden (policy) | 403 | `forbidden` |
| Model/route not found | 404 | `not_found` — same body whether the ULID is unknown or belongs to another branch; the difference is an enumeration oracle |
| `DomainException` | `httpStatus()` (409/422) | `errorCode()` |
| `FeatureNotLicensedException` | 402 | `feature_not_licensed` |
| Rate limited | 429 + `Retry-After` | `rate_limited` |
| Anything else | 500 | `server_error` — generic message, full detail to the log with the request id, **never** a stack trace or SQL to the client |

`Idempotency-Key` is honoured on `POST` payment endpoints — the header maps
onto the idempotency mechanism the `payments` table already has, so a mobile
client retrying on a dropped connection cannot charge a patient twice.

---

## 9. Rate limiting

Named limiters registered beside the existing ones, values from **settings**
(clinic-tunable) with config fallbacks:

| Limiter | Applies to | Default |
|---|---|---|
| `api` | authenticated, keyed by user id | 120/min |
| `api-guest` | unauthenticated, keyed by IP | 20/min |
| `api-token-issue` | `POST auth/tokens`, keyed by IP **and** by email | 5/min — the same posture as web login, plus the `login_attempts` lockout underneath |

429 responses use the envelope and carry `Retry-After`. Every response carries
`X-RateLimit-Limit` / `X-RateLimit-Remaining` so a well-behaved client can back
off before hitting the wall.

---

## 10. OpenAPI preparation

No generator dependency now. The conventions above are chosen so one can be
added later **without structural change**, because everything a spec needs is
already mechanical:

- No closure routes → every operation reflects to a controller method.
- One Form Request per write endpoint → request schema derives from `rules()`.
- One Resource per representation → response schema derives from the resource.
- Stable route names → operation ids (`api.v1.patients.show`).
- Stable error `code` slugs → a documentable error registry (`docs/api/errors.md`
  grows one line per code, enforced by a test that every `DomainException`
  subclass's code appears in it).
- Global headers (`X-Branch`, `X-Request-Id`, `Idempotency-Key`) documented once.

When generation is wanted, Scribe or PHP attributes bolt onto this shape;
nothing designed here would need to move.

---

## 11. Architecture tests

The five required rules, and how each is enforced *mechanically*:

| Rule | Enforcement |
|---|---|
| API controllers never touch repositories | Already enforced twice since the `Repositories\Contracts` split: Deptrac's Controller layer, and the boundary test. The three violations that split exposed are the proof it now bites |
| API controllers contain no business logic | Honest framing: "business logic" is not machine-decidable, so the **proxies** are enforced — no Eloquent imports, no `DB::`, no transactions (Deptrac), plus a new test: every Api controller method body ≤ the coordinate-only pattern (authorize → DTO → one service/query call → resource). The un-mechanizable remainder is review |
| API controllers never return Eloquent models | New test: reflect every method in `Http/Controllers/Api/**`; the declared return type must be `JsonResource`, `ResourceCollection`, or `JsonResponse`. Declared types are required, so this cannot be dodged by omission |
| Endpoints cannot bypass services | Corollary of the import rules: with Eloquent, `DB`, and repositories all unreachable from a controller, a write has nowhere to go but a service; reads go through Query objects and directories, same as web |
| Module API routes registered only in their own module | Two existing tests already cover most of it (core may not import module controllers; module A may not import module B's controllers). New addition: iterate `Route::getRoutes()` under `api/v1` and assert every controller lives in `App\Http\Controllers\Api` (core) or `App\Modules\<X>\Http\Controllers\Api` — no vendor, no closures, no strays |

Plus, beyond the brief:

- **Parity test scaffold**: for each capability exposed on both doors, a test
  asserting the API controller and web controller inject the *same* service
  interface — the mechanical core of "no duplicated business logic".
- **Resource allow-list test**: no resource may call `toArray()` on its
  underlying model.

---

## 12. Build order — state

| Step | Work | State |
|---|---|---|
| 1 | Foundation: session-free `EstablishClinicContext` (+ `X-Branch`), `ApiResponse` + `ApiController`, envelope exception renderer, `api`/`api-guest`/`api-token-issue` limiters | **Done** — plus the binding-order fix §1.1 |
| 2 | Auth endpoints: token issue (2FA-aware, on the shared `CredentialVerifier`) / list / revoke; `/me` rebuilt from a closure onto `MeController` + `UserResource`; ULIDs on personal access tokens | **Done** |
| 3 | Architecture tests (§11) | **Done** — `tests/Architecture/ApiPlatformTest.php`, 5 rules |
| 4 | Patients read vertical: index (filters, pagination), show (`?include=allergies`, record-access logged), allergies | **Done** — the template for every module vertical |
| 5 | `/me` contract finalized: `is_super_admin` boolean added, `permissions` stays explicit-only (§2.2); four-role contract tests | **Done** — decided 2026-08-02, permanent |
| 6 | Error-code registry doc + the test that keeps it complete | **Done** — `docs/api/errors.md`; the arch test scans every module and Foundation exception for code literals and fails on any unregistered or UPPER_SNAKE code |
| 7 | **Appointments vertical**: list/show/book/status, slots, queue, walk-in — same form requests and `AppointmentServiceInterface` as web, parity pair added; `docs/api/appointments.md` | **Done** — verified live 2026-08-02 (token → slots → book → 409 conflict → check-in → queue → walk-in → cancel envelope → revoke) |
| 8 | **Consultations vertical**: lifecycle (open from booking/cold, close, cancel), notes with the full amendment chain, vitals, diagnoses — web's inline validation extracted into shared form requests used by both doors; note routes scope-bound on both doors; `docs/api/consultations.md` | **Done** — verified live 2026-08-02 (409 already-open → walk-in → open-from-booking → sign → amend → vitals-empty 422 → diagnosis → full read → close → booking completed → closed-encounter backstop 422) |
| 9 | **Prescriptions vertical**: draft → items (formulary-copied or free-text) → allergy-gated issue → cancel/reissue chain, plus formulary search with inline per-patient warnings; encounter attachment by ULID through Consultations' new published `EncounterDirectory`; `docs/api/prescriptions.md` | **Done** — verified live 2026-08-02 (search-with-warnings → draft → conflicting item → 409 silent-issue → documented override → immutability → reissue chain → cancel) |
| 10 | **Billing vertical**: invoices (draft→issue→void), lines, discounts, payments idempotent via the `Idempotency-Key` header, reversals/refunds, credit notes, cash sessions, catalogue search, payment methods; `docs/api/billing.md` | **Done** — verified live 2026-08-02 (methods → catalogue → draft → issue INV-…-00001 → cash-refused-without-till → idempotent retry → till reconciled with variance → credit note → negative balance) |
| 11 | **Laboratory vertical**: orders with panel expansion, accessioning, sex/age-matched flagging, the verification gate, versioned amendments, critical-value acknowledgement, the worklist; `docs/api/laboratory.md` | **Done** — verified live 2026-08-02 (catalogue → STAT panel order → accession → receive → critical 6.9 flagged against the patient's own range → verify → acknowledge by name → amend to v2 re-flagged → worklist). **All five verticals are now live: the API platform is feature-complete for v1.** |

The Laboratory build fixed the same two defect classes as Billing, found by
the same audit: both module exceptions extended bare `RuntimeException`
(refusals rendered as 500s on both doors — now `lab_order_error` /
`lab_result_error` with machine-readable `meta.context.reason`), and
`place()` accepted any `encounter_id` unvalidated (now
`lab_order_encounter_mismatch`, guarded in the service).

The Billing build fixed a platform-level defect on both doors: the module's
four exceptions extended bare `RuntimeException` — a refused payment rendered
as a **500** on web and API alike. They now extend `DomainException`
(`payment_refused`, `invoice_not_editable`, `credit_note_refused`,
`cash_session_error`, each with a machine-readable `meta.context.reason`),
which buys the web flash message and the API envelope in one move. It also
closed the same context-integrity gap Prescriptions had: `draftFor` now
verifies the encounter/appointment belongs to the billed patient
(`invoice_context_mismatch`), in the service where no transport can skip it.

The Prescriptions build hardened one integrity gap on both doors at once: the
web form accepted any raw `encounter_id` integer unvalidated, so a
prescription could be attached to another patient's consultation. The
ownership guard now lives in `PrescriptionService::startDraft` (via
Consultations' published `EncounterDirectory`), where no transport can skip
it; the API additionally never accepts internal ids — encounters are named by
ULID.

The Consultations build also fixed two latent web-door defects in passing:
`type` on the open form was validated as a bare string (an unknown value
became a 500 inside `EncounterType::from`), and the nested note routes never
actually scoped `{note}` to its encounter — the comment promised it, but
without `scopeBindings()` each parameter bound independently. Both doors now
share the fixed contract.

Two defects the Appointments vertical's build caught, for the record:

- **Web validation shape leaking through the API door**: `BaseFormRequest`
  intercepted every JSON validation failure with the frozen web-AJAX
  `{error: {…}}` shape via `HttpResponseException` — a prepared response
  bypasses the exception renderer, so the envelope never applied to shared
  form requests. Now excluded by path (`api/*` lets `ValidationException`
  propagate); web-AJAX unchanged.
- **Raw lang keys as messages**: the API controllers named lang keys that did
  not exist, and live responses carried `appointments::messages.…` verbatim.
  Caught only by the live gate; now pinned by message assertions in the
  vertical's tests.

`Accept-Language` is now honoured on the API (done 2026-08-03). `SetLocale`
runs on the `api` group and the precedence differs per door, deliberately:

```
web : user preference -> session         -> clinic default
API : Accept-Language -> user preference -> clinic default
```

The header outranks the stored preference on the API because `users.locale`
is `NOT NULL DEFAULT 'en'` — every account carries a value whether or not
anyone chose it, so a stored `en` cannot be told apart from an untouched
default. Ranking it first would have left the header unreachable on an API
where every route is authenticated, and an Arabic client would still have been
served English. The web keeps user-preference-first, so a clinician's profile
choice is never overridden by their browser.

Region-qualified tags fall back to their base language (`ar-IQ` → `ar`), tags
are ranked by q-value rather than written order, and locales the clinic has no
translations for are ignored rather than matched. `SetLocale` also had the
same unguarded `$request->session()` call that was fixed in
`EstablishClinicContext`; it is now guarded, since it runs on stateless
requests.

## 13. Decisions needing sign-off

1. **Envelope + additive `code` field** (§7.1) — the approved shape, plus one
   field clients can branch on. Without it they parse English.
2. **Web-AJAX keeps its existing envelope for now** (§7.2) — the platform
   envelope applies to `api/*`; migrating internal AJAX is deferred.
3. **Token abilities are not authorization** (§2.2) — policies remain the
   single authority; abilities reserved for future third-party scoping.
4. **2FA enforced at token issuance** (§2.1).
5. **`X-Branch` header semantics** (§3.1) — default to the user's branch,
   explicit header validated against membership, 403 on mismatch.
