Appointments substrate
Status: Spec, not yet built. The
appointmentstable is documented in features/appointments/ at the feature level, but no migration or Go domain exists yet. This doc is the authoritative substrate spec for when it lands — schema, status enum, indexes, RLS, and the contract the cadence engine'ssuperviseddispatch depends on. The feature-level doc covers the two-phase booking model, forms generation, video rooms, calendar views, and other higher-level behaviour; this doc owns the load- bearing shape.Decisions ratified 2026-08-05. Six of the nine open decisions below are now settled and folded into the schema. They are recorded in place under Open decisions with their reasoning, and summarised here because three of them changed the canonical schema:
Decision Outcome Schema effect #7 specialist_idnullabilityNullable NOT NULLdropped; new partial index for the unassigned queue#8 Add-ons Struck entirely additional_offering_idsremoved — no writer, no reader, and leo has no add-on concept at all#9 Terminal status vs. late arrival noshow → inprogresspermitted when the noshow was system-setTransition graph gains one guarded edge; no column change #1 Late-cancellation threshold organization_settings.late_cancellation_hours(default 24)New settings column, plus noshow_grace_minutes(default 30)#2 Reschedule semantics Mutate scheduled_atin place, auditedNone — no supersession FK #5 Patient-self booking Deferred; F5 ships staff-side first None — F5.4 lands after Corrected 2026-08-02. The leo port survey found four defects that would have failed on first contact with the codebase (leo-port-map.md §0.3). All four are fixed below; the old shapes are recorded here so a reader of an older copy can tell what changed:
Was Now Why patient_person_id → patient_persons(id),current_user_patient_person_ids()patient_profile_id → patient_profiles(id),current_human_patient_profile_ids()Renamed in migration 000006; the old names exist nowhere in codecontact_emailclasspii_contactpii_basicpii_contactis not one of the nine classes ininternal/shared/classification/types.go.make check(cmd/check-classification) fails on itspecialist_principal_id → principals(id)specialist_id → specialists(id)P9: specialists.human_idisUNIQUE NULL— calendar-only specialists have no principal and would have been unbookablepatient_service_plan_id BIGINT,plan_session_number INTremoved (reserved) Service plans are F2.2, deferred; and P26 makes every PK/FK UUID, neverBIGINT. See Reserved for F2.2Two consequential renames land with them, from the naming settled 2026-08-02:
service_id→offering_id(the glossary'sservices → offerings, no longer deferred now that the F2.1 stand-in ships) andadditional_product_idsdropped with F2.3.
What this is
The appointments table is the source of truth for specialist-led sessions — what was booked, when, by whom, for whom, through which channel, and how it actually ended. Two consumers depend on its shape:
- The booking + lifecycle feature (apps/docs/features/appointments/) — public booking → onboarding → confirmation → in-progress → done, plus reschedules, cancellations, no-show detection, and Daily.co room lifecycle.
- The cadence engine's
superviseddispatch (apps/docs/architecture/cadence-and-supervision.md) — when a protocol hassupervision_mode='supervised', each prescribed session is materialised as an appointment row, and the adherence denominator comes from this table (not a cadence walk).
The substrate must serve both cleanly without one consumer's requirements warping the other.
Schema (canonical)
CREATE TABLE appointments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
-- ── Identity (two-phase, per feature spec) ────────────────────────
-- Phase 1 (public booking): patient_profile_id set, patient_id NULL.
-- Phase 2 (onboarded): both set.
patient_profile_id UUID NOT NULL REFERENCES patient_profiles(id) ON DELETE RESTRICT,
patient_id UUID REFERENCES patients(id) ON DELETE RESTRICT,
-- FK to `specialists`, NOT to `principals`. Per P9 a specialist may
-- be "calendar-only" (`specialists.human_id IS NULL`) — an
-- explicitly designed state with no principal to reference. A
-- principal FK here would make every calendar-only specialist
-- unbookable.
--
-- NULLABLE (settled 2026-08-05, decision #7). `calendars.assignment_
-- strategy = 'manual'` ships in 000045 and means exactly "a booking
-- arrives unassigned; staff assigns after". NOT NULL would make that
-- shipped value inert. NULL is the pre-assignment state, never a
-- terminal one — a service-layer guard rejects the transition into
-- `inprogress` while it is unset.
specialist_id UUID REFERENCES specialists(id) ON DELETE RESTRICT,
-- ── Offering + calendar + location context ────────────────────────
-- `offerings` is the F2.1 catalog-identity stand-in (settled
-- 2026-08-02): catalog identity + offering_specialists +
-- offering_forms. No pricing, no plans, no products.
offering_id UUID NOT NULL REFERENCES offerings(id) ON DELETE RESTRICT,
calendar_id UUID REFERENCES calendars(id) ON DELETE SET NULL,
location_id UUID REFERENCES locations(id) ON DELETE SET NULL,
-- ── Pre-onboarding contact (used until patient_id is linked) ──────
contact_email TEXT,
-- Server-signed HttpOnly cookie value, persisted here. NOT a
-- caller-supplied field: it keys the public-booking cooldown, so a
-- client that can choose or rotate it defeats the rate limit.
booking_client_id TEXT,
-- ── Cadence integration (cadence-and-supervision.md) ──────────────
-- Set when this appointment fulfills a supervised protocol's
-- prescribed session. NULL for stand-alone consultations.
protocol_id UUID REFERENCES protocols(id) ON DELETE SET NULL,
session_id UUID REFERENCES sessions(id) ON DELETE SET NULL,
-- Per-appointment delivery channel. Org-level capabilities filter
-- the available values (pure-telerehab clinic only offers
-- 'online_live'; brick-and-mortar-only clinic only offers
-- 'in_person'; hybrid clinic offers both, patient/clinic picks
-- per appointment). Channel is irrelevant to adherence math.
channel TEXT NOT NULL DEFAULT 'in_person'
CHECK (channel IN ('in_person', 'online_live')),
-- ── Scheduling window ─────────────────────────────────────────────
scheduled_at TIMESTAMPTZ NOT NULL,
duration_minutes INT NOT NULL,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
-- ── Status (see "Status enum" below) ──────────────────────────────
status TEXT NOT NULL DEFAULT 'booked'
CHECK (status IN (
'booked', 'upcoming', 'confirmed',
'inprogress', 'done', 'noshow',
'cancelled_by_patient',
'cancelled_by_clinic',
'cancelled_late'
)),
-- ── Cancellation context (set when status enters a cancelled_* value) ─
cancelled_at TIMESTAMPTZ,
cancellation_reason TEXT,
cancelled_by_principal_id UUID REFERENCES principals(id) ON DELETE SET NULL,
-- ── Audit ─────────────────────────────────────────────────────────
created_by_principal_id UUID NOT NULL REFERENCES principals(id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- ── Constraints ───────────────────────────────────────────────────
-- protocol_id and session_id pair together: either both NULL
-- (stand-alone appointment) or both set (supervised-protocol
-- appointment).
CONSTRAINT chk_appointments_protocol_session_pair CHECK (
(protocol_id IS NULL AND session_id IS NULL)
OR (protocol_id IS NOT NULL AND session_id IS NOT NULL)
),
-- Cancellation timestamp matches cancelled_* states.
CONSTRAINT chk_appointments_cancelled_pair CHECK (
(status IN ('cancelled_by_patient', 'cancelled_by_clinic', 'cancelled_late')
AND cancelled_at IS NOT NULL)
OR (status NOT IN ('cancelled_by_patient', 'cancelled_by_clinic', 'cancelled_late')
AND cancelled_at IS NULL)
),
-- Duration sanity.
CONSTRAINT chk_appointments_duration_pos CHECK (duration_minutes > 0),
-- Timeline sanity.
CONSTRAINT chk_appointments_ended_after_started CHECK (
ended_at IS NULL OR started_at IS NULL OR ended_at >= started_at
)
);Reserved for F2.2 (service plans)
The previous version of this schema carried patient_service_plan_id BIGINT + plan_session_number INT, for the "session 3 of 10" multi-session-package case. Both are removed, not retyped. Two independent reasons:
- The type was wrong. P26 makes every PK and FK
UUID(gen_random_uuid()default, UUIDv7 Go-side).BIGINTis a relic of the pre-audit feature specs, which usedBIGSERIALthroughout. - The referent is deferred.
patient_service_plansis F2.2, and F2.2 is explicitly out of scope (settled 2026-08-02: the F2.1 stand-in ships catalog identity only — no pricing, no plans, no products, no purchase path). ANOT NULL-less FK to a table nobody will create is dead schema, and the "sessions remaining" semantics it implies is itself an open question (see leo-port-map.md §8 — appointment-package tracking has no platform equivalent today and is F2.2-adjacent).
When F2.2 ships, the re-add is a column addition, not a reshape: patient_service_plan_id UUID REFERENCES patient_service_plans(id) ON DELETE SET NULL plus plan_session_number INT, with their own classification rows. Adding a nullable FK column to a live table is cheap; carrying a mistyped column that real rows have already written to is not. This is P36's reservation rule applied in the honest direction — reserve the shape decision, not the column.
Struck: add-on offerings (decision #8, settled 2026-08-05)
Earlier revisions carried additional_offering_ids UUID[] NOT NULL DEFAULT '{}', described as driving the "Servicii efectuate" row on the F6 report. The column is struck, not deferred to a shape, because the premise was wrong.
Checked against the live system before deciding: leo's appointments content type (restartix-leo-api/src/api/appointment/content-types/appointment/schema.json) relates one appointment to exactly one speciality, one-to-one. There is no add-on field, array or relation. The report template's "Servicii efectuate" row renders that single speciality name (restartix-leo-dashboard/core/utils/report-templates/default.tsx), not a list. restartix-intakes has no add-on concept either. The array was inherited from a pre-audit feature spec and described behaviour that has never run.
So the choice was never array-vs-junction — both would have shipped a column with no writer and no reader, which is the Layer-2 no-speculation rule's exact target. Adding either later is additive: a nullable column, or a sibling appointment_offerings table with a composite PK. Neither reshapes appointments.
If it ever ships, the junction is the better shape and the reason is concrete: an array carries no FK, so a clinic pruning Masaj terapeutic from its catalog silently leaves dangling UUIDs in every historical appointment that referenced it, and the old reports render a gap with nothing warning anyone. A junction's ON DELETE RESTRICT turns that into a 409 with an in-use count — the pre-check pattern F2.1 already established for offerings.specialty_id.
Status enum
Nine values, reconciling the feature-spec booking lifecycle (booked → done) with the cadence-design adherence attribution (cancelled_by_*).
| Status | Cadence-denominator effect | When set |
|---|---|---|
booked | excluded (future / pre-onboarding) | initial state after public booking |
upcoming | excluded (future) | onboarding completed; forms + video room ready |
confirmed | excluded (future) | patient confirmed attendance |
inprogress | excluded (in-flight) | specialist opened the session |
done | counts (numerator + denominator) | session completed |
noshow | counts (denominator only) | patient didn't attend; usually set by the auto-noshow cron after a threshold |
cancelled_by_patient | counts (denominator only) | patient cancelled with reasonable notice |
cancelled_late | counts (denominator only) | patient cancelled too late to rebook (threshold below) |
cancelled_by_clinic | excluded | clinic cancelled (specialist unavailable, illness, etc.) — patient isn't penalised for clinic capacity shortfalls |
The split of the previous single cancelled status into three values is load-bearing for fair adherence: a clinic-attributable cancellation must not drop the patient's adherence ratio. The cancel endpoint receives the attribution from the caller (patient self-service flow → _patient; clinic admin flow → _clinic) and computes cancelled_late from (scheduled_at - now) < threshold when the attribution is patient.
Transition graph
┌─→ cancelled_by_patient
├─→ cancelled_by_clinic
booked ─→ upcoming ─→ confirmed ─→ inprogress ─→ done
│ │ │ ↑ ╲
│ │ │ ┆ ─→ noshow (cron)
│ ↓ ↓ ┆ ┆
↓ cancelled_* cancelled_* cancelled_* ┆
cancelled_by_* (rare; rebook (very rare) ┆
window) ┆
┆
late-arrival correction (guarded) ─────────┘doneand anycancelled_*are terminal — no transitions out. Reinstating a cancelled appointment creates a new row (cleaner audit trail than reviving a terminal one).cancelled_lateis set automatically when the cancel endpoint sees(scheduled_at - now)is below the late-cancellation threshold; callers don't pickcancelled_latedirectly.
noshow is terminal with one guarded exception (decision #9)
Settled 2026-08-05. noshow → inprogress is permitted only when every one of these holds:
- The
noshowwas written by the system actor — the auto-noshow cron, not a human. Checkaudit_logfor the transition'sactor_type = 'system'. - The correction happens within a bounded window of the scheduled start (proposal:
noshow_grace_minutes × 2, so 60 min at the default). - The transition is audited as a correction, carrying the superseded status.
A human-set noshow stays terminal. The distinction is the whole point: this undoes a machine's guess, never a clinician's judgement.
Without the exception, the failure is silent and it corrupts adherence. The cron marks noshow at +30 min; the patient walks in at +35. A strict terminal rule forces a new row, which orphans the generated forms, the video room, and the protocol_id/session_id pair the adherence engine reads — and leaves the auto-noshow permanently in the denominator, penalising a patient who actually attended. See leo-port-map.md §8.7.
Indexes
-- RLS + listing indexes (per feature spec).
CREATE INDEX idx_appointments_org_scheduled ON appointments (organization_id, scheduled_at DESC);
CREATE INDEX idx_appointments_patient ON appointments (patient_id) WHERE patient_id IS NOT NULL;
CREATE INDEX idx_appointments_patient_profile ON appointments (patient_profile_id);
CREATE INDEX idx_appointments_specialist ON appointments (specialist_id, scheduled_at)
WHERE specialist_id IS NOT NULL;
-- The unassigned queue: bookings taken against a `manual`-strategy
-- calendar, waiting for staff to assign a specialist. Renders as its own
-- bucket in the clinic list UI, so it needs its own index rather than a
-- scan of the org's whole appointment history.
CREATE INDEX idx_appointments_unassigned
ON appointments (organization_id, scheduled_at)
WHERE specialist_id IS NULL;
-- Cadence-engine adherence query — protocol_id + window scan.
-- Partial: only rows that participate in adherence math.
CREATE INDEX idx_appointments_protocol_window
ON appointments (protocol_id, scheduled_at)
WHERE protocol_id IS NOT NULL
AND status IN ('done','noshow','cancelled_by_patient','cancelled_late');
-- Auto-noshow cron scan: "upcoming/confirmed appointments that started
-- N minutes ago". Partial on the pre-terminal statuses keeps the scan
-- tight.
CREATE INDEX idx_appointments_pending_start
ON appointments (scheduled_at)
WHERE status IN ('upcoming', 'confirmed');RLS
ALTER TABLE appointments ENABLE ROW LEVEL SECURITY;
-- Patient self: see appointments for self + managed dependents
-- (current_human_patient_profile_ids() returns the set — the helper
-- shipped in migration 000006).
CREATE POLICY appointments_select_patient_self ON appointments FOR SELECT
USING (
patient_profile_id = ANY(current_human_patient_profile_ids())
);
-- Org-scoped reads: anyone at the org with appointments.read.
CREATE POLICY appointments_select_org ON appointments FOR SELECT
USING (
organization_id = current_app_org_id()
AND current_app_has_permission('appointments', 'read')
);
-- Specialist sees own. Per P9 the join goes through `specialists`,
-- because the appointment references the specialist row, not a
-- principal — a calendar-only specialist has no principal at all.
--
-- `specialist_id` is nullable (decision #7) and this policy handles it
-- correctly without a branch: `NULL IN (…)` is NULL, never true, so an
-- unassigned appointment is invisible to every specialist until someone
-- is assigned. That is the intended behaviour — the unassigned queue is
-- an org-staff surface, reached through appointments_select_org.
CREATE POLICY appointments_select_specialist ON appointments FOR SELECT
USING (
specialist_id IN (
SELECT id FROM specialists WHERE human_id = current_app_principal_id()
)
);
-- Writes gated on appointments.manage. Patient-self booking is a
-- separate WITH CHECK policy gated by the public-booking endpoint
-- (no permission required, but rate-limited via booking_client_id).Permissions do not exist yet. appointments.read / appointments.manage are referenced above and in rbac-permissions.md, but zero appointments.* rows are seeded in any migration today — verified 2026-08-02. The migration that creates this table seeds the permission rows and the system-role-template grants in the same PR, or every policy above denies.
Route layer. The per-org appointments route group mounts middleware.RequireURLOrgMatchesScope("id") (P47) whether or not the endpoint caches. RLS hides a mismatched row once; a shared cache propagates that response to every later caller.
Cadence integration — AppointmentCounter contract
The cadence engine's supervised dispatch consults the appointments table via the AppointmentCounter interface (cadence.go):
type AppointmentCounter interface {
CountInWindow(protocolID string, window DateRange) (int, error)
}The repo implementation runs:
SELECT COUNT(*)
FROM appointments
WHERE protocol_id = $1
AND scheduled_at::date >= $2 -- window.Start, inclusive
AND scheduled_at::date <= $3 -- window.End, inclusive
AND status IN ('done', 'noshow', 'cancelled_by_patient', 'cancelled_late');Critical: cancelled_by_clinic is not in the IN clause. Clinic- attributable cancellations don't enter the denominator — the patient isn't penalised for capacity the clinic couldn't deliver. Future-state appointments (booked/upcoming/confirmed/inprogress) are also excluded because they haven't resolved yet.
Numerator (completed-runs side) joins through:
session_runs sr
JOIN appointments a ON a.session_id = sr.session_id
AND a.status = 'done'
WHERE a.protocol_id = $1
AND sr.completed = TRUEThe repo layer owns the SQL; the engine itself stays pure.
Lazy booking model
Protocols do not auto-create appointments at prescribe time. The clinical reality is that a 12-week supervised protocol = ~24 appointments, and bulk-creating 24 rows against speculative future specialist availability is brittle (specialist takes a week off → 5 cancelled rows + 5 new bookings + double the audit churn).
Booking flow:
- At prescribe time: specialist creates a protocol with
supervision_mode='supervised'+cadence_kind='scheduled'+cadence_config={days_of_week:['tue','thu']}(or aflexibleshape). No appointments yet. - Each week: the patient (via portal) or clinic admin (via clinic app) books appointments from real specialist availability for the configured weekdays. The booking endpoint enforces:
protocol_idmatches an active supervised protocol for the patientsession_idconsumes the next un-played session from the patient- instance program's flat session list (or the next scheduled weekday slot, depending on cadence_kind)- The specialist has an open slot at
scheduled_atfor the requestedduration_minutes
- Adherence math sees what's been booked + resolved so far. Specialist-unavailable periods don't appear in the denominator because no appointment was created for them — that's a clinic-side fill-rate metric (booked vs. cadence-prescribed), tracked separately from patient adherence.
This is the architecturally honest split: patient adherence reflects what the patient could have done; clinic fill-rate reflects what the clinic could have delivered. The two are different problems with different owners.
Capability gating (channel availability)
Which channel values an org can offer is an org-level capability, not a per-appointment column:
- Org has physical premises (i.e., at least one
locationsrow in statusactive) →in_personavailable. - Org has the
online_videocapability enabled (Daily.co integration provisioned per integrations) →online_liveavailable.
Both = hybrid clinic (most common); only one = single-channel clinic. The booking UI filters the choices based on capability resolution; the DB-level CHECK accepts either value because the substrate doesn't know which clinics have which capabilities. Service-layer validation rejects out-of-capability channels at booking time.
Audit + soft-delete
- Audit: every status transition + every reschedule writes an
audit_logrow per CLAUDE.md → Audit Logging. The patient-vs-clinic attribution is recorded both in the row (cancelled_by_principal_id, status enum) and in audit (actor, timestamp, before/after). - Soft-delete: appointments are clinical records — never hard- deleted. The status enum already covers every "this didn't happen" case (
cancelled_*,noshow); there's no separatedeleted_at. GDPR erasure anonymises by clearingcontact_email, the patient'sname(onpatient_profiles), and thecancellation_reasontext, preserving the structural row for clinical-record retention.
Companion settings on organization_settings
Two thresholds are clinic-configurable rather than platform constants (decision #1, settled 2026-08-05). Both are added by the same migration that creates this table, with their own classification rows:
ALTER TABLE organization_settings
ADD COLUMN late_cancellation_hours INT NOT NULL DEFAULT 24,
ADD COLUMN noshow_grace_minutes INT NOT NULL DEFAULT 30;late_cancellation_hours— the delta below which acancelled_by_patientbecomescancelled_late. The cancel endpoint reads it; callers never pickcancelled_latedirectly.noshow_grace_minutes— how long afterscheduled_atthe auto-noshow sweep waits before flipping a still-upcoming/confirmedappointment. leo already parameterises this one, so a constant would be a regression against the system being ported.
Both are fixed-size limits that vary by clinic policy, which is exactly the case CLAUDE.md → Foundation Discipline names: don't ship a fixed-size limit the spec says should be configurable.
Open decisions
Six of the original nine were settled 2026-08-05 and are folded into the schema above — #1 (thresholds), #2 (reschedule), #5 (patient-self booking), #7 (specialist_id nullability), #8 (add-ons, struck), and #9 (late-arrival correction). Their reasoning lives at the point of change: #7 in the schema comment, #8 in Struck: add-on offerings, #9 in noshow is terminal with one guarded exception, #1 in Companion settings.
Two are recorded here in full because they shaped F5's scope rather than its schema:
#2 Reschedule semantics — mutate scheduled_at in place, audited. This is leo's shipped behaviour and it preserves duration exactly (business rule A1: duration is never re-derived from an offering that may have changed since booking). The audit row carries before/after. The alternative — cancel + create a new row — is forensically cleaner but needs a rescheduled_from_id self-FK, and every downstream reader (forms, video room, protocol_id/session_id, the adherence denominator) would have to walk the chain instead of reading one row. Rule A2 still holds: cannot reschedule into the past.
#5 Patient-self booking — deferred; F5 ships staff-side first. The public path (GET /v1/public/availability, POST /v1/public/bookings), the Redis slot-hold system carried over from F4.4, and the server-signed booking-client cookie all land in F5.4, after the staff surface, the state machine, and the adherence wiring are working. leo's real creation surface is staff-side, so this matches the port; it also keeps the unauthenticated abuse surface out of the critical path. The substrate supports both via RLS, so nothing here reshapes.
The remaining three need no decision to build F5 — the substrate absorbs every reasonable answer:
- Group sessions. Multi-patient appointments aren't in the substrate above (one
patient_profile_idper row). If/when this ships, the path is a sibling tableappointment_attendeeswithappointment_id + patient_profile_idrows; the existingappointmentsrow becomes the slot, the attendees table records who attended. Not in scope until a real clinical need surfaces. - Calendar integration (Cat B). When Google Calendar / iCal sync ships, add
external_calendar_event_id TEXT(per-provider) +external_calendar_etag TEXTfor change detection. Substrate- compatible; no other column moves. - Time-zone handling.
scheduled_atisTIMESTAMPTZ— stored in UTC. Which zone a slot means is not a presentation concern and is already solved:scheduling.ResolveSchedulingTimezoneshipped with F4 and implements P23's fallback chain (locations.timezone→specialists.scheduling_timezone→organizations.default_timezone). The calendar-view endpoint buckets by that resolved zone, never bytoISOString()— leo's near-midnight wrong-day bug is exactly this mistake. No column needed onappointments.
Data classification
When the table ships, add these registry rows to data-classification.md:
One row per column, no exceptions — cmd/check-classification parses both the migration and the registry and fails the build on any column present in one and absent from the other.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| patient_profile_id | system_metadata | bulk_export, support_export |
| patient_id | system_metadata | bulk_export, support_export |
| specialist_id | system_metadata | bulk_export, support_export |
| offering_id | system_metadata | bulk_export, support_export |
| calendar_id | system_metadata | bulk_export, support_export |
| location_id | system_metadata | bulk_export, support_export |
| contact_email | pii_basic | bulk_export, support_export |
| booking_client_id | system_metadata | support_export |
| protocol_id | clinical | bulk_export, support_export |
| session_id | clinical | bulk_export, support_export |
| channel | clinical | bulk_export, support_export |
| scheduled_at | clinical | bulk_export, support_export |
| duration_minutes | clinical | bulk_export, support_export |
| started_at | clinical | bulk_export, support_export |
| ended_at | clinical | bulk_export, support_export |
| status | clinical | bulk_export, support_export |
| cancelled_at | clinical | bulk_export, support_export |
| cancellation_reason | clinical | bulk_export, support_export |
| cancelled_by_principal_id | system_metadata | support_export |
| created_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | bulk_export, support_export |
| updated_at | system_metadata | bulk_export, support_export |
contact_email is pii_basic — same posture as humans.email and organization_invites.email in the live registry. It stays plaintext per decisions.md → Why most PII is plaintext.
The class list is closed:
public,org_internal,pii_basic,pii_regulated,clinical,clinical_sensitive,auth_secret,audit_only,system_metadata— defined inservices/api/internal/shared/classification/types.goand mirrored in data-classification.md. Anything else failsmake check. The earlier revision of this table usedpii_contact, which has never been a class.
Related docs
- Cadence & supervision design — the upstream consumer of
protocol_id+session_id+ the cancellation status split. - features/appointments/ — feature-level behaviour (booking flow, forms, video, calendar views).
- features/appointments/lifecycle.md — status machine (will be updated to match this substrate when the table ships).
- features/appointments/api.md — wire endpoints.
- data-classification.md — column-level classification, registry rows above.
- implementation-plan/platform-completion.md — the active plan; F5 is where this table is created for the first time.
- implementation-plan/leo-port-map.md — §0.3 is the audit that produced the four corrections above; §4.2 is the appointment business-rule appendix (auto-noshow grace, reschedule-preserves-duration, unscheduled appointments as a first-class bucket).
- decisions.md → Why clinic is controller, platform is processor — GDPR controllership rule that governs appointment data handling.