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
DRAFTorPRICED(re-pricing is idempotent) and moves it toPRICED. - Underwrite accepts only
PRICEDand moves it toUNDERWRITTEN. - Issue accepts
PRICEDorUNDERWRITTEN: underwriting is not a mandatory step. A quote can be priced and issued directly. - Refuse is allowed from
DRAFT,PRICED, orUNDERWRITTEN, and setsDECLINED. - Discard is allowed only from
DRAFT. A priced or underwritten quote that should not proceed must be refused, not discarded. EXPIREDis an enum value backed by thequotes.expires_atcolumn; 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.
| Transition | Route | New policy status | Transaction category |
|---|---|---|---|
| Cancel | PATCH /policies/{l}/cancel | CANCELLED | CANCELLATION (APPLIED) |
| Reinstate | PATCH /policies/{l}/reinstate | ACTIVE | REINSTATEMENT (APPLIED) |
| Renew | PATCH /policies/{l}/renew | (unchanged) | RENEWAL (DRAFT) + new term |
| Endorse | PATCH /policies/{l}/endorse | (unchanged) | ENDORSEMENT (DRAFT) |
| Lapse (internal) | PATCH /internal/policies/{l}/lapse | LAPSED | (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
| Method | Path | Notes |
|---|---|---|
POST | /quotes | create; 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}/price | rule-gate → PRICED |
PATCH | /quotes/{locator}/underwrite | rule-gate → UNDERWRITTEN |
PATCH | /quotes/{locator}/issue | → ACCEPTED, creates policy (201) |
PATCH | /quotes/{locator}/refuse | → DECLINED |
PATCH | /quotes/{locator}/discard | → DISCARDED (DRAFT only) |
POST | /quotes/{locator}/number/generate | returns 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
| Method | Path | Notes |
|---|---|---|
GET | /policies?accountId=… (or ?brokerLocator=…), /policies/list | paginated list |
GET | /policies/{locator} | single policy |
PATCH | /policies/{locator}/{cancel|reinstate|renew|endorse} | lifecycle transitions (above) |
GET | /policies/{locator}/{terms|elements|transactions}/list | child collections |
POST | /policies/{locator}/number/generate | returns the locator as the policy number |
Transactions, terms, field values
| Method | Path | Notes |
|---|---|---|
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/list | term + its elements |
PATCH GET | /{quotes|policies|transactions}/{locator}/static | set / get flexible field values |
Internal (service-to-service, no JWT)
| Method | Path | Consumer | Purpose |
|---|---|---|---|
GET | /internal/policies/{locator} | Claims, Eligibility | returns {policy, activeElements} |
GET | /internal/policies/{locator}/terms/{termLocator} | Claims, Eligibility | one term |
PATCH | /internal/policies/{locator}/lapse | Billing | mark 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
eventType | Emitted when | State subjects |
|---|---|---|
quote.created | D2C quote created | quote |
quote.calculated | pricing computed a premium, quote moved to PRICED (fires on every re-price) | quote |
premium.rated | same transaction as quote.calculated, analytics-facing sibling; also on transaction pricing (Price) | quote |
underwriting.decided | underwriting outcome recorded during pricing | quote |
identity.verified / sanctions.screened / fraud.assessed / affordability.checked | one event per external check returning during pricing (#1675) | quote |
quote.updated | member declaration mutated on a DRAFT quote | quote |
quote.accepted | quote issued into a policy | quote, policy |
quote.declined | underwriting refusal at pricing, or explicit Refuse | quote |
quote.discarded | quote discarded | quote |
review.approved / review.rejected | a manual review decision on a REVIEW-flagged check | quote |
There is no quote.expired event: expiry is enforced as an issuance-time refusal (ErrQuoteExpired), no event fires.
Policy and element lifecycle
eventType | Emitted when | State subjects |
|---|---|---|
policy.issued | policy row written (D2C Issue or Flow-0 IssueInternal) | policy |
policy.activated | cover goes on risk: at issue when inception is now, else by the activation job | policy |
scheme.member_enrolled | Flow-0 policy links a party to a scheme; keyed on the scheme locator | policy |
policy.cancelled | cancel (ACTIVE or cooling-off PENDING), or an applied CANCELLATION transaction | policy, transaction |
policy.reinstated | reinstate from CANCELLED/LAPSED, or an applied REINSTATEMENT transaction | policy, transaction |
policy.lapsed | internal lapse of an ACTIVE policy (ops surface, race-safe conditional update) | policy |
policy.renewal_drafted | Renew created the next term + DRAFT RENEWAL transaction (intent, not fact) | policy, transaction |
policy.renewed | a RENEWAL transaction is applied (never at draft time, #1783 §4) | policy, transaction |
policy.endorsement_drafted | Endorse created a DRAFT ENDORSEMENT transaction (intent, not fact) | policy, transaction |
policy.endorsed | coverage upgrade applied (UpgradeCoverage, rich payload with elements + coverageTerms), or an applied ENDORSEMENT transaction (slim payload) | policy, transaction |
element.added | add-on elected at issue, element added via the internal element-change surface, or re-versioned on apply | element (+policy/+transaction per site) |
element.updated | element re-versioned with changed content: upgrade, or applied ENDORSEMENT/RENEWAL | element (+policy/+transaction per site) |
element.removed | element 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
| Table | Purpose |
|---|---|
quotes | quote root (UUID id, locator, account_id, product_version_id, status, document, expires_at) |
quote_field_values | flexible quote attributes (one row per field definition) |
policies | issued policies (account_id, quote_id, product_version_id, status, inception_date, jurisdiction, region, broker_locator, document) |
policy_terms | coverage periods (term_number, effective_from/to both NOT NULL, status) |
policy_transactions | issuance + all in-force change (category, status, term_id, document) |
policy_elements | covered parties (policy_id, transaction_id, static_id, element_type, status, party_id, data, coverage_terms) |
policy_field_values, transaction_field_values | flexible attributes on policies / transactions |
underwriting_flags | reviewer flags on a quote (flag_type APPROVE/BLOCK/DECLINE) |
quote_events | append-only quote-event table; defined but unused (no live write path) |
outbox | transactional outbox for Kafka publishing |
Invariants
- Quote transitions are forward-only:
PRICEDdoes not return toDRAFT.DECLINEDandDISCARDEDare terminal. - Issuance writes quote→
ACCEPTED, policy, first term, issuance transaction, and one outbox row in a single DB transaction. - A policy's
product_version_idis captured at quote time and never changes for that policy. policy_elements.static_idis indexed and is the stable handle a party keeps across versions; element rows are versioned bystatus(ACTIVE/REMOVED) andeffective_from/effective_to, not deleted in place.- A policy term is current when
status = 'ACTIVE'(selection is by status, not by a nulleffective_to; both term dates are NOT NULL). policies.quote_idis 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_appliedare not part of the model. - No Temporal. Underwriting is a synchronous in-process ruleengine evaluation.
TEMPORAL_ADDRESSappears in.env.examplebut 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_eventshas 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.issuedand friends). - Does not auto-expire quotes or auto-lapse policies on a schedule (lapse is an explicit internal call).
