Skip to content
Updated Aug 22, 2026

Provider credentialing & reviews

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

Tablesprovider.credentialing_requests[1], provider.provider_reviews[2] - the two satellites of the providers directory
Owner serviceprovider (sole writer)
LocatorsCRD-YYYY-NNNNNN (credentialing) · reviews have no locator - a uuid PK only, not externally addressable
Last updated2026-08-21
CompanionERD story, slide 10 · Providers (narrative) · previous: Provider (#10)

1. Scope and usage

These two tables are the satellites of the provider directory. A credentialing request is the onboarding/vetting workflow that decides whether a clinician or organisation is admitted to the network; a provider review is the member-facing quality signal on the profile. Both hang off providers by a real in-service foreign key[1][2] and neither has a life outside that provider.

Credentialing is the doorway onto the network list - "insurers have developed hospital networks, i.e. a list of hospitals with which the insurer has contracts" - and approval is the one event that flips a provider's network_status to ACTIVE[10]. Reviews are the clinician-profile feature (#1108): a rating and a comment that aggregate onto the profile read as an average and a count[15].

Both write paths are built and exercised; the content is fixture. The model already carries what a real credentialing operation needs: a status lifecycle with a SUBMITTED-only decision guard, a reviewed_at stamp, a free-text decision rationale, and the one code path that admits a provider to the network. On the review side, rating is range-enforced by the database itself, and the average is computed at read time so there is no denormalised value to drift. A reviewer workflow with a real admin surface, a decision history, and rating-aware provider matching are each a named change on top of these fields rather than a re-model (§9).

Live population as of 2026-08-21: credentialing holds 61 rows - 23 SUBMITTED, 19 APPROVED, 19 REJECTED - and each of the 38 decisions carries the reviewer note e2e approve or e2e reject, written by the e2e suite against the live service. provider_reviews holds 11 rows across 6 providers, all seeded, all ratings 4 or 5 (average 4.64).

2. Boundaries and relationships

A credentialing request / review is not…That concern lives inJoin
the provider directory or the network flag itselfprovider.providers - identity, contact, network_status. Approval flips that flag in a second UPDATE on the providers row, it does not store it here[10]provider_id FK
an audit trail of the decisionnot modelled here - the request row is mutated in place with a GORM Save[14], so it holds current state rather than a ledger. History arrives as an append-only satellite alongside it, the shape claims.claim_events already uses (§9)none
a verified identity of the reviewernot modelled here - review author is free text with no party_id, no JWT, no moderation state; whoever calls the endpoint names themselves[16]none
a ranking input for searchreviews aggregate onto the profile read only[17]; the nearest-provider search ranks purely by distance and filters on ACTIVE - it never reads rating[18]none
the network gate at settlementclaims never consults credentialing or network_status (see Provider §3) - the gate is enforced at booking, not adjudicationnone
a cross-service event contractthe provider.events Kafka topic - credentialing and review changes are announced by a best-effort direct publish from the handler, not a transactional outbox[22]Kafka topic

3. Structure

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

credentialing_requests

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE CRD-YYYY-NNNNNN
provider_iduuidReal FK to providers (in-service)[1]; indexed
statustextDefault 'SUBMITTED'; SUBMITTED | APPROVED | REJECTED (Go enum[5], no CHECK)
submitted_attimestamptzDefault now(); always stamped
reviewed_attimestamptzStamped on the approve/reject decision; NULL while SUBMITTED (*time.Time in the model)
reviewer_notestextFree-text decision rationale. Nullable in DDL, but never NULL in practice - the Go field is a non-pointer string, so a SUBMITTED insert writes '' (see §9)
created_attimestamptzDefault now()

provider_reviews

FieldTypeReqNotes
iduuidPK. No locator - reviews are not externally addressable
provider_iduuidReal FK to providers[2]; indexed
authortextFree-text display name; no party link, no moderation
ratingintDB CHECK 1..5[2] - a rare DB-enforced invariant on this platform (see §4)
commenttextFree text; optional
created_attimestamptzDefault now()

Field-by-field: what and why

credentialing_requests.status - the load-bearing column. It is text with a DEFAULT 'SUBMITTED' and no CHECK constraint; the three legal values live only in the Go CredentialingStatus enum[5]. The legal transitions are enforced in code, not the schema: both ApproveCredentialing and RejectCredentialing refuse to act on anything that is not currently SUBMITTED, returning a 409[10][11]. There is no re-open path and no in-place history: the decision is a Save over the same row[14], so the request is a current state, not a ledger.

reviewer_notes - the decision rationale, and a ''-vs-NULL trap. It is text (nullable) in the migration[1], but the Go model declares ReviewerNotes string (a non-pointer)[3], so every GORM insert that has no note writes the Go zero value '', not NULL. The submit handler never sets it, so all 23 SUBMITTED rows carry reviewer_notes = '' (verified live: 0 NULL, 23 empty). The 38 decided rows carry a real (here, test) string. This is the same ''-vs-NULL trap the Party and Provider pages document - a column that looks consistently populated because the ORM refuses to leave it NULL.

locator on credentialing - CRD-2026-000060. Minted from a one-row counter table, not a Postgres SEQUENCE: credentialing_locator_seq is the sibling of provider_locator_seq[21], and the repository increments it with UPDATE credentialing_locator_seq SET val = val + 1 RETURNING val then formats CRD-%d-%06d by hand[13]. Same odd-one-out pattern as the rest of the provider service (see Provider §3): the row-lock on UPDATE serialises minting, but the table has no PK and relies on holding exactly one row. Live val is 61 and the highest locator is CRD-2026-000061, so the counter and the data agree.

provider_reviews.rating - the platform's rare DB-enforced invariant. The column carries CHECK (rating BETWEEN 1 AND 5)[2], and the add-review handler also validates 1 <= rating <= 5 and returns a 400 before the insert[16]. So rating is guarded twice - application first, DB CHECK as the backstop - where almost every other vocabulary in this estate (status, network_status) is code-only. Because the average is computed at read time there is no denormalised rating column to drift[15].

Where the rating surfaces - GET /providers/{locator} calls reviews.Stats and attaches rating (avg) and reviewCount to the profile response, best-effort (a stats error omits them)[17]. The nearest-provider search does not read rating at all: it filters network_status = 'ACTIVE' and orders by PostGIS distance (location <-> point)[18]. Rating is a display signal on one provider's page, never a ranking input across providers.

4. Invariants

InvariantEnforced by
rating between 1 and 5DB CHECK[2] - the rare DB-enforced invariant here - and application pre-validation (400 before insert)[16]
locator unique (credentialing)DB UNIQUE[1]
Credentialing request / review belongs to a real providerDB FKs (in-service)[1][2]
Credentialing decision only legal from SUBMITTEDApplication: approve/reject both refuse any other status with a 409[10][11]
APPROVED ⇒ provider ACTIVE + activated_at stampedApplication, same handler path, not a transaction: two sequential Updates on two rows[10] (live data is consistent)
Credentialing status vocabularyGo enum only[5] - no CHECK on status
reviewer_notes is meaningful when presentNothing - nullable in DDL; the non-pointer Go string means SUBMITTED rows hold '', not NULL, and nothing forbids an empty decision note (§9)
One review per member per providerNothing - no uniqueness beyond the PK; a caller may post unlimited reviews under any author
Credentialing decision history preservedNothing - the row is Saved in place[14]; there is no events table and no trigger
Row changes captured to CDCDebezium publication dbz_provider (live \d, both tables)

5. Lifecycle

Credentialing is a synchronous, in-process service flow: approve is one HTTP handler doing two row updates[10], with no Temporal workflow anywhere in the service (see Provider §5 for the stale architecture-page claim to the contrary).

Every arrow is a code method, each stamping reviewed_at and overwriting status/reviewer_notes on the single row:

  • submit - POST /credentialing looks up the provider by locator, mints a CRD- locator, and inserts a SUBMITTED request[6]. This is also the service_provider_credentialing_submit MCP tool's route.
  • approve - PATCH /credentialing/{locator}/approve guards SUBMITTED, sets APPROVED + reviewed_at + notes, then in the same call but a separate UPDATE flips the provider to ACTIVE and stamps activated_at[10].
  • reject - PATCH /credentialing/{locator}/reject guards SUBMITTED, sets REJECTED + reviewed_at + notes, and touches only the request[11]. A rejected provider stays PENDING in the directory (see the Provider page for the "PENDING conflates never-reviewed with reviewed-and-refused" wart).

There is a read-only status shortcut too: GET /credentialing/{npi}/status resolves a provider by NPI and returns its most recent request's status - the route behind the provider_credentialing_status MCP tool[9].

Reviews have no lifecycle. A review is a single INSERT[16]; there is no update, delete, moderation or reply path in the repository or handler. Once written, a row is immutable by absence of any code to change it.

6. Populated example

61 credentialing requests and 11 reviews live. Rows below are real; the free-text reviewer_notes and review comment are shown or redacted per their provenance.

Credentialing, one of each status

json
// CRD-2026-000001  (the oldest live request, still open)
{ "locator": "CRD-2026-000001", "provider_id": "8882b174-…-5f85adbf9433",
  "status": "SUBMITTED", "submitted_at": "2026-04-11T06:06:14Z",
  "reviewed_at": null, "reviewer_notes": "" }

// CRD-2026-000006  (an e2e-suite approval)
{ "locator": "CRD-2026-000006", "provider_id": "a1a8a27f-…-8be757725b20",
  "status": "APPROVED", "submitted_at": "2026-08-12T14:03:49Z",
  "reviewed_at": "2026-08-12T14:03:49Z", "reviewer_notes": "e2e approve" }

// CRD-2026-000007  (an e2e-suite rejection)
{ "locator": "CRD-2026-000007", "provider_id": "7de05eb1-…-8b2b19158a8c",
  "status": "REJECTED", "submitted_at": "2026-08-12T14:03:49Z",
  "reviewed_at": "2026-08-12T14:03:49Z", "reviewer_notes": "e2e reject" }
KeyRead byWhat actually happens
status: SUBMITTED + reviewed_at: nullapprove/reject guardsthe request is open; a decision is legal only from here[10]
reviewer_notes: "" on the SUBMITTED rownothingthe Go zero value, not a NULL - all 23 SUBMITTED rows are identical here (§9)
reviewer_notes: "e2e approve" / "e2e reject"display / auditthe column stores whatever the caller sent; every one of the 38 decisions is an e2e-suite write, sub-millisecond submit-to-decide
status: APPROVED on CRD-2026-000006its provider rowthe approval flipped that provider to ACTIVE and stamped activated_at in the same call[10]
status: REJECTED on CRD-2026-000007its provider rownothing on the provider - a rejected provider stays PENDING, never INACTIVE

Live population: 61 credentialing requests - 23 SUBMITTED, 19 APPROVED, 19 REJECTED (38 rows carry reviewed_at; all 38 notes are e2e approve/e2e reject; the 23 SUBMITTED notes are all ''). The SUBMITTED rows date to April 2026 seed data; the decided rows are August e2e runs.

A review

json
{
  "id": "ae180aae-5458-473f-b0d7-0ddf5095e1a7",
  "provider_id": "5a8ae096-…-8601bba9203f",
  "author": "Priya N.",
  "rating": 5,
  "comment": "[redacted free text]",
  "created_at": "2026-07-06T09:37:48Z"
}
KeyRead byWhat actually happens
author: "Priya N."display onlya free-text string with no party link - the writer named themselves[16]
rating: 5AVG(rating) on the profile read[17]contributes to this provider's displayed average; guarded 1..5 by the CHECK
commentdisplay onlyredacted here per house style; genuine member prose in production would be PII-adjacent

Live population: 11 reviews across 6 providers, ratings only 4 (×4) and 5 (×7), average 4.64 - a seeded favourable fixture. The profile read returns the latest reviews plus rating + total computed on the fly[15].

7. Who references credentialing & reviews

WhereColumn / mechanismMeaning there
provider.providersprovider_id FK (both tables)the parent; approval writes back network_status / activated_at on it[10]
provider profile readreviews.Statsrating + reviewCount[17]the member-visible aggregate on GET /providers/{locator}
web-admincredentialing resource (list / show / create)[24]ops review queue; there is no separate reviews admin resource
MCP / platform toolsservice_provider_credentialing_submitPOST /credentialing[6]; provider_credentialing_statusGET /credentialing/{npi}/status[9]submit an onboarding request; check a provider's status by NPI
provider.events (Kafka)credentialing.submitted / .approved / .rejected, provider.reviewed[23]best-effort announcements; no confirmed downstream consumer in-repo

Neither table is referenced by a foreign key in any other database - the cross-service rule, as everywhere. provider_reviews is not referenced at all outside its own service.

8. Design determinations

  1. Credentialing is synchronous and in-process - approve/reject are HTTP handlers doing in-place updates; no Temporal saga, no outbox. (§5)
  2. Approval is the network-entry event - the one code path that flips a provider to ACTIVE, done as a second UPDATE in the same call rather than a transaction[10]. (§4)
  3. Decisions are current-state, not a ledger - Save over the request row, no credentialing_events table[14]; history is not retained. (§2)
  4. Reviews are the clinician-profile feature (#1108) - member-visible rating
    • comment, aggregated at read time so there is no denormalised value to drift[15]. (§3)
  5. Rating is display, not ranking - it rides on one provider's profile read; nearest-provider search ranks by distance and never reads it[18]. (§3)
  6. rating is DB-CHECK-enforced - the platform's habit is code-only vocabularies; the 1..5 CHECK is a deliberate belt-and-braces on the one field where a bad value would silently skew a public average[2]. (§4)
  7. Events emitted from the handler, best-effort - the semantic credentialing.* / provider.reviewed producers were added platform-wide (#1644); provider publishes them directly to provider.events after the DB write, ignoring the error[22]. (§9)
  8. The model is current-state and additive - decision history, event durability, reviewer identity and review moderation are each a satellite table or a column added beside these two, not a re-model of them. That is why every row in §9 names a migration or a query, and none names a rewrite.

9. Caveats and extensibility

Group and individual. Nothing here is scheme- or policy-aware. Credentialing vets a provider onto the network and reviews rate a provider's profile; both serve group members and (future) individual policyholders identically. The group/individual fork never reaches these tables - it lives entirely in the policy and authorisation layers.

Extension points - when, what, where.

When we need …What to addWhere
credentialing to run a real reviewer workflowThe lifecycle is already modelled: submit inserts SUBMITTED, approve and reject are each guarded to SUBMITTED with a 409, reviewed_at is stamped and reviewer_notes carries the rationale, and approval flips the provider to ACTIVE. What is added is the surface and the identity - an ops screen driving approve/reject, and a reviewed_by column written from the authenticated caller instead of an anonymous decider. Today every decision in the table carries e2e approve / e2e reject and the web-admin resource is list/show/createops screen: apps/web-admin/src/app/admin/App.tsx credentialing resource[24]; decision path: services/provider/internal/service/provider.go[10][11]; reviewed_by as a new migration in services/provider/migrations/
decisions to carry historyRequests are Saved in place, so a row is current state rather than a ledger. Add an append-only credentialing_events satellite written in the same call as the decision (who, when, from-status, to-status, note), the shape claims.claim_events already uses; the request row stays the fast current-state readnew migration in services/provider/migrations/ (next after 0010_add_location_to_providers.sql); write seam is the repository Update[14]
provider events to be durablecredentialing.* and provider.reviewed publish directly to provider.events after the DB write, best-effort, so a Kafka hiccup drops the announcement. Provider has no outbox package; adding one - event row written in the decision's transaction, drained by a relay - follows the pattern care and claims already run, and pairs naturally with the history table aboveoutbox table via services/provider/migrations/, relay + producer wiring in services/provider/cmd/server/main.go[22]
ratings to influence matchingprovider_reviews already feeds a display average and count onto the profile read, computed at query time. FindNearest filters network_status = 'ACTIVE' and orders by PostGIS distance alone, so ranking on rating is a scoring change in one query, not a schema change: blend the average into the ORDER BY (or materialise it onto providers if the read cost bites)services/provider/internal/repository/gorm_providers.go (FindNearest)[18], aggregate already available from gorm_reviews.Stats[15]
reviews to be a trustworthy signalauthor is free text with no party link and no moderation state. A member link plus a moderation/status column makes reviews attributable and suppressible - the prerequisite for the row above, since ranking on an unmoderated average is gameableservices/provider/migrations/ for the columns; services/provider/internal/handler/reviews.go[16] for the write
approval to be atomicApprove does two sequential UPDATEs in one call - the request, then the provider. Folding both into one DB transaction closes the window where a request reads APPROVED but the provider was not activated. Live data is consistent today; nothing enforces itservices/provider/internal/service/provider.go (ApproveCredentialing)[10]

The invariant worth keeping. rating is one of the few genuinely DB-enforced vocabularies in this estate: CHECK (rating BETWEEN 1 AND 5) in the migration[2], with the handler validating the same range and returning 400 before the insert[16]. Guarded twice, so a bad rating cannot reach the average by any write path - including a direct SQL one. Credentialing status, by contrast, is a Go-enum vocabulary with no CHECK behind it (§4).

What the current population means. Every APPROVED/REJECTED note is e2e approve or e2e reject, written sub-millisecond by the e2e suite; the decision path is exercised end to end, and no admin UI drives it yet. The 11 reviews are a seeded favourable fixture across 6 providers (ratings 4 and 5 only, average 4.64), so the data holds no negative rating to test the display path against.

Known defects, with the fix:

  • Three service methods have no callers. ProviderService.SubmitCredentialing, GetCredentialing and ListCredentialing exist and are OTel-instrumented, but the handler reimplements submit/get/list directly against the repository[6] and routes only approve/reject through the service[12]. Two implementations of submit means a rule added to the service one (an event, a validation) silently does not apply to the route anyone calls. Fix: point the handler at the service methods, or delete them. Where:services/provider/internal/handler/credentialing.go and services/provider/internal/service/provider.go.
  • reviewer_notes stores '' where the DDL means NULL. The Go field is a non-pointer string[3], so every insert without a note writes the zero value: all 23 SUBMITTED rows hold an empty string, 0 hold NULL. "No note yet" and "decided with an empty note" are then indistinguishable in SQL, and the same trap the Party and Provider pages flag. Fix: make the field *string (or normalise '' to NULL on write). Where:packages/go/domain/providers.go.
  • REJECTED leaves the provider PENDING. Rejection touches only the request[11], so the directory cannot distinguish never-reviewed from reviewed-and-refused. Fix: set a distinct directory state on reject (detailed on the Provider page). Where:RejectCredentialing in services/provider/internal/service/provider.go.

References

Code links are pinned to commit b61c5802 on main (2026-08-21); the file is the anchor if lines drift. Pins are checked mechanically by docs/site/scripts/check-code-refs.py.

  1. provider/migrations/0003_create_credentialing_requests.sql - credentialing DDL: locator UNIQUE L4, provider FK L5, status default L6, reviewer_notes nullable L9
  2. 0009_provider_profile.sql - provider_reviews DDL: FK L5, rating CHECK 1..5 L7, index L11
  3. packages/go/domain/providers.go#L54 - CredentialingRequest model; non-pointer ReviewerNotes string L61, pointer ReviewedAt *time.Time L60
  4. packages/go/domain/providers.go#L38 - ProviderReview model; Rating int L42
  5. packages/go/domain/enums.go#L62 - CredentialingStatus vocabulary (no CHECK backs it)
  6. provider/internal/handler/credentialing.go#L56 - submit: provider lookup, CRD- mint, SUBMITTED insert (the credentialing_submit MCP route)
  7. credentialing.go#L158 - approve handler + credentialing.approved emit
  8. credentialing.go#L206 - reject handler + credentialing.rejected emit
  9. credentialing.go#L183 - getStatus by NPI (the provider_credentialing_status MCP route)
  10. provider/internal/service/provider.go#L228 - ApproveCredentialing: SUBMITTED guard L237-241, provider activation L250-261 (separate UPDATE)
  11. provider.go#L265 - RejectCredentialing: SUBMITTED guard L274-278, request only (provider untouched)
  12. provider.go#L178 - the SubmitCredentialing / GetCredentialing / ListCredentialing service methods, which have no callers
  13. provider/internal/repository/gorm_credentialing.go#L60 - NextLocator: counter-table UPDATE + CRD-%d-%06d
  14. gorm_credentialing.go#L56 - Update: GORM Save in place, no history
  15. provider/internal/repository/gorm_reviews.go#L36 - Stats: COALESCE(AVG(rating),0), COUNT at read
  16. provider/internal/handler/reviews.go#L78 - addReview: rating 1..5 validation L94-96 + provider.reviewed emit
  17. provider/internal/handler/providers.go#L153 - get: attaches rating avg + reviewCount to the profile, best-effort
  18. provider/internal/repository/gorm_providers.go#L98 - FindNearest: ACTIVE + distance KNN, no rating
  19. provider/internal/handler/handler.go#L99 - mountCredentialingRoutes: submit/list/get/approve/reject/status
  20. handler.go#L86 - mountProviderRoutes: reviews list/add at L93-94
  21. 0008_create_seq_tables.sql - the one-row credentialing_locator_seq counter table
  22. provider/cmd/server/main.go#L67 - event producer wired to provider.events, no outbox; emits are best-effort
  23. provider/internal/kafka/producer.go#L44 - credentialing.* + provider.reviewed event constants (#1644)
  24. apps/web-admin/src/app/admin/App.tsx#L140 - the credentialing web-admin resource (no reviews resource)

Live-schema facts (constraint list including the rating CHECK and the Debezium publication, credentialing status distribution, the reviewer_notes''-vs-NULL split, the counter value, the review rating distribution and average, the worked rows) come from PGPASSWORD=… psql -h 10.0.1.2 -U olly -d provider · \d provider.credentialing_requests, \d provider.provider_reviews, select status, count(*) …, select rating, count(*) …, 2026-08-21.

Olly Health Insurance Platform