Party
Schema deep-dive · living document
| Table | policy_admin.parties[1] |
| Owner service | policy-admin (sole writer) |
| Locator | PTY-YYYY-NNNNNN |
| Last updated | 2026-08-18 |
| Companion | ERD 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 in | Join |
|---|---|---|
| a Member | Member = projection of Party + role + eligibility.member_coverage[11]. There is no members table. | party_locator |
| a login | Keycloak user (realm olly); party_locator is a user attribute riding a realm-level claim mapper[12] | attribute, not FK |
| a Provider row | provider.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 party | party_id (nullable) |
| a triage user | triage.users is an API-credential record[14] - no party column exists in the triage schema | none (by design) |
| a scheme / employer relationship | group_scheme.schemes points back via employer_party_locator[15] | locator |
| a billing payer | billing.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
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK. Internal only - never leaves the service |
locator | text | ✓ | UNIQUE. PTY-YYYY-NNNNNN, the external identity |
type | text | ✓ | CHECK: INDIVIDUAL | ORGANISATION | PROVIDER |
first_name | text | Individuals | |
last_name | text | Individuals | |
name | text | Organisations; also set as display name on individuals | |
email | text | Contact + lookup (btree index). PII | |
phone | text | PII | |
address | jsonb | Unstructured postal address. PII | |
date_of_birth | date | Individuals. PII; feeds age-based logic | |
lat, lng | double | Write-side of geolocation | |
location | geography(Point,4326) | Generated by trigger from lat/lng; GiST-indexed | |
created_at, updated_at | timestamptz | ✓ | Bookkeeping |
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
| Invariant | Enforced by |
|---|---|
locator unique | DB unique constraint[1] |
type ∈ INDIVIDUAL, ORGANISATION, PROVIDER | DB CHECK[1] |
location consistent with lat/lng | DB trigger[4] |
party_roles.party_id must exist | DB FK[2] (in-service, so a real FK - the no-FK rule applies only across services) |
Locator format PTY-YYYY-NNNNNN | Application (shared locator package[6]) |
| Only policy-admin writes this table | Convention + network posture; other services get POST /internal/parties[8] behind the X-Internal-Service guard[9] |
Name fields match type | Nothing - convention only (see §3) |
| Row changes captured to CDC | Debezium 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].
| Aspect | Detail |
|---|---|
| Set route | POST /me/pin, JWT-protected (authenticated member)[17] |
| Body | {pin, confirm_pin} - each exactly 6 digits, must match |
| Storage | bcrypt hash in KC user attribute pin_hash; no Postgres column (identity is DB-free) |
| Verify route | POST /onboarding/verify-pin - compares against pin_hash[18] |
| Journey role | D2C Quote & Buy account-activation step, after purchase |
6. Populated example
The two shapes, as they exist live (values fictionalised, structure exact):
{
"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"
}{
"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.table | Column | Meaning there |
|---|---|---|
enrollment.policies | party_locator | the policyholder |
group_scheme.schemes | employer_party_locator | the sponsoring employer |
group_scheme.scheme_members | member_party_locator | the enrolled employee |
billing.accounts | org_locator (UNIQUE) | the paying employer |
eligibility.member_coverage | party_locator | the covered person |
claims.claims | member_locator | the claimant |
care.episodes | party_locator | the patient |
consent.consent_records | party_locator | the consenting subject |
documents.documents | party_locator | the document subject |
notifications.notification_log | party_locator | the recipient |
balance.streams.profile | party_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
- One table, type-discriminated - over per-kind tables. Every consumer handles "a party" uniformly. (§3
type) - uuid inside, locator outside - with the locator minted from a per-prefix DB sequence. ADR-1164-05 · #1169.
- policy-admin owns parties; identity owns credentials - the auth surface was extracted (SRP), parties stayed. D-10 · #1013, #1161.
- Minting ≠ notifying - creating a party never sends email. ADR-1164-01 · #1171.
- Member is a projection, not a table - Party + role + coverage. (Members & Parties)
- No lifecycle on Party - activation is an identity-service concern. D-42 · #1271 (open).
- PII plaintext accepted for now - envelope-encryption design proven in POC, not yet applied here. D-32 · #1035.
- Capability modelled ahead of the products that use it - the
typevocabulary,party_rolesand 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 add | Where |
|---|---|---|
| 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 kind | a 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 path | services/provider; the nullable back-ref column already exists |
| Dependants and beneficiaries on a policy | insert party_roles rows - the role vocabulary already admits BENEFICIARY and DEPENDENT, and CreateRole / ListRoles are on the repository | policy_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 CHECK | a policy-admin migration; party_roles |
| A new service to name a person or an employer | a 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 created | a consumer on member.party_created, published keyed on the locator at create time | policy-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 there | add 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 jsonb | no migration; policy_admin.parties.address |
| PII encrypted at rest | token + display surrogate + HMAC blind index per PII field, replacing the plaintext email btree lookup | parties DDL + the lookup path; design D-32 · #1035 |
| The name-field split enforced rather than conventional | a 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_rolesrow and apolicy_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.policiescarries bothparty_locatorand a NULLmember_locator- two columns for one fact. The fix is to dropmember_locatorin an enrollment migration once no reader references it.- Individuals carry a denormalised
namealongsidefirst_name/last_name, kept in step by convention. The type-conditional CHECK in the table above is the hardening; derivingnamein the read model is the alternative. - The alias zoo -
member_locator,org_locator,employer_party_locator,member_party_locatorare one value space (§2). The fix is documentation-side: every new column that holds aPTY-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.
migrations/0002_create_parties.sql- table DDL: CHECK L5, UNIQUE L15, indexes L17-18migrations/0003_create_party_roles.sql- party_roles + FKmigrations/0014_locator_sequences.sql#L12-locator_seq_pty START 1000000migrations/0021_add_location_to_parties.sql- lat/lng/location +sync_party_locationtriggerpackages/go/domain/parties.go#L10- the Party model (shared domain package)packages/go/service/locator/locator.go#L27-PGGenerator.Next, sequence-per-prefixpolicy-admin/internal/service/party.go#L83-mintPartyLocator+ legacy in-process fallbackpolicy-admin/internal/handler/internal_parties.go#L39-handleInternalCreatePartypolicy-admin/internal/handler/internal_guard.go#L9-X-Internal-Servicegate, fail-closedidentity/internal/handler/mint_with_pin.go#L67-handleMintWithPineligibility/migrations/0002_create_member_coverage.sql#L2- member_coverage DDL,party_locator NOT NULL- the projection a "Member" resolves toidentity/internal/handler/keycloak_admin.go#L950- "the party_locator claim rides the realm-level user-attribute mapper"provider/migrations/0002_create_providers.sql#L2-party_id uuidnullable back-reftriage/migrations/001_create_schema.sql#L2-userswithapi_key_hash, no party columngroup-scheme-service/migrations/0001_create_schema.sql#L4-employer_party_locator NOT NULL+ indexbilling/migrations/0018_create_accounts.sql#L25-org_locator NOT NULL UNIQUE, commented as the employer PTY aliasidentity/internal/handler/pin_set.go#L54-POST /me/pinroute (L54), bcrypt hash (L39), stored to KC attributepin_hash(L98). Pinned to commit86cc746(#1675, onmain), which post-dates the8329d7banchor above and is where the PIN files first existidentity/internal/handler/verify_pin.go#L88-POST /onboarding/verify-pinchecks the presented PIN againstpin_hashviabcrypt.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.
