# Sojourncase API Reference

The complete HTTP contract for the Sojourncase Rust/Axum API (`apps/api`). Every
endpoint below is a real route in `apps/api/src/router.rs`, backed by a handler in
`apps/api/src/routes/*.rs`, with request/response shapes from `apps/api/src/dto.rs`
and 1:1 typed client wrappers in `apps/web/src/lib/api.ts`.

- **Base URL**: `https://sojourn.cross-fare.com`
- **API prefix**: all application endpoints live under `/api/*`. nginx proxies
  `/api/*` to the API; the SPA is served static at the root.
- **Media type**: every request/response body is `application/json` unless noted
  (file upload/download use raw bytes).
- **Anchors**: each endpoint section uses a stable anchor matching the
  `operationId` convention (e.g. [`#addrecord`](#addrecord)), so the agentic
  envelope's `meta.docs.endpoints` map can deep-link directly into this file.

---

## Conventions

### Authentication

All endpoints except `register`, `login`, and the two health probes require a
**Bearer JWT** in the `Authorization` header:

```
Authorization: Bearer <token>
```

- Tokens are HS256, `sub = user uuid`, TTL 7 days (`apps/api/src/auth.rs`).
- A missing, malformed, or expired token → **401 Unauthorized**.
- The token is obtained from [`login`](#login) or (in open-registration mode)
  [`register`](#register).

### Case-scoped authorization

Most data is scoped under `/api/cases/{caseId}/...`. Every case-scoped route runs
one of three authorization choke points on `:caseId`, resolved from the
`case_members` table:

| Guard            | Roles allowed                    | On failure |
| ---------------- | -------------------------------- | ---------- |
| `require_member` | owner, editor, reviewer, viewer  | **403**    |
| `require_writer` | owner, editor                    | **403**    |
| `require_owner`  | owner                            | **403**    |

A non-member always receives **403 Forbidden** (never 404) so case existence is
never revealed to outsiders.

### Identifiers (client-supplied ids)

Create endpoints accept an optional client-minted `id` (a UUID). The server adopts
it when it parses as a valid UUID, otherwise it mints its own (`dto::id_or_new`).
This lets the SPA's optimistic row and the persisted row share one id with no
reconciliation step. A malformed `id` is silently absorbed (never a 422).

### Error envelope

Every non-2xx response is a uniform JSON body:

```json
{ "error": "human-readable message" }
```

Status codes (`apps/api/src/error.rs`):

| Status | Meaning                                                                 |
| ------ | ---------------------------------------------------------------------- |
| 400    | Bad request (malformed id, empty key, path/body mismatch)              |
| 401    | Unauthorized (missing/invalid/expired token)                          |
| 402    | Insufficient credits (balance below the action's cost)                |
| 403    | Forbidden (not a case member, or insufficient role) / access-gated    |
| 404    | Not found (row missing, or a referenced FK row is gone)               |
| 409    | Conflict (duplicate email; non-reversible undo)                       |
| 413    | Payload too large (file upload > 25 MB)                               |
| 422    | Validation failed (invalid enum value: stage, status, type, priority) |
| 500    | Internal (DB / hashing / crypto error — opaque to clients)            |
| 503    | AI unavailable (no provider configured, or provider call/parse failed) |

### Credit costs (AI / metered actions)

AI endpoints are **server-authoritative**: the client awaits the returned body
(which includes the new `balance`) and merges it. The fixed product price is
charged in the same transaction as the mutation, **after** the provider returns
successfully. If no provider is configured or the call fails, the endpoint returns
**503 before charging anything** — mock output is never substituted.

| Action                       | operationId       | Cost (credits)            |
| ---------------------------- | ----------------- | ------------------------- |
| Autofill a tracker record    | [`autofillRecord`](#autofillrecord) | **2** (only when it delivers a fill/amend; 0 otherwise) |
| Generate petition brief      | [`generateBrief`](#generatebrief)   | **48**                    |
| Run adversarial review       | [`reviewBrief`](#reviewbrief)       | **24**                    |
| File upload/download/delete  | —                 | Free                      |

### Pagination (feeds)

The events and ledger feeds are newest-first and cursor-paged via query params:

- `limit` — rows per page (events: default 100, clamp 1–500; ledger: default 50,
  clamp 1–200).
- `before` — RFC3339 timestamp; only rows strictly older are returned.

---

## Tag index

- [Auth](#auth) — [`register`](#register), [`login`](#login), [`getMe`](#getme), [`logout`](#logout)
- [Visa](#visa) — [`listVisaTypes`](#listvisatypes), [`getVisaType`](#getvisatype)
- [Cases](#cases) — [`listCases`](#listcases), [`getCase`](#getcase), [`createCase`](#createcase), [`patchCase`](#patchcase), [`deleteCase`](#deletecase), [`toggleClaim`](#toggleclaim), [`listEvents`](#listevents), [`undoEvent`](#undoevent)
- [Columns](#columns) — [`addColumn`](#addcolumn), [`updateColumn`](#updatecolumn), [`moveColumn`](#movecolumn), [`deleteColumn`](#deletecolumn)
- [Tasks](#tasks) — [`addTask`](#addtask), [`updateTask`](#updatetask), [`markDone`](#markdone), [`deleteTask`](#deletetask), [`spawnTask`](#spawntask)
- [Groups](#groups) — [`addGroup`](#addgroup)
- [Docs](#docs) — [`addDoc`](#adddoc), [`addLinkedDoc`](#addlinkeddoc), [`deleteDoc`](#deletedoc), [`setDocStatus`](#setdocstatus), [`setDocSource`](#setdocsource), [`uploadDocFile`](#uploaddocfile), [`downloadDocFile`](#downloaddocfile), [`deleteDocFile`](#deletedocfile)
- [Trackers](#trackers) — [`addTracker`](#addtracker), [`updateTracker`](#updatetracker), [`deleteTracker`](#deletetracker), [`addTrackerField`](#addtrackerfield), [`configureTracker`](#configuretracker)
- [Records](#records) — [`addRecord`](#addrecord), [`updateRecord`](#updaterecord), [`setRecordValue`](#setrecordvalue), [`deleteRecord`](#deleterecord), [`autofillRecord`](#autofillrecord)
- [Draft](#draft) — [`getDraft`](#getdraft), [`generateBrief`](#generatebrief), [`reviewBrief`](#reviewbrief), [`findingToTask`](#findingtotask), [`signoffBrief`](#signoffbrief)
- [Credits](#credits) — [`getCredits`](#getcredits), [`getLedger`](#getledger), [`setTheme`](#settheme)
- [Health](#health) — `health`, `healthDb`
- [Agentic navigation rels](#agentic-navigation-rels) — `self`, `up`, `case`, `today`, `build`, `tracker`, `record`, `records`, `evidence`, `doc`, `source`, `draft`, `task`, `tasks`, `events`, `credits`, `visaTypes`, `visaDef`, `next`, `new`

---

## Auth

Routes in `apps/api/src/routes/auth.rs`. Passwords are argon2id-hashed; tokens are
HS256 JWTs. Unknown-email and bad-password both yield the same 401 (no account
enumeration).

### register

`POST /api/auth/register` · **public (no auth)**

Create a user account and grant the signup credit allotment in one transaction.
Gated by the operator's registration policy:

- **Open** → account is `active`, a token is issued (**201**, `AuthResp`).
- **Approval** → account is created `pending`, NO token (**202**, `PendingResp`);
  the user can sign in only after an operator approves them.
- **Paused** → **403** `AccessGated`, no account created.

**Request body** (`RegisterReq`):

```json
{ "name": "Ada Lovelace", "email": "ada@example.com", "password": "secret" }
```

**Response 201** (`AuthResp`):

```json
{ "token": "<jwt>", "user": { "name": "Ada Lovelace", "email": "ada@example.com" } }
```

**Response 202** (`PendingResp`):

```json
{ "pending": true, "message": "Your account was created and is awaiting approval." }
```

**Status codes**: 201 created · 202 pending approval · 403 registration paused
· 409 duplicate email · 422 missing email/password.

### login

`POST /api/auth/login` · **public (no auth)**

Exchange credentials for a bearer token. Unknown email, OAuth-only account, and
bad password all return the SAME 401 (constant-time; no enumeration). A correct
password on a non-`active` account (pending/banned) returns a distinct 403.

**Request body** (`LoginReq`):

```json
{ "email": "ada@example.com", "password": "secret" }
```

**Response 200** (`AuthResp`): `{ "token": "<jwt>", "user": { "name", "email" } }`

**Status codes**: 200 ok · 401 unknown email / bad password · 403 account
pending or banned.

### getMe

`GET /api/auth/me` · **Bearer**

Return the current user. Name/email are read fresh from the DB, never from the
token.

**Response 200** (`User`): `{ "name": "Ada Lovelace", "email": "ada@example.com" }`

**Status codes**: 200 ok · 401 invalid token.

### logout

`POST /api/auth/logout` · **Bearer**

Stateless-JWT no-op. The client clears its local token; the server returns
**204 No Content**.

**Status codes**: 204 no content · 401 invalid token.

---

## Visa

Routes in `apps/api/src/routes/visa.rs`. Reads the seeded `visa_types` +
`requirements` tables. Both endpoints require auth.

### listVisaTypes

`GET /api/visa-types` · **Bearer**

All visa definitions (`eb1a`, `niw`, `o1a`, `eb1b`), each with its requirement
names joined in sort order.

**Response 200** (`VisaDef[]`):

```json
[
  {
    "id": "eb1a",
    "name": "EB-1A",
    "full": "Extraordinary Ability",
    "rule": { "kind": "atLeast", "n": 3 },
    "finalMerits": true,
    "offer": false,
    "reqs": ["Awards", "Membership", "Press", "Judging", "Original contributions"]
  }
]
```

`offer` is present only when true (EB-1B). **Status codes**: 200 ok · 401.

### getVisaType

`GET /api/visa-types/{visaId}` · **Bearer**

One visa definition by id.

**Path params**: `visaId` — e.g. `eb1a`.

**Response 200** (`VisaDef`). **Status codes**: 200 ok · 401 · 404 unknown visa.

---

## Cases

Routes in `apps/api/src/routes/cases.rs`. Every mutation appends a `case_events`
row and bumps `cases.updated_at`. The `Case` aggregate is fully assembled
(columns, tasks, groups, docs, people, events, trackers, draft).

### listCases

`GET /api/cases` · **Bearer**

Every case the authenticated user is a member of, newest-updated first, each
assembled in full.

**Response 200** (`Case[]`). **Status codes**: 200 ok · 401.

### getCase

`GET /api/cases/{caseId}` · **Bearer** · `require_member`

One fully-assembled case (the SPA's refetch-on-error path).

**Path params**: `caseId` (UUID).

**Response 200** (`Case`). **Status codes**: 200 ok · 401 · 403 not a member.

### createCase

`POST /api/cases` · **Bearer**

Seed a new case for the given visa and return the freshly-assembled aggregate.
The caller becomes its owner. `id` is client-suppliable.

**Request body** (`CreateCaseReq`, plus optional `id`):

```json
{ "id": "<uuid?>", "visa": "eb1a" }
```

**Response 201** (`Case`). **Status codes**: 201 created · 401.

### patchCase

`PATCH /api/cases/{caseId}` · **Bearer** · `require_writer`

Partial update of scalar case fields (`setField` / `setStage`). Only keys present
in the body are written (COALESCE). `stage` is validated against
`build | file | after`.

**Path params**: `caseId` (UUID).

**Request body** (`CasePatch`, all optional):

```json
{ "name": "…", "status": "…", "stage": "file", "target": "…", "receipt": "…", "field": "…", "attorney": "…" }
```

**Response 200** (`Case`). **Status codes**: 200 ok · 401 · 403 · 422 invalid stage.

### deleteCase

`DELETE /api/cases/{caseId}` · **Bearer** · `require_owner`

Owner-only. `ON DELETE CASCADE` removes all child rows; the case's on-disk evidence
blobs are best-effort removed.

**Response 204**. **Status codes**: 204 no content · 401 · 403 not owner · 404
already gone.

### toggleClaim

`PUT /api/cases/{caseId}/claims/{req}` · **Bearer** · `require_writer`

Flip the boolean stored at `claims[req]` (jsonb), keyed by the requirement NAME.
`{req}` arrives URL-encoded (names contain spaces).

**Path params**: `caseId` (UUID), `req` (URL-encoded requirement name).

**Response 200** (`ClaimResp`): `{ "req": "Awards", "claimed": true }`

**Status codes**: 200 ok · 400 empty key · 401 · 403 · 404 case gone.

### listEvents

`GET /api/cases/{caseId}/events` · **Bearer** · `require_member`

History feed, newest first, cursor-paged.

**Path params**: `caseId` (UUID). **Query params**: `limit` (default 100,
clamp 1–500), `before` (RFC3339 cursor).

**Response 200** (`CaseEvent[]`):

```json
[ { "id": "…", "t": "Jun 21", "text": "Added task <b>Draft CV</b>", "src": "edit", "usage": 48, "unreviewed": true } ]
```

**Status codes**: 200 ok · 401 · 403.

### undoEvent

`POST /api/cases/{caseId}/events/{eventId}/undo` · **Bearer** · `require_writer`

M3 stub: the committed `case_events` table carries no reversibility payload, so
every undo currently returns **409 Conflict** ("not reversible"). Still gated on
writer so case existence is never leaked.

**Status codes**: 409 conflict (always) · 401 · 403.

---

## Columns

Routes in `apps/api/src/routes/columns.rs`. Workstream columns on the Build board.
All `require_writer`. Colors cycle through the fixed PALETTE.

### addColumn

`POST /api/cases/{caseId}/columns` · **Bearer** · `require_writer`

Add a "New workstream" column. `id` client-suppliable.

**Request body** (`AddColumnReq`): `{ "id": "<uuid?>" }`

**Response 201** (`IdResp`): `{ "id": "<uuid>" }`. **Status codes**: 201 · 401 · 403.

### updateColumn

`PATCH /api/cases/{caseId}/columns/{colId}` · **Bearer** · `require_writer`

Rename and/or recolor in one request. A blank/whitespace `name` is ignored (keeps
the current name).

**Request body** (`ColumnPatch`, both optional): `{ "name": "…", "color": "#0a63d6" }`

**Response 200** (`Column`): `{ "id", "name", "color" }`. **Status codes**: 200 ·
401 · 403 · 404 column not in case.

### moveColumn

`POST /api/cases/{caseId}/columns/{colId}/move` · **Bearer** · `require_writer`

Reorder by swapping with the adjacent column in direction `dir` (`-1` | `1`).
Out-of-range neighbor is a no-op. Returns the reordered list.

**Request body** (`MoveColumnReq`): `{ "dir": 1 }`

**Response 200** (`Column[]`). **Status codes**: 200 · 401 · 403 · 404 column not
found · 422 invalid `dir`.

### deleteColumn

`DELETE /api/cases/{caseId}/columns/{colId}` · **Bearer** · `require_writer`

Remove a column, reassigning its orphaned tasks to the first other column (or
leaving them column-less when none remains). Returns the post-delete snapshot the
client adopts.

**Response 200** (`DeleteColumnResp`): `{ "cols": Column[], "tasks": Task[] }`

**Status codes**: 200 · 401 · 403 · 404 column not in case.

---

## Tasks

Routes in `apps/api/src/routes/tasks.rs`. `priority` is validated against
`critical | high | medium | low`.

### addTask

`POST /api/cases/{caseId}/tasks` · **Bearer** · `require_writer`

Create a task. The full `Task` (with its client `id`) is the body; the server
adopts that id. `colId` empty/invalid → no column; `linkedRecordId` is validated
as a UUID when non-empty.

**Request body** (`Task`):

```json
{
  "id": "<uuid>", "title": "Draft CV", "colId": "<uuid|>",
  "priority": "high", "req": "Awards", "date": "Jun 30", "deadline": false,
  "notes": "", "steps": [ { "t": "Outline", "done": false } ],
  "docIds": [], "done": false, "unreviewed": false, "linkedRecordId": null
}
```

**Response 201** (`Task`). **Status codes**: 201 · 401 · 403 · 400 invalid
linkedRecordId · 422 invalid priority.

### updateTask

`PUT /api/cases/{caseId}/tasks/{taskId}` · **Bearer** · `require_writer`

Full replace of the task's mutable fields.

**Request body** (`Task`, same shape as [`addTask`](#addtask)).

**Response 200** (`Task`). **Status codes**: 200 · 401 · 403 · 404 task not in
case · 400 invalid linkedRecordId · 422 invalid priority.

<a id="markdone"></a>

> **`markDone`** is the same `PUT /api/cases/{caseId}/tasks/{taskId}` endpoint with
> `{ "done": true }` (or `false` to reopen). The agentic envelope surfaces it as a
> distinct convenience action so an agent can toggle completion without re-sending
> the full task body.

### deleteTask

`DELETE /api/cases/{caseId}/tasks/{taskId}` · **Bearer** · `require_writer`

Remove a task.

**Response 204**. **Status codes**: 204 · 401 · 403 · 404 task not in case.

### spawnTask

`POST /api/cases/{caseId}/trackers/{trackerId}/records/{recordId}/spawn-task` ·
**Bearer** · `require_writer`

Create a "Follow up — &lt;record primary value&gt;" task in the first column,
inheriting `record.req`, priority `medium`, and `linkedRecordId = recordId`.
`id` client-suppliable.

**Request body** (`SpawnTaskReq`): `{ "id": "<uuid?>" }`

**Response 201** (`IdResp`): `{ "id": "<uuid>" }`. **Status codes**: 201 · 401 ·
403 · 404 tracker or record not in case.

---

## Groups

Route in `apps/api/src/routes/groups.rs`. Evidence groups on the Evidence board.

### addGroup

`POST /api/cases/{caseId}/groups` · **Bearer** · `require_writer`

Add a "New group" (`kind: "user"`); `req` and `strength` stay null. `id`
client-suppliable.

**Request body** (`AddGroupReq`): `{ "id": "<uuid?>" }`

**Response 201** (`Group`): `{ "id", "name", "kind": "user" }`. **Status codes**:
201 · 401 · 403.

---

## Docs

Routes in `apps/api/src/routes/docs.rs` (metadata) and
`apps/api/src/routes/doc_files.rs` (real file attachments, one per doc). `status`
is validated against `need | draft | final`; `source.kind` against `task | record`.

### addDoc

`POST /api/cases/{caseId}/docs` · **Bearer** · `require_writer`

Add a doc to a group: title "New document", status `need`, type `file`, date
`Jun`. `id` client-suppliable.

**Request body** (`AddDocReq`): `{ "id": "<uuid?>", "groupId": "<uuid>" }`

**Response 201** (`Doc`). **Status codes**: 201 · 400 invalid groupId · 401 · 403.

### addLinkedDoc

`POST /api/cases/{caseId}/docs/linked` · **Bearer** · `require_writer`

Create a doc linked to a Build effort (task or record). The server resolves the
requirement from the source, then files it into the matching req-group (else first
user-group, else first group).

**Request body** (`AddLinkedDocReq`):

```json
{ "id": "<uuid?>", "title": "Award certificate", "source": { "kind": "record", "trackerId": "<uuid>", "recordId": "<uuid>" } }
```

**Response 201** (`IdResp`): `{ "id": "<uuid>" }`. **Status codes**: 201 · 400
invalid source ids · 401 · 403 · 404 no group exists · 422 invalid source.kind.

### deleteDoc

`DELETE /api/cases/{caseId}/docs/{docId}` · **Bearer** · `require_writer`

Remove a doc (and its attached file row + on-disk blob, best-effort).

**Response 204**. **Status codes**: 204 · 401 · 403 · 404 doc not in case.

### setDocStatus

`PATCH /api/cases/{caseId}/docs/{docId}/status` · **Bearer** · `require_writer`

Set the doc status.

**Request body** (`SetDocStatusReq`): `{ "status": "final" }`

**Response 200** (`Doc`). **Status codes**: 200 · 401 · 403 · 404 doc not in
case · 422 invalid status.

### setDocSource

`PUT /api/cases/{caseId}/docs/{docId}/source` · **Bearer** · `require_writer`

Link the doc to a source effort, or pass `source: null` to clear the link.

**Request body** (`LinkDocReq`): `{ "source": { "kind": "task", "taskId": "<uuid>" } }`
or `{ "source": null }`.

**Response 200** (`Doc`). **Status codes**: 200 · 401 · 403 · 404 doc not in
case · 422 invalid source.kind.

### uploadDocFile

`POST /api/cases/{caseId}/docs/{docId}/file` · **Bearer** · `require_writer` ·
**raw body**

Attach a real file to the doc (replaces any prior attachment). Bytes are the raw
request body (≤ **25 MB**, else 413); the filename rides in the `x-filename`
header (URI-encoded). Stored encrypted at rest when a master key is configured.
**Free** (no credit charge).

**Headers**: `x-filename: <uri-encoded name>`, `Content-Type: <mime>`.

**Response 201** (`DocFile`):

```json
{ "id": "<uuid>", "filename": "cv.pdf", "contentType": "application/pdf", "sizeBytes": 84213 }
```

**Status codes**: 201 · 400 empty file · 401 · 403 · 404 doc not in case · 413
> 25 MB.

### downloadDocFile

`GET /api/cases/{caseId}/docs/{docId}/file` · **Bearer** · `require_member`

Return the (decrypted) bytes as a forced attachment (`Content-Disposition:
attachment`, `X-Content-Type-Options: nosniff`). Active content types are never
echoed (served as `application/octet-stream` instead). Requires the Bearer header,
so a plain `<a href>` won't work. **Free.**

**Response 200**: raw file bytes. **Status codes**: 200 · 401 · 403 · 404 no file.

### deleteDocFile

`DELETE /api/cases/{caseId}/docs/{docId}/file` · **Bearer** · `require_writer`

Remove the attached file (row + blob). **Free.**

**Response 204**. **Status codes**: 204 · 401 · 403 · 404 no file.

---

## Trackers

Routes in `apps/api/src/routes/trackers.rs`. A tracker is created empty
(no fields, no statuses) and then configured once via [`configureTracker`](#configuretracker).
Field `type` is validated against `text | date | select | link`.

### addTracker

`POST /api/cases/{caseId}/trackers` · **Bearer** · `require_writer`

Create an unconfigured tracker ("New tracker", empty fields/statuses, PALETTE color
by tracker count). `id` client-suppliable.

**Request body** (`AddTrackerReq`): `{ "id": "<uuid?>" }`

**Response 201** (`IdResp`): `{ "id": "<uuid>" }`. **Status codes**: 201 · 401 · 403.

### updateTracker

`PATCH /api/cases/{caseId}/trackers/{trackerId}` · **Bearer** · `require_writer`

Rename and/or recolor. Blank name ignored. Returns the full assembled tracker.

**Request body** (`UpdateTrackerReq`, both optional): `{ "name": "…", "color": "#1f9d57" }`

**Response 200** (`Tracker`). **Status codes**: 200 · 401 · 403 · 404 tracker not
in case.

### deleteTracker

`DELETE /api/cases/{caseId}/trackers/{trackerId}` · **Bearer** · `require_writer`

Delete a tracker; child fields and records cascade.

**Response 204**. **Status codes**: 204 · 401 · 403 · 404 tracker not in case.

### addTrackerField

`POST /api/cases/{caseId}/trackers/{trackerId}/fields` · **Bearer** · `require_writer`

Append a field to a tracker. `id` client-suppliable.

**Request body** (`AddFieldReq`): `{ "id": "<uuid?>", "name": "Issuer", "type": "text" }`

**Response 201** (`TrackerField`): `{ "id", "name", "type", "options"? }`.
**Status codes**: 201 · 401 · 403 · 404 tracker not in case · 422 invalid type.

### configureTracker

`PUT /api/cases/{caseId}/trackers/{trackerId}/configure` · **Bearer** · `require_writer`

Apply a stereotype to a freshly-created tracker: set name + statuses + preset and
insert its fields, atomically. Called once.

**Request body** (`ConfigureTrackerReq`):

```json
{
  "name": "Awards", "preset": "awards",
  "statuses": ["Backlog", "Active", "Done"],
  "fields": [ { "id": "<uuid?>", "name": "Award", "type": "text" },
              { "name": "Date", "type": "date" },
              { "name": "Tier", "type": "select", "options": ["national", "international"] } ]
}
```

**Response 200** (`Tracker`). **Status codes**: 200 · 401 · 403 · 404 tracker not
in case · 422 invalid field type.

---

## Records

Routes in `apps/api/src/routes/records.rs`. Tracker records (rows). `values` is a
map keyed by field id.

### addRecord

`POST /api/cases/{caseId}/trackers/{trackerId}/records` · **Bearer** · `require_writer`

Create an empty record (`values: {}`, `status = statuses[0]`, `req: null`). `id`
client-suppliable.

**Request body** (`AddRecordReq`): `{ "id": "<uuid?>" }`

**Response 201** (`TrackerRecord`):

```json
{ "id": "<uuid>", "values": {}, "status": "Backlog", "nextAction": "", "req": null }
```

**Status codes**: 201 · 401 · 403 · 404 tracker not in case.

### updateRecord

`PATCH /api/cases/{caseId}/trackers/{trackerId}/records/{recordId}` · **Bearer** ·
`require_writer`

Partial patch (Object.assign semantics). `req` is present-but-nullable — pass
`"req": null` to clear it, omit it to leave it unchanged.

**Request body** (`RecordPatch`, all optional):

```json
{ "values": { "<fieldId>": "value" }, "status": "Active", "nextAction": "Email issuer", "req": "Awards", "notes": "…", "privateNotes": "…" }
```

**Response 200** (`TrackerRecord`). **Status codes**: 200 · 401 · 403 · 404 record
not in case.

### setRecordValue

`PUT /api/cases/{caseId}/trackers/{trackerId}/records/{recordId}/values/{fieldId}`
· **Bearer** · `require_writer`

Patch a single `values[fieldId]` key. The path `:fieldId` is authoritative; if the
body also carries `fieldId` it must match.

**Request body** (`SetRecordValueReq`): `{ "fieldId": "<uuid?>", "value": "ACM Prize" }`

**Response 200** (`TrackerRecord`). **Status codes**: 200 · 400 fieldId path/body
mismatch · 401 · 403 · 404 record not in case.

### deleteRecord

`DELETE /api/cases/{caseId}/trackers/{trackerId}/records/{recordId}` · **Bearer** ·
`require_writer`

Delete a record.

**Response 204**. **Status codes**: 204 · 401 · 403 · 404 record not in case.

### autofillRecord

`POST /api/cases/{caseId}/trackers/{trackerId}/records/{recordId}/autofill` ·
**Bearer** · `require_writer`

**AI · cost 2 credits · server-authoritative.** Run a real provider lookup over
the record's fields. No mock fallback: if no provider is configured or the call
fails, returns **503 before any charge**. The 2-credit charge is taken only when
the AI delivers a fill or amendment (`cost > 0`); a null/confirm-only result is
free. The response carries the authoritative new `balance` and kinded suggestions
(`fill` | `amend` | `confirm`) the client stages and applies per-item.

**Request body**: none.

**Response 200** (`AutofillResp`):

```json
{
  "found": true, "note": "Verified against issuer site.", "cost": 2,
  "suggestions": [
    { "fieldId": "<uuid>", "name": "Issuer", "kind": "fill", "value": "ACM", "current": "" },
    { "fieldId": "<uuid>", "name": "Year", "kind": "amend", "value": "2021", "current": "2020" }
  ],
  "balance": 318
}
```

**Status codes**: 200 · 401 · 403 · 404 record not in case · 503 AI unavailable.
(402 not applicable: no upfront debit; charge happens post-success.)

---

## Draft

Routes in `apps/api/src/routes/draft.rs`. The petition draft lives in the
`cases.draft` jsonb column. `generate` and `review` are AI, server-authoritative,
and return `{ draft, balance }`.

### getDraft

`GET /api/cases/{caseId}/draft` · **Bearer** · `require_member`

Return the persisted draft, or **204 No Content** when none exists yet.

**Response 200** (`Draft`) or **204**. **Status codes**: 200 · 204 no draft · 401 · 403.

### generateBrief

`POST /api/cases/{caseId}/draft/generate` · **Bearer** · `require_writer`

**AI · cost 48 credits · server-authoritative.** Build the brief sections, set
`generatedAt`, reset `review = null` and `signedOff = false`, persist, and charge
48. 503 before charging if no provider / call fails.

**Request body**: none.

**Response 200** (`DraftResp`): `{ "draft": Draft, "balance": 272 }`

**Status codes**: 200 · 401 · 403 · 503 AI unavailable.

### reviewBrief

`POST /api/cases/{caseId}/draft/review` · **Bearer** · `require_writer`

**AI · cost 24 credits · server-authoritative.** Run the adversarial review, set
`draft.review`, persist, charge 24. Requires an existing draft (400 otherwise).
503 before charging if no provider / call fails.

**Request body**: none.

**Response 200** (`DraftResp`): `{ "draft": Draft, "balance": 248 }`

**Status codes**: 200 · 400 no draft to review · 401 · 403 · 503 AI unavailable.

### findingToTask

`POST /api/cases/{caseId}/draft/findings/{findingId}/task` · **Bearer** · `require_writer`

Create a Task in the first column from a review finding (inheriting the finding's
`req`; priority `high` when `band == "t"`, else `medium`), then stamp
`finding.taskId` on the persisted draft. Free.

**Request body** (`FindingTaskReq`): `{ "id": "<uuid?>" }`

**Response 201** (`Task`). **Status codes**: 201 · 401 · 403 · 404 draft, review,
or finding missing.

### signoffBrief

`POST /api/cases/{caseId}/draft/signoff` · **Bearer** · `require_writer`

Set `signedOff = true`, persist, log "Sent the brief to counsel". Free.

**Request body**: none.

**Response 200** (`Draft`). **Status codes**: 200 · 401 · 403 · 404 no draft.

---

## Credits

Routes in `apps/api/src/routes/credits.rs`.

### getCredits

`GET /api/credits/balance` · **Bearer**

The authoritative credit balance (`COALESCE(SUM(delta), 0)` over the ledger).

**Response 200** (`CreditBalance`): `{ "balance": 320 }`. **Status codes**: 200 · 401.

### getLedger

`GET /api/credits/ledger` · **Bearer**

Usage history, newest first, cursor-paged.

**Query params**: `limit` (default 50, clamp 1–200), `before` (RFC3339 cursor).

**Response 200** (`LedgerEntry[]`):

```json
[ { "id": 42, "delta": -48, "reason": "spend:brief", "at": "2026-06-21T14:03:00Z" } ]
```

**Status codes**: 200 · 401.

### setTheme

`PUT /api/me/theme` · **Bearer**

Theme is client-persisted in M4; this is a 200 no-op that echoes `{theme}` after
validating it is `light | dark`.

**Request body** (`ThemeReq`): `{ "theme": "dark" }`

**Response 200**: `{ "theme": "dark" }`. **Status codes**: 200 · 401 · 422 invalid theme.

---

## Health

Unauthenticated probes (no `/api` prefix).

- `GET /health` → `200 "ok"` (liveness).
- `GET /health/db` → `200` when a real `SELECT 1` succeeds, `503` otherwise
  (readiness gate).

---

## DTO schemas

Camel-cased wire shapes from `apps/api/src/dto.rs`. Dates are humane labels
(e.g. `"Jun 21"`) or ISO-8601 where noted; enums are bare string literals.

### Case

```ts
{
  id: string; visa: string; name: string; status: string; stage: "build"|"file"|"after";
  target: string; receipt: string; field: string; attorney: string;
  claims: Record<string, boolean>;           // keyed by requirement name
  cols: Column[]; tasks: Task[]; groups: Group[]; docs: Doc[];
  people: Person[]; events: CaseEvent[]; trackers: Tracker[];
  draft?: Draft;                              // omitted when none
}
```

### Column
`{ id: string; name: string; color: string }`

### Task
```ts
{
  id: string; title: string; colId: string;
  priority: "critical"|"high"|"medium"|"low"|null;
  req: string|null; date: string|null; deadline: boolean;
  notes: string; steps: { t: string; done: boolean }[];
  docIds: string[]; done: boolean;
  unreviewed?: boolean;        // omitted when false
  linkedRecordId?: string;     // omitted when absent
}
```

### Group
`{ id: string; name: string; kind: "req"|"user"; req?: string; strength?: "s"|"g"|"t" }`

### Doc / DocSource / DocFile
```ts
Doc       = { id; title; groupId; status: "need"|"draft"|"final"; type: "file"|"link"|"draft"; date: string; unreviewed?: boolean; source?: DocSource; file?: DocFile }
DocSource = { kind: "task"|"record"; taskId?: string; trackerId?: string; recordId?: string }
DocFile   = { id: string; filename: string; contentType: string; sizeBytes: number }
```

### Person
`{ name: string; role: string; you?: boolean }`

### CaseEvent
`{ id: string; t: string; text: string; src: string; usage?: number; unreviewed?: boolean }`

### Tracker / TrackerField / TrackerRecord
```ts
Tracker       = { id; name; color; preset?: string; fields: TrackerField[]; statuses: string[]; records: TrackerRecord[] }
TrackerField  = { id; name; type: "text"|"date"|"select"|"link"; options?: string[] }
TrackerRecord = { id; values: Record<string,string>; status: string; nextAction: string; req: string|null; notes?: string; privateNotes?: string }
```

### Draft / BriefSection / BriefSource / DraftReview / Finding
```ts
Draft        = { generatedAt: string|null; sections: BriefSection[]; review: DraftReview|null; signedOff: boolean }
BriefSection = { id; title; req: string|null; body: string; sources: BriefSource[] }
BriefSource  = { kind: "exhibit"|"effort"|"field"; label: string; docId?; taskId?; trackerId?; recordId? }
DraftReview  = { ranAt: string; findings: Finding[] }
Finding      = { id; kind: "gap"|"rfe"|"boilerplate"; req: string|null; title; detail; band: "s"|"g"|"t"; taskId?: string }
```

### VisaDef / VisaRule
```ts
VisaDef  = { id; name; full; rule: VisaRule; finalMerits: boolean; offer?: boolean; reqs: string[] }
VisaRule = { kind: string; n: number }
```

### User / AuthResp / PendingResp
```ts
User        = { name: string; email: string }
AuthResp    = { token: string; user: User }
PendingResp = { pending: true; message: string }
```

### CreditBalance / LedgerEntry
```ts
CreditBalance = { balance: number }
LedgerEntry   = { id: number; delta: number; reason: string; at: string /* RFC3339 */ }
```

### Response envelopes
```ts
IdResp           = { id: string }
ClaimResp        = { req: string; claimed: boolean }
DraftResp        = { draft: Draft; balance: number }
DeleteColumnResp = { cols: Column[]; tasks: Task[] }
AutofillResp     = { found: boolean; note: string; cost: number; suggestions: AutofillSuggestion[]; balance: number }
AutofillSuggestion = { fieldId: string; name: string; kind: "fill"|"amend"|"confirm"; value: string; current: string }
Error            = { error: string }
```

## Agentic navigation rels

These are the navigation `_links` rels emitted by the `/.agentic` mirror — and the
keys of `meta.docs.endpoints` that are *not* action names. Each rel is reached with a
**safe GET** on an `/.agentic/...` URL (never `/api`); follow the rel's `href` in the
envelope to traverse. This section is the canonical "what does this rel mean" target
that `meta.docs.endpoints` deep-links to (action keys deep-link to their operationId
section above). Public URLs drop the `/api` prefix (nginx rewrites `/.agentic/*` →
`/api/agentic/*`); the shapes below use the public form.

| rel | points to | mirror GET (public form) |
| --- | --- | --- |
| <a id="self"></a>`self` | this very resource (== `@id`) | the current `/.agentic/...` URL |
| <a id="up"></a>`up` | the parent resource | record → tracker, tracker → case, case → entry |
| [`case`](#case) | the owning case overview | `/.agentic/case/{caseId}` |
| <a id="today"></a>`today` | the case "Today" / planning view | `/.agentic/today/{caseId}` |
| <a id="build"></a>`build` | the case build board (trackers + tasks) | `/.agentic/build/{caseId}` |
| <a id="tracker"></a>`tracker` | a single tracker | `/.agentic/build/{caseId}/{trackerId}` |
| <a id="record"></a>`record` | a single tracker record | `/.agentic/build/{caseId}/{trackerId}/records/{recordId}` |
| [`records`](#records) | a tracker's record collection | `/.agentic/build/{caseId}/{trackerId}/records` |
| <a id="evidence"></a>`evidence` | the case evidence surface | `/.agentic/evidence/{caseId}` |
| <a id="doc"></a>`doc` | a single evidence doc | `/.agentic/evidence/{caseId}/docs/{docId}` |
| <a id="source"></a>`source` | the effort a doc is linked to | the linked task/record mirror |
| [`draft`](#draft) | the case draft & review surface | `/.agentic/draft/{caseId}` |
| [`task`](#tasks) | a single task | `/.agentic/build/{caseId}/tasks/{taskId}` |
| [`tasks`](#tasks) | the case task collection | `/.agentic/build/{caseId}/tasks` |
| <a id="events"></a>`events` | the case audit / event log | `/.agentic/case/{caseId}/events` |
| [`credits`](#credits) | the account credits surface | `/.agentic/credits` |
| <a id="visatypes"></a>`visaTypes` | the visa-type catalog | `/.agentic/visa-types` |
| <a id="visadef"></a>`visaDef` | a single visa-type definition | `/.agentic/visa-types/{visaId}` |
| <a id="next"></a>`next` | the next page of a paginated collection | a continuation `/.agentic/...` URL |
| <a id="new"></a>`new` | the case-creation affordance | `/.agentic/new` |

> `describedby` is the one rel that does **not** resolve here — by design it points at
> [`/openapi.json`](/openapi.json), the machine-readable schema, and `meta.docs.endpoints`
> maps it there directly.
