Enrollment: dynamic fields & underwriting
Schema deep-dive · living document · #15 in the reading sequence
| Tables | enrollment.policy_field_values[1], enrollment.quote_field_values[2], enrollment.transaction_field_values[3], enrollment.quote_events[4], enrollment.underwriting_flags[5] |
| Owner service | enrollment (sole writer) |
| Locator | QFV- / PFV- / TFV- (field values, hand-rolled) · ELM- (underwriting_flags, via the element generator - §9) · quote_events has no locator (uuid PK) |
| Last updated | 2026-08-22 (D2C Quote & Buy additions, §10, branch feat/1675) |
| Companion | previous: Terms, transactions & elements (#5) · Quote & Policy (#4) · Data architecture |
1. Scope and usage
These are the enrollment tables that let a product carry its own data and its own underwriting decisions without a schema migration. Two capabilities live here, plus a quote-lifecycle audit trail:
A dynamic-field (EAV) layer - the three
*_field_valuestables. A product version declares named, typed fields onpolicy_admin.field_definitions(STRING | NUMBER | BOOLEAN | DATE | ENUM, each withRequired,DefaultValue,AllowedValuesfor enums, and aDisplayOrder)[6]; the value a specific quote / policy / transaction carries for one of those fields is a row in the matching value table, keyed byfield_definition_id. That is a full typed form spec a product configures for itself - required, type-checked, enum-constrained, display-ordered - with no new columns and no migration. The write path is built: aFieldValueServicewith type validation[7],Upsertrepositories[8] andPATCH/GET .../staticroutes[9] constructed inmain.go[10].An underwriting-decision store -
underwriting_flags, plus a quoteunderwritestep. A reviewer records anAPPROVE | BLOCK | DECLINEon a quote with areason, reversible throughvoided_atso a decision keeps its audit trail rather than being overwritten[17][18]; the add/remove routes for all three verbs are registered[19], andPATCH /quotes/{locator}/underwriteruns the underwriting ruleset on aPRICEDquote[27].quote_eventsis the quote-lifecycle audit trail its writerAddEventfills[14].
What the model already absorbs (the extension paths, detailed in §9):
A. Per-product data capture without a migration. A product version declares typed fields (required, enum-constrained, ordered); the values land in
quote_field_valuesat quote time and carry topolicy_field_valuesat bind[6]. This is the typed, validated, per-field-queryable form of the data the live quote path keeps in an opaquedocumentblob today.B. Underwriting inputs and reversible decisions. Declared fields (smoker, occupation, declared conditions) become typed underwriting inputs, and
underwriting_flagsrecords an APPROVE / BLOCK / DECLINE per quote with areasonand avoided_atreversal[17][18]. This is where the decline and loading logic that lives as Go constants ind2cratingtoday[25] moves into product configuration plus a recorded human decision.Update (#1675, D2C Quote & Buy). Half of this has now shipped, but it landed on the quote Document and the event stream, not on
underwriting_flags. Rating (IPT, age bands, region, excess, smoker/BMI/condition loadings, declining conditions) is now catalogue-driven viaParseRatingTable(§10.1), and the accept/decline decision (with basis, loadings, exclusions, factors) is stamped ondocument.underwritingbymergeUnderwritingand emitted asunderwriting.decided(§10.2). Theunderwriting_flagsreviewer surface remains unwired (§5, §9 defect); the enforced D2C record isdocument.underwriting.decision, not a flag row.C. Mid-term changes and lifecycle audit.
transaction_field_valuescaptures the data behind an endorsement or mid-term adjustment on a policy transaction;quote_eventsis the quote-lifecycle audit trail - funnel analytics, and showing a regulator how a price and a decision were reached[14].
Why it is idle today. The current quote mechanism is simple by design. A quote is created from three inputs - accountId, productVersionId, and a free-form document json blob[26] - and priced by first-principles rules in d2crating, which take a fixed Individual struct (Age, Smoker, Conditions) and apply named age / smoker / condition factors within eligibility bounds of 18-69[24][25]. No product declares custom fields (policy_admin.field_definitions is itself empty), so the value tables have nothing to store yet; and no human underwriting step runs, so no flag is raised. Medical-ish inputs do move the price today - smoker and declared conditions both load or decline - but through those d2crating rules, not through field_definitions or underwriting_flags. When the quote mechanism grows past a fixed input set, this is the model that carries it, and §9 names where each change lands.
2. Boundaries and relationships
| This is not… | That concern lives in | Join |
|---|---|---|
| the field definitions | policy_admin.field_definitions - the schema of custom fields, declared per product version; fetched cross-DB over HTTP GET /internal/product-versions/{id}/fields[12]. Itself 0 rows | field_definition_id soft ref (no FK) |
| the cross-service event stream | the enrollment outbox → Kafka enrollment.events; every quote/policy lifecycle event flows there[11], not through quote_events | none - different mechanism |
| the rule-evaluation audit | policy_admin.rule_evaluations - where the rule engine records its effects, including UNDERWRITING_FLAG effects[22]. A BLOCK effect aborts the transition (ErrRuleDeny); it never writes a row here | none |
| the fixed policy shape | enrollment.policies / quotes columns (plan_tier, policy_structure, document jsonb) - the shape the live path actually uses (Quote & Policy §3) | the EAV alternative, unused |
| the parents | enrollment.quotes / policies / policy_transactions - the aggregates these tables hang off (Quote & Policy, Terms…) | quote_id / policy_id / transaction_id real in-service FKs |
field_definition_id is a cross-DB soft reference. The value tables FK their parent (a quote/policy/transaction, in-service, real FK) but only soft-reference the definition: field_definition_id is a bare uuid column, no FK, pointing at a row in a different database owned by policy-admin[6]. Standard cross-service posture - and here it points at an empty table, so no value could resolve a definition even if one were written.
3. Structure
DDL[1][2][3][4][5] · Go models[6][15][16]
The three *_field_values tables
quote_field_values, policy_field_values and transaction_field_values are byte-for-byte identical except for their parent-FK column. One shape, described once; the parent-FK row names all three variants.
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK |
locator | text | ✓ | UNIQUE. QFV- / PFV- / TFV-, hand-rolled (§9) |
<parent>_id | uuid | ✓ | Real in-service FK: quote_id → quotes, policy_id → policies, transaction_id → policy_transactions |
field_definition_id | uuid | ✓ | Cross-DB soft ref to policy_admin.field_definitions; no FK |
value | text | ✓ | The field value, always as a string; typed-checked in code before write, stored stringly (§3 below) |
created_at / updated_at | timestamptz | ✓ | Default now() |
| (constraint) | UNIQUE (<parent>_id, field_definition_id): one value per field per parent |
quote_events
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK, DEFAULT gen_random_uuid() |
quote_id | uuid | ✓ | Real FK to quotes; indexed[4] |
event_type | text | ✓ | Free text; no vocabulary anywhere (no writer to constrain it) |
payload | jsonb | Nullable event body | |
created_at | timestamptz | ✓ | Default now(). No locator column - the only table here without one |
underwriting_flags
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK |
locator | text | ✓ | UNIQUE. Minted as ELM-… by GenerateElementLocator (§9 wart)[17] |
quote_id | uuid | Real FK to quotes, nullable; indexed[5] | |
flag_type | text | ✓ | APPROVE | BLOCK | DECLINE; enforced in code (validFlagTypes), no DB CHECK[17] |
reason | text | Free-text reviewer note; nullable | |
created_at | timestamptz | ✓ | Default now() |
voided_at | timestamptz | Soft-delete stamp; a live flag is voided_at IS NULL[18] |
Field-by-field: what and why
value is TEXT for every field type. The definition carries the type (STRING | NUMBER | BOOLEAN | DATE | ENUM)[6]; the value column stores the string form and the service validates it against the type before the write: booleans must be "true"/"false", numbers must ParseFloat, dates must parse YYYY-MM-DD, enums must match one of the definition's AllowedValues (case-insensitive)[7]. The classic EAV trade: one narrow table for arbitrary fields, at the cost of the column type telling you nothing.
The (parent_id, field_definition_id) unique constraint makes writes upserts. The repositories use GORM ON CONFLICT (parent_id, field_definition_id) DO UPDATE SET value, updated_at[8], so setting a field twice overwrites rather than duplicates - a field has exactly one value per parent. The SetQuoteFieldValues / SetPolicyFieldValues / SetTransactionFieldValues methods loop the request entries, resolve each fieldDefinitionLocator against the definitions fetched from policy-admin, reject an unknown locator, validate the value, then upsert[7]. With field_definitions empty, that resolution step fails every entry with "unknown field definition" - so even a hand-crafted call to a live /static endpoint cannot write a row today.
field_definition_id on the value, not the locator. The request speaks in definition locators (fieldDefinitionLocator), but the stored row keys on the definition's uuid (def.ID)[7] - the uuid is the stable identity, the locator the human handle, the same split the rest of the estate uses.
quote_events.event_type is free TEXT, its vocabulary set by the caller. The column has no CHECK and no typed constant; the vocabulary is chosen when a transition calls AddEvent (§9.C). The writer picks a stable event name at that point, the same way the outbox events are named today.
underwriting_flags.quote_id is nullable, unlike the field-value parents. A flag models a reviewer decision that the code always attaches to a quote (AddFlag loads the quote and sets QuoteID[17]), but the schema leaves room for a future policy- or transaction-level flag by not forcing it. voided_at makes retraction a soft delete: RemoveFlag stamps voided_at, and ListByQuote filters voided_at IS NULL, so history is retained[18].
4. Invariants
| Invariant | Enforced by |
|---|---|
All four locators unique | DB UNIQUE (field values[1], underwriting_flags[5]). quote_events has no locator |
| One value per field per parent | DB UNIQUE (<parent>_id, field_definition_id)[1] + upsert-on-conflict[8] |
| Every row belongs to a real parent | DB FKs (in-service, so real): value→quote/policy/txn[1], quote_events→quote[4], flag→quote[5] |
value present | DB NOT NULL[1] - enforced for every row a field write inserts |
value conforms to its field's type | Application: validateValue before upsert[7]; no DB check (the column is TEXT) |
field_definition_id names a real, current definition | Application only - resolved by HTTP against policy-admin at write time[7]; no FK, cross-DB. Nothing stops a stale/dangling id once written |
| Required fields present before a quote issues | Application: validateRequiredFieldValues gates Issue[11] - but no-ops when the product has no required fields (the live case) |
flag_type ∈ {APPROVE, BLOCK, DECLINE} | Application: validFlagTypes map[17]; no DB CHECK |
| A voided flag stays out of the live set | Application: Void stamps voided_at, ListByQuote filters it[18] |
quote_events is append-only | Not enforced; the sole writer AddEvent has no caller yet (§9) |
| Row changes captured to CDC | Debezium publication dbz_enrollment - all five tables are members (live \dRp+), so CDC carries each table's rows the moment they are written |
5. Lifecycle
No table here has a status machine. Two have a write path worth walking; the third, quote_events, has a writer whose caller is a later step (§9).
Field values - the write-and-validate path (reachable and functional; awaiting field definitions).
Steps 1-4 are wired end to end[9][7]; the routes sit inside the JWT-protected group[23]. In a populated estate the definitions come back, validateValue passes, and the row upserts. Today the definitions come back empty, so every entry is rejected as unknown - the reason the tables are empty is one HTTP hop upstream, not a missing endpoint. The only place the live path reads these tables is the required-field gate at issuance, which loops the product's required definitions and refuses Issue if any lack a value[11] - and with zero definitions, that loop has nothing to check and returns nil.
Underwriting flags - the create/void lifecycle.
AddFlag validates the type, loads the quote, and inserts a flag[17]; RemoveFlag stamps voided_at[18] - a clean [*] → RAISED → VOIDED soft-delete. The lifecycle is complete in the service layer. One wiring step is missing to expose it: main.go builds a FieldValueService and hands it to Deps, but does not build a UnderwritingFlagService or set Deps.FlagService[10], so deps.FlagService is nil and both handlers short-circuit to 501 Not Implemented[19]. That gap is a genuine defect, tracked in §9, and its fix is one constructor plus one assignment.
Note the separation from the rule engine. Pricing and underwriting run rulesets, and a rule may emit an UNDERWRITING_FLAG effect - but on a BLOCK the quote service returns ErrRuleDeny to abort the transition[11], and the effect itself is audited to policy_admin.rule_evaluations[22]. Nothing on that path writes an enrollment.underwriting_flags row. Automatic underwriting and this table have never been connected.
quote_events has no lifecycle to draw: AddEvent is the sole writer and has no caller (§6, §9).
6. Populated example: no live rows - the write paths, walked
All five tables are empty, so every row below is constructed from the code's insert path, not pulled live. Each is what the writer would persist, with the locators taking realistic shapes from the generators.
A field value (constructed from SetQuoteFieldValues)
Assume a product version that declared a required BOOLEAN field "Smoker" (definition locator FD-2026-000004, some uuid d1e2…). A member front-end PATCH /quotes/QTE-2026-000051/static with {"fields":[{"fieldDefinitionLocator":"FD-2026-000004","value":"false"}]} would, once definitions exist, produce:
{
"id": "a3f1c7e2-5b90-4d11-9c33-0e77a6b2d401",
"locator": "QFV-2026-047213",
"quote_id": "…the QTE-2026-000051 uuid…",
"field_definition_id": "d1e2f3a4-…",
"value": "false",
"created_at": "2026-08-21T10:00:00Z",
"updated_at": "2026-08-21T10:00:00Z"
}| Key | Read by | What actually happens |
|---|---|---|
field_definition_id | the required-field gate at Issue[11] | marks this definition "fulfilled" so issuance is not blocked on it. The join target (policy_admin.field_definitions) is a cross-DB soft ref - no FK verifies it |
value: "false" | nothing downstream today | validated as a boolean before write[7], then stored as the string "false" (the column is TEXT for all types) |
locator: QFV-2026-047213 | callers echoing the write | minted by fvLocator("QFV") = QFV-<year>-<uuid.ID()%06d>[7] - not a DB sequence (§9) |
policy_field_values and transaction_field_values are identical, differing only in the parent column (policy_id / transaction_id) and locator prefix (PFV- / TFV-).
An underwriting flag (constructed from AddFlag)
A POST /quotes/QTE-2026-000051/underwritingFlags/block {"reason":"pre-existing condition disclosed"} would, if the service were wired, produce:
{
"id": "7c0b…",
"locator": "ELM-2026-001240",
"quote_id": "…the QTE-2026-000051 uuid…",
"flag_type": "BLOCK",
"reason": "[redacted - reviewer note]",
"created_at": "2026-08-21T10:05:00Z",
"voided_at": null
}| Key | Read by | What actually happens |
|---|---|---|
flag_type: BLOCK | (would gate issuance, if a gate read it) | validated against validFlagTypes[17]. Note: no code reads flags on the issue path - they are a reviewer record, not an enforced gate |
locator: ELM-2026-001240 | callers | minted by GenerateElementLocator(), so a flag carries an ELM- prefix - the element namespace (§9 wart)[17] |
| the whole row | never, in the deployed binary | POST returns 501 because Deps.FlagService is nil[19] - this JSON is what the wired service would write |
A quote event (constructed from AddEvent - no caller)
GormQuoteRepository.AddEvent would insert:
{ "id": "…", "quote_id": "…", "event_type": "quote.priced", "payload": {"…":"…"}, "created_at": "2026-08-21T…" }but no service method calls AddEvent[14]. The equivalent real event - quote.calculated on pricing, quote.accepted on issuance - is enqueued to the outbox instead[11], which is why this table holds no rows under the current code. Calling AddEvent from the quote transitions is the step that populates it (§9.C).
Live population, for the record (2026-08-21): policy_field_values 0, quote_field_values 0, transaction_field_values 0, quote_events 0, underwriting_flags 0 - against 165 quotes, 1 422 policies and 53 policy transactions live in the same database. The parents are busy; the value and audit tables are not yet exercised, and activate on the paths in §9.
7. Who references these tables
| Where | Column / mechanism | Meaning there |
|---|---|---|
enrollment.quotes / policies / policy_transactions | their id, via the value/event/flag FKs pointing inward | these are leaf tables - the parents do not read them back on any live path |
policy_admin.field_definitions | field_definition_id (cross-DB soft ref, no FK) | the schema a value instantiates; a value resolves against it once a product declares fields |
| the required-field gate | quote_field_values via ListByQuote at Issue[11] | the one live read on the current path; it returns nil while no product declares required fields |
Debezium / dbz_enrollment | logical replication of all five tables | CDC is configured; the stream carries each table's rows as they are written |
No cross-service consumer reads these tables on the current path, and no downstream service holds one of their locators yet. They are leaf tables by design: the consumer for each is wired when the capability it feeds switches on (§9).
8. Design determinations
- Custom fields are EAV against product-defined definitions ("Option A"). Definitions live on the product version (policy-admin); values live on the enrollment entity keyed by
field_definition_id. Landed in one commit that named the fork explicitly - "add product field definitions and field values (Option A)" (792677dc, 2026-03-13)[6]. Three parallel value tables (quote / policy / transaction) rather than one polymorphic table, so each keeps a real in-service FK to its parent. The determination is to absorb per-product data as configuration rather than schema: a new product's fields are rows infield_definitions, not a migration - the headroom this layer exists to buy. - Validation at write, storage as TEXT. The type system is enforced in the service (
validateValue), not the schema, keeping one narrow value table for all field types[7]. - Required-field enforcement at issuance, not at set-time. A value can be absent while a quote is worked; the gate fires only at
Issue, and only forRequireddefinitions[11]. - The outbox superseded
quote_events. The cross-service-safe event mechanism became the transactional outbox →enrollment.events; the in-service audit table (migration 0003, among the earliest enrollment tables) kept its writer for the in-service audit path (§9.C)[14]. ThatAddEventhas no caller today is a gap tracked in §9, not a recorded decision. - Underwriting flags are a manual reviewer surface, separate from rule effects. A flag is a human APPROVE/BLOCK/DECLINE with a reason and a void; rule-engine
UNDERWRITING_FLAGeffects are a different concern that aborts the transition and audits torule_evaluations[22]. The manual surface landed early (migration 0011,a1139750, 2026-03-12), modelled ahead of a real underwriting step; wiring its service intomain.gois the remaining step, tracked as a defect in §9.
No decision-record or issue number is cited above where none exists in the code or git history; these determinations are grounded in commit messages and the code itself.
9. Caveats and extensibility
Group and individual. The dynamic-field layer is structure-agnostic: a field value hangs off whichever entity carries it, so an employer/scheme quote and a direct-to-consumer quote attach fields the same way, and a group-master policy attaches policy-level fields exactly as a member-level one does - one capability serving both models with no schema change. The D2C path (#1675) that minted most of the live quotes keeps its health-and-lifestyle declaration in the quote document jsonb (Quote & Policy §3) today; a typed field set is the structured form that data takes once a product declares it.
Extension points - when the need lands, where the change goes. Each row is a capability the model already carries; adding it is configuration or a small wiring change, not a schema migration.
| When we need ... | What to add | Where |
|---|---|---|
| a product to capture its own data (occupation, declared conditions, scheme specifics) | declare typed FieldDefinitions on the product version; values flow to the quote then carry to the policy at bind | policy_admin.field_definitions[6] → enrollment.quote_field_values → policy_field_values; write path already built[7][9] |
| underwriting to become a real step (medical questions, referrals, loadings) | move the decline/loading rules out of Go constants into product config, and record each decision (reversible) | d2crating constants[25] → product config; APPROVE/BLOCK/DECLINE rows in enrollment.underwriting_flags via the underwrite step[27][17] (after the wiring fix below) |
| policies to change mid-term (endorsements, adjustments) | capture the changed data against the transaction | enrollment.transaction_field_values (same write path, transaction_id parent) |
| quote-funnel analytics or regulatory demonstrability | emit lifecycle events per quote transition | enrollment.quote_events via AddEvent[14] (after adding a caller below) |
Partly built (#1675, D2C Quote & Buy). The "move the decline/loading rules out of Go constants into product config" half of the underwriting row is now done for the D2C individual flow: rating and decline rules parse from the product catalogue
ratingblock, base premium and add-on prices come from the catalogue, and the decision is recorded on the quote Document plus anunderwriting.decidedevent (§10). What is NOT done: routing that decision into anenrollment.underwriting_flagsrow. The reviewer surface stays unwired (the 501 defect below), so the D2C decision lives on the Document, not in this table.
Known defects - state and fix. These are wiring and locator faults, independent of product maturity; each is stated with the change that resolves it.
underwriting_flagsroutes return 501 - wire the service.NewUnderwritingFlagServiceis never constructed andDeps.FlagServiceis never assigned at the composition root[10], so the handler nil-guard fires[19]. Fix: build the service and assignDeps.FlagServiceinmain.go(one constructor, one assignment); the six routes then serve the reviewer surface.quote_eventswriter has no caller - call it or drop it.AddEventexists, is on the repository interface, and is unit-tested, but no service invokes it[14]; the outbox carries lifecycle events instead. Fix: callAddEventfrom the quote transitions when the in-service audit trail is wanted (extension C above), or remove the table.- Field-value locators are hand-rolled, not sequenced.
fvLocatorbuildsQFV-<year>-<uuid.ID()%06d>[7] from a random uint32 truncated to six digits, using neither the shared per-prefix DB sequences nor the service's ownnextLocatorfallback[20]. Collision-prone by construction. Fix: route field-value locators throughnextLocatorbefore the first field write lands. - Underwriting-flag locators borrow the
ELM-namespace.AddFlagmints viaGenerateElementLocator[17], so a flag carries aPOL--family element prefix and reads as apolicy_elementsrow at a glance. Fix: add aUWF-/FLG-generator before flags are written.
10. D2C Quote & Buy: underwriting on the quote Document (#1675)
D2C QnB additions, grounded in branch
feat/1675, pending merge. Citations in this section arepath:lineon that branch, not pinned to amainSHA (the References block above is unchanged). Once merged, fold these into the numbered, SHA-pinned list.
The "future work" that §1.B and §9 anticipate has largely shipped for the D2C individual flow (V1.4), but it landed on the quote Document (jsonb) and the enrollment outbox, not on the EAV/underwriting_flags tables this page documents. The D2C price/underwrite path is QuoteService.priceD2C, taken when a quote is requires_prepayment/channel:"D2C" (services/enrollment/internal/service/quote.go:76-215, gated at quote.go:510-519). Three pieces below.
10.1 Rating is DB-driven (catalogue rating block), not Go-constants-only
§1.B and the §9 extension table framed the decline/loading rules as Go constants in d2crating awaiting a move to product config. That move is done: priceD2C resolves the product catalogue (by productVersionId, else by cover tier), reads its raw rating block, and parses a RatingTable from it; only when NO catalogue resolves does rating fall back to the code tables.
| Was (pre-QnB) | Now (#1675) | Source |
|---|---|---|
| loadings/decline as Go constants only | RatingTable parsed from the catalogue rating block via ParseRatingTable (partial block valid, overrides only what it names); defaultRatingTable is the fallback | d2crating/d2crating.go:278-318, d2crating.go:216-258 |
| IPT, age bands, region, excess, smoker/BMI/condition loadings, declining conditions hardcoded | every field of RatingTable, each catalogue-overridable | d2crating.go:133-144; wire shape wireRating d2crating.go:262-273 |
add-on prices from the addOnAnnual code map | Individual.AddOnPrices from catalogue.AddonPrices(); authoritative when non-nil (a key absent from it is unpriced, £0, priced:false), code map used ONLY when no catalogue loads | d2crating.go:59-63; addOnPrice d2crating.go:348-355; catalogue/catalogue.go:293-309 |
base premium from the tierBaseAnnual code map | Individual.BaseAnnualOverride = premium_per_member × 12 from the catalogue (<= 0 falls back to tierBaseAnnual) | d2crating.go:64-68; wiring quote.go:88-92; catalogue.go:347-348 |
The catalogue rating block is read from the parsed catalogue's Raw map (cat.Raw["rating"], quote.go:94-98; Raw populated at catalogue.go:209, catalogue.go:340), so a product tunes IPT/bands/loadings/declines without a code change. RateIndividual consumes in.Rating for eligibility, decline, loadings and factors (d2crating.go:362-448; eligibility+decline d2crating.go:370-382; base override d2crating.go:409-417; add-on pricing d2crating.go:424-428).
10.2 The underwriting decision is Document data plus a domain fact
mergeUnderwriting stamps the outcome onto document.underwriting and the price build-up onto document.premium, so both travel with the quote and show on the schedule (quote.go:219-236). The decision moved from an implicit field to a first-class fact: previously only a decline surfaced an event (quote.declined); now accept is promoted out of a field on quote.calculated into its own underwriting.decided event.
document.underwriting sub-block:
| Key | Type | Meaning | Source |
|---|---|---|---|
decision | ACCEPT | ACCEPT_WITH_TERMS | DECLINE | underwriting outcome | d2crating.go:25-29 |
basis | MORATORIUM | underwriting basis (the D2C default: pre-existing excluded for a look-back, not assessed up front) | d2crating.go:31-34 |
loadings[] | {reason, pct} | percentage premium uplifts (smoker, BMI band, loaded condition) | d2crating.go:76-79 |
exclusions[] | {reason, detail} | moratorium carve-outs per declared condition | d2crating.go:81-85 |
factors{} | {age, region, excess, loading} | multiplier breakdown for audit | d2crating.go:433-436 |
addOns[] | {key, annual, priced} | per-requested add-on price + whether it was in the rate table (priced:false = unrecognised, £0) | d2crating.go:112-116 |
Events emitted by priceD2C (all to the outbox → enrollment.events):
| Event | When | Payload keys (beyond the usual locators) | Source |
|---|---|---|---|
underwriting.decided | every priced (non-declined) quote | decision, basis | quote.go:180-189 |
quote.declined | a DECLINE outcome (age out of range / declining condition) | declineReason | quote.go:103-120 |
This is separate from enrollment.underwriting_flags (this page's manual reviewer table). The QnB decision writes to the Document + outbox, not to the flags table, which stays unwired (§5, §9). On the D2C path the enforced record of the decision is document.underwriting.decision.
10.3 The external checks sub-schema (mock providers, real domain events)
After pricing, priceD2C runs the integrations client, which fans out in parallel to the external providers and normalises each raw record into a uniform envelope on document.checks (quote.go:127-145; client integrations/client.go:64-134). These are mock providers today (NewFromEnv wires MOCK_*_URL, client.go:28-43), stand-ins for real KYC/sanctions/fraud/affordability vendors; the Document contract and the domain events are what downstream binds to, so both survive the swap to a real vendor (client.go:1-9).
document.checks (each value an envelope):
| Key | type | outcome | provider | detail | Source |
|---|---|---|---|---|---|
identity | identity | mock-kyc status | mock-kyc | - | client.go:102-104 |
screening | screening | mock-sanctions result | mock-sanctions | lists, hits | client.go:105-108 |
fraud | fraud | mock-fraud decision (+ score) | mock-fraud | deviceFingerprint, velocityScore, blocklistHit | client.go:109-114 |
affordability | affordability | BAND_<creditBand> (+ band) | mock-credit | affordabilityFlags, thinFile | client.go:115-120 |
Envelope shape (uniform across providers): {type, outcome, provider, reference?, at?, detail?} (envelope, client.go:138-150). Consumers read checks.<concept>.outcome, never a provider-specific payload.
Each check also becomes a domain fact. Enrollment (the orchestrator) emits these, not the mock provider, so the event contract holds when the mock is replaced. The concept→event map is checkEventTypes (quote.go:65-70); the loop that emits them is quote.go:190-209:
| Domain event | from check key |
|---|---|
identity.verified | identity |
sanctions.screened | screening |
fraud.assessed | fraud |
affordability.checked | affordability |
Consumers named in code: audit/compliance trail, timeline read-model, fraud analytics, inbox (quote.go:174-179).
premium.optimisation (observe-only). mergeChecks nests the pricing optimiser output under document.premium.optimisation (a pricing concern, not a risk check), or at document.priceOptimisation if premium is not a map (quote.go:243-266). It is observe-only: it records a suggested final price with applied:false and does not change the amount charged (client.go:124-132).
| Field | Meaning |
|---|---|
technicalPrice / finalPrice | the rated price in, the optimiser's suggested price out |
marginFloor / competitorGuardrail / elasticityAdj | the optimiser's guardrails and adjustment |
provider (mock-pricing) / reference / at | envelope provenance |
applied (false) | observe-only marker: recorded, not charged |
Where this lands relative to these tables. None of §10 writes an enrollment.underwriting_flags, quote_events, or *_field_values row. The D2C decision, checks and optimisation live on the quote document jsonb and the outbox event stream. The EAV/flags model this page documents is still the typed, per-field-queryable form these Document blobs take once a product declares field definitions and the reviewer surface is wired (§9).
References
Code links are pinned to commit b61c5802 on main (2026-08-21); the file is the anchor if lines drift. Pins are checked mechanically by docs/site/scripts/check-code-refs.py.
enrollment/migrations/0014_create_policy_field_values.sql- policy_field_values DDL: UNIQUE locator L4, parent FK L5, value NOT NULL L7,(policy_id, field_definition_id)UNIQUE L10enrollment/migrations/0013_create_quote_field_values.sql- quote_field_values DDL (identical shape,quote_idFK)enrollment/migrations/0015_create_transaction_field_values.sql- transaction_field_values DDL (identical shape,transaction_idFK)enrollment/migrations/0003_create_quote_events.sql- quote_events DDL: quote FK L4, index L9; no locator columnenrollment/migrations/0011_create_underwriting_flags.sql- underwriting_flags DDL: UNIQUE locator L4, nullable quote FK L5, index L11packages/go/domain/field_definitions.go#L46- QuoteFieldValue/PolicyFieldValue/TransactionFieldValue models; FieldDefinition + FieldType enum L13-L37enrollment/internal/service/field_values.go- SetQuoteFieldValues L63-108; validateValue L270-298; fvLocator L300-302enrollment/internal/repository/gorm_field_values.go#L22- Upsert with ON CONFLICT (parent_id, field_definition_id) DO UPDATEenrollment/internal/handler/field_values.go#L25- registerFieldValueRoutes: PATCH/GET/{quotes,policies,transactions}/{locator}/staticenrollment/cmd/server/main.go#L155- Deps literal: FieldValueService wired L162, no FlagService line; fieldValueSvc built L116enrollment/internal/service/quote.go#L892- validateRequiredFieldValues gate; Issue outbox events L844-880; UNDERWRITING_FLAG→ErrRuleDeny L599-607enrollment/internal/client/policyadmin.go#L102- GetFieldDefinitions: cross-DB HTTPGET /internal/product-versions/{id}/fieldsenrollment/internal/repository/gorm_quotes.go#L87- AddEvent: the quote_events writer, never called by any servicepackages/go/domain/quotes.go#L32- QuoteEvent modelpackages/go/domain/policies.go#L13- UnderwritingFlag modelenrollment/internal/service/underwritingflag.go#L26- AddFlag (ELM- locator L44), validFlagTypes L12, RemoveFlag L56-81enrollment/internal/repository/gorm_underwritingflags.go#L36- ListByQuote (voided_at IS NULL) + Void soft-delete + Createenrollment/internal/handler/quotes.go#L36- six underwriting-flag routes; addUnderwritingFlag/removeUnderwritingFlag nil→501 L251-290enrollment/internal/service/locator.go#L63- nextLocator (DB sequence preferred, atomic fallback); GenerateElementLocator L84enrollment/internal/service/errors.go#L44- ErrMissingRequiredFields; ErrRuleDeny L15packages/go/ruleengine/writer.go#L14- rule effects (incl. UNDERWRITING_FLAG) audit to policy_admin.rule_evaluations, not hereenrollment/internal/handler/handler.go#L128- JWT-protected group mounts registerFieldValueRoutes; Deps.FlagService field L98enrollment/internal/d2crating/d2crating.go#L36- eligibleMinAge/MaxAge (18-69) L38-39; Individual rating input struct (Age, Smoker, Conditions) L47-57enrollment/internal/d2crating/d2crating.go#L133- RatingTable: AgeBands, SmokerLoading, ConditionLoadings, DecliningConditions (code-level rating/decline rules; DecisionDecline L28)enrollment/internal/handler/quotes.go#L44- createQuote payload: accountId, productVersionId, free-form document json.RawMessageenrollment/internal/service/quote.go#L500- QuoteService.Underwrite: runs the underwriting ruleset on a PRICED quote
Live-schema facts (all five row counts = 0; policy_admin.field_definitions = 0; parent counts 165 quotes / 1 422 policies / 53 transactions; the dbz_enrollment publication membership) come from PGPASSWORD=olly psql -h 10.0.1.2 -U olly -d enrollment · \d enrollment.{policy,quote,transaction}_field_values, \d enrollment.quote_events, \d enrollment.underwriting_flags, plus -d policy_admin for field_definitions, 2026-08-21.
