Skip to content
Updated Jul 12, 2026

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.activated Kafka 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

TablePurpose
member_coverageCoverage records per member/policy/term with effective dates and network tier
coverage_accumulatorsRunning totals for deductible, OOP max, and other benefit limits per term
projection_checkpointsKafka consumer offset tracking for the enrollment projection consumer

API Routes

MethodPathAuthDescription
GET/checkJWTCheck eligibility by query parameters
POST/checkJWTCheck eligibility with a request body
GET/coverage/listJWTList all coverage records (web admin)
GET/coverage/{locator}JWTGet a coverage record by locator
GET/members/{locator}/coverageJWTGet active coverage for a member
GET/members/{locator}/coverage/listJWTList all coverage records for a member
GET/members/{locator}/accumulatorsJWTGet accumulator totals for a member
GET/members/{locator}/accumulators/listJWTList all accumulator records for a member
GET/schemes/{locator}/utilisationJWT + scheme ownershipAggregate utilisation per service across a scheme's members
GET/internal/members/{locator}/coverageInternalFetch coverage for Claims/Care (no JWT)
PATCH/internal/members/{locator}/accumulators/{termLocator}/applyInternalApply 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:

json
{
  "schemeLocator": "SCH-2026-000001",
  "items": [
    {"service": "gp_video", "label": "GP video consultations", "unit": "sessions",
     "used": 3, "limit": 12, "unlimited": false, "members": 2}
  ]
}
  • label and unit come from the module catalogue carried on each member's coverage terms, so the portal shows the product's display names ("GP video consultations", not gp_video), the same labels the quote page and policy schedule use.
  • limit is summed across covered members; members counts them; used sums scheme consumption per coverage term key.
  • A module with no included_per_year and no annual_limit reports unlimited: true with limit: 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.

eventTypeEmitted whenState subjects
eligibility.coverage.verifieda point-of-care /check finds ACTIVE coverage on the service datecoverage, accumulators
eligibility.coverage.not_founda /check finds no active coverage on the service datenone (no coverage entity in scope)
eligibility.coverage.changedthe policy-lifecycle projection flips a member ACTIVE/INACTIVE (cancel, lapse, reinstate, activation); one event per affected member per flipcoverage
eligibility.accumulators.applieda 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.reseta renewal opens a new term and re-creates the member's accumulators at zero consumption; one event per membernone (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:

eventTypeAction taken
policy.issuedbootstraps coverage rows and accumulators via HTTP fetch from enrollment; a PENDING policy projects INACTIVE coverage
policy.activatedflips coverage ACTIVE at cover start; publishes eligibility.coverage.changed
element.added / element.updated / element.removedper-element coverage row and accumulator upkeep
policy.endorsedre-projects the endorsed elements
policy.cancelled / policy.lapsedflips the policy's coverage INACTIVE; publishes eligibility.coverage.changed per affected member
policy.reinstatedflips coverage back ACTIVE; publishes eligibility.coverage.changed
policy.renewedterminates 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

ServiceHow used
EnrollmentHTTP client used for bootstrap coverage fetch during read-model reconstruction
Group SchemeResolves 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.

Olly Health Insurance Platform