Skip to content
Updated Jul 4, 2026

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

TableRole
episodesRoot aggregate. One per member care journey; scopes all child resources.
appointmentsProvider appointments within an episode.
prescriptionsPrescription records within an episode; medication is a JSONB blob.
diagnostics_referralsReferrals to clinics / diagnostic facilities within an episode.
provider_slotsCare-owned slot inventory per provider. Care manages these itself; they are not synced from the Provider service.
outboxTransactional 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.

ObjectStatuses (default first)Notes
EpisodeOPENCLOSED | CANCELLEDNo IN_PROGRESS state. Close and cancel both require the episode to be OPEN; re-closing or re-cancelling a terminal episode errors.
AppointmentPENDINGCONFIRMEDATTENDED; or CANCELLED | NO_SHOWTransitions are not guarded: any action sets its target status.
Provider slotAVAILABLEBOOKED | BLOCKEDBooking an OWN_SLOT appointment flips the slot to BOOKED atomically; cancel releases it.
PrescriptionISSUEDFILLED | VOIDED
ReferralREFERREDBOOKEDCOMPLETED; 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

MethodPathRequest bodyResponse
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

MethodPathRequest bodyResponse
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.

MethodPathRequest bodyResponse
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

MethodPathRequest bodyResponse
POST/episodes/{locator}/prescriptions{providerLocator, medication, issuedAt?}201 full Prescription
GET/episodes/{locator}/prescriptions-200 Prescription list
PATCH/prescriptions/{locator}/fill-204FILLED
PATCH/prescriptions/{locator}/void-204VOIDED

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

MethodPathRequest bodyResponse
POST/episodes/{locator}/referrals{referralType, clinicLocator?}201 full Referral (status=REFERRED)
GET/episodes/{locator}/referrals-200 Referral list
PATCH/referrals/{locator}/book{clinicLocator, appointmentLocator}204BOOKED
PATCH/referrals/{locator}/complete-204COMPLETED
PATCH/referrals/{locator}/cancel-204CANCELLED

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.

MethodPathResponse
GET/internal/episodes/{locator}The Episode row
GET/internal/members/{partyLocator}/episodesAll 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 no presenting_complaint, closed_by, or updated_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. One notes column, 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

eventTypeEmitted whenState subjects
care.episode.openedPOST /episodes creates an episode (idempotent per party + care type + triage session while OPEN)episode
care.episode.closedepisode close (with summary; terminal)episode
care.episode.cancelledepisode cancel (terminal)episode
care.appointment.confirmedappointment confirm action; payload enriched with eventId/partyLocator/scheduledAt/appointmentType for notifications (#1630)appointment
care.appointment.attendedappointment attend actionappointment
care.appointment.cancelledappointment cancel action (same tx releases an OWN_SLOT slot; Kry bookings cancelled upstream first)appointment
care.appointment.no_showappointment no-show actionappointment
care.appointment.reminderT-24h sweep over CONFIRMED appointments (#1630); at most once per appointment via the appointment_reminders_sent ledgerappointment
care.prescription.issuedPOST prescription within an episodeprescription
care.prescription.filledprescription fill actionprescription
care.prescription.voidedprescription void actionprescription
care.referral.createddiagnostics referral raised within an episodereferral
care.referral.completedreferral complete actionreferral
care.referral.cancelledreferral cancel actionreferral

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

DependencyEnvPurposeFailure mode
Postgres (Cloud SQL care prod / dev-2 dev)DATABASE_URLEpisodes and child resourcesHard fail: service unavailable
Provider service (REST)PROVIDER_SERVICE_URLOne 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_TOPICOutbox publishing of care.eventsDegraded: unpublished outbox rows accumulate and retry; no data loss
Notifications (downstream, via care.events → Novu)-Member reminders for episode / appointment eventsDecoupled: async; does not block Care writes

Invariants

  1. Episode status starts OPEN. Close and cancel each require the current status to be OPEN; a terminal episode (CLOSED/CANCELLED) cannot be closed or cancelled again. There is no reopen.
  2. An OWN_SLOT appointment claims its slot atomically: the appointment insert and the slot flip to BOOKED commit together, and cancel releases the slot in one transaction.
  3. 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 eventId is the outbox row id, so consumers dedup on it.
  4. 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; triageSessionLocator is a caller-supplied opaque string, never resolved against triage.
  • Statuses and types are conventions, not DB enums. status, care_type, and appointment type are free TEXT; only OWN_SLOT has special code behavior.
  • Weak validation. scheduledAt is optional and not checked against now(); close accepts an empty summary. 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).

Olly Health Insurance Platform