Skip to content
Updated Jun 9, 2026

Enrollment

Enrollment is the quote-to-policy factory. It opens a Quote, advances it through pricing and underwriting gates, and on issuance creates a Policy with one PolicyTerm, an issuance PolicyTransaction, and the covered PolicyElement rows. All in-force change (cancellation, reinstatement, renewal, endorsement) is modelled as a further PolicyTransaction. Owned by the enrollment service (Go, port 4003, container olly-enrollment).

Field reference: full columns, types and nullability live in the catalog: glossary terms Quote, Policy, PolicyTerm, PolicyTransaction, PolicyElement, QuoteFieldValue, UnderwritingFlag. This page is the narrative.

Identifiers are human-readable prefixed-sequence locators (QTE-2026-000123, POL-…, TRM-…, TXN-…, ELM-…), stored in a locator TEXT NOT NULL UNIQUE column. The UUID id is the internal primary key; the locator is the external handle every other service uses.

The quote lifecycle

QuoteStatus = DRAFT · PRICED · UNDERWRITTEN · ACCEPTED · DECLINED · EXPIRED · DISCARDED (from packages/go/domain/enums.go). There is no RATED state and no rate step: pricing computes nothing monetary, it is a rule-gate (see below). The terminal success state is ACCEPTED (set at issuance), and the adverse state is DECLINED (set by refuse). DISCARDED is for an abandoned draft.

  • Price accepts a quote in DRAFT or PRICED (re-pricing is idempotent) and moves it to PRICED.
  • Underwrite accepts only PRICED and moves it to UNDERWRITTEN.
  • Issue accepts PRICED or UNDERWRITTEN: underwriting is not a mandatory step. A quote can be priced and issued directly.
  • Refuse is allowed from DRAFT, PRICED, or UNDERWRITTEN, and sets DECLINED.
  • Discard is allowed only from DRAFT. A priced or underwritten quote that should not proceed must be refused, not discarded.
  • EXPIRED is an enum value backed by the quotes.expires_at column; the service does not yet auto-expire quotes.

Pricing and underwriting are rule-gates, not calculators

No premium is computed or stored on a quote

price and underwrite do not produce a PMPM, an annual premium, or a factors_applied audit. They fetch the product version's pricing / underwriting ruleset from Policy Admin and evaluate it through the embedded ruleengine against the quote's document JSON. A DENY effect, or an UNDERWRITING_FLAG effect with flag BLOCK, returns a rule-deny error (HTTP 422); otherwise the status advances. The quote has no pricing columns. A standalone rating engine exists on an unmerged worktree but is not wired into enrollment.

UnderwritingFlag.flag_type is one of APPROVE | BLOCK | DECLINE (reviewer-applied, via the underwritingFlags/{approve,block,decline} routes). underwrite returns the quote (status UNDERWRITTEN) or a 422 on a rule DENY/BLOCK; there is no decision payload.

Issuance and the policy lifecycle

Issuance is one DB transaction: the quote flips to ACCEPTED, and a Policy (status ACTIVE), a first PolicyTerm (term_number 1, one-year span, status ACTIVE), an ISSUANCE PolicyTransaction (status APPLIED), and one outbox row are written together. Required field-definition values are validated first (see Flexible fields). jurisdiction is read from the quote document (UNKNOWN if absent). The product_version_id is carried from the quote unchanged: the issued policy reflects the product version chosen at quote time.

PolicyStatus = ACTIVE · LAPSED · CANCELLED · EXPIRED. There is no TERMINATED.

TransitionRouteNew policy statusTransaction category
CancelPATCH /policies/{l}/cancelCANCELLEDCANCELLATION (APPLIED)
ReinstatePATCH /policies/{l}/reinstateACTIVEREINSTATEMENT (APPLIED)
RenewPATCH /policies/{l}/renew(unchanged)RENEWAL (DRAFT) + new term
EndorsePATCH /policies/{l}/endorse(unchanged)ENDORSEMENT (DRAFT)
Lapse (internal)PATCH /internal/policies/{l}/lapseLAPSED(none)

Cancel, reinstate, and lapse act on ACTIVE (cancel/lapse) or CANCELLED/LAPSED (reinstate) policies and update status immediately. Renew and endorse create a transaction in DRAFT and do not mutate policy status; the change is staged on the transaction and worked through its own lifecycle.

Transaction lifecycle

A PolicyTransaction has its own status: DRAFT · PRICED · UNDERWRITTEN · APPLIED · DECLINED · DISCARDED · REVERSED, driven by PATCH /transactions/{l}/{price,underwrite,apply,decline,discard,reverse}. Issuance/cancel/reinstate transactions are created already APPLIED; renewal/endorsement transactions start DRAFT and are advanced to APPLIED via apply (which also upserts the transaction's elements). category is one of ISSUANCE | ENDORSEMENT | CANCELLATION | REINSTATEMENT | RENEWAL | REVERSAL. The change set lives in the transaction's document JSONB, not in dedicated columns.

API routes

All routes are mounted at the service root. The /internal/* group has no JWT (service-to-service, network-scoped); every other route requires a JWT.

Quotes

MethodPathNotes
POST/quotescreate; body {accountId, productVersionId, document} → 201 DRAFT quote
GET/quotes/list?accountId=…paginated by account
GET/quotes/{locator}single quote
PATCH/quotes/{locator}replace document
PATCH/quotes/{locator}/pricerule-gate → PRICED
PATCH/quotes/{locator}/underwriterule-gate → UNDERWRITTEN
PATCH/quotes/{locator}/issueACCEPTED, creates policy (201)
PATCH/quotes/{locator}/refuseDECLINED
PATCH/quotes/{locator}/discardDISCARDED (DRAFT only)
POST/quotes/{locator}/number/generatereturns the locator as the quote number
PUT PATCH DELETE GET/quotes/{locator}/elements[/…]element upsert / delete / list
POST DELETE/quotes/{locator}/underwritingFlags/{approve|block|decline}[/{flag}]add / remove an underwriting flag

Policies

MethodPathNotes
GET/policies?accountId=… (or ?brokerLocator=…), /policies/listpaginated list
GET/policies/{locator}single policy
PATCH/policies/{locator}/{cancel|reinstate|renew|endorse}lifecycle transitions (above)
GET/policies/{locator}/{terms|elements|transactions}/listchild collections
POST/policies/{locator}/number/generatereturns the locator as the policy number

Transactions, terms, field values

MethodPathNotes
GET PATCH/transactions/{locator}get / replace document
PATCH/transactions/{locator}/{price|underwrite|apply|decline|discard|reverse}transaction lifecycle
PUT PATCH DELETE/transactions/{locator}/elements[/{element}]transaction-scoped element edits
GET/terms/{locator}, /terms/{locator}/elements/listterm + its elements
PATCH GET/{quotes|policies|transactions}/{locator}/staticset / get flexible field values

Internal (service-to-service, no JWT)

MethodPathConsumerPurpose
GET/internal/policies/{locator}Claims, Eligibilityreturns {policy, activeElements}
GET/internal/policies/{locator}/terms/{termLocator}Claims, Eligibilityone term
PATCH/internal/policies/{locator}/lapseBillingmark LAPSED on non-payment

There is no party-scoped point-in-time coverage endpoint (/internal/policies?party_locator=…&effective_on=…); callers resolve coverage from the policy + its active elements.

Enroll / QLE / COBRA (stubs)

POST /enroll, PUT /enroll/{id}, POST /enroll/{id}/qle, POST /cobra/elect are registered but not implemented: they echo a generated id or return 404/ELECTED with no persistence. They are placeholders, not a working enrollment surface.

Events

Enrollment produces only via a transactional outbox; it runs no Kafka consumer. Every emit is EnqueueState into enrollment.outbox in the same transaction as the state change; the in-process worker (internal/outbox/worker.go) builds the canonical platform envelope (see the Kafka Event Catalog) with the outbox row id as eventId, stamps correlationId/causationId and the client lineage (sessionId/activityId/activityName) from the Enqueue-time trace context, attaches the row's state snapshot, and publishes to the topic recorded on the row: enrollment.events for every emit site. (KAFKA_TOPIC, default enrollment-events, survives only as the producer's fallback for rows with no topic; no current emit site relies on it.)

State subjects: each event freezes its complete subject entities at emit time under the envelope's state key ({"quote": …}, {"policy": …, "transaction": …}), built by the stateOf* helpers in internal/service/state.go.

Quote lifecycle

eventTypeEmitted whenState subjects
quote.createdD2C quote createdquote
quote.calculatedpricing computed a premium, quote moved to PRICED (fires on every re-price)quote
premium.ratedsame transaction as quote.calculated, analytics-facing sibling; also on transaction pricing (Price)quote
underwriting.decidedunderwriting outcome recorded during pricingquote
identity.verified / sanctions.screened / fraud.assessed / affordability.checkedone event per external check returning during pricing (#1675)quote
quote.updatedmember declaration mutated on a DRAFT quotequote
quote.acceptedquote issued into a policyquote, policy
quote.declinedunderwriting refusal at pricing, or explicit Refusequote
quote.discardedquote discardedquote
review.approved / review.rejecteda manual review decision on a REVIEW-flagged checkquote

There is no quote.expired event: expiry is enforced as an issuance-time refusal (ErrQuoteExpired), no event fires.

Policy and element lifecycle

eventTypeEmitted whenState subjects
policy.issuedpolicy row written (D2C Issue or Flow-0 IssueInternal)policy
policy.activatedcover goes on risk: at issue when inception is now, else by the activation jobpolicy
scheme.member_enrolledFlow-0 policy links a party to a scheme; keyed on the scheme locatorpolicy
policy.cancelledcancel (ACTIVE or cooling-off PENDING), or an applied CANCELLATION transactionpolicy, transaction
policy.reinstatedreinstate from CANCELLED/LAPSED, or an applied REINSTATEMENT transactionpolicy, transaction
policy.lapsedinternal lapse of an ACTIVE policy (ops surface, race-safe conditional update)policy
policy.renewal_draftedRenew created the next term + DRAFT RENEWAL transaction (intent, not fact)policy, transaction
policy.reneweda RENEWAL transaction is applied (never at draft time, #1783 §4)policy, transaction
policy.endorsement_draftedEndorse created a DRAFT ENDORSEMENT transaction (intent, not fact)policy, transaction
policy.endorsedcoverage upgrade applied (UpgradeCoverage, rich payload with elements + coverageTerms), or an applied ENDORSEMENT transaction (slim payload)policy, transaction
element.addedadd-on elected at issue, element added via the internal element-change surface, or re-versioned on applyelement (+policy/+transaction per site)
element.updatedelement re-versioned with changed content: upgrade, or applied ENDORSEMENT/RENEWALelement (+policy/+transaction per site)
element.removedelement removed from cover (REMOVED successor version, same staticID)element (+policy/+transaction per site)

Contracts (payload schema, required state subjects, lineage, producers/consumers as code refs, golden examples) live in the event registry, one directory per type under packages/go/domain/eventregistry/registry/.

Flexible fields

Product attributes are not schema columns. A QuoteFieldValue (and the policy/transaction equivalents) stores one value per field, keyed by quote_id + field_definition_id (UUID FK to a Policy Admin FieldDefinition), with value held as TEXT and typed by the definition's field_type (STRING | NUMBER | BOOLEAN | DATE | ENUM). Unique on (quote_id, field_definition_id). Adding a product attribute means inserting a FieldDefinition in Policy Admin: no enrollment migration. At issuance, every required field definition for the product version must have a value, or issuance fails with a missing-fields error.

Policy Admin dependency: HTTP + TTL cache

Enrollment reads product versions, rulesets, and field definitions from Policy Admin over HTTP on demand (no event subscription, no startup bulk-load). The ruleset client caches each (productLocator, version, name) result with a 5-minute TTL; an expired entry triggers a refetch. The HTTP client timeout is 10s. Staleness is bounded by the TTL, not invalidated by Kafka events.

Owned objects

TablePurpose
quotesquote root (UUID id, locator, account_id, product_version_id, status, document, expires_at)
quote_field_valuesflexible quote attributes (one row per field definition)
policiesissued policies (account_id, quote_id, product_version_id, status, inception_date, jurisdiction, region, broker_locator, document)
policy_termscoverage periods (term_number, effective_from/to both NOT NULL, status)
policy_transactionsissuance + all in-force change (category, status, term_id, document)
policy_elementscovered parties (policy_id, transaction_id, static_id, element_type, status, party_id, data, coverage_terms)
policy_field_values, transaction_field_valuesflexible attributes on policies / transactions
underwriting_flagsreviewer flags on a quote (flag_type APPROVE/BLOCK/DECLINE)
quote_eventsappend-only quote-event table; defined but unused (no live write path)
outboxtransactional outbox for Kafka publishing

Invariants

  • Quote transitions are forward-only: PRICED does not return to DRAFT. DECLINED and DISCARDED are terminal.
  • Issuance writes quote→ACCEPTED, policy, first term, issuance transaction, and one outbox row in a single DB transaction.
  • A policy's product_version_id is captured at quote time and never changes for that policy.
  • policy_elements.static_id is indexed and is the stable handle a party keeps across versions; element rows are versioned by status (ACTIVE/REMOVED) and effective_from/effective_to, not deleted in place.
  • A policy term is current when status = 'ACTIVE' (selection is by status, not by a null effective_to; both term dates are NOT NULL).
  • policies.quote_id is the provenance link to the originating quote.

Caveats

  • No rating engine. Pricing is a rule-evaluation gate; nothing monetary is stored on a quote. PMPM / premium / factors_applied are not part of the model.
  • No Temporal. Underwriting is a synchronous in-process ruleengine evaluation. TEMPORAL_ADDRESS appears in .env.example but no workflow code exists.
  • No event consumer. Enrollment only produces. Policy Admin config reaches it via on-demand HTTP + a TTL cache, not events.
  • Statuses are stored as free TEXT. The enums above are enforced in app code (packages/go/domain), not by DB CHECK constraints.
  • Enroll / QLE / COBRA are stubs, and quote_events has no live writer. Treat both as scaffolding, not working features.

Non-goals

  • Does not own product definitions or rulesets (Policy Admin does).
  • Does not adjudicate claims (Claims does, against the issued policy and its elements).
  • Does not bill (Billing reacts to policy.issued and friends).
  • Does not auto-expire quotes or auto-lapse policies on a schedule (lapse is an explicit internal call).

Olly Health Insurance Platform