Skip to content
Updated Aug 22, 2026

Claim

Schema deep-dive · living document · #7 in the reading sequence

Tablesclaims.claims, claims.claim_lines, claims.claim_events[1][2][3] (second ring: prior_auths, claim_documents, outbox)
Owner serviceclaims (sole writer)
LocatorCLM-YYYY-NNNNNN (prior auths: PAU-)
Last updated2026-08-19
CompanionERD 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 inJoin
the coverage decisioneligibility - 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 movementbilling - consumes the claims.events topic[8] and raises the CLAIM-category reimbursement chargeoutbox event, claimLocator in payload
the care episodecare.episodes / appointments; the claim's document carries episode_locator / appointment_locator as context, unverifiedjsonb copy
the chat that caused ittriage.sessions; triage_session_locator is a deliberately unverified cross-boundary pointer (see §3)TEXT uuid, no FK
the document storedocument-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 documentsclaim_id FK (in-service)
a prior authorisationclaims.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 documentsnone 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

DDL[1][2][3] · Go models[13]

claims

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE CLM-YYYY-NNNNNN
policy_id, term_id, claimant_party_iduuidCross-service uuid soft refs; term_id is what the accumulator apply keys on
policy_locator, member_locatortextPOL- / PTY- soft refs (migration 0010)
statustextThe 7-state machine; convention in DB, enforced in code (§4)
incident_datedateWhen the loss occurred
documentjsonbThe submission payload - free-shape context (see §6)
triage_session_locatortextTEXT with a uuid-shape CHECK, NOT VALID, partially indexed (below)

claim_lines

FieldTypeReqNotes
claim_iduuidReal FK to claims (in-service)
line_numberintUNIQUE with claim_id; assigned max+1 by AddLine[14]
element_iduuidDesigned to join policy_elements by static id; see the zero-uuid defect, §9
coverage_term_keytextThe catalogue module key - the join that moves accumulators
amount_claimed / amount_allowed / amount_paidnumeric(14,4)Default 0; billed / adjudicated / settled
reason_codetextPer-line adjudication reason
metadatajsonbe.g. {"quantity": 3} - sessions this line represents[15]

claim_events

FieldTypeReqNotes
claim_iduuidReal FK; indexed
event_typetextLive vocabulary: SUBMITTED, REVIEWED, APPROVED, REJECTED, INFO_REQUESTED, CLOSED, PAID, NOTE
from_status / to_statustextNullable in DB; stamped by UpdateStatus in code
actortextsystem or user today; nullable in DB
notetextFree text; NOTE events are the reviewer surface (#1475)[16]
reason_codetextNOT 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

InvariantEnforced by
locator uniqueDB UNIQUE[1]
One line number per claimDB UNIQUE (claim_id, line_number)[2]
Lines / events / documents belong to a real claimDB FKs (in-service - the no-FK rule applies only across services)
triage_session_locator is a lowercase uuid or NULLDB CHECK (NOT VALID - new writes only)[22] + handler canonicalisation[23]
Status transitions are legalApplication: 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, everApplication + test: enum comment[4] and TestClaimStatusNoDraft[5]; no DB CHECK on status
APPROVED ⇒ allowance burntApplication: every path to APPROVED calls the idempotent accumulator apply first[28][29]
A caller only sees their tenant's claimsApplication: JWT-derived member scope runs first; other filters can only narrow[12]; cross-tenant submission is a 403[30]
Events are append-onlyConvention - no UPDATE/DELETE path exists in the repository; nothing in the DB forbids one
amount_allowed <= amount_claimedNothing - stated as an expectation in the narrative, enforced nowhere
Row changes captured to CDCDebezium 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

json
{
  "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"
}
KeyRead byWhat actually happens
document.source/category/…nothing on the adjudication pathsubmission 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_locatorchat → claim lookups (ListByTriageSession, filter-narrowing for tenants)[39]support can walk claim → transcript and back; this uuid is a real triage session
term_idthe accumulator applyscopes the burn to the 2026 policy year
status: APPROVEDeveryoneand per the invariant, the allowance is already burnt (see below)

The line

json
{
  "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"}
}
KeyRead byWhat actually happens
coverage_term_key: gp_videoeligibility applyresolves to the member's SESSION_LIMIT accumulator; the stored type wins over any hint
amount_claimed: 1 + metadata.quantity: 1buildAccumulatorRequest[24]one session burnt (a session-unit line, not £1)
element_id: 0000…nothingthe 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 claimnothing corrects itautoApprove burns and transitions but does not run the line-amount update; only the review path writes allowed amounts (defect, §9)

The trail

event_typefrom → toactornote
SUBMITTED- → SUBMITTEDsystem
APPROVEDSUBMITTED → APPROVEDsystemauto-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-002725

This 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

WhereColumn / mechanismMeaning there
eligibility.accumulator_applicationsclaim_locator, claim_line_idwhich claim line consumed the allowance - the cross-service double-entry
eligibility.coverage_accumulatorslast_claim_locatorlegacy display pointer to the most recent burner
billingconsumes topic claims.events[8]claim.approved raises the CLAIM-category reimbursement charge
member app / timelineGET /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

  1. No DRAFT - claims are complete at submission. D-37 · #1135; enum comment + guard test[4][5].
  2. 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).
  3. APPROVED means the allowance has been burnt - apply first, transition second, replay idempotently as the repair path[28].
  4. 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].
  5. Event + outbox in the transition's transaction - the audit trail and the cross-service announcement can never drift from the status[26].
  6. Tenancy scope from the verified JWT, filters can only narrow - and cross-tenant submission is refused with 403[12][30].
  7. Decline reasons are durable - reason_code on 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 addWhere
Real adjudication - rules decide instead of approving on submissionRules 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 statusReviewClaim's decision branch[32] and the adjudicator package it calls
Prior-auth to gate a claimThe 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 consumerScoped 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 buildclaims.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 branchclaim_lines.coverage_term_key[2]buildAccumulatorRequest[24]
Per-line decline reasons on the member surfaceWrite 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 0014claim_lines.reason_code[2]; claim_events.reason_code[17]
A terminal state for lineless SUBMITTED claimsA sweep or expiry that transitions them; CLOSED, its entry gate and its event vocabulary already exist, so this is a job, not a model changeCloseClaim[36]
Direct-to-consumer claimsNothing in this schema. The claim is always member-level; the group-ness lives in tenant scope resolutionmember_locator + the JWT-derived scope[12]

Known defects - the fix, and where it goes:

  • Auto-approved claims do not get line amounts. autoApprove burns and transitions but does not run the allowed/paid update, so APPROVED claims show amount_allowed = 0 (§6) and PayClaim, 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_id is the zero uuid on the live booking path. The member app BFF sends no elementId, so 00000000-… satisfies the NOT NULL and the column looks populated while joining to nothing; coverage_term_key is the join that carries adjudication. Fix, either half: stamp the real element id at line creation[14], or make the column nullable in a new services/claims/migrations step so absence reads as absence.
  • amount_allowed <= amount_claimed is enforced nowhere. The narrative states it as an expectation; neither the DB nor the write path checks it (§4). Fix: a CHECK on claim_lines in 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_seq plus hand-rolled fmt.Sprintf instead of the shared locator_seq_<prefix> machinery every other service uses (§3, ADR-1164-05). Fix: mint through packages/go/service/locator[20], seeding locator_seq_CLM from the current last_value so locators stay monotonic.
  • document-service's topic subscription was wrong and is fixed - it subscribed to claims-events (hyphen) while the claims outbox publishes to claims.events (dot), so it was a consumer wired to a topic that never received a message. It now subscribes to enrollment-events, claims.events and billing.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_code on 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_events columns 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.

  1. claims/migrations/0002_create_claims.sql - claims DDL, UNIQUE locator L13
  2. 0004_create_claim_lines.sql - lines DDL, UNIQUE (claim_id, line_number) L15
  3. 0003_create_claim_events.sql - events DDL
  4. packages/go/domain/enums.go#L20 - ClaimStatus; "No DRAFT - claims are complete at submission" at L23
  5. packages/go/domain/enums_test.go#L17 - TestClaimStatusNoDraft
  6. claims/internal/handler/claims.go#L100 - submit returns 202
  7. claims/internal/client/eligibility.go#L129 - ApplyAccumulators wire call
  8. billing/internal/config/config.go#L93 - billing consumes claims.events
  9. 0005_create_claim_documents.sql - claim_documents (0 live rows)
  10. 0006_create_prior_auths.sql - prior_auths (116 live rows)
  11. 0010_add_locator_columns.sql - policy/member locator columns + the in-schema sequences
  12. claims/internal/service/claim.go#L776 - ClaimFilter: JWT tenant scope wins, filters only narrow
  13. packages/go/domain/claims.go#L10 - Claim / ClaimEvent / ClaimLine models; triage-pointer rationale L21-L26
  14. claim.go#L556 - AddLine: max+1 numbering, then autoApprove
  15. claim.go#L875 - lineQuantity from metadata
  16. claim.go#L698 - AddNote: notes are events (#1475)
  17. 0014_claim_events_reason_code.sql - durable reason_code (#1626)
  18. claim.go#L99 - locator mint CLM-%d-%06d
  19. repository/gorm_claims.go#L172 - nextval('claims.claims_locator_seq')
  20. packages/go/service/locator/locator.go#L119 - the shared locator_seq_<prefix> naming everyone else uses
  21. 0012_claims_triage_session.sql - TEXT + partial index; NULL-over-guess rationale
  22. 0013_claims_triage_session_uuid_check.sql - uuid CHECK, NOT VALID, defence-in-depth rationale
  23. handler/claims.go#L74 - shape-checked, existence deliberately not; canonical-form storage
  24. claim.go#L889 - buildAccumulatorRequest: claimed fallback + line-id idempotency key
  25. claim.go#L508 - PayClaim pays at allowed amount
  26. repository/gorm_claims.go#L54 - UpdateStatus: row lock, from-assert, event in same tx
  27. claim.go#L259 - transition + outbox in one runInTx
  28. claim.go#L601 - autoApprove ordering invariant: APPROVED = burnt
  29. claim.go#L227 - review path burns before APPROVED too
  30. handler/claims.go#L47 - cross-tenant submission refused (403 with rationale)
  31. claim.go#L95 - SubmitClaim
  32. claim.go#L147 - ReviewClaim; empty-rules auto-approve L179-L186; allowed = claimed L211-L218
  33. claim.go#L277 - ApproveClaim; reachability-repair comment L287-L302
  34. claim.go#L372 - RejectClaim; durable reason at L393
  35. claim.go#L414 - RequestInfo
  36. claim.go#L452 - CloseClaim
  37. claim.go#L489 - PayClaim (route is internal-guarded: handler.go L83)
  38. claim.go#L587 - the founder's-call auto-approve comment
  39. claim.go#L799 - narrowToTriageSession: subtract-only semantics
  40. handler/handler.go#L97 - the JWT-guarded claim routes (notes at L115)
  41. 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.

Olly Health Insurance Platform