Skip to content
Updated Aug 22, 2026

Provider

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

Tablesprovider.providers, provider.credentialing_requests, provider.provider_reviews[1][2][3]
Owner serviceprovider (sole writer)
LocatorsPRV-YYYY-NNNNNN (providers) · CRD-YYYY-NNNNNN (credentialing; reviews have no locator)
Last updated2026-08-18
CompanionERD story, slide 10 · Providers (narrative) · previous: Invoices, payments & ledger

1. Scope and usage

A Provider is a healthcare organisation or clinician the platform can book against and (eventually) settle with: GPs, physios, mental-health practitioners, hospitals, labs. The table is a directory plus a network gate: identity and contact on one axis, network_status on the other.

The network gate is the insurance-industry mechanic, not a CRUD nicety: "insurers have developed hospital networks, i.e. a list of hospitals with which the insurer has contracts" - a curated list is the insurer's cost and quality lever. Credentialing (credentialing_requests) is the doorway onto that list; provider_reviews is the member-facing quality signal on the profile.

Use this schema when you need who can deliver care and whether they are in the network. Do not look here for when they can deliver it - availability, slots and appointments deliberately live in the care service (D-12, #1015); this service has no slot table and no calendar concept.

2. Boundaries and relationships

A Provider is not…That concern lives inJoin
a Partypolicy_admin.parties admits type = PROVIDER and this table carries a party_id back-ref[1] - both ends of the bridge are in place and reserved; no live row carries a link yet (see §3 party_id)party_id / party_locator (reserved)
bookable timecare.provider_slots + care.appointments reference providers by provider_locator, no FK (cross-service). Care, not provider, owns slot inventory - D-12 · #1015[13]locator
a claims payeethe claims-side surface today is a provider_id uuid filter on claims.prior_authorizations[14]; settlement-side coupling activates when adjudication consults the network gate (§9)uuid soft ref (prior auth only)
a booking validator's cachecare re-fetches the provider at booking time over /internal/providers/{locator} and refuses non-ACTIVE[10]HTTP, per booking
a loginKeycloak holds the provider portal client (web-provider); linking a session to a directory row activates when the portal lands, keyed on party_locator (§9)none

3. Structure

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

providers

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE PRV-YYYY-NNNNNN
npitextUNIQUE (twice over - see §4). US-style clinical identifier, a stand-in on a UK platform
party_id, party_locatoruuid, textProvider-as-party link, reserved; live rows carry Go zero values (zero-uuid / '' - §9)
name, specialtytext✓/-Display name; free-text specialty (GP, Physiotherapy, MentalHealth, …)
network_statustextDefault 'PENDING'; the load-bearing column. Indexed
address, phone, emailjsonb/textContact
photo_urltextProfile photo (clinician profile, #1108)
lat, lng, locationdouble / geography(Point,4326)Geo write-side + trigger-derived PostGIS point, GiST-indexed[5]
activated_attimestamptzStamped when the provider goes ACTIVE
created_at, updated_attimestamptzBookkeeping

credentialing_requests

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE CRD-YYYY-NNNNNN
provider_iduuidReal FK to providers (in-service)
statustextDefault 'SUBMITTED'; SUBMITTED | APPROVED | REJECTED (Go enum, no CHECK)
submitted_at, reviewed_attimestamptz✓/-Submission always stamped; review stamped on decision
reviewer_notestextFree-text decision rationale

provider_reviews

FieldTypeReqNotes
iduuidPK. No locator - reviews are not externally addressable
provider_iduuidReal FK to providers
authortextFree-text display name; no party link
ratingintDB CHECK 1..5[3]
commenttextFree text

Field-by-field: what and why

locator - PRV-2026-000142. Minted from a counter table, not a Postgres sequence: public.provider_locator_seq is a one-row, one-column table (val integer)[6] and the repository increments it with UPDATE provider_locator_seq SET val = val + 1 RETURNING val[7] (credentialing_locator_seq works the same way for CRD-[8]). This is the odd one out in the estate: every other service mints from a real SEQUENCE via the shared per-prefix generator (ADR-1164-05, #1169 - see Party). The row-lock on UPDATE serialises minting correctly, but the table has no PK and relies on holding exactly one row; live values (val 142 and 61) agree with the highest minted locators. Why not migrated? Predates the ADR and works; converging on the shared generator is cheap hardening, not a bug fix.

npi - the clinical identifier, unique. NPI is the US National Provider Identifier; this is a UK platform, and the narrative page is explicit that the real registers are GMC (doctors), GPhC (pharmacists), ODS (organisations) and CQC (regulated activity). Treat npi as the slot where the UK identifier scheme will land. Live values are synthetic fixtures (1000000001, 9876543210).

party_id / party_locator - the provider-as-party bridge, built at both ends. The Party page notes the PROVIDER party type is in the CHECK and reserved; this is the other end of it. The write path is finished: pass partyLocator on create and the handler validates it against policy-admin and copies both fields over[9], so realising provider-as-party is a caller change plus a backfill rather than a migration. One thing to fix on the way: the Go model declares PartyID uuid.UUID (non-pointer) and PartyLocator string[4], so an insert with no party writes the Go zero values rather than NULL - live, 92 of 95 rows carry party_id = 00000000-0000-0000-0000-000000000000 and party_locator = '' (the other 3, seeded outside Go, are NULL). See §9.

network_status - PENDING | ACTIVE | INACTIVE (Go enum[11], no CHECK; live distinct values match: 45 ACTIVE, 42 PENDING, 8 INACTIVE). The load-bearing column - but be precise about where it bears load today:

ConsumerWhat ACTIVE gatesEvidence
care bookingValidateActive refuses to book a non-ACTIVE provider[10]every non-Kry booking
care slot searchdirectory sourced via /internal/providers?specialty=…&networkStatus=ACTIVE[12]who a member can see
spatial searchFindNearest filters network_status = 'ACTIVE'[7]who "near me" returns
claim adjudicationthe target model on the narrative page has network status gating adjudication; at the pinned commit the claims service holds no reference to network_status, so this switches on when claims read the directory (§9)activates when claims consult provider

So today network_status is enforced at the front door (booking): an out-of-network provider cannot be booked. The back door (settlement) reads the same column the day adjudication asks for it - the gate is one directory call away, not a schema change (§9).

lat / lng / location - same pattern as Party: write lat/lng, a BEFORE INSERT OR UPDATE trigger (sync_provider_location) derives the PostGIS point, GiST index on top[5], and GET /providers/nearest runs a KNN (ORDER BY location <-> point) query[7]. The machinery is complete - trigger, GiST index, KNN endpoint, optional specialty filter - and switches on the moment coordinates land on rows: no backfill of live rows has run, so nearest-search returns an empty list today. A catchment radius is one ST_DWithin predicate on the same query (§9).

photo_url + reviews - the clinician profile (#1108). Reviews are append-only rows; the profile read aggregates AVG(rating) + count on the fly[15] - no denormalised rating column to drift. author is a free-text display string with no party linkage and no moderation state: whoever calls POST /providers/{locator}/reviews names themselves.

4. Invariants

InvariantEnforced by
locator unique; npi uniqueDB unique constraints - npi twice (a UNIQUE column constraint and a redundant unique index from migration 0004; live \d shows both providers_npi_key and idx_providers_npi)
rating between 1 and 5DB CHECK[3]
Credentialing request / review belongs to a real providerDB FKs (in-service)[2][3]
location consistent with lat/lngDB trigger[5]
Credentialing decision only legal from SUBMITTEDApplication: approve/reject both refuse any other status[16]
APPROVED ⇒ provider ACTIVE + activated_at stampedApplication, same code path, not a transaction: two sequential updates[16] (live data is consistent: all 19 providers with an APPROVED request are ACTIVE)
network_status / credentialing status vocabulariesGo enums only[11] - no CHECK on either
party_id points at a real partyNothing in the DB - the link is validated in the create handler when a caller supplies one[9]; live rows hold Go zero values (§9)
Row changes captured to CDCDebezium publication dbz_provider (live \d, all three tables)

5. Lifecycle

Credentialing is a synchronous, in-process service flow - approve is one HTTP handler doing two row updates[16]. This contradicts architecture/services, which still describes "Temporal workflows for credentialing"; the code has no Temporal import anywhere in the service, and the narrative Providers page states the synchronous reality. Trust the code and the narrative page.

Live statuses match the diagram exactly: 23 SUBMITTED, 19 APPROVED, 19 REJECTED, nothing else.

Two lifecycle facts worth internalising:

  • Rejection does not touch the provider. RejectCredentialing updates only the request[17]; a rejected provider stays PENDING in the directory rather than becoming INACTIVE. Filtering consumers never see them (they filter on ACTIVE), but "PENDING" conflates "never reviewed" with "reviewed and refused".
  • Activation has a bypass. PATCH /providers/{locator}/activate sets ACTIVE directly with no credentialing request at all[18] - admin convenience today; when credentialing becomes a compliance requirement, gate that handler behind an APPROVED request or a reviewer role (§9).

6. Populated example

95 providers live: a seeded GP/physio/mental-health directory plus e2e residue. Provider names are business data, not member PII; rows below are real (contact values are the seeded .test fixtures they are).

The provider - PRV-2026-000033

json
{
  "locator": "PRV-2026-000033",
  "npi": "1000000001",
  "name": "Dr. Sarah Chen",
  "specialty": "GP",
  "network_status": "ACTIVE",
  "party_id": "00000000-0000-0000-0000-000000000000",
  "party_locator": "",
  "address": { "city": "London", "line1": "1 Harley St", "postcode": "W1G 9QD" },
  "phone": "+442071234567",
  "email": "sarah.chen@ollycare.test",
  "photo_url": "https://ui-avatars.com/api/?size=256&…&name=Dr.+Sarah+Chen",
  "lat": null, "lng": null, "location": null,
  "activated_at": "2026-07-04T13:31:00Z",
  "created_at": "2026-07-04T13:30:34Z"
}
KeyRead byWhat actually happens
network_status: "ACTIVE"care booking gate[10] + slot-search directory[12]this provider is bookable; members see her in GP slot search
specialty: "GP"care's providerTypeToSpecialty mapthe API enum GP resolves to exactly this string - the two services agree by convention
npi: "1000000001"internal lookup fallback[19]resolvable by NPI for EDI-style callers; locator tried first
party_id: 0000…0000nothing yetthe reserved party link, unset - a Go zero value rather than NULL (§9)
lat/lng/location: nullFindNearest filternot returned by spatial search until coordinates are written to the row
activated_atdisplaystamped by the credentialing approval that made her ACTIVE

The credentialing decision - CRD-2026-000060

json
{
  "locator": "CRD-2026-000060",
  "provider_locator": "PRV-2026-000141",
  "status": "APPROVED",
  "submitted_at": "2026-08-12",
  "reviewed_at": "2026-08-12",
  "reviewer_notes": "e2e approve"
}

The reviewer_notes value is honest provenance: the e2e suite exercises the approve path against the live service. Its sibling CRD-2026-000061 (REJECTED, "e2e reject") left its provider PENDING - the §5 rejection semantics, observable in data.

A review

json
{
  "provider_id": "…uuid of PRV-2026-000033…",
  "author": "Priya N.",
  "rating": 5,
  "comment": "Listened properly and explained everything.",
  "created_at": "2026-07-06"
}

11 reviews live, all seeded onto the profile providers; the profile read returns them with AVG(rating) computed on the fly[15].

7. Who references a Provider

WhereColumn / mechanismNature
care.appointmentsprovider_locatorsoft ref; validated ACTIVE at booking via HTTP[10]
care.provider_slots, care.prescriptionsprovider_locatorsoft refs (care-owned inventory + issuance)
care slot search/internal/providers?specialty=…&networkStatus=ACTIVE[12]the bookable directory, fetched live
claims.prior_authorizationsprovider_id (uuid)filter param only[14]; the sole claims-side touchpoint
web-adminproviders / credentialing resourcesfull CRUD over the public routes[20]

No table in any other database holds a PRV- foreign key - the cross-service rule, as everywhere.

8. Design determinations

  1. Care owns slots; provider owns identity and network membership - this service has no availability concept at all, on purpose. D-12 · #1015.
  2. Credentialing is synchronous and in-process - no Temporal saga; the services-page claim to the contrary is stale. (§5)
  3. One network gate, read by whoever needs it - network_status is the single column booking, slot search and spatial search all filter on; adjudication reads the same column when claims start asking. (§3)
  4. Provider-as-party is modelled ahead of need - party_id / party_locator and the Party CHECK's PROVIDER type are both in place, and the create handler already validates and copies them, so realising it is a caller change plus a backfill. (§3)
  5. npi is the reserved slot for the UK registers - GMC/GPhC/ODS/CQC are the target scheme, stated on the narrative page; a unique text column takes whichever one lands. (§9)
  6. Reviews aggregate at read time - no denormalised rating to drift. #1108. (§3)
  7. The directory is category-agnostic - specialty is free text and network_status is the only gate, so GPs, physios, mental-health practitioners, clinics, labs and partner-sourced clinicians are all the same row shape. Adding a category is a string plus a mapping entry, not a table. (§9)

9. Caveats and extensibility

Group and individual. Nothing here is scheme- or policy-aware: the directory and network gate serve group members and (future) individual policyholders identically. The group/individual fork never reaches this schema.

What this model already absorbs. A provider row is deliberately thin: identity, contact, a free-text specialty, a geo point and one gate column. That shape is what lets one table serve GP, physio, mental-health, clinic, hospital and lab entries without a subtype table, and it is why a partner-sourced clinician (a Kry-style third-party directory) can join the same directory rather than needing a parallel one. The satellites are the same story: credentialing carries the vetting decision, reviews carry the member-facing quality signal, both hanging off the provider by a real in-service FK - see Provider credentialing & reviews (#18) for their own deep-dive.

Extension points:

When we need …What to addWhere
a new provider category (clinic, hospital, lab, pharmacy, dentist)a specialty string on the row plus the API-enum entry that maps to it; no schema changeprovider.providers.specialty[1] · care's providerTypeToSpecialty map, read through /internal/providers?specialty=…[12]
"near me" search livecoordinates on provider rows; the trigger derives the PostGIS point and the GiST index and KNN endpoint are already in placebackfill lat/lng on provider.providers; sync_provider_location[5] · FindNearest[7]
a catchment radius or "within N km" filterone ST_DWithin predicate beside the existing ACTIVE and specialty filtersFindNearest[7] and the /providers/nearest query params
UK registers (GMC, GPhC, ODS, CQC)sibling identifier columns beside npi, plus the lookup fallback that resolves themmigration under services/provider/migrations/ · internal get, locator-first with identifier fallback[19]
providers as parties (a provider portal login, provider-side payments)mint PROVIDER parties and pass partyLocator on create; validation and copy already exist, then backfill existing rowscreate handler[9] · Party CHECK on Party
network status to gate claim settlementclaims reading the directory (or a projection of it) at adjudication; the column and the read endpoint both exist/internal/providers/{locator}[19] → the claims side[14]
partner-sourced providers in the same directoryrows minted from the partner feed with their own specialty and network_status; care already books partner slots, and the ACTIVE gate applies to directory rows as it standsPOST /providers[20] · booking gate[10]
credentialing to become a governed process (documents, expiry, re-credentialing, reviewer roles)columns or satellite tables on credentialing_requests, plus a gate on the activate bypassProvider credentialing & reviews (#18) · ApproveCredentialing[16]
moderated, attributable reviewsa moderation status column and a party link beside author; the read-time aggregate needs no changeprovider_reviews[3] · aggregate[15] · (#18)
locator minting converged on the estate standardswap the counter tables for the shared per-prefix SEQUENCE generatorprovider_locator_seq / credentialing_locator_seq[6][7]; ADR-1164-05, #1169

Known defects, and the fix:

  • party_id / party_locator hold Go zero values, not NULL on 92 of 95 rows - non-pointer model fields[4] writing 00000000-… and ''. This is the '' vs NULL trap the platform has been bitten by before, and it must be cleaned before the party bridge is realised. Fix: *uuid.UUID / *string in packages/go/domain/providers.go, plus an UPDATE … SET party_id = NULL WHERE party_id = '00000000-…' migration.
  • npi is double-constrained - a UNIQUE column constraint and a redundant unique index from migration 0004 (live \d shows both providers_npi_key and idx_providers_npi). Fix: drop idx_providers_npi in a new migration.
  • REJECTED credentialing leaves the provider PENDING.RejectCredentialing updates only the request[17], so PENDING conflates "never reviewed" with "reviewed and refused". Fix: set network_status = INACTIVE on reject, in that same service call.
  • The activate endpoint bypasses credentialing.PATCH /providers/{locator}/activate sets ACTIVE with no request at all[18]. Fix: require an APPROVED request, or a reviewer role, in ActivateProvider before compliance depends on it.
  • APPROVED ⇒ ACTIVE is two sequential updates, not one transaction[16] - a crash between them leaves an approved request on a PENDING provider (live data is consistent: all 19 are ACTIVE). Fix: wrap both in one db.Transaction.
  • No DB enforcement of any vocabulary - network_status, credentialing status and specialty are bare text held to Go enums[11], and specialty additionally has to agree with care's type-to-specialty map by convention. Fix: CHECK constraints on the two status columns; for specialty, a shared constant in packages/go/domain.
  • Review author is unverified free text - whoever calls POST /providers/{locator}/reviews names themselves. Fix: derive the author from the caller's party_locator claim in the reviews handler.

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.

  1. provider/migrations/0002_create_providers.sql - providers DDL: npi UNIQUE L5, party_id/party_locator L6-7, network_status default L10
  2. 0003_create_credentialing_requests.sql - credentialing DDL, FK L5, status default L6
  3. 0009_provider_profile.sql - photo_url + provider_reviews (rating CHECK L7)
  4. packages/go/domain/providers.go - the three models; non-pointer PartyID uuid.UUID L13
  5. 0010_add_location_to_providers.sql - PostGIS columns, sync_provider_location trigger, GiST index
  6. 0008_create_seq_tables.sql - the one-row counter tables
  7. internal/repository/gorm_providers.go#L86 - NextLocator counter UPDATE L86-96; FindNearest KNN with ACTIVE + location filters L98-118
  8. internal/repository/gorm_credentialing.go#L60 - CRD- counter minting
  9. internal/handler/providers.go#L100 - create: party validation via policy-admin + PartyID/PartyLocator copy
  10. care/internal/client/provider.go#L49 - ValidateActive: non-ACTIVE providers are unbookable
  11. packages/go/domain/enums.go#L54 - NetworkStatus + CredentialingStatus vocabularies
  12. internal/handler/internal.go#L16 - listBySpecialty, the no-JWT directory read care's slot search uses
  13. care/migrations/00001_care_schema.sql#L18 - provider_slots living in care, not here
  14. claims/internal/repository/gorm_priorauth.go#L81 - the only claims-side provider reference (prior-auth filter)
  15. internal/repository/gorm_reviews.go#L36 - review stats aggregated at read
  16. internal/service/provider.go#L228 - ApproveCredentialing: SUBMITTED guard L237-241, provider activation L250-261
  17. internal/service/provider.go#L265 - RejectCredentialing: request only, provider untouched
  18. internal/service/provider.go#L142 - ActivateProvider, the credentialing bypass
  19. internal/handler/internal.go#L38 - internal get: locator-first, NPI fallback
  20. internal/handler/handler.go#L77 - the public route table (providers + credentialing + reviews)

Live-schema facts (constraint list including the double npi uniqueness, Debezium publication, network_status / credentialing status distributions, party_id zero-uuid observation, counter values, geo-column emptiness) come from PGPASSWORD=… psql -h 10.0.1.2 -U olly -d provider · \d provider.providers, \d provider.credentialing_requests, \d provider.provider_reviews, \d public.provider_locator_seq, select network_status, count(*) …, 2026-08-18.

Olly Health Insurance Platform