Skip to content

Product catalogue: the shape

A product version carries the whole commercial definition of what Olly sells as data, not code. Adjudication, accumulator seeding and pricing all read it generically, so changing what a product covers is a new version rather than a deploy. That only works if the shape is predictable, which is what this note fixes.

Worked example: product-example.json. The table it describes is covered in the Product & catalogue deep-dive; the member-facing meaning of each term is in Plan terms.

1. Every module carries the same keys

A module is one benefit. Whatever the benefit, the key set is identical, and a key that does not apply is null rather than absent - a reader never has to ask whether a missing key means "no limit" or "nobody filled it in".

KeyMeaning
labelmember-facing name
typeclinical_service · diagnostic · platform_capability
unitsessions · money · unmetered - how consumption is counted
included_per_cyclesessions included before a contribution applies (sessions only)
cycle_limitmoney cap for the cycle (money only)
currencyISO code for any amount on the module
channels_covereddelivery channels the product insures for this module
referral_requiredwhether an Olly referral gates booking
waiting_periodstyped list: initial, condition-specific, pre-existing. Where they overlap the longer applies
contributionthe member's share, or null when there is none
includes / excludesvocabulary references, see §3

Addons carry the same keys plus optional: true. They are modules the member elects, not a different kind of thing.

Cycle, not year. Limits are per policy term, declared once as "cycle": "policy_term". included_per_year implied a calendar year the model does not use: accumulators are keyed by term, so a renewal starts fresh.

2. Type says what a module is

unit describes how a benefit is metered; type describes what it is. Without it, "5 GP sessions", "£250 of tests" and "the app" are structurally identical and only prose distinguishes them.

  • clinical_service - a clinician delivers care (GP, physiotherapy, therapy)
  • diagnostic - a test is performed and reported
  • platform_capability - Olly itself provides it (triage, the app, guidance)

The split matters downstream: a claim against a clinical_service needs a provider, a diagnostic needs a referral and a result, and a platform_capability is not claimable at all.

3. Inclusions and exclusions reference a vocabulary

Previously these were bare strings ("cbt", "imaging"). Nothing defined them, so nothing stopped "CBT" or "cognitive_behavioural_therapy" appearing in the next version, and nothing could join an exclusion to anything else.

They are now references into a shared dictionary, policy_admin.clinical_terms:

json
"includes": [
  { "id": "fdef8e26-25c7-5f1d-8aa2-b2cae28c7138",
    "key": "cbt",
    "display": "Cognitive behavioural therapy" }
]

Each entry carries a stable id to reference back, the key a catalogue author writes, and the display name a member reads. Ids are UUIDv5 of the key under a fixed namespace, so the same key resolves to the same id in every environment and a seed is reproducible rather than random.

The table:

ColumnPurpose
idstable UUID, the reference target
keyslug used in catalogues
displaymember-facing name
kindservice · condition · channel
systemcoding system, olly today
snomed_codenullable, filled by clinical review
statusACTIVE · DEPRECATED - retire a term without deleting history

SNOMED CT is the target, not the starting point. It is the coding system the NHS uses, and it is where these terms should land. Mapping each one is clinical review work, so snomed_code is nullable and empty: a term without a code is usable, a term with a wrong one is worse than none.

One shape everywhere. A policy-level exclusion is the same object as a module-level one, plus two fields:

json
{ "id": "…", "key": "emergency_treatment", "display": "Emergency treatment",
  "basis": "emergency", "enforcement": "hard" }

enforcement says how the exclusion is decided: hard where the platform can decide it, manual_review where a clinician must. Deliberately explicit - pretending a clinical judgement is computable is the worse failure.

4. Channels are a list, not a name

channels_covered names the delivery channels the product insures for a module, drawn from channel_vocabulary (video, phone, in_person, inpatient).

Doctor type and delivery channel are independent axes. Every clinician type can in principle be seen remotely or in person; which of those this product pays for is a commercial choice. So a richer product covers the same module with ["video", "phone", "in_person"], and no new module key is needed.

This also removes a duplicate representation: "in-person GP is not covered" used to be stated twice, once as channel: "video_only" and again as an entry in an excluded array, which could disagree. A covered channel now has exactly one representation, and its absence is the exclusion.

5. Contribution is one object, or null

The member's share is {applies, basis, amount, currency} on every module that has one, and null on every module that does not.

  • applies: "after_included" - free until the included allowance is used, then charged per use (GP, physiotherapy, therapy: £25 a session after five)
  • applies: "per_use" - every use carries a share, quoted before the service (diagnostics)

Previously this was a number on one module (contribution_after_included: 25) and a string on another (contribution_per_use: "fixed_confirmed_in_advance"), which is the same concept in two shapes. null is used rather than applies: "none" with three empty siblings: a currency on a charge that does not exist is noise.

6. Waiting periods

Every module declares waiting_periods, a list rather than a number, because a UK product carries several kinds at once and where they overlap the longer applies:

json
"waiting_periods": [
  { "kind": "initial",   "days": 30, "conditions": [] },
  { "kind": "condition", "days": 365,
    "conditions": [{ "id": "…", "key": "hernia", "display": "Hernia" }] }
]

kind is initial (all illness), condition (naming the conditions it applies to, referencing the same vocabulary as includes/excludes) or pre_existing. An empty list means cover is immediate.

They exist to price the product honestly: without one, a member can buy cover on Monday for a course of therapy booked on Tuesday. The values in the example (0 for GP and platform capabilities, 14 for therapy and physiotherapy, 30 for diagnostics, 90 for the preventative panel) are illustrative and want a commercial decision.

7. The specification beyond cover

Cover is one layer. A product sold in the UK also has to state its contract terms, how it treats medical history, what it costs beyond the premium, and who it is for. Those live in four blocks alongside modules, and the test for whether they are complete is whether an IPID can be generated from them.

contract - product type, territorial scope, term length, renewal (with discloses_previous_premium, which a consumer renewal notice is required to carry), cancellation including what happens after the cooling-off period, and the member's obligations. These are exactly the IPID rows the catalogue could not previously answer.

underwriting - the style, from the UK set: moratorium, full_medical_underwriting, cpme, mhd. Plus continuity_credit_accepted, which decides whether cover moved from another insurer carries its served waiting periods across. That single flag is what an employer switching provider asks about first.

cost_sharing - policy-level excess and copayment, both nullable. The slot exists so a product can be sold at two prices; a product that sells one price at one level of cover sets both to null and says so explicitly rather than leaving the absence to be inferred. Per-module contribution is unaffected: it is a per-benefit co-payment and already works.

governance - target market (whose age bounds double as product eligibility), the IPID version this catalogue corresponds to, and the date of the last fair-value review.

Open questions

Worth settling before this shape hardens:

  1. Who owns the vocabulary? Adding a term is currently a migration. It probably wants an authoring surface, and a rule about who may add one.
  2. When does SNOMED mapping happen? Every term is unmapped today. The trigger is likely the first integration that must speak a clinical code outward (a provider, a lab, an FHIR record).
  3. Per-claim caps. There is a cycle cap and an included allowance, but no per-event limit ("up to £X per test"), which UK PMI commonly carries.
  4. Waiting-period interaction with renewal. Does a waiting period reset on renewal, or is it served once for the life of the membership? Serving it again each year would be unusual and would surprise members.
  5. Network constraints. partner_laboratory_processing is currently an included service rather than a network rule. If cover depends on which provider delivers it, that is a different kind of constraint.

Olly Health Insurance Platform