Skip to content
Updated Aug 22, 2026

Enrollment: dynamic fields & underwriting

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

Tablesenrollment.policy_field_values[1], enrollment.quote_field_values[2], enrollment.transaction_field_values[3], enrollment.quote_events[4], enrollment.underwriting_flags[5]
Owner serviceenrollment (sole writer)
LocatorQFV- / PFV- / TFV- (field values, hand-rolled) · ELM- (underwriting_flags, via the element generator - §9) · quote_events has no locator (uuid PK)
Last updated2026-08-22 (D2C Quote & Buy additions, §10, branch feat/1675)
Companionprevious: 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_values tables. A product version declares named, typed fields on policy_admin.field_definitions (STRING | NUMBER | BOOLEAN | DATE | ENUM, each with Required, DefaultValue, AllowedValues for enums, and a DisplayOrder)[6]; the value a specific quote / policy / transaction carries for one of those fields is a row in the matching value table, keyed by field_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: a FieldValueService with type validation[7], Upsert repositories[8] and PATCH/GET .../static routes[9] constructed in main.go[10].

  • An underwriting-decision store - underwriting_flags, plus a quote underwrite step. A reviewer records an APPROVE | BLOCK | DECLINE on a quote with a reason, reversible through voided_at so a decision keeps its audit trail rather than being overwritten[17][18]; the add/remove routes for all three verbs are registered[19], and PATCH /quotes/{locator}/underwrite runs the underwriting ruleset on a PRICED quote[27].

  • quote_events is the quote-lifecycle audit trail its writer AddEvent fills[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_values at quote time and carry to policy_field_values at bind[6]. This is the typed, validated, per-field-queryable form of the data the live quote path keeps in an opaque document blob today.

  • B. Underwriting inputs and reversible decisions. Declared fields (smoker, occupation, declared conditions) become typed underwriting inputs, and underwriting_flags records an APPROVE / BLOCK / DECLINE per quote with a reason and a voided_at reversal[17][18]. This is where the decline and loading logic that lives as Go constants in d2crating today[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 via ParseRatingTable (§10.1), and the accept/decline decision (with basis, loadings, exclusions, factors) is stamped on document.underwriting by mergeUnderwriting and emitted as underwriting.decided (§10.2). The underwriting_flags reviewer surface remains unwired (§5, §9 defect); the enforced D2C record is document.underwriting.decision, not a flag row.

  • C. Mid-term changes and lifecycle audit. transaction_field_values captures the data behind an endorsement or mid-term adjustment on a policy transaction; quote_events is 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 inJoin
the field definitionspolicy_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 rowsfield_definition_id soft ref (no FK)
the cross-service event streamthe enrollment outbox → Kafka enrollment.events; every quote/policy lifecycle event flows there[11], not through quote_eventsnone - different mechanism
the rule-evaluation auditpolicy_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 herenone
the fixed policy shapeenrollment.policies / quotes columns (plan_tier, policy_structure, document jsonb) - the shape the live path actually uses (Quote & Policy §3)the EAV alternative, unused
the parentsenrollment.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.

FieldTypeReqNotes
iduuidPK
locatortextUNIQUE. QFV- / PFV- / TFV-, hand-rolled (§9)
<parent>_iduuidReal in-service FK: quote_idquotes, policy_idpolicies, transaction_idpolicy_transactions
field_definition_iduuidCross-DB soft ref to policy_admin.field_definitions; no FK
valuetextThe field value, always as a string; typed-checked in code before write, stored stringly (§3 below)
created_at / updated_attimestamptzDefault now()
(constraint)UNIQUE (<parent>_id, field_definition_id): one value per field per parent

quote_events

FieldTypeReqNotes
iduuidPK, DEFAULT gen_random_uuid()
quote_iduuidReal FK to quotes; indexed[4]
event_typetextFree text; no vocabulary anywhere (no writer to constrain it)
payloadjsonbNullable event body
created_attimestamptzDefault now(). No locator column - the only table here without one

underwriting_flags

FieldTypeReqNotes
iduuidPK
locatortextUNIQUE. Minted as ELM-… by GenerateElementLocator (§9 wart)[17]
quote_iduuidReal FK to quotes, nullable; indexed[5]
flag_typetextAPPROVE | BLOCK | DECLINE; enforced in code (validFlagTypes), no DB CHECK[17]
reasontextFree-text reviewer note; nullable
created_attimestamptzDefault now()
voided_attimestamptzSoft-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

InvariantEnforced by
All four locators uniqueDB UNIQUE (field values[1], underwriting_flags[5]). quote_events has no locator
One value per field per parentDB UNIQUE (<parent>_id, field_definition_id)[1] + upsert-on-conflict[8]
Every row belongs to a real parentDB FKs (in-service, so real): value→quote/policy/txn[1], quote_events→quote[4], flag→quote[5]
value presentDB NOT NULL[1] - enforced for every row a field write inserts
value conforms to its field's typeApplication: validateValue before upsert[7]; no DB check (the column is TEXT)
field_definition_id names a real, current definitionApplication 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 issuesApplication: 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 setApplication: Void stamps voided_at, ListByQuote filters it[18]
quote_events is append-onlyNot enforced; the sole writer AddEvent has no caller yet (§9)
Row changes captured to CDCDebezium 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:

json
{
  "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"
}
KeyRead byWhat actually happens
field_definition_idthe 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 todayvalidated as a boolean before write[7], then stored as the string "false" (the column is TEXT for all types)
locator: QFV-2026-047213callers echoing the writeminted 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:

json
{
  "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
}
KeyRead byWhat 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-001240callersminted by GenerateElementLocator(), so a flag carries an ELM- prefix - the element namespace (§9 wart)[17]
the whole rownever, in the deployed binaryPOST 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:

json
{ "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

WhereColumn / mechanismMeaning there
enrollment.quotes / policies / policy_transactionstheir id, via the value/event/flag FKs pointing inwardthese are leaf tables - the parents do not read them back on any live path
policy_admin.field_definitionsfield_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 gatequote_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_enrollmentlogical replication of all five tablesCDC 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

  1. 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 in field_definitions, not a migration - the headroom this layer exists to buy.
  2. 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].
  3. 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 for Required definitions[11].
  4. 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]. That AddEvent has no caller today is a gap tracked in §9, not a recorded decision.
  5. 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_FLAG effects are a different concern that aborts the transition and audits to rule_evaluations[22]. The manual surface landed early (migration 0011, a1139750, 2026-03-12), modelled ahead of a real underwriting step; wiring its service into main.go is 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 addWhere
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 bindpolicy_admin.field_definitions[6]enrollment.quote_field_valuespolicy_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 transactionenrollment.transaction_field_values (same write path, transaction_id parent)
quote-funnel analytics or regulatory demonstrabilityemit lifecycle events per quote transitionenrollment.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 rating block, base premium and add-on prices come from the catalogue, and the decision is recorded on the quote Document plus an underwriting.decided event (§10). What is NOT done: routing that decision into an enrollment.underwriting_flags row. 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_flags routes return 501 - wire the service.NewUnderwritingFlagService is never constructed and Deps.FlagService is never assigned at the composition root[10], so the handler nil-guard fires[19]. Fix: build the service and assign Deps.FlagService in main.go (one constructor, one assignment); the six routes then serve the reviewer surface.
  • quote_events writer has no caller - call it or drop it. AddEvent exists, is on the repository interface, and is unit-tested, but no service invokes it[14]; the outbox carries lifecycle events instead. Fix: call AddEvent from 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. fvLocator builds QFV-<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 own nextLocator fallback[20]. Collision-prone by construction. Fix: route field-value locators through nextLocator before the first field write lands.
  • Underwriting-flag locators borrow the ELM- namespace. AddFlag mints via GenerateElementLocator[17], so a flag carries a POL--family element prefix and reads as a policy_elements row at a glance. Fix: add a UWF-/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 are path:line on that branch, not pinned to a main SHA (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 onlyRatingTable parsed from the catalogue rating block via ParseRatingTable (partial block valid, overrides only what it names); defaultRatingTable is the fallbackd2crating/d2crating.go:278-318, d2crating.go:216-258
IPT, age bands, region, excess, smoker/BMI/condition loadings, declining conditions hardcodedevery field of RatingTable, each catalogue-overridabled2crating.go:133-144; wire shape wireRating d2crating.go:262-273
add-on prices from the addOnAnnual code mapIndividual.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 loadsd2crating.go:59-63; addOnPrice d2crating.go:348-355; catalogue/catalogue.go:293-309
base premium from the tierBaseAnnual code mapIndividual.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:

KeyTypeMeaningSource
decisionACCEPT | ACCEPT_WITH_TERMS | DECLINEunderwriting outcomed2crating.go:25-29
basisMORATORIUMunderwriting 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 conditiond2crating.go:81-85
factors{}{age, region, excess, loading}multiplier breakdown for auditd2crating.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):

EventWhenPayload keys (beyond the usual locators)Source
underwriting.decidedevery priced (non-declined) quotedecision, basisquote.go:180-189
quote.declineda DECLINE outcome (age out of range / declining condition)declineReasonquote.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):

