Skip to content
Updated Aug 22, 2026

Product & catalogue

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

Tablespolicy_admin.products, policy_admin.product_versions, policy_admin.clinical_terms[1][2]
Owner servicepolicy-admin (sole writer)
LocatorsPRD- (products) · PVR- (versions)
Last updated2026-08-19
CompanionERD 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 inJoin
the cover someone holdsenrollment.policies pins a product_version_id at issue; the elements snapshot the catalogue into coverage_termsuuid soft ref
a price listpricing 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 factorsin-service FK
the eligibility ruleseteligibility evaluates the snapshot on member_coverage.coverage_terms, never this table - the product can change without touching in-force covervia snapshot
a per-member thingversions are per-product; the member-specific view is the policy element (next pages)-
the market/regulatory configmarket_profiles (+versions) carry per-jurisdiction flags and rules; products pin a market codemarket

3. Structure

DDL[1][2] · Go models[3]

products

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE PRD-
codetextUNIQUE. The business identity: OHC-2026, DEN-2025
name, lobtextDisplay name; line of business (health, dental)
marketvarchar(10)Default 'GB'; indexed
statustextCHECK ACTIVE | INACTIVE[1]

product_versions

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE PVR-
product_iduuidReal FK to products (in-service)
versionintUNIQUE with product_id - the edition number
policy_schema, claim_schemajsonbDocument-shape declarations
element_schemajsonbThe benefit catalogue - see below
charge_typesjsonbCharge vocabulary: ["PREMIUM", "CONTRIBUTION"]
policy_structuretextCHECK GROUP_MASTER | MEMBER_LEVEL, default GROUP_MASTER[4]
statustextCHECK DRAFT | PUBLISHED[2]
published_attimestamptzStamped 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.

KeyRead byEffect
typedisplay, claim routingclinical_service · diagnostic · platform_capability
unitprojection[6]sessions → SESSION_LIMIT · money → BENEFIT_LIMIT · unmetered → USAGE, no ceiling
included_per_cycleprojectionseeds the session limit
cycle_limitprojectionseeds the money limit
channels_coveredverdicts[7]a channel absent from the list is not covered
referral_requiredverdictsREQUIRES_REFERRAL verdict
waiting_periodsverdicts, schedule[8]gap before the benefit is usable; where periods overlap the longer applies
contributiondisplay, billingthe member's share, or null
includes / excludesvocabulary joinsreferences 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 (PHYSIOTHERAPYphysio_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]:

BlockCarries
contractproduct type, territorial scope, term length, renewal (including discloses_previous_premium), cancellation after the cooling-off window, member obligations
underwritingstyle (moratorium · full_medical_underwriting · cpme · mhd), moratorium months, and continuity_credit_accepted: whether cover moved from another insurer keeps its served waiting periods
cost_sharingpolicy-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
governancetarget 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

InvariantEnforced by
code, both locators uniqueDB unique constraints[1]
One edition number per productDB UNIQUE (product_id, version)[2]
Version belongs to a real productDB FK (in-service)[2]
status, policy_structure vocabulariesDB CHECKs - policy-admin is the best-constrained schema in the estate
Published versions are immutableApplication: catalogue edits return ErrPublished once published[14]
Catalogue shape is validNot 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

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

v1v2
modules / addons / exclusionsidenticalidentical
premiumabsent19.5, under pricing
policy_structureGROUP_MASTERGROUP_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:

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

json
"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": [  ]
}
KeyRead byWhat actually happens
unit: "sessions" + included_per_cycle: 5projection[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
contributiondisplay, billing£25 a session after the first five. Displayed, not yet collected (§9 of the Scheme money pages)
includes / excludesvocabulary joinsresolvable back to clinical_terms rather than being prose

Money-metered - diagnostics:

json
"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" }
}
KeyRead byWhat actually happens
unit: "money" + cycle_limit: 250projectionseeds a BENEFIT_LIMIT accumulator, limit_amount = 250.00 GBP
referral_required: trueverdictsevery check returns a REQUIRES_REFERRAL verdict; enforced at booking
waiting_periodsverdicts, schedule30 days from cover start before a test is payable
contribution.applies: "per_use"displayevery use carries a share, quoted before the service

Unmetered - digital (and neurodiversity):

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

json
{ "id": "2b4237e8-…", "key": "emergency_treatment",
  "display": "Emergency treatment",
  "basis": "emergency", "enforcement": "hard" }
ExclusionEnforced how
emergency_treatmenthard: the emergency flag short-circuits to EXCLUDED ("go to A&E / 999")[11]
chronic_condition_symptom_control, investigations_into_diagnosed_conditionsmanual_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:

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

  1. Snapshot at issue - the policy example's element carries this exact catalogue in coverage_terms.

  2. Accumulators seeded from it - live rows in eligibility.coverage_accumulators:

    typecoverage_term_keylimitconsumed
    SESSION_LIMITgp_video59
    SESSION_LIMITgp_video53
    BENEFIT_LIMITdiagnostics250.00240.00
    BENEFIT_LIMITdiagnostics250.0085.00

    Note consumed 9 of limit 5: consumption keeps counting past the included allowance - sessions 6-9 are the contribution-payable ones, exactly the contribution_after_included semantics. And the member at £240 of £250 diagnostics is one blood test away from exhausting the benefit.

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

