Skip to content
Updated Aug 22, 2026

Party

Schema deep-dive · living document

Tablepolicy_admin.parties[1]
Owner servicepolicy-admin (sole writer)
LocatorPTY-YYYY-NNNNNN
Last updated2026-08-18
CompanionERD story, slide 1 · Members & Parties (narrative)

1. Scope and usage

A Party is any legal person the platform can refer to: a covered member, an employer, (eventually) a provider. It is the root of the identity spine - one table, one locator scheme, referenced by every other service.

Use a Party when you need who someone is: name, contact, date of birth. Do not use it for what someone can do, what they are covered for, or how they log in - those live elsewhere (see Boundaries).

Party is deliberately thin and timeless. It has no status column, no roles inline, no credentials. Everything situational hangs off it by reference.

2. Boundaries and relationships

What a Party is not - and where that concern lives instead:

A Party is not…That concern lives inJoin
a MemberMember = projection of Party + role + eligibility.member_coverage[11]. There is no members table.party_locator
a loginKeycloak user (realm olly); party_locator is a user attribute riding a realm-level claim mapper[12]attribute, not FK
a Provider rowprovider.providers - has its own locator (PRV-); a nullable party_id back-ref is in place[13] for the day a provider also has to be addressed as a partyparty_id (nullable)
a triage usertriage.users is an API-credential record[14] - no party column exists in the triage schemanone (by design)
a scheme / employer relationshipgroup_scheme.schemes points back via employer_party_locator[15]locator
a billing payerbilling.accounts points back via org_locator - the migration comment states the alias: "Employer PARTY locator (PTY-…), the org_locator claim"[16]locator

Aliases (important): party_locator, member_locator, org_locator, employer_party_locator, member_party_locator are all the same value space - a PTY- locator. The column name encodes the role the party plays in that table, not a different entity.

3. Structure

DDL[1] · Go model[5]

FieldTypeReqNotes
iduuidPK. Internal only - never leaves the service
locatortextUNIQUE. PTY-YYYY-NNNNNN, the external identity
typetextCHECK: INDIVIDUAL | ORGANISATION | PROVIDER
first_nametextIndividuals
last_nametextIndividuals
nametextOrganisations; also set as display name on individuals
emailtextContact + lookup (btree index). PII
phonetextPII
addressjsonbUnstructured postal address. PII
date_of_birthdateIndividuals. PII; feeds age-based logic
lat, lngdoubleWrite-side of geolocation
locationgeography(Point,4326)Generated by trigger from lat/lng; GiST-indexed
created_at, updated_attimestamptzBookkeeping

Field-by-field: what and why

id - internal uuid PK. Why two identities? UUIDs join fast and stay private; support conversations and cross-service references need something a human can read and repeat. Rule: uuid inside the service, locator outside.party_roles.party_id (the one real FK to this table) uses the uuid.

locator - the external identity, PTY-2026-000123. Minted from a dedicated Postgres sequence (locator_seq_pty, seeded at 1 000 000)[3] via the shared packages/go/service/locator generator[6] - decided in ADR-1164-05 (#1169) after a process-local counter produced restart collisions. The service keeps a legacy in-process fallback when no generator is injected[7] - tests and old wiring only; production paths use the sequence. Why not a uuid? "Can you check PTY-2026-000123?" works at 2am; a uuid does not. The year segment is mint-year (provenance), not validity.

type - the discriminator, DB-enforced (a rarity here - most platform enums are conventions; this one is a real CHECK[1]). Three values, two in live use: INDIVIDUAL and ORGANISATION. PROVIDER is reserved - providers are addressed today through their own service and their own PRV- locator, and the value switches on the moment one of them also has to be a party (a clinician who is a member, provider payment details hanging off an identity). Widening the vocabulary further is one line of migration, because every consumer handles "a party" without knowing the kind. Why one table rather than individuals + organisations? Every consumer (consent, documents, notifications, billing) needs "a party" regardless of kind; two tables would double every locator column into a polymorphic pair.

first_name / last_name vs name - individuals use first/last; organisations use name. Nothing enforces the split (all three are nullable, no cross-field CHECK), and in practice individuals get name populated as a display convenience while organisations carry empty-string first/last.

Convention, not constraint

"Which name fields does an ORGANISATION have?" is answered by convention. A type-conditional CHECK is cheap hardening if this ever bites.

email, phone, address, date_of_birth - contact + demographic PII, stored in plaintext, and email is btree-indexed because party lookup by email does a plaintext match. This is a known, accepted-for-now posture: the envelope-encryption + blind-index design (D-32, #1035, and the at-rest design) is POC-proven and not yet applied to this table. When it lands, this table changes shape: token + display surrogate + HMAC blind index per PII field.

lat / lng / location - write lat/lng; a BEFORE INSERT OR UPDATE trigger (sync_party_location)[4] derives the PostGIS point, GiST-indexed. Why on Party? Proximity ("physio near the member") is a property of where the person is, reused by any service via one query, without teaching Go services PostGIS geometry writes. The derivation and the index are in place; turning proximity search on is a matter of feeding lat/lng from the write path (§9).

4. Invariants

InvariantEnforced by
locator uniqueDB unique constraint[1]
type ∈ INDIVIDUAL, ORGANISATION, PROVIDERDB CHECK[1]
location consistent with lat/lngDB trigger[4]
party_roles.party_id must existDB FK[2] (in-service, so a real FK - the no-FK rule applies only across services)
Locator format PTY-YYYY-NNNNNNApplication (shared locator package[6])
Only policy-admin writes this tableConvention + network posture; other services get POST /internal/parties[8] behind the X-Internal-Service guard[9]
Name fields match typeNothing - convention only (see §3)
Row changes captured to CDCDebezium publication dbz_policy_admin → BigQuery olly_cdc

5. Lifecycle

A Party has no lifecycle. There is no status column: a party is identity, and identity does not lapse. Everything stateful lives on the entities that reference it (policy status, scheme-member status, coverage windows). Member activation is a known open design (D-42, #1271) and will live in the identity service - not as a column here.

How a party comes to exist - the Flow-0 path (employer adds an employee)[10]:

Design points encoded in that diagram: identity owns Keycloak admin but no database - parties stay here (D-10, #1013 / #1161); and party creation never sends email - notification is the caller's step (ADR-1164-01, #1171).

Account activation: member PIN

The one member-facing "activation" step (D2C Quote & Buy, ENG-243 / #1675) lives in the identity service, not on this table. After purchase, the authenticated member chooses a 6-digit Login PIN via POST /me/pin (JWT-protected; the caller is the member, resolved from the token sub)[17]. The body is {pin, confirm_pin} (both exactly 6 digits, and equal). The value is stored as a bcrypt hash[17] in the Keycloak user attribute pin_hash[17] (consistent with the DB-free posture of §5: there is no PIN column on parties or any other Postgres table). Setting a PIN also clears the legacy server-generated plaintext pin attribute, retiring the reverse-lookup for that member. This activation step pairs with the OTP onboarding (verify-otp) that mints the member and party_locator; the PIN is the login credential set on top of that identity.

Verification is a separate route, POST /onboarding/verify-pin: it resolves the member (by party_locator or email, optionally device-bound), then compares the presented PIN against pin_hash with bcrypt.CompareHashAndPassword[18].

AspectDetail
Set routePOST /me/pin, JWT-protected (authenticated member)[17]
Body{pin, confirm_pin} - each exactly 6 digits, must match
Storagebcrypt hash in KC user attribute pin_hash; no Postgres column (identity is DB-free)
Verify routePOST /onboarding/verify-pin - compares against pin_hash[18]
Journey roleD2C Quote & Buy account-activation step, after purchase

6. Populated example

The two shapes, as they exist live (values fictionalised, structure exact):

json
{
  "locator": "PTY-2026-000001",
  "type": "ORGANISATION",
  "name": "Olldemo Ltd",
  "first_name": "",
  "last_name": "",
  "email": "hr@olldemo.co.uk",
  "phone": "",
  "address": null,
  "date_of_birth": null,
  "lat": null, "lng": null, "location": null,
  "created_at": "2026-05-02T09:14:03Z",
  "updated_at": "2026-05-02T09:14:03Z"
}
json
{
  "locator": "PTY-2026-000006",
  "type": "INDIVIDUAL",
  "first_name": "Grant",
  "last_name": "Kernow",
  "name": "Grant Kernow",
  "email": "grant@olldemo.co.uk",
  "phone": "+447700900123",
  "address": null,
  "date_of_birth": "1991-03-14",
  "lat": null, "lng": null, "location": null,
  "created_at": "2026-08-17T08:33:29Z",
  "updated_at": "2026-08-17T08:33:29Z"
}

From here the domain story continues: this employer party is referenced by a scheme (employer_party_locator), the member party by a policy (party_locator), and both by billing, consent, documents and notifications - walk it in the ERD story.

7. Who references a Party

party_locator is the widest-reaching reference in the platform - ~19 tables across 12 databases. The main ones:

Database.tableColumnMeaning there
enrollment.policiesparty_locatorthe policyholder
group_scheme.schemesemployer_party_locatorthe sponsoring employer
group_scheme.scheme_membersmember_party_locatorthe enrolled employee
billing.accountsorg_locator (UNIQUE)the paying employer
eligibility.member_coverageparty_locatorthe covered person
claims.claimsmember_locatorthe claimant
care.episodesparty_locatorthe patient
consent.consent_recordsparty_locatorthe consenting subject
documents.documentsparty_locatorthe document subject
notifications.notification_logparty_locatorthe recipient
balance.streams.profileparty_locator (UNIQUE)the sole bridge from health-data world to insurance world

None of these are foreign keys - cross-service integrity is application code plus events, by design (data architecture).

8. Design determinations

  1. One table, type-discriminated - over per-kind tables. Every consumer handles "a party" uniformly. (§3 type)
  2. uuid inside, locator outside - with the locator minted from a per-prefix DB sequence. ADR-1164-05 · #1169.
  3. policy-admin owns parties; identity owns credentials - the auth surface was extracted (SRP), parties stayed. D-10 · #1013, #1161.
  4. Minting ≠ notifying - creating a party never sends email. ADR-1164-01 · #1171.
  5. Member is a projection, not a table - Party + role + coverage. (Members & Parties)
  6. No lifecycle on Party - activation is an identity-service concern. D-42 · #1271 (open).
  7. PII plaintext accepted for now - envelope-encryption design proven in POC, not yet applied here. D-32 · #1035.
  8. Capability modelled ahead of the products that use it - the type vocabulary, party_roles and the geolocation column set were built before brokers, dependants and proximity search needed them. The consequence is that each of those arrives as configuration or write-path work, not as a migration of the identity spine every service references. (§9)

9. Caveats and extensibility

One identity serves every counterparty. Individuals, employers and providers are the same row shape under one locator scheme, discriminated by type and situated by party_roles. That is what lets a new counterparty class - a broker, a TPA, a corporate group - enter the platform without a second identity table, a second locator prefix, or a polymorphic pair of columns on all ~19 consuming tables (§7).

Group and individual ride the same spine. An employer is an ORGANISATION party; a member is an INDIVIDUAL party; org_locator on billing accounts is a party locator under an alias. Adding direct-to-consumer (individual) policies requires no party-model change: an individual party can stand behind a billing account and hold a MEMBER_LEVEL policy today. The group vs individual fork is expressed downstream - policy_structure on the product version - never here.

Soft references are the join strategy. Every consumer outside policy-admin holds a PTY- string, not an FK (§7). A new service joins the identity spine by adding a text column to its own schema and reading GET /parties/{locator}; this table does not change, and no cross-database migration is coordinated.

Roles carry the vocabulary of insurable interest. party_roles is the polymorphic party↔entity join - ACCOUNT_HOLDER / INSURED / BENEFICIARY / DEPENDENT over ACCOUNT / POLICY / QUOTE - modelled and FK-backed[2], with the read endpoint already exposed at GET /parties/{locator}/roles/list. It holds no rows yet because Olly sells single-life cover today; the vocabulary is the insurance one - "only those who have an insurable interest can recover on the insurance contract" - so dependants and beneficiaries populate it rather than reshape it. Until they do, read it as additive: a party's coverage story is told by eligibility.member_coverage, not by this table.

Extension points

When we need …What to addWhere
A new counterparty class (broker, TPA, corporate group)one value on the type CHECK; consumers need no change, since each handles "a party" without branching on kinda policy-admin migration alongside 0002_create_parties.sql
A provider who is also a party (clinician who is a member, payment details on an identity)mint a PROVIDER party and set provider.providers.party_id on the provider create pathservices/provider; the nullable back-ref column already exists
Dependants and beneficiaries on a policyinsert party_roles rows - the role vocabulary already admits BENEFICIARY and DEPENDENT, and CreateRole / ListRoles are on the repositorypolicy_admin.party_roles · policy-admin/internal/repository/gorm_party.go
A role over an entity kind beyond account / policy / quote (a scheme, a claim, an episode)one value on the entity_type CHECKa policy-admin migration; party_roles
A new service to name a person or an employera party_locator text column in that service's own schema, read via GET /parties/{locator}the consuming service; nothing here, no FK (§7)
Something to react when a party is first createda consumer on member.party_created, published keyed on the locator at create timepolicy-admin/internal/service/party.go, Kafka
Proximity search ("physio near the member")lat / lng on the write path - the trigger derives location and the GiST index is already thereadd the two fields to CreatePartyRequest / UpdatePartyRequest in policy-admin/internal/service/party.go and map them in internal/handler/parties.go
Richer or per-market address shapes (structured, multiple, non-UK)keys on the address jsonbno migration; policy_admin.parties.address
PII encrypted at resttoken + display surrogate + HMAC blind index per PII field, replacing the plaintext email btree lookupparties DDL + the lookup path; design D-32 · #1035
The name-field split enforced rather than conventionala type-conditional CHECK (ORGANISATION ⇒ name, INDIVIDUAL ⇒ first/last)a policy-admin migration (§3)

Known defects

Distinct from the headroom above - these are things to fix, not capabilities awaiting their product:

  • Dependants are expressible two ways - a DEPENDENT party_roles row and a policy_elements.element_type = dependent. Pick one before either is populated, and state it in Members & Parties; reconciling afterwards means rewriting rows in two services.
  • enrollment.policies carries both party_locator and a NULL member_locator - two columns for one fact. The fix is to drop member_locator in an enrollment migration once no reader references it.
  • Individuals carry a denormalised name alongside first_name / last_name, kept in step by convention. The type-conditional CHECK in the table above is the hardening; deriving name in the read model is the alternative.
  • The alias zoo - member_locator, org_locator, employer_party_locator, member_party_locator are one value space (§2). The fix is documentation-side: every new column that holds a PTY- should be named *_party_locator.

References

Code links are pinned to commit 8329d7b on main (2026-08-19); the file is the anchor if lines drift on later commits.

  1. migrations/0002_create_parties.sql - table DDL: CHECK L5, UNIQUE L15, indexes L17-18
  2. migrations/0003_create_party_roles.sql - party_roles + FK
  3. migrations/0014_locator_sequences.sql#L12 - locator_seq_pty START 1000000
  4. migrations/0021_add_location_to_parties.sql - lat/lng/location + sync_party_location trigger
  5. packages/go/domain/parties.go#L10 - the Party model (shared domain package)
  6. packages/go/service/locator/locator.go#L27 - PGGenerator.Next, sequence-per-prefix
  7. policy-admin/internal/service/party.go#L83 - mintPartyLocator + legacy in-process fallback
  8. policy-admin/internal/handler/internal_parties.go#L39 - handleInternalCreateParty
  9. policy-admin/internal/handler/internal_guard.go#L9 - X-Internal-Service gate, fail-closed
  10. identity/internal/handler/mint_with_pin.go#L67 - handleMintWithPin
  11. eligibility/migrations/0002_create_member_coverage.sql#L2 - member_coverage DDL, party_locator NOT NULL - the projection a "Member" resolves to
  12. identity/internal/handler/keycloak_admin.go#L950 - "the party_locator claim rides the realm-level user-attribute mapper"
  13. provider/migrations/0002_create_providers.sql#L2 - party_id uuid nullable back-ref
  14. triage/migrations/001_create_schema.sql#L2 - users with api_key_hash, no party column
  15. group-scheme-service/migrations/0001_create_schema.sql#L4 - employer_party_locator NOT NULL + index
  16. billing/migrations/0018_create_accounts.sql#L25 - org_locator NOT NULL UNIQUE, commented as the employer PTY alias
  17. identity/internal/handler/pin_set.go#L54 - POST /me/pin route (L54), bcrypt hash (L39), stored to KC attribute pin_hash (L98). Pinned to commit 86cc746 (#1675, on main), which post-dates the 8329d7b anchor above and is where the PIN files first exist
  18. identity/internal/handler/verify_pin.go#L88 - POST /onboarding/verify-pin checks the presented PIN against pin_hash via bcrypt.CompareHashAndPassword (verifyPINHash)

Live-schema facts (constraint list, Debezium publication, unused-field observations) come from psql -h 10.0.1.2 -d policy_admin · \d policy_admin.parties, 2026-08-18.

Olly Health Insurance Platform