Data Classification
Every column the platform stores carries a class (what kind of data it is) and a list of egress targets (where it is allowed to flow outside the tenant). The registry below is the source of truth, enforced by:
- CI check —
services/api/cmd/check-classificationparses everymigrations/core/*.up.sqland this file. Build fails if a schema column is missing from the registry, the registry references a non-existent column, or a class/target name is undefined. Wired intomake checkand the GitHub Actions PR pipeline. - Runtime helper —
services/api/internal/shared/classification/parses this doc once at startup. Egress paths callclassification.AllowedFor(table, target) []stringandclassification.Filter(record, target) anyto project allowed columns. Default is block: a column missing from the registry, or with no matching egress target, cannot leave the tenant.
The plan that put this in place is implementation-plan.md → Layer 1.25. The rationale is in decisions.md → Why a column-level data classification.
Class taxonomy
Each class implies retention, encryption, RLS, and audit expectations. Adding a class is a deliberate change to this doc + the runtime helper's enum, not casual.
| Class | Definition | Implications |
|---|---|---|
public | No protection required. Org public face (name, slug, logo URL), platform catalog (plan codes, feature names, permission codes). | No encryption. No RLS scoping needed (catalog tables have permissive SELECT policies). May appear in unauthenticated responses. |
org_internal | Settings, configuration, internal flags. Visible to org members but not public. | RLS-scoped to current_app_org_id(). Plaintext fine. Must not leak across orgs. |
pii_basic | Names, emails, phones, addresses, normal contact info. Identifies a person but is not a regulated identifier or a credential. | Plaintext at rest. RLS-scoped. Mask in logs (P11). Subject to GDPR access/erasure. Protection is the layered envelope (RLS + audit + at-rest disk encryption + encrypted backups + restricted DB access), not column-level encryption. See decisions.md → Why most PII is plaintext. |
pii_regulated | National IDs, SSNs, tax IDs (CUI in RO), passport numbers — extra-protected by national law beyond GDPR Art. 6. | Column-encrypted at the application layer (P12). RLS-scoped, plus typically a per-row read audit. Mask in logs. Column name MUST end in _encrypted and type MUST be BYTEA — enforced by cmd/check-classification. |
clinical | Diagnoses, treatments, notes, prescriptions — health data under GDPR Art. 9. | RLS-scoped. Plaintext fine at rest under EU MDR/GDPR for clinical use. Audit reads at the row level. Soft-delete only (P13). |
clinical_sensitive | Mental health, sexual health, HIV status, addiction, genetic data — GDPR Art. 9 special category with the strictest handling. | Same as clinical plus: per-row read audit always (no batch summaries), explicit consent at write, surfaced through dedicated UI surfaces only. |
auth_secret | Credentials and authentication artifacts: external auth-provider subject IDs (cross-system identifier — Clerk JWT sub today, any future provider's equivalent), API key hashes, webhook signing secrets, OAuth refresh tokens, domain verification tokens. Compromise = identity takeover. | Never logged (mask absolutely). Hashed where the wire format is a credential (API keys are SHA-256 BYTEA); column-encrypted where the platform must read the value back (signing secrets, refresh tokens). Cross-system identifiers (e.g., humans.provider_subject_id) and short-lived verification tokens may stay TEXT — cmd/check-classification only requires BYTEA on *_encrypted and *_hash columns. Excluded from every egress target by default. |
audit_only | IPs, user agents, request paths, audit-row metadata. Pseudonymous PII per GDPR — useful for security/compliance, never for product features. | Stored in audit_log. RLS gated on audit_log.view_org permission. Retention ≥ 6 years (CLAUDE.md). |
system_metadata | Timestamps, foreign keys, internal IDs that carry no user-facing meaning on their own. | No special handling. May still flow only to targets that explicitly allow it; system_metadata is not a "go anywhere" pass. |
Egress target taxonomy
A target is an external surface where data leaves the tenant. The registry's egress column lists the targets each column is allowed for. Targets extend per-feature — adding a target is a deliberate change to this doc + the runtime helper's enum.
| Target | Where it applies | Notes |
|---|---|---|
bulk_export | GDPR Art. 20 patient data portability — the patient downloads a structured archive of their own data. | Recipients are end users. Lights up when the GDPR export endpoint ships (deferred to a Layer 12 or post-Layer 8 feature). |
analytics_internal | Telemetry service pipeline. Pseudonymized identifiers only — the pseudonymization helper (internal/shared/pseudonym/) is applied separately at the egress site. | Recipients are platform staff via dashboards. The registry permits the column to leave; pseudonymization is a transform, not a registry decision. |
webhook_egress | Outbound webhooks (Layer 8) for clinic-installed integrations. Per-event payloads. | Per-org subscription; org-controlled. |
marketing_email | Layer 8 marketing campaigns and transactional notifications that include user-identifying content. | Strict per-patient consent gate (P17) on top of the registry. |
support_export | Break-glass support exports — when platform support staff legitimately need to dump org or principal data to investigate an incident. | Audited as action_context = 'support_export'. Excludes credentials by class — auth_secret columns never appear here. |
ai_clinical_drafting | (Placeholder.) The first AI feature drafting clinical content. Per-clinic consent for the AI processing purpose (P17) on top of the registry. | Empty across the registry until the first AI clinical feature ships. |
ai_admin_summarization | (Placeholder.) The first AI feature summarizing admin/operational data. Per-clinic consent on top of the registry. | Empty across the registry until the first such feature ships. |
patient_document | The F6 PDF renderer — a generated report or medical prescription, composed in the Clinic app and handed to the patient. | Distinct from bulk_export: a portability archive is the patient's own data going to the patient, whereas a document carries what the clinic may see, which its own patients row decides. national_id_encrypted reaches a document only when a published patient_details block selected the cnp field. Enforced at BUILD time, not at render time — see below. |
How callers use it
internal/shared/classification exposes the intended runtime shape — an egress path consults the helper before constructing a payload, and never hand-builds the field list:
// Allowed column names for that table+target. Empty slice = nothing leaves.
cols := classification.AllowedFor("organizations", "support_export")
// Project a record to only the allowed columns. Reflection-based; works on
// tagged structs and map[string]any. Unknown table/target = empty result.
filtered := classification.Filter(record, "support_export")Read this next part before reasoning about what the registry enforces today.
Load parses this markdown file, and nothing calls it at runtime. The API image is built from the services/api directory alone, so this document is not in the container at all; the only caller is cmd/check-classification, at build time. That makes the registry today a build-time contract, not a runtime filter — the guarantee is "code and registry agree when the build passes", not "the process consults the registry per request".
That distinction is load-bearing, and getting it wrong has already cost something: this section previously claimed the helper "parses this doc once at process startup", which made the registry read as an enforcing control. Under that belief, humans.email sat classified support_export-only while the F6 renderer printed it onto patient documents for the whole life of the feature. Nothing caught it, because nothing was checking (found and corrected 2026-08-07).
So each target is enforced by a specific, nameable mechanism, and the honest answer per target is:
| Target | Enforced by |
|---|---|
patient_document | CI. check-classification asserts every field a patient_details block can print maps to a column whose row here allows patient_document. Adding a printable field without a registry row — or with a row that forbids it — fails make check. |
| everything else | Nothing yet. No egress path calls AllowedFor at runtime. bulk_export lights up with F11's DSAR export, which is where a runtime registry genuinely earns its keep: there the field list is data rather than a fixed catalog, and a CI assertion cannot cover it. |
A runtime registry — a generated Go table compiled into the binary, kept in sync with this file by CI — is the shape that closes the rest. It was deliberately not built for A2: while a printable field list is fixed and enumerable, the build-time assertion gives the same protection for a fraction of the machinery. Build it with the first egress path whose field list is genuinely dynamic.
Encryption invariants
Column-level encryption is reserved for two narrow categories: credential material (auth_secret) and regulated identifiers (pii_regulated). Every other class — including pii_basic, clinical, and clinical_sensitive — is plaintext at rest, protected by the layered envelope (RLS + audit + at-rest disk encryption + encrypted backups + restricted DB access). The rationale is in decisions.md → Why most PII is plaintext.
services/api/cmd/check-classification enforces three structural invariants on every make check run, so drift in either direction fails the build:
- Regulated identifiers must be encrypted. A column classified
pii_regulatedMUST have typeBYTEAAND a name ending in_encrypted. Catches the case where someone addspassport_number TEXTand classifies itpii_regulated— the build fails until the column is renamed and re-typed (or the class is downgraded with documented reasoning). - The
_encryptedsuffix is reserved. A column whose name ends in_encryptedMUST have typeBYTEAAND classpii_regulatedorauth_secret. Catches the case where someone addsaddress_encrypted BYTEAclassifiedpii_basic— the build fails until the column is renamed (matching the plaintext rule forpii_basic) or the class is upgraded. - Credential hashes are BYTEA. A column whose name ends in
_hashAND class isauth_secretMUST have typeBYTEA. Catchesapi_key_hash TEXTdeclaredauth_secret— SHA-256 belongs inBYTEA, not hex-encoded text.
What's intentionally NOT enforced:
auth_secretcolumns aren't required to beBYTEAacross the board. Cross-system identifiers likehumans.provider_subject_idand short-liveddomain_verification_tokens.tokenare TEXT today and the wire format is opaque to us; the invariants only fire on the encryption-style suffixes.pii_basic,clinical,clinical_sensitive,org_internal,audit_only,system_metadata, andpubliccolumns have no encryption-related constraints. They rely on the layered controls.- Columns whose name happens to end in
_hashoutside anauth_secretcontext — e.g.,audit_log.inputs_hash(system_metadata, a SHA-256 forensic linker) — are not constrained. The invariant only applies when class isauth_secret.
When adding a new column, the class drives the encryption posture automatically. Pick the class; the invariants pick the column shape.
Adding a new column
When a migration adds a new column:
- Decide its class from the table above.
- Decide which egress targets it is explicitly allowed for. Default is none.
- Add a row to the appropriate registry section below — same PR as the migration. CI rejects PRs that add a column without a registry entry.
When a migration renames a column, update the registry row in the same PR. The CI check fails on dangling registry rows referring to columns that no longer exist.
When a migration drops a column, drop the registry row in the same PR.
Registry
One row per (table, column). Columns with no egress entry have an empty cell and are blocked from every target by default. Tables are grouped by data-model area; ordering within a section follows column-declaration order in the migration for reviewability.
Audit & provenance
audit_log
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| actor_id | system_metadata | support_export |
| actor_type | audit_only | support_export |
| action | audit_only | support_export |
| entity_type | audit_only | support_export |
| entity_id | audit_only | support_export |
| changes | audit_only | support_export |
| ip_address | audit_only | support_export |
| user_agent | audit_only | support_export |
| request_path | audit_only | support_export |
| request_method | audit_only | support_export |
| status_code | audit_only | support_export |
| request_id | audit_only | support_export |
| action_context | audit_only | support_export |
| break_glass_id | audit_only | support_export |
| impersonation_id | audit_only | support_export |
| patient_profile_id | audit_only | support_export |
| created_at | system_metadata | support_export |
patient_profile_id is WHO A ROW IS ABOUT, as opposed to actor_id (who did it). audit_only like the rest of the event metadata: it is a pointer, not a personal detail — it identifies a person only to somebody who can already resolve patient_profiles, which is exactly the audience audit_select admits. Declared with the table in 000001. NULL on every row that concerns no patient — role grants, org settings, catalog edits — which is most of them.
audit_ai_provenance
| Column | Class | Egress |
|---|---|---|
| audit_log_id | system_metadata | support_export |
| audit_log_created_at | system_metadata | support_export |
| model_id | audit_only | support_export |
| inputs_hash | audit_only | support_export |
| confidence | audit_only | support_export |
| created_at | system_metadata | support_export |
Identity
principals
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| principal_type | system_metadata | support_export |
| parent_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export |
humans
| Column | Class | Egress |
|---|---|---|
| principal_id | system_metadata | support_export |
| provider_subject_id | auth_secret | |
| provider_org_id | auth_secret | |
| pii_basic | support_export, patient_document | |
| name | pii_basic | support_export, patient_document |
| confirmed | org_internal | support_export |
| blocked | org_internal | support_export |
| portal_credential_generation | auth_secret | |
| last_activity | audit_only | support_export |
| preferred_language | org_internal | support_export |
| timezone | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
email_change_requests
A pending change of the address on an account. Both addresses are pii_basic for the same reason humans.email is — they are the same value, one before and one after. token_hash is auth_secret with no egress at all: it authorises re-pointing a login, which is the highest-value thing a token in this platform can do.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| human_id | system_metadata | support_export |
| new_email | pii_basic | support_export |
| old_email | pii_basic | support_export |
| token_hash | auth_secret | |
| expires_at | system_metadata | support_export |
| confirmed_at | system_metadata | support_export |
| cancelled_at | system_metadata | support_export |
| requested_by_principal_id | system_metadata | support_export |
| initiated_by | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
agents
| Column | Class | Egress |
|---|---|---|
| principal_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| name | org_internal | support_export |
| description | org_internal | support_export |
| model_provider | org_internal | support_export |
| model_name | org_internal | support_export |
| model_version | org_internal | support_export |
| scope | org_internal | support_export |
| system_prompt_ref | org_internal | support_export |
| configuration | org_internal | support_export |
| enabled | org_internal | support_export |
| deleted_at | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
service_accounts
| Column | Class | Egress |
|---|---|---|
| principal_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| name | org_internal | support_export |
| description | org_internal | support_export |
| integration_kind | org_internal | support_export |
| api_key_hash | auth_secret | |
| api_key_prefix | org_internal | support_export |
| expires_at | org_internal | support_export |
| last_used_at | audit_only | support_export |
| rotated_at | org_internal | support_export |
| revoked_at | org_internal | support_export |
| deleted_at | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
platform_memberships
| Column | Class | Egress |
|---|---|---|
| principal_id | system_metadata | support_export |
| role | org_internal | support_export |
| granted_by_principal_id | system_metadata | support_export |
| granted_at | system_metadata | support_export |
RBAC
permissions
| Column | Class | Egress |
|---|---|---|
| code | public | support_export |
| resource | public | support_export |
| action | public | support_export |
| description | public | support_export |
| created_at | system_metadata | support_export |
roles
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| code | org_internal | support_export |
| name | org_internal | support_export |
| description | org_internal | support_export |
| is_system | org_internal | support_export |
| customized_at | system_metadata | support_export |
| requires_specialist_profile | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
role_permissions
| Column | Class | Egress |
|---|---|---|
| role_id | system_metadata | support_export |
| permission_code | system_metadata | support_export |
| created_at | system_metadata | support_export |
organization_memberships
| Column | Class | Egress |
|---|---|---|
| principal_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| role_id | system_metadata | support_export |
| is_owner | system_metadata | support_export |
| last_used_at | audit_only | support_export |
| invited_at | system_metadata | support_export |
| invited_by | system_metadata | support_export |
| accepted_at | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
Organizations & domains
organizations
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| name | public | support_export, bulk_export |
| slug | public | support_export |
| tagline | public | support_export |
| description | public | support_export |
| public | support_export | |
| phone | public | support_export |
| website | public | support_export |
| location | public | support_export |
| logo_url | public | support_export |
| icon_url | public | support_export |
| language_code | org_internal | support_export |
| portal_self_signup_enabled | public | support_export |
| branding | public | support_export |
| tenancy_mode | org_internal | support_export |
| activated_at | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
organization_domains
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| domain | org_internal | support_export |
| domain_type | org_internal | support_export |
| status | org_internal | support_export |
| cloudflare_hostname_id | system_metadata | support_export |
| ssl_status | org_internal | support_export |
| verification_token | auth_secret | |
| verified_at | system_metadata | support_export |
| last_check_at | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
Org settings & companions
organization_settings
| Column | Class | Egress |
|---|---|---|
| organization_id | system_metadata | support_export |
| marketing_email_enabled | org_internal | support_export |
| marketing_sms_enabled | org_internal | support_export |
| audit_retention_months | org_internal | support_export |
| support_locale | org_internal | support_export |
| default_timezone | org_internal | support_export |
| feature_flags | org_internal | support_export |
| late_cancellation_hours | org_internal | support_export |
| noshow_grace_minutes | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
organization_billing
| Column | Class | Egress |
|---|---|---|
| organization_id | system_metadata | support_export |
| current_tier_id | org_internal | support_export |
| billing_email | pii_basic | support_export |
| billing_contact_name | pii_basic | support_export |
| billing_address_line1 | pii_basic | support_export |
| billing_address_line2 | pii_basic | support_export |
| billing_city | pii_basic | support_export |
| billing_postal_code | pii_basic | support_export |
| billing_country | pii_basic | support_export |
| tax_id_encrypted | pii_regulated | support_export |
| currency | org_internal | support_export |
| external_customer_id | org_internal | support_export |
| payment_provider | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
organization_entitlements
| Column | Class | Egress |
|---|---|---|
| organization_id | system_metadata | support_export |
| telerehab_enabled | org_internal | support_export |
| video_consultations_enabled | org_internal | support_export |
| pose_estimation_enabled | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
organization_designations
Per-org legal/regulatory contact assignments (DPO, billing contact, etc.). External-contact fields are PII when the designee is an external party (a contracted DPO firm's named person + email + phone); the same shape carries no PII when the designation points at an internal principal_id. Classification is the upper bound, applied to the columns regardless of which case populates them.
| Column | Class | Egress |
|---|---|---|
| organization_id | system_metadata | support_export |
| kind | system_metadata | support_export |
| principal_id | system_metadata | support_export |
| external_contact_name | pii_basic | support_export |
| external_contact_email | pii_basic | support_export |
| external_contact_phone | pii_basic | support_export |
| notes | org_internal | support_export |
| assigned_by_principal_id | system_metadata | support_export |
| assigned_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
organization_ownership_transfers
State machine for org ownership handoffs. accept_token is generated, used once, and presented by the recipient to claim ownership — same secret-class as auth credentials. Notes are operator/operator-supplied free text scoped to the org's directory.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| from_principal_id | system_metadata | support_export |
| to_principal_id | system_metadata | support_export |
| initiated_by_principal_id | system_metadata | support_export |
| accept_token | auth_secret | |
| status | system_metadata | support_export |
| initiated_at | system_metadata | support_export |
| expires_at | system_metadata | support_export |
| resolved_at | system_metadata | support_export |
| initiation_note | org_internal | support_export |
| resolution_note | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
Tiers catalog
tiers
| Column | Class | Egress |
|---|---|---|
| id | public | support_export |
| code | public | support_export |
| name | public | support_export |
| description | public | support_export |
| kind | public | support_export |
| billing_cycle | public | support_export |
| base_price | public | support_export |
| currency | public | support_export |
| version | public | support_export |
| published | public | support_export |
| is_public | public | support_export |
| published_at | public | support_export |
| deprecated_at | public | support_export |
| translations | public | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
tier_versions
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| tier_id | system_metadata | support_export |
| version | public | support_export |
| published_at | public | support_export |
| entitlements_snapshot | public | support_export |
| limits_snapshot | public | support_export |
| metadata_snapshot | public | support_export |
| changed_by_principal_id | org_internal | support_export |
| created_at | system_metadata | support_export |
entitlements
| Column | Class | Egress |
|---|---|---|
| code | public | support_export |
| name | public | support_export |
| description | public | support_export |
| regulated | public | support_export |
| scope | public | support_export |
| entitlement_column | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
limit_definitions
| Column | Class | Egress |
|---|---|---|
| code | public | support_export |
| name | public | support_export |
| description | public | support_export |
| unit | public | support_export |
| default_behavior | public | support_export |
| period_kind | public | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
tier_entitlements
| Column | Class | Egress |
|---|---|---|
| tier_id | public | support_export |
| entitlement_code | public | support_export |
| enabled | public | support_export |
| created_at | system_metadata | support_export |
tier_limits
| Column | Class | Egress |
|---|---|---|
| tier_id | public | support_export |
| limit_code | public | support_export |
| cap_value | public | support_export |
| behavior_override | public | support_export |
| created_at | system_metadata | support_export |
Org subscriptions
organization_subscriptions
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| tier_id | org_internal | support_export |
| tier_version | org_internal | support_export |
| status | org_internal | support_export |
| started_at | org_internal | support_export |
| current_period_starts_at | org_internal | support_export |
| current_period_ends_at | org_internal | support_export |
| cancel_at | org_internal | support_export |
| canceled_at | org_internal | support_export |
| payment_provider | org_internal | support_export |
| external_subscription_id | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
organization_subscription_entitlements
| Column | Class | Egress |
|---|---|---|
| subscription_id | system_metadata | support_export |
| entitlement_code | org_internal | support_export |
| enabled | org_internal | support_export |
| created_at | system_metadata | support_export |
organization_subscription_limits
| Column | Class | Egress |
|---|---|---|
| subscription_id | system_metadata | support_export |
| limit_code | org_internal | support_export |
| cap_value | org_internal | support_export |
| behavior | org_internal | support_export |
| created_at | system_metadata | support_export |
organization_subscription_overrides
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| subscription_id | system_metadata | support_export |
| override_kind | org_internal | support_export |
| entitlement_code | org_internal | support_export |
| entitlement_enabled | org_internal | support_export |
| limit_code | org_internal | support_export |
| cap_value | org_internal | support_export |
| behavior_override | org_internal | support_export |
| granted_by_principal_id | org_internal | support_export |
| reason | org_internal | support_export |
| effective_from | org_internal | support_export |
| expires_at | org_internal | support_export |
| revoked_at | org_internal | support_export |
| revoked_by_principal_id | org_internal | support_export |
| created_at | system_metadata | support_export |
Patient tiers
patient_tiers
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| code | org_internal | support_export |
| name | org_internal | support_export, bulk_export |
| description | org_internal | support_export |
| is_active | org_internal | support_export |
| is_default | org_internal | support_export |
| sort_order | org_internal | support_export |
| version | org_internal | support_export |
| published | org_internal | support_export |
| published_at | org_internal | support_export |
| external_price_hint | org_internal | support_export |
| currency | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
patient_tier_versions
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| tier_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| version | org_internal | support_export |
| published_at | org_internal | support_export |
| entitlements_snapshot | org_internal | support_export |
| limits_snapshot | org_internal | support_export |
| metadata_snapshot | org_internal | support_export |
| changed_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
patient_tier_entitlements
| Column | Class | Egress |
|---|---|---|
| tier_id | system_metadata | support_export |
| entitlement_code | org_internal | support_export |
| enabled | org_internal | support_export |
| created_at | system_metadata | support_export |
patient_tier_limits
| Column | Class | Egress |
|---|---|---|
| tier_id | system_metadata | support_export |
| limit_code | org_internal | support_export |
| cap_value | org_internal | support_export |
| behavior_override | org_internal | support_export |
| created_at | system_metadata | support_export |
Patient identity
patient_profiles
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export, bulk_export |
| human_id | system_metadata | support_export, bulk_export |
| name | pii_basic | support_export, patient_document, bulk_export |
| date_of_birth | pii_basic | support_export, patient_document, bulk_export |
| sex | pii_basic | support_export, patient_document, bulk_export |
| phone | pii_basic | support_export, patient_document, bulk_export |
| occupation | pii_basic | support_export, patient_document, bulk_export |
| residence | pii_basic | support_export, patient_document, bulk_export |
| national_id_encrypted | pii_regulated | bulk_export, patient_document |
| national_id_hmac | system_metadata | |
| blood_type | clinical | support_export, patient_document, bulk_export |
| allergies | clinical | support_export, patient_document, bulk_export |
| chronic_conditions | clinical | support_export, patient_document, bulk_export |
| emergency_contact_name | pii_basic | support_export, patient_document, bulk_export |
| emergency_contact_phone | pii_basic | support_export, patient_document, bulk_export |
| insurance_entries | pii_basic | support_export, patient_document, bulk_export |
| created_at | system_metadata | support_export, bulk_export |
| updated_at | system_metadata | support_export, bulk_export |
| anonymized_at | system_metadata | support_export, bulk_export |
Seven of these gained patient_document on 2026-08-07 (A2): sex, blood_type, allergies, chronic_conditions, both emergency-contact columns and insurance_entries. They had been support_export-only, not because printing them was weighed and refused but because the document catalog was inherited from the legacy system's report and never revisited — a patient could fill these in through a form and no document could print them back. allergies and chronic_conditions are clinical, and that is the point of them being on a medical report; the printing clinic's own patients row still scopes them, exactly as it does a date of birth.
national_id_hmac carries no egress target at all, which is the registry's way of saying it may never leave the tenant by any path. It is a blind index — HMAC-SHA256 of the CNP under a key derived from the active encryption key — and it exists so a clinic can look a patient up by CNP, which random-nonce AES-GCM makes impossible. It is classified system_metadata rather than pii_regulated because the encryption invariants reserve the _encrypted suffix for values that decrypt back, and this one never does; the empty target list is what actually protects it.
It leaks EQUALITY by design: two rows with the same digest hold the same CNP. That is how the lookup works and how a duplicate patient record surfaces. It leaks nothing else without the derived key — but an attacker holding both the database and that key can confirm a guessed CNP offline, so it widens the blast radius of a key compromise and nothing else.
The 2026-08-21 bulk_export amendment (Art. 15 / Art. 20)
bulk_export is defined in this document as the PATIENT'S GDPR ARCHIVE — see the pose_data_quality_overrides and session_pose_overrides sections, both of which grant it so "the patient's GDPR archive reflects the override." The target existed, was documented, and had no caller; the rows behind it were never populated for the tables an archive is actually made of.
The state before this amendment was not a policy, it was a gap, and it was incoherent on its face: the ONLY patient_profiles column permitted to reach a patient's own archive was national_id_encrypted, so an export could return someone their encrypted CNP and not their name. forms.values and forms.files — the answers the patient themselves typed — carried NO egress target at all, meaning the platform would refuse to give a person back the health information they had personally entered. Art. 15(3) requires exactly that copy, and Art. 20 requires it machine-readable.
THE RULE APPLIED, and the one to apply to any future row: a column reaches bulk_export when it is the patient's own data about themselves.
Excluded deliberately, and consistently with what this registry already does elsewhere:
- Staff identity.
created_by_principal_id,added_by_principal_id,removed_by_principal_idand their siblings stay off. This mirrors the rule already written atsession_pose_overrides: the patient receives the fact and the clinical rationale, not the identity of the individual who acted. A specialist's name is that specialist's personal data, not the patient's. - Blind indexes and secrets.
national_id_hmacstays off — it is a search index, not information about the person, and exporting it leaks the construction of the index. - Audit-only forensics.
signed_via_ipandsigned_user_agentstayaudit_only; they exist to prove a signature happened, and the class is the reason they are not part of the subject's copy. - Other data subjects.
plan_membersis untouched: a row names somebody ELSE who is on the plan, and an Art. 15 request is not a route to another person's data. The subscription itself is exported; its roster is not. - Commercial internals.
patient_subscriptions.payment_providerandexternal_subscription_idstay off — a Stripe identifier is a fact about a billing integration, not about the patient.
Two NAME columns outside the patient's own tables are granted alongside them: organizations.name and patient_tiers.name. The clinic's name is not optional archive decoration — Art. 15(1)(c) obliges the controller to tell the data subject the recipients of their data, so an archive listing clinics by UUID would be non-compliant on its face. organizations.name is already classified public (clinic naming is a marketing surface), so this widens nothing. patient_tiers.name is the clinic-authored plan label the patient is already shown on their own subscription page.
national_id_encrypted already carried bulk_export before this amendment and keeps it. The archive renders the CNP decrypted, which is the same disclosure the patient's own profile page already makes to them (Art. 15 entitles them to it); the column name reflects storage, not wire format.
patient_caregivers
| Column | Class | Egress |
|---|---|---|
| patient_profile_id | system_metadata | support_export |
| caregiver_human_id | system_metadata | support_export |
| relationship | pii_basic | support_export |
| is_legal_representative | pii_basic | support_export |
| representative_basis | pii_basic | support_export |
| representative_attested_by_principal_id | system_metadata | support_export |
| representative_attested_at | system_metadata | support_export |
| created_at | system_metadata | support_export |
representative_basis is pii_basic rather than metadata because it is free text a clinic types about a person's legal standing — a court order number, a guardianship reference. It says something about the subject's capacity, which is among the more sensitive things this schema records about anyone, and it must never leave on a marketing or analytics path.
patients
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export, bulk_export |
| organization_id | system_metadata | support_export, bulk_export |
| patient_profile_id | system_metadata | support_export, bulk_export |
| consumer_id | org_internal | support_export |
| patient_number | org_internal | support_export, patient_document, bulk_export |
| last_used_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export, bulk_export |
| created_at | system_metadata | support_export, bulk_export |
| updated_at | system_metadata | support_export, bulk_export |
Patient subscriptions
patient_subscriptions
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export, bulk_export |
| organization_id | system_metadata | support_export, bulk_export |
| patient_id | system_metadata | support_export, bulk_export |
| tier_id | system_metadata | support_export, bulk_export |
| tier_version | org_internal | support_export, bulk_export |
| status | org_internal | support_export, bulk_export |
| started_at | system_metadata | support_export, bulk_export |
| current_period_starts_at | org_internal | support_export, bulk_export |
| current_period_ends_at | org_internal | support_export, bulk_export |
| cancel_at | org_internal | support_export, bulk_export |
| canceled_at | org_internal | support_export, bulk_export |
| payment_provider | org_internal | support_export |
| external_subscription_id | org_internal | support_export |
| created_at | system_metadata | support_export, bulk_export |
| updated_at | system_metadata | support_export, bulk_export |
patient_subscription_entitlements
| Column | Class | Egress |
|---|---|---|
| subscription_id | system_metadata | support_export |
| entitlement_code | org_internal | support_export |
| enabled | org_internal | support_export |
| created_at | system_metadata | support_export |
patient_subscription_limits
| Column | Class | Egress |
|---|---|---|
| subscription_id | system_metadata | support_export |
| limit_code | org_internal | support_export |
| cap_value | org_internal | support_export |
| behavior | org_internal | support_export |
| created_at | system_metadata | support_export |
patient_subscription_overrides
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| subscription_id | system_metadata | support_export |
| override_kind | org_internal | support_export |
| entitlement_code | org_internal | support_export |
| entitlement_enabled | org_internal | support_export |
| limit_code | org_internal | support_export |
| cap_value | org_internal | support_export |
| behavior_override | org_internal | support_export |
| granted_by_principal_id | org_internal | support_export |
| reason | org_internal | support_export |
| effective_from | org_internal | support_export |
| expires_at | org_internal | support_export |
| revoked_at | org_internal | support_export |
| revoked_by_principal_id | org_internal | support_export |
| created_at | system_metadata | support_export |
Plan members
Who else a subscription covers — "people on your plan". Commercial, not clinical: the row says somebody's subscription pays for somebody else and nothing about anyone's care, which is why nothing here rises above org_internal and why being on a plan grants no sight of a member's record.
Membership dates are org_internal rather than system_metadata: left_at is what the 90-day rejoin cooldown is measured from and what answers a seat dispute, so it is business data the clinic reasons about, not an operational timestamp.
plan_members
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| owner_patient_profile_id | system_metadata | support_export |
| member_patient_profile_id | system_metadata | support_export |
| joined_at | org_internal | support_export |
| left_at | org_internal | support_export |
| added_by_principal_id | org_internal | support_export |
| removed_by_principal_id | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
Consents
consent_purposes
| Column | Class | Egress |
|---|---|---|
| code | public | bulk_export, support_export |
| scope | public | bulk_export, support_export |
| name | public | bulk_export, support_export |
| description | public | bulk_export, support_export |
| translations | public | bulk_export, support_export |
| legal_basis | public | bulk_export, support_export |
| withdrawable | public | bulk_export, support_export |
| created_at | system_metadata | bulk_export, support_export |
| enforcement | public | bulk_export, support_export |
| requires_instrument | public | bulk_export, support_export |
consent_purpose_versions
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| purpose_code | public | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| version | public | bulk_export, support_export |
| revision | public | bulk_export, support_export |
| body_translations | public | bulk_export, support_export |
| body_format | public | bulk_export, support_export |
| published_at | system_metadata | bulk_export, support_export |
| published_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | bulk_export, support_export |
consents
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| patient_profile_id | pii_basic | bulk_export, support_export |
| purpose_code | org_internal | bulk_export, support_export |
| purpose_version | system_metadata | bulk_export, support_export |
| purpose_version_id | system_metadata | bulk_export, support_export |
| source | audit_only | bulk_export, support_export |
| source_form_id | system_metadata | bulk_export, support_export |
| granted_at | audit_only | bulk_export, support_export |
| granted_by_principal_id | pii_basic | bulk_export, support_export |
| granted_via_ip | audit_only | support_export |
| withdrawn_at | audit_only | bulk_export, support_export |
| withdrawn_by_principal_id | pii_basic | bulk_export, support_export |
| withdrawal_reason | pii_basic | bulk_export, support_export |
| created_at | system_metadata | bulk_export, support_export |
Legal documents
Clinic-owned instruments — the documents a patient reads before agreeing to something. Two families: terms and privacy_notice are assembled from platform templates per 1B.10, while the four clinical consents (telemedicine, video_recording, biometric_capture, telerehab) are authored by the clinic from blank, because drafting a controller's clinical text at the platform would be GDPR Art. 26 joint-controllership drift.
Editor state lives in organization_legal_documents; the immutable artefact patients accept is the corresponding row in consent_purpose_versions (already classified above). authored_body_translations is org_internal for the same reason placeholder_values is — it is the clinic's draft, and the published copy is what a patient ever sees.
legal_document_templates
Platform catalog. Bodies are public-by-design (the same way consent_purposes is) — they're the scaffolding clinics fill in, not clinic-specific data.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| document_type | public | bulk_export, support_export |
| version | public | bulk_export, support_export |
| locale | public | bulk_export, support_export |
| body_with_placeholders | public | bulk_export, support_export |
| required_placeholders | public | bulk_export, support_export |
| toggleable_sections | public | bulk_export, support_export |
| published_at | system_metadata | bulk_export, support_export |
| published_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | bulk_export, support_export |
organization_legal_documents
Per-org editor state. placeholder_values carries clinic-identifying fields (clinic name, DPO email, registered address) — not patient-identifying, but the registered DPO email is regulated contact data that belongs in org_internal, not public. The consent_purpose_versions row produced at publish time is what patients see; this table is editor scratch space.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| document_type | public | bulk_export, support_export |
| source_template_version | public | bulk_export, support_export |
| placeholder_values | org_internal | bulk_export, support_export |
| included_sections | org_internal | bulk_export, support_export |
| authored_body_translations | org_internal | bulk_export, support_export |
| published_version | system_metadata | bulk_export, support_export |
| published_revision | system_metadata | bulk_export, support_export |
| published_against_template_version | system_metadata | bulk_export, support_export |
| last_reviewed_by_principal_id | system_metadata | support_export |
| last_reviewed_at | audit_only | bulk_export, support_export |
| created_at | system_metadata | bulk_export, support_export |
| updated_at | system_metadata | bulk_export, support_export |
Notifications
The outbox + per-channel-delivery + dedup-guard + sparse-prefs tables for the platform's notification primitive (Foundation 1A.18). The rendered subject + body live on notifications directly: GDPR access (Art. 15) returns the recipient's row verbatim; support export ships the same shape. Per-delivery transitions on notification_deliveries are operational metadata — the row's columns ARE the forensic record (no audit_log row written by the dispatcher per CLAUDE.md "Operational-metadata bumps are exempt"). notifications + notification_deliveries are range-partitioned monthly in lockstep (P41); notification_idempotency_keys is the flat (non-partitioned) producer-dedup guard.
notifications
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| recipient_principal_id | system_metadata | bulk_export, support_export |
| recipient_email | pii_basic | bulk_export, support_export |
| category | org_internal | bulk_export, support_export |
| idempotency_key | system_metadata | support_export |
| locale | org_internal | bulk_export, support_export |
| timezone | org_internal | bulk_export, support_export |
| subject | pii_basic | bulk_export, support_export |
| body_text | pii_basic | bulk_export, support_export |
| body_html | pii_basic | bulk_export, support_export |
| patient_profile_id | system_metadata | bulk_export, support_export |
| attachments | clinical | support_export |
| scheduled_at | system_metadata | bulk_export, support_export |
| created_at | system_metadata | bulk_export, support_export |
notification_schedules
What a scheduled notification is about, so it can be called off if that thing stops existing. Flat rather than partitioned: the lookup is by entity id and has no time bound, so partitioning it would mean scanning every month. Holds identifiers only — never a recipient, never rendered content.
| Column | Class | Egress |
|---|---|---|
| notification_id | system_metadata | support_export |
| notification_created_at | system_metadata | support_export |
| source_entity_type | org_internal | support_export |
| source_entity_id | system_metadata | support_export |
| scheduled_at | system_metadata | bulk_export, support_export |
| created_at | system_metadata | support_export |
notification_deliveries
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| notification_id | system_metadata | bulk_export, support_export |
| notification_created_at | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| channel | org_internal | bulk_export, support_export |
| status | org_internal | bulk_export, support_export |
| attempts | system_metadata | support_export |
| claimed_at | system_metadata | support_export |
| claimed_by_worker_id | system_metadata | support_export |
| next_attempt_at | system_metadata | support_export |
| sent_at | system_metadata | bulk_export, support_export |
| provider_message_id | system_metadata | support_export |
| last_error | org_internal | support_export |
| suppressed_reason | org_internal | bulk_export, support_export |
| delivered_at | system_metadata | bulk_export, support_export |
| bounced_at | system_metadata | bulk_export, support_export |
| bounce_type | org_internal | bulk_export, support_export |
| complained_at | system_metadata | bulk_export, support_export |
| read_at | system_metadata | bulk_export, support_export |
| created_at | system_metadata | bulk_export, support_export |
notification_idempotency_keys
Producer-side dedup guard (non-partitioned). Holds no PII — an opaque (category, idempotency_key) claim plus the resolved notification id. Ships to support_export only (operational debugging of "why was this not re-sent").
| Column | Class | Egress |
|---|---|---|
| category | org_internal | support_export |
| idempotency_key | system_metadata | support_export |
| notification_id | system_metadata | support_export |
| notification_created_at | system_metadata | support_export |
| created_at | system_metadata | support_export |
notification_suppression
Addresses the platform must not mail, and why. email_address is pii_basic — the row exists precisely to name a person's mailbox — so it ships to support_export only, never to bulk_export: a deliverability list is not part of a clinic's data export, and the platform-scope rows (hard bounces) are other clinics' patients. Release is soft (released_at), because "we stopped mailing this person, then someone decided we could again" is the sequence an authority asks about.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| email_address | pii_basic | support_export |
| reason | org_internal | support_export |
| provider_subtype | org_internal | support_export |
| source_delivery_id | system_metadata | support_export |
| notes | org_internal | support_export |
| created_at | system_metadata | support_export |
| created_by_principal_id | system_metadata | support_export |
| released_at | system_metadata | support_export |
| released_by_principal_id | system_metadata | support_export |
| release_reason | org_internal | support_export |
platform_notification_policy
The platform's emergency stop on a notification category — what you reach for when a template renders wrongly or a producer loops, never to express a preference. Sparse: a row exists only where the platform has intervened. reason is NOT NULL because a category stopped without one is a category nobody can safely turn back on. Holds no patient data; it names a category and why it was halted.
| Column | Class | Egress |
|---|---|---|
| category | org_internal | support_export |
| enabled | org_internal | support_export |
| reason | org_internal | support_export |
| updated_at | system_metadata | support_export |
| updated_by_principal_id | system_metadata | support_export |
organization_notification_policy
Which notification categories a clinic sends. Sparse — a row exists only where the clinic differs from the category default. Tenant-scope categories only: a clinic cannot switch off the platform-scope notices that exist to warn it about its own account. Holds no patient data; it is a configuration decision about the clinic's own service, so it ships with the rest of the clinic's settings.
| Column | Class | Egress |
|---|---|---|
| organization_id | system_metadata | bulk_export, support_export |
| category | org_internal | bulk_export, support_export |
| enabled | org_internal | bulk_export, support_export |
| config | org_internal | bulk_export, support_export |
| updated_at | system_metadata | bulk_export, support_export |
notification_sending_pauses
A clinic whose bounce or complaint rate reached the point of endangering every other clinic on the shared SES account, and the measurements that said so. The snapshot columns exist because the rates are derived live over a rolling window — by the time anyone reads the row, the window has moved and the numbers that triggered it are gone. Holds no patient data at all: the row is about an organisation's sending behaviour in aggregate, never about any individual recipient. Ships to bulk_export because a clinic is entitled to its own account history.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| paused_at | system_metadata | bulk_export, support_export |
| bounce_rate | org_internal | bulk_export, support_export |
| complaint_rate | org_internal | bulk_export, support_export |
| sends_in_window | org_internal | bulk_export, support_export |
| window_days | system_metadata | bulk_export, support_export |
| paused_by_principal_id | system_metadata | bulk_export, support_export |
| lifted_at | system_metadata | bulk_export, support_export |
| lifted_by_principal_id | system_metadata | bulk_export, support_export |
| lift_reason | org_internal | bulk_export, support_export |
notification_preferences
| Column | Class | Egress |
|---|---|---|
| recipient_principal_id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| category | org_internal | bulk_export, support_export |
| channel | org_internal | bulk_export, support_export |
| enabled | org_internal | bulk_export, support_export |
| updated_at | system_metadata | bulk_export, support_export |
Break-glass sessions
Platform-staff elevation records (Foundation 1B.11). Every row is the forensic record of "platform staff X opened time-bound elevated access against clinic Y at scope Z, justified by reason R, between times T0 and T1." Audit_log rows written during the open window carry break_glass_id linking back. Reason fields can carry support-context PII ("looking up patient John Doe per ticket #42") so they ship to support_export only — the audit story for the patient + clinic is the bounded session row + linked audit_log entries, not these reason fields.
break_glass_sessions
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| principal_id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| scope | org_internal | bulk_export, support_export |
| reason_category | org_internal | bulk_export, support_export |
| reason_text | pii_basic | support_export |
| reason_ref | org_internal | support_export |
| opened_at | system_metadata | bulk_export, support_export |
| expires_at | system_metadata | bulk_export, support_export |
| closed_at | system_metadata | bulk_export, support_export |
| closed_by_principal_id | system_metadata | bulk_export, support_export |
Patient impersonation sessions
Clinic-internal access pattern (Foundation 1B.13). Every row records "clinic staff X opened a time-bound session to act on patient Y's behalf at clinic Z, justified by reason R, between T0 and T1." Audit_log rows written during the open window carry impersonation_id linking back. Lives entirely within one clinic's controllership scope (per-clinic counterpart to break-glass; not a controller/processor concern). Reason can carry support context that mentions clinical scenarios ("patient called in confused about their treatment plan") so it ships to support_export only.
patient_impersonation_sessions
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| staff_principal_id | system_metadata | bulk_export, support_export |
| target_patient_id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| reason | pii_basic | support_export |
| opened_at | system_metadata | bulk_export, support_export |
| expires_at | system_metadata | bulk_export, support_export |
| closed_at | system_metadata | bulk_export, support_export |
| closed_by_principal_id | system_metadata | bulk_export, support_export |
Personal invitations + patient share-links
Org-scoped invite primitives (Foundation 1B.12). organization_invites is a per-recipient personal invite (staff or patient) keyed to a Clerk-side invitation; share_links is a code-anchored multi-use redemption primitive (patient-only). Both are state, not events — flat tables with low cardinality per org. Email lives at pii_basic — same posture as humans.email — and never leaves on bulk_export (which is the GDPR-export pipeline scoped to the inviting clinic, not the recipient). The Clerk invitation id is opaque external metadata, support_export-only. patient_profile_id names the EXISTING person a patient invitation hands a login to (the P7 claim) — a foreign key and nothing more, so system_metadata like its specialist_id sibling: it identifies a row, it does not describe a human. What that person's profile CONTAINS is classified on patient_profiles and gated there.
organization_invites
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| provider_invitation_id | system_metadata | support_export |
| pii_basic | support_export | |
| kind | org_internal | support_export |
| role_id | system_metadata | support_export |
| patient_tier_id | system_metadata | support_export |
| specialist_id | system_metadata | support_export |
| patient_profile_id | system_metadata | support_export |
| invited_by_principal_id | system_metadata | support_export |
| invited_at | system_metadata | support_export |
| expires_at | system_metadata | support_export |
| accepted_at | system_metadata | support_export |
| accepted_principal_id | system_metadata | support_export |
| consumed_at | system_metadata | support_export |
| revoked_at | system_metadata | support_export |
| revoked_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
share_links
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| code | org_internal | support_export |
| kind | org_internal | support_export |
| patient_tier_id | system_metadata | support_export |
| max_uses | org_internal | support_export |
| use_count | org_internal | support_export |
| expires_at | system_metadata | support_export |
| note | org_internal | support_export |
| created_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| revoked_at | system_metadata | support_export |
| revoked_by_principal_id | system_metadata | support_export |
Locations
Physical clinic locations (Foundation 1B.14). One row per (org × site); state, not events. Address fields ship pii_basic because a small specialty clinic's location list — combined with appointment data downstream — could enable patient-identity inference; conservative posture. name and slug are public (clinic naming is a marketing surface). timezone, phone, email, and status are operational metadata at org_internal. No bulk_export egress on PII fields — locations are clinic operational data, not patient-export data.
locations
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| slug | public | support_export |
| name | public | support_export |
| timezone | org_internal | support_export |
| phone | org_internal | support_export |
| org_internal | support_export | |
| address_line1 | pii_basic | support_export |
| address_line2 | pii_basic | support_export |
| city | pii_basic | support_export |
| county | pii_basic | support_export |
| postal_code | pii_basic | support_export |
| country | pii_basic | support_export |
| status | org_internal | support_export |
| closed_at | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
Platform service providers
Cat A provider resolution table (Foundation 1C.2). Holds platform-default and per-org-override credentials for capabilities like email, storage, auth, and (future) SMS, video, AI, payments. credentials_encrypted is auth_secret — never leaves the tenant, no egress targets. The non-secret operational columns (provider_name, capability, status, healthcheck metadata) are org_internal with support_export so platform support staff can investigate broken provider rows. config is org_internal; per-provider config payloads must be reviewed when a new provider ships — anything sensitive in config is a bug (move it to credentials_encrypted).
platform_service_providers
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| capability | org_internal | support_export |
| organization_id | system_metadata | support_export |
| provider_name | org_internal | support_export |
| credentials_encrypted | auth_secret | |
| config | org_internal | support_export |
| status | org_internal | support_export |
| last_error_at | org_internal | support_export |
| last_error | org_internal | support_export |
| last_health_check_at | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
Outbound webhook subscriptions
Cat C outbound webhook subscriptions and per-attempt deliveries (Foundation 1C.4). Subscriptions are clinic-managed integrations that POST signed event payloads to clinic-controlled URLs (Make.com, Zapier, n8n, custom backends). signing_secret_encrypted and signing_secret_previous_encrypted are auth_secret — never leave the tenant, no egress targets; the dual-secret rotation window keeps both populated for 24h after a rotation. Operational columns (target_url, event_filters, status, failure_count, success/failure timestamps) are org_internal with support_export so platform support can investigate broken integrations.
outbound_webhook_deliveries is one row per attempt, range-partitioned monthly per P41. The payload column is the full envelope (event, event_id, occurred_at, organization_id, data) snapshotted at enqueue — variable class. By the locked design, the deliveries table inherits the most-permissive class of any included event payload; in practice no event payload sets a class higher than support_export, so the table is support_export only and never feeds bulk_export / analytics_internal / marketing_email. When a future event payload registers a more sensitive class, this table inherits the constraint.
outbound_webhook_subscriptions
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| target_url | org_internal | support_export |
| signing_secret_encrypted | auth_secret | |
| signing_secret_previous_encrypted | auth_secret | |
| signing_secret_rotated_at | system_metadata | support_export |
| event_filters | org_internal | support_export |
| status | org_internal | support_export |
| failure_count | system_metadata | support_export |
| last_success_at | system_metadata | support_export |
| last_failure_at | system_metadata | support_export |
| created_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
outbound_webhook_deliveries
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| subscription_id | system_metadata | support_export |
| event_id | system_metadata | support_export |
| event_name | org_internal | support_export |
| payload | org_internal | support_export |
| status | system_metadata | support_export |
| attempt_count | system_metadata | support_export |
| next_attempt_at | system_metadata | support_export |
| claimed_at | system_metadata | support_export |
| claimed_by_worker_id | system_metadata | support_export |
| last_attempt_at | system_metadata | support_export |
| last_response_status_code | system_metadata | support_export |
| last_response_body | org_internal | support_export |
| dead_lettered_at | system_metadata | support_export |
| created_at | system_metadata | support_export |
Connected Accounts
Cat B Connected Accounts catalog and per-org connections (Foundation 1C.5). The catalog (integration_services) is platform-scoped — clinics consume but never write — and is public because the marketplace landing page renders it pre-auth. Per-org connections (organization_integrations) are org_internal plus the credentials_encrypted column which is auth_secret (no egress; mirrors platform_service_providers.credentials_encrypted and outbound_webhook_subscriptions.signing_secret_encrypted). The config column is variable-class — per-service config payloads must be reviewed when each F-tier connector ships, anything sensitive in config is a bug (move it to credentials_encrypted).
integration_services
| Column | Class | Egress |
|---|---|---|
| id | public | support_export |
| slug | public | support_export |
| name | public | support_export |
| description | public | support_export |
| auth_type | public | support_export |
| oauth_scopes | public | support_export |
| oauth_client_capability | system_metadata | support_export |
| icon_url | public | support_export |
| status | public | support_export |
| config_schema | public | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
organization_integrations
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| integration_service_id | system_metadata | support_export |
| auth_type | org_internal | support_export |
| external_account_id | org_internal | support_export |
| title | org_internal | support_export |
| status | org_internal | support_export |
| oauth_expires_at | system_metadata | support_export |
| credentials_encrypted | auth_secret | |
| config | org_internal | support_export |
| last_used_at | system_metadata | support_export |
| last_error_at | system_metadata | support_export |
| last_error | org_internal | support_export |
| created_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| inbound_token | system_metadata | support_export |
| inbound_signing_secret_encrypted | auth_secret |
Inbound webhook dedup
Operational dedup table for the Cat D Inbound Webhook Convention (Foundation 1C.6). One row per (provider, event_id) we've processed. Range-partitioned monthly per P41; not tenant-scoped — provider events arrive at platform-level /webhooks/{provider} endpoints whose handlers resolve the org from the payload after dedup. AdminPool-only by REVOKE; the table is invisible to restartix_app for both reads and writes. No clinic-facing surface, no egress beyond support_export for incident investigation.
inbound_webhook_dedup
| Column | Class | Egress |
|---|---|---|
| provider | system_metadata | support_export |
| event_id | system_metadata | support_export |
| processed_at | system_metadata | support_export |
Metering & quotas
Per-capability usage records, live per-org quotas, and closed-period summaries (Foundation 1C.7). Counts and timestamps only — no patient data, no message content. Capability codes are platform-internal taxonomy. AdminPool writes; SELECT gated on the per-org usage.view_org permission so clinic admins can audit their own usage and bill-relevant aggregates. No external egress beyond support_export and the telemetry pipe (organization_id pseudonymized at forwarding) — billing reconstruction stays internal until the billing engine ships.
usage_records.metadata is variable-class: foundation consumers (notify.email at 1C.7) write {}. The first capability that puts identifiable shape into metadata registers the column on a per-capability filter (P39) and lifts the classification accordingly.
usage_records
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| capability | system_metadata | support_export |
| units | system_metadata | support_export |
| unit_type | system_metadata | support_export |
| cost_cents | system_metadata | support_export |
| principal_id | system_metadata | support_export |
| occurred_at | system_metadata | support_export |
| metadata | system_metadata | support_export |
usage_quotas
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| capability | system_metadata | support_export |
| period | system_metadata | support_export |
| limit_units | system_metadata | support_export |
| current_units | system_metadata | support_export |
| period_start_at | system_metadata | support_export |
| period_end_at | system_metadata | support_export |
| last_reset_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
usage_summaries
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| capability | system_metadata | support_export |
| period | system_metadata | support_export |
| period_start_at | system_metadata | support_export |
| period_end_at | system_metadata | support_export |
| total_units | system_metadata | support_export |
| total_cost_cents | system_metadata | support_export |
| calls_count | system_metadata | support_export |
| created_at | system_metadata | support_export |
AI model registry
ai_models is public-by-design — registered models are surfaced on patient-facing AI transparency UIs ("this output was produced by Claude Opus 4.7") so the column class is org_internal with a support_export egress target. ai_model_pricing_history is the inverse: pricing detail is platform-confidential (margin disclosure + commercial contracts), no SELECT policy on the table, AdminPool-only — the columns carry the audit_only class with no support_export egress, so a leaked pricing row never reaches a clinic egress channel even if RLS is misconfigured.
ai_models
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| model_provider | org_internal | support_export |
| model_name | org_internal | support_export |
| model_version | org_internal | support_export |
| capability | org_internal | support_export |
| unit_type | system_metadata | support_export |
| validation_status | org_internal | support_export |
| validation_notes | org_internal | support_export |
| status | org_internal | support_export |
| introduced_at | system_metadata | support_export |
| retired_at | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
ai_model_pricing_history
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | |
| model_id | system_metadata | |
| cost_per_input_unit_cents | audit_only | |
| cost_per_output_unit_cents | audit_only | |
| effective_from | audit_only | |
| effective_to | audit_only | |
| changed_by_principal_id | audit_only | |
| notes | audit_only | |
| created_at | system_metadata |
Exercise library
exercises and exercise_renders are platform-curated catalog tables: every authenticated principal SELECTs them (specialists browse the library while authoring treatment plans; patients see published rows in their portal catalog), and mutations route through AdminPool only — the same public-by-design model as ai_models. No PII: every column is either operational metadata (slugs, lifecycle status, hashes, durations, render-queue lease bookkeeping — claimed_at / claimed_by_worker_id / attempts / next_attempt_at) or links to external rendering systems (Bunny video IDs, collection IDs). Most columns carry org_internal or system_metadata with support_export egress so exports for ops debugging can ship rendered-video state alongside the rest of the platform-config payload. The three catalog_thumbnail_* columns are public: they hold Bunny Storage Zone CDN URLs that are served unauthenticated by design — the catalog thumbnail asset is the same URL across Console / Clinic / Portal and is explicitly the platform's public face. reference_code (the shareable EX-NNNN catalog code) is likewise public — it's the patient-quotable, language-neutral identifier shown across every surface; reference_number is its internal backing counter (system_metadata).
exercises
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| ownership_kind | org_internal | support_export |
| organization_id | system_metadata | support_export |
| slug | org_internal | support_export |
| reference_number | system_metadata | support_export |
| reference_code | public | support_export |
| name | public | support_export |
| description | public | support_export |
| translations | public | support_export |
| kind | org_internal | support_export |
| status | org_internal | support_export |
| asset_version | system_metadata | support_export |
| default_preview_render_id | system_metadata | support_export |
| video_collection_id | org_internal | support_export |
| catalog_thumbnail_loop_url | public | support_export |
| catalog_thumbnail_poster_url | public | support_export |
| manifest_version | system_metadata | support_export |
| laterality | org_internal | support_export |
| languages | org_internal | support_export |
| capabilities | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
exercise_renders
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| exercise_id | system_metadata | support_export |
| recipe_hash | system_metadata | support_export |
| language | org_internal | support_export |
| recipe | org_internal | support_export |
| content_file_id | system_metadata | support_export |
| manifest_url | org_internal | support_export |
| manifest_version | system_metadata | support_export |
| status | org_internal | support_export |
| asset_version | system_metadata | support_export |
| duration_seconds | system_metadata | support_export |
| picks | org_internal | support_export |
| rendered_at | system_metadata | support_export |
| failed_reason | org_internal | support_export |
| claimed_at | system_metadata | support_export |
| claimed_by_worker_id | system_metadata | support_export |
| attempts | system_metadata | support_export |
| next_attempt_at | system_metadata | support_export |
| created_at | system_metadata | support_export |
Exercise taxonomy (F9.1 Phase 2 Sub-phase A)
The F9.1 Phase 2 Sub-phase A expansion adds the full clinical/biomechanical exercise taxonomy on top of the F9.1 Phase 1 exercises row. Design source of truth: exercise-taxonomy-pose-tracking.md; schema reflection: data-model.md Area 9. Every table in this section is platform/clinic-curated catalog content with no patient-identifying fields; rows follow the exercises / exercise_renders pattern — system_metadata or org_internal with support_export egress only. Sub-phase B (pose_engines, pose_landmarks) is registered in the pose-tracking foundation section; Sub-phase C (exercise_pose_configs, exercise_pose_config_history, exercise_pose_landmarks, exercise_pose_metrics, exercise_pose_feedback_rules, pose_data_quality_overrides) is registered in the pose-tracking per-exercise config section.
Class IIa provenance columns (tagged_by_principal_id, tagged_at, clinical_basis) appear on every tag association row per D3. tagged_by_principal_id and tagged_at are system_metadata (the same shape as created_by_principal_id / created_at on other catalog rows); clinical_basis is org_internal (free-text clinical rationale authored by a platform or clinic curator — catalog content, not patient data). All three egress to support_export only; they are not patient PII so they do not flow to bulk_export. Per-tag deprecation columns (deprecated_at, replaced_by_id) appear on every tag entity per D4 and are system_metadata.
exercise_categories
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| name | org_internal | support_export |
| slug | org_internal | support_export |
| description | org_internal | support_export |
| parent_id | system_metadata | support_export |
| sort_order | system_metadata | support_export |
| deprecated_at | system_metadata | support_export |
| replaced_by_id | system_metadata | support_export |
| translations | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
exercise_body_regions
Platform-only per D5 (cohort analytics require comparable vocabulary across clinics). organization_id is held NULL by CHECK constraint.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| name | org_internal | support_export |
| slug | org_internal | support_export |
| body_area | org_internal | support_export |
| sort_order | system_metadata | support_export |
| deprecated_at | system_metadata | support_export |
| replaced_by_id | system_metadata | support_export |
| translations | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
exercise_equipment
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| name | org_internal | support_export |
| slug | org_internal | support_export |
| icon_url | org_internal | support_export |
| sort_order | system_metadata | support_export |
| deprecated_at | system_metadata | support_export |
| replaced_by_id | system_metadata | support_export |
| translations | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
exercise_movement_patterns
Platform-only per D5 (pose-engine rep-counting heuristics map to these). organization_id is held NULL by CHECK constraint.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| name | org_internal | support_export |
| slug | org_internal | support_export |
| description | org_internal | support_export |
| sort_order | system_metadata | support_export |
| deprecated_at | system_metadata | support_export |
| replaced_by_id | system_metadata | support_export |
| translations | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
exercise_recovery_phases
Platform-only per D5 (comparable across clinics for cohort analytics). organization_id is held NULL by CHECK constraint.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| name | org_internal | support_export |
| slug | org_internal | support_export |
| description | org_internal | support_export |
| sort_order | system_metadata | support_export |
| deprecated_at | system_metadata | support_export |
| replaced_by_id | system_metadata | support_export |
| translations | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
exercise_conditions
Platform-canonical condition name with optional ICD-10 mapping per B5. icd10_code is org_internal rather than public — it is per-row catalog metadata, not platform branding, and matches the egress posture of the surrounding catalog columns.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| name | org_internal | support_export |
| slug | org_internal | support_export |
| description | org_internal | support_export |
| icd10_code | org_internal | support_export |
| body_region_id | system_metadata | support_export |
| status | org_internal | support_export |
| sort_order | system_metadata | support_export |
| deprecated_at | system_metadata | support_export |
| replaced_by_id | system_metadata | support_export |
| translations | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
exercise_skill_prerequisites
Platform-only per D5 (algorithmic program suggestion requires a locked vocabulary). organization_id is held NULL by CHECK constraint.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| name | org_internal | support_export |
| slug | org_internal | support_export |
| description | org_internal | support_export |
| sort_order | system_metadata | support_export |
| deprecated_at | system_metadata | support_export |
| replaced_by_id | system_metadata | support_export |
| translations | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
exercise_tags
Polymorphic junction (P24). tag_id resolves against the appropriate tag-entity table per tag_type.
| Column | Class | Egress |
|---|---|---|
| exercise_id | system_metadata | support_export |
| tag_type | org_internal | support_export |
| tag_id | system_metadata | support_export |
| tagged_by_principal_id | system_metadata | support_export |
| tagged_at | system_metadata | support_export |
| clinical_basis | org_internal | support_export |
exercise_prerequisites
Self-M2M between exercises (D2) — "Bird Dog before Side Plank" chains for program-builder ordering.
| Column | Class | Egress |
|---|---|---|
| exercise_id | system_metadata | support_export |
| prerequisite_exercise_id | system_metadata | support_export |
| tagged_by_principal_id | system_metadata | support_export |
| tagged_at | system_metadata | support_export |
| clinical_basis | org_internal | support_export |
exercise_instructions
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| exercise_id | system_metadata | support_export |
| sort_order | system_metadata | support_export |
| title | org_internal | support_export |
| content | org_internal | support_export |
| image_url | org_internal | support_export |
| instruction_type | org_internal | support_export |
| tagged_by_principal_id | system_metadata | support_export |
| tagged_at | system_metadata | support_export |
| clinical_basis | org_internal | support_export |
| translations | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
exercise_contraindications
Per B5: freetext condition_name replaced with condition_id FK to exercise_conditions.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| exercise_id | system_metadata | support_export |
| condition_id | system_metadata | support_export |
| description | org_internal | support_export |
| severity | org_internal | support_export |
| tagged_by_principal_id | system_metadata | support_export |
| tagged_at | system_metadata | support_export |
| clinical_basis | org_internal | support_export |
| translations | org_internal | support_export |
| created_at | system_metadata | support_export |
Pose-tracking foundation (F9.1 Phase 2 Sub-phase B)
The pose-tracking foundation ships two global reference tables consumed by the Sub-phase C per-exercise pose config (and later, telemetry's pose-frame aggregator). Both rows are vendor-catalog content — they describe which pose engines exist and which landmarks each engine emits, not tenant data. No organization_id, no RLS; writes are AdminPool-only (REVOKE on restartix_app). Classification follows the same system_metadata / org_internal floor used throughout the taxonomy section.
pose_engines
Reference table — pose-engine vendor catalog (D15). Read-only at the API layer; seeded with mediapipe.holistic at F9.1 Phase 2.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| code | org_internal | support_export |
| display_name | org_internal | support_export |
| vendor | org_internal | support_export |
| version | org_internal | support_export |
| landmark_catalog_version | system_metadata | support_export |
| status | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
pose_landmarks
Per-engine landmark catalog (~543 rows for MediaPipe holistic = 33 pose + 21 left hand + 21 right hand + 468 face) per D17.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| engine_id | system_metadata | support_export |
| code | org_internal | support_export |
| display_name | org_internal | support_export |
| display_name_translations | org_internal | support_export |
| body_part_category | org_internal | support_export |
| status | org_internal | support_export |
| deprecated_at | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
Pose-tracking per-exercise config (F9.1 Phase 2 Sub-phase C)
Per-exercise pose-tracking configuration on top of the Sub-phase B reference catalog: which engine, which landmark subset, which metrics + thresholds, which feedback rules. Tables follow the catalog pattern — system_metadata for internal FKs / versioning pointers, org_internal for clinician-authored operational thresholds and patient-facing display strings; egress to support_export only because nothing here is patient PII. The exception is pose_data_quality_overrides, which reaches into a specific patient's session and therefore follows the protocol_pauses clinical-state pattern (clinical for the override fact + reason, bulk_export so the patient's GDPR archive reflects the override).
exercise_pose_configs
1:1 with exercises per D8; the active pose-tracking configuration. Operational thresholds (camera_distance_cm_min/_max, min_landmark_confidence, rep_success_rule_params) are org_internal authoring content. engine_id and pinned_asset_version are internal FKs / version pointers — system_metadata.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| exercise_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| tracking_enabled | org_internal | support_export |
| engine_id | system_metadata | support_export |
| camera_angle | org_internal | support_export |
| camera_distance_cm_min | org_internal | support_export |
| camera_distance_cm_max | org_internal | support_export |
| lighting_requirement | org_internal | support_export |
| in_frame_requirements | org_internal | support_export |
| rep_success_rule_type | org_internal | support_export |
| rep_success_rule_params | org_internal | support_export |
| pinned_asset_version | system_metadata | support_export |
| min_landmark_confidence | org_internal | support_export |
| status | org_internal | support_export |
| tagged_by_principal_id | system_metadata | support_export |
| tagged_at | system_metadata | support_export |
| clinical_basis | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
exercise_pose_config_history
Append-only snapshots per D8 — every edit to exercise_pose_configs (including status transitions and asset-version invalidations) writes a full-row snapshot. Required for Class IIa reproducibility. The snapshot columns mirror exercise_pose_configs 1:1 and carry the same class as their source columns.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| exercise_pose_config_id | system_metadata | support_export |
| snapshot_at | system_metadata | support_export |
| snapshot_reason | system_metadata | support_export |
| exercise_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| tracking_enabled | org_internal | support_export |
| engine_id | system_metadata | support_export |
| camera_angle | org_internal | support_export |
| camera_distance_cm_min | org_internal | support_export |
| camera_distance_cm_max | org_internal | support_export |
| lighting_requirement | org_internal | support_export |
| in_frame_requirements | org_internal | support_export |
| rep_success_rule_type | org_internal | support_export |
| rep_success_rule_params | org_internal | support_export |
| pinned_asset_version | system_metadata | support_export |
| min_landmark_confidence | org_internal | support_export |
| status | org_internal | support_export |
| tagged_by_principal_id | system_metadata | support_export |
| tagged_at | system_metadata | support_export |
| clinical_basis | org_internal | support_export |
| created_at | system_metadata | support_export |
exercise_pose_landmarks
M2M between a pose config and the landmark subset it tracks per D17.
| Column | Class | Egress |
|---|---|---|
| exercise_pose_config_id | system_metadata | support_export |
| landmark_id | system_metadata | support_export |
| tagged_by_principal_id | system_metadata | support_export |
| tagged_at | system_metadata | support_export |
| clinical_basis | org_internal | support_export |
exercise_pose_metrics
Per-config metric definitions per D18. landmark_refs and derived_from_metric_ids are UUID arrays of FKs — system_metadata, the same shape as a scalar internal FK.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| exercise_pose_config_id | system_metadata | support_export |
| metric_type | org_internal | support_export |
| target_min | org_internal | support_export |
| target_max | org_internal | support_export |
| tolerance | org_internal | support_export |
| weight_pct | org_internal | support_export |
| landmark_refs | system_metadata | support_export |
| derived_from_metric_ids | system_metadata | support_export |
| axis | org_internal | support_export |
| label | org_internal | support_export |
| label_translations | org_internal | support_export |
| tagged_by_principal_id | system_metadata | support_export |
| tagged_at | system_metadata | support_export |
| clinical_basis | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
exercise_pose_feedback_rules
Single table collapsing form-errors and live-warnings per D10 — semantically identical (condition → patient-facing message), differentiated by severity. patient_message and patient_message_translations are clinician-authored display strings (catalog content, not patient PII), so org_internal is the right floor — they belong to the same category as sessions.name / program_phases.name.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| exercise_pose_config_id | system_metadata | support_export |
| severity | org_internal | support_export |
| condition_expression | org_internal | support_export |
| condition_format | org_internal | support_export |
| patient_message | org_internal | support_export |
| patient_message_translations | org_internal | support_export |
| tagged_by_principal_id | system_metadata | support_export |
| tagged_at | system_metadata | support_export |
| clinical_basis | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
pose_data_quality_overrides
Per B3 — specialist clinical override of session pose data at session_run or session_exercise_event granularity. Reaches into patient-linked clinical data (a session run), so this table follows the protocol_pauses egress pattern, not the catalog pattern: clinical state and the override timestamp flow to bulk_export so the patient's GDPR archive reflects the override; overridden_by_principal_id stays support_export-only matching protocols.approved_by_principal_id (the patient receives the fact of the override and its clinical rationale, not the identity of the specialist who applied it). override_reason is clinical because it carries clinical context about a specific patient's session.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| scope | clinical | bulk_export, support_export |
| session_run_id | system_metadata | bulk_export, support_export |
| session_exercise_event_id | system_metadata | bulk_export, support_export |
| override_reason | clinical | bulk_export, support_export |
| overridden_by_principal_id | system_metadata | support_export |
| overridden_at | clinical | bulk_export, support_export |
| created_at | system_metadata | bulk_export, support_export |
Sessions
The session model is source-agnostic: sessions is the org-scoped template, session_exercises carries per-exercise dose, session_runs is the per-playthrough state (clinical record — patient-authored), session_pain_events is the partitioned append-only event log of mid-session pain reports, and session_exercise_events is the partitioned append-only event log of per-exercise progress milestones (Design C clinical record — counterpart to telemetry's video-QoS heartbeat). Template tables (sessions, session_exercises) are clinic-curated content — names + materials + dose are operational content with no patient-identifying fields, so org_internal is the right floor. Per-run tables (session_runs, session_pain_events, session_exercise_events) carry patient-linked clinical data: status, completion, pain reports, per-exercise progress, post-session VAS/RPE feedback. clinical class everywhere a patient action is recorded; the patient's own GDPR bulk_export receives those rows (bulk_export egress); support troubleshooting receives the operational metadata via support_export. idempotency_key is an opaque retry token with no egress on either table — it has no value to anyone outside the API write path.
sessions
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| ownership_kind | org_internal | support_export |
| organization_id | system_metadata | support_export |
| patient_id | system_metadata | support_export |
| program_id | system_metadata | support_export |
| phase_id | system_metadata | support_export |
| order_in_phase | system_metadata | support_export |
| kind | org_internal | support_export |
| name | org_internal | support_export |
| subtitle | org_internal | support_export |
| translations | org_internal | support_export |
| cover_url | public | support_export |
| objective | org_internal | support_export |
| estimated_duration_s | system_metadata | support_export |
| exercise_count | system_metadata | support_export |
| content_version | system_metadata | support_export |
| content_updated_at | system_metadata | support_export |
| source_session_id | system_metadata | support_export |
| source_content_version | system_metadata | support_export |
| content_severed_at | system_metadata | support_export |
| status | org_internal | support_export |
| idempotency_key | system_metadata | |
| created_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export |
session_exercises
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| session_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| exercise_id | system_metadata | support_export |
| sequence_order | system_metadata | support_export |
| mode | org_internal | support_export |
| sets | org_internal | support_export |
| reps_per_set | org_internal | support_export |
| hold_seconds | org_internal | support_export |
| side | org_internal | support_export |
| rest_between_sets_s | org_internal | support_export |
| rest_after_exercise_s | org_internal | support_export |
| language | org_internal | support_export |
| seed | system_metadata | support_export |
| asset_version | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export |
deleted_at enables the soft-delete-+-insert pattern for mid-treatment edits (see cadence-and-supervision.md): removing / replacing / re-dosing an exercise soft-deletes the row rather than mutating it, preserving historical session_exercise_events.session_exercise_id resolution.
session_runs
status (in_progress / ended_naturally / ended_explicit / auto_closed) describes how the run terminated; closed_reason (silence_timeout / superseded, CHECK-pinned to auto_closed, NULL otherwise) records WHY the platform closed it — silence-sweep cron vs the same-org close-and-restart supersede; completed (boolean) describes whether the patient walked the whole sequence — orthogonal axes per decisions.md → Why session_runs carries both status and completed. Both are server-derived; exercises_completed is the count of completed-kind exercise events at the terminal write. pose_tracking_choice is the patient's biometric consent for this run (GDPR Art. 9 special category, per-run). safety_acknowledged_at + safety_text_version pin which version of the daily safety reminder the patient saw — IEC 62304 / MDR Class I traceability + product-liability evidence; both clinical because they are direct patient acknowledgments. Engagement telemetry has no column here — it runs as legitimate interest, see decisions.md → Why engagement telemetry is legitimate interest, not consent. country + city are coarse location captured at run-create from Cloudflare edge headers (CF-IPCountry / CF-IPCity) — pii_basic (location is personal data, but low-precision and not clinical), processed under legitimate interest for operational analytics on the clinic's own cohort (the superadmin platform-aggregate dashboard, Stream F). Displayed only as aggregate counts-by-location, never as a per-named-patient map; both ride the patient's bulk_export (their own captured location) and support_export.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| patient_id | system_metadata | bulk_export, support_export |
| patient_profile_id | system_metadata | support_export |
| session_id | system_metadata | bulk_export, support_export |
| status | clinical | bulk_export, support_export |
| closed_reason | clinical | bulk_export, support_export |
| started_at | clinical | bulk_export, support_export |
| completed_at | clinical | bulk_export, support_export |
| completed | clinical | bulk_export, support_export |
| exercises_completed | clinical | bulk_export, support_export |
| pose_tracking_choice | clinical | bulk_export, support_export |
| safety_acknowledged_at | clinical | bulk_export, support_export |
| safety_text_version | clinical | bulk_export, support_export |
| feedback_pain_level_now | clinical | bulk_export, support_export |
| feedback_perceived_effort | clinical | bulk_export, support_export |
| feedback_notes | clinical | bulk_export, support_export |
| idempotency_key | system_metadata | |
| created_at | system_metadata | bulk_export, support_export |
| updated_at | system_metadata | bulk_export, support_export |
| country | pii_basic | bulk_export, support_export |
| city | pii_basic | bulk_export, support_export |
session_pain_events
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| run_id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| exercise_id | system_metadata | bulk_export, support_export |
| set_idx | clinical | bulk_export, support_export |
| side | clinical | bulk_export, support_export |
| seconds_into_set | clinical | bulk_export, support_export |
| severity | clinical | bulk_export, support_export |
| action | clinical | bulk_export, support_export |
| reported_region_id | system_metadata | bulk_export, support_export |
| reported_region_note | clinical | bulk_export, support_export |
| reported_at | clinical | bulk_export, support_export |
| client_event_id | system_metadata |
session_exercise_events
Design C clinical record: per-exercise milestones (started / completed / skipped / abandoned / paused_for_pain / paused / resumed) the player POSTs as the patient progresses through a run, plus the server-synthesized abandoned event. Same class shape as session_pain_events — every patient action is clinical. Drop point is inferred server-side; the client never fires a dropped kind. abandoned is server-synthesized on non-natural run termination (EndEarly, AutoCloseRun) for any session_exercise that has events but no terminal event; reason discriminates the cause (session_ended for explicit end-session, auto_closed for the silence-sweep). set_count_completed and video_time_s are populated only when meaningful (completed, paused_for_pain) and stay clinical. reason discriminates non-pain pauses (manual / visibility / network), the abandonment cause (session_ended / auto_closed), the skip cause (pain / too_difficult / no_equipment / other), and is mirrored on the matching resumed row; it's clinical because the cause of a pause, skip, or abandonment is part of the clinical narrative ("session took 5m for a 90s video because the network kept dropping" / "patient ended session during foot-slide after reporting pain"). skip_note is the optional patient-entered free-text feedback accompanying any skip (≤200 chars, any reason) — patient-authored clinical narrative, same clinical class.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| run_id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| session_exercise_id | system_metadata | bulk_export, support_export |
| kind | clinical | bulk_export, support_export |
| reason | clinical | bulk_export, support_export |
| skip_note | clinical | bulk_export, support_export |
| video_time_s | clinical | bulk_export, support_export |
| set_count_completed | clinical | bulk_export, support_export |
| occurred_at | clinical | bulk_export, support_export |
| created_at | system_metadata | bulk_export, support_export |
| client_event_id | system_metadata |
protocols
Renamed from patient_assignments by the 2026-05-23 protocols rename (the "assignment" naming was a relic of the shared-by-reference model where patients were "assigned to" shared programs; under three-tier copy-on-derive the row is the workflow wrapper around a patient-instance program — "protocol" matches the concept). program_id references the PATIENT-INSTANCE program the prescribe/enroll service deep-copied (1:1 with this protocol, never shared), and source_program_id records which platform/org template the instance was copied from (analytics like "patients on the Knee Rehab template family"). Covers both prescriptions (specialist-driven, cadence + adherence) and enrollments (self-initiated, course progress, no adherence). Each row represents a patient's active or historical engagement with a program — clinical context: which program was prescribed, with what cadence, with what supervision mode, over what date range, with what approval state. The cadence_config JSONB is purely operational shape (e.g., {"sessions_per_week": 3} / {"days_of_week": ["tue","thu"]}) carrying no patient-identifiable content, so org_internal. Approval + status + dates + supervision + cadence are first-class clinical state — clinical class. Patient GDPR bulk_export receives the whole row so "what was prescribed to me, when, and by whom" is part of the portability surface; support exports likewise. See cadence-and-supervision.md for column-shape definitions (2026-05-28 renames: modality → supervision_mode; appointment_driven cadence_kind dropped).
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| patient_id | system_metadata | bulk_export, support_export |
| program_id | system_metadata | bulk_export, support_export |
| source_program_id | system_metadata | bulk_export, support_export |
| kind | clinical | bulk_export, support_export |
| supervision_mode | clinical | bulk_export, support_export |
| cadence_kind | clinical | bulk_export, support_export |
| cadence_config | org_internal | bulk_export, support_export |
| status | clinical | bulk_export, support_export |
| start_date | clinical | bulk_export, support_export |
| end_date | clinical | bulk_export, support_export |
| end_date_is_hard_cap | clinical | bulk_export, support_export |
| completed_at | clinical | bulk_export, support_export |
| approval_status | clinical | bulk_export, support_export |
| approved_by_principal_id | system_metadata | support_export |
| required_entitlement | org_internal | bulk_export, 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 |
protocol_pauses
Append-only history of pause intervals on a protocol (F9.2 PR 4, renamed 2026-05-23). One row per pause; resumed_at NULL means currently paused. Each pause is patient-visible clinical state (the cadence engine subtracts paused intervals from the adherence denominator), so timestamps + reason are clinical. Patient GDPR bulk_export receives the rows for "when was my protocol paused, by whom, and why."
kind (000051) is clinical for the same reason reason is: it is part of the answer to "why was my treatment stopped for those eleven days." A content_edit pause tells the patient their clinic was retuning the program rather than that a clinician judged they should rest — a materially different fact about their own care, and one they are entitled to on export.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| protocol_id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| paused_at | clinical | bulk_export, support_export |
| resumed_at | clinical | bulk_export, support_export |
| reason | clinical | bulk_export, support_export |
| kind | clinical | bulk_export, support_export |
| paused_by_principal_id | system_metadata | support_export |
| resumed_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | bulk_export, support_export |
TV companion mode
Phase 1 of the phone–TV companion build (000024). session_pairings is the short-lived bridge between an anonymous TV (POST /v1/session-pairings) and an authenticated phone (POST /{pair_id}/claim); the claim atomically creates the session_runs row via sessions.Service.CreateRun. session_tv_liveness is the TV-side heartbeat (10s cadence) the auto-close cron reads to decide whether to mark abandoned runs auto_closed (status) — the cron's partial/unknown classification lives as observability metrics, not as separate status values; the row carries exercises_completed > 0 as the partial-vs-unknown distinguisher. Neither table participates in patient GDPR bulk_export — pairings are ephemeral session-setup mechanics with no patient-readable meaning, and liveness is TV-side operational metadata that exists to prove "the session was running" rather than "what the patient did." Both flow to support_export so platform support can investigate "the pairing code didn't work" / "did the TV ever connect?" tickets. code is auth_secret — within its 5-min TTL the code carries session-level bearer authority; never logged, never exported. display_token_jti is auth_secret (JWT id for revocation; the token itself is never persisted, only its jti is kept for the lifetime of the run for the post-terminal /feedback revocation lookup).
session_pairings
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| code | auth_secret | |
| organization_id | system_metadata | support_export |
| run_id | system_metadata | support_export |
| claimed_at | system_metadata | support_export |
| claimed_by_principal_id | system_metadata | support_export |
| claim_idempotency_key | system_metadata | |
| display_token_jti | auth_secret | |
| source_ip | audit_only | support_export |
| expires_at | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
session_tv_liveness
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| run_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| ts | system_metadata | support_export |
| conductor_status | audit_only | support_export |
| current_exercise_idx | system_metadata | support_export |
| exercise_video_time_s | system_metadata | support_export |
| network_state | audit_only | support_export |
| idempotency_key | system_metadata |
Programs & content (F9.2 Phase 1 substrate)
The F9.2 substrate adds the cross-cutting catalog model: content_files registers consumable media (audio / video / image / document); programs containers package sessions into multi-week journeys with optional phases; program_phases / program_assets / session_audio_items / session_assets are owned-child tables that hang off programs or sessions. Sessions point at their parent program directly via sessions.program_id under the three-tier copy-on-derive model (2026-05-22) — the previous program_sessions junction was retired, and per-prescription isolation is provided by server-side deep copies instead of version snapshots (the dropped program_versions / session_versions tables). programs.derived_from_program_id is the flat lineage pointer for org-tier saved variants. Every table is clinic-curated content — names, descriptions, dose configs, file metadata — with no patient-identifying fields except the patient_id on patient-specific tier rows (an FK identifier; the clinical content lives in the referenced session/program columns). Everything is org_internal or system_metadata with support_export egress, matching the exercises and sessions pattern — except the per-phase dosing columns added with the treatment-as-journey spine (2026-08-24), which mirror their protocols namesakes: program_phases.cadence_kind / supervision_mode are clinical, while cadence_config carries the same operational JSONB shape and so stays org_internal.
content_files
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| ownership_kind | org_internal | support_export |
| organization_id | system_metadata | support_export |
| kind | org_internal | support_export |
| storage_provider | org_internal | support_export |
| storage_ref | org_internal | support_export |
| mime_type | org_internal | support_export |
| file_size_bytes | system_metadata | support_export |
| duration_seconds | system_metadata | support_export |
| metadata | org_internal | support_export |
| uploaded_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
programs
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| ownership_kind | org_internal | support_export |
| organization_id | system_metadata | support_export |
| patient_id | system_metadata | support_export |
| slug | org_internal | support_export |
| name | org_internal | support_export |
| subtitle | org_internal | support_export |
| description_html | org_internal | support_export |
| translations | org_internal | support_export |
| tags | org_internal | support_export |
| cover_url | public | support_export |
| status | org_internal | support_export |
| derived_from_program_id | system_metadata | support_export |
| structure_version | system_metadata | support_export |
| structure_updated_at | system_metadata | support_export |
| source_structure_version | system_metadata | support_export |
| created_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export |
program_phases
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| program_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| name | org_internal | support_export |
| description | org_internal | support_export |
| translations | org_internal | support_export |
| order_in_program | system_metadata | support_export |
| entry_criteria | org_internal | support_export |
| requires_unlock | system_metadata | support_export |
| unlocked_at | system_metadata | support_export |
| unlocked_by_principal_id | system_metadata | support_export |
| kind | system_metadata | support_export |
| cadence_kind | clinical | support_export |
| cadence_config | org_internal | support_export |
| supervision_mode | clinical | support_export |
| entered_at | system_metadata | support_export |
| source_phase_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export |
program_assets
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| program_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| content_file_id | system_metadata | support_export |
| label | org_internal | support_export |
| order_in_program | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export |
session_audio_items
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| session_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| content_file_id | system_metadata | support_export |
| order_in_session | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
session_assets
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| session_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| content_file_id | system_metadata | support_export |
| label | org_internal | support_export |
| order_in_session | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
Patient catalog (F9.x Phase 1)
The merchandising layer that decouples how content is presented to patients from what the content is. Per-org (organization_id set) plus platform-default (organization_id IS NULL) curation; catalog_entries place programs/sessions into ordered, featured catalog_sections. All clinic-curated presentation config — section/entry names, badges, ordering — no patient-identifying fields. org_internal / system_metadata with support_export, matching the Programs & content family. required_entitlement is reserved for Phase 2 tier-gating.
catalog_sections
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| code | org_internal | support_export |
| name | org_internal | support_export |
| description | org_internal | support_export |
| translations | org_internal | support_export |
| sort_order | system_metadata | support_export |
| status | org_internal | support_export |
| grid_columns | system_metadata | support_export |
| card_aspect | system_metadata | support_export |
| created_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
grid_columns and card_aspect are presentation config, classed system_metadata beside sort_order for the same reason: they describe how a shelf is drawn, not what a clinic offers or what a patient did. They reach the patient's browser on every catalog read, which is the point of them.
catalog_entries
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| section_id | system_metadata | support_export |
| content_type | org_internal | support_export |
| content_id | system_metadata | support_export |
| sort_order | system_metadata | support_export |
| featured | org_internal | support_export |
| required_entitlement | org_internal | support_export |
| cover_content_file_id | system_metadata | support_export |
| badge | org_internal | support_export |
| subtitle | org_internal | support_export |
| translations | org_internal | support_export |
| status | org_internal | support_export |
| created_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
patient_content_grants
Per-patient ownership of specific catalog content (a program or standalone session), independent of the patient's tier — the third OR-branch of the catalog access check (free OR grant OR tierHas(code)) and the migration target for the ~20k legacy D2C patients' per-program ownership (catalog Phase 3.1). Same access-state family as patient_subscriptions: no patient-identifying content, org_internal / system_metadata, support_export only. reason is curator-entered free text (comp/promo rationale) — org_internal, not clinical.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| patient_id | system_metadata | support_export |
| patient_profile_id | system_metadata | support_export |
| content_type | org_internal | support_export |
| content_id | system_metadata | support_export |
| granted_from | org_internal | support_export |
| granted_until | org_internal | support_export |
| source | org_internal | support_export |
| granted_by_principal_id | system_metadata | support_export |
| reason | org_internal | support_export |
| fulfillment_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
access_offers
The merchandised access bundle a clinic authors (commerce/campaign provisioning core, 000035). No patient data — an internal label + status + authoring provenance. org_internal / system_metadata, support_export only.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| name | org_internal | support_export |
| status | org_internal | support_export |
| created_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
access_offer_items
The grant set of an offer (000035): what content/tier each item grants and on what terms. Typed-polymorphic content_id (no FK) like patient_content_grants. No patient data; org_internal / system_metadata, support_export only.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| offer_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| grant_kind | org_internal | support_export |
| content_type | org_internal | support_export |
| content_id | system_metadata | support_export |
| tier_id | system_metadata | support_export |
| term_kind | org_internal | support_export |
| duration_days | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
access_offer_fulfillments
Idempotency + forensic ledger of "offer X provisioned to patient Y via trigger Z" (000035). Same access-state family as patient_content_grants: no patient-identifying content beyond the patient_id FK, org_internal / system_metadata, support_export only. trigger_ref is an internal/external order or claim reference, not PII.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| patient_id | system_metadata | support_export |
| offer_id | system_metadata | support_export |
| trigger_type | org_internal | support_export |
| trigger_ref | org_internal | support_export |
| campaign_item_id | system_metadata | support_export |
| source | org_internal | support_export |
| provisioned_at | system_metadata | support_export |
| revoked_at | system_metadata | support_export |
| created_at | system_metadata | support_export |
access_offer_campaigns
The marketing-campaign front door for access offers (000038): a clinic-authored presentation card (headline / blurb / image) bound to an offer, published to the public portal campaign page. The presentation columns are class public (they appear in unauthenticated responses by design); offer_id is the confidential link and stays system_metadata — never egressed, resolved server-side at claim time (access-offers locked decision 3).
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| offer_id | system_metadata | support_export |
| headline | public | support_export |
| blurb | public | support_export |
| image_url | public | support_export |
| published | org_internal | support_export |
| published_at | system_metadata | support_export |
| sort_order | org_internal | support_export |
| featured | org_internal | support_export |
| created_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
access_offer_sku_bindings
Maps a shop product/variation SKU to an access offer (000037). Clinic commerce config — no patient data. org_internal / system_metadata, support_export only.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| integration_id | system_metadata | support_export |
| external_product_ref | org_internal | support_export |
| external_variation_ref | org_internal | support_export |
| offer_id | system_metadata | support_export |
| created_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
access_offer_orders
Staged paid-order record awaiting the buyer's claim (000037). No PII (order_ref / order_key are order identifiers, not patient data; billing is never persisted here). org_internal / system_metadata, support_export only.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| integration_id | system_metadata | support_export |
| provider | org_internal | support_export |
| order_ref | org_internal | support_export |
| order_key | org_internal | support_export |
| status | org_internal | support_export |
| matched_offer_ids | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
Specialists & specialties (F1)
The clinic's provider roster (F1). All three tables are state, org-scoped.
name, title and description are public: a specialist's professional identity is the clinic's marketing surface — it renders on the public booking page (F5.4) to an unauthenticated visitor choosing who to book with. That is a deliberate difference from humans.email, which stays pii_basic; the roster is published, the person's contact details are not.
signature_url and avatar_url have matching names and opposite postures, which is deliberate rather than an oversight.
signature_url holds a private S3 key, not a URL, and has no egress target. A signature is the instrument that makes a prescription legally binding (F6), so it must never leave the tenant through any bulk or support path — a leaked signature image is forgeable. It reaches a browser only as a presigned URL that expires, and reaches a document only as embedded base64.
avatar_url holds a public Bunny CDN URL and is classed public, alongside the name/title/description it appears next to. A clinician's photo is part of the professional identity the clinic publishes: it renders on the public booking page (F5.4) to an unauthenticated visitor, so treating it as confidential would have been a label the system did not honour. Presigning it bought nothing — the same audience sees it either way — while costing a round-trip per row, which is why photos could not appear on the roster at all under the old design.
Two consequences worth stating plainly. The URL is unguessable but permanent and unauthenticated: content-addressing (sha12) means nobody enumerates it, but anyone who has it keeps it, so clearing the column is not enough — the upload path orphan-deletes the CDN object, and that deletion is what actually revokes access. And a photo is still personal data: public describes where it may be served, not that it escapes erasure. A GDPR erasure covering a specialist must delete the object, not merely null the column.
metadata is org_internal and non-clinical by contract — sparse descriptive extras only. A regulated identifier may never be written here; pii_regulated is encrypted BYTEA on patient_profiles and never in a generic value store. Promote a key to a typed column the moment a UI surface needs to filter on it.
scheduling_timezone and scheduling_active are operational scheduling config, not personal data.
specialties
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| title | public | support_export |
| slug | public | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
specialist_titles
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| key | system_metadata | support_export |
| title | public | support_export |
| description | org_internal | support_export |
| sort_order | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export |
specialists
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| human_id | system_metadata | support_export |
| name | public | support_export, patient_document |
| title | public | support_export |
| specialist_title_id | system_metadata | support_export |
| description_html | public | support_export |
| slug | public | support_export |
| signature_url | pii_basic | |
| avatar_url | public | support_export |
| scheduling_timezone | org_internal | support_export |
| scheduling_active | org_internal | support_export |
| metadata | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export |
specialist_specialties
| Column | Class | Egress |
|---|---|---|
| specialist_id | system_metadata | support_export |
| specialty_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
Offerings (F2.1)
The clinic's catalog of clinical services (F2.1) — the configuration spine calendars (F4), appointments (F5) and offering_forms (F3.4) reference. Both tables are state, org-scoped.
title, slug, description and video_url are public for the same reason F1's specialists.name is: the catalog is the clinic's marketing surface and renders on the public booking page (F5.4) to an unauthenticated visitor choosing what to book. video_url is an external URL the clinic itself publishes (its own YouTube/Vimeo presentation clip), not a platform asset — publishing it is the whole point.
cover_url holds a public Bunny CDN URL and is classed public. It used to hold a private S3 key on the reasoning that the rendered image is public but the storage address is not — a distinction that bought nothing here, because a cover depicts nobody, carries no clinical meaning, and exists to be seen by unauthenticated visitors browsing a booking page. What the split did cost was a presign round-trip per catalog card and the ability to server-render the image at all, since a signed URL embedded in a cached page outlives its own signature.
programs.cover_url is the same field for clinical programs and takes the same class for the same reasons — artwork of nobody, on a browse surface, presigned for no one's benefit. It sits on the PROGRAM rather than on catalog_entries because most program cards have no catalog entry: a prescribed program was never catalogued, and an enrolled one is a patient-instance deep copy that is not catalogued either. DeepCopyProgram carries the column, so a patient's instance inherits the template's artwork and the same public class travels with it.
sessions.cover_url is the third instance and takes the same class for the third time — artwork of nobody, on a browse surface. It exists because a STANDALONE session is catalogued in its own right (catalog_entries.content_type = 'session') and had no artwork to show there. Both session copy edges in programs/copy.go list the column, so an attached or prescribed copy inherits the template's artwork along with its public class.
The contrast worth holding onto is specialists.signature_url, which stayed private for exactly the reason this one did not: a leaked signature is forgeable. Public is a property of the content, not a convenience.
Same two consequences as any public asset: the URL is unguessable (content-addressed) but permanent and unauthenticated, so revocation is the CDN object delete the upload path performs, not nulling the column; and a clinic that removes a service should not assume the artwork vanished because the row did.
published, published_at and is_public are org_internal: they describe the clinic's own catalog state, and a draft offering's existence is not something an unauthenticated visitor is entitled to know.
metadata is org_internal and non-clinical by contract, matching specialists.metadata — sparse descriptive extras only, never a regulated identifier.
priority on the junction is the assignment engine's walk order (F4.3), org-internal scheduling config rather than anything about the person.
offerings
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| title | public | support_export, patient_document |
| slug | public | support_export |
| description_html | public | support_export |
| specialty_id | system_metadata | support_export |
| default_duration_minutes | org_internal | support_export |
| cover_url | public | support_export |
| video_url | public | support_export |
| published | org_internal | support_export |
| published_at | org_internal | support_export |
| is_public | org_internal | support_export |
| metadata | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export |
offering_specialists
| Column | Class | Egress |
|---|---|---|
| offering_id | system_metadata | support_export |
| specialist_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| priority | org_internal | support_export |
| created_at | system_metadata | support_export |
Custom fields (F3.1)
The clinic's reusable field library and the canonical org-scoped value store (F3.1). Both tables are state, org-scoped.
custom_fields is definitions, not data — description and options are org_internal because they describe how a clinic configures its own intake, and a competitor learning that a clinic asks about smoking history is disclosure the clinic did not choose. They are not public: unlike a specialist's professional identity or an offering's title, a field definition is never a marketing surface. key and system_key are system_metadata — stable identifiers PDF templates, exports and cross-org template copy resolve against.
label additionally carries patient_document, because a printed row needs a caption: the F6 renderer draws the clinic's own label beside the value ("Greutate (kg): 74"). The rest of the definition — description, options, is_private — stays inside the tenant. A document prints what a field holds, never how it was configured.
custom_field_values.value is clinical, and its only egress target is patient_document. It holds whatever the clinic chose to ask a patient — medication, prior surgeries, activity level — so no bulk, support, marketing or AI path has a reason to receive it. The document target is the exception because the recipient is the same clinic that wrote the value, printing it on their own patient's record (A2, 2026-08-07).
No cross-clinic scoping applies to this store, and that is not an omission. patient_profiles is portable across every clinic a patient attends, so a staff read of it has to be scoped to a patients row at the reading org. custom_field_values carries organization_id and never crosses a clinic boundary in the first place — a clinic printing these values is printing what it recorded itself.
A private field is never printed. custom_fields.is_private means staff-only, and a generated document is handed to a patient — so the resolver drops private fields regardless of document type, and the template builder never offers one. That rule is uniform across the platform: it retired the earlier carve-out (D3) under which a medical prescription printed private form answers for transparency.
A pii_regulated value can never appear here, and that is enforced by the custom_field_values_reject_regulated trigger rather than by convention — a field_type = 'national_id' field raises on INSERT/UPDATE. CNP lives once, encrypted, on patient_profiles.
entity_type / entity_id are the polymorphic pair (P24); entity_id is patients.id for entity_type = 'patient', never patient_profiles.id, because this store never crosses a clinic boundary.
custom_fields
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| entity_type | system_metadata | support_export |
| key | system_metadata | support_export |
| label | org_internal | support_export, patient_document |
| field_type | system_metadata | support_export |
| options | org_internal | support_export |
| description | org_internal | support_export |
| is_private | org_internal | support_export |
| sort_order | system_metadata | support_export |
| system_key | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
custom_field_values
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| custom_field_id | system_metadata | support_export |
| entity_type | system_metadata | support_export |
| entity_id | system_metadata | support_export |
| value | clinical | patient_document |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
Forms (F3.2 / F3.3 / F3.4)
Form templates, instances, version history and the offering junction (F3). All are org-scoped; forms and form_templates soft-delete.
form_templates.name / description and form_templates.fields are org_internal — a template is the clinic's own authoring work, and the questions it asks reveal how that clinic practises. fields additionally carries the binding map: which shared value each question reads, and whether an answer writes back. That is configuration about patient data rather than patient data itself, which is why it classifies org_internal and not clinical.
forms.values and forms.files are clinical_sensitive with no egress target. This is the free-text clinical record: what a patient answered about their medication, their pain, their history — plus, once signed, the evidence behind a consent. No bulk or support path receives it. A patient reaches their own through the DSAR route, and a clinician through the authenticated UI; neither is a classification egress target.
forms.title / description are org_internal: they come from the template, not from the patient.
fields on an instance is the snapshot — a copy of the template's arrangement, taken at first write. Same class as the template's own fields for the same reason.
form_template_versions.fields_snapshot is org_internal and append-only. It is what a rollback restores from and what answers "what exactly did v1 say" when a patient disputes a consent, so it must survive even when the live template has moved on.
offering_forms is pure configuration — which template attaches in which category of which service.
document_categories is the clinic's paperwork taxonomy and is entirely org_internal configuration. Nothing in it describes a patient: it says which kinds of document this clinic issues, what it calls them, who fills each one, and how many an offering may attach. title and description are the clinic's own words and reveal how that clinic practises — the same reasoning that makes form_templates.name org_internal rather than public — while key, filled_by, cardinality and generatable_on_appointment are structural. key additionally carries webhook_egress: it is the category_key a form.created payload already contains, and a subscriber that receives the key with no way to resolve what it means is receiving noise.
webhook_egress on the forms tables (F3, added 2026-08-04). The Cat E events form.created / .completed / .signed and form_template.published are the first real producers for the Cat C outbound-webhook framework, and a webhook body is delivered verbatim to a URL the clinic configured — so a payload field is an egress and needs a target here. The columns carrying webhook_egress are exactly the ones those four payloads contain: identifiers, the form type and status, the template name and version, and the consent declaration.
What deliberately does NOT carry it is the whole point. forms.values and forms.files are clinical_sensitive with no egress target at all — what a patient wrote about their medication never leaves through an integration. Nor does the signature evidence: signed_name is pii_basic and signed_via_ip / signed_user_agent are audit_only, and none of the three is in a payload. A subscriber that needs more calls the API with its own credentials and gets what its permissions allow, which is the right place for that decision.
Framework gap, not an F3 one. Nothing in the webhook delivery path calls
classification.AllowedFor— the registry describes what MAY egress, but the Cat C dispatcher does not consult it, and the event producers that shipped before F3 (organization,designations,portalonboarding,exercises,ownershiptransfer,pose_configs) carry payload fields whose columns have nowebhook_egresstarget. Until that check exists, these entries are a declaration of intent rather than an enforced boundary. Closing it belongs with F11 compliance hardening.
consent_purposes and signature_mode (F3.5, on the template, its version history and the instance snapshot) are org_internal: they say which ledger entries signing this document produces and how the signature is captured. That is authoring configuration, and the consent it eventually produces is a consents row, classified there.
forms.required_signature_mode is what a form DEMANDS (configuration, frozen at first write) and forms.signature_mode is what actually happened (evidence, null until signed). Both are org_internal.
The instance's signature evidence splits. forms.signed_name is the name the patient typed and is pii_basic — it is a name, and it classifies like every other name on the platform. forms.signed_via_ip and forms.signed_user_agent are audit_only: they exist to make a signature defensible if it is ever disputed, not to describe the patient. They carry support_export to match consents.granted_via_ip, which is the same fact recorded from the ledger's side of the same act — a support export that showed the IP behind the consent row but not the IP behind the form it came from would be describing one event twice and disagreeing with itself.
form_templates
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| name | org_internal | support_export, webhook_egress |
| description | org_internal | support_export |
| category_id | system_metadata | support_export, webhook_egress |
| fields | org_internal | support_export |
| version | system_metadata | support_export, webhook_egress |
| published | org_internal | support_export |
| published_at | org_internal | support_export |
| pdf_template_id | system_metadata | support_export |
| consent_purposes | org_internal | support_export |
| signature_mode | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export |
form_template_versions
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| form_template_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| version | system_metadata | support_export |
| fields_snapshot | org_internal | support_export |
| consent_purposes | org_internal | support_export |
| signature_mode | org_internal | support_export |
| published_at | system_metadata | support_export |
| changed_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
forms
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export, webhook_egress, bulk_export |
| organization_id | system_metadata | support_export, bulk_export |
| appointment_id | system_metadata | support_export, webhook_egress, bulk_export |
| form_template_id | system_metadata | support_export, webhook_egress, bulk_export |
| template_version | system_metadata | support_export, webhook_egress, bulk_export |
| fields | org_internal | support_export, bulk_export |
| patient_profile_id | system_metadata | support_export, webhook_egress, bulk_export |
| title | org_internal | support_export, bulk_export |
| description | org_internal | support_export, bulk_export |
| category_key | system_metadata | support_export, webhook_egress, bulk_export |
| values | clinical_sensitive | bulk_export |
| files | clinical_sensitive | bulk_export |
| sort_order | system_metadata | support_export, bulk_export |
| status | org_internal | support_export, webhook_egress, bulk_export |
| completed_at | org_internal | support_export, bulk_export |
| signed_at | org_internal | support_export, bulk_export |
| consent_purposes | org_internal | support_export, webhook_egress, bulk_export |
| required_signature_mode | org_internal | support_export, webhook_egress, bulk_export |
| signature_mode | org_internal | support_export, bulk_export |
| signed_name | pii_basic | support_export, bulk_export |
| signed_via_ip | audit_only | support_export |
| signed_user_agent | audit_only | support_export |
| created_by_principal_id | system_metadata | support_export |
| signed_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export |
| trigger_event | system_metadata | support_export |
document_categories
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| key | system_metadata | support_export, webhook_egress |
| title | org_internal | support_export, webhook_egress |
| description | org_internal | support_export |
| filled_by | system_metadata | support_export |
| cardinality | system_metadata | support_export |
| generatable_on_appointment | system_metadata | support_export |
| sort_order | system_metadata | support_export |
| system_key | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export |
offering_forms
| Column | Class | Egress |
|---|---|---|
| offering_id | system_metadata | support_export |
| form_template_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| category_key | system_metadata | support_export |
| category_is_single | system_metadata | support_export |
| specialist_title_id | system_metadata | support_export |
| sort_order | system_metadata | support_export |
| created_at | system_metadata | support_export |
form_sessions
Token-authorised access to ONE form, for a patient who is not logged in. The raw token is never stored — only its SHA-256, which is auth_secret because it is exactly that: a credential that authorises signing a consent.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| form_id | system_metadata | support_export |
| token_hash | auth_secret | |
| mode | system_metadata | support_export |
| expires_at | system_metadata | support_export |
| consumed_at | system_metadata | support_export |
| revoked_at | system_metadata | support_export |
| created_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
form_triggers
Clinic configuration — which paperwork fires at an event with no appointment behind it. Carries no patient reference of any kind; the forms it produces do.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| form_template_id | system_metadata | support_export |
| trigger_event | system_metadata | support_export |
| category_key | system_metadata | support_export |
| sort_order | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
Scheduling (F4)
Booking configuration and specialist availability. Six tables, all state, all org-scoped.
Availability rows are org_internal, not public. specialist_weekly_hours and specialist_schedule_overrides say when a named clinician at a named clinic is physically working, and — via location_id — where. That is the clinic's operational picture, not a marketing surface, and it is the reason both tables carry RLS despite being reachable only through a specialist. Patients never read these rows: the portal reaches availability through the derived-slot endpoint, which is a computed projection that discloses free times without disclosing the schedule that produced them.
calendars splits along the same line offerings does. name, slug and description_html are public — they are what a patient reads on a booking page, exactly like an offering's title. description_html holds SANITISED HTML and nothing else may write it: clinic-authored markup rendered to unauthenticated visitors is a stored-XSS surface, and scheduling.SanitizeDescription with its narrow allowlist is the only thing between a pasted <script> and every visitor to that clinic's booking page. Every booking RULE (slot_duration_minutes, slot_gap_minutes, cooldown_minutes, min_lead_time_minutes, horizon_days, assignment_strategy) is org_internal: a competitor learning that a clinic runs 20-minute slots with a 48-hour cooldown is disclosure the clinic did not choose. The patient-facing consequences of those rules reach the portal as computed slots and structured errors, never as the settings themselves.
specialist_assignment_tracking is org_internal throughout. How a clinic distributes work between its clinicians is an internal management fact.
No table here has a webhook_egress target. F4 publishes no Cat E events yet; when it does, the payload fields need targets added in the same PR, because a webhook body is delivered verbatim to a clinic-configured URL (the rule F3 established).
specialist_weekly_hours
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| specialist_id | system_metadata | support_export |
| day_of_week | org_internal | support_export |
| start_time | org_internal | support_export |
| end_time | org_internal | support_export |
| location_id | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
specialist_schedule_overrides
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| specialist_id | system_metadata | support_export |
| calendar_id | system_metadata | support_export |
| start_date | org_internal | support_export |
| end_date | org_internal | support_export |
| availability | org_internal | support_export |
| location_id | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
calendars
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| offering_id | system_metadata | support_export |
| name | public | support_export |
| slug | public | support_export |
| description_html | public | support_export |
| location_id | org_internal | support_export |
| slot_duration_minutes | org_internal | support_export |
| slot_gap_minutes | org_internal | support_export |
| cooldown_minutes | org_internal | support_export |
| min_lead_time_minutes | org_internal | support_export |
| horizon_days | org_internal | support_export |
| slots_open_at | org_internal | support_export |
| slots_close_at | org_internal | support_export |
| assignment_strategy | org_internal | support_export |
| published | org_internal | support_export |
| published_at | system_metadata | support_export |
| is_public | org_internal | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export |
calendar_specialists
| Column | Class | Egress |
|---|---|---|
| calendar_id | system_metadata | support_export |
| specialist_id | system_metadata | support_export |
| offering_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| priority | org_internal | support_export |
| created_at | system_metadata | support_export |
calendar_forms
| Column | Class | Egress |
|---|---|---|
| calendar_id | system_metadata | support_export |
| form_template_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| category_key | system_metadata | support_export |
| category_is_single | system_metadata | support_export |
| sort_order | system_metadata | support_export |
| created_at | system_metadata | support_export |
specialist_assignment_tracking
| Column | Class | Egress |
|---|---|---|
| calendar_id | system_metadata | support_export |
| specialist_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| last_assigned_at | org_internal | support_export |
| assignment_count | org_internal | support_export |
F5 Appointments
appointments
contact_name, contact_email and contact_phone are pii_basic — the same posture as humans.email and organization_invites.email, plaintext per decisions.md → Why most PII is plaintext. All three hold what a person typed when booking, before they onboarded at the clinic; once patient_id links, the authoritative copy is on patient_profiles and these go NULL.
cancellation_reason is clinical_sensitive rather than clinical, and it is the one column here blocked from bulk_export. It is free text a patient or clinician typed about why a medical appointment did not happen; in practice that is where a diagnosis, a bereavement or a symptom lands. The structured cancellation attribution (status, cancelled_at, cancelled_by_principal_id) carries everything an export legitimately needs, so the prose adds risk without adding meaning.
booking_client_id is support_export only. It is a pseudonymous device/browser identifier that keys the public-booking cooldown — a cross-booking correlator by construction, which is precisely why it must not reach an analytics or webhook target.
webhook_egress on appointments (N3, added 2026-08-25). The Cat E events appointment.booked / .rescheduled / .cancelled / .completed / .noshow are the second real producers for the Cat C outbound-webhook framework, and a webhook body is delivered verbatim to a URL the clinic configured — so a payload field is an egress and needs a target here. The columns carrying it are exactly the ones those payloads contain: identifiers, the delivery channel, the instant and duration, and the status with its transition timestamps.
What is deliberately absent is the point. contact_name, contact_email and contact_phone are what a person typed when booking and never travel — a subscriber that needs to reach a patient asks the API with its own credentials and gets whatever its permissions allow, which is where that decision belongs. cancellation_reason is free text where a diagnosis or a bereavement lands. booking_client_id is a cross-booking correlator by construction. And protocol_id / session_id stay out because "this appointment belongs to that course of treatment" is clinical linkage the payload has no need of.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export, webhook_egress |
| organization_id | system_metadata | bulk_export, support_export, webhook_egress |
| patient_profile_id | system_metadata | bulk_export, support_export, webhook_egress |
| patient_id | system_metadata | bulk_export, support_export |
| specialist_id | system_metadata | bulk_export, support_export, webhook_egress |
| offering_id | system_metadata | bulk_export, support_export, webhook_egress |
| calendar_id | system_metadata | bulk_export, support_export, webhook_egress |
| location_id | org_internal | bulk_export, support_export |
| contact_name | pii_basic | bulk_export, support_export |
| contact_email | pii_basic | bulk_export, support_export |
| contact_phone | 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, webhook_egress |
| scheduled_at | clinical | bulk_export, support_export, patient_document, webhook_egress |
| duration_minutes | clinical | bulk_export, support_export, webhook_egress |
| started_at | clinical | bulk_export, support_export |
| ended_at | clinical | bulk_export, support_export |
| status | clinical | bulk_export, support_export, webhook_egress |
| cancelled_at | clinical | bulk_export, support_export, webhook_egress |
| cancellation_reason | clinical_sensitive | support_export |
| cancelled_by_principal_id | system_metadata | support_export |
| noshow_at | clinical | bulk_export, support_export, webhook_egress |
| noshow_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 |
appointment_files
Patient- and staff-uploaded documents attached to a consultation — imaging the patient brought, a scan the clinic took, a referral letter.
file_name is clinical_sensitive, and it is the column that decides this table's posture. A filename the patient chose is free text about a medical document, and in practice it is where the diagnosis lands: RMN-hernie-L4-L5.pdf, analize-oncologie.jpg. Treating it as an innocuous label because it is "just metadata" is exactly how a bulk export leaks a diagnosis, so it carries the same class and the same single egress target as appointments.cancellation_reason.
file_url is clinical_sensitive and blocked from every export. It is an S3 object key: not merely a pointer to clinical content but the addressable location of it, and a key that escapes the tenant is a key that outlives every access check the platform makes. Nothing legitimately needs it — an export that should carry the bytes resolves them server-side and embeds them; it does not ship the address and hope.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| appointment_id | system_metadata | bulk_export, support_export |
| file_url | clinical_sensitive | |
| file_name | clinical_sensitive | support_export |
| file_type | system_metadata | bulk_export, support_export |
| file_size | system_metadata | bulk_export, support_export |
| kind | system_metadata | bulk_export, support_export |
| uploaded_by_principal_id | system_metadata | support_export |
| deleted_at | system_metadata | bulk_export, support_export |
| created_at | system_metadata | bulk_export, support_export |
video_room_participants
Maps the opaque provider-visible participant reference back to a principal.
participant_ref is auth_secret for the same reason room_ref is: it is what the provider was told, and pairing it with a principal here is precisely the correlation the opacity exists to prevent elsewhere. It never leaves.
The table as a whole says "this person was given a way into this consultation", which is why principal_id carries only the support target — a bulk export listing who attended whose consultation is a disclosure about the STAFF, not only the patient.
| Column | Class | Egress |
|---|---|---|
| video_room_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| participant_ref | auth_secret | |
| principal_id | system_metadata | support_export |
| first_granted_at | system_metadata | support_export |
appointment_notes
Staff-only notes on a consultation, including the pre-call technical check.
body is clinical, and the reasoning is the point rather than the class. A technical check is not a clinical act — somebody rang to see whether the camera works. But this is free text a staff member writes ABOUT a patient, and what a person types cannot be bounded: "patient is hard of hearing, use captions" is health information however operational the intent was. Classing it org_internal would be classifying the intent instead of the contents.
STAFF-ONLY IS A VISIBILITY RULE, NOT A DSAR EXEMPTION. The table has no patient RLS branch — deliberately, so a clinic can be candid in its own log — but a subject-access request still reaches personal data held about a patient. Which notes an export carries is an F11 decision, and bulk_export is left empty here to force that decision rather than pre-empt it.
outcome is clinical for a narrower reason: "unreachable" twice before a consultation says something about a patient's circumstances, and it is exactly the field a bulk export would carry without anyone thinking about it.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| appointment_id | system_metadata | support_export |
| kind | system_metadata | support_export |
| outcome | clinical | support_export |
| body | clinical | support_export |
| author_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
F5.5 Video Consultations
The substrate for remote consultations (000049): video_rooms is the room that exists, video_session_events is what happened inside it.
video_rooms
Two columns carry the weight, and both are addresses rather than content.
room_ref is auth_secret. It is not clinical data — it is the opaque name that, presented to the provider, admits the bearer to a live medical consultation. That is the same shape as a webhook signing secret, and it takes the same posture: excluded from every egress target, never logged, never serialised. The wire type the API returns has no field for it, so a projection cannot regain the value by someone adding a line — the F5.4 hold-broadcast lesson applied ahead of the leak rather than after it. leo stores the appointment uid here and puts it in URLs and emails, which is what makes G14 a permanent capability rather than a bug.
provider_room_id gets the same treatment for the same reason: with the platform's API key it addresses the same room, and nothing outside the adapter has a use for it.
last_error is org_internal and support-only, with a handling rule the class alone does not express: it is a provider error string, and a provider error string can quote the room name it failed on. Whatever writes it sanitises first. A column that exists to explain a failure must not become the exfiltration path for the secret the failure was about.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| appointment_id | system_metadata | bulk_export, support_export |
| provider | system_metadata | support_export |
| provider_room_id | auth_secret | |
| room_ref | auth_secret | |
| not_before_at | system_metadata | bulk_export, support_export |
| expires_at | system_metadata | bulk_export, support_export |
| status | system_metadata | bulk_export, support_export |
| last_error | org_internal | 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 |
video_session_events
The append-only record of a consultation's call. The columns are individually mundane and the table collectively is not: it says this person was in a medical consultation at this time, which is health-adjacent metadata even though no diagnosis appears in it. Patient-facing portability carries it (it is the patient's own attendance) and nothing else does.
payload is clinical_sensitive with no egress target at all, and the reason is that we do not control its shape. It is the provider's raw webhook body, retained for forensics and read by nothing — a third party's blob whose fields can change without notice, and which may well carry a display name the provider derived. A column whose contents are defined by someone else cannot be permitted to leave; classifying it optimistically would be classifying a future version of it.
participant_ref is opaque by construction — it is the reference the platform sent, never a name or an email — which is what lets attribution work without putting patient identity into a third party's event log.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| video_room_id | system_metadata | support_export |
| appointment_id | system_metadata | bulk_export, support_export |
| provider | system_metadata | support_export |
| provider_event_id | system_metadata | support_export |
| event_type | system_metadata | bulk_export, support_export |
| participant_ref | system_metadata | support_export |
| principal_id | system_metadata | support_export |
| occurred_at | system_metadata | bulk_export, support_export |
| received_at | system_metadata | support_export |
| payload | clinical_sensitive |
clinical_captures
One clinician-performed capture during a consultation — today a posture-grid photograph. The bytes live in appointment_files; this row is what they mean.
The table is clinical as a whole even though no column contains a diagnosis: it records that a clinician photographed a patient's body during a consultation, and the existence of that act is health data about a person. The patient's own portability archive carries it; nothing else does.
settings is org_internal rather than clinical. It describes the tool (grid theme, opacity, whether the overlay was drawn), not the patient — the same distinction pdf_templates.editor_state draws between what a document will say about anyone and what is true of one person.
tool_version earns its row in this table for a reason the class does not express: it is the only thing that makes an artefact interpretable later. It is dull metadata that becomes load-bearing the moment anything is derived from a capture.
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| appointment_id | system_metadata | bulk_export, support_export |
| appointment_file_id | system_metadata | bulk_export, support_export |
| tool | clinical | bulk_export, support_export |
| tool_version | system_metadata | bulk_export, support_export |
| video_room_id | system_metadata | support_export |
| settings | org_internal | support_export |
| captured_by_principal_id | system_metadata | support_export |
| captured_at | system_metadata | bulk_export, support_export |
| created_at | system_metadata | bulk_export, support_export |
F6.1 PDF Templates
The block-based template builder (000047). All three tables are org-scoped configuration; pdf_templates soft-deletes, pdf_template_versions is append-only.
editor_state and its version snapshot are org_internal, and the reasoning matters more than the class. A block list is the clinic's own authoring work — its layout, its letterhead, and above all the rich_text blocks, which are clinician-authored prose (dietary protocols, lab panels, treatment advice) representing real professional effort. It is not clinical: it describes what a document will say about any patient, never what is true of one. No patient value is stored here. The values arrive at render time and land in appointment_documents, where they are classified.
The same distinction governs patient_details block config. A block declaring fields: ["name", "cnp"] states which columns a rendered document will read — a field list, not field values — so it classifies with the template. The values those keys resolve to are decided at render time by classification.AllowedFor and the renderer's own org-scoped query, never by what the template asked for. A template cannot widen its own egress, which is the property that makes storing the field list safe at org_internal.
requires_national_id is org_internal on both the template and its version snapshot, and it exists ONLY here — the form-side twin was removed, because a form field's presence already declares that the form collects a CNP, whereas this flag decides something the layout cannot: whether the value may be decrypted at all. It is a posture flag — "documents from this template may carry a CNP" — and carrying it in version history is what lets a rollback restore the posture along with the blocks, instead of restoring layout under whatever flag happens to be set today.
No webhook_egress anywhere in this section, deliberately. No Cat E event carries a template body, and a block list delivered verbatim to a clinic-configured URL would ship rich_text prose the clinic may never have published to anyone. A subscriber that needs template detail calls the API with its own credentials and receives what its permissions allow.
offering_documents
Pure configuration, and the mirror of offering_forms: which PDF layouts a service produces, and which professional title may generate each. specialist_title_id is the doctor-versus-therapist rule; it names a specialist_titles row and describes no patient.
| Column | Class | Egress |
|---|---|---|
| offering_id | system_metadata | support_export |
| pdf_template_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| specialist_title_id | system_metadata | support_export |
| sort_order | system_metadata | support_export |
| created_at | system_metadata | support_export |
pdf_templates
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| name | org_internal | support_export |
| description | org_internal | support_export |
| category_id | system_metadata | support_export |
| requires_signature | system_metadata | support_export |
| editor_state | org_internal | support_export |
| layout_config | org_internal | support_export |
| version | system_metadata | support_export |
| published | org_internal | support_export |
| published_at | org_internal | support_export |
| requires_national_id | org_internal | support_export |
| created_by_principal_id | system_metadata | support_export |
| updated_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
| deleted_at | system_metadata | support_export |
pdf_template_versions
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| pdf_template_id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| version | system_metadata | support_export |
| editor_state_snapshot | org_internal | support_export |
| layout_config_snapshot | org_internal | support_export |
| requires_national_id | org_internal | support_export |
| requires_signature | org_internal | support_export |
| published_at | org_internal | support_export |
| changed_by_principal_id | system_metadata | support_export |
| change_notes | org_internal | support_export |
| created_at | system_metadata | support_export |
pdf_template_components
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | support_export |
| organization_id | system_metadata | support_export |
| name | org_internal | support_export |
| description | org_internal | support_export |
| blocks | org_internal | support_export |
| category | system_metadata | support_export |
| created_by_principal_id | system_metadata | support_export |
| created_at | system_metadata | support_export |
| updated_at | system_metadata | support_export |
F6.3 Appointment documents
The generated artifact (000048) — a report or medical prescription rendered from a signed form and a frozen pdf_templates version.
document_url is clinical_sensitive and blocked from every export, matching appointment_files.file_url exactly. It is an S3 object key: not merely a pointer to clinical content but the addressable location of it, and a key that escapes the tenant outlives every access check the platform makes. Nothing legitimately needs it — an export that should carry the bytes resolves them server-side and embeds them; it does not ship the address and hope.
title is clinical rather than org_internal. It comes from the template in the ordinary case, but a clinician can name a document, and a title like "Raport evaluare — hernie de disc L4-L5" is a diagnosis written on the outside of the envelope. Classifying it by where it usually comes from rather than by what it can contain is how clinical text ends up in a bulk export.
type, published, pdf_template_version and the supersession pair are system_metadata / org_internal — they describe the document's lifecycle, not its contents. pdf_template_version carries support_export because "which layout produced this" is exactly the question a support investigation asks, and the answer discloses nothing about the patient.
metadata is org_internal and non-clinical by contract — generation diagnostics (render duration, block count, renderer version) and nothing else. A clinical value may never be written here; promote it to a typed column the moment a surface needs one.
optional_choices is that promotion, and it is why it is clinical rather than sitting beside the diagnostics. It records which optional sections (C2) the clinician chose to print and which they were offered and declined, so it describes what a medical document CONTAINS — and a clinic's own label for a section ("Anexă — evaluare psihologică") can say as much about the patient as the section would. support_export only, like title.
No webhook_egress anywhere in this section, deliberately. No Cat E event carries a document, and a clinic-configured URL is not somewhere a patient's report goes. A subscriber that needs one calls the API with its own credentials and receives what its permissions allow.
appointment_documents
| Column | Class | Egress |
|---|---|---|
| id | system_metadata | bulk_export, support_export |
| organization_id | system_metadata | bulk_export, support_export |
| appointment_id | system_metadata | bulk_export, support_export |
| form_id | system_metadata | bulk_export, support_export |
| category_key | system_metadata | bulk_export, support_export |
| pdf_template_id | system_metadata | support_export |
| pdf_template_version | system_metadata | support_export |
| title | clinical | support_export |
| document_url | clinical_sensitive | |
| published | org_internal | bulk_export, support_export |
| published_at | org_internal | bulk_export, support_export |
| generated_by_principal_id | system_metadata | support_export |
| published_by_principal_id | system_metadata | support_export |
| superseded_at | system_metadata | bulk_export, support_export |
| superseded_by_id | system_metadata | support_export |
| optional_choices | clinical | support_export |
| metadata | org_internal | support_export |
| created_at | system_metadata | bulk_export, support_export |
| updated_at | system_metadata | bulk_export, support_export |