Skip to content
Updated Aug 22, 2026

Eligibility projection

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

Tableseligibility.member_coverage, eligibility.coverage_accumulators, eligibility.accumulator_applications, eligibility.projection_checkpoints[1][2][3][4]
Owner serviceeligibility (sole writer; every row derived from enrollment events or a claims apply call)
Locatornone minted here - rows are uuid-keyed and carry PTY- / POL- / SCH- / CLM- references
Last updated2026-08-19
CompanionERD story, slide 6 · real policy JSON · Eligibility & Accumulators (narrative) · previous: Terms, transactions & elements

1. Scope and usage

The eligibility schema is the platform's CQRS read model: it answers "is this person covered for X today, and how much of the allowance is left" in a single-table read, at booking speed. It holds no source of truth. Enrollment owns the policy; the product catalogue owns the terms; this schema holds a flattened, query-shaped copy of both, maintained by consuming enrollment's Kafka events[5].

Four tables, four jobs:

  • member_coverage - what is covered: one row per (member, benefit element, term), with the catalogue snapshot inlined.
  • coverage_accumulators - how much has been used: one counter per (member, term, metering type, benefit key).
  • accumulator_applications - which claim line used it: the idempotency ledger that makes consumption exactly-once.
  • projection_checkpoints - consumer bookkeeping: last processed (topic, partition, offset).

The model is eventually consistent by design: a just-issued policy becomes adjudicable only once its event has been projected, and the narrative page states the trade plainly - adjudication reads are single-table and fast, and a coverage gap usually means an unprojected event, not missing enrollment data.

2. Boundaries and relationships

The projection is not…That concern lives inJoin
the source of truth for coverenrollment.policies / policy_elements; this schema is rebuilt from their events (policy.issued, element.added, policy.renewed, …)[5]Kafka enrollment-events[6]
the product cataloguepolicy_admin.product_versions.element_schema; each coverage row carries a coverage_terms snapshot of it (see Product & catalogue)jsonb copy, not a ref
the claimclaims.claims / claim_lines; claims consumes allowance by calling PATCH /internal/members/{locator}/accumulators/{term}/apply over HTTP[7]claim_locator / claim_line_id text in the ledger
membershipgroup_scheme.scheme_members; roster rows never gate eligibility - coverage starts at enrol (D-07, #1010)scheme_locator, carried through
the consumer's resume positionKafka consumer-group offsets (group eligibility-projection[8]); projection_checkpoints is written on every message[9] as the lag signal; the resume position itself stays in Kafka-

Zero foreign keys, on purpose. Not one table in this schema has an FK - not even to each other. Everything joins by uuid or locator value. This is the cross-service no-FK rule taken to its logical end: a projection that FK'd anything would block on the very data it exists to decouple from.

3. Structure

DDL[1][2][3] · Go model[10]

member_coverage

FieldTypeReqNotes
iduuidPK, internal
party_id, policy_id, policy_element_id, element_static_id, term_iduuidThe provenance quintet - see below
policy_locator, party_locatortextPOL- / PTY- for human-facing reads; party_locator is the lookup key for checks
scheme_locatortextAdded by migration 0008 for the employer utilisation panel; empty for direct-to-consumer[11]
product_codetextNOT NULL but empty string on live rows (see §9)
element_typetextTwo vocabularies live (see §9)
coverage_termsjsonbSnapshot of the catalogue at projection time
statustextACTIVE | TERMINATED | INACTIVE - convention, no CHECK
effective_from / effective_todate✓/-The validity window; effective_to NULL = open, exclusive when set

UNIQUE (party_id, element_static_id, term_id, effective_from)[1].

coverage_accumulators

FieldTypeReqNotes
party_id, term_iduuidWhose allowance, which policy year
accumulator_typetextSESSION_LIMIT | BENEFIT_LIMIT | USAGE (live vocabulary; see below)
coverage_term_keytextThe catalogue module key: gp_video, diagnostics, …
limit_amount / consumed_amountnumeric(14,4)The ceiling and the running total (consumed defaults 0)
currencytextDefault 'GBP' - every live row is GBP
last_claim_locatortextLegacy dedup guard, superseded by the ledger; still written (see §9)

UNIQUE (party_id, term_id, accumulator_type, coverage_term_key)[2].

accumulator_applications

FieldTypeReqNotes
claim_locatortextThe consuming CLM-
claim_line_idtextTEXT, not uuid: a line without an id falls back to its coverage-term key[12]
party_id, term_id, accumulator_type, coverage_term_keyuuid/textWhich accumulator this application moved
amountnumeric(14,4)In the accumulator's own metering unit - sessions or pounds
applied_attimestamptzDefault now()

UNIQUE (claim_locator, claim_line_id)[3] - the constraint is the mechanism (see below).

projection_checkpoints

(topic, partition, "offset", processed_at), UNIQUE (topic, partition). One live row: enrollment-events / 0.

Field-by-field: what and why

The provenance quintet - five uuids per coverage row because each answers a different question. party_id - who. policy_id - under which contract. term_id - in which policy year (the accumulator scope). policy_element_id

  • which exact element version produced this row. element_static_id - the identity that is stable across element versions, and therefore the one in the unique key: when an endorsement re-versions an element, the new event must land on the same projection row, not beside it. The upsert converges on the latest event by design - the repository comment records that a DoNothing conflict policy once left an upgraded member TERMINATED with no active cover[13].

coverage_terms - the same catalogue bytes the policy element snapshotted at issue, carried again here so an eligibility check is one read: active row → parse modules → verdicts[14]. EvaluateExclusions reads it for channel exclusions, referral requirements and the MANUAL_REVIEW surfacings described on the product page.

effective_from / effective_to - the adjudication window. GetActiveByPartyLocator filters status = 'ACTIVE' AND effective_from <= date AND (effective_to IS NULL OR effective_to > date)[15] - so effective_to is exclusive. Terminate stamps it and flips status on every open row for the static id[16].

accumulator_type - the metering archetype, seeded from the catalogue through the shared reader[17]: unit: "sessions" + included_per_cycle → SESSION_LIMIT; cycle_limit → BENEFIT_LIMIT; unmetered → USAGE with a zero limit. The projection's comment is explicit that an unmetered module still gets a row, because consumption against it must remain recordable or "unmetered" silently means "untracked". Since 2026-08-18 unmetered is a declared unit rather than an absent limit, so "no ceiling" and "nobody filled it in" no longer produce the same row. Live counts: 1 404 SESSION_LIMIT, 948 USAGE, 468 BENEFIT_LIMIT. A legacy flat-key path (annual_deductible, oop_max, benefit_limit) would mint key-named types[19]; zero such rows exist. Per-benefit annual money caps are standard PMI practice - benefits run "usually up to a stated annual limit" - and the catalogue's cycle_limit is exactly that number landing in limit_amount.

term_id scoping on accumulators - allowances are per policy year, not per policy. PMI cover is written "typically on a rolling monthly or an annually renewable basis" , and the schema encodes it: policy.renewed terminates the old coverage rows and seeds fresh accumulators under the new term id[20] - the reset is a new counter, never an UPDATE of the old one, so history survives renewal.

The ledger INSERT is the lock - applying a claim line is a non-idempotent read-modify-write (consumed_amount = consumed_amount + n). The previous guard, last_claim_locator != $claim, only remembered the most recent claim: the sequence apply(A), apply(B), apply(A) sailed through and counted A twice. The migration comment tells this story in full[3]. Now Apply runs one transaction: INSERT … ON CONFLICT (claim_locator, claim_line_id) DO NOTHING; if the insert took no row it is a replay and returns before touching the accumulator; if the subsequent UPDATE moves no accumulator, a sentinel error rolls the ledger row back so the slot is not banked without the burn[21].

Sessions vs pounds is eligibility's call - the claims caller sends both quantity and amount per line; consumedUnits picks which one the stored accumulator's type meters (SESSION_LIMIT/USAGE count events, everything else counts money)[22], and resolveAccumulatorType lets the stored row win over any caller hint[23] - claims deliberately keeps no term-key → type map to drift.

projection_checkpoints.partition - named partition here, while the same table in billing and notifications names it partition_id[24][25] - a naming drift between three hand-rolled copies of the same idea. This copy also pairs it with "offset", which needs quoting in every statement that touches it[26].

4. Invariants

InvariantEnforced by
One projection row per (party, static element, term, start date)DB UNIQUE[1]
One accumulator per (party, term, type, key)DB UNIQUE[2]
A claim line consumes at most onceDB UNIQUE (claim_locator, claim_line_id) + the INSERT-claims-the-slot transaction[21]
Ledger row ⇔ accumulator movedApplication: same transaction, sentinel rollback when no accumulator exists[21]
Upsert refreshes ceiling, never consumptionApplication: conflict updates limit_amount only[27]
A member checks only their own eligibilityApplication: verified party_locator claim overrides the request body (#1544)[28]
status / element_type / accumulator_type vocabulariesNothing - no CHECK on any of them; conventions held by the writers
consumed_amount <= limit_amountNothing, deliberately - consumption counts past the included allowance (that is the contribution-payable zone; see §6)
Every event is projectedNot guaranteed - the consumer is log-and-skip and always advances the offset[29]; a failed handler is a logged gap, not a retry
Row changes captured to CDCDebezium publication dbz_eligibility (all four tables, live \d)

5. Lifecycle

Rows here have no lifecycle of their own - they shadow enrollment's. The two write paths, end to end:

Nine event types are handled[5]. Three shapes worth knowing: policy.issued bootstraps by fetching the full policy back from enrollment over HTTP (a thin event, a fat fetch)[30]; element.updated and policy.endorsed terminate-then-upsert under the same static id; policy.renewed terminates the old term's rows and re-seeds coverage and accumulators under the new term_id[20] - the annual reset.

Consumption is synchronous and claims-initiated: every path that lands a claim in APPROVED calls the apply endpoint, and idempotency is what lets it call unconditionally[31] (the full claim-side story is the next page).

6. Populated example: PTY-2026-000459, walked end to end

One live member, all four tables. The coverage row (no PII lives in this database; locators and uuids are real):

json
{
  "id": "bf6cdada-ea4c-4c53-a817-d3875a630d92",
  "party_id": "53b82c7e-15cb-5daf-a04d-bee5f4c664cd",
  "policy_id": "a5a79e39-94fd-449c-9a2d-53b30c5f0721",
  "policy_element_id": "da9c5563-7ea2-4353-a83d-5543ed0b02f8",
  "element_static_id": "fa1d5f6f-d181-4ccb-9cd4-c74d2fb774b0",
  "term_id": "e5e93520-4367-4aea-8c97-fc3aa8db62c8",
  "policy_locator": "POL-2026-001315",
  "party_locator": "PTY-2026-000459",
  "scheme_locator": "SCH-2026-001126",
  "product_code": "",
  "element_type": "employee",
  "coverage_terms": { "…": "the full OHC-2026 catalogue snapshot - tier STANDARD, six modules, premium_per_member_monthly 19.5" },
  "status": "ACTIVE",
  "effective_from": "2026-07-12",
  "effective_to": null
}

coverage_terms is byte-for-byte the catalogue walked on the product page; note the empty product_code (§9).

The accumulators the projection seeded from it

Live rows for (party 53b82c7e…, term e5e93520…):

accumulator_typecoverage_term_keylimitconsumedlast_claim_locator
SESSION_LIMITgp_video59CLM-2026-002725
SESSION_LIMITphysio_remote526CLM-2026-002730
SESSION_LIMITmental_health52CLM-2026-002684
BENEFIT_LIMITdiagnostics250.000.00-
USAGEdigital00-
USAGEneurodiversity00-
ObservationWhat it tells you
gp_video 9 of 5, physio_remote 26 of 5Consumption counts past the included allowance by design - there is no ceiling check on Apply. Sessions 6+ are the contribution-payable zone (contribution_after_included: 25.0 in the snapshot); the counter keeps the truthful total either way
digital / neurodiversity at limit 0The unmetered-still-metered rule[18]: USAGE rows exist so cover with no ceiling stays recordable
last_claim_locator still movingApply still writes the legacy column on every burn[21]; it is display trivia now, not the guard

The ledger that explains every unit

37 application rows exist for this member; the nine gp_video rows are the 9 on the counter. The most recent:

claim_locator    claim_line_id                         type           key       amount  applied_at
CLM-2026-002725  3098ce13-a064-47a8-95ef-a0cc721a0dd9  SESSION_LIMIT  gp_video  1.0000  2026-07-30 14:24:49

amount = 1.0000 is one session, not one pound - the unit is whatever the accumulator type meters[22]. That claim line is the worked example on the Claim page - the same row seen from the other side of the service boundary.

The money-metered case: PTY-2026-000075

accumulatorlimitconsumedledger behind it
BENEFIT_LIMIT diagnostics250.00240.00two applications of 120.0000 each (CLM-2026-002679, CLM-2026-002677)

Same mechanics, pounds instead of sessions: this member is one blood test away from exhausting the diagnostics benefit, and the next eligibility check surfaces exactly that arithmetic to the caller.

The checkpoint

topic: enrollment-events   partition: 0   offset: 1834   processed_at: 2026-08-17 08:33:31

One partition, one row - the projection's heartbeat. Staleness of processed_at against topic activity is the lag signal.

7. Who reads the projection

Nothing joins these tables from outside - the fan-out is HTTP, which is the point of a read model:

CallerRouteWhat it reads
claims (adjudication)PATCH /internal/members/{loc}/accumulators/{term}/apply[32]burns allowance; also GET /internal/…/coverage for policy resolution
member app (BFF)GET /check, GET /members/{loc}/coverage, /accumulators[33]"am I covered", benefit balances; member locator forced from JWT
employer portalGET /schemes/{loc}/utilisation (scheme-ownership gated)[34]per-module used/limit summed across the roster[35]
triage health-chat agentGET /internal/members/{loc}/accumulators (VPC-only)[36]the accumulators tool behind "how many sessions do I have left"
web-adminGET /coverage/list, /accumulators/list via APISIXthe ops read

8. Design determinations

  1. CQRS read model, no source of truth, zero FKs - adjudication reads are single-table; integrity is events + idempotency, never constraints across services. (data architecture, narrative)
  2. Coverage starts at enrol - the projection consumes enrollment events; roster membership never gates it. D-07 · #1010.
  3. Accumulators are seeded from the catalogue - product is data; a new module needs no eligibility deploy to be metered. (§3, Product & catalogue)
  4. Idempotency by ledger, not by last-writer memory - the (claim_locator, claim_line_id) UNIQUE replaced the last_claim_locator guard that double-counted on A, B, A. Migration 0009's comment is the decision record[3].
  5. Eligibility owns the metering semantics - callers send quantity and amount; the stored accumulator type decides which is consumed[22].
  6. Unmetered is metered at limit 0 - untracked and unmetered are not the same thing[18].
  7. Eventual consistency accepted; log-and-skip on poison events - a bad event is a logged gap to repair, never a stuck partition[29].
  8. Members can only check themselves - the verified JWT claim overrides the request. #1544.
  9. Metering archetypes modelled ahead of the products that use them - session, monetary and unmetered are all derived from catalogue data, and every non-session type already meters in money[22]. A deductible or an out-of-pocket max is therefore a product definition rather than an eligibility deploy, and because every row here is derived, the projection can be rebuilt from the source events at any time. §9 names each surface.

9. Caveats and extensibility

Group and individual share the projection unchanged. The only group-aware column is scheme_locator, added for the employer utilisation panel and documented as empty for direct-to-consumer policies in the model[10]. An individual policy projects rows with an empty scheme and every check, accumulator and ledger path behaves identically - the group vs individual fork was spent upstream on policy_structure, never here.

The join that keeps this cheap. Claims burns allowance without holding a foreign key into this schema: it names a coverage_term_key - a catalogue module key - and eligibility resolves the accumulator from its own stored row, letting the stored type win over any caller hint[23]. Any future consumer of allowance joins the same way, so a new metering surface needs no schema change on either side of the boundary.

Where to extend. Each row is a change the model already absorbs; the "where" is the surface someone touches when the need lands.

When we need …What to addWhere
A new benefit module meteredNothing here: publish the module in the catalogue. parseAccumulatorSpecs walks whatever module and addon keys the snapshot carries and seeds one accumulator per key[17], and verdicts resolve a service type against the catalogue rather than a hardcoded mappolicy_admin.product_versions.element_schemacoverage_terms
A session allowance ("5 GP video sessions a year")unit: "sessions" + included_per_cycle on the module → SESSION_LIMIT with that ceilingthe module definition in the catalogue
A monetary annual cap ("£250 of diagnostics")cycle_limit on the module → BENEFIT_LIMIT, the number landing in limit_amountthe module definition in the catalogue
Cover with no ceiling that still must be recordableunmetered unit → USAGE at limit 0, so consumption stays countable[18]the module definition in the catalogue
A deductible or out-of-pocket maxSeed rows with the new type; consumedUnits already meters every non-session type in money[22] and the stored row decides the type, so claims stays unchangedseeding in services/eligibility/internal/projection/accumulators.go
To reverse consumption (a claim rejected after apply)An un-apply endpoint that deletes the ledger row and decrements the accumulator in one transaction; ListApplications is already the per-claim basis for it[37]. Until it exists, consumption on a reversed claim standsservices/eligibility/internal/repository/gorm_accumulators.go + the /internal/…/accumulators route block[32]
To rebuild the projection from sourceA replay command: reset the consumer-group offset, truncate coverage and accumulators, reconsume. Every row here is derived, so a rebuild loses nothing except the claim-side ledger, which is preservedconsumer group eligibility-projection[8] over topic enrollment-events
A new consumer of allowance (care, prevention, a wellness credit)Call the same apply endpoint with a locator and a line id; the UNIQUE (claim_locator, claim_line_id) is the whole contract, and idempotency makes the call safe to repeatPATCH /internal/members/{loc}/accumulators/{term}/apply
A scheme-level allowance (an employer pot, not a member one)An accumulator scoped on scheme_locator beside the party-scoped one; the utilisation route already sums per-member entitlement across a roster[35]a column beside coverage_accumulators; services/eligibility/internal/handler/utilisation.go
A non-annual cycle or carry-over between yearsNothing here: a new term from enrollment is already a new accumulator scope, so a different cycle length changes only the term window upstreamenrollment.policy_terms
Checkpoint-based recovery rather than Kafka offsetsRead projection_checkpoints back at consumer start; the row is already written per messageservices/eligibility/internal/repository/gorm_checkpoint.go

Known warts, stated:

  • element_type carries two vocabularies - live values are lowercase catalogue types (employee ×499, health ×1) and uppercase legacy seeds (OUTPATIENT, INPATIENT, DENTAL, one row each), while the Go model's comment promises a third story ("employee | dependent")[10]. Nothing reads the column on the adjudication path, which is why the drift survives. The fix: normalise the fossil rows and settle the vocabulary on the model comment in packages/go/domain/eligibility.go.
  • product_code is NOT NULL and empty - the enrollment element payload arrives without it, so live rows carry '' (the example row above). The fix: carry the product code on the element event and set it in the upsert, services/eligibility/internal/projection/handlers.go.
  • last_claim_locator is superseded as a guard but still written on every apply - harmless, and one more thing to misread as authoritative. The fix, when a migration is cheap: drop the column and the write in services/eligibility/internal/repository/gorm_accumulators.go.
  • partition vs partition_id - three services hand-rolled the same checkpoint table with drifting column names (§3). The fix: one shared migration snippet, renaming this copy's partition and unquoted-offset columns to match billing and notifications.
  • Log-and-skip means silent under-projection is possible - a handler failure (for example the bootstrap HTTP fetch failing on policy.issued) advances the offset anyway[30], and today the repair is re-emitting the event after reading the logs. The fix: a dead-letter table written from the error branch of the Run loop, services/eligibility/internal/projection/consumer.go.

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. eligibility/migrations/0002_create_member_coverage.sql - member_coverage DDL, UNIQUE L19
  2. 0003_create_coverage_accumulators.sql - accumulators DDL, UNIQUE L13, legacy last_claim_locator L11
  3. 0009_create_accumulator_applications.sql - the idempotency ledger; the A-B-A double-count story in the header comment L2-L13
  4. 0004_create_projection_checkpoints.sql - checkpoints DDL (partition, quoted "offset")
  5. projection/handlers.go#L15 - the nine registered event handlers
  6. internal/config/config.go - topic default, now the dual list enrollment-events,enrollment.events
  7. claims/internal/client/eligibility.go#L129 - the claims-side apply call (and the 404-for-months wrong-route history in its comment)
  8. cmd/server/main.go#L86 - consumer group eligibility-projection
  9. projection/consumer.go#L100 - commit + checkpoint upsert per message
  10. packages/go/domain/eligibility.go#L9 - MemberCoverage model ("never written by other services"); scheme comment L21; element_type comment L23
  11. 0008_member_coverage_scheme.sql - scheme_locator + why (employer utilisation)
  12. handler/internal.go#L191 - line-id fallback to coverage_term_key
  13. repository/gorm_coverage.go#L22 - Upsert converge-on-latest + the DoNothing regression story
  14. handler/verdicts.go#L44 - EvaluateExclusions over the coverage_terms snapshot
  15. repository/gorm_coverage.go#L69 - GetActiveByPartyLocator window semantics
  16. repository/gorm_coverage.go#L84 - Terminate
  17. projection/accumulators.go#L56 - unit / included_per_cycle / cycle_limit / unmetered → accumulator type
  18. projection/accumulators.go#L36 - unlimited-still-metered rationale
  19. projection/accumulators.go#L76 - legacy flat-key path
  20. projection/handlers.go#L421 - handlePolicyRenewed: terminate + re-seed under new term
  21. repository/gorm_accumulators.go#L39 - Apply: INSERT-claims-the-slot transaction + sentinel rollback
  22. handler/internal.go#L104 - consumedUnits: sessions vs pounds
  23. handler/internal.go#L120 - resolveAccumulatorType: stored row wins
  24. billing/migrations/0009_create_projection_checkpoints.sql#L5 - billing names it partition_id
  25. notifications/migrations/0004_create_projection_checkpoints.sql#L5 - notifications too
  26. repository/gorm_checkpoint.go#L41 - quoted "offset" upsert
  27. repository/gorm_accumulators.go#L20 - Upsert moves the ceiling, never consumption
  28. handler/check.go#L88 - #1544 self-check override
  29. projection/consumer.go#L69 - Run loop: log-and-skip, always advance
  30. projection/handlers.go#L30 - handlePolicyIssued bootstrap fetch (log-and-skip at L38-L42)
  31. claims/internal/service/claim.go#L922 - applyAccumulators: safe to call on every path to APPROVED
  32. handler/handler.go#L68 - the /internal route block
  33. handler/handler.go#L50 - the JWT-guarded member routes
  34. handler/handler.go#L63 - scheme-ownership-gated utilisation route
  35. handler/utilisation.go#L22 - per-member entitlement summed to the scheme denominator
  36. handler/internal.go#L31 - the triage agent's VPC-only accumulators read
  37. repository/gorm_accumulators.go#L100 - ListApplications, "the basis for reversing it"

Live-schema facts (constraint list, Debezium publication, type/element-type counts, the 9-of-5 / 26-of-5 / 240-of-250 rows, checkpoint row) come from PGPASSWORD=… psql -h 10.0.1.2 -U olly -d eligibility · \d eligibility.member_coverage, \d eligibility.coverage_accumulators, \d eligibility.accumulator_applications, \d eligibility.projection_checkpoints, 2026-08-18.

Olly Health Insurance Platform