Skip to content

Appointments

A booked consultation between a patient and a specialist — from the moment of booking through the end of the session, with forms, video, and documentation all tied together.

THE appointments TABLE DOES NOT EXIST — reconciled 2026-08-02

Verified against schema at migration 000039: there is no appointments table, no appointment_files, no appointment_reviews, no Go domain, no routes, and zero appointments.* permission rows in any migration. Every appointments reference in the shipped migrations is a forward-looking comment.

june-launch.md claims F5's substrate columns "already landed in the cadence redesign." That is false. The cadence redesign designed those columns against a table that was never created. Building against that claim produces a broken migration.

The table lands in migration 000046, after F1 → F2.1 → F3 → F4. internal/core/domain/adherence/cadence.go already defines the AppointmentCounter interface and returns ErrSupervisedNotImplementedsupervised protocols are shipped and live in production, waiting on exactly this table.

appointments-substrate.md is authoritative for the table shape — but will not apply as written

Four verified defects must be fixed before that doc becomes a migration:

Doc saysReality
patient_persons, current_user_patient_person_ids()patient_profiles / current_human_patient_profile_ids() (migration 000006)
class pii_contactNot a class. The nine are public, org_internal, pii_basic, pii_regulated, clinical, clinical_sensitive, auth_secret, audit_only, system_metadata. make check fails.
specialist_principal_id → principals(id)Contradicts P9's specialists.human_id UNIQUE NULL — it makes calendar-only specialists unbookable
patient_service_plan_id BIGINTP26 is UUIDv7 — and the column is deferred anyway (see below)

Other corrections to this page: service_idoffering_id (Offerings is the canonical name); service_formsoffering_forms; there is no users table and no user_id; additional_product_ids is dropped (Products is deferred); and patient_service_plan_id / plan_session_number are removed, because the plans they FK are deferred and there is no target — do not add a nullable placeholder "for later" (P36).


What this enables

  • A patient books an appointment through the public booking page (no account required at booking time)
  • The specialist or admin onboards the patient afterward — creating their account and generating the required forms
  • The appointment moves through a clear lifecycle: booked → upcoming → confirmed → in-progress → done
  • A video call room opens automatically when the appointment is onboarded — no manual setup
  • Patients and specialists can review and complete all forms tied to the appointment in one place
  • After completion, patients can leave a star rating — low ratings trigger an alert for the admin to review
  • Appointment history is permanent and soft-deletable only — clinical records are never erased

How it works

The two-phase booking model

Booking and patient onboarding are intentionally separate:

Phase 1 — Public booking (no login required)
  Patient picks a time and fills in name, email, phone
  → patient_profiles record created with name and phone
  → Appointment created with status: booked
  → contact_email stored for confirmation notification

Phase 2 — Onboarding (admin or specialist)
  Staff opens the booking and registers the patient
  → auth account created (if not existing) → principals + humans rows
  → patient_profiles.human_id linked to the new human
  → patients row (the org ↔ patient link) created
  → appointments.patient_id linked (was NULL through phase 1)
  → Required forms generated automatically, pre-filled from the portable profile
  → Video room created
  → Status: booked → upcoming

This design lets clinics accept bookings from people who don't yet have an account, which is the most common real-world situation.

Two-phase identity, precisely: patient_profile_id is set at booked with patient_id NULL; patient_id is linked at onboarding. Both directions are enforced by pair CHECKs. There is no user_id anywhere — the platform has no users table; humans live in humans(principal_id).

Lifecycle at a glance

StatusWhat it means
bookedBooking confirmed, contact info captured, no patient account yet
upcomingPatient onboarded, forms ready, video room active
confirmedPatient confirmed they'll attend
inprogressSpecialist has opened the session
doneSession complete, forms signed
cancelled_by_patientPatient cancelled
cancelled_by_clinicClinic cancelled — excluded from the adherence denominator
cancelled_latePatient cancelled inside the late threshold (24h in the live system)
noshowPatient didn't attend

Nine statuses, and cancelled splits three ways on purpose. The adherence denominator must exclude clinic-attributable cancellations — a patient is never penalised for a capacity shortfall. A single cancelled status cannot express that.

There is no deleted_at. The status enum covers every did-not-happen case, so a soft-deleted appointment would be a second, redundant way to say the same thing. This is the deliberate exception to the platform's soft-delete-only rule for clinical records: the record is never removed, it is terminal-stated.

The state machine is new construction, not a port. The legacy system enforces nothing — any status to any status, and done can be dragged back to upcoming from a free select. P33 explicit machine, validated server-side.

Unscheduled appointments are a first-class bucket. started_at IS NULL is a real, common row — walk-ins and initial registrations — with its own list tab and summary card. The list UI must not assume every appointment has a slot.

Forms on appointments

Forms come from two sources and are merged automatically:

  1. Offering forms (offering_forms) — defined on the offering being booked (intrinsic to the procedure)
  2. Calendar forms (calendar_forms) — additional forms specific to the booking channel (e.g., a promotional disclaimer)

Merged, deduplicated, and instantiated in one INSERT in one transaction, each instance carrying a fields JSONB snapshot + template_version frozen at creation.

Attachable slots: disclaimer (multiple), survey (multiple), parameters, analysis, advice. report and prescription are not attachable slots — report is generated via a separate path and prescription has no attach route at all. A global form (appointment_id IS NULL) gates every appointment; that is how clinic-wide disclaimers work. See forms/.

Reviews

After an appointment is done, the patient can submit a rating (1–5 stars) and optional comment. Ratings below 5 trigger an alert in the org dashboard. Admins acknowledge the alert after reviewing.


Technical Reference

Everything below is intended for developers.

Key schema additions

Offering & Calendar references:

  • offering_id (UUID, NOT NULL) — always set; defines what was booked. (This page previously called it service_id; "service" is forbidden vocabulary for this concept.)
  • calendar_id (UUID, nullable) — which calendar was used (NULL for direct registrations)
  • organization_id (UUID, NOT NULL) + RLS, like every tenant table
  • location_id (UUID, nullable) — NULL = remote/telerehab, per the 1B.14 contract (P40)

Contact info (pre-onboarding):

  • contact_email — plaintext (pii_basic), stored for booking confirmation emails before the patient has an account. Per the encryption-invariants rule, all contact PII outside auth_secret / pii_regulated is plaintext + layered defense; the dedupe-by-email path stays simple as a result.
  • booking_client_idserver-derived, from a signed HttpOnly cookie, and persisted. In the legacy system this is caller-supplied (body > query > cookie > generated), never validated, never persisted, and the dashboard regenerates it after every successful booking — which defeats the 24h rebooking cooldown entirely.
  • contact_name and contact_phone are not stored here — they live on patient_profiles, created at booking time

Add-ons:

  • additional_offering_ids (UUID[]) — offerings added during the appointment
  • additional_product_idsdropped. Products are deferred and nothing depends on them.

Multi-session plans — REMOVED, and deliberately not a placeholder column:

  • patient_service_plan_id / plan_session_number have no FK target: the plans feature is deferred (see Offerings). Appointment-package tracking — "this patient has N sessions of Offering X remaining" — is a genuine open gap with no platform equivalent; protocols.kind='enrollment' covers program enrolment only. Ship without the columns rather than with speculative nullable ones (P36).

Cadence links:

  • protocol_id, session_id, channel (in_person | online_live) — the pair the adherence engine reads

Status machine

State transitions have validation and side effects:

TransitionSide effects
bookedupcomingCreate patient + user (if new), generate forms, create Daily.co room
upcomingconfirmedNotify specialist
any → cancelled / noshowDelete Daily.co room
reinstated from cancelled/noshowRecreate Daily.co room

See Lifecycle → for the full state machine with all validations.

Videocall integration

Daily.co is a Cat A Curated Provider: a video.Provider capability plus a platform_service_providers catalog row (the chk_psp_capability_provider whitelist needs ('video','daily') added), resolved per-org through the shipped 1C.2 resolver. A signed BAA/DPA gates it carrying real consultations.

  • Room names derive from a server-side secret and are never URL-visible, and the room is bound to an authenticated principal.
  • Expires at appointment.ended_at
  • Deleted automatically on cancellation or noshow
  • Recreated on reinstatement

Do not port the legacy room-naming scheme

A room name of the form {prefix}-{orgID}-{appointmentID} is derivable by anyone who knows the appointment ID. In the live legacy system the videocall page is unauthenticated and mints a guest token for whatever room name is in the URL — which makes the appointment identifier a non-expiring capability URL to a live medical consultation. This was the single worst defect found in the port survey. Room identity must not be derivable from data that appears in a URL.

See Videocall → for Daily.co integration details.

Calendar views

  • Month view: aggregate appointment counts per day, from a SQL COUNT … GROUP BY — never data.length over a fetched page
  • Week view: full appointment details with time slots
  • Bucketed by scheduling timezone, not by toISOString(). The legacy implementation buckets on UTC, which puts near-midnight appointments on the wrong day.
  • Bounded. The endpoint takes an explicit window; there is no unbounded "return the whole calendar" mode. (The legacy calendar endpoint passes pageSize: -1.)
  • Filtering: by specialist, status, date range — server-side, against fixed allow-lists (apiquery), on indexed columns
  • RLS applied: patients see own appointments, specialists see assigned, admins see all
  • Converted bookings are de-duplicated so one booking does not render twice on the staff calendar

Forms generation on onboarding

1. Collect offering_forms  WHERE offering_id  = appointment.offering_id
2. Collect calendar_forms  WHERE calendar_id  = appointment.calendar_id
3. Merge and deduplicate
4. Generate form instances — ONE INSERT, ONE TRANSACTION — each carrying a
   fields JSONB snapshot + template_version frozen at creation, with
   auto-fill COPIED in from patient_profiles + custom_field_values

Both properties in step 4 are load-bearing. The legacy system creates instances through N+1 client-side POSTs in a for await loop, with a standing in-code TODO that navigating away mid-loop corrupts the form — and it has no snapshot at all, so editing a template retroactively rewrites how every historical form renders.

Patient identity on appointments

Appointments reference patient_profile_id (not user_id). This supports:

  • Patients without accounts — a daughter booking for her elderly father who has no login
  • Family management — one login managing appointments for multiple people

The RLS helper current_human_patient_profile_ids() returns every patient_profiles.id the current human can act on behalf of (themselves + managed dependents), so patients and their caregivers see the appointments they are involved in.

patient_profiles is patient-owned and deliberately has no organization_id — it is the portable profile that crosses clinics. The org boundary on an appointment comes from appointments.organization_id, and what a clinic may read off the profile is scoped by its own patients row — a clinic the patient is not registered at reads nothing (P8, retired).

Access control

Patients can only see their own appointments (and those of anyone they manage). Specialists see appointments assigned to them. Admins see all appointments in their org. Enforced by RLS in the database, plus RequirePermission at the route and RequireURLOrgMatchesScope("id") on the per-org route group (P47). Public booking endpoints live under /v1/public/ on the AdminPool per P5, with responses projected through classification.Filter — never a hand-built field list.

There is no exempt route. In the legacy system the tenant filter applies to list GETs only — findOne and every write are unscoped, five entities are explicitly exempted, and PKs are sequential integers, so any authenticated patient can walk the ID space across every clinic.

Programs & Protocols consumer

A protocol with supervision_mode='supervised' materialises each prescribed session as an appointments row — appointments are the source of truth for what was actually delivered. The adherence engine's supervised dispatch counts appointments in window with status IN ('done','noshow','cancelled_late','cancelled_by_patient'); clinic-attributable cancellations (cancelled_by_clinic) are excluded so patients aren't penalised for capacity shortfalls.

Booking is lazy (week-by-week against real specialist availability) — protocols don't auto-create 24+ appointments upfront. A clinic-side fill-rate metric (booked vs. cadence-prescribed) is a separate F-tier concern, distinct from patient adherence.

The full substrate spec — schema, status enum, indexes, RLS, AppointmentCounter SQL contract, capability gating, open decisions — lives at architecture/appointments-substrate.md, subject to the four corrections in the banner at the top of this page. That is the authoritative source for the table shape and the cadence-integration contract; this feature page covers the higher-level booking + lifecycle behaviour on top of it.

Business rules carried from the live clinic

Extracted from running code, absent from every spec on both sides:

RuleDetail
Reschedule preserves duration exactlyIt is not re-derived from the offering, which may have changed since booking
Cannot reschedule into the past
Auto-noshow grace = 30 min after scheduled start; sweep runs every 15 minMake the grace period organization_settings.noshow_grace_minutes — the legacy system already parameterises it. Nothing writes noshow in the legacy system at all — there is no sweep; this is new construction. Apply the lesson from the session_runs silence timeout: only flip genuinely-unstarted appointments
Cancellation captures a free-text reason, capped at 500 charsBehind a confirm dialog
Late cancellation flagged at a 24h thresholdOrg-configurable vs platform constant is open
Specialist overlap on the staff-created path is a WARNING, not a blockDeliberate — appointments are created manually, and different calendars may share a specialist. The DB exclusion constraint still guards the booking path
The actions dropdown shows the exact phone number and email in the confirmation modal before dispatchStaff verify the destination

Open decisions

  • Terminal-status policy vs. late arrival — silent adherence corruption if unresolved. The substrate makes noshow terminal; the legacy system permits noshow → inprogress. The cron marks noshow at +30 min; the patient walks in at +35. Under a strict terminal rule the specialist must create a new row — orphaning the generated forms, the video room, and the protocol_id / session_id pair the adherence engine reads — and leaving the auto-noshow permanently in the adherence denominator, penalising a patient who actually attended. The proposal on file: permit noshow → inprogress only when the noshow was set by the system actor and within a bounded window, audited as a correction. That undoes a machine's guess, not a human's decision. Not ratified.
  • Appointment-package tracking — no platform equivalent; see the schema note above.
  • Reschedule semantics — mutate scheduled_at in place with audit (legacy behaviour, preserves duration) vs. cancel + new row (forensically cleaner, consistent with the terminal-cancel rule).
  • Late-cancellation thresholdorganization_settings.late_cancellation_hours (default 24h) or a platform constant.
  • Legacy cancelled → which of the three buckets? The legacy data carries no attribution signal beyond free text. Mapping everything to cancelled_by_clinic avoids retroactively penalising thousands of migrated patients, but overstates clinic-caused cancellations in fill-rate reporting.
  • Cross-org double-bookingspecialists.organization_id NOT NULL means the single-true-availability invariant holds only within an org.
  • Patient self-booking at launch? Shipping the public path exposes unauthenticated hold + book endpoints and their abuse surface.