Skip to content

Appointments substrate

Status: Spec, not yet built. The appointments table 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's supervised dispatch 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:

DecisionOutcomeSchema effect
#7 specialist_id nullabilityNullableNOT NULL dropped; new partial index for the unassigned queue
#8 Add-onsStruck entirelyadditional_offering_ids removed — no writer, no reader, and leo has no add-on concept at all
#9 Terminal status vs. late arrivalnoshow → inprogress permitted when the noshow was system-setTransition graph gains one guarded edge; no column change
#1 Late-cancellation thresholdorganization_settings.late_cancellation_hours (default 24)New settings column, plus noshow_grace_minutes (default 30)
#2 Reschedule semanticsMutate scheduled_at in place, auditedNone — no supersession FK
#5 Patient-self bookingDeferred; F5 ships staff-side firstNone — 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:

WasNowWhy
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 code
contact_email class pii_contactpii_basicpii_contact is not one of the nine classes in internal/shared/classification/types.go. make check (cmd/check-classification) fails on it
specialist_principal_id → principals(id)specialist_id → specialists(id)P9: specialists.human_id is UNIQUE NULL — calendar-only specialists have no principal and would have been unbookable
patient_service_plan_id BIGINT, plan_session_number INTremoved (reserved)Service plans are F2.2, deferred; and P26 makes every PK/FK UUID, never BIGINT. See Reserved for F2.2

Two consequential renames land with them, from the naming settled 2026-08-02: service_idoffering_id (the glossary's services → offerings, no longer deferred now that the F2.1 stand-in ships) and additional_product_ids dropped 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:

  1. 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.
  2. The cadence engine's supervised dispatch (apps/docs/architecture/cadence-and-supervision.md) — when a protocol has supervision_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)

sql
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:

  1. The type was wrong. P26 makes every PK and FK UUID (gen_random_uuid() default, UUIDv7 Go-side). BIGINT is a relic of the pre-audit feature specs, which used BIGSERIAL throughout.
  2. The referent is deferred. patient_service_plans is 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). A NOT 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 (bookeddone) with the cadence-design adherence attribution (cancelled_by_*).

StatusCadence-denominator effectWhen set
bookedexcluded (future / pre-onboarding)initial state after public booking
upcomingexcluded (future)onboarding completed; forms + video room ready
confirmedexcluded (future)patient confirmed attendance
inprogressexcluded (in-flight)specialist opened the session
donecounts (numerator + denominator)session completed
noshowcounts (denominator only)patient didn't attend; usually set by the auto-noshow cron after a threshold
cancelled_by_patientcounts (denominator only)patient cancelled with reasonable notice
cancelled_latecounts (denominator only)patient cancelled too late to rebook (threshold below)
cancelled_by_clinicexcludedclinic 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) ─────────┘
  • done and any cancelled_* are terminal — no transitions out. Reinstating a cancelled appointment creates a new row (cleaner audit trail than reviving a terminal one).
  • cancelled_late is set automatically when the cancel endpoint sees (scheduled_at - now) is below the late-cancellation threshold; callers don't pick cancelled_late directly.

noshow is terminal with one guarded exception (decision #9)

Settled 2026-08-05. noshow → inprogress is permitted only when every one of these holds:

  1. The noshow was written by the system actor — the auto-noshow cron, not a human. Check audit_log for the transition's actor_type = 'system'.
  2. The correction happens within a bounded window of the scheduled start (proposal: noshow_grace_minutes × 2, so 60 min at the default).
  3. 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

sql
-- 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

sql
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):

go
type AppointmentCounter interface {
    CountInWindow(protocolID string, window DateRange) (int, error)
}

The repo implementation runs:

sql
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:

sql
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 = TRUE

The 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:

  1. At prescribe time: specialist creates a protocol with supervision_mode='supervised' + cadence_kind='scheduled' + cadence_config={days_of_week:['tue','thu']} (or a flexible shape). No appointments yet.
  2. 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_id matches an active supervised protocol for the patient
    • session_id consumes 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_at for the requested duration_minutes
  3. 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 locations row in status active) → in_person available.
  • Org has the online_video capability enabled (Daily.co integration provisioned per integrations) → online_live available.

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_log row 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 separate deleted_at. GDPR erasure anonymises by clearing contact_email, the patient's name (on patient_profiles), and the cancellation_reason text, 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:

sql
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 a cancelled_by_patient becomes cancelled_late. The cancel endpoint reads it; callers never pick cancelled_late directly.
  • noshow_grace_minutes — how long after scheduled_at the auto-noshow sweep waits before flipping a still-upcoming/confirmed appointment. 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:

  1. Group sessions. Multi-patient appointments aren't in the substrate above (one patient_profile_id per row). If/when this ships, the path is a sibling table appointment_attendees with appointment_id + patient_profile_id rows; the existing appointments row becomes the slot, the attendees table records who attended. Not in scope until a real clinical need surfaces.
  2. Calendar integration (Cat B). When Google Calendar / iCal sync ships, add external_calendar_event_id TEXT (per-provider) + external_calendar_etag TEXT for change detection. Substrate- compatible; no other column moves.
  3. Time-zone handling. scheduled_at is TIMESTAMPTZ — stored in UTC. Which zone a slot means is not a presentation concern and is already solved: scheduling.ResolveSchedulingTimezone shipped with F4 and implements P23's fallback chain (locations.timezonespecialists.scheduling_timezoneorganizations.default_timezone). The calendar-view endpoint buckets by that resolved zone, never by toISOString() — leo's near-midnight wrong-day bug is exactly this mistake. No column needed on appointments.

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.

ColumnClassEgress
idsystem_metadatabulk_export, support_export
organization_idsystem_metadatabulk_export, support_export
patient_profile_idsystem_metadatabulk_export, support_export
patient_idsystem_metadatabulk_export, support_export
specialist_idsystem_metadatabulk_export, support_export
offering_idsystem_metadatabulk_export, support_export
calendar_idsystem_metadatabulk_export, support_export
location_idsystem_metadatabulk_export, support_export
contact_emailpii_basicbulk_export, support_export
booking_client_idsystem_metadatasupport_export
protocol_idclinicalbulk_export, support_export
session_idclinicalbulk_export, support_export
channelclinicalbulk_export, support_export
scheduled_atclinicalbulk_export, support_export
duration_minutesclinicalbulk_export, support_export
started_atclinicalbulk_export, support_export
ended_atclinicalbulk_export, support_export
statusclinicalbulk_export, support_export
cancelled_atclinicalbulk_export, support_export
cancellation_reasonclinicalbulk_export, support_export
cancelled_by_principal_idsystem_metadatasupport_export
created_by_principal_idsystem_metadatasupport_export
created_atsystem_metadatabulk_export, support_export
updated_atsystem_metadatabulk_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 in services/api/internal/shared/classification/types.go and mirrored in data-classification.md. Anything else fails make check. The earlier revision of this table used pii_contact, which has never been a class.