Scheme & roster
Schema deep-dive · living document · #2 in the reading sequence
| Tables | group_scheme.schemes, group_scheme.scheme_members[1] |
| Owner service | group-scheme-service (sole writer) |
| Locator | SCH-YYYY-NNNNNN (schemes; roster rows have no locator) |
| Last updated | 2026-08-19 |
| Companion | ERD story, slide 2 · Group Schemes (narrative) · previous: Party |
1. Scope and usage
A Scheme is the commercial agreement between an employer and Olly: "we cover your team on these terms". The roster (scheme_members) is who is in it. Together they answer "which employer, which employees" - and deliberately nothing else: the cover itself is a policy (next pages), the money is a billing account, the people are parties.
Employer-paid healthcare is a recognised PMI segment in its own right, with its own commercial mechanics - down to the employee's P11D benefit-in-kind tax treatment .
Per decision #219 the scheme is the group contract - there is no separate group_contracts table. It governs payment and grouping, not insurance: whether the member's policy is their own contract or a certificate under a group master is decided by policy_structure on the product version, never here.
2. Boundaries and relationships
| Scheme / roster is not… | That concern lives in | Join |
|---|---|---|
| the employer | policy_admin.parties (ORGANISATION); employer_party_locator points at it[1] | locator |
| the cover | enrollment.policies - a roster row points at the member's policy, it never is one[1] | policy_locator (nullable text) |
| the payer of record | billing.accounts, keyed by the employer's party locator (org_locator) | locator, indirect |
| a membership lifecycle authority | member activation is an open design (D-42, #1271); roster status defaults to the legacy immediate 'ACTIVE'[3] | - |
| an eligibility source | coverage starts at enrol by decision D-07 (#1010); eligibility reads projections of enrollment events, never this roster | - |
Joining a scheme does not make you covered. The roster row records membership; cover exists only once enrollment issues the policy the row's policy_locator then points at. Membership and cover are different facts with different owners.
3. Structure
schemes
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK, internal |
locator | text | ✓ | UNIQUE. SCH-YYYY-NNNNNN |
code | text | ✓ | UNIQUE. Human/business scheme code |
name | text | ✓ | Display name |
employer_party_locator | text | ✓ | The sponsoring employer's PTY-; indexed |
status | text | ✓ | Default 'ACTIVE'; convention, no CHECK |
account_id, product_version_id, inception_date, renewal_date, premium_basis | uuid/date/jsonb | Group-contract fields (#219), codified by migration 0007 and mapped on the Go model; reserved for the elevation, unset on live schemes - see §9 |
scheme_members
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK. No locator - roster rows are not externally addressable |
scheme_id | uuid | ✓ | Real FK to schemes (in-service) |
member_party_locator | text | ✓ | The employee's PTY-; UNIQUE with scheme_id |
policy_locator | text | The POL- issued for this member; indexed, no FK (cross-service) | |
status | text | ✓ | Default 'ACTIVE' (legacy immediate value) |
dispatched_at, activated_at | timestamptz | Gift-box lifecycle timestamps (J-003) | |
demographics | jsonb | Enrolment-time capture from the quote funnel |
Field-by-field: what and why
schemes.locator vs schemes.code - two external identifiers on purpose. The locator is the platform's minted reference (locator_seq_sch, same per-prefix sequence machinery as Party[2][6]); the code is the business-facing handle a broker or HR team quotes. Why both? Codes carry meaning and provenance (live values include e2e and funnel-generated codes); meaning-bearing identifiers make poor join keys, so the meaningless locator does the joining.
employer_party_locator - the scheme's anchor to Party: the sponsoring ORGANISATION. NOT NULL and indexed[1] - "all schemes for this employer" is a first-class query. A locator, not an FK: parties live in another database.
scheme_members composite uniqueness - UNIQUE (scheme_id, member_party_locator)[1]: one roster row per person per scheme. A person may appear in two schemes (job change, overlap); they may not appear twice in one.
scheme_members.policy_locator - nullable, because the row is created before the policy exists (the composer issues the policy and back-fills the pointer). #219 names the target state: this becomes a real FK once schemes and policies share transactional context worth enforcing - today it is the standard cross-service soft reference, indexed for the reverse lookup.
status, dispatched_at, activated_at - the gift-box onboarding lifecycle (J-003): PENDING → DISPATCHED → ACTIVATED, with timestamps so a dashboard can show "box shipped / member activated"[3]. ACTIVE is the pre-lifecycle value, named as such by the repository's MemberStatus type: rows that predate the model, plus the Flow-0 single-add path, which enrols immediately and has no box to ship[7]. The gift-box states apply to the dispatch-and-activation path, whose transition machinery is in place ahead of the endpoints that drive it: SchemeService.SetMemberStatus validates the target, has the repository stamp dispatched_at / activated_at, and emits member.dispatched / member.activated (§9). None of this gates coverage (D-07): it is onboarding telemetry.
premium_basis - the rule by which this employer's premium is computed, as distinct from the list price. The product catalogue states what a member costs in the general case (OHC-2026: £19.50 per member per month); the scheme is where a specific employer's commercial deal lives, because a 500-person employer does not pay list price.
The bases group health uses in practice:
| Basis | Premium is a function of |
|---|---|
| per capita (PMPM) | headcount x a flat rate. What Olly charges today |
| age-banded | the member's age band, so one scheme costs different amounts per person |
| community-rated | nothing member-specific: everyone pays the same regardless of risk |
| experience-rated | the group's own claims history, adjusting at renewal |
Rating is one axis. Funding is the other, and it decides who carries claims risk: fully insured, risk share, cost plus, self-insured, corporate deductible. IF7 notes fully insured "is the most common approach... where there are fewer than 1,000 employees on cover", and it is the only one expressible today. Both axes belong on this column, which is why the UK specification page proposes it as {"funding": "fully_insured", "rating": "per_capita", "risk_share": null} rather than a single string.
The column is the reserved slot from the #219 group-contract elevation, typed jsonb precisely so both axes ride one value: a second funding model or rating basis is a key on the object, not a migration. It becomes load-bearing when quoting and pricing move to the scheme, which is when the determination below has to be made.
Decide before it is populated
Two commercial sources that can disagree is the failure this column can introduce. Either premium_basis overrides the catalogue price, and billing must read it, or it only describes how the catalogue price was arrived at, and billing keeps pricing from the product. Settling it while the column is empty costs a conversation; settling it afterwards costs a reconciliation.
demographics - jsonb capture from the quote funnel ({"age": 34, "sexAtBirth": "female", "postcode": "SW1A 1AA"})[5]. Why here and not on Party? It is quote-time context (what was declared when pricing this scheme), not identity - the Party record may later hold richer, verified data without disturbing what the quote was based on.
4. Invariants
| Invariant | Enforced by |
|---|---|
locator, code unique on schemes | DB unique constraints[1] |
| One roster row per member per scheme | DB UNIQUE (scheme_id, member_party_locator)[1] |
| Roster row must belong to a real scheme | DB FK (in-service)[1] |
SCH- locator format + sequence | Application, shared locator package; sequence ensured at boot[6] |
status vocabularies (scheme + member) | Convention only - no CHECK on either table |
policy_locator points at a real policy | Application only - back-filled by the composer; nothing verifies it after |
| Row changes captured to CDC | Debezium publication dbz_group_scheme (live \d) |
5. Lifecycle
Two lifecycles live here - the scheme's (one state today, ACTIVE, by convention) and the roster row's (the gift-box journey):
The transitions stamp their timestamps: dispatched_at on box-shipped, activated_at on activation.
None of these states gate coverage (D-07); D-42 (#1271, open) will move the real activation lifecycle to the identity service.
How a member joins - one hop of the Flow-0 composition (ComposeAddMember[7], exposed at POST /schemes/{locator}/members[8]): mint party → issue policy → write roster row with policy_locator → trigger notification. The full sequence diagram is on the Party page; this table holds hop 3. Bulk enrolment runs the same conceptual path per CSV row via bulk_enrollment_jobs (second-ring table, same DDL file[1]); whether bulk literally loops the composer is ADR-1164-08 (#1177, determination pending).
6. Populated example
{
"locator": "SCH-2026-000001",
"code": "OLLDEMO-2026",
"name": "Olldemo Ltd - Health Cover",
"employerPartyLocator": "PTY-2026-000001",
"status": "ACTIVE",
"createdAt": "2026-05-02T09:15:11Z",
"updatedAt": "2026-05-02T09:15:11Z"
}{
"schemeId": "…uuid of SCH-2026-000001…",
"memberPartyLocator": "PTY-2026-000006",
"policyLocator": "POL-2026-001500",
"status": "ACTIVE",
"dispatchedAt": null,
"activatedAt": null,
"demographics": { "age": 34, "sexAtBirth": "male", "postcode": "SW1A 1AA" },
"createdAt": "2026-08-17T08:33:30Z"
}Continuity with the worked examples: this roster row's policyLocator is the real policy JSON from the ERD story, whose scheme_locator points straight back here.
7. Who references a Scheme
| Database.table | Column | Meaning there |
|---|---|---|
enrollment.policies / quotes | scheme_locator | the scheme this cover was issued under |
eligibility.member_coverage | scheme_locator | carried through the projection |
billing.accounts / invoices / charges | scheme_locator | which scheme the money relates to |
documents.documents | scheme_locator | scheme-scoped documents (employer contract) |
policy_admin.onboarding_quotes | scheme_locator | the Flow-0 funnel hand-off |
All soft locator references; none are FKs (cross-service rule).
8. Design determinations
- The scheme is the group contract - no separate table; contract-shaped fields live (or will live) here. #219.
- Scheme governs payment and grouping, not insurance - member cover is a policy; CONTRACT vs CERTIFICATE is
policy_structureon the product version. #219 / #225. - Membership ≠ coverage - roster rows never gate eligibility; coverage starts at enrol. D-07 · #1010.
- Roster rows are not externally addressable - no locator; the member is addressed by
PTY-, the cover byPOL-. - Gift-box lifecycle is telemetry, not authority - timestamps for the dashboard; real activation lifecycle is D-42's to define. #1271 (open).
SCH-minting uses the shared per-prefix sequence - same determination as Party (ADR-1164-05 · #1169).- Bulk enrolment should compose, not reimplement - recommended (a) in ADR-1164-08 · #1177; determination pending.
- Contract shape modelled ahead of the commercial products that use it - the group-contract columns, the gift-box transition machinery and the
demographicsjsonb were built before quoting-at-scheme, Ops dispatch and richer enrolment capture needed them. Each of those therefore lands as write-path or endpoint work on a schema that already holds the shape. (§9)
9. Caveats and extensibility
Group and individual. The scheme is the only group-specific construct in the core model. An individual (direct-to-consumer) policy has no scheme: policies.scheme_locator stays NULL, billing pivots on the individual's own party locator, and nothing else changes. The group/individual fork costs one nullable column downstream, not a parallel model - which is why the same roster machinery does not have to be duplicated for D2C.
One employer, many schemes. employer_party_locator is NOT NULL and indexed but not unique. Subsidiaries, sites, or a second scheme on different terms for the executive population are all the same query shape today; multi-scheme employers need no model change, only product rules for which scheme a given employee joins.
The group-contract elevation is staged in schema.#219's target shape gives the scheme its contract fields - billing anchor, product pin, inception and renewal dates, premium basis - so a renewal is scheme-level, not per-policy. Migration 0007_codify_group_contract_columns.sql adds all five to schemes and the Scheme model maps them as nullable fields. The column set, the index on account_id and the model are owned; what remains is the write path that populates them, which means the elevation is service work rather than a migration of a table five other services join to.
The roster row absorbs new enrolment facts without a migration.demographics is jsonb by design (the migration says "until the party / enrollment chain carries them natively"): smoker status, dependants declared at enrol, an employer's own payroll reference all land as keys. The quote funnel can widen what it captures ahead of the identity chain modelling it.
Extension points
| When we need … | What to add | Where |
|---|---|---|
| Ops dispatch and member activation (the gift-box journey driven end to end) | routes for the two transitions; SchemeService.SetMemberStatus already validates the target, stamps dispatched_at / activated_at[3] and emits member.dispatched / member.activated | group-scheme-service/internal/handler/schemes.go, next to resend-activation; the Flow-0 single-add path keeps writing 'ACTIVE'[7] for members with no box to ship |
| Quoting and pricing at scheme level (a 500-person employer off list price) | populate premium_basis on scheme create / update, and settle the override-vs-describe determination in the warning above before any row carries a value | internal/service/scheme.go create + update; columns already there (#219) |
| Scheme-level renewal rather than per-policy | populate inception_date / renewal_date / product_version_id, then drive the renewal off the scheme; per-member endorsement already exists as SchemeUpgradeService | internal/service/scheme.go, internal/service/scheme_upgrade.go |
| A second funding or rating basis (risk share, cost plus, experience-rated) | a key on the premium_basis jsonb object | no migration; group_scheme.schemes.premium_basis |
| Richer enrolment-time capture (smoker status, dependants, payroll ref) | keys on the demographics jsonb | no migration; group_scheme.scheme_members.demographics |
| A scheme status vocabulary beyond ACTIVE (LAPSED, CANCELLED, RENEWING) | a typed vocabulary next to MemberStatus plus a CHECK migration on schemes.status | internal/repository/member_status.go pattern; a new group-scheme migration |
| A new service to scope something to a scheme | a scheme_locator text column in that service's own schema | the consuming service; nothing here, no FK (§7) |
| Employer comms in a new channel | a link table keyed on scheme_locator and party_locator, the shape slack_workspaces / slack_member_links already use | group-scheme-service/migrations, alongside 0005 / 0006 |
| CSV enrolment at employer scale | the async job substrate is already modelled - bulk_enrollment_jobs carries status, totals and an errors jsonb; whether it loops the composer is ADR-1164-08 (#1177) | internal/service/bulk_enrollment.go |
| Roster rows addressable from outside (an ops link to one membership) | a locator column plus a per-prefix sequence, the same machinery SCH- uses - this reverses determination 4, so decide it deliberately | a group-scheme migration + internal/service/scheme.go |
Known defects
Distinct from the headroom above - these are things to fix, not capabilities awaiting their product:
- The forward-only transition rule is declared but not applied on the write path.
MemberStatus.CanTransitionToencodes PENDING → DISPATCHED → ACTIVATED, andSchemeService.SetMemberStatuschecks onlystatus.Valid(), so an out-of-order transition would be accepted. Fix: load the current status and gate onCanTransitionTobefore the repository write, ininternal/service/scheme.go. Worth doing before the endpoints in the table above ship. - Both
statuscolumns are convention-only text with no CHECK on either table (§4). Fix: a CHECK migration per table, taking the vocabulary frominternal/repository/member_status.gofor the roster and a new typed vocabulary for schemes. codevalues carry funnel and e2e provenance strings. The column is unique and business-facing, so test runs and funnel autogeneration compete in one namespace. Fix: prefix or partition generated codes at the point of mint ininternal/service/scheme.go, so a real employer code cannot collide with a harness one.
The Slack link tables (slack_workspaces, slack_member_links) live in this schema as second-ring members of the roster story: a scheme-scoped channel binding and a Slack-user-to-party link, both keyed by locator like everything else here.
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.
group-scheme-service/migrations/0001_create_schema.sql- schemes L4-16 · scheme_members L18-29 · bulk_enrollment_jobs L31-430002_locator_sequence.sql-locator_seq_sch0003_member_lifecycle.sql- gift-box lifecycle + the legacy-ACTIVE admission0004_add_demographics_to_scheme_members.sql- demographics jsonbinternal/repository/repository.go#L20- Scheme + SchemeMember models, gift-box and demographics commentsinternal/service/scheme.go#L99-locators.Next(ctx, "SCH")·cmd/server/main.go#L70- sequence ensured at bootinternal/service/flow0_compose.go#L106-ComposeAddMember;#L159- roster row written with immediate'ACTIVE'internal/handler/schemes.go#L163-handleAddMember
Live-schema facts (extra columns, indexes, Debezium publication, goose history) come from psql -h 10.0.1.2 -d group_scheme · \d group_scheme.schemes, \d group_scheme.scheme_members, select * from goose_db_version, 2026-08-18.
