Claim
Schema deep-dive · living document · #7 in the reading sequence
| Tables | claims.claims, claims.claim_lines, claims.claim_events[1][2][3] (second ring: prior_auths, claim_documents, outbox) |
| Owner service | claims (sole writer) |
| Locator | CLM-YYYY-NNNNNN (prior auths: PAU-) |
| Last updated | 2026-08-19 |
| Companion | ERD story, slide 7 · real policy JSON · Claims Lifecycle (narrative) · previous: Eligibility projection |
1. Scope and usage
A Claim is a request for payment for medical services under a policy. The claim row is the head (who, which policy, what happened, what state); claim_lines are the billable items (which benefit, how much); and claim_events are the append-only audit trail of everything that happened to it. Claims is where the platform is judged - in the industry's own words, "The claims department can be seen as the ‘shop window’ of the insurance company" - which is why the trail is written in the same transaction as every state change, not as an afterthought.
Two shape decisions define the model. Claims are complete at submission: there is no DRAFT status, by decision (D-37, #1135) - the shared enum carries the comment "No DRAFT - claims are complete at submission"[4] and a test exists solely to assert DRAFT never becomes valid[5]. And intake is asynchronous: POST /claims returns 202 Accepted[6] with the claim in SUBMITTED; adjudication happens afterwards.
2. Boundaries and relationships
| A Claim is not… | That concern lives in | Join |
|---|---|---|
| the coverage decision | eligibility - verdicts and accumulators; claims asks by HTTP and burns allowance via the apply call[7] | claim_lines.coverage_term_key → accumulator; ledger records claim_locator |
| the money movement | billing - consumes the claims.events topic[8] and raises the CLAIM-category reimbursement charge | outbox event, claimLocator in payload |
| the care episode | care.episodes / appointments; the claim's document carries episode_locator / appointment_locator as context, unverified | jsonb copy |
| the chat that caused it | triage.sessions; triage_session_locator is a deliberately unverified cross-boundary pointer (see §3) | TEXT uuid, no FK |
| the document store | document-service; the in-schema claim_documents table is a ready attachment surface - table, FK and endpoints in place, 0 rows so far[9], documented on Prior auth & claim documents | claim_id FK (in-service) |
| a prior authorisation | claims.prior_auths - own PAU- locator and lifecycle[10]; 116 live rows. The claim-path gate is the deferred consumer, scoped in #1705 and walked on Prior auth & claim documents | none today |
policy_locator / member_locator are soft references - text columns added by migration 0010[11], nullable, no FK: the standard cross-service posture. member_locator is a PTY- value (the alias zoo from the Party page); it is also the tenancy key - list queries scope to the member locators derived from the caller's verified JWT, never from a query parameter[12].
3. Structure
claims
| Field | Type | Req | Notes |
|---|---|---|---|
id / locator | uuid / text | ✓ | PK / UNIQUE CLM-YYYY-NNNNNN |
policy_id, term_id, claimant_party_id | uuid | ✓ | Cross-service uuid soft refs; term_id is what the accumulator apply keys on |
policy_locator, member_locator | text | POL- / PTY- soft refs (migration 0010) | |
status | text | ✓ | The 7-state machine; convention in DB, enforced in code (§4) |
incident_date | date | ✓ | When the loss occurred |
document | jsonb | The submission payload - free-shape context (see §6) | |
triage_session_locator | text | TEXT with a uuid-shape CHECK, NOT VALID, partially indexed (below) |
claim_lines
| Field | Type | Req | Notes |
|---|---|---|---|
claim_id | uuid | ✓ | Real FK to claims (in-service) |
line_number | int | ✓ | UNIQUE with claim_id; assigned max+1 by AddLine[14] |
element_id | uuid | ✓ | Designed to join policy_elements by static id; see the zero-uuid defect, §9 |
coverage_term_key | text | ✓ | The catalogue module key - the join that moves accumulators |
amount_claimed / amount_allowed / amount_paid | numeric(14,4) | ✓ | Default 0; billed / adjudicated / settled |
reason_code | text | Per-line adjudication reason | |
metadata | jsonb | e.g. {"quantity": 3} - sessions this line represents[15] |
claim_events
| Field | Type | Req | Notes |
|---|---|---|---|
claim_id | uuid | ✓ | Real FK; indexed |
event_type | text | ✓ | Live vocabulary: SUBMITTED, REVIEWED, APPROVED, REJECTED, INFO_REQUESTED, CLOSED, PAID, NOTE |
from_status / to_status | text | Nullable in DB; stamped by UpdateStatus in code | |
actor | text | system or user today; nullable in DB | |
note | text | Free text; NOTE events are the reviewer surface (#1475)[16] | |
reason_code | text | ✓ | NOT NULL DEFAULT '' - added by 0014 so the decline reason is durable, not outbox-only (#1626)[17] |
Field-by-field: what and why
locator - minted as CLM-<year>-<seq %06d> from claims.claims_locator_seq[18][19]. Note the wart: the sequence lives inside the claims schema (claims.claims_locator_seq, and claims.prior_auths_locator_seq beside it[11]), while every other service mints through the shared locator package whose sequences are named locator_seq_<prefix>[20] (ADR-1164-05, #1169). Same idea - per-prefix Postgres sequence - independently reimplemented with its own naming and a hand-rolled fmt.Sprintf. Live last_value: 2791.
triage_session_locator - the reverse half of the chat ↔ claim double-entry (care.episodes carries the forward half). Three deliberate choices, each documented in the migrations: it is TEXT, not UUID, so it joins care's same-typed column across databases without a cast[21]; shape is guarded by a uuid-regex CHECK marked NOT VALID (new writes checked, existing rows never rescanned, no table lock) as defence in depth against backfills and support-psql writes[22]; and existence in the triage DB is never checked - a claim whose origin cannot be proven stores NULL, never a guess[23]. The partial index covers only non-NULL rows because linked claims are the interesting minority - live: 28 of 1123. The handler stores the canonical lowercase uuid form, because uuid.Parse accepts spellings the CHECK does not[23].
coverage_term_key on lines - the platform's benefit join: the same string that names a module in the catalogue and an accumulator row in eligibility. When a claim approves, each line is sent as {claimLineId, coverageTermKey, quantity, amount} and eligibility decides whether it burns sessions or pounds[24]. The line's uuid is the idempotency key - two GP-video lines on one claim are two sessions; the same line replayed is one.
The amount triple - amount_claimed is what was billed; amount_allowed is what adjudication granted; amount_paid what settled. The accumulator request falls back to amount_claimed when allowed is zero, because a freshly added line has never been adjudicated - the old code sent the zero and consumed £0[24]. PayClaim marks paid at the allowed amount[25].
claim_events as double-entry with the outbox - every status change writes its event and its claims.events outbox message in the same transaction as the row update[26][27]: the event is the durable in-service record, the outbox row the cross-service announcement, and neither can exist without the transition that caused it.
4. Invariants
| Invariant | Enforced by |
|---|---|
locator unique | DB UNIQUE[1] |
| One line number per claim | DB UNIQUE (claim_id, line_number)[2] |
| Lines / events / documents belong to a real claim | DB FKs (in-service - the no-FK rule applies only across services) |
triage_session_locator is a lowercase uuid or NULL | DB CHECK (NOT VALID - new writes only)[22] + handler canonicalisation[23] |
| Status transitions are legal | Application: UpdateStatus takes a row lock, asserts the from-status, and writes the event in the same tx[26]; each service method gates its entry states (§5) |
| No DRAFT, ever | Application + test: enum comment[4] and TestClaimStatusNoDraft[5]; no DB CHECK on status |
| APPROVED ⇒ allowance burnt | Application: every path to APPROVED calls the idempotent accumulator apply first[28][29] |
| A caller only sees their tenant's claims | Application: JWT-derived member scope runs first; other filters can only narrow[12]; cross-tenant submission is a 403[30] |
| Events are append-only | Convention - no UPDATE/DELETE path exists in the repository; nothing in the DB forbids one |
amount_allowed <= amount_claimed | Nothing - stated as an expectation in the narrative, enforced nowhere |
| Row changes captured to CDC | Debezium publication dbz_claims (live \d) |
5. Lifecycle
Every arrow is a service method that gates its entry states and emits an event + outbox message: submit[31], review[32], approve[33], reject[34], requestInfo[35], close[36], pay (internal-only route)[37].
The rule-engine seam is in place; today's path approves on submission.ReviewClaim already evaluates a rule set, branches on a manual-review decision and routes to UNDER_REVIEW; the rule set it evaluates is empty at this product stage, so every line approves and amount_allowed falls back to the billed amount_claimed[32]. Rules landing in the adjudicator light up the manual-review half of the state machine with no schema change (§9). The default path does not pass through review at all: adding a line triggers autoApprove, and the code comment records the founder's call that a submitted claim consumes the member's allowance immediately, with no insurer review step[38] - a product-stage decision, recorded where it is executed. The ordering is the invariant: burn the accumulators first, transition second, so APPROVED means "the allowance has been burnt"; a claim whose apply failed stays visibly SUBMITTED and is repaired by ApproveClaim, which replays the apply idempotently[28]. That is also why ApproveClaim accepts SUBMITTED and even APPROVED as entry states - its comment records the previous version demanding an UNDER_REVIEW that nothing could produce, leaving the only accumulator-applying code behind a door with no key[33].
6. Populated example: CLM-2026-002725, walked end to end
A live claim from the member app's booking flow - the same claim whose ledger row appears on the previous page. No personal text lives in this claim's document; locators and uuids are real.
The claim row
{
"id": "9a652fcc-8eb1-4c7d-bc63-5d1772f249f5",
"locator": "CLM-2026-002725",
"policy_id": "a5a79e39-94fd-449c-9a2d-53b30c5f0721",
"term_id": "e5e93520-4367-4aea-8c97-fc3aa8db62c8",
"claimant_party_id": "53b82c7e-15cb-5daf-a04d-bee5f4c664cd",
"status": "APPROVED",
"incident_date": "2026-07-30",
"document": {
"unit": "session", "amount": 1,
"source": "member-app-booking", "category": "gp_video",
"episode_locator": "EP-54fc7a5f", "appointment_locator": "APT-18e8ea84"
},
"policy_locator": "POL-2026-001315",
"member_locator": "PTY-2026-000459",
"triage_session_locator": "b9c6ab3a-0327-423a-92f6-f434e797e87d",
"created_at": "2026-07-30T14:24:49Z",
"updated_at": "2026-07-30T14:24:49Z"
}| Key | Read by | What actually happens |
|---|---|---|
document.source/category/… | nothing on the adjudication path | submission context from the member app's booking BFF: the app files a claim right after care confirms the appointment, so the session burns real balance |
triage_session_locator | chat → claim lookups (ListByTriageSession, filter-narrowing for tenants)[39] | support can walk claim → transcript and back; this uuid is a real triage session |
term_id | the accumulator apply | scopes the burn to the 2026 policy year |
status: APPROVED | everyone | and per the invariant, the allowance is already burnt (see below) |
The line
{
"id": "3098ce13-a064-47a8-95ef-a0cc721a0dd9",
"line_number": 1,
"element_id": "00000000-0000-0000-0000-000000000000",
"coverage_term_key": "gp_video",
"description": "Booked GP session (APT-18e8ea84)",
"amount_claimed": "1.0000", "amount_allowed": "0.0000", "amount_paid": "0.0000",
"metadata": {"quantity": 1, "episode_locator": "EP-54fc7a5f", "appointment_locator": "APT-18e8ea84"}
}| Key | Read by | What actually happens |
|---|---|---|
coverage_term_key: gp_video | eligibility apply | resolves to the member's SESSION_LIMIT accumulator; the stored type wins over any hint |
amount_claimed: 1 + metadata.quantity: 1 | buildAccumulatorRequest[24] | one session burnt (a session-unit line, not £1) |
element_id: 0000… | nothing | the booking BFF sends no elementId, so the zero uuid satisfies NOT NULL - the benefit join that carries adjudication is coverage_term_key (defect, §9) |
amount_allowed: 0 on an APPROVED claim | nothing corrects it | autoApprove burns and transitions but does not run the line-amount update; only the review path writes allowed amounts (defect, §9) |
The trail
| event_type | from → to | actor | note |
|---|---|---|---|
| SUBMITTED | - → SUBMITTED | system | |
| APPROVED | SUBMITTED → APPROVED | system | auto-approved on line submission |
The downstream evidence
The same 30 milliseconds, seen from eligibility (live rows):
accumulator_applications: (CLM-2026-002725, 3098ce13-…) → gp_video, 1.0000, 2026-07-30 14:24:49
coverage_accumulators: gp_video consumed 9.0000 of limit 5.0000, last_claim_locator = CLM-2026-002725This claim is session nine of five - past the included allowance, into the contribution-payable zone, exactly as the previous page describes. And an outbox row on topic claims.events (2 150 live rows, 0 unpublished) announced claim.approved to billing in the same transaction as the transition.
Live population for context: 1 123 claims - 1 027 SUBMITTED, 54 APPROVED, 12 REJECTED, 11 PENDING_INFO, 11 CLOSED, 5 UNDER_REVIEW, 3 PAID - and 1 558 events including 330 NOTEs (the reviewer surface).
7. Who references a Claim
| Where | Column / mechanism | Meaning there |
|---|---|---|
eligibility.accumulator_applications | claim_locator, claim_line_id | which claim line consumed the allowance - the cross-service double-entry |
eligibility.coverage_accumulators | last_claim_locator | legacy display pointer to the most recent burner |
| billing | consumes topic claims.events[8] | claim.approved raises the CLAIM-category reimbursement charge |
| member app / timeline | GET /claims (JWT-scoped), the timeline's claim lane | "my claims", each item keyed by CLM- |
| triage / support tooling | ?triageSessionLocator= filter[12] | chat → claim; can only narrow a tenant's own scope |
| web-admin / Ollyverse | /claims/list, POST /claims/{loc}/notes[40] | ops list + reviewer verdict notes (NOTE events) |
None are FKs; all are locator/topic references (cross-service rule).
8. Design determinations
- No DRAFT - claims are complete at submission. D-37 · #1135; enum comment + guard test[4][5].
- Auto-approve on line submission - the deliberate product-stage choice that a submitted claim consumes the allowance immediately, with no insurer review step. The founder's call, recorded in code at the point that executes it[38]. The review branch and UNDER_REVIEW state stay modelled beside it, so switching adjudication on is a rules change (§9).
- APPROVED means the allowance has been burnt - apply first, transition second, replay idempotently as the repair path[28].
- The triage link is shape-checked, existence-unverified - a claim with no provable chat stores NULL; no synchronous cross-DB lookup at write time. Migrations 0012 + 0013[21][22].
- Event + outbox in the transition's transaction - the audit trail and the cross-service announcement can never drift from the status[26].
- Tenancy scope from the verified JWT, filters can only narrow - and cross-tenant submission is refused with 403[12][30].
- Decline reasons are durable -
reason_codeon the event, not just the outbox. #1626[17].
9. Caveats and extensibility
Group and individual. A claim knows nothing about schemes: it is always member-level (member_locator), whichever contract structure the policy has. The group-ness lives entirely in authorisation - the employer-side tenant scope resolves to the scheme's member locators, and an individual policyholder is a tenant of one. No schema change is needed for direct-to-consumer claims.
Where to extend, and what it costs:
| When we need … | What to add | Where |
|---|---|---|
| Real adjudication - rules decide instead of approving on submission | Rules for the adjudicator to evaluate. ReviewClaim already branches on a manual-review decision and routes to UNDER_REVIEW, which the state machine models and the DB already stores (5 live rows); no migration, no new status | ReviewClaim's decision branch[32] and the adjudicator package it calls |
| Prior-auth to gate a claim | The claim-path lookup that matches an APPROVED prior-auth to the claim before it approves. The 116 rows, the PAU- lifecycle and the decision events are already there; the gate is the deferred consumer | Scoped in #1705; table and lifecycle documented on Prior auth & claim documents[10] |
| Claims to carry attachments (evidence, invoices, imaging) | Callers start using the attach path. Table, in-service FK, service method and endpoint are in place, so this is adoption rather than build | claims.claim_documents[9] and POST /claims/{locator}/documents, walked on Prior auth & claim documents |
| A new benefit to be claimable (physio, dental, diagnostics …) | The module key in the catalogue, then sent as the line's coverage_term_key. Lines, the accumulator apply and the unit semantics absorb it with no migration and no code branch | claim_lines.coverage_term_key[2] → buildAccumulatorRequest[24] |
| Per-line decline reasons on the member surface | Write reason_code on the line when adjudication rejects it; the column exists on both the line and the event, and the event's is durable since 0014 | claim_lines.reason_code[2]; claim_events.reason_code[17] |
| A terminal state for lineless SUBMITTED claims | A sweep or expiry that transitions them; CLOSED, its entry gate and its event vocabulary already exist, so this is a job, not a model change | CloseClaim[36] |
| Direct-to-consumer claims | Nothing in this schema. The claim is always member-level; the group-ness lives in tenant scope resolution | member_locator + the JWT-derived scope[12] |
Known defects - the fix, and where it goes:
- Auto-approved claims do not get line amounts.
autoApproveburns and transitions but does not run the allowed/paid update, so APPROVED claims showamount_allowed = 0(§6) andPayClaim, which pays at the allowed amount[25], would settle 0 for them. Fix: write the line amounts in the same transaction as the auto-approve transition, alongside the accumulator apply[28], matching what the review path already does[32]. element_idis the zero uuid on the live booking path. The member app BFF sends no elementId, so00000000-…satisfies the NOT NULL and the column looks populated while joining to nothing;coverage_term_keyis the join that carries adjudication. Fix, either half: stamp the real element id at line creation[14], or make the column nullable in a newservices/claims/migrationsstep so absence reads as absence.amount_allowed <= amount_claimedis enforced nowhere. The narrative states it as an expectation; neither the DB nor the write path checks it (§4). Fix: a CHECK onclaim_linesin a new migration, plus the same assertion in the adjudication write path so the API refuses rather than the insert failing.- In-schema locator sequence.
claims.claims_locator_seqplus hand-rolledfmt.Sprintfinstead of the sharedlocator_seq_<prefix>machinery every other service uses (§3, ADR-1164-05). Fix: mint throughpackages/go/service/locator[20], seedinglocator_seq_CLMfrom the currentlast_valueso locators stay monotonic. - document-service's topic subscription was wrong and is fixed - it subscribed to
claims-events(hyphen) while the claims outbox publishes toclaims.events(dot), so it was a consumer wired to a topic that never received a message. It now subscribes toenrollment-events,claims.eventsandbilling.events, the last of which was missing entirely, which is why no invoice document was ever generated[41].
Notes on the live shape, for anyone querying it:
reason_codeon events is live and empty so far: all 23 REJECTED events carry'', because the rejections predate migration 0014 and no rejection has carried a code since.- 1 027 of 1 123 claims sit in SUBMITTED - mostly lineless claims (seeder / e2e / dashboards that POST the head and not the lines), for which auto-approve correctly has nothing to consume. The terminal sweep is an extension point above.
claim_eventscolumns are looser than the code: from_status / to_status / actor are nullable with no vocabulary CHECK, and append-only is a repository convention rather than a grant or trigger.
References
Code links are pinned to commit 8329d7b on main (2026-08-19); the file is the anchor if lines drift. Pins are checked mechanically by docs/site/scripts/check-code-refs.py.
claims/migrations/0002_create_claims.sql- claims DDL, UNIQUE locator L130004_create_claim_lines.sql- lines DDL, UNIQUE (claim_id, line_number) L150003_create_claim_events.sql- events DDLpackages/go/domain/enums.go#L20- ClaimStatus; "No DRAFT - claims are complete at submission" at L23packages/go/domain/enums_test.go#L17- TestClaimStatusNoDraftclaims/internal/handler/claims.go#L100- submit returns 202claims/internal/client/eligibility.go#L129- ApplyAccumulators wire callbilling/internal/config/config.go#L93- billing consumesclaims.events0005_create_claim_documents.sql- claim_documents (0 live rows)0006_create_prior_auths.sql- prior_auths (116 live rows)0010_add_locator_columns.sql- policy/member locator columns + the in-schema sequencesclaims/internal/service/claim.go#L776- ClaimFilter: JWT tenant scope wins, filters only narrowpackages/go/domain/claims.go#L10- Claim / ClaimEvent / ClaimLine models; triage-pointer rationale L21-L26claim.go#L556- AddLine: max+1 numbering, then autoApproveclaim.go#L875- lineQuantity from metadataclaim.go#L698- AddNote: notes are events (#1475)0014_claim_events_reason_code.sql- durable reason_code (#1626)claim.go#L99- locator mintCLM-%d-%06drepository/gorm_claims.go#L172-nextval('claims.claims_locator_seq')packages/go/service/locator/locator.go#L119- the sharedlocator_seq_<prefix>naming everyone else uses0012_claims_triage_session.sql- TEXT + partial index; NULL-over-guess rationale0013_claims_triage_session_uuid_check.sql- uuid CHECK, NOT VALID, defence-in-depth rationalehandler/claims.go#L74- shape-checked, existence deliberately not; canonical-form storageclaim.go#L889- buildAccumulatorRequest: claimed fallback + line-id idempotency keyclaim.go#L508- PayClaim pays at allowed amountrepository/gorm_claims.go#L54- UpdateStatus: row lock, from-assert, event in same txclaim.go#L259- transition + outbox in one runInTxclaim.go#L601- autoApprove ordering invariant: APPROVED = burntclaim.go#L227- review path burns before APPROVED toohandler/claims.go#L47- cross-tenant submission refused (403 with rationale)claim.go#L95- SubmitClaimclaim.go#L147- ReviewClaim; empty-rules auto-approve L179-L186; allowed = claimed L211-L218claim.go#L277- ApproveClaim; reachability-repair comment L287-L302claim.go#L372- RejectClaim; durable reason at L393claim.go#L414- RequestInfoclaim.go#L452- CloseClaimclaim.go#L489- PayClaim (route is internal-guarded: handler.go L83)claim.go#L587- the founder's-call auto-approve commentclaim.go#L799- narrowToTriageSession: subtract-only semanticshandler/handler.go#L97- the JWT-guarded claim routes (notes at L115)document-service/internal/config/config.go#L54- consumer topic list, with the comment recording why the hyphenated default and the missing billing topic were both wrong
Live-schema facts (constraint list including the NOT VALID CHECK and partial index, Debezium publication, status/event counts, sequence last_value, outbox topic census, the worked claim's rows) come from PGPASSWORD=… psql -h 10.0.1.2 -U olly -d claims · \d claims.claims, \d claims.claim_lines, \d claims.claim_events, \ds claims.*, 2026-08-18.
