Product & catalogue
Schema deep-dive · living document · #3 in the reading sequence
| Tables | policy_admin.products, policy_admin.product_versions, policy_admin.clinical_terms[1][2] |
| Owner service | policy-admin (sole writer) |
| Locators | PRD- (products) · PVR- (versions) |
| Last updated | 2026-08-19 |
| Companion | ERD story, slide 3 · real product JSON · catalogue shape · UK specification · previous: Scheme & roster |
1. Scope and usage
A Product is what Olly sells; a ProductVersion is one immutable edition of it. The version carries the entire commercial definition as data: the benefit catalogue (element_schema), the document schemas, the charge vocabulary, and the legal structure of the contract (policy_structure). The product row itself is a stable identity for the family of versions.
The consequence that shapes everything downstream: the insurance product is configuration, not code. Adjudication, accumulator seeding and pricing all read the catalogue generically; changing what a product covers is a data change on a new version, not a deploy.
2. Boundaries and relationships
| Product / version is not… | That concern lives in | Join |
|---|---|---|
| the cover someone holds | enrollment.policies pins a product_version_id at issue; the elements snapshot the catalogue into coverage_terms | uuid soft ref |
| a price list | pricing today reads the catalogue premium; the factor-based rating engine (rating_factors, rate_tables) hangs off the version with real FKs[live] and switches on when a product prices off factors | in-service FK |
| the eligibility ruleset | eligibility evaluates the snapshot on member_coverage.coverage_terms, never this table - the product can change without touching in-force cover | via snapshot |
| a per-member thing | versions are per-product; the member-specific view is the policy element (next pages) | - |
| the market/regulatory config | market_profiles (+versions) carry per-jurisdiction flags and rules; products pin a market code | market |
3. Structure
products
| Field | Type | Req | Notes |
|---|---|---|---|
id / locator | uuid / text | ✓ | PK / UNIQUE PRD- |
code | text | ✓ | UNIQUE. The business identity: OHC-2026, DEN-2025 |
name, lob | text | ✓ | Display name; line of business (health, dental) |
market | varchar(10) | ✓ | Default 'GB'; indexed |
status | text | ✓ | CHECK ACTIVE | INACTIVE[1] |
product_versions
| Field | Type | Req | Notes |
|---|---|---|---|
id / locator | uuid / text | ✓ | PK / UNIQUE PVR- |
product_id | uuid | ✓ | Real FK to products (in-service) |
version | int | ✓ | UNIQUE with product_id - the edition number |
policy_schema, claim_schema | jsonb | Document-shape declarations | |
element_schema | jsonb | The benefit catalogue - see below | |
charge_types | jsonb | Charge vocabulary: ["PREMIUM", "CONTRIBUTION"] | |
policy_structure | text | ✓ | CHECK GROUP_MASTER | MEMBER_LEVEL, default GROUP_MASTER[4] |
status | text | ✓ | CHECK DRAFT | PUBLISHED[2] |
published_at | timestamptz | Stamped on publish |
Field-by-field: what and why
code vs locator - same split as Scheme: the code is the meaning-bearing business handle, the locator is the minted join key.
policy_structure - the single most load-bearing config field in the platform. The migration comment states the model in full[4]: GROUP_MASTER (employer is policyholder, member policies are certificates) or MEMBER_LEVEL (the member policy is the contract) - "One model, two configs." Decided in #225 (R-01) under epic #219: build both, let the product choose. This is why group vs individual is a product setting, not an architecture fork.
element_schema - the catalogue - the full benefit definition: modules (gp_video, physio_remote, mental_health, diagnostics, digital, neurodiversity), addons, exclusions, the contractual blocks, and pricing. See it live on the real product JSON.
One shape, one reader. Every module carries the same keys, and a key that does not apply is null rather than absent. Six services used to parse the catalogue with their own structs; they now share packages/go/catalogue[5], which is the only definition of what a catalogue is.
| Key | Read by | Effect |
|---|---|---|
type | display, claim routing | clinical_service · diagnostic · platform_capability |
unit | projection[6] | sessions → SESSION_LIMIT · money → BENEFIT_LIMIT · unmetered → USAGE, no ceiling |
included_per_cycle | projection | seeds the session limit |
cycle_limit | projection | seeds the money limit |
channels_covered | verdicts[7] | a channel absent from the list is not covered |
referral_required | verdicts | REQUIRES_REFERRAL verdict |
waiting_periods | verdicts, schedule[8] | gap before the benefit is usable; where periods overlap the longer applies |
contribution | display, billing | the member's share, or null |
includes / excludes | vocabulary joins | references into clinical_terms, not strings |
Absence is not inferred. unmetered is declared rather than deduced from a missing limit, because "no ceiling" and "nobody filled it in" must not look the same. The failure mode this guards against is silent: a reader that stops finding a key reads zero, and a zero is an accumulator seeded empty or a premium of nothing.
Module keys are resolved by lookup, not by a map. A service type (GP_VIDEO) resolves to a module key (gp_video) by normalisation, with the catalogue consulted so an unknown type fails as "not covered" rather than resolving to a key nobody defined[9]. A short alias table carries only the pairs that differ (PHYSIOTHERAPY → physio_remote). A product can therefore introduce a module without a deploy, which was not true while the mapping was a Go map.
Inclusions and exclusions reference a vocabulary. They were bare strings, so nothing stopped "CBT" and "cognitive_behavioural_therapy" appearing in successive versions and nothing could join an exclusion to anything else. They are now {id, key, display} references into policy_admin.clinical_terms[10].
Exclusions carry their enforcement nature. enforcement: "hard" where the platform can decide it, manual_review where a clinician must. Clinical judgment exclusions surface as MANUAL_REVIEW verdicts rather than pretending to be computable[11]. Channel exclusions are gone as a separate list: a covered channel has exactly one representation, and its absence is the exclusion.
The specification beyond cover. Cover is one layer. Four blocks carry what a UK product must also state, and the test of whether they are complete is whether an IPID can be generated from them[12]:
| Block | Carries |
|---|---|
contract | product type, territorial scope, term length, renewal (including discloses_previous_premium), cancellation after the cooling-off window, member obligations |
underwriting | style (moratorium · full_medical_underwriting · cpme · mhd), moratorium months, and continuity_credit_accepted: whether cover moved from another insurer keeps its served waiting periods |
cost_sharing | policy-level excess and copayment, both nullable. The slot exists so a product can be sold at two prices; this one is not, and says so |
governance | target market (whose age bounds are also product eligibility), IPID pin, fair-value review date |
Full reasoning, and what was deliberately not added, in What a UK product specification has to say.
charge_types - the version declares its money vocabulary: PREMIUM (employer pays) and CONTRIBUTION (member's share). Billing's charge category is expected to stay within it.
4. Invariants
| Invariant | Enforced by |
|---|---|
code, both locators unique | DB unique constraints[1] |
| One edition number per product | DB UNIQUE (product_id, version)[2] |
| Version belongs to a real product | DB FK (in-service)[2] |
status, policy_structure vocabularies | DB CHECKs - policy-admin is the best-constrained schema in the estate |
| Published versions are immutable | Application: catalogue edits return ErrPublished once published[14] |
| Catalogue shape is valid | Not enforced at publish, so a typo'd machine key changes behaviour silently. The catalogue package is the de-facto schema; making it a publish gate is the fix (§9) |
5. Lifecycle
Publish is a real endpoint (PATCH /products/{locator}/versions/{version}/publish[16], stamping published_at[17]) and publishing is what arms the immutability guard. Repricing or changing benefits means creating version N+1; policies keep the version they were sold under, which is what makes "what exactly did this member buy in 2026" permanently answerable.
6. Populated example: OHC-2026, walked end to end
Five products are live (GH-2025, DEN-2025, OHC-2026, OHC-BASE-2026, OHC-UNL-2026 - all market GB, all ACTIVE). The catalogue is also what the member-facing Benefit Schedule renders from - industry convention: "the schedule attached to the policy will usually show the applicable limits"
OHC-2026 (Olly Health Cover,STANDARD tier, the product Flow-0 sells) is the worked example; the complete document is at product-example.json.
The product row
{ "code": "OHC-2026", "name": "Olly Health Cover", "lob": "health",
"market": "GB", "status": "ACTIVE", "locator": "PRD-…" }Identity only. Everything commercial lives on the versions.
Two editions
| v1 | v2 | |
|---|---|---|
| modules / addons / exclusions | identical | identical |
| premium | absent | 19.5, under pricing |
policy_structure | GROUP_MASTER | GROUP_MASTER |
v2 exists because the premium moved into the catalogue, a commercial change, so it is a new edition rather than an edit. Policies issued against v1 still pin v1; nothing they were sold under changed. This is the versioning model doing its job on a real change.
The 2026-08-18 reshape is a different kind of change and was handled differently. It altered the shape of the catalogue rather than what it sells, so a new edition would have asserted a commercial change that did not happen. The data was converted in place instead, across all three tables that carry a catalogue, and the conversion is a script that can be re-read: infra/local/reconcile/catalogue_to_v2.py. Rewriting an in-force snapshot is normally the one thing effective-dated versioning exists to prevent; it was acceptable here because this is a development estate and carrying a compatibility layer through six services costs more than the history is worth. It would not be acceptable against real policies.
The catalogue, section by section (v2 element_schema)
term - the contractual mechanics:
"term": { "start": "on_application_confirmation",
"payment": "monthly_direct_debit",
"renewal_notice": true, "cooling_off_days": 14 }cooling_off_days: 14 is the member's cancellation right; start encodes D-07's coverage-at-enrol position in product language. All copy today - no code reads this block.
tier - "STANDARD". The value that must agree with policies.plan_tier and billing's price lookup (the stringly-typed glue noted in §9).
The modules - six, in three metering archetypes. Same key set in every one; only the values differ. One of each, annotated:
Session-metered - gp_video:
"gp_video": {
"label": "GP video consultations", "type": "clinical_service",
"unit": "sessions", "included_per_cycle": 5, "cycle_limit": null,
"currency": "GBP", "channels_covered": ["video"],
"referral_required": false, "waiting_periods": [],
"contribution": { "applies": "after_included", "basis": "fixed",
"amount": 25.0, "currency": "GBP" },
"includes": [{ "id": "f065a724-…", "key": "gp_consultation",
"display": "GP consultation" }, …],
"excludes": [ … ]
}| Key | Read by | What actually happens |
|---|---|---|
unit: "sessions" + included_per_cycle: 5 | projection[6] | seeds a SESSION_LIMIT accumulator, limit_amount = 5 |
channels_covered: ["video"] | verdicts[7] | an in-person GP request returns EXCLUDED, because in_person is not in the list |
contribution | display, billing | £25 a session after the first five. Displayed, not yet collected (§9 of the Scheme money pages) |
includes / excludes | vocabulary joins | resolvable back to clinical_terms rather than being prose |
Money-metered - diagnostics:
"diagnostics": {
"label": "Diagnostics (blood and urine tests)", "type": "diagnostic",
"unit": "money", "included_per_cycle": null, "cycle_limit": 250.0,
"currency": "GBP", "channels_covered": ["in_person"],
"referral_required": true,
"waiting_periods": [{ "kind": "initial", "days": 30, "conditions": [] }],
"contribution": { "applies": "per_use", "basis": "quoted_in_advance",
"amount": null, "currency": "GBP" }
}| Key | Read by | What actually happens |
|---|---|---|
unit: "money" + cycle_limit: 250 | projection | seeds a BENEFIT_LIMIT accumulator, limit_amount = 250.00 GBP |
referral_required: true | verdicts | every check returns a REQUIRES_REFERRAL verdict; enforced at booking |
waiting_periods | verdicts, schedule | 30 days from cover start before a test is payable |
contribution.applies: "per_use" | display | every use carries a share, quoted before the service |
Unmetered - digital (and neurodiversity):
"digital": {
"label": "Ask Olly and the app", "type": "platform_capability",
"unit": "unmetered", "included_per_cycle": null, "cycle_limit": null,
"channels_covered": ["video", "phone"], "contribution": null,
"waiting_periods": []
}unit: "unmetered" is declared, not inferred from two null limits. It seeds a USAGE accumulator with no ceiling, so consumption stays recordable: otherwise "unmetered" would quietly mean "untracked".
addons - preventative_panel, flagged optional: true: cover the member can elect on top of the tier. Election lands on the policy element, not here.
exclusions - objects, not strings, each carrying its enforcement nature:
{ "id": "2b4237e8-…", "key": "emergency_treatment",
"display": "Emergency treatment",
"basis": "emergency", "enforcement": "hard" }| Exclusion | Enforced how |
|---|---|
emergency_treatment | hard: the emergency flag short-circuits to EXCLUDED ("go to A&E / 999")[11] |
chronic_condition_symptom_control, investigations_into_diagnosed_conditions | manual_review: clinical judgment, deliberately not computed |
The three channel exclusions this list used to carry (in_person_gp_consultations and siblings) are gone. They said in a second place what channels_covered already says, and two representations that can disagree is the thing the reshape removed.
underwriting - where pre-existing conditions now live:
"underwriting": { "style": "moratorium", "moratorium_months": 24,
"declared_conditions_required": false,
"continuity_credit_accepted": true }This was restrictions.pre_existing_conditions.basis = "assessed_at_claim", a single string that meant moratorium without saying so. It still surfaces as a MANUAL_REVIEW verdict on every eligibility check[11].
restrictions - what remains is copy: residency, access channel, and two statements of process.
pricing - { "premium_per_member": 19.5, "per": "month", "currency": "GBP" }. Ten employees on STANDARD gives ten £19.50 charges a month on the employer's invoice. Billing resolves this by planTier through GET /onboarding/products[13], with a caveat worth knowing: see §9.
Where the catalogue lands downstream
The same bytes travel the whole platform:
Snapshot at issue - the policy example's element carries this exact catalogue in
coverage_terms.Accumulators seeded from it - live rows in
eligibility.coverage_accumulators:type coverage_term_key limit consumed SESSION_LIMIT gp_video 5 9 SESSION_LIMIT gp_video 5 3 BENEFIT_LIMIT diagnostics 250.00 240.00 BENEFIT_LIMIT diagnostics 250.00 85.00 Note
consumed 9 of limit 5: consumption keeps counting past the included allowance - sessions 6-9 are the contribution-payable ones, exactly thecontribution_after_includedsemantics. And the member at £240 of £250 diagnostics is one blood test away from exhausting the benefit.Claim lines name it -
claim_lines.coverage_term_key = "gp_video"is what joins an adjudicated line back to this module and its accumulator.
7. Who references a version
| Where | Column / mechanism | Nature |
|---|---|---|
enrollment.policies, enrollment.quotes | product_version_id | cross-service uuid soft ref - the pin |
enrollment.policy_elements | coverage_terms jsonb | snapshot of the catalogue at issue |
group_scheme.schemes | product_version_id | staged for the #219 elevation (migration 0007) |
billing pricing | planTier → catalogue premium[13] | consumer coupling: enrollment's tier and billing's lookup must resolve the same price |
policy_admin.field_definitions, rating_factors, rate_tables, rule_sets | product_version_id | real FKs (in-service) - the per-version config layer: dynamic capture fields, rating factors, rate tables, rulesets. Each activates when its rows are written, per product, with no migration (§9) |
8. Design determinations
- Product is data, not code - the catalogue drives adjudication, accumulators and pricing generically; changing what a product covers is a version, not a deploy.
- One shape, one reader -
packages/go/catalogueis the only definition of what a catalogue is. The alternative, a compatibility layer over two shapes, would have outlived the thing it was compatible with. - No fallbacks, because the failure is silent - a reader that stops finding a key reads zero, not an error. That is why the reshape was a hard data migration rather than a tolerant parser.
- Both contract structures, config-selected -
policy_structureon the version. #225 (R-01) under #219. - Immutable editions, pinned at issue - policies reference the version they were sold under; elements snapshot it. Effective-dating starts here.
- Machine-enforceable vs clinical-judgment exclusions are split on purpose -
enforcementstates which a given exclusion is, rather than leaving a reader to infer it. - A vocabulary, not strings -
clinical_termsgives every inclusion and exclusion a stable id. SNOMED CT is the target coding system and every term is currently unmapped: a term without a code is usable, a term with a wrong one is worse than none. - The specification is complete when the IPID can be generated from it - a sharper test than any gap list, because the IPID's layout is fixed, so the required facts are known in advance.
- Rating is modelled ahead of the products that price off factors - the factor/rate-table engine hangs off the version with real FKs, so factor-based pricing is rows in
rating_factors+rate_tablesrather than a migration. The catalogue premium is the price source today.
9. Caveats and extensibility
Group and individual. One product family serves both: publish a version with policy_structure = MEMBER_LEVEL and the same catalogue sells direct-to-consumer. Nothing else on these tables is group-aware.
Where to extend, and where the change lands. The catalogue was modelled ahead of the products that use it, so most of the following is a data change on a new version rather than a migration.
| When we need … | What to add | Where |
|---|---|---|
| a new benefit (an extra therapy, a second diagnostics pot) | a module entry in element_schema carrying the standard key set | a new version via POST /products/{locator}/versions, then PATCH …/publish[16]. The key resolves by lookup, not by a Go map[9] |
| that benefit to meter itself | nothing further: unit plus included_per_cycle / cycle_limit seed the accumulator, and the projection walks modules and addons alike | eligibility/internal/projection/accumulators.go[6] |
| an elective benefit bought on top of the tier | the same entry under addons with optional: true (a price_annual marks it elective on its own) | element_schema.addons; the onboarding projection surfaces it as an add-on |
| a price or cover change on a live product | version N+1; in-force policies keep the version they were sold under | POST /products/{locator}/versions |
| a new inclusion or exclusion term | a clinical_terms row (id = UUIDv5 of the key), then a {id, key, display} reference from the catalogue | a migration in services/policy-admin/migrations/, following 0025/0026's ON CONFLICT (key) DO NOTHING seed[10]. Vocabulary writes are migration-only today; a write API is the change if authoring moves to clinicians |
| terms coded for exchange | snomed_code per row. Nullable on purpose: a term is usable before it is coded, so coding is incremental (42 terms carry keys today, 0 carry codes) | policy_admin.clinical_terms.snomed_code |
| per-product data captured at quote or issue | a typed field_definition per version (STRING/NUMBER/BOOLEAN/DATE/ENUM, required, allowed_values); enrollment stores answers in quote_field_values / policy_field_values and gates issue on the required ones | POST /products/{locator}/versions/{version}/fields; consumers on Enrollment: dynamic fields & underwriting |
| pricing off factors rather than a flat premium | rating_factors (source_path, data_type) + rate_tables rows against the version | policy_admin.rating_factors / rate_tables, read by GET /internal/product-versions/{id}/rating |
| eligibility or underwriting rules per product | a rule_set + rules for the version; enrollment evaluates the pricing and underwriting sets by name against the quote document | policy_admin.rule_sets; GET /internal/products/{locator}/versions/{version}/rulesets/{name} |
| to sell into a second market | a market_profile and a published version of it carrying jurisdiction_code, regulatory_regime, feature_flags, mandatory_fields, document_templates; products carry the matching market code | POST /market-profiles + PATCH …/versions/{v}/publish, authored in web-admin. One profile is live (GB v2, regime PMI_UK_FCA, published). Product-side filtering is the code change: repository.ProductFilter carries status/limit/offset, so the list routes return every market |
| an IPID and a fair-value review date | governance.ipid_ref + fair_value_review on the version; the block is already shaped for both | element_schema.governance[12] |
| the same cover sold at two prices | cost_sharing.excess / .copayment on the version, both nullable slots that already exist | element_schema.cost_sharing[12] |
Publishing state. OHC-2026 v2, the version behind live cover, is PUBLISHED (2026-08-19), so the ErrPublished immutability guard[14] is armed on it. The remaining catalogue editions are DRAFT and therefore still editable, which is what a draft is for.
Known defects, with the fix:
- The public catalogue route ignores version status.
buildOnboardingProducttakes the highest version carrying a tier and modules,DRAFTorPUBLISHED, so a draft edition can render on the public quote funnel and price it. Fix: preferPUBLISHEDin that version choice, inservices/policy-admin/internal/handler/onboarding_products.go; the response already carriesversionStatusfor the caller to see[15]. - No shape validation at publish. Nothing rejects a catalogue whose modules disagree with the shape this page describes, and a typo'd machine key changes behaviour silently. Fix: run
packages/go/catalogueparse-and-validate as a gate insidehandlePublishVersion[16]. GET /onboarding/productswas not mounted until 2026-08-18. The handler was written, unit-tested and left unregistered, so it 404'd from the day it landed. Both callers failed silently: enrollment's tier resolution returnedErrProductVersionUnavailable, and billing fell through topricing.fallbackPence, a hardcoded mirror of the catalogue commented "for use only when policy-admin cannot be reached"[13]. It could not be reached, so the mirror was the only price path. The prices agreed, so nothing was mischarged, but the catalogue was not billing's price source until the route was mounted. Fix landed, and the guard against a repeat isTestOnboardingProductsRouteIsMounted, which walks the real route tree and asserts the public routes are registered.plan_tieris stringly-typed glue. Enrollment'splan_tier, billing's price lookup and the catalogue'stieragree by convention only. Fix: resolve the tier case-insensitively at both readers, or promote the tier to a checked vocabulary shared frompackages/go/domain; billing's own tests hold the two lineages together meanwhile.
Open questions, worth settling before the shape hardens: who may add a vocabulary term (it is a migration today); whether a waiting period is served once for the life of a membership or re-served at renewal; whether per-claim caps ("up to £X per test", common in UK PMI) are needed alongside the cycle cap; and whether network constraints are a module inclusion or a different kind of rule. Discussed in Product catalogue: the shape.
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. Pins are checked mechanically by docs/site/scripts/check-code-refs.py, which compares each cited range against the target commit and fails on a claim whose code has moved out from under it.
policy-admin/migrations/0004_create_products.sql- products DDL: status CHECK L8, UNIQUEs L11-12policy-admin/migrations/0005_create_product_versions.sql- versions DDL: 4 jsonb schemas L7-10, status CHECK L11, UNIQUE (product, version) L16packages/go/domain/products.go#L10- Product + ProductVersion modelspolicy-admin/migrations/0016_add_policy_structure.sql- policy_structure + CHECK, model stated in the commentpackages/go/catalogue/catalogue.go#L48-Module: the key set every module carrieseligibility/internal/projection/accumulators.go#L60- unit → accumulator type: unmetered / SESSION_LIMIT / BENEFIT_LIMITpackages/go/catalogue/catalogue.go#L79-CoversChannel: absence from the list is the exclusionpackages/go/catalogue/catalogue.go#L96-WaitingPeriod+WaitingDaysFor: where periods overlap the longer appliespackages/go/catalogue/catalogue.go#L341-ModuleKeyForServiceType: lookup, not a hardcoded mappolicy-admin/migrations/0025_create_clinical_terms.sql#L11- clinical vocabulary DDL; ids are UUIDv5 of the keyeligibility/internal/handler/verdicts.go#L34-EvaluateExclusions: hard checks + MANUAL_REVIEW surfacingpackages/go/catalogue/catalogue.go#L128-Contract,Underwriting,CostSharing,Governancebilling/internal/pricing/pricing.go#L53-fallbackPence, the mirror that was the only price path until the route was mountedpolicy-admin/internal/service/field_definition.go#L40-ErrPublishedimmutability guardpolicy-admin/internal/handler/handler.go#L192- the public onboarding routes, including the catalogue projectionpolicy-admin/internal/handler/products.go#L32- publish route (handlePublishVersionat L291)policy-admin/internal/repository/gorm_product.go#L142-PublishVersionrepository write
Live-schema facts (constraint list, FK graph, all-versions-DRAFT observation, 42 vocabulary terms with 0 SNOMED codes) come from docker exec olly-postgres psql -U olly -d policy_admin on dev-2, 2026-08-19.