WhereColumn / mechanismNature
enrollment.policies, enrollment.quotesproduct_version_idcross-service uuid soft ref - the pin
enrollment.policy_elementscoverage_terms jsonbsnapshot of the catalogue at issue
group_scheme.schemesproduct_version_idstaged for the #219 elevation (migration 0007)
billing pricingplanTier → 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_setsproduct_version_idreal 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

  1. Product is data, not code - the catalogue drives adjudication, accumulators and pricing generically; changing what a product covers is a version, not a deploy.
  2. One shape, one reader - packages/go/catalogue is 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.
  3. 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.
  4. Both contract structures, config-selected - policy_structure on the version. #225 (R-01) under #219.
  5. Immutable editions, pinned at issue - policies reference the version they were sold under; elements snapshot it. Effective-dating starts here.
  6. Machine-enforceable vs clinical-judgment exclusions are split on purpose - enforcement states which a given exclusion is, rather than leaving a reader to infer it.
  7. A vocabulary, not strings - clinical_terms gives 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.
  8. 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.
  9. 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_tables rather 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 addWhere
a new benefit (an extra therapy, a second diagnostics pot)a module entry in element_schema carrying the standard key seta 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 itselfnothing further: unit plus included_per_cycle / cycle_limit seed the accumulator, and the projection walks modules and addons alikeeligibility/internal/projection/accumulators.go[6]
an elective benefit bought on top of the tierthe 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 productversion N+1; in-force policies keep the version they were sold underPOST /products/{locator}/versions
a new inclusion or exclusion terma clinical_terms row (id = UUIDv5 of the key), then a {id, key, display} reference from the cataloguea 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 exchangesnomed_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 issuea 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 onesPOST /products/{locator}/versions/{version}/fields; consumers on Enrollment: dynamic fields & underwriting
pricing off factors rather than a flat premiumrating_factors (source_path, data_type) + rate_tables rows against the versionpolicy_admin.rating_factors / rate_tables, read by GET /internal/product-versions/{id}/rating
eligibility or underwriting rules per producta rule_set + rules for the version; enrollment evaluates the pricing and underwriting sets by name against the quote documentpolicy_admin.rule_sets; GET /internal/products/{locator}/versions/{version}/rulesets/{name}
to sell into a second marketa market_profile and a published version of it carrying jurisdiction_code, regulatory_regime, feature_flags, mandatory_fields, document_templates; products carry the matching market codePOST /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 dategovernance.ipid_ref + fair_value_review on the version; the block is already shaped for bothelement_schema.governance[12]
the same cover sold at two pricescost_sharing.excess / .copayment on the version, both nullable slots that already existelement_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. buildOnboardingProduct takes the highest version carrying a tier and modules, DRAFT or PUBLISHED, so a draft edition can render on the public quote funnel and price it. Fix: prefer PUBLISHED in that version choice, in services/policy-admin/internal/handler/onboarding_products.go; the response already carries versionStatus for 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/catalogue parse-and-validate as a gate inside handlePublishVersion[16].
  • GET /onboarding/products was 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 returned ErrProductVersionUnavailable, and billing fell through to pricing.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 is TestOnboardingProductsRouteIsMounted, which walks the real route tree and asserts the public routes are registered.
  • plan_tier is stringly-typed glue. Enrollment's plan_tier, billing's price lookup and the catalogue's tier agree by convention only. Fix: resolve the tier case-insensitively at both readers, or promote the tier to a checked vocabulary shared from packages/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.

  1. policy-admin/migrations/0004_create_products.sql - products DDL: status CHECK L8, UNIQUEs L11-12
  2. policy-admin/migrations/0005_create_product_versions.sql - versions DDL: 4 jsonb schemas L7-10, status CHECK L11, UNIQUE (product, version) L16
  3. packages/go/domain/products.go#L10 - Product + ProductVersion models
  4. policy-admin/migrations/0016_add_policy_structure.sql - policy_structure + CHECK, model stated in the comment
  5. packages/go/catalogue/catalogue.go#L48 - Module: the key set every module carries
  6. eligibility/internal/projection/accumulators.go#L60 - unit → accumulator type: unmetered / SESSION_LIMIT / BENEFIT_LIMIT
  7. packages/go/catalogue/catalogue.go#L79 - CoversChannel: absence from the list is the exclusion
  8. packages/go/catalogue/catalogue.go#L96 - WaitingPeriod + WaitingDaysFor: where periods overlap the longer applies
  9. packages/go/catalogue/catalogue.go#L341 - ModuleKeyForServiceType: lookup, not a hardcoded map
  10. policy-admin/migrations/0025_create_clinical_terms.sql#L11 - clinical vocabulary DDL; ids are UUIDv5 of the key
  11. eligibility/internal/handler/verdicts.go#L34 - EvaluateExclusions: hard checks + MANUAL_REVIEW surfacing
  12. packages/go/catalogue/catalogue.go#L128 - Contract, Underwriting, CostSharing, Governance
  13. billing/internal/pricing/pricing.go#L53 - fallbackPence, the mirror that was the only price path until the route was mounted
  14. policy-admin/internal/service/field_definition.go#L40 - ErrPublished immutability guard
  15. policy-admin/internal/handler/handler.go#L192 - the public onboarding routes, including the catalogue projection
  16. policy-admin/internal/handler/products.go#L32 - publish route (handlePublishVersion at L291)
  17. policy-admin/internal/repository/gorm_product.go#L142 - PublishVersion repository 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.

Olly Health Insurance Platform