# Sojourncase Domain Model — Entity Glossary for Agents

This is the authoritative glossary of the Sojourncase domain entities for machine
consumers of the agentic layer. Every entity below is keyed to the real API schema
names. The two sources of truth are:

- **`apps/api/src/dto.rs`** — Rust DTOs, byte-for-byte JSON parity with the web types.
- **`apps/web/src/lib/types.ts`** — TypeScript interfaces (the SPA's view of the same wire shapes).
- **`apps/web/src/lib/visa.ts`** — the static visa catalog (`VisaDef`, `VISA`).

JSON wire keys are **camelCase** (Rust uses `#[serde(rename_all = "camelCase")]`).
Where a Rust field name differs from the wire key, both are noted (e.g. `col_id` → `colId`).

How to read this doc:

- **What it is** — the entity's role in the domain.
- **Fields** — wire key, type, and enum resolution.
- **Relationships / FKs** — how it links to sibling entities (most links are string ids).
- **Agentic surface** — which `/.agentic/<path>` route(s) expose it, and which `actions` mutate it.

A note on identity: nearly all `id` fields are UUID strings on the wire. Clients MAY
supply an `id` on optimistic create; a malformed client id is silently absorbed and the
server mints a fresh UUID (`dto::id_or_new`). Dates are ISO-8601 or short month labels
(`YYYY-MM`) depending on field; per-field notes call this out.

---

## Enum glossary (resolve these first)

These string-literal unions appear across many entities. The Rust side stores them as
`String` aliases and validates via `dto::valid::*`; the TypeScript side is a literal union.

| Enum | Wire values | Resolved meaning | Validator |
|------|-------------|------------------|-----------|
| **Band** | `"s"` \| `"g"` \| `"t"` | `s` = strong, `g` = getting there (building), `t` = thin | `valid::band` (server-set only) |
| **Priority** | `"critical"` \| `"high"` \| `"medium"` \| `"low"` \| `null` | task urgency; `null` = unset | `valid::priority` |
| **DocStatus** | `"need"` \| `"draft"` \| `"final"` | evidence readiness of a Doc | `valid::doc_status` |
| **DocType** | `"file"` \| `"link"` \| `"draft"` | what the Doc is (server-set `"file"` in current milestone) | `valid::doc_type` (reserved) |
| **Stage** | `"build"` \| `"file"` \| `"after"` | case lifecycle stage | `valid::stage` |
| **FieldType** | `"text"` \| `"date"` \| `"select"` \| `"link"` | tracker field input type | `valid::field_type` |
| **FindingKind** | `"gap"` \| `"rfe"` \| `"boilerplate"` | adversarial review finding category | `valid::finding_kind` (AI-produced) |
| **Group.kind** | `"req"` \| `"user"` | evidence group is requirement-bound vs. user-made | `valid::group_kind` (server-set) |
| **DocSource.kind** | `"task"` \| `"record"` | which Build effort a Doc traces to | `valid::doc_source_kind` |
| **BriefSource.kind** | `"exhibit"` \| `"effort"` \| `"field"` | what a brief span traces to | `valid::brief_source_kind` (AI-produced) |
| **VisaRule.kind** | `"n"` \| `"all"` | rule is "N of M criteria" vs. "all prongs" | — |
| **Member role** (reserved) | `"owner"` \| `"editor"` \| `"reviewer"` \| `"viewer"` | collaborator role (M4 invite flow) | `valid::role` (reserved) |

**Band shorthand for humans** (from `apps/web/src/lib/readiness.ts`, `BAND`):
`s` → "strong", `g` → "getting there", `t` → "thin".

---

## Case

**What it is.** One immigration petition for one beneficiary under one visa framework.
It is the aggregate root: most other entities are children scoped under
`/api/cases/{caseId}/...`. The wire `Case` is a fully-hydrated document — it embeds its
columns, tasks, groups, docs, people, events, trackers, and (optionally) the draft.

**Fields** (`dto::Case`, `types.ts Case`):

| Wire key | Type | Notes |
|----------|------|-------|
| `id` | string (UUID) | case id |
| `visa` | string | FK → `VisaDef.id` (e.g. `"eb1a"`, `"niw"`, `"o1a"`, `"eb1b"`) |
| `name` | string | display name of the case / petition |
| `status` | string | free-text status label (e.g. "Building", "Filed") |
| `stage` | Stage enum | `"build"` \| `"file"` \| `"after"` |
| `target` | string | target filing date / window (free-text or `YYYY-MM`) |
| `receipt` | string | USCIS receipt number (empty until filed) |
| `field` | string | the beneficiary's field of endeavor (e.g. "Management consulting") |
| `attorney` | string | attorney name / firm (free-text) |
| `claims` | map<string, bool> | requirement-label → claimed?; see **Requirement** |
| `cols` | Column[] | embedded board columns |
| `tasks` | Task[] | embedded tasks |
| `groups` | Group[] | embedded evidence groups |
| `docs` | Doc[] | embedded documents |
| `people` | Person[] | embedded people on the case |
| `events` | CaseEvent[] | embedded event log |
| `trackers` | Tracker[] | embedded specialized trackers |
| `draft` | Draft \| null | embedded petition draft (omitted/`null` until generated) |

**Server row** (`dto::CaseRow`) additionally carries `owner_id` (UUID), `created_at`,
`updated_at` (timestamps, not yet on the wire DTO).

**Relationships / FKs.**
- `visa` → `VisaDef.id` (static catalog).
- `owner_id` → `User` (DB-side; not on the wire `Case`).
- Owns all child collections listed above (1-to-many).
- `claims` keys are the **requirement labels** drawn from `VISA[visa].reqs`.

**Agentic surface.**
- `/.agentic/today/{caseId}` — readiness, status/stage, next deadline, agenda.
- `/.agentic/case/{caseId}` — the case record itself: status/stage/target/receipt/attorney/visa, claims+strength, people, events.
- `/.agentic/case/{caseId}/events` — the event feed.
- Actions: `patch-case` (PATCH `/api/cases/{caseId}`, operationId `patchCase`),
  `toggle-claim` (PUT `/api/cases/{caseId}/claims/{req}`, `toggleClaim`),
  `undo-event` (POST `/api/cases/{caseId}/events/{eventId}/undo`).
  Create-case lives on the catalog routes (see **VisaDef**).

**Patch shape** (`dto::CasePatch`): any subset of
`name, status, stage, target, receipt, field, attorney` (all optional).

---

## VisaDef (a.k.a. VisaType) and VisaRule

**What it is.** A reusable visa framework: the static rubric a Case is built against.
The catalog is hard-coded in `apps/web/src/lib/visa.ts` (`VISA: Record<string, VisaDef>`)
and served by the API at `/api/visa-types`. It is **not** case-scoped and not user-editable.

**VisaDef fields** (`dto::VisaDef`, `visa.ts VisaDef`):

| Wire key | Type | Notes |
|----------|------|-------|
| `id` | string | catalog id / slug (e.g. `"eb1a"`); the web `VISA` map key. The TS `VisaDef` omits `id` because the map key *is* the id; the API DTO surfaces it explicitly. |
| `name` | string | short name (e.g. "EB-1A") |
| `full` | string | full title (e.g. "EB-1A — Extraordinary Ability") |
| `rule` | VisaRule | the satisfaction rule (below) |
| `finalMerits` | bool (`final_merits`) | whether a final-merits determination applies (EB-1A `true`) |
| `offer` | bool? | requires a job offer (e.g. EB-1B `true`); omitted otherwise |
| `reqs` | string[] | ordered list of **requirement labels** (the criteria/prongs) |

**VisaRule fields** (`dto::VisaRule`):

| Wire key | Type | Notes |
|----------|------|-------|
| `kind` | `"n"` \| `"all"` | "N of the listed criteria" vs. "all prongs required" |
| `n` | int | the N (for `"n"`); for `"all"`, the count required |

**Catalog snapshot** (from `visa.ts`):

| id | name | rule | finalMerits | offer | reqs |
|----|------|------|-------------|-------|------|
| `eb1a` | EB-1A | n=3 | true | — | Original contributions; Judging; Published material about you; Authorship; High remuneration |
| `niw` | EB-2 NIW | all=3 | false | — | Substantial merit & national importance; Well-positioned to advance; On balance, beneficial |
| `o1a` | O-1A | n=3 | false | — | Original contributions; Critical role; High remuneration; Press about you |
| `eb1b` | EB-1B | n=2 | false | true | Major awards; Membership; Published material about you; Judging; Original contributions; Authorship |

**Relationships / FKs.** Referenced by `Case.visa`. Its `reqs[]` strings are the
universe of **Requirement** labels for any case on that visa.

**Agentic surface.**
- `/.agentic/new` and `/.agentic/visa-types[/{visaId}]` — the catalog and per-visa detail.
- Action: `create-case` (POST `/api/cases`, operationId `createCase`, body `{visa}`)
  surfaced from the catalog route. List/get operationIds: `listVisaTypes`, `getVisaType`
  (routes `GET /api/visa-types`, `GET /api/visa-types/{visaId}`).

---

## Requirement (criterion / prong / gate)

**What it is.** A single criterion the petition must satisfy — e.g. "Original
contributions" or "Judging". **There is no standalone Requirement table or DTO.** A
requirement is a **label string** drawn from `VisaDef.reqs[]`. Its per-case state lives
in three places:

1. **Claimed?** — `Case.claims` is a `map<requirementLabel, bool>`. Toggled via the
   `toggleClaim` action (PUT `/api/cases/{caseId}/claims/{req}` where `{req}` is the label).
2. **Strength** — computed per requirement from its requirement-bound **Group**: the
   Group with `kind="req"` and `req == <label>` carries a `strength` **Band**. Readiness
   roll-up (`apps/web/src/lib/readiness.ts`) counts requirements that are claimed AND
   whose group strength is `"s"` (strong).
3. **Linkage** — many child entities carry a nullable `req` foreign key holding the same
   label string: `Group.req`, `Task.req`, `TrackerRecord.req`, `BriefSection.req`,
   `Finding.req`. This is how tasks/records/evidence/brief-sections/findings attach to a
   specific criterion.

**The reference key.** A Requirement's identity is its **label string**, and it is valid
only within the context of `VISA[case.visa].reqs`. Order matters (the array order is the
display/agenda order).

**Roll-up rule** (from `readiness.ts`):
- `strong` = count of `groups` where `kind="req"` AND `claims[group.req]` AND `strength="s"`.
- `claimed` = count of `VISA[visa].reqs` that are `true` in `claims`.
- For `rule.kind="n"`: ready when `strong >= rule.n`.
- For `rule.kind="all"`: ready when `strong >= claimed`.

**Agentic surface.**
- Claim toggling + per-requirement strength: `/.agentic/case/{caseId}`
  (`claims+strength` in `data`; action `toggle-claim`).
- Readiness roll-up summary: `/.agentic/today/{caseId}`.
- Requirement-bound evidence: `/.agentic/evidence/{caseId}` (the `kind="req"` groups).

---

## Column

**What it is.** A column on the Build task board (a kanban-style lane). Tasks reference
their column by id.

**Fields** (`dto::Column`):

| Wire key | Type | Notes |
|----------|------|-------|
| `id` | string (UUID) | column id |
| `name` | string | column title |
| `color` | string | hex color (see `PALETTE` in `visa.ts`) |

**Relationships / FKs.** Belongs to a `Case` (embedded in `Case.cols`). Referenced by
`Task.colId`. Deleting a column also returns the affected tasks (`dto::DeleteColumnResp`
= `{cols, tasks}`), which are reassigned/cleared.

**Agentic surface.**
- `/.agentic/build/{caseId}` — the board's columns.
- Actions: `add-column` (POST `/api/cases/{caseId}/columns`, `addColumn`),
  plus update/delete/move via `updateColumn` (PATCH — combined rename+recolor),
  `deleteColumn` (DELETE), and `moveColumn` (POST `.../move`, body `{dir: -1|1}`).

---

## Task and Step

**What it is.** A unit of Build work on the board. A Task may be a plain to-do, a
deadline item, or a task spawned from a tracker record or a review finding. `Step` is an
inline checklist item inside a Task.

**Task fields** (`dto::Task`, `types.ts Task`):

| Wire key | Type | Notes |
|----------|------|-------|
| `id` | string (UUID) | task id |
| `title` | string | task title |
| `colId` | string (`col_id`) | FK → `Column.id` |
| `priority` | Priority enum | `critical`\|`high`\|`medium`\|`low`\|`null` |
| `req` | string \| null | FK → requirement label (nullable) |
| `date` | string \| null | due date / `YYYY-MM` (nullable) |
| `deadline` | bool | is this a hard deadline item |
| `notes` | string | free-text notes |
| `steps` | Step[] | inline checklist |
| `docIds` | string[] (`doc_ids`) | FKs → `Doc.id` produced by/attached to this task |
| `done` | bool | completion flag |
| `unreviewed` | bool? | present when AI-generated and not yet human-reviewed |
| `linkedRecordId` | string \| null (`linked_record_id`) | FK → `TrackerRecord.id` this task was spawned from |

**Step fields** (`dto::Step`): `t` (string, label), `done` (bool).

**Server row** (`dto::TaskRow`): `col_id` is nullable UUID at the DB layer (a task can be
column-less, e.g. after its column is deleted).

**Relationships / FKs.**
- `colId` → `Column`.
- `req` → requirement label.
- `docIds[]` → `Doc`.
- `linkedRecordId` → `TrackerRecord` (set when spawned from a record).
- Referenced back by `DocSource.taskId` and `BriefSource.taskId` and `Finding.taskId`.

**Agentic surface.**
- `/.agentic/build/{caseId}` (board summary) and `/.agentic/build/{caseId}/tasks[/{taskId}]`.
- Actions: `add-task` (POST `/api/cases/{caseId}/tasks`, `addTask`),
  `update`/`mark-done` (PUT `/api/cases/{caseId}/tasks/{taskId}`, `updateTask`),
  `delete` (DELETE, `deleteTask`). Tasks spawned from records: `spawn-task`
  (POST `.../records/{recordId}/spawn-task`); from findings: `finding-to-task`
  (POST `/api/cases/{caseId}/draft/findings/{findingId}/task`).

---

## Group (evidence group)

**What it is.** A bucket on the Evidence area that holds related Docs. A group is either
**requirement-bound** (`kind="req"` — tied to one criterion and carrying a strength Band)
or **user-made** (`kind="user"` — an arbitrary user bucket).

**Fields** (`dto::Group`, `types.ts Group`):

| Wire key | Type | Notes |
|----------|------|-------|
| `id` | string (UUID) | group id |
| `name` | string | group title |
| `kind` | `"req"` \| `"user"` | requirement-bound vs. user-made |
| `req` | string? | requirement label (present when `kind="req"`) |
| `strength` | Band? | `s`\|`g`\|`t` evidence strength (server-set; present on `req` groups) |

**Relationships / FKs.**
- `req` → requirement label (drives the per-requirement strength roll-up).
- Parent of `Doc` via `Doc.groupId`.

**Agentic surface.**
- `/.agentic/evidence/{caseId}` — groups, docs, strength, gap notes.
- Action: `add-group` (POST `/api/cases/{caseId}/groups`, `addGroup`).

---

## Doc, DocSource, DocFile

**What it is.** A `Doc` is one piece of evidence (an exhibit) inside a Group. It MAY have
a real attached file (`DocFile`) and MAY link back to the Build effort that produced it
(`DocSource`).

**Doc fields** (`dto::Doc`, `types.ts Doc`):

| Wire key | Type | Notes |
|----------|------|-------|
| `id` | string (UUID) | doc id |
| `title` | string | doc title |
| `groupId` | string (`group_id`) | FK → `Group.id` |
| `status` | DocStatus | `need`\|`draft`\|`final` |
| `type` | DocType (`doc_type`) | `file`\|`link`\|`draft` (server-set `"file"` currently) |
| `date` | string | doc date |
| `unreviewed` | bool? | AI-generated, not yet reviewed |
| `source` | DocSource \| null | which Build effort this traces to |
| `file` | DocFile? | attached file metadata (omitted when none) |

**DocSource fields** (`dto::DocSource`): `kind` (`"task"`\|`"record"`),
`taskId?` (`task_id`), `trackerId?` (`tracker_id`), `recordId?` (`record_id`).

**DocFile fields** (`dto::DocFile`): `id`, `filename`, `contentType` (`content_type`),
`sizeBytes` (`size_bytes`, int). The bytes never travel in this struct — only via the
dedicated file routes; the file is stored encrypted at rest.

**Relationships / FKs.**
- `groupId` → `Group`.
- `source` → a Task (`taskId`) or a Tracker record (`trackerId`+`recordId`).
- Referenced by `Task.docIds[]` and `BriefSource.docId`.

**Agentic surface.**
- `/.agentic/evidence/{caseId}` and `/.agentic/evidence/{caseId}/docs/{docId}`.
- Actions: `add-doc` (POST `/api/cases/{caseId}/docs`, `addDoc`),
  `add-linked` (POST `.../docs/linked`, `addLinkedDoc`),
  `set-status` (PATCH `.../docs/{docId}/status`, `setDocStatus`),
  `set-source` (PUT `.../docs/{docId}/source`, `setDocSource`),
  `upload` (POST `.../docs/{docId}/file`, `uploadDocFile`; also download + delete on the
  same route, 25 MB cap), `delete` (DELETE `.../docs/{docId}`, `deleteDoc`).

---

## Tracker, TrackerField, TrackerRecord

**What it is.** A specialized spreadsheet inside the Build area. A `Tracker` defines a set
of `TrackerField` columns and a set of `statuses`, and holds `TrackerRecord` rows. Used to
manage repeated evidence-gathering (e.g. a "Press mentions" tracker, an "Authorship"
tracker). May be seeded from a named `preset`.

**Tracker fields** (`dto::Tracker`, `types.ts Tracker`):

| Wire key | Type | Notes |
|----------|------|-------|
| `id` | string (UUID) | tracker id |
| `name` | string | tracker title |
| `color` | string | hex color |
| `preset` | string? | preset template name this tracker was seeded from |
| `fields` | TrackerField[] | column definitions |
| `statuses` | string[] | the allowed per-record status labels (free-text set) |
| `records` | TrackerRecord[] | the rows |

**TrackerField fields** (`dto::TrackerField`):

| Wire key | Type | Notes |
|----------|------|-------|
| `id` | string (UUID) | field id (used as the key in `TrackerRecord.values`) |
| `name` | string | field/column label |
| `type` | FieldType (`field_type`) | `text`\|`date`\|`select`\|`link` |
| `options` | string[]? | choice list (present for `select`) |

**TrackerRecord fields** (`dto::TrackerRecord`):

| Wire key | Type | Notes |
|----------|------|-------|
| `id` | string (UUID) | record id |
| `values` | map<string,string> | keyed by **TrackerField.id** |
| `status` | string | one of the tracker's `statuses` |
| `nextAction` | string (`next_action`) | the next step for this row |
| `req` | string \| null | FK → requirement label (nullable) |
| `notes` | string? | shared with collaborators |
| `privateNotes` | string? (`private_notes`) | only you — hidden from collaborators |

**Server rows** (`dto::TrackerRow`, `FieldRow`, `RecordRow`): `FieldRow.tracker_id` and
`RecordRow.tracker_id` are explicit FKs to the parent tracker; on the wire these are
implied by nesting.

**Relationships / FKs.**
- `Tracker` is embedded in `Case.trackers`; owns its `fields` and `records`.
- `TrackerRecord.values` keys → `TrackerField.id`.
- `TrackerRecord.status` ∈ `Tracker.statuses`.
- `TrackerRecord.req` → requirement label.
- A record can be referenced by `Task.linkedRecordId`, `DocSource.recordId`,
  `BriefSource.recordId` (all alongside the parent `trackerId`).

**Agentic surface.**
- `/.agentic/build/{caseId}` (trackers summary) and
  `/.agentic/build/{caseId}/{trackerId}` (meta + fields + statuses + countByStatus).
- `/.agentic/build/{caseId}/{trackerId}/records` and `.../records/{recordId}` (RecordSheet:
  values, status, substatus, nextAction, req, notes, linkedDocs, linkedTasks).
- Tracker actions: `add-tracker` (POST `/api/cases/{caseId}/trackers`, `addTracker`),
  `add-field` (POST `.../fields`, `addTrackerField`),
  `configure` (PUT `.../configure`, `configureTracker` — sets name+statuses+preset+fields
  atomically), `rename`/recolor (PATCH `.../{trackerId}`, `updateTracker` — combined),
  `delete` (DELETE, `deleteTracker`).
- Record actions: `add-record` (POST `.../records`, `addRecord`),
  `patch` (PATCH `.../records/{recordId}`, `updateRecord`),
  `set-value` (PUT `.../records/{recordId}/values/{fieldId}`, `setRecordValue`),
  `autofill` (POST `.../records/{recordId}/autofill`, `autofillRecord` — **2 credits**,
  `serverAuthoritative`; charged only when value is delivered),
  `spawn-task` (POST `.../records/{recordId}/spawn-task`, `spawnTask`),
  `delete` (DELETE, `deleteRecord`).

**RecordPatch shape** (`dto::RecordPatch`): any subset of
`values, status, nextAction, req (nullable), notes, privateNotes`.

**Autofill response** (`dto::AutofillResp` / `AutofillSuggestion`): `{found, note, cost,
suggestions[], balance}`; each suggestion is `{fieldId, name, kind, value, current}` where
`kind` ∈ `"fill"` (was empty) \| `"amend"` (replace a different value) \| `"confirm"`
(verified correct, unchanged).

---

## Draft, BriefSection, BriefSource, DraftReview, Finding

**What it is.** The `Draft` is the generated petition brief for a Case. It is composed of
`BriefSection`s (each traced to its evidence via `BriefSource`s), may carry a `DraftReview`
(an adversarial review producing `Finding`s), and a `signedOff` flag.

**Draft fields** (`dto::Draft`, `types.ts Draft`):

| Wire key | Type | Notes |
|----------|------|-------|
| `generatedAt` | string \| null (`generated_at`) | when the brief was generated (`null` until generated) |
| `sections` | BriefSection[] | the brief body |
| `review` | DraftReview \| null | the latest review (`null` until run) |
| `signedOff` | bool (`signed_off`) | human sign-off flag |

The TS `Draft.review` is inlined as `{ ranAt, findings } | null`; the Rust DTO names it
`DraftReview` (`ran_at` → `ranAt`, `findings`).

**BriefSection fields** (`dto::BriefSection`): `id`, `title`, `req` (string \| null →
requirement label), `body` (string), `sources` (BriefSource[]).

**BriefSource fields** (`dto::BriefSource`): `kind` (`"exhibit"`\|`"effort"`\|`"field"`),
`label`, and optional FKs `docId`, `taskId`, `trackerId`, `recordId` — the evidence the
span traces to.

**DraftReview fields** (`dto::DraftReview`): `ranAt` (string), `findings` (Finding[]).

**Finding fields** (`dto::Finding`, `types.ts Finding`):

| Wire key | Type | Notes |
|----------|------|-------|
| `id` | string | finding id |
| `kind` | FindingKind | `gap`\|`rfe`\|`boilerplate` |
| `req` | string \| null | FK → requirement label |
| `title` | string | short finding title |
| `detail` | string | finding detail |
| `band` | Band | `s`\|`g`\|`t` severity/strength of the issue |
| `taskId` | string \| null | FK → Task created from this finding (when one exists) |

**Relationships / FKs.**
- `Draft` is embedded in `Case.draft` (1-to-1, optional).
- `BriefSection.req` and `Finding.req` → requirement labels.
- `BriefSource.*` and `Finding.taskId` → Docs / Tasks / Trackers / Records.

**Agentic surface.**
- `/.agentic/draft/{caseId}` — draft sections + review findings + signedOff.
- Actions: `generate` (POST `/api/cases/{caseId}/draft/generate`, `generateBrief` —
  **48 credits**), `review` (POST `.../draft/review`, `reviewBrief` — **24 credits**),
  `finding-to-task` (POST `.../draft/findings/{findingId}/task`, `findingTask`/the
  finding-to-task action), `signoff` (POST `.../draft/signoff`, `signoffBrief`).
- Read: `GET /api/cases/{caseId}/draft` (`getDraft`). Generate/review return
  `DraftResp` = `{draft, balance}`.

---

## Person

**What it is.** A person associated with the Case (the beneficiary, the attorney,
recommenders, collaborators). The `you` flag marks the current viewer's own person row.

**Fields** (`dto::Person`, `types.ts Person`):

| Wire key | Type | Notes |
|----------|------|-------|
| `name` | string | person name |
| `role` | string | role on the case (free-text) |
| `you` | bool? | true when this person is the viewer (derived from `user_id`) |

**Server row** (`dto::PersonRow`): `name`, `role`, `user_id` (nullable UUID → `User`). The
wire `you` flag is computed by comparing `user_id` to the authenticated user.

**Relationships / FKs.** Embedded in `Case.people`. `user_id` → `User` (DB-side).

**Agentic surface.** `/.agentic/case/{caseId}` (`people` in `data`). No dedicated person
mutation action in the current contract (people are seeded with the case / via the M4
collaborator-invite flow).

---

## CaseEvent

**What it is.** An entry in the Case's append-only event log / audit feed. Some events
carry credit `usage` and can be undone.

**Fields** (`dto::CaseEvent`, `types.ts CaseEvent`):

| Wire key | Type | Notes |
|----------|------|-------|
| `id` | string (UUID) | event id |
| `t` | string | timestamp / time label |
| `text` | string | human-readable event text |
| `src` | string | event source/origin tag |
| `usage` | int? | credits consumed by this event (present for AI spends) |
| `unreviewed` | bool? | event references AI output not yet reviewed |

**Server row** (`dto::EventRow`): same shape; `usage` is `i32?`.

**Relationships / FKs.** Embedded in `Case.events`; belongs to a Case.

**Agentic surface.**
- `/.agentic/case/{caseId}/events` (the feed) and `/.agentic/today/{caseId}` (recent agenda).
- Action: `undo-event` (POST `/api/cases/{caseId}/events/{eventId}/undo`, `undoEvent`).
- Read: `GET /api/cases/{caseId}/events` (`listEvents`).

---

## User

**What it is.** An authenticated account. On the wire the SPA's `User` is exactly
`{ name, email }` — nothing more.

**Fields** (`dto::User`, `types.ts User`): `name` (string), `email` (string).

**Server row** (`dto::UserRow`): `id` (UUID), `email`, `display_name` (nullable),
`password_hash` (nullable — OAuth-only users have none), `access_status`
(`"active"` = may log in \| `"pending"` = awaiting operator approval). The wire `name`
falls back to the email local-part when `display_name` is empty (`UserRow::to_user`).

**Auth wire DTOs.**
- `RegisterReq` `{name, email, password}`, `LoginReq` `{email, password}`.
- `AuthResp` `{token, user}` on success.
- `PendingResp` `{pending, message}` when registration is in approval mode (no token
  issued; the client detects this by the absence of `token`).

**Relationships / FKs.** Owns Cases (`CaseRow.owner_id`); linked from `PersonRow.user_id`.

**Agentic surface.**
- `/.agentic` (entrypoint) — caller identity (the `User`), credit balance, accessible cases,
  visa-catalog link.
- Auth operationIds: `register`, `login`, `me` (GET `/api/auth/me`), `logout`.

---

## Credits: CreditBalance and LedgerEntry

**What it is.** The credit wallet for AI spend. `CreditBalance` is the current balance;
`LedgerEntry` is one line of the spend/grant ledger.

**CreditBalance fields** (`dto::CreditBalance`): `balance` (int).

**LedgerEntry fields** (`dto::LedgerEntry`, `LedgerRow`):

| Wire key | Type | Notes |
|----------|------|-------|
| `id` | int | ledger entry id |
| `delta` | int | signed change (negative = spend, positive = grant) |
| `reason` | string | reason tag (e.g. `"spend:autofill"`, `"spend:review"`) |
| `at` | string | timestamp |

**Spend reasons & fixed prices** (from the route handlers):

| AI action | reason | credits | route / operationId |
|-----------|--------|---------|---------------------|
| Generate brief | `spend:generate` (label) | **48** | `draft::generate` / `generateBrief` |
| Review brief | `spend:review` | **24** | `draft::review` / `reviewBrief` |
| Autofill record | `spend:autofill` | **2** | `records::autofill` / `autofillRecord` (charged only when value delivered) |

**Relationships / FKs.** Wallet is per-User (account-scoped, not case-scoped). AI-spend
events also surface on the Case feed as `CaseEvent.usage`.

**Agentic surface.**
- `/.agentic/credits` — balance + ledger.
- Read operationIds: `getCredits` (balance, GET `/api/credits/balance`),
  ledger (GET `/api/credits/ledger`). The credit-spending actions live on their owning
  domain routes (draft generate/review, record autofill) and are `serverAuthoritative`.

---

## Relationship map (quick reference)

```
User ──owns──▶ Case ──visa──▶ VisaDef ──reqs[]──▶ Requirement (label string)
                 │                                     ▲
                 │ claims{label→bool} ─────────────────┘
                 ├─ cols[]    Column ◀──colId── Task ──req──▶ Requirement
                 ├─ tasks[]   Task  ──docIds[]──▶ Doc
                 │            Task  ──linkedRecordId──▶ TrackerRecord
                 ├─ groups[]  Group(kind=req,req,strength) ─parent─▶ Doc(groupId)
                 ├─ docs[]    Doc ──source(DocSource)──▶ Task | (Tracker,Record)
                 │            Doc ──file──▶ DocFile
                 ├─ people[]  Person ──(user_id)──▶ User
                 ├─ events[]  CaseEvent (usage→credits)
                 ├─ trackers[] Tracker ─fields─▶ TrackerField
                 │             Tracker ─records─▶ TrackerRecord(values{fieldId→str}, req)
                 └─ draft     Draft ─sections─▶ BriefSection(req) ─sources─▶ BriefSource
                              Draft ─review──▶ DraftReview ─findings─▶ Finding(req, taskId)

Credits (per-User): CreditBalance + LedgerEntry[]  ◀── AI spend (generate 48 / review 24 / autofill 2)
```

**Source files:** `apps/api/src/dto.rs`, `apps/web/src/lib/types.ts`,
`apps/web/src/lib/visa.ts`, `apps/web/src/lib/readiness.ts`, `apps/api/src/router.rs`.