KeytypeoutcomeproviderdetailSource
identityidentitymock-kyc statusmock-kyc-client.go:102-104
screeningscreeningmock-sanctions resultmock-sanctionslists, hitsclient.go:105-108
fraudfraudmock-fraud decision (+ score)mock-frauddeviceFingerprint, velocityScore, blocklistHitclient.go:109-114
affordabilityaffordabilityBAND_<creditBand> (+ band)mock-creditaffordabilityFlags, thinFileclient.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 eventfrom check key
identity.verifiedidentity
sanctions.screenedscreening
fraud.assessedfraud
affordability.checkedaffordability

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).

FieldMeaning
technicalPrice / finalPricethe rated price in, the optimiser's suggested price out
marginFloor / competitorGuardrail / elasticityAdjthe optimiser's guardrails and adjustment
provider (mock-pricing) / reference / atenvelope 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.

  1. 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 L10
  2. enrollment/migrations/0013_create_quote_field_values.sql - quote_field_values DDL (identical shape, quote_id FK)
  3. enrollment/migrations/0015_create_transaction_field_values.sql - transaction_field_values DDL (identical shape, transaction_id FK)
  4. enrollment/migrations/0003_create_quote_events.sql - quote_events DDL: quote FK L4, index L9; no locator column
  5. enrollment/migrations/0011_create_underwriting_flags.sql - underwriting_flags DDL: UNIQUE locator L4, nullable quote FK L5, index L11
  6. packages/go/domain/field_definitions.go#L46 - QuoteFieldValue/PolicyFieldValue/TransactionFieldValue models; FieldDefinition + FieldType enum L13-L37
  7. enrollment/internal/service/field_values.go - SetQuoteFieldValues L63-108; validateValue L270-298; fvLocator L300-302
  8. enrollment/internal/repository/gorm_field_values.go#L22 - Upsert with ON CONFLICT (parent_id, field_definition_id) DO UPDATE
  9. enrollment/internal/handler/field_values.go#L25 - registerFieldValueRoutes: PATCH/GET /{quotes,policies,transactions}/{locator}/static
  10. enrollment/cmd/server/main.go#L155 - Deps literal: FieldValueService wired L162, no FlagService line; fieldValueSvc built L116
  11. enrollment/internal/service/quote.go#L892 - validateRequiredFieldValues gate; Issue outbox events L844-880; UNDERWRITING_FLAG→ErrRuleDeny L599-607
  12. enrollment/internal/client/policyadmin.go#L102 - GetFieldDefinitions: cross-DB HTTP GET /internal/product-versions/{id}/fields
  13. enrollment/internal/repository/gorm_quotes.go#L87 - AddEvent: the quote_events writer, never called by any service
  14. packages/go/domain/quotes.go#L32 - QuoteEvent model
  15. packages/go/domain/policies.go#L13 - UnderwritingFlag model
  16. enrollment/internal/service/underwritingflag.go#L26 - AddFlag (ELM- locator L44), validFlagTypes L12, RemoveFlag L56-81
  17. enrollment/internal/repository/gorm_underwritingflags.go#L36 - ListByQuote (voided_at IS NULL) + Void soft-delete + Create
  18. enrollment/internal/handler/quotes.go#L36 - six underwriting-flag routes; addUnderwritingFlag/removeUnderwritingFlag nil→501 L251-290
  19. enrollment/internal/service/locator.go#L63 - nextLocator (DB sequence preferred, atomic fallback); GenerateElementLocator L84
  20. enrollment/internal/service/errors.go#L44 - ErrMissingRequiredFields; ErrRuleDeny L15
  21. packages/go/ruleengine/writer.go#L14 - rule effects (incl. UNDERWRITING_FLAG) audit to policy_admin.rule_evaluations, not here
  22. enrollment/internal/handler/handler.go#L128 - JWT-protected group mounts registerFieldValueRoutes; Deps.FlagService field L98
  23. enrollment/internal/d2crating/d2crating.go#L36 - eligibleMinAge/MaxAge (18-69) L38-39; Individual rating input struct (Age, Smoker, Conditions) L47-57
  24. enrollment/internal/d2crating/d2crating.go#L133 - RatingTable: AgeBands, SmokerLoading, ConditionLoadings, DecliningConditions (code-level rating/decline rules; DecisionDecline L28)
  25. enrollment/internal/handler/quotes.go#L44 - createQuote payload: accountId, productVersionId, free-form document json.RawMessage
  26. enrollment/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.

Olly Health Insurance Platform