Skip to content
Updated Aug 22, 2026

Quote & Policy

Schema deep-dive · living document · #4 in the reading sequence

Tablesenrollment.quotes, enrollment.policies[1][2]
Owner serviceenrollment (sole writer)
LocatorsQTE-YYYY-NNNNNN (quotes) · POL-YYYY-NNNNNN (policies)
Last updated2026-08-18
CompanionERD story, slide 4 · real policy JSON · previous: Product & catalogue

1. Scope and usage

A Policy is the contract of insurance - the row that says this party holds this cover, under this product version, from this date. A Quote is the priced offer that can bind into one. Together they are the hub of the model: the policy row is where the identity spine (Party), the grouping (Scheme), the catalogue (ProductVersion) and, downstream, eligibility, claims and billing all meet. Every locator radiates from here.

Two issuance lineages share these tables:

  • Quote → bind - the classic chain: create a DRAFT quote, price it, optionally underwrite it, issue it. Issue atomically flips the quote to ACCEPTED and creates the policy with quote_id pinned[8]. Fully coded and exposed on real routes[9]; it activates the day broker or underwritten business arrives. Live issuance has run through Flow-0, so no live policy references a quote yet.
  • Flow-0 direct issue - IssueInternal (#1164): HR adds an employee, the group-scheme composer calls POST /internal/policies/issue, and a member-level policy is created quote-lessly, idempotent on (party, scheme)[7][10]. This is the path every live Flow-0 policy took.

Use the policy when you need what cover exists and for whom. Do not use it for what the cover contains (elements, next page), what has been consumed (eligibility accumulators), or what it costs per month (billing charges).

2. Boundaries and relationships

A Policy is not…That concern lives inJoin
the productpolicy_admin.product_versions; the policy pins one at issue via product_version_id (uuid, cross-service soft ref)[2]uuid soft ref
the content of the coverpolicy_elements snapshot the catalogue into coverage_terms (next page)policy_id FK
the adjudication anchorpolicy_terms - accumulators and claims are term-scoped, not policy-scoped (next page)policy_id FK
a scheme membershipgroup_scheme.scheme_members points at the policy via policy_locator; the roster row exists before the policy does (Scheme & roster)locator, reversed
the payer or the moneybilling.accounts (one per employer, keyed org_locator); policies.account_id is a synthetic per-member UUID, not that account - see §3none that resolves (§9)
what the member is eligible foreligibility.member_coverage + accumulators, a projection built from policy.issued events[15] - never read from these tables at check timeevents

The locator hub. policies carries five locator columns - party_locator, member_locator, scheme_locator, org_locator, broker_locator - all soft references, none FKs (cross-service rule). Live data uses exactly two: party_locator (the policyholder, 495 rows) and scheme_locator (495 rows). member_locator and org_locator are NULL on every row. broker_locator is the column a broker-placed policy fills, and ListByBrokerLocator already serves broker portfolio views over it[6]; the broker identity it names is the piece still to model (§9). Same value space as Party's alias zoo: member_locatorparty_locator ≡ a PTY- value.

3. Structure

DDL[1][2] · Go models[5][4]

quotes

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE QTE-
account_iduuidSoft ref, indexed. Synthetic in live data (see below)
product_version_iduuidThe version being quoted; cross-service soft ref
statustextDefault 'DRAFT'. Convention, no CHECK - vocabulary in §5
documentjsonbQuote document; live rows carry {"jurisdiction": "GB"}
expires_attimestamptzReserved for a quote validity window; NULL on all live rows (§9)
total_premium, premium_currencynumeric(14,4) / textWritten by the rating pass at price time[11]; NULL live, because the catalogue premium is the price source today (Product & catalogue §8)
member_locator, scheme_locatortextLive-only columns: present in the DB, populated on all 17 live quotes, but no migration codifies them at the pinned commit and the pinned Quote model has no such fields[4] - added out-of-band by branch-side Flow-0 work (commit c877e6a7, not on main). §9

policies

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE POL-
account_iduuidNOT NULL but synthetic - UUIDv5 of the party locator (below)
quote_iduuidReal FK to quotes (in-service)[2]. NULL on all 1 399 live rows
product_version_iduuidThe pin: what this cover was sold under
statustextDefault 'ACTIVE'. Convention, no CHECK. Live: ACTIVE (700), LAPSED (699)
inception_datedateContract start
jurisdiction, regiontext✓/-"GB" on live rows; extracted from the quote document on the bind path[8]
documentjsonbThin by design: Flow-0 rows carry only {"flow0": true}[7]
party_locator, scheme_locatortextThe Flow-0 anchors, added by migration 0019[3]; both indexed
member_locator, org_locatortextPresent + indexed in the live DB, NULL on every row, no codifying migration at the pinned commit (§9)
broker_locatortextMigration 0012[12], indexed, NULL everywhere; the column a broker-placed policy fills (§9)
policy_structuretextDefault 'INDIVIDUAL', no CHECK - the vocabulary mismatch, §9
plan_tiertextStringly: live values STANDARD (464), UNLIMITED (3), lowercase standard (28), NULL (904)

Field-by-field: what and why

locator - POL-2026-001500 reads over the phone; the uuid joins fast. Same uuid-inside/locator-outside rule as Party. Minted from per-prefix Postgres sequences created by migration 0021 (locator_seq_pol, _qte, _trm, _txn, _elm, all START 1000)[13] via the shared PGGenerator (ADR-1164-05, #1169); a process-local atomic counter seeded from wall-clock nanos remains as an explicit fallback-with-warning when the DB generator errors[14]. The migration comment is candid that START 1000 exists to keep sequence-minted locators visually distinct from the pre-migration counter-minted rows[13] - which is why live locators cluster around POL-2026-001NNN.

account_id - NOT NULL, and a lie worth understanding. Flow 0 has no Account object, so IssueInternal derives a stable UUIDv5: uuid.NewSHA1(NameSpaceOID, "flow0-account:"+partyLocator)[7] - repeat calls hash to the same value, so the column is deterministic but backed by no row anywhere. Billing's accounts migration opens by naming this exact problem: "invoices.account_id was a UUID with no row behind it - enrollment mints a synthetic per-MEMBER UUIDv5" - and answers it with one real account per employer keyed by org_locator, deliberately not by this column[16]. Verified live: account_id on the worked example equals uuidv5(OID, "flow0-account:PTY-2026-000006") exactly.

product_version_id - the pin, and since the #1164 product-wiring fix, a hard gate: IssueInternal resolves the tier against the catalogue and refuses to issue when no version resolves - "issuing cover that covers nothing is worse than refusing to issue it"[7]. This closed the Flow-0 coverage gap where planTier-only policies were invisible to eligibility. Coverage attaches at enrol (D-07, #1010).

party_locator + scheme_locator - migration 0019's comment records the archaeology: scheme_locator already existed in the live DB (added out-of-band) and 0019 codified it as IF NOT EXISTS[3]. Migration 0020 then finished the flat shape and added the load-bearing index:

idx_policies_flow0_natural - UNIQUE (party_locator, scheme_locator) WHERE both NOT NULL[17]. This partial unique index is Flow-0's idempotency key: one member-level policy per (person, scheme). IssueInternal layers a fast-path pre-check on top, and treats a concurrent 23505 as "the other caller won" and re-fetches[7]. Legacy quote→bind policies leave both columns NULL and are untouched by the index[17]. The HTTP layer adds a third ring: the internal route requires an Idempotency-Key header and replays the first 2xx verbatim[10].

policy_structure - promoted out of the Document JSONB by 0020, default 'INDIVIDUAL', "Flow 0 issues member-level policies only. Group-master rows get 'GROUP_MASTER' when that path lands"[17]. Unconstrained TEXT here, while the product side CHECKs GROUP_MASTER | MEMBER_LEVEL - the two sides of decision #225 speak different vocabularies for the same concept. The top wart; anatomised in §9.

plan_tier - the stringly-typed glue between enrollment, billing's price lookup and the catalogue's tier (Product & catalogue §9). Nullable so pre-#1164 rows need no backfill[17]; nothing normalises case, and a 2026-08-12 test batch proved it by minting 28 lowercase 'standard' rows that a case-sensitive tier lookup would miss.

document - on the bind path this carries the quote's document forward; on Flow-0 it is deliberately a marker ({"flow0": true}) so analytics can find Flow-0 rows - "planTier / structure are first-class columns as of 0020"[7]. The schema-flattening direction: promote what code reads, keep jsonb for provenance.

D2C Quote & Buy: the Document is not thin

D2C Quote & Buy (QnB) additions, grounded in branch feat/1675, pending merge. Citations below are path:line relative to the repo root; they carry no main commit SHA because the code is not yet on main.

The Flow-0 and bind rows keep the Document thin ({"flow0": true} / {"jurisdiction": "GB"}). The D2C (individual, direct-to-consumer) channel is the exception: the same quotes.document jsonb becomes the record of the whole Quote & Buy transaction. A member creates the quote through the member-scoped front door (POST /me/quotes), and buildD2CDocument forces three keys before anything else touches it: channel: "D2C", requires_prepayment: true, and the verified party_locator from the JWT (never the client)[D1]. Price then routes any quote whose Document says channel:"D2C" or requires_prepayment:true to the D2C underwriting + rating path (quoteRequiresPrepaymentpriceD2C)[D2] instead of the scheme rate-table engine.

Pricing stamps the outcome back onto the Document in two passes: mergeUnderwriting writes underwriting + premium[D3], then mergeChecks writes checks + nests premium.optimisation[D4].

BlockWritten byKeysNotes
channel / requires_prepayment / party_locatorbuildD2CDocument (handler) at create[D1]"D2C" / true / PTY-…the three forced keys; party_locator is the JWT claim, and every /me/quotes/* route ownership-checks the caller against it[D1]
declarationthe FE, at create/updateage or dateOfBirth, sexAtBirth, postcode/postcodeArea, coverTier, voluntaryExcess, smoker, heightCm, weightKg, conditions[], addOns[], coverStartthe health & lifestyle questionnaire; ParseDeclaration maps it to the rating input, and needs at least an age or dateOfBirth (else ErrMissingDeclaration)[D5]. declaration.coverStart (YYYY-MM-DD, today-or-later) sets the policy inception at issue[D6]
underwritingmergeUnderwriting[D3]decision, basis, loadings[], exclusions[], factors{}, addOns[]decisionACCEPT/REFER/DECLINE; basis defaults MORATORIUM; factors is the multiplier breakdown kept for audit; addOns[] carries per-add-on {key, annual, priced} (an unrecognised add-on is priced:false, £0, visible not silently free)[D7]
premiummergeUnderwriting (build-up) + mergeChecks (optimisation)[D3][D4]currency, baseAnnual, netAnnual, ipt, grossAnnual, grossMonthly, optimisation{}the rated build-up (IPT 12% on net)[D8]; grossAnnual is what the top-level quotes.total_premium is set to. optimisation is observe-only (applied:false): it records a suggested final price, it does not change the charge[D9]
checksmergeChecks[D4]identity, screening, fraud, affordabilityone uniform envelope per check: {type, outcome, provider, reference, at, detail{}}; fraud adds score, affordability adds band[D9]. Provider is a mock today (mock-kyc / mock-sanctions / …); consumers read checks.<concept>.outcome, never a provider-specific shape, so the contract survives the swap to a real KYC/sanctions vendor. Best-effort: a missing/slow provider leaves its key off

A priced D2C quote's Document, grounded in what the code writes:

json
{
  "channel": "D2C",
  "requires_prepayment": true,
  "party_locator": "PTY-2026-000006",
  "declaration": {
    "dateOfBirth": "1990-04-12",
    "sexAtBirth": "female",
    "postcode": "SW1A 1AA",
    "coverTier": "STANDARD",
    "voluntaryExcess": 250,
    "smoker": false,
    "heightCm": 168,
    "weightKg": 64,
    "conditions": [],
    "addOns": ["dental"],
    "coverStart": "2026-09-01"
  },
  "underwriting": {
    "decision": "ACCEPT",
    "basis": "MORATORIUM",
    "loadings": [],
    "exclusions": [],
    "factors": { "age": 1.0, "region": 1.05, "excess": 0.9 },
    "addOns": [ { "key": "dental", "annual": 120, "priced": true } ]
  },
  "premium": {
    "currency": "GBP",
    "baseAnnual": 234,
    "netAnnual": 341.1,
    "ipt": 40.93,
    "grossAnnual": 382.03,
    "grossMonthly": 31.84,
    "optimisation": {
      "technicalPrice": 382.03, "finalPrice": 379,
      "provider": "mock-pricing", "reference": "opt_7f3c",
      "at": "2026-08-21T09:14:02Z", "applied": false
    }
  },
  "checks": {
    "identity":      { "type": "identity",      "outcome": "VERIFIED", "provider": "mock-kyc",       "reference": "kyc_a1", "at": "2026-08-21T09:14:01Z" },
    "screening":     { "type": "screening",     "outcome": "CLEAR",    "provider": "mock-sanctions", "reference": "scr_b2", "at": "2026-08-21T09:14:01Z", "detail": { "lists": ["OFAC","HMT"], "hits": 0 } },
    "fraud":         { "type": "fraud",         "outcome": "PASS",     "provider": "mock-fraud",     "reference": "frd_c3", "at": "2026-08-21T09:14:01Z", "score": 12 },
    "affordability": { "type": "affordability", "outcome": "BAND_A",   "provider": "mock-credit",    "reference": "enr_d4", "at": "2026-08-21T09:14:01Z", "band": "A" }
  }
}

On issue this whole Document is copied verbatim onto policies.document (policy.Document = q.Document)[D6], so a D2C policy row carries the underwriting basis, premium build-up and check trail, where a Flow-0 policy carries only {"flow0": true}.

4. Invariants

InvariantEnforced by
Both locators uniqueDB unique constraints[1][2]
One member-level policy per (party, scheme)DB partial unique index idx_policies_flow0_natural[17] + 23505 re-fetch[7]
A bound policy's quote existsDB FK policies.quote_id → quotes.id (in-service, so a real FK)[2]
Quote / policy status vocabulariesApplication only - typed constants[18][19] + per-transition guards; no CHECK on either table
Status transitions race-safeApplication: conditional UPDATE … WHERE status = 'ACTIVE' so exactly one concurrent lapse wins[6]
A policy always names a resolvable product versionApplication: ErrProductVersionUnavailable refuses quote-less issue without one[7]; the uuid itself is never verified again after issue
policy_structure vocabularyNothing - free TEXT with a default, while the product side CHECKs a different vocabulary (§9)
party_locator / scheme_locator / broker_locator point at real rowsNothing - cross-service soft refs, application-trusted
Issue emits exactly one policy.issuedApplication: outbox row enqueued in the same DB transaction as the policy[7], drained to Kafka topic enrollment.events by the outbox worker[20]
A prepaid (D2C) quote is not issued until its premium settles (QnB, branch feat/1675)Application, fail-closed: Issue refuses a requires_prepayment/channel:"D2C" quote with ErrPaymentRequired (→402) when no paymentVerifier is wired or billing reports unpaid; issues only when the Stripe webhook has marked it paid, stamping policies.payment_intent_id[D20][D21]. Invoice-billed employer/scheme quotes skip the gate
A priced D2C quote is issuable only within its validity window (QnB)Application: Issue refuses a quote past expires_at with ErrQuoteExpired (→410); the window is 30 days, stamped at price time[D10][D23]
Row changes captured to CDCDebezium publication dbz_enrollment (live \d)

5. Lifecycle

Two state machines, one per table. The quote's is the classic funnel - and acceptance is the moment that matters: "the contract will be in force from the effective date that has been agreed" .

Vocabulary from domain.QuoteStatus[19]; transitions guarded in QuoteService[8] (EXPIRED is declared, reserved for the validity window in §9). Pricing runs the rating pass and the pricing ruleset; a DENY or blocking underwriting flag aborts the transition. Live data holds DRAFT (2), PRICED (2) and ACCEPTED (13) rows only.

Policy vocabulary is ACTIVE | LAPSED | CANCELLED | EXPIRED[18]; live rows are ACTIVE/LAPSED only (EXPIRED not yet written, CANCELLED transient under test churn). Cancel and reinstate write a policy transaction alongside the status flip[6]; lapse flips status only. Every transition enqueues its event (policy.cancelled, policy.reinstated, policy.lapsed) for the eligibility projection to mirror.

How a Flow-0 policy comes to exist: hop 2 of the composition walked on the Party page - the composer calls Enrollment.IssuePolicy with an idempotency key derived from (party, scheme)[21], IssueInternal writes policy + term + element + outbox atomically[7], and the roster row gets the POL- back-filled.

D2C Quote & Buy: state machine, event vocabulary and the payment gate

D2C Quote & Buy (QnB) additions, grounded in branch feat/1675, pending merge. Citations are path:line (no main SHA, code not yet merged); see D2C refs.

The D2C quote walks the same table, one more transition: Price can land in DECLINED when underwriting declines, and a priced quote now carries an expires_at (30-day validity window)[D10] so an abandoned quote lapses rather than being buyable forever.

enrollment.events vocabulary (D2C). Every event is enqueued to the outbox in the same DB transaction as the state change, keyed by the quote (or policy) locator, drained to Kafka topic enrollment.events. Each risk / eligibility outcome is its own domain fact (not a field on quote.calculated), emitted by enrollment (the orchestrator), not by the mock provider, so the contract holds when a real vendor lands[D11]. Every D2C-path event carries partyLocator (resolved from the Document), which lets the notifications / timeline / audit consumers bind to the member.

EventFires whenKey payload fieldsRef
quote.createdCreate opens a DRAFT quote (D2C or employer)quoteLocator, accountId, partyLocator, status:"DRAFT"[D12]
quote.calculatedpriceD2C moves the quote to PRICEDquoteLocator, accountId, partyLocator, status:"PRICED", premiumCurrency, totalPremium, premiumMonthly, underwritingDecision, expiresAt[D13]
premium.ratedthe scheme rate-table price path computes a premium (not the D2C path; that build-up rides quote.calculated)quoteLocator, productVersionId, premiumCurrency, totalPremium[D14]
underwriting.decidedpriceD2C, alongside quote.calculatedquoteLocator, accountId, partyLocator, decision, basis[D15]
identity.verifiedpriceD2C, when the KYC check returnedquoteLocator, accountId, partyLocator, outcome, provider, reference, at[D16]
sanctions.screenedpriceD2C, when the sanctions check returnedas above (screening envelope)[D16]
fraud.assessedpriceD2C, when the fraud check returnedas above (fraud envelope)[D16]
affordability.checkedpriceD2C, when the credit/affordability check returnedas above (affordability envelope)[D16]
quote.declinedpriceD2C DECLINE or Refuse (employer/broker manual/rule decline)D2C: quoteLocator, accountId, partyLocator, status:"DECLINED", declineReason. Refuse: quoteLocator, accountId, productVersionId, previousStatus, status:"DECLINED" (no partyLocator)[D17]
quote.acceptedIssue, as the quote flips to ACCEPTEDquoteLocator, accountId, productVersionId, policyLocator, status:"ACCEPTED"[D18]
policy.issuedIssue, as the policy is createdpolicyLocator, accountID, inceptionDate, partyLocator (added for the D2C notifications consumer)[D18]

Maps checkEventTypes (identityidentity.verified, screeningsanctions.screened, fraudfraud.assessed, affordabilityaffordability.checked) is the one place a check concept is bound to its event[D19]; a check whose provider did not return emits no event.

The payment gate. A D2C quote may not be issued until its premium is settled, or a paid product could be obtained for £0. Issue calls quoteRequiresPrepayment(Document) (true when channel:"D2C" or requires_prepayment:true)[D2], and for those quotes fails closed[D20]:

Condition at issueResultHTTP
requires prepayment, no paymentVerifier wiredErrPaymentRequired402
requires prepayment, verifier says not paidErrPaymentRequired402
requires prepayment, verifier says paidissues; PaymentIntentID stamped on the policy201
invoice-billed (employer/scheme, neither key)gate skipped-
priced quote past expires_atErrQuoteExpired410

The verifier is the billing service: BillingClient.QuotePaymentStatus GETs billing /internal/quotes/{locator}/payment and returns (paid, paymentIntentId); a 404 is read as "not paid", so an unpaid quote fails the gate cleanly rather than erroring[D21]. paid flips true when the Stripe webhook records the settled payment on the billing side; the member's next POST /me/quotes/{locator}/issue then passes the gate, and the settling PaymentIntent id is written to the new policies.payment_intent_id column[D22]. The handler maps ErrPaymentRequired→402 (so the checkout UI can tell "pay first" from a hard error) and ErrQuoteExpired→410 (re-price to continue)[D23].

D2C code references (branch feat/1675, paths relative to repo root; line anchors may drift, the function is the anchor):

  • [D1] services/enrollment/internal/handler/me_quotes.go:100-163 - createMemberQuote + buildD2CDocument force channel/requires_prepayment/party_locator; :80-92 ownsQuote; :28-45 member-scoped routes
  • [D2] services/enrollment/internal/service/quote.go:347-365 - quoteRequiresPrepayment; :510-519 Price routes to priceD2C
  • [D3] services/enrollment/internal/service/quote.go:217-236 - mergeUnderwriting (stamps underwriting + premium)
  • [D4] services/enrollment/internal/service/quote.go:238-266 - mergeChecks (stamps checks, nests premium.optimisation)
  • [D5] services/enrollment/internal/d2crating/parse.go:29-77 - ParseDeclaration; service/quote.go:77-80 + service/errors.go:29 ErrMissingDeclaration
  • [D6] services/enrollment/internal/service/quote.go:268-302 - coverStartFrom; :893-908 inception + policy.Document = q.Document
  • [D7] services/enrollment/internal/d2crating/d2crating.go:97-116 - Outcome + AddOnLine (priced:false for unrecognised add-ons)
  • [D8] services/enrollment/internal/d2crating/d2crating.go:42-95 - Premium build-up, iptRate = 0.12
  • [D9] services/enrollment/internal/integrations/client.go:101-150 - check envelope (type/outcome/provider/reference/at/detail, fraud.score, affordability.band); :124-132 optimisation (applied:false)
  • [D10] services/enrollment/internal/service/quote.go:56-58 - quoteValidity = 30 * 24h; :146,157,534,541 SetExpiry
  • [D11] services/enrollment/internal/service/quote.go:174-209 - per-outcome domain facts emitted by enrollment
  • [D12] services/enrollment/internal/service/quote.go:402-434 - Create emits quote.created
  • [D13] services/enrollment/internal/service/quote.go:160-171 - priceD2C emits quote.calculated
  • [D14] services/enrollment/internal/service/quote.go:559-570 - scheme Price emits premium.rated
  • [D15] services/enrollment/internal/service/quote.go:180-189 - underwriting.decided
  • [D16] services/enrollment/internal/service/quote.go:190-209 - per-check events via checkEventTypes
  • [D17] services/enrollment/internal/service/quote.go:103-120 (priceD2C DECLINE) + :788-808 (Refuse) - quote.declined
  • [D18] services/enrollment/internal/service/quote.go:929-963 - Issue emits quote.accepted + policy.issued (with partyLocator)
  • [D19] services/enrollment/internal/service/quote.go:60-70 - checkEventTypes map
  • [D20] services/enrollment/internal/service/quote.go:869-891 - the payment gate (fail-closed); service/errors.go:20 ErrPaymentRequired, :24 ErrQuoteExpired
  • [D21] services/enrollment/internal/client/billing.go:13-63 - BillingClient.QuotePaymentStatus (404 = not paid); service/quote.go:321-332 PaymentVerifier + SetPaymentVerifier/SetIntegrations
  • [D22] services/enrollment/internal/service/quote.go:855-891 expiry gate + intent capture; packages/go/domain/policies.go:55-58 PaymentIntentID column
  • [D23] services/enrollment/internal/handler/handler.go:199-207 - ErrPaymentRequired→402, ErrQuoteExpired→410; handler/me_quotes.go:217-245 issueMemberQuote

6. Populated example: POL-2026-001500, walked end to end

The worked example of the whole documentation set - the same policy as the real policy JSON, the Party page's member Grant, and the Scheme & roster roster row. As it exists live:

json
{
  "locator": "POL-2026-001500",
  "account_id": "e88be030-df5e-56e4-8238-0ce04db3d4fc",
  "quote_id": null,
  "product_version_id": "446b1e15-6cf2-45e7-80f5-c848d6c6dedb",
  "status": "ACTIVE",
  "inception_date": "2026-08-17",
  "jurisdiction": "GB",
  "region": null,
  "document": { "flow0": true },
  "broker_locator": null,
  "org_locator": null,
  "member_locator": null,
  "scheme_locator": "SCH-2026-000001",
  "party_locator": "PTY-2026-000006",
  "policy_structure": "INDIVIDUAL",
  "plan_tier": "STANDARD",
  "created_at": "2026-08-17T08:33:30Z"
}

Field by field, what each value does:

KeyRead byWhat actually happens
party_locator: PTY-2026-000006eligibility projection, claims, timeline, member portalthe policyholder join. Verified downstream: eligibility.member_coverage carries this exact (policy, party) pair live
scheme_locator: SCH-2026-000001employer surfaces, billing pivotwhich scheme sponsored it; with party_locator, the natural key the partial unique index enforces
product_version_id: 446b1e15…eligibility bootstrap fetch[15], upgrade idempotency checkresolves live to OHC-2026 v2 - the exact version walked on the Product & catalogue page
account_id: e88be030…nothing that resolvesuuidv5(OID, "flow0-account:PTY-2026-000006") - recomputed and matched exactly. No account row behind it (§3)
quote_id: null-Flow-0 issues quote-lessly, so the bind FK is not exercised by these rows; it carries the QTE- pin the day a quote binds
policy_structure: "INDIVIDUAL"nothing at read timenote the contradiction: the pinned product version says GROUP_MASTER. Nothing reconciles the two (§9)
plan_tier: "STANDARD"billing price lookupresolves to £19.50/member/month from the OHC-2026 v2 catalogue
document: {"flow0": true}analyticsthe lineage marker, nothing else

What issuing it caused (all verified live):

  1. One term + one element - TRM-2026-001502 (term 1, 2026-08-17 → 2027-08-17) and ELM-2026-001518 snapshotting the OHC-2026 v2 catalogue into coverage_terms - the next page's worked example.
  2. One policy.issued outbox row - topic enrollment.events, key POL-2026-001500, published; payload carries coverageTerms, planTier, partyLocator, schemeLocator, productVersionId - exactly the shape enqueued at issue[7].
  3. The projection materialised - one eligibility.member_coverage row (PTY-2026-000006 × element static id f6c127ac…) and six accumulators, term-scoped: gp_video 0/5, physio_remote 0/5, mental_health 0/5 sessions, diagnostics £0/£250, plus two unmetered USAGE rows.
  4. The roster pointer - the Scheme & roster example's policyLocator is this row.

And the quote table's live shape, since this policy has no quote: a real ACCEPTED quote (structure exact, one of the 17):

json
{
  "locator": "QTE-2026-000051",
  "status": "ACCEPTED",
  "account_id": "7a9186e7-2d11-49a9-8911-4dbc96e94623",
  "product_version_id": "9b127459-17e8-42a0-b5d5-a35eba8f3ceb",
  "document": { "jurisdiction": "GB" },
  "member_locator": "PTY-2026-000006",
  "scheme_locator": "SCH-2026-000001",
  "total_premium": null,
  "premium_currency": "GBP",
  "expires_at": null,
  "created_at": "2026-06-15T14:03:35Z"
}

Note what it shows: an ACCEPTED quote naming the same member and scheme as the policy above, that no policy row references - the funnel that minted it (June) and the issuance that covered the member (August) are separate lineages. Its product_version_id resolves to GH-2025 v1, not the version the policy was issued under. The quote table is currently a record of funnel activity, not the contractual offer the policy bound.

7. Who references a Policy

Database.tableColumnMeaning there
group_scheme.scheme_memberspolicy_locatorthe cover issued for this roster row (back-filled by the composer)
eligibility.member_coveragepolicy_locator + policy_idthe projection's provenance - what this coverage row was built from
claims.claimspolicy_locator + policy_idthe policy claimed against
documents.documentspolicy_locatorpolicy schedule documents
billing.charges / ledger_entries / installment_schedulespolicy_id (uuid)money joins on the uuid, not the locator
enrollment.policy_terms / _transactions / _elementspolicy_idreal FKs (in-service) - the next page

Quotes are referenced by quote_events, quote_field_values and underwriting_flags (real in-service FKs, all empty live) and by policies.quote_id (not yet populated). No cross-service reference to a QTE- locator exists.

8. Design determinations

  1. Two issuance lineages, one table - Flow-0 issues quote-lessly rather than forcing HR flows through a quote funnel; the bind chain remains for broker/underwritten business. #1164 (#1171 epic context).
  2. Member-level vs group-master is product config, not a policy fork - the policy row carries policy_structure denormalised; the determination lives on the product version. #225 (R-01) under epic #219.
  3. Idempotency by natural key, forward-fix, no compensation - the partial unique index is the source of truth for "already issued"; failure recovery is retry + idempotent replay, not sagas[7]. #1164 / DR #2 as cited in code.
  4. Locators from per-prefix DB sequences - same determination as Party. ADR-1164-05 · #1169.
  5. No coverage without a product version - refuse to issue rather than issue cover that covers nothing; coverage attaches at enrol. D-07 · #1010.
  6. A synthetic uuid ref is an acceptable interim identity - account_id is a documented TODO(#1164); billing built the real employer account alongside it rather than blocking on enrollment growing one[16].
  7. Issue emits, projections listen - eligibility is built from policy.issued + element events, never by reading these tables. (Data architecture)

9. Caveats and extensibility

Group and individual. The fork costs this table almost nothing: an individual (direct-to-consumer) policy is a row with scheme_locator NULL, priced off a MEMBER_LEVEL product version. Every live row is a member-level policy under a group scheme; a future group-master row would be one more row with policy_structure = 'GROUP_MASTER' and elements per insured. The partial unique index already anticipates both: it only binds rows that have both locators.

Where to extend, and where the change lands. The hub was modelled for the full contract lifecycle, so most of what a new distribution channel or product shape needs is already a column, a route or a transaction category here.

When we need …What to addWhere
broker or underwritten businessnothing on the schema: create → price → underwrite → issue is coded and routed[9], and Issue pins quote_id on the policy it creates[8]. underwriting_flags carries the per-quote referrals, voidable via voided_atQuoteService; a caller that drives the funnel
per-product questions on the quote formfield_definition rows on the product version; answers land in quote_field_values, and Issue refuses while a required one is missing[8]POST /products/{locator}/versions/{version}/fields in policy-admin; consumers on Enrollment: dynamic fields & underwriting
quote data we have not promoted to columnskeys in quotes.document jsonb: the rule engine takes the whole document as its evaluation context, so a new key is rule-addressable with no migration, and bind reads jurisdiction straight out of it[8]quotes.document; evaluateQuoteRules / extractJurisdiction
priced quotes to expirestamp expires_at at price time and gate Issue on it; the column is on the table and EXPIRED is already in the status vocabulary[19]enrollment.quotes.expires_at; QuoteService.Price / Issue
direct-to-consumer (individual) covera row with scheme_locator NULL against a MEMBER_LEVEL product version; the partial unique index binds only rows carrying both locators, so it leaves these alone[17]no schema change
a group-master contract with member certificates under ita policy row with policy_structure = 'GROUP_MASTER' and elements per insured; migration 0020's comment reserves exactly this[17]enrollment.policies
broker-placed policiesset broker_locator at issue; the column is indexed[12] and ListByBrokerLocator serves portfolio views over it[6]. The addition is the broker identity itself: a BROKER value in the party_roles role CHECK (ACCOUNT_HOLDER, INSURED, BENEFICIARY, DEPENDENT today), or a locator on broker_api.broker_configspolicy-admin party_roles migration · broker_api.broker_configs
annual renewalPolicyService.Renew writes term N+1 plus a DRAFT RENEWAL transaction atomically, on PATCH /policies/{locator}/renew. The addition is the scheduler that calls it on the anniversaryenrollment/internal/service/policy.go · Terms, transactions & elements
a mid-term change (add a dependant, switch tier)PATCH /policies/{locator}/endorse writes an ENDORSEMENT transaction; the transaction category vocabulary already carries ISSUANCE | ENDORSEMENT | CANCELLATION | REINSTATEMENT | RENEWAL | REVERSALPolicyService.Endorse; enrollment.policy_transactions
to sell into a second marketjurisdiction (and region for sub-jurisdiction) are columns on the policy already, set from the quote document at bind; the per-market rules live in policy-admin's market_profilespolicies.jurisdiction / .region · Product & catalogue §9

Reading the live table. 699 LAPSED plus hundreds of NULL-tier legacy rows come from lifecycle e2e batches; the 495 (party, scheme)-keyed rows are the Flow-0 business rows. Filter on document->>'flow0' or party_locator IS NOT NULL for analysis. The 17 live quotes (13 ACCEPTED) were minted by branch-side funnel code and bound no policy, so the ERD's "quote binds into policy" edge is true of the code before it is true of the data.

Known defects, with the fix:

  • The policy_structure vocabulary mismatch. Policies default free-text 'INDIVIDUAL'; product versions CHECK GROUP_MASTER | MEMBER_LEVEL. Same concept, two dialects, and the worked example holds the contradiction live: a policy stamped INDIVIDUAL pinned to a version stamped GROUP_MASTER, with nothing translating between them. Fix: migrate policies.policy_structure to the product-side vocabulary (INDIVIDUALMEMBER_LEVEL) and add the matching CHECK, in a new services/enrollment/migrations/ step; IssueInternal is the single writer to update[7].
  • plan_tier case drift. 28 lowercase 'standard' rows sit alongside 464 'STANDARD', and a case-sensitive tier lookup misses them. Fix: upper-case the tier at both writers (IssueInternal and the upgrade path) and upper-case the 28 existing rows. The NULLs stay: the column is nullable by design so pre-#1164 rows need no backfill[17].
  • Four columns exist in the live DB with no migration behind them.quotes.member_locator, quotes.scheme_locator, policies.member_locator and policies.org_locator are present and indexed live, but a fresh database built from the pinned commit's migrations would not have them, and the pinned Quote model has no such fields[4]. Fix: add the codifying migration (ADD COLUMN IF NOT EXISTS + index, the pattern 0019 used for scheme_locator[3]) for the ones we keep, and drop the rest.

References

Code links are pinned to commit 8329d7b on main (2026-08-19); the file is the anchor if lines drift. Pins are checked mechanically by docs/site/scripts/check-code-refs.py.

  1. enrollment/migrations/0002_create_quotes.sql - quotes DDL: UNIQUE locator L4, DRAFT default L7
  2. enrollment/migrations/0004_create_policies.sql - policies DDL: quote_id FK L6, ACTIVE default L8
  3. 0019_add_party_locator_to_policies.sql - party/scheme locators + the out-of-band-scheme_locator admission
  4. packages/go/domain/quotes.go#L11 - Quote model (no member/scheme locator fields at this commit)
  5. packages/go/domain/policies.go#L32 - Policy model, Flow-0 column comments L44-54
  6. enrollment/internal/service/policy.go#L134 - Cancel + Reinstate; #L113 ListByBrokerLocator; #L687-L700 race-safe Lapse
  7. enrollment/internal/service/policy.go#L465 - IssueInternal: idempotency L470-496, UUIDv5 account L503-505, refuse-without-version L531-537, term+element L590-617, outbox L618-639, 23505 path L641-652
  8. enrollment/internal/service/quote.go#L397 - Issue: quote→ACCEPTED L449, QuoteID pinned L425, ISSUANCE txn L443; #L482-L521 required-field gate; #L525-L537 extractJurisdiction
  9. enrollment/internal/handler/quotes.go#L16 - quote routes (price / underwrite / issue / refuse / discard)
  10. enrollment/internal/handler/internal.go#L28 - /internal/policies/issue behind mandatory Idempotency-Key middleware
  11. enrollment/internal/service/quote.go#L268 - rateQuoteElements: writes rated_premium + total_premium at price time
  12. 0012_add_broker_locator_to_policies.sql - broker_locator + index
  13. 0021_locator_sequences.sql - the five sequences, START 1000 rationale
  14. enrollment/internal/service/locator.go#L63 - nextLocator: DB sequence preferred, atomic-counter fallback with warn-log
  15. eligibility/internal/projection/handlers.go#L30 - handlePolicyIssued: bootstrap fetch + member_coverage + accumulators
  16. billing/migrations/0018_create_accounts.sql - the synthetic-UUIDv5 problem statement; org_locator comment L25-27
  17. 0020_flow0_policy_columns.sql - policy_structure + plan_tier + idx_policies_flow0_natural
  18. packages/go/domain/enums.go#L11 - PolicyStatus vocabulary
  19. packages/go/domain/enums.go#L42 - QuoteStatus vocabulary
  20. enrollment/internal/outbox/worker.go#L20 - outbox poll → Kafka publish → mark published
  21. group-scheme-service/internal/service/flow0_compose.go#L139 - hop 2: composer calls enrollment issue with derived idempotency key

Live-schema facts (constraint list, index list, Debezium publication, all row counts and value distributions, the UUIDv5 recomputation, outbox and member_coverage evidence) come from PGPASSWORD=olly psql -h 10.0.1.2 -U olly -d enrollment · \d enrollment.quotes, \d enrollment.policies, plus -d eligibility / -d policy_admin / -d billing for the downstream verifications, 2026-08-18.

Olly Health Insurance Platform