Care
Go microservice, port 4009, container
olly-care. Owns the post-triage care workflow: episodes, appointments, provider slots, prescriptions, and diagnostic referrals.
Care holds a member's care journey after triage. An episode is the root aggregate; appointments, prescriptions, and referrals hang off it by locator. Everything is created synchronously over HTTP. There is no Kafka consumer in the service: episodes are not opened by an event, and appointment status is not driven by provider events. A caller that wants to link an episode back to a triage session passes triageSessionLocator in the create body; the field is stored as-is and never resolved against triage.
Field reference: full columns, types, and nullability live in the catalog. This page is the narrative.
GCP migration: sub-project #4 (GKE). Database: Cloud SQL care (prod) / postgres on dev-2 (dev).
What Care owns
| Table | Role |
|---|---|
episodes | Root aggregate. One per member care journey; scopes all child resources. |
appointments | Provider appointments within an episode. |
prescriptions | Prescription records within an episode; medication is a JSONB blob. |
diagnostics_referrals | Referrals to clinics / diagnostic facilities within an episode. |
provider_slots | Care-owned slot inventory per provider. Care manages these itself; they are not synced from the Provider service. |
outbox | Transactional outbox. Rows are written in the same DB transaction as the business write and drained to Kafka by a background worker. |
Children reference the parent by episode_locator TEXT (FK to episodes.locator), not by a numeric id. Primary keys are UUIDs; locator, party_locator, and the cross-service refs are all TEXT.
Identity and status
Locators are generated server-side with a typed prefix: episodes EP-, appointments APT-, slots SLT-, prescriptions RX-, referrals REF-. Statuses are UPPERCASE strings stored as free TEXT (no DB CHECK); the app sets them.
| Object | Statuses (default first) | Notes |
|---|---|---|
| Episode | OPEN → CLOSED | CANCELLED | No IN_PROGRESS state. Close and cancel both require the episode to be OPEN; re-closing or re-cancelling a terminal episode errors. |
| Appointment | PENDING → CONFIRMED → ATTENDED; or CANCELLED | NO_SHOW | Transitions are not guarded: any action sets its target status. |
| Provider slot | AVAILABLE → BOOKED | BLOCKED | Booking an OWN_SLOT appointment flips the slot to BOOKED atomically; cancel releases it. |
| Prescription | ISSUED → FILLED | VOIDED | |
| Referral | REFERRED → BOOKED → COMPLETED; or CANCELLED |
care_type is free text: the only check is non-empty. There is no gp_appointment / urgent_care enum enforced in code or schema. Appointment type is also free text; the single value with special behavior is the literal OWN_SLOT, which makes booking claim a Care-owned slot.
API routes
All public routes sit behind JWT middleware (KEYCLOAK_JWKS_URI is a required env var, so the binary exits if it is unset). Locators in paths are the typed string locators above. Request and response bodies are camelCase JSON.
Episodes
| Method | Path | Request body | Response |
|---|---|---|---|
POST | /episodes | {partyLocator, careType, triageSessionLocator?} | 201 full Episode |
GET | /episodes/{locator} | - | 200 the flat Episode row (no nested appointments / prescriptions / referrals) |
GET | /episodes?partyLocator=X&status=Y&careType=Z | - | 200 Episode list (all query params optional) |
PATCH | /episodes/{locator}/close | {summary} | 204 No Content |
PATCH | /episodes/{locator}/cancel | - | 204 No Content |
Close sets status=CLOSED, stores summary, stamps closed_at, and enqueues care.episode.closed. It is not idempotent: closing an already-CLOSED/CANCELLED episode returns an error. summary is not validated: an empty string is accepted.
Appointments
| Method | Path | Request body | Response |
|---|---|---|---|
POST | /episodes/{locator}/appointments | {providerLocator, type, slotLocator?, linkOutUrl?, scheduledAt?, notes?} | 201 full Appointment |
GET | /episodes/{locator}/appointments | - | 200 Appointment list |
PATCH | /appointments/{locator}/confirm | - | 204; enqueues care.appointment.confirmed |
PATCH | /appointments/{locator}/attend | - | 204; enqueues care.appointment.attended |
PATCH | /appointments/{locator}/cancel | - | 204; releases the slot if OWN_SLOT |
PATCH | /appointments/{locator}/no-show | - | 204 |
Booking validates the provider (see Dependencies) and creates a PENDING appointment. scheduledAt is optional and not checked against now(): past or absent dates are accepted. A failed provider check returns 422, not 503.
Slots
Care owns its slot inventory. These routes let a provider-facing caller populate and block availability.
| Method | Path | Request body | Response |
|---|---|---|---|
POST | /providers/{providerLocator}/slots | {startTime, endTime} | 201 ProviderSlot (validates provider is ACTIVE) |
GET | /providers/{providerLocator}/slots?from=X&to=Y | - | 200 available slots for the provider |
PATCH | /slots/{locator}/block | - | 204; only an AVAILABLE slot can be blocked |
Prescriptions
| Method | Path | Request body | Response |
|---|---|---|---|
POST | /episodes/{locator}/prescriptions | {providerLocator, medication, issuedAt?} | 201 full Prescription |
GET | /episodes/{locator}/prescriptions | - | 200 Prescription list |
PATCH | /prescriptions/{locator}/fill | - | 204 → FILLED |
PATCH | /prescriptions/{locator}/void | - | 204 → VOIDED |
medication is an opaque JSON object (conventionally {name, dosage, frequency, instructions}) stored as JSONB. There are no separate medicationCode / dosage / durationDays columns. issuedAt defaults to now() when omitted.
Referrals
| Method | Path | Request body | Response |
|---|---|---|---|
POST | /episodes/{locator}/referrals | {referralType, clinicLocator?} | 201 full Referral (status=REFERRED) |
GET | /episodes/{locator}/referrals | - | 200 Referral list |
PATCH | /referrals/{locator}/book | {clinicLocator, appointmentLocator} | 204 → BOOKED |
PATCH | /referrals/{locator}/complete | - | 204 → COMPLETED |
PATCH | /referrals/{locator}/cancel | - | 204 → CANCELLED |
There is no specialty / reason / urgency on a referral. The create body is {referralType, clinicLocator?}.
Internal (no JWT)
Used by olly-member-portal-api for member-facing fan-in. These routes are registered outside the JWT group and have no authentication today: no bearer token, no X-Internal-Token, no mTLS. They rely on network / gateway isolation and are not exposed through APISIX.
| Method | Path | Response |
|---|---|---|
GET | /internal/episodes/{locator} | The Episode row |
GET | /internal/members/{partyLocator}/episodes | All episodes for a member |
Database
Schema lives in migrations/00001_care_schema.sql (the five domain tables) and migrations/00002_create_outbox.sql (the outbox). Column-level detail is in the catalog; the shapes that matter for callers:
episodes:id UUID PK,locator TEXT UNIQUE,party_locator TEXT,triage_session_locator TEXT?(no enforced FK to triage),care_type TEXT,status TEXT DEFAULT 'OPEN',summary TEXT?,created_at,closed_at?. There is nopresenting_complaint,closed_by, orupdated_at.appointments:id UUID PK,locator,episode_locator → episodes.locator,provider_locator,type,status DEFAULT 'PENDING',slot_locator?,link_out_url?,scheduled_at? (nullable),notes?,created_at. Onenotescolumn, not member/clinician split.provider_slots:id UUID PK,locator,provider_locator,start_time,end_time,status DEFAULT 'AVAILABLE',appointment_locator?,created_at.prescriptions:id UUID PK,locator,episode_locator,provider_locator,medication JSONB,status DEFAULT 'ISSUED',issued_at,created_at.diagnostics_referrals:id UUID PK,locator,episode_locator,referral_type,clinic_locator?,status DEFAULT 'REFERRED',appointment_locator?,created_at.outbox:id UUID PK,topic,key,payload JSONB,published_at?,created_at. Partial index on unpublished rows.
Events
Care produces events through the transactional outbox. Every emitting write enqueues a row in the same DB transaction; a background worker (internal/outbox) polls unpublished rows every 500 ms and publishes to Kafka at-least-once, restoring the originating trace context onto the message headers.
One topic, the canonical envelope
All events go to a single topic care.events (the worker falls back to care-events, from KAFKA_CARE_TOPIC, only for a row with an empty topic, which the service never produces). The kind of event is the eventType field, not the topic.
Every message is the platform's canonical envelope (packages/go/domain/event_envelope.go): eventId (the outbox row id, stable across re-publications, so idempotent consumers can dedupe), eventType, occurredAt, partyLocator (lifted from the payload where it carries one), correlationId (the originating request's trace id), causationId, session/activity lineage from client baggage, the payload exactly as the emit site wrote it, and state: the complete subject entity frozen at emit time, because a locator alone cannot tell an auditor what the row looked like when the event fired.
Produced
eventType | Emitted when | State subjects |
|---|---|---|
care.episode.opened | POST /episodes creates an episode (idempotent per party + care type + triage session while OPEN) | episode |
care.episode.closed | episode close (with summary; terminal) | episode |
care.episode.cancelled | episode cancel (terminal) | episode |
care.appointment.confirmed | appointment confirm action; payload enriched with eventId/partyLocator/scheduledAt/appointmentType for notifications (#1630) | appointment |
care.appointment.attended | appointment attend action | appointment |
care.appointment.cancelled | appointment cancel action (same tx releases an OWN_SLOT slot; Kry bookings cancelled upstream first) | appointment |
care.appointment.no_show | appointment no-show action | appointment |
care.appointment.reminder | T-24h sweep over CONFIRMED appointments (#1630); at most once per appointment via the appointment_reminders_sent ledger | appointment |
care.prescription.issued | POST prescription within an episode | prescription |
care.prescription.filled | prescription fill action | prescription |
care.prescription.voided | prescription void action | prescription |
care.referral.created | diagnostics referral raised within an episode | referral |
care.referral.completed | referral complete action | referral |
care.referral.cancelled | referral cancel action | referral |
Slot changes and BookAppointment itself emit nothing (there is no care.appointment.booked; the first event in an appointment's life is confirmed). BookReferral also emits nothing.
The full contract for each type (payload JSON Schema, state subjects, lineage requirements, producers/consumers as code refs, golden examples) lives in the event registry at packages/go/domain/eventregistry/registry/care.*.
Consumes
None. Care runs no Kafka consumer. There is no triage.session.completed projection, no olly-care-projection consumer group, and no provider.slot.* handling. Episodes and appointment status are driven only by the HTTP routes above. Downstream, care.events is read by notifications (topic-level dispatcher) and the debugger.
Dependencies
| Dependency | Env | Purpose | Failure mode |
|---|---|---|---|
Postgres (Cloud SQL care prod / dev-2 dev) | DATABASE_URL | Episodes and child resources | Hard fail: service unavailable |
| Provider service (REST) | PROVIDER_SERVICE_URL | One call: GET /internal/providers/{locator}, checks networkStatus == ACTIVE. Used on book-appointment and add-slot. 5 s timeout, no retries. | Provider not found / not active → booking returns 422. Care does not call any provider slots endpoint and does not reserve slots remotely. |
| Kafka (Strimzi prod / Redpanda dev) | KAFKA_BROKERS, KAFKA_CARE_TOPIC | Outbox publishing of care.events | Degraded: unpublished outbox rows accumulate and retry; no data loss |
Notifications (downstream, via care.events → Novu) | - | Member reminders for episode / appointment events | Decoupled: async; does not block Care writes |
Invariants
- Episode status starts
OPEN. Close and cancel each require the current status to beOPEN; a terminal episode (CLOSED/CANCELLED) cannot be closed or cancelled again. There is no reopen. - An
OWN_SLOTappointment claims its slot atomically: the appointment insert and the slot flip toBOOKEDcommit together, and cancel releases the slot in one transaction. - Every emitting write (the fourteen event types above) inserts exactly one outbox row in the same transaction as the business change; the worker publishes it at-least-once and the envelope
eventIdis the outbox row id, so consumers dedup on it. - Child resources reference their episode by
episode_locator; the FK guarantees an appointment / prescription / referral cannot exist without its episode.
Caveats
- No event-driven creation. Episodes are created only by
POST /episodes;triageSessionLocatoris a caller-supplied opaque string, never resolved against triage. - Statuses and types are conventions, not DB enums.
status,care_type, and appointmenttypeare freeTEXT; onlyOWN_SLOThas special code behavior. - Weak validation.
scheduledAtis optional and not checked against now(); close accepts an emptysummary. Appointment status transitions are not guarded. - Internal routes are unauthenticated.
/internal/*has no token or mTLS today; isolation is network-level only.
Non-goals
- Does NOT dispatch prescriptions to pharmacies; that is a downstream pharmacy concern.
- Does NOT process insurance claims; Claims service (
olly-claims, port 4001). - Does NOT own the provider directory; Provider service (
olly-provider, port 4005). Care validates a provider's active status but holds its own slot inventory. - Does NOT enforce eligibility; Eligibility service (
olly-eligibility, port 4002).
