Skip to content
Updated Aug 13, 2026

Kafka Event Catalog

All asynchronous service-to-service communication in Olly flows through Kafka topics. Each service writes domain events to a transactional outbox table in the same Postgres transaction as its state change; a background outbox worker (packages/go/middleware/outboxpoll) polls the outbox and publishes to Kafka. Kafka runs as a single broker in docker-compose on dev-2 with replication.factor=1 (there is no MSK/Strimzi/GKE cluster today - see the target-state note below).

Event Envelope

Every published message is a uniform camelCase JSON envelope (canonical type: packages/go/domain/event_envelope.go), produced by the outbox workers (services/*/internal/outbox/worker.go) or, for the DB-free services (identity, policy-admin, eligibility, notifications), stamped directly by the producer:

json
{
  "eventId": "uuid",
  "eventType": "quote.calculated",
  "occurredAt": "2026-08-29T00:50:12Z",
  "partyLocator": "PTY-2026-001797",
  "correlationId": "ec26dfca…",
  "causationId": "…",
  "sessionId": "88e482f4-…",
  "activityId": "e949fd74-…",
  "activityName": "price",
  "schemaVersion": 1,
  "payload": { },
  "state":   { "quote": { } }
}
  • eventId - the outbox row UUID; consumers with a processed_events inbox table (billing, eligibility) dedupe on it durably, the rest opportunistically.
  • eventType - a dot-separated verb string, e.g. quote.calculated, policy.issued. Every type is defined in the event registry.
  • The lineage tree answers four nested audit questions: sessionId (which client session - the browser tab / device / smoke walk, carried as W3C baggage from the frontend) ⊃ activityId/activityName (which named journey step, the parent-span analogue) ⊃ correlationId (which request/trace) ⊃ causationId (which exact event caused this one, e.g. the source event id on every notification.dispatched).
  • payload - what happened: the business delta, field shapes governed per type by the registry schema.
  • state - event-carried state: the complete subject entity (or named entities, {"quote": …, "policy": …}) frozen at emit time. Locators are immutable pointers into mutable rows; state is what the entity looked like when the event fired. Outbox services persist it in the outbox row's state jsonb column, so the DB keeps the audit copy. Snapshots are sanitized (no Stripe client secrets, no OTP credential material, no PDF bytes).
  • schemaVersion - the registry contract version the event was written under; absent means v1 (every pre-versioning event is retroactively v1). Old events are validated against the contract of their own version (kept in the registry's priorVersions with an upcast note).
  • Message key is a business locator, chosen per type (domain.MessageKeyOf: policy → party → invoice → document, quote locator for quote events); it is documented per type in the registry.

Schema governance (as-built)

The event registry - packages/go/domain/eventregistry, rendered as the Event Registry catalog - defines every event type: payload JSON Schema, required state subjects, lineage contract, versioning, producers and consumers as code refs, and golden examples that are its test cases. It is enforced at three levels:

  1. CI - a generic test validates every golden (valid-* must pass, invalid-* must fail) and lints routing/docs.
  2. e2e - tests/e2e/registry_compliance_test.go prices a real quote and judges every resulting wire event; an unregistered type or contract violation fails the suite.
  3. Runtime - the debugger service validates every event it ingests and attaches a contract verdict (registered/valid/schemaVersion/problems/warnings), so contract drift is visible on every live smoke walk.

🚧 Target-state - not yet built

IDL codegen (buf over api/proto/olly/*/v1/, generating Go/TS/Python payload types and buf breaking in CI) remains planned (#1766). When it lands, generated schemas feed the same registry layout; the enforcement machinery above stays.

Live Event Catalogue (as-built)

Grounded in the events actually flowing to BigQuery olly_analytics.events (verified 2026-08-12). The per-domain JSON examples further down this page are older/aspirational; this table is the running set. Kept in sync with the CDC coverage tracker (OllyInsurance/olly#1625).

Every event scales with one of a few base units - the "common denominator": a member, a policy, a claim, an invoice, or a care episode. Multiply the base count by the ratio to forecast volume at any scale. Current base counts: ~2,600 members, 1,370 policies, 1,069 claims, 1,117 invoices, 143 care episodes.

The 34-event core semantic contract below now fires end-to-end (proven by the live e2e suite tests/e2e/*_events_test.go, 34/34, 0 skips), alongside the wider onboarding funnel and lifecycle events, for 65+ distinct event types in total. Grouped by domain, with each event's emit site as path:line (the producer call at the real state transition), validated against the as-built code by a 13-service sweep. These are plain references, not links, on purpose: the producers are deployed and firing but their source is currently uncommitted working-tree state on dev-1 (94 dirty service files), not in any git commit, on origin/main, or on origin/dev2-deploy. So there is no commit a GitHub permalink could resolve against yet; open the path:line in the dev-1 /root/olly checkout to verify. Committing this source (and landing it on main) is tracked in the CDC coverage tracker (#1625).

Policy & enrollment (base: policy)

EventWhat it meansScales withEmit site (path:line)
policy.issuedA new policy went live for a member1 per policyservices/enrollment/internal/service/policy.go:632
policy.lapsedA policy ended (missed payments)~0.8 per policy over its lifeservices/enrollment/internal/service/policy.go:726
policy.cancelledA policy was cancelled before term endrareservices/enrollment/internal/service/policy.go:169
policy.endorsedA policy amended mid-termrareservices/enrollment/internal/service/policy.go:347
policy.renewedA policy renewed for a new term1 per policy per yearservices/enrollment/internal/service/policy.go:299
policy.reinstatedA lapsed/cancelled policy was brought backrareservices/enrollment/internal/service/policy.go:216
element.added / element.updated / element.removedA single coverage line changedrareservices/enrollment/internal/service/transaction.go:259
scheme.member_enrolledA member was enrolled into an employer scheme1 per scheme memberservices/enrollment/internal/service/policy.go:647

Quote & rating (base: quote priced)

EventWhat it meansScales withEmit site (path:line)
quote.calculated / premium.ratedA quote was priced / the rating engine computed a premium1 per pricingservices/enrollment/internal/service/quote.go:191 / services/enrollment/internal/service/quote.go:207
quote.accepted / quote.declinedA prospect accepted / declined a quote1 per quote outcomeservices/enrollment/internal/service/quote.go:506 / services/enrollment/internal/service/quote.go:405

Claims & prior-auth (base: claim)

EventWhat it meansScales withEmit site (path:line)
claim.submittedA member filed a claim1 per claimservices/claims/internal/service/claim.go:146
claim.approved / claim.rejectedAdjudication outcome1 per adjudicated claimservices/claims/internal/service/claim.go:277 / services/claims/internal/service/claim.go:498
claim.review_requiredA claim routed to manual reviewhigh-value / flagged subsetservices/claims/internal/service/claim.go:277
claim.info_requested / claim.closedInfo requested / claim closedper claim lifecycleservices/claims/internal/service/claim.go:537 / services/claims/internal/service/claim.go:574
claim.payment_initiated / claim.paidReimbursement initiated / paidapproved subsetservices/claims/internal/service/claim.go:631 / services/claims/internal/service/claim.go:640
prior_auth.submitted / prior-auth.decided / prior_auth.escalatedPre-approval requested / decided / escalated to clinical review~1 per 10 claimsservices/claims/internal/service/priorauth.go:71 / services/claims/internal/service/priorauth.go:136 / services/claims/internal/service/priorauth.go:103

Billing & payments (base: invoice / payment)

EventWhat it meansScales withEmit site (path:line)
invoice.finalised / invoice.paidA bill was issued / paid in full~1 per policy per cycleservices/billing/internal/service/billing.go:119 / services/billing/internal/service/billing.go:223
invoice.overdue / invoice.delinquent / invoice.lapsedDunning escalation on an unpaid billoverdue subsetservices/billing/internal/job/lapse.go:193 / services/billing/internal/job/delinquency.go:207 / services/billing/internal/job/delinquency.go:177
invoice.voidA bill was voidedrareservices/billing/internal/service/billing.go:529
payment.received / payment.settledMoney received / a funnel payment settled (Stripe)1 per paymentservices/billing/internal/service/billing.go:231 / services/billing/internal/service/onboarding.go:273
payment.voided / payment.refundedA payment reversed / refundedrareservices/billing/internal/service/billing.go:299 / services/billing/internal/service/billing.go:309
charge.created / charge.voidedA charge line created / voidedper chargeservices/billing/internal/service/onboarding.go:214 / services/billing/internal/service/billing.go:484
adjustment.created / adjustment.applied / adjustment.reversedBill correction (credit/debit) raised / applied / reversedrareservices/billing/internal/service/billing.go:583 / services/billing/internal/service/billing.go:373 / services/billing/internal/service/billing.go:436
account.suspendedBilling account suspended for non-payment (account.created is a policy-admin event, below)rareservices/billing/internal/job/delinquency.go:190
installment.created / installment.cancelledA payment schedule was set up / cancelledper installment planservices/billing/internal/service/installment.go:116 / services/billing/internal/service/installment.go:162

Eligibility (base: care episode / claim) - point-of-care, transient (CDC-invisible)

EventWhat it meansScales withEmit site (path:line)
eligibility.coverage.verified / eligibility.coverage.not_foundCover checked at point of care~1+ per episodeservices/eligibility/internal/handler/check.go:194
eligibility.coverage.changedCoverage transitioned active/inactiveper policy lifecycleservices/eligibility/internal/projection/handlers.go:312
eligibility.accumulators.applied / eligibility.accumulators.resetDeductible/limit consumed / reset at renewalper claim / per renewalservices/eligibility/internal/handler/internal.go:225 / services/eligibility/internal/projection/handlers.go:562

Provider & credentialing (base: member / provider)

EventWhat it meansScales withEmit site (path:line)
provider.searchedA member searched the provider directory (behavioural)many per memberservices/provider/internal/handler/providers.go:203
provider.created / provider.activated / provider.deactivatedProvider registered / (de)activated in the network1 per provider transitionservices/provider/internal/handler/providers.go:140 / services/provider/internal/handler/providers.go:287 / services/provider/internal/handler/providers.go:311
provider.reviewedA member reviewed a providerper reviewservices/provider/internal/handler/reviews.go:111
credentialing.submitted / credentialing.approved / credentialing.rejectedProvider-network onboarding lifecycle1 per credentialing requestservices/provider/internal/handler/credentialing.go:98 / services/provider/internal/handler/credentialing.go:175 / services/provider/internal/handler/credentialing.go:223

Employer / group scheme (base: scheme / member)

EventWhat it meansScales withEmit site (path:line)
scheme.created / scheme.updatedAn employer scheme created / amended1 per schemeservices/group-scheme-service/internal/service/scheme.go:128 / services/group-scheme-service/internal/service/scheme.go:183
member.added / member.removedA member added to / removed from a scheme1 per membership changeservices/group-scheme-service/internal/service/scheme.go:215 / services/group-scheme-service/internal/service/scheme.go:240 (emits scheme.member_removed)
member.dispatched / member.activatedGift-box dispatched / member activatedper onboarding memberservices/group-scheme-service/internal/service/scheme.go:299
bulk_enrollment.completed / bulk_enrollment.failedA bulk-enrol job finished / failed1 per bulk jobservices/group-scheme-service/internal/service/bulk_enrollment.go:288

Broker (base: broker / policy)

EventWhat it meansScales withEmit site (path:line)
broker.appointedA broker was appointed on a scheme1 per appointmentservices/broker-api/internal/service/authority.go:56
broker.authority.breached / broker.authority.updatedPolicy issued above delegated authority / authority limit changedrareservices/broker-api/internal/consumer/projector.go:128 (via projector) / services/broker-api/internal/service/authority.go:74
commission.earned / commission.paidCommission accrued / paid out1 per commissioned policyservices/broker-api/internal/service/commission.go:117 / services/broker-api/internal/service/commission.go:208

Care (base: care episode)

EventWhat it meansScales withEmit site (path:line)
care.episode.opened / care.episode.closed / care.episode.cancelledA care journey started / ended / cancelled1 per episodeservices/care/internal/service/service.go:131 / services/care/internal/service/service.go:206 / services/care/internal/service/service.go:235
care.appointment.confirmed / care.appointment.attended / care.appointment.cancelled / care.appointment.no_showAppointment lifecycle~1 per episodeservices/care/internal/service/service.go:555 / services/care/internal/service/service.go:592 / services/care/internal/service/service.go:646 / services/care/internal/service/service.go:662
care.prescription.issued / care.prescription.filled / care.prescription.voidedPrescription lifecycleper prescriptionservices/care/internal/service/service.go:766 / services/care/internal/service/service.go:808 / services/care/internal/service/service.go:826
care.referral.created / care.referral.completed / care.referral.cancelledDiagnostics/secondary-care referral lifecycleper referralservices/care/internal/service/service.go:866 / services/care/internal/service/service.go:926 / services/care/internal/service/service.go:944

Consent (base: member)

EventWhat it meansScales withEmit site (path:line)
consent.changed / consent.granted / consent.withdrawnA data consent changed / granted / withdrawn~1 per memberservices/consent/internal/handler/consent.go:138 / services/consent/internal/handler/consent.go:155 / services/consent/internal/handler/consent.go:166
consent.deletion.requested / deletion.completed / deletion.failedRight-to-erasure request lifecyclerareservices/consent/internal/handler/deletion.go:69 / services/consent/internal/job/erasure.go:191 / services/consent/internal/job/erasure.go:156

Documents (base: invoice / member)

EventWhat it meansScales withEmit site (path:line)
document.ready / document.reissuedA PDF (invoice/letter/schedule) generated / regenerated~0.7 per invoiceservices/document-service/internal/service/document.go:188
document.generation_failedDocument generation failedrareservices/document-service/internal/service/document.go:226
document.downloadedA document was downloaded (audit)per accessservices/document-service/internal/service/document.go:280

Member & onboarding (base: member)

EventWhat it meansScales withEmit site (path:line)
member.party_createdA member/party record first created1 per memberservices/policy-admin/internal/service/party.go:94
party.updatedA party record was modifiedper editservices/policy-admin/internal/service/party.go:177
product.published / account.createdA product version published / an account opened (policy-admin)per config changeservices/policy-admin/internal/service/product.go:275 / services/policy-admin/internal/service/account.go:70
onboarding.*Onboarding funnel (identity is DB-free, so the event stream IS the record): account_created, otp_sent/verified, magic_link_sent/redeemed, pin_issued/validated, password_set, login_success, plus google/slack import, secondary-email, waitlist and employer-activation branches~6+ per onboarding memberservices/identity/internal/handler/member_onboarding.go:77 (~25-type funnel)

Triage (clinical) (base: triage session)

EventWhat it meansScales withEmit site (path:line)
triage.session.started / triage.session.completedA triage session opened / reached a terminal state1 per sessionservices/triage/src/triage/v5_narrow.py:785 / services/triage/src/triage/chat_completions.py:716
triage.disposition.reachedA clinical outcome (care type + disposition) was determined1 per completed sessionservices/triage/src/triage/chat_completions.py:707
triage.red_flag.identifiedA clinical red-flag category was raised (safety signal)per flagged turnservices/triage/src/triage/rf_sidecar.py:701
triage.pathway.selected / triage.summary.createdA pathway was committed / a clinician summary writtenper sessionservices/triage/src/triage/v5_narrow.py:832 / services/triage/src/triage/summary_store.py:201
fhir.record.createdA clinical FHIR record pushed to the GCP Healthcare store~1 per media uploadservices/triage/src/triage/v5_media_api.py:575

Coverage note

The two-layer model: named semantic events (above, olly_analytics.events) are the business-event contract; the raw log-based CDC layer (olly_cdc) independently captures every row change of every entity as a completeness safety net. The remaining gap is a small tail of empty child tables + low-value operational signals, tracked on the CDC coverage tracker (#1625).

Event surface: needed vs emitted (the gap)

A 13-domain code audit put the complete member-journey event surface at 136 events. Status (2026-08-13, after the semantic-event build-out):

StatusCountMeaning
Firing live65+ distinct typesObserved in olly_analytics.events - the whole core contract across claims, policy, billing, eligibility, provider, care, consent, broker, triage and onboarding
Built this pass48The previously-absent named events (14 P0 + 25 P1 + 9 P2), now emitting at their real state transitions
Absentsmall tailMostly empty child tables + low-value operational signals; the P0/P1/P2 core-journey gaps are closed

The full 34-event core semantic contract now fires end-to-end, proven by a live e2e suite (tests/e2e/*_events_test.go) that drives each real transition and asserts the event lands on its topic - 34/34, 0 skips.

Two layers - be precise about "moved to BigQuery": the raw log-based CDC layer (olly_cdc) moves every state change of every entity to BigQuery (every policy update/renewal, every new policy/product creation, verified live). The named semantic events sit on top - the meaningful business signals (policy.renewed, commission.earned, triage.disposition.reached), not raw diffs.

P0 core-journey events - all now BUILT + firing (were the original gaps):

DomainP0 events (now live)
Eligibilityeligibility.coverage.verified/not_found/changed, eligibility.accumulators.applied
Providerprovider.created/activated, credentialing.submitted/approved/rejected
Triagetriage.session.started/completed, triage.disposition.reached, triage.red_flag.identified, fhir.record.created
Employer (group scheme)scheme.created, member.added/activated/dispatched, bulk_enrollment.completed/failed
Brokercommission.earned/paid, broker.appointed, broker.authority.breached
Member onboardingmember.party_created, onboarding.* (full funnel)
Claims / Billingclaim.rejected/paid/closed/review_required, prior_auth.decided/escalated, payment.settled/voided, invoice.overdue/delinquent/lapsed, account.suspended

The full breakdown (built / firing / remaining tail, per domain, with priorities) is maintained on the CDC coverage tracker (OllyInsurance/olly#1625).

Core Insurance Journeys - the Kafka event contract

Principle. The core insurance journeys are first-class, well-defined Kafka events - a stable semantic contract every service and the warehouse relies on. Raw log-based CDC (olly_cdc) is the completeness safety-net (it captures every row change so nothing is ever lost); it is not the contract for core journeys. If a step is a core-journey milestone, it MUST be an explicit event - never inferred from a CDC row diff.

Each journey is an ordered event chain. Status: [live] fires today (built this pass or already emitting), [build] still absent (no distinct state transition in code yet).

1. Quote → Buyquote.created [build] → quote.calculated [live] → premium.rated [live] → quote.underwritten [build] → quote.accepted [live] → policy.issued [live] → policy.activated [build]

2. Member onboardingmember.registered [live] → member.email_verified [live] → member.party_created [live] → onboarding.* funnel [live] → consent.granted [live]

3. Health & care (the clinical loop)triage.session.started [live] → triage.disposition.reached [live] → eligibility.coverage.verified [live] → care.episode.opened [live] → care.appointment.confirmed [live] → care.appointment.attended [live] → fhir.record.created [live] → care.episode.closed [live]

4. Claim → reimbursementclaim.submitted [live] → prior-auth.decided [live] → claim.approved [live] / claim.rejected [live] → claim.paid [live]

5. Billing & paymentsinvoice.finalised [live] → payment.attempted [build] → payment.received [live] / payment.settled [live] → (dunning) invoice.overdue/invoice.delinquent [live] → policy.lapsed [live]

6. Policy lifecycle & renewaltransaction.created [build] → transaction.priced [build] → transaction.underwritten [build] → policy.renewed [live] / policy.endorsed [live] / policy.cancelled [live] → policy.reinstated [live]

7. Employer onboardingscheme.created [live] → bulk_enrollment.started [build] → bulk_enrollment.completed [live] → member.activated [live]

8. Provider networkcredentialing.submitted [live] → credentialing.approved [live] / credentialing.rejected [live] → provider.activated [live]

9. Brokerbroker.appointed [live] → commission.earned [live] → commission.paid [live]

What remains. The [build] markers above are the small tail: pre-issue quote/transaction lifecycle events (quote.created, quote.underwritten, transaction.*), payment.attempted (decline capture), and a couple of enum-only states (policy.activated, bulk_enrollment.started) with no distinct transition in code yet. All the P0 core-journey milestones (clinical loop, employer onboarding, provider network, broker money, member onboarding) now fire; CDC (olly_cdc) stays as the safety-net underneath for everything.

Topic Naming Convention

Topics are one per service, named <service>.events:

claims.events    eligibility.events    enrollment.events
billing.events   provider.events       care.events    consent.events

Every event type a service emits (e.g. CLAIM_SUBMITTED, INVOICE_GENERATED) is written to that service's single <service>.events topic; the eventType envelope field discriminates them. Topic names use dot-separated lowercase segments only.

Historical note

The 3-segment {domain}.{entity}.{event} subject names (e.g. claims.claim.submitted) used elsewhere on this page are legacy/aspirational and do not match the running topology, which is per-service <service>.events. Treat any 3-segment topic name below as the logical event class, not the physical Kafka topic.

Consumer group naming: groups follow {service}-{domain}-consumer, e.g. billing-claims-consumer, notifications-enrollment-consumer, eligibility-enrollment-consumer. Consumers maintain durable per-partition projection checkpoints (CQRS-lite read models).


Default Topic Configuration

Unless noted otherwise, topics are created with these defaults:

ParameterDefaultNotes
replication.factor1Single broker in docker-compose (dev-2). No MSK/multi-broker cluster today.
retention.ms604800000 (7 days)Sufficient for consumer lag recovery
compression.typelz4Good ratio/speed balance
max.message.bytes1048576 (1 MB)
cleanup.policydeleteLog compaction not used; events are time-bounded
partitions6

🚧 Target-state - not yet built

Multi-broker durability (replication.factor=3, min.insync.replicas=2, acks=all), per-topic partition tuning, and 12-partition/14-day high-volume topics are all planned for a managed-Kafka production target. None are provisioned today (infra/terraform and infra/k8s contain only .gitkeep).


Claims Domain

TopicPartitionsRetentionProducerConsumers
claims.claim.submitted1214 daysClaims ServiceBilling Service, Notifications Service
claims.claim.adjudicated1214 daysClaims ServiceBilling Service, Notifications Service, EDI (Mirth Connect)
claims.claim.paid67 daysBilling Service (primary), Claims Service (audit mirror)Notifications Service, OpenSearch
claims.prior_auth.decision67 daysClaims ServiceNotifications Service, Eligibility Service
claims.appeal.resolved67 daysClaims ServiceNotifications Service, Billing Service

claims.claim.submitted

Event types: CLAIM_SUBMITTED

json
{
  "eventType": "CLAIM_SUBMITTED",
  "claimId": "uuid",
  "memberId": "uuid",
  "providerId": "uuid",
  "planId": "uuid",
  "claimType": "PROFESSIONAL",
  "totalBilledAmount": "250.00",
  "serviceDate": "2026-01-15",
  "submittedAt": "2026-01-16T10:00:00Z",
  "requiresPriorAuth": true,
  "priorAuthId": "uuid-or-null"
}

claims.claim.paid

Event types: CLAIM_PAID

Producer: Billing Service. Consumers: Notifications Service, OpenSearch.

json
{
  "event_type": "CLAIM_PAID",
  "claim_locator": "CLM-789",
  "party_locator": "PTY-456",
  "policy_locator": "POL-123",
  "amount_paid": 2750.50,
  "payment_method": "EFT",
  "remittance_advice_ref": "edi-835-2026-06-08-00004",
  "paid_at": "2026-06-08T15:00:00Z"
}

claims.prior_auth.decision

Event types: PRIOR_AUTH_DECISION

Producer: Claims Service. Consumers: Notifications Service, Eligibility Service.

json
{
  "event_type": "PRIOR_AUTH_DECISION",
  "prior_auth_locator": "PA-321",
  "claim_locator": null,
  "party_locator": "PTY-456",
  "decision": "APPROVED",
  "reason_code": null,
  "approved_units": 12,
  "expires_at": "2026-07-08T00:00:00Z",
  "decided_at": "2026-06-08T13:30:00Z",
  "decided_by": "underwriting-rule-engine"
}

decision enum: APPROVED / DENIED / NEEDS_INFO. claim_locator is null when the authorisation is pre-claim.

claims.appeal.resolved

Event types: APPEAL_RESOLVED

Producer: Claims Service. Consumers: Notifications Service, Billing Service.

json
{
  "event_type": "APPEAL_RESOLVED",
  "appeal_locator": "APL-654",
  "claim_locator": "CLM-789",
  "party_locator": "PTY-456",
  "outcome": "OVERTURNED",
  "new_amount_paid": 2950.00,
  "reviewer": "claims-appeals-team",
  "resolved_at": "2026-06-08T16:00:00Z"
}

outcome enum: OVERTURNED / UPHELD / WITHDRAWN. new_amount_paid is null unless outcome is OVERTURNED.

claims.claim.adjudicated

Event types: CLAIM_APPROVED, CLAIM_DENIED

json
{
  "eventType": "CLAIM_APPROVED",
  "claimId": "uuid",
  "memberId": "uuid",
  "providerId": "uuid",
  "status": "APPROVED",
  "totalBilledAmount": "250.00",
  "allowedAmount": "180.00",
  "paidAmount": "144.00",
  "memberResponsibility": "36.00",
  "deductibleApplied": "0.00",
  "copayApplied": "36.00",
  "coinsuranceApplied": "0.00",
  "adjudicatedAt": "2026-01-17T14:30:00Z",
  "eobS3Key": "eob/2026/01/uuid.pdf"
}
json
{
  "eventType": "CLAIM_DENIED",
  "claimId": "uuid",
  "memberId": "uuid",
  "providerId": "uuid",
  "denialReasonCode": "CO-4",
  "denialReasonText": "Service requires prior authorization",
  "appealDeadline": "2026-02-17",
  "adjudicatedAt": "2026-01-17T14:30:00Z"
}

Eligibility Domain

TopicPartitionsRetentionProducerConsumers
eligibility.coverage.verified67 daysEligibility ServiceClaims Service
eligibility.coverage.terminated67 daysEligibility ServiceClaims Service, Notifications Service

eligibility.coverage.verified

Event types: COVERAGE_VERIFIED, COVERAGE_NOT_FOUND

json
{
  "eventType": "COVERAGE_VERIFIED",
  "memberId": "uuid",
  "planId": "uuid",
  "groupNumber": "GRP-00123",
  "memberNumber": "MEM-456789",
  "effectiveDate": "2026-01-01",
  "terminationDate": null,
  "coverageType": "MEDICAL",
  "networkTier": "IN_NETWORK",
  "deductibleRemaining": "1500.00",
  "oopMaxRemaining": "4000.00",
  "verifiedAt": "2026-01-16T10:01:00Z"
}

COVERAGE_NOT_FOUND variant - emitted when no active policy exists for the member on the requested service date:

json
{
  "event_type": "COVERAGE_NOT_FOUND",
  "verified_at": "2026-06-08T13:42:00Z",
  "party_locator": "PTY-456",
  "service_date": "2026-06-08",
  "lookup_reason": "no_active_policy",
  "deductible_remaining": null,
  "oop_max_remaining": null,
  "policy_locator": null
}

lookup_reason values: no_active_policy, policy_lapsed, member_not_found.


Enrollment Domain

TopicPartitionsRetentionProducerConsumers
enrollment.enrollment.submitted67 daysEnrollment ServiceEligibility Service, Billing Service, Notifications Service
enrollment.enrollment.activated67 daysEnrollment ServiceEligibility Service, Billing Service, Notifications Service
enrollment.enrollment.terminated67 daysEnrollment ServiceEligibility Service, Billing Service, Notifications Service
enrollment.enrollment.plan_changed67 daysEnrollment ServiceEligibility Service, Billing Service, Notifications Service
enrollment.cobra.notice_sent67 daysEnrollment ServiceNotifications Service
enrollment.cobra.elected67 daysEnrollment ServiceEligibility Service, Billing Service, Notifications Service

enrollment.enrollment.submitted

Event types: ENROLLMENT_SUBMITTED

json
{
  "eventType": "ENROLLMENT_SUBMITTED",
  "enrollmentId": "uuid",
  "memberId": "uuid",
  "groupId": "uuid-or-null",
  "planId": "uuid",
  "coverageType": "MEDICAL",
  "electionType": "NEW",
  "effectiveDate": "2026-01-01",
  "tierCode": "EE+FAM",
  "memberPremium": "480.00",
  "totalPremium": "1200.00",
  "dependentCount": 2,
  "submittedAt": "2025-11-15T10:00:00Z"
}

enrollment.enrollment.activated

Event types: ENROLLMENT_ACTIVATED

Producer: Enrollment Service. Consumers: Eligibility Service, Billing Service, Notifications Service.

json
{
  "event_type": "ENROLLMENT_ACTIVATED",
  "policy_locator": "POL-123",
  "policy_term_locator": "PT-001",
  "account_locator": "ACC-789",
  "party_locators": ["PTY-456", "PTY-457"],
  "effective_from": "2026-07-01",
  "effective_to": "2027-06-30",
  "premium_annual": 4800.00,
  "activated_at": "2026-06-08T14:00:00Z"
}

enrollment.enrollment.terminated

Event types: ENROLLMENT_TERMINATED

Producer: Enrollment Service. Consumers: Eligibility Service, Billing Service, Notifications Service.

json
{
  "event_type": "ENROLLMENT_TERMINATED",
  "policy_locator": "POL-123",
  "policy_term_locator": "PT-001",
  "party_locators": ["PTY-456"],
  "termination_reason": "MEMBER_REQUEST",
  "effective_date": "2026-09-01",
  "terminated_at": "2026-06-08T16:00:00Z"
}

termination_reason enum: MEMBER_REQUEST / NON_PAYMENT / EMPLOYER_TERMINATION / END_OF_TERM.

enrollment.enrollment.plan_changed

Event types: PLAN_CHANGED

Producer: Enrollment Service. Consumers: Eligibility Service, Billing Service, Notifications Service.

json
{
  "event_type": "PLAN_CHANGED",
  "policy_locator": "POL-123",
  "previous_product_version_locator": "PV-456",
  "new_product_version_locator": "PV-457",
  "effective_date": "2026-07-01",
  "premium_delta_annual": 600.00,
  "changed_at": "2026-06-08T16:30:00Z",
  "changed_by": "admin-portal-user"
}

enrollment.cobra.notice_sent

Event types: COBRA_NOTICE_SENT

Producer: Enrollment Service. Consumer: Notifications Service.

json
{
  "event_type": "COBRA_NOTICE_SENT",
  "policy_locator": "POL-123",
  "party_locator": "PTY-456",
  "qualifying_event": "TERMINATION_OF_EMPLOYMENT",
  "election_deadline": "2026-09-01",
  "monthly_premium": 480.00,
  "notice_sent_at": "2026-06-08T17:00:00Z"
}

enrollment.cobra.elected

Event types: COBRA_ELECTED

Producer: Enrollment Service. Consumers: Eligibility Service, Billing Service, Notifications Service.

json
{
  "event_type": "COBRA_ELECTED",
  "policy_locator": "POL-123",
  "party_locator": "PTY-456",
  "cobra_coverage_start": "2026-09-01",
  "cobra_coverage_end": "2027-08-31",
  "first_premium_due": "2026-09-15",
  "elected_at": "2026-06-08T18:00:00Z"
}

Billing Domain

TopicPartitionsRetentionProducerConsumers
billing.invoice.generated1214 daysBilling ServiceNotifications Service
billing.payment.received67 daysBilling ServiceEnrollment Service, Notifications Service
billing.payment.missed67 daysBilling ServiceEnrollment Service, Notifications Service
billing.payment.completed67 daysBilling ServiceClaims Service, Notifications Service

billing.invoice.generated

Event types: INVOICE_GENERATED

Producer: Billing Service. Consumer: Notifications Service.

json
{
  "event_type": "INVOICE_GENERATED",
  "invoice_locator": "INV-2026-000123",
  "account_locator": "ACC-789",
  "policy_term_locator": "PT-001",
  "total_amount": 400.00,
  "currency": "GBP",
  "due_date": "2026-07-01",
  "generated_at": "2026-06-08T14:00:00Z"
}

billing.payment.received

Event types: PAYMENT_RECEIVED

Producer: Billing Service. Consumers: Enrollment Service, Notifications Service.

json
{
  "event_type": "PAYMENT_RECEIVED",
  "payment_locator": "PAY-456",
  "account_locator": "ACC-789",
  "amount": 400.00,
  "currency": "GBP",
  "method": "DIRECT_DEBIT",
  "external_ref": "GoCardless-PMT-xyz",
  "received_at": "2026-06-08T13:00:00Z"
}

billing.payment.completed

Event types: PAYMENT_COMPLETED

Producer: Billing Service. Consumers: Claims Service, Notifications Service.

json
{
  "event_type": "PAYMENT_COMPLETED",
  "payment_locator": "PAY-456",
  "account_locator": "ACC-789",
  "invoice_locator": "INV-2026-000123",
  "amount": 400.00,
  "currency": "GBP",
  "completed_at": "2026-06-08T13:00:30Z"
}

billing.payment.missed

Event types: PAYMENT_MISSED, GRACE_PERIOD_STARTED, GRACE_PERIOD_EXPIRED

json
{
  "eventType": "GRACE_PERIOD_STARTED",
  "memberId": "uuid",
  "invoiceId": "uuid",
  "gracePeriodEndDate": "2026-03-30",
  "premiumCents": 48000,
  "missedAt": "2026-02-28T23:59:59Z"
}

Provider Domain

TopicPartitionsRetentionProducerConsumers
provider.credentialing.status_changed67 daysProvider ServiceClaims Service, Eligibility Service, Notifications Service
provider.network.updated67 daysProvider ServiceClaims Service, Eligibility Service

provider.network.updated

Event types: NETWORK_UPDATED

Producer: Provider Service (Temporal credentialing workflow). Consumers: Claims Service, Eligibility Service.

json
{
  "event_type": "NETWORK_UPDATED",
  "provider_locator": "PROV-987654",
  "previous_status": "PENDING",
  "new_status": "ACTIVE",
  "changed_at": "2026-06-08T14:30:00Z",
  "changed_by": "credentialing-temporal-workflow",
  "credentialing_request_locator": "CRED-321"
}

new_status enum: ACTIVE / INACTIVE / SUSPENDED / TERMINATED.

provider.credentialing.status_changed

Event types: CREDENTIALING_APPROVED, CREDENTIALING_DENIED, CREDENTIALING_EXPIRED, CREDENTIALING_SUSPENDED

json
{
  "eventType": "CREDENTIALING_APPROVED",
  "providerId": "uuid",
  "npi": "1234567890",
  "providerName": "Jane Smith MD",
  "specialty": "Orthopedic Surgery",
  "credentialingStatus": "APPROVED",
  "networkStatus": "IN_NETWORK",
  "effectiveDate": "2026-02-01",
  "approvedAt": "2026-01-28T10:00:00Z"
}

EDI Domain

🚧 Target-state - not yet built (and out of market scope)

Olly operates in the United Kingdom (NHS-111 pathways, ICO/UK-GDPR, Companies House, GBP). The X12/EDI transaction set below (837/834/270/271/834/835) is a US clearinghouse operating model carried over from an earlier US plan. None of it is built - there is no EDI ingestion, no Mirth-driven X12 pipeline, and no edi.* topics in the running system. This section is retained only as historical/aspirational reference and should not be treated as current state.

TopicPartitionsRetentionProducerConsumers
edi.inbound.received630 daysEDI (Mirth Connect)Claims Service, Enrollment Service, Eligibility Service
edi.outbound.generated630 daysEDI (Mirth Connect)OpenSearch (audit index)

Event types for inbound: EDI_837_RECEIVED, EDI_834_RECEIVED, EDI_270_RECEIVED Event types for outbound: EDI_835_GENERATED, EDI_834_GENERATED, EDI_271_GENERATED

Raw EDI storage. Raw X12 documents are too large for Kafka payloads. Each event carries a raw_uri pointing to the full document: gs://olly-prod-edi-{inbound,outbound}/ in production (GCS), s3://olly-dev-edi-{inbound,outbound}/ as a dev backup. Consumers that need to inspect the document fetch it by URI using the message_id as the filename stem. The Kafka payload itself is metadata + pointer only.

edi.inbound.received - inbound events

EDI_837_RECEIVED (claim submission batch)

Producer: Mirth Connect. Consumers: Claims Service, Enrollment Service, Eligibility Service.

json
{
  "event_type": "EDI_837_RECEIVED",
  "message_id": "edi-837-2026-06-08-00001",
  "received_at": "2026-06-08T13:42:11Z",
  "submitter_id": "PROV-987654",
  "transaction_count": 24,
  "interchange_control_number": "000001234",
  "raw_uri": "gs://olly-prod-edi-inbound/2026/06/08/edi-837-00001.x12"
}

submitter_id maps to EDI ISA06. transaction_count is the number of individual claims in the batch.

EDI_834_RECEIVED (member enrollment batch)

Producer: Mirth Connect. Consumers: Enrollment Service.

json
{
  "event_type": "EDI_834_RECEIVED",
  "message_id": "edi-834-2026-06-08-00002",
  "received_at": "2026-06-08T13:50:00Z",
  "sponsor_id": "EMP-123",
  "member_count": 1850,
  "interchange_control_number": "000001235",
  "raw_uri": "gs://olly-prod-edi-inbound/2026/06/08/edi-834-00002.x12"
}

sponsor_id maps to EDI ISA08 (employer/group sponsor).

EDI_270_RECEIVED (eligibility inquiry)

Producer: Mirth Connect. Consumer: Eligibility Service.

json
{
  "event_type": "EDI_270_RECEIVED",
  "message_id": "edi-270-2026-06-08-00003",
  "received_at": "2026-06-08T13:52:00Z",
  "submitter_id": "PROV-987654",
  "subscriber_id": "MEM-456",
  "service_date": "2026-06-08",
  "raw_uri": "gs://olly-prod-edi-inbound/2026/06/08/edi-270-00003.x12"
}

edi.outbound.generated - outbound events

EDI_835_GENERATED (payment remittance)

Producer: Mirth Connect (triggered by Billing Service). Consumer: OpenSearch (audit index).

json
{
  "event_type": "EDI_835_GENERATED",
  "message_id": "edi-835-2026-06-08-00004",
  "generated_at": "2026-06-08T15:00:00Z",
  "payer_id": "OLLY-1",
  "payee_id": "PROV-987654",
  "claim_count": 22,
  "total_paid": 42500.75,
  "interchange_control_number": "000005001",
  "raw_uri": "gs://olly-prod-edi-outbound/2026/06/08/edi-835-00004.x12"
}

EDI_834_GENERATED (enrollment confirmation outbound)

Producer: Mirth Connect (triggered by Enrollment Service). Consumer: OpenSearch (audit index).

json
{
  "event_type": "EDI_834_GENERATED",
  "message_id": "edi-834-out-2026-06-08-00005",
  "generated_at": "2026-06-08T16:00:00Z",
  "sponsor_id": "EMP-123",
  "member_count": 1850,
  "interchange_control_number": "000005002",
  "raw_uri": "gs://olly-prod-edi-outbound/2026/06/08/edi-834-00005.x12"
}

EDI_271_GENERATED (eligibility response)

Producer: Mirth Connect (triggered by Eligibility Service). Consumer: OpenSearch (audit index).

json
{
  "event_type": "EDI_271_GENERATED",
  "message_id": "edi-271-2026-06-08-00006",
  "generated_at": "2026-06-08T13:52:05Z",
  "submitter_id": "PROV-987654",
  "subscriber_id": "MEM-456",
  "service_date": "2026-06-08",
  "eligible": true,
  "deductible_remaining": 250.00,
  "oop_max_remaining": 4800.00,
  "raw_uri": "gs://olly-prod-edi-outbound/2026/06/08/edi-271-00006.x12"
}

Schema governance

Current state: there is none. Messages are plain versionless JSON (see Event Envelope). There is no Avro encoding, no wire-format magic prefix, no Confluent/_schemas registry, and no compatibility checking. payload field names are whatever the producer writes; consumers must match casing exactly.

🚧 Target-state - not yet built

Introducing a schema registry with explicit event versioning is a real planned improvement. The motivation is concrete: three production incidents to date were caused by producer/consumer field-casing drift in the unversioned JSON payloads (e.g. policyLocator casing, PascalCase, and snake_case mismatches). A registry with BACKWARD compatibility, a per-topic subject naming strategy, and CI-enforced schema evolution would catch these at publish time. None of this exists yet - do not encode Avro, register subjects, or emit a schemaVersion field in current code.


Delivery, retries and idempotency

Current state. Consumers read from <service>.events and update their local projection (read-model) tables, tracking durable per-partition checkpoints. Delivery semantics today:

  • Producer side. The outbox worker publishes and marks rows published in separate steps. If a service restarts between the Kafka write and the MarkPublished stamp, the row is re-written on the next poll, so a given event can be delivered more than once (at-least-once). The outbox fetch is a plain SELECT ... WHERE published_at IS NULL ORDER BY created_at ASC LIMIT n with no FOR UPDATE SKIP LOCKED - publishing is correct only because exactly one replica runs the worker today.
  • Consumer side. Consumers dedupe opportunistically on the envelope eventId, and idempotent handlers guard against re-application via state-transition checks (ErrInvalidTransition). There is no dedicated inbox/dedup table, no Valkey positional-key guard, and no dead-letter queue: a message that fails to process is retried on redelivery, not routed to a .dlq topic.

🚧 Target-state - not yet built

The following are planned but not implemented today:

  • A dead-letter queue (<topic>.dlq) with bounded retries + backoff and an alerting DLQ consumer.
  • Exactly-once-ish consumer idempotency via a durable inbox table (or Valkey guard) keyed on eventId.
  • Multi-replica-safe publishing via FOR UPDATE SKIP LOCKED on the outbox fetch, so more than one outbox worker can run without double-publishing.

Do not assume any of these exist when reasoning about failure handling in the current build.

Olly Health Insurance Platform