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 ErrSupervisedNotImplemented — supervised 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 says | Reality |
|---|---|
patient_persons, current_user_patient_person_ids() | patient_profiles / current_human_patient_profile_ids() (migration 000006) |
class pii_contact | Not 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 BIGINT | P26 is UUIDv7 — and the column is deferred anyway (see below) |
Other corrections to this page: service_id → offering_id (Offerings is the canonical name); service_forms → offering_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 → upcomingThis 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
| Status | What it means |
|---|---|
booked | Booking confirmed, contact info captured, no patient account yet |
upcoming | Patient onboarded, forms ready, video room active |
confirmed | Patient confirmed they'll attend |
inprogress | Specialist has opened the session |
done | Session complete, forms signed |
cancelled_by_patient | Patient cancelled |
cancelled_by_clinic | Clinic cancelled — excluded from the adherence denominator |
cancelled_late | Patient cancelled inside the late threshold (24h in the live system) |
noshow | Patient 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:
- Offering forms (
offering_forms) — defined on the offering being booked (intrinsic to the procedure) - 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 itservice_id; "service" is forbidden vocabulary for this concept.)calendar_id(UUID, nullable) — which calendar was used (NULLfor direct registrations)organization_id(UUID, NOT NULL) + RLS, like every tenant tablelocation_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 outsideauth_secret/pii_regulatedis plaintext + layered defense; the dedupe-by-email path stays simple as a result.booking_client_id— server-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_nameandcontact_phoneare not stored here — they live onpatient_profiles, created at booking time
Add-ons:
additional_offering_ids(UUID[]) — offerings added during the appointment— dropped. Products are deferred and nothing depends on them.additional_product_ids
Multi-session plans — REMOVED, and deliberately not a placeholder column:
/patient_service_plan_idhave 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;plan_session_numberprotocols.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:
| Transition | Side effects |
|---|---|
booked → upcoming | Create patient + user (if new), generate forms, create Daily.co room |
upcoming → confirmed | Notify specialist |
any → cancelled / noshow | Delete Daily.co room |
reinstated from cancelled/noshow | Recreate 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— neverdata.lengthover 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_valuesBoth 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:
| Rule | Detail |
|---|---|
| Reschedule preserves duration exactly | It 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 min | Make 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 chars | Behind a confirm dialog |
| Late cancellation flagged at a 24h threshold | Org-configurable vs platform constant is open |
| Specialist overlap on the staff-created path is a WARNING, not a block | Deliberate — 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 dispatch | Staff verify the destination |
Open decisions
- Terminal-status policy vs. late arrival — silent adherence corruption if unresolved. The substrate makes
noshowterminal; the legacy system permitsnoshow → 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 theprotocol_id/session_idpair the adherence engine reads — and leaving the auto-noshow permanently in the adherence denominator, penalising a patient who actually attended. The proposal on file: permitnoshow → inprogressonly 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_atin place with audit (legacy behaviour, preserves duration) vs. cancel + new row (forensically cleaner, consistent with the terminal-cancel rule). - Late-cancellation threshold —
organization_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 tocancelled_by_clinicavoids retroactively penalising thousands of migrated patients, but overstates clinic-caused cancellations in fill-rate reporting. - Cross-org double-booking —
specialists.organization_id NOT NULLmeans 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.
Related docs
- API Reference →
- Lifecycle (state machine) →
- Calendar View →
- Videocall Integration →
- Scheduling & Booking →
- Forms →
- Programs & Assignments → (appointment-driven cadence consumer)