Eligibility
Owns member coverage records and accumulator tracking that is the authoritative source for "is this member covered and how much have they spent?"
Overview
The Eligibility service projects and caches member coverage from enrollment events, providing fast lookups for other services that need to verify whether a member has active coverage and what their current cost-sharing position is.
When a policy is activated in Enrollment, Eligibility consumes the enrollment.policy.activated Kafka event and creates or updates MemberCoverage records. These records reflect the member's plan, effective dates, and network tier. Accumulators track running totals for deductibles, out-of-pocket maximums, and other benefit limits against each coverage term.
Because coverage is a projection of enrollment state, Eligibility can also bootstrap from Enrollment directly via HTTP if it needs to reconstruct its read model. In normal operation all updates flow through Kafka.
Responsibilities
- Project member coverage records from
enrollment.policy.activatedKafka events - Expose point-in-time eligibility checks for Claims, Care, and other services
- Track accumulator balances (sessions or money consumed) per coverage term
- Provide internal endpoints for Claims to apply accumulator amounts when a claim line is approved
- Aggregate scheme-level utilisation for the employer portal, gated on scheme ownership
- Support administrative coverage lookups for the web admin panel
Database
Schema: eligibility
| Table | Purpose |
|---|---|
member_coverage | Coverage records per member/policy/term with effective dates and network tier |
coverage_accumulators | Running totals for deductible, OOP max, and other benefit limits per term |
projection_checkpoints | Kafka consumer offset tracking for the enrollment projection consumer |
API Routes
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /check | JWT | Check eligibility by query parameters |
POST | /check | JWT | Check eligibility with a request body |
GET | /coverage/list | JWT | List all coverage records (web admin) |
GET | /coverage/{locator} | JWT | Get a coverage record by locator |
GET | /members/{locator}/coverage | JWT | Get active coverage for a member |
GET | /members/{locator}/coverage/list | JWT | List all coverage records for a member |
GET | /members/{locator}/accumulators | JWT | Get accumulator totals for a member |
GET | /members/{locator}/accumulators/list | JWT | List all accumulator records for a member |
GET | /schemes/{locator}/utilisation | JWT + scheme ownership | Aggregate utilisation per service across a scheme's members |
GET | /internal/members/{locator}/coverage | Internal | Fetch coverage for Claims/Care (no JWT) |
PATCH | /internal/members/{locator}/accumulators/{termLocator}/apply | Internal | Apply approved claim-line amounts to accumulators (idempotent) |
/internal/* paths are blocked at the APISIX edge (404) and are reachable only in-cluster.
Scheme utilisation
GET /eligibility/schemes/{locator}/utilisation powers the employer portal's Plan and Insights views:
{
"schemeLocator": "SCH-2026-000001",
"items": [
{"service": "gp_video", "label": "GP video consultations", "unit": "sessions",
"used": 3, "limit": 12, "unlimited": false, "members": 2}
]
}labelandunitcome from the module catalogue carried on each member's coverage terms, so the portal shows the product's display names ("GP video consultations", notgp_video), the same labels the quote page and policy schedule use.limitis summed across covered members;memberscounts them;usedsums scheme consumption per coverage term key.- A module with no
included_per_yearand noannual_limitreportsunlimited: truewithlimit: 0.
Tenancy: the route is wrapped in requireSchemeOwnership. The caller's org_locator JWT claim is resolved to its owned schemes via group-scheme-service; a scheme the caller does not own returns 404 (identical to absence, so sequential SCH- locators are not an existence oracle). A caller with no org_locator and no admin role gets 403; if the scheme resolver is down the check fails closed with 503. Admin roles (admin, mcp:operator) bypass; employer-admin deliberately does not.
Accumulator application
Claims applies consumption synchronously via PATCH /internal/members/{locator}/accumulators/{termLocator}/apply, called when a claim line is approved (the wired auto-approve path fires on line submission, and again defensively on review/approve). Application is idempotent per claim line: each apply inserts a ledger row into eligibility.accumulator_applications with ON CONFLICT (claim_locator, claim_line_id) DO NOTHING, and only a fresh insert updates consumed_amount, in the same transaction. A replayed apply is a no-op. Session-limited benefits consume quantity; monetary benefits consume amount. The response reports per-line {applied, consumed, reason} so a silent zero-consume is visible to the caller.
Events
Publishes
All events publish DIRECTLY to topic eligibility.events (no outbox; every emit is fire-and-forget, so a publish failure never fails the check, the apply, or the projection), keyed by the member's party locator. Each message is the canonical envelope (see the Kafka Event Catalog): eventId, eventType, occurredAt, payload, and state, the subject entities frozen at emit time (event-carried state). The eligibility envelope carries no correlationId or sessionId fields, so lineage cannot be required on these types; trace context rides in the Kafka message headers instead.
eventType | Emitted when | State subjects |
|---|---|---|
eligibility.coverage.verified | a point-of-care /check finds ACTIVE coverage on the service date | coverage, accumulators |
eligibility.coverage.not_found | a /check finds no active coverage on the service date | none (no coverage entity in scope) |
eligibility.coverage.changed | the policy-lifecycle projection flips a member ACTIVE/INACTIVE (cancel, lapse, reinstate, activation); one event per affected member per flip | coverage |
eligibility.accumulators.applied | a claim line actually consumes an accumulator (fresh ledger insert, never an idempotent replay) | none (the repository Apply returns only a bool, so the post-apply row is not in scope; recorded gap) |
eligibility.accumulators.reset | a renewal opens a new term and re-creates the member's accumulators at zero consumption; one event per member | none (rows are created inside createAccumulators, which returns only an error; recorded gap) |
Consumes
The projection consumer reads the enrollment stream (topics enrollment-events,enrollment.events, consumer group eligibility-projection) and maintains the coverage read model:
eventType | Action taken |
|---|---|
policy.issued | bootstraps coverage rows and accumulators via HTTP fetch from enrollment; a PENDING policy projects INACTIVE coverage |
policy.activated | flips coverage ACTIVE at cover start; publishes eligibility.coverage.changed |
element.added / element.updated / element.removed | per-element coverage row and accumulator upkeep |
policy.endorsed | re-projects the endorsed elements |
policy.cancelled / policy.lapsed | flips the policy's coverage INACTIVE; publishes eligibility.coverage.changed per affected member |
policy.reinstated | flips coverage back ACTIVE; publishes eligibility.coverage.changed |
policy.renewed | terminates old-term rows, creates new-term ACTIVE coverage with zeroed accumulators; publishes eligibility.accumulators.reset |
Per-type contracts (payload JSON Schema, state subjects, lineage requirements, producers/consumers as code refs, golden examples) live in the Event Registry under packages/go/domain/eventregistry/registry/<eventType>/.
Dependencies
| Service | How used |
|---|---|
| Enrollment | HTTP client used for bootstrap coverage fetch during read-model reconstruction |
| Group Scheme | Resolves the caller's owned schemes for the utilisation tenancy check (fail-closed) |
Key Design Decisions
Read model / projection pattern: Eligibility is a pure read-side projection of Enrollment state. It never writes back to Enrollment. This allows Claims and Care to query coverage at very low latency without touching the Enrollment database.
Internal accumulator application: When Claims approves a claim line, it calls PATCH /internal/members/{locator}/accumulators/{termLocator}/apply to update running totals atomically and idempotently (see Accumulator application). This endpoint is not JWT-protected; the APISIX edge returns 404 for all /eligibility/internal/* paths, so it is reachable only in-cluster. Unlike claims' internal routes, it does not yet carry an in-service X-Internal-Service guard.
Member-keyed routes are not tenant-scoped: /check and the /members/{locator}/... routes serve the member app, whose tokens carry no org claim to scope by; only the scheme utilisation route enforces employer tenancy today.
