Skip to content
Updated Aug 22, 2026

Care

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

Tablesepisodes, appointments, provider_slots (db care, schema public)[1]; second ring: prescriptions, diagnostics_referrals, appointment_reminders_sent
Owner servicecare (sole writer)
LocatorsEP- / APT- / SLT- + 8 hex chars, minted app-side (no sequence)
Last updated2026-08-18
CompanionERD story, slide 11 · Care Pathways (narrative) · previous: Provider

1. Scope and usage

Care is where the insurer stops adjudicating and starts coordinating: an Episode is one health concern being handled ("this member's physio issue"), an Appointment is a concrete booking against a provider inside it, and provider_slots is the slot inventory care itself owns (D-12, #1015). Prescriptions and diagnostics referrals hang off episodes as the second ring.

Two structural facts make this schema unlike every other Go service:

  • It is the only service whose tables live on the public schema of its database rather than a service-named schema (care db, public.episodes; everywhere else it is provider.providers, claims.claims, …).
  • It is the only service using real foreign keys on locator values: appointments.episode_locator REFERENCES episodes(locator)[1] (and the same for prescriptions and referrals). Elsewhere the pattern is uuid FKs in-service, locator soft refs across services; care joins its own tables on the external identifier itself.

Care is also the middle link of the platform's traceability spine: the triage_session_locator stamped here at booking is the same value claims stamps at submission, so chat → episode → appointment → claim is one join key end to end (§6).

2. Boundaries and relationships

Care is not…That concern lives inJoin
the provider directoryprovider.providers; care re-validates a provider is ACTIVE over HTTP at every booking[2] and sources the bookable list from the provider service's internal readprovider_locator, no FK
the triage sessiontriage.sessions (AI service, separate DB). The link is a nullable, shape-checked uuid column - existence is deliberately never verified cross-service[3]triage_session_locator
the claimclaims.claims carries its own triage_session_locator as "the reverse half of the triage double-entry"[4] - claims and episodes join through the session id, never directlyshared session uuid
an eligibility gatenothing here checks coverage; verdicts (REQUIRES_REFERRAL etc.) are eligibility's, enforced by callers-
the video calllink_out_url stores whatever the caller (or the Meet integration) supplies - care never runs the call. D-38 · #1136opaque URL
a notification sendercare only emits care.events; the notifications service consumes care.appointment.confirmed / .reminder and talks to Novu[5]Kafka

3. Structure

DDL[1] · locator minting[6]

episodes

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE EP- + hex8
party_locatortextThe patient's PTY-; indexed
triage_session_locatortextNullable; CHECK enforces uuid shape[7]; partial index WHERE NOT NULL
care_typetextFree text; live vocab is dirty (see below)
statustextDefault 'OPEN'; OPEN | CLOSED | CANCELLED by convention
summarytextWritten at close
created_at, closed_attimestamptz✓/-Bookkeeping / close stamp

appointments

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE APT- + hex8
episode_locatortextReal FK to episodes(locator) - the locator-valued FK
provider_locatortextSoft ref to the provider directory
typetextFree-form; live: video, SEARCHED_SLOT, VIRTUAL_GP (see wart below)
statustextDefault 'PENDING'; PENDING | CONFIRMED | ATTENDED | NO_SHOW | CANCELLED
slot_locatortextThe slot claimed or the synthetic searched-slot id
link_out_urltextCaller-supplied video link; Meet link on confirm; kry:<id> ref on Kry bookings
scheduled_attimestamptzPart of the double-booking guard
cancellation_reason, cancellation_commenttext#1131 taxonomy + free text[8]
notes, created_attext / timestamptz-/✓

provider_slots

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE SLT- + hex8
provider_locatortextWhose diary; indexed
start_time, end_timetimestamptzThe slot window
statustextDefault 'AVAILABLE'; AVAILABLE | BOOKED | BLOCKED; indexed
appointment_locatortextBack-pointer set on claim, cleared on release

Field-by-field: what and why

Locators: EP-805e14fc - minted app-side as prefix + first 8 hex of a uuid[6]. No sequence, no counter table, no year segment - the only Go service that mints this way. Why acceptable here? Episodes are high-volume, member-facing-transient records; the hex8 space (4 billion) plus the UNIQUE constraint is collision-safe enough, and nothing downstream parses care locators for provenance. The cost: they sort in creation order only by created_at, never by locator.

episodes.triage_session_locator - nullable by design, not by neglect: the episode is created at booking, by the member-app, not at triage disposition (D-17, #1020 - "the episode is the we're committed to care signal; that commitment lives at booking"). A member who books directly without chatting first produces a NULL, honestly. When present it must be a canonical lowercase uuid: the handler normalises and 400s malformed values[3], and a NOT VALID CHECK mirrors the same rule at the storage layer[7] - including forbidding '', the exact ''-vs-NULL split that caused the docsvc invoice_locator incident. Existence in the triage DB is deliberately NOT checked: a malformed id is a caller bug worth a 400; a well-formed id care cannot resolve is not care's call to make. Live: 29 of 141 episodes carry a session link.

episodes.care_type and status - convention-only text, and the live data shows what convention-only buys: care_type holds GP (97), Physio (41), physio (2) and VIRTUAL_GP (1); status holds OPEN (136), open (2), CLOSED (1), CANCELLED (2). Mixed-case duplicates of the same vocabulary, written by different callers at different times. A wart, plainly: any WHERE care_type = 'Physio' silently misses rows.

appointments.type - free-form, and doubly conflated. Live values are video (65), SEARCHED_SLOT (1), VIRTUAL_GP (1): one names a delivery channel, one names a booking mechanism, one names a service type. The code branches on exactly two values - OWN_SLOT triggers the atomic slot claim[9] (zero live rows) and video triggers the Meet invite[10]. The channel-vs-type split is exactly what the standardised catalogue's channels_covered direction resolves: each appointment records its delivery channel, and eligibility compares it against the module's covered channels. Until then this column is both things at once.

appointments.link_out_url - the fulfilment seam (D-38, #1136). Care stores whatever it is given: a caller-supplied URL at booking, a Google Meet link written back on confirm of a video appointment (best-effort - an invite failure never fails the confirm)[10], or - a deliberate column-reuse - kry:<appointmentId> on Kry/Livi bookings so cancel can reach the right upstream booking without a schema migration[11]. Google Meet is the stated interim; the column is provider-agnostic.

cancellation_reason / cancellation_comment - the #1131 taxonomy: change_of_plans | feeling_better | provider_unavailable | scheduling_conflict | cost | other, validated in code[12], with other carrying the free-text comment. This is the data the late-cancellation forfeit rule needs (who cancelled, why, how close to scheduled_at); the forfeit itself is a plan-terms rule not yet computed anywhere.

provider_slots + the claim/release cycle - care owns slot inventory (D-12): booking an OWN_SLOT appointment claims the slot atomically with the appointment insert - status flips to BOOKED and appointment_locator back-points, in one transaction[9]; cancelling releases it (status back to AVAILABLE, back-pointer cleared) in the cancel transaction[13]. The slot-source seam is that SearchSlots has three interchangeable sources behind one API: the DB table, a deterministic in-memory generator over the ACTIVE provider directory (the live default - synthetic SLOT-<provider>-<time> ids, 2pm-8pm London weekdays, ~25% deterministically blocked)[14], and Kry/Livi real GP availability behind KRY_BOOKING_ENABLED (default off), whose opaque slot ids pass through verbatim[15]. Because the synthetic path stores its generated id in appointments.slot_locator, most live slot_locator values do not reference provider_slots rows - which is why that column has no FK while episode_locator does.

Double-booking guard - since the searched-slot path claims no DB row, what stops two members booking the same generated slot? A partial unique index: at most one active (not CANCELLED/NO_SHOW) appointment per (provider_locator, scheduled_at)[8], surfaced as ErrSlotTaken. The full TTL-hold (reserve-before-book) design is explicitly deferred.

appointment_reminders_sent - a one-column dedupe ledger: appointment_locator PRIMARY KEY[16]. The T-24h reminder sweep (15-minute in-process ticker) claims via INSERT … ON CONFLICT DO NOTHING in the same transaction as the outbox enqueue, so only the inserting run emits care.appointment.reminder - restart-safe and replica-safe[17][18].

4. Invariants

InvariantEnforced by
All three locators uniqueDB unique constraints[1]
Appointment / prescription / referral belongs to a real episodeDB FK on the locator value[1] - unique in the estate
triage_session_locator is a canonical uuid or NULL, never ''Handler 400[3] + NOT VALID DB CHECK[7]
One active appointment per provider + timePartial unique index[8]
Slot claim atomic with appointment insert; release atomic with cancelApplication transaction[9]
One reminder per appointment, everDB PK + same-tx claim[16]
Provider must be ACTIVE to book or add slotsApplication, via HTTP to the provider service[2]
Cancellation reason in the v1 taxonomyApplication map[12]
One OPEN episode per (party, care type, triage session)Application idempotency check on create - lookup-failure falls through to create (duplicate episode < refused booking)[19]
care_type / status / type vocabulariesNothing - no CHECKs, and the live mixed-case values prove it
Appointment status transitions form a valid machineNothing - UpdateAppointmentStatus applies any action to any current status[20]
Events reach KafkaTransactional outbox (outbox table, topic care.events) drained by a background worker - never a direct publish[21]
Row changes captured to CDCDebezium publication dbz_care (live \d)

5. Lifecycle

The appointment is the state-bearing entity (episodes go OPEN → CLOSED/CANCELLED):

The diagram shows the intended machine; per §4 the code does not guard transitions, so attend on a CANCELLED appointment would succeed. Live distribution: 54 PENDING, 9 CANCELLED, 2 CONFIRMED, 1 ATTENDED, 1 NO_SHOW - the confirm step is not yet part of the routine booking flow, so most rows sit at PENDING, and the reminder sweep (which scans CONFIRMED rows) stays quiet until they advance.

What each transition emits on care.events (transactional outbox): care.episode.opened and .closed from the episode side[22], care.appointment.confirmed, .attended, .reminder from the appointment side. The book and cancel paths do not enqueue an outbox row, so a consumer learns of a cancellation by reading care rather than the stream (§9). Live outbox contents match: 145 opened, 3 confirmed, 1 attended, 1 reminder, 1 closed.

How the pieces run: booking validates the provider (or routes to Kry), then books[23]; the reminder ticker and outbox worker are goroutines in the service process[17] - care has no Temporal, no scheduler, no saga.

6. Populated example: one triage chat, walked to its booking

141 episodes and 68 appointments live. This is a real chain from 2026-07-31 (party locator real, summary fields empty as they are in the row):

The episode - EP-805e14fc

json
{
  "locator": "EP-805e14fc",
  "party_locator": "PTY-2026-000652",
  "triage_session_locator": "9399a51e-fd81-4330-9dc4-575202ef55d3",
  "care_type": "Physio",
  "status": "OPEN",
  "summary": null,
  "created_at": "2026-07-31T13:10:25Z",
  "closed_at": null
}
KeyRead byWhat actually happens
triage_session_locatorGET /internal/episodes?triageSessionLocator=…[24] + the care.episode.opened payload"which care came out of this chat" resolves without polling; consumers can join chat → care off the stream
same value on claims.claimsclaims' partial index[4]the full thread: this chat's session id joins triage → this episode → any claim filed from it, across three databases, no FK anywhere
care_type: "Physio"slot search's type→specialty mapthe booking screen showed physios, not GPs
status: "OPEN" + party + care type + sessioncreate-idempotency[19]a double-tap of Book returns this row instead of minting a sibling

Its appointment - APT-22f465c8

json
{
  "locator": "APT-22f465c8",
  "episode_locator": "EP-805e14fc",
  "provider_locator": "PRV-2026-000065",
  "type": "video",
  "status": "PENDING",
  "slot_locator": "SLOT-PRV-2026-000065-20260731T133000Z",
  "link_out_url": null,
  "scheduled_at": "2026-07-31T13:30:00Z",
  "cancellation_reason": null
}
KeyRead byWhat actually happens
episode_locatorthe real FKthe DB itself refuses an appointment against a non-existent episode
provider_locator: "PRV-2026-000065"booking-time ValidateActive[2]a real, ACTIVE directory provider - the searched-slot path books real providers against generated times
slot_locator: "SLOT-PRV-…-133000Z"nothing after bookingthe synthetic searched-slot id[14] - it references no provider_slots row; the (provider, scheduled_at) unique index is what protects the time
status: "PENDING" + scheduled_atdouble-booking guard[8]; reminder sweep scans CONFIRMED rows onlyholds 13:30 against this physio; the sweep picks it up once it is confirmed
link_out_url: nullconfirm pathpopulates with a Meet link when this video appointment is confirmed and the Meet client is configured

A DB slot, for contrast - SLT-5e4dd8d8

json
{ "locator": "SLT-5e4dd8d8", "provider_locator": "PRV-GP-001",
  "start_time": "2026-06-16T09:00:00Z", "end_time": "2026-06-16T09:30:00Z",
  "status": "AVAILABLE", "appointment_locator": null }

The only 3 live provider_slots rows all belong to PRV-GP-001 - a locator from the retired in-code stub list[14] that does not even match the directory's PRV-YYYY-NNNNNN format. The owned-inventory table, its atomic claim and its release are built and exercised by tests; the generated searched-slot path is what live traffic uses today. D-12 chose care-owns precisely so a provider-sync can fill this table behind the same API, with no change to the booking contract.

7. Who references care

WhereMechanismMeaning there
claims.claimsshared triage_session_locator value[4]the traceability spine: triage → care → claim on one uuid
timeline serviceGET /care/v1/episodes, party-scoped[25]care lane of the member timeline
notifications serviceconsumes care.events (care.appointment.confirmed, .reminder)[5]booking confirmations + T-24h reminders via Novu
triage / member-appPOST /episodes at booking (D-17); /internal/episodes?triageSessionLocator= for chat → outcomethe writers of the spine
provider servicereferenced, never referencing - care calls it, it never calls careone-way dependency (D-12)

8. Design determinations

  1. Care owns slot inventory; provider-sync deferred - the slot table, atomic claim and release live here; the provider service has no availability concept. D-12 · #1015.
  2. Episode is created at booking by the member-app, not at triage disposition - triage disposition is advisory; commitment lives at booking. Hence triage_session_locator nullable with a linkage-only contract. D-17 · #1020.
  3. Fulfilment is a stored URL, not an integration - Google Meet interim; link_out_url stays provider-agnostic (and gets reused as the Kry back-ref). D-38 · #1136.
  4. Shape validated, existence not - the triage link is checked to be a canonical uuid at the edge and in the DB, and deliberately never resolved cross-service. (§3; same contract on the claims side.)
  5. Interim double-booking guard over TTL holds - the partial unique index stops concurrent booking of one searched slot; reserve-before-book is explicitly v2. #1118. (§3)
  6. Events via transactional outbox only - no direct Kafka writes from the request path. (§4)
  7. Cancellation gets a taxonomy - six reasons, other + comment, feeding the late-cancellation-forfeit rule. #1131. (§3)

9. Caveats and extensibility

Group and individual. Care is purely party-scoped: party_locator in, everything else follows. No scheme, policy or plan-tier column exists here, so group and individual cover share this schema unchanged - whether the booking is covered is eligibility's question, asked by callers before booking, never recorded here.

The episode is the spine. One health concern gets one row, and every other care fact hangs off it: appointments by real FK, prescriptions and referrals the same way, the chat by triage_session_locator, the claim by that same session uuid on the claims side. Adding a care fact is adding a child of the episode; adding a source of care (a partner booker, a new modality, a new fulfilment surface) is filling a seam that already exists. The vocabularies here are free text and the fulfilment link is an opaque column on purpose: both were chosen so a new modality or partner arrives as data and configuration rather than a migration.

Extension points - when, what, where:

When we need ...What to addWhere
a new care modality (dermatology, podiatry, dietetics)one enum entry plus one specialty mapping, and provider-directory rows carrying that specialty. episodes.care_type and appointments.type are free text, so the stored value is a value, not a migrationvalidProviderTypes in care/internal/handler/slots.go · providerTypeToSpecialty in care/internal/service/slot_search.go
real provider availability instead of generated slotsrows in provider_slots from a provider-availability sync, then flip search to prefer the table. The atomic claim, the release and the back-pointer are built and unit-tested[9][13]provider_slots (D-12's chosen seam) · SearchSlots[14]
a second partner booking source beyond Kry/Livianother implementation of care's four-method booking interface, a synthetic provider locator for it, and its own enable flag. Opaque slot ids already pass through verbatim[15] and the cancel round-trip rides the kry:-style back-ref[11]care/internal/client (booking interface + noop) · KRY_BOOKING_ENABLED-shaped flag in care/internal/config/config.go
a new fulfilment surface (phone bridge, home visit, another video partner)write that surface's URL or reference into link_out_url; the column is provider-agnostic by determination, and Meet is the current occupant, not the contractappointments.link_out_url (D-38 · #1136)[10]
delivery channel separated from service typeadopt the standardised catalogue's channels_covered split so eligibility can compare a booking's channel against the module's covered channels; the live appointments.type values are the migration's inputappointments.type
a consumer that reacts to bookings and cancellationsan outbox enqueue in the book and cancel paths, on the existing care.events topic; the outbox, the worker and the payload shape of every other transition are already in placeBookAppointment[23] · the cancel arm of UpdateAppointmentStatus[20]
the late-cancellation forfeit computedthe rule and the ledger entry it writes; who cancelled, why, and how close to scheduled_at are captured at cancel timeplan-terms rule · CancellationReasons[12]
TTL slot holds (reserve-before-book)a hold row or a RESERVED slot status with an expiry sweep; the partial unique index holds the line meanwhile, so this is an upgrade, not a rescueprovider_slots · uniq_active_provider_slot[8]
a third surface joined to the same chatstamp triage_session_locator on the new record with the same uuid CHECK + partial index. The double-entry (episode here, claim there) extends by adding a stamp - no FK, no cross-service lookup, no change to the two existing halvesepisodes.triage_session_locator[7] · claims.claims[4]

Prescriptions, diagnostics referrals, the provider-slot operations and the reminder ledger hang off this same spine and are covered in Care fulfilment (#16).

Ahead of need: the OWN_SLOT claim path matches zero live rows because live traffic books generated slots - the branch, its transaction and its tests are the inventory story arriving before the provider sync that feeds it. Most slot_locator values reference no provider_slots row for the same reason, which is why that column carries no FK while episode_locator does.

Known warts, stated (each as the fix and where it goes):

  • Mixed-case vocabularies - care_type holds Physio/physio, status holds OPEN/open, so WHERE care_type = 'Physio' misses rows. Fix: normalise on write in the episode handler and backfill + CHECK in a new services/care/migrations/ step.
  • Unguarded status transitions - attend on a CANCELLED appointment succeeds. Fix: a from→to transition table in UpdateAppointmentStatus[20], rejecting anything off the §5 machine.
  • Book and cancel emit no outbox row - the two transitions a consumer most wants are the two not on the stream. Fix: enqueue in the same transaction as the write, exactly as confirm/attend do (table row above).
  • CancelEpisode neither emits an event nor cascades to the episode's appointments[26], so a cancelled episode can keep live bookings. Fix: cancel the child appointments (releasing their slots) and enqueue care.episode.cancelled in that transaction.
  • Phantom goose version 4 - the migration history carries a version with no file behind it, which is why the triage-link migration is numbered 00005[7]. Fix: leave it documented, or reconcile goose_db_version on a clean rebuild.
  • Fallback topic default disagrees with the rows - the worker's configured default is care-events (dash) while every enqueued row says care.events (dot); the per-row topic wins, so nothing misroutes today. Fix: change the default in care/internal/config/config.go[27].

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. care/migrations/00001_care_schema.sql - episodes L3-13 · provider_slots L18-27 · appointments L32-44 (locator FK L35) · prescriptions L48-59 · diagnostics_referrals L61-72
  2. care/internal/client/provider.go#L49 - ValidateActive against the provider directory
  3. care/internal/handler/episodes.go#L25 - normaliseTriageSessionLocator: NULL-not-'' + shape-yes/existence-no rationale
  4. claims/migrations/0012_claims_triage_session.sql - "the reverse half of the triage double-entry"; claims-side write at claims.go#L82
  5. notifications/internal/projection/consumer.go#L37 - care.appointment.confirmed / .reminder consumers; topic subscription at config.go#L88
  6. care/internal/service/service.go#L73 - newLocator: prefix + hex8 of a uuid
  7. care/migrations/00005_episodes_triage_session_idx.sql - phantom-00004 note L3-10 · partial index L21-23 · uuid CHECK (NOT VALID, forbids '') L31-36
  8. care/migrations/00004_cancellation_and_slot_guard.sql - cancellation columns (#1131) + uniq_active_provider_slot (#1118)
  9. care/internal/service/service.go#L313 - atomic OWN_SLOT claim: appointment insert + slot BOOKED + back-pointer, one tx
  10. care/internal/service/service.go#L569 - best-effort Meet invite on confirm of video, link written to link_out_url
  11. care/internal/service/service.go#L20 - Kry constants + the kry: LinkOutURL reuse
  12. care/internal/service/service.go#L500 - CancellationReasons v1 taxonomy
  13. care/internal/service/service.go#L639 - slot release on cancel (AVAILABLE + back-pointer cleared)
  14. care/internal/service/slot_search.go#L210 - deterministic slot generation, synthetic SLOT- id at L231; retired stub list L33-47
  15. care/internal/service/slot_search.go#L156 - Kry GP slots: 90-day clamp, opaque ids verbatim
  16. care/migrations/00006_appointment_reminders_sent.sql - reminder dedupe ledger, PK = appointment_locator
  17. care/internal/service/reminders.go#L43 - SweepReminders: claim-in-tx + enqueue; ticker loop L25-39
  18. care/internal/repository/gorm_appointments.go#L61 - TryMarkReminderSent: INSERT ON CONFLICT DO NOTHING
  19. care/internal/service/service.go#L102 - episode create-idempotency (same party + care type + session, still OPEN)
  20. care/internal/service/service.go#L528 - UpdateAppointmentStatus: confirm / attend / cancel / no-show, no current-state guard
  21. care/internal/service/service.go#L32 - outbox-only event posture (Deps comment); worker at outbox/worker.go#L53
  22. care/internal/service/service.go#L130 - care.episode.opened payload incl. nullable triageSessionLocator
  23. care/internal/service/service.go#L264 - BookAppointment: precondition reads outside the tx, Kry routing, ACTIVE gate
  24. care/internal/handler/handler.go#L116 - /internal/episodes?triageSessionLocator= chat → outcome lookup; public routes L82-113
  25. timeline/src/sources/care.ts#L23 - timeline's party-scoped episode read
  26. care/internal/service/service.go#L217 - CancelEpisode: no event, no appointment cascade
  27. care/internal/config/config.go#L61 - KAFKA_CARE_TOPIC default care-events (fallback only; rows carry care.events)

Live-schema facts (constraint list including the locator FKs and the NOT VALID CHECK, Debezium publication, care_type / status / type distributions, outbox event-type counts, slot and reminder row counts) come from PGPASSWORD=… psql -h 10.0.1.2 -U olly -d care · \d episodes, \d appointments, \d provider_slots, \d outbox, select care_type, count(*) …, 2026-08-18. The traceability check on the claims side comes from psql -h 10.0.1.2 -d claims · \d claims.claims, same date.

Olly Health Insurance Platform