Skip to content

Master Data Model

The complete entity-relationship model for the platform, derived from every feature spec in apps/docs/features/. This is the single source of truth for what tables will exist across all 16 Areas, what columns they have, what relationships connect them, and what cross-cutting patterns apply.

Scope and shipped status. This doc covers both shipped schema (Area 1 — Foundation, partially shipped through 1A + 1B.1–1B.10 + 1C partial; see implementation-plan/foundation.md for which sub-phases have closed) and planned schema (Areas 2–16, scoped here so the foundation lands them correctly when their layer ships). For the canonical current schema, the migrations under services/api/migrations/core/ are authoritative; this doc is forward-looking design and may run ahead of code on unshipped Areas.

Why this exists. The phased implementation plan was sequencing features without a holistic view of the schema. That risks rework: every late feature surfaces an entity or column the foundation should have included. This doc is the holistic view — read every feature spec once, model every entity, decide once.

Companion docs. patterns.md catalogs every cross-cutting pattern the model relies on. dependency-map.md sequences the implementation order.


Cross-Cutting Conventions (apply to every table)

These are decided once, applied everywhere. See patterns.md for full definitions.

ConventionRule
Primary keysUUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(). Go-side: UUIDv7 via uuid.NewV7(). (See "Schema reconciliation" below — feature spec docs use BIGSERIAL; that is out of date.)
Tenant columnorganization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE. Always indexed: CREATE INDEX idx_{table}_org ON {table}(organization_id).
RLSEnabled on every tenant table. Policy template: WHERE organization_id = current_app_org_id() for SELECT, plus current_app_has_permission(resource, action) for mutations. See P1, P3, P4 in patterns.
Timestampscreated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() with trigger_set_updated_at().
Soft deletedeleted_at TIMESTAMPTZ on clinical tables (P13). Repos default-filter WHERE deleted_at IS NULL.
MoneyDECIMAL(10,2) + currency TEXT DEFAULT 'RON'. Never floats.
EncryptionSensitive PII columns are BYTEA, named _encrypted suffix, AES-256-GCM via internal/core/crypto/ (P12).
TranslationsGlobal content tables get translations JSONB NOT NULL DEFAULT '{}'. Org-scoped tables don't. (P21)
JSONBSnapshots immutable. Query-needed JSONB indexed with GIN + jsonb_path_ops. (P25)

Entity Catalog by Area

Area 1: Foundation (Org, Principal, Auth, RBAC)

The actor model is principals as the root identity: every actor in the system — human, AI agent, integration service account, scheduled system job — is a row in principals. Profile data lives in a sibling table per actor type (humans, agents, service_accounts). Audit, RLS, RBAC, and every domain reference principals.id. There is no users table; humans are not privileged in the schema. See decisions.md → Why principals as the root identity for the full rationale.

The platform-level identity and authorization model. Already implemented for the most part; included here for completeness.

organizations

The root of multi-tenancy. Every tenant table FKs to this.

ColumnTypeNotes
idUUID PK
nameTEXT NOT NULL
slugTEXT NOT NULL UNIQUEURL-safe, used by domain routing
tagline, descriptionTEXT
email, phone, website, locationTEXT
logo_url, icon_urlTEXTS3 keys
language_codeTEXT NOT NULL DEFAULT 'en'ISO 639-1, drives translations (P21)
portal_self_signup_enabledBOOLEAN NOT NULL DEFAULT FALSEPer-clinic toggle for portal walk-up signup (P22).
brandingJSONB NOT NULL DEFAULT '{}'White-label branding payload (colors, theme, footer_text, etc.). Read as a blob by public-resolve.
tenancy_modeTEXT NOT NULL DEFAULT 'shared' CHECK (tenancy_mode IN ('shared', 'dedicated'))Tenancy topology — see Tenant Isolation. shared (default) = pooled platform infrastructure with logical isolation (RLS, prefix scoping, app-layer entitlement checks). dedicated = reserved for future per-tenant Clerk org + addon mechanisms (own S3 bucket, own CMK); not yet provisionable via API — every creation path lands shared today.
activated_atTIMESTAMPTZ NULLLifecycle gate. NULL = draft (not routable from public endpoints); non-NULL = active. Today every creation path sets activated_at = NOW() because dedicated mode is not yet provisionable. Column is preserved as a reservation for when dedicated-mode provisioning ships and creation needs a draft window before infrastructure provisioning completes.
created_at, updated_atTIMESTAMPTZ

Indexes. idx_organizations_draft (id) WHERE activated_at IS NULL is preserved — keeps the draft-state filter cheap regardless of how many orgs exist; cardinality stays tiny while only the dedicated-provisioning flow produces draft rows.

RLS. Members see their org. Updates gated by organizations.update. Inserts/deletes by superadmin only (AdminPool). Public-resolve. GET /v1/public/organizations/resolve returns 404 for draft orgs (activated_at IS NULL). Owner first-login bind and welcome-email queuing also gate on activated_at IS NOT NULL.

organization_domains

Custom domains per clinic (universal — available on both shared and dedicated tenancy modes as a visual-branding customization).

ColumnTypeNotes
idUUID PK
organization_idUUID FK
domainTEXT NOT NULL UNIQUE
domain_typeenum domain_type`clinic
statusenum domain_status`pending
verification_tokenTEXT NOT NULLDNS-01 token written to TXT record
verified_at, last_check_atTIMESTAMPTZ NULLlast_check_at is updated on every verify attempt (success or failure)
created_at, updated_atTIMESTAMPTZ

RLS. Org members see their domains. Mutations gated by organizations.manage_domains. Public-resolve policy reads verified rows when no session vars set.

organization_integrations (Foundation 1C.5 — design pending)

Per-org third-party API credentials for Connected Accounts (Cat B) — clinic-owned external accounts (Google Calendar, Slack, HubSpot, ...) the clinic configures via OAuth or API key. Sensitive. Not yet shipped — no migration creates this table. Conceptually a foundation companion table alongside organization_settings / _billing / _capabilities; the schema design is deferred to a dedicated discussion chat. The first OAuth-based clinic integration will be the first CONSUMER. See foundation.md § 1C.5 (Connected Accounts — Cat B) for the framing and glossary.md → Integration categories for the canonical Cat A vs Cat B distinction. The table sketch below is illustrative — the canonical design lands when 1C.5 implements. The 1C.5 sub-phase additionally introduces the platform-defined integration_services catalog (the list of available Cat B services, their OAuth/API-key shape, scopes, etc.) — schema also pending.

Sibling but distinct concern. Cat A platform-curated providers (SES, Daily.co, Twilio, Anthropic, ...) do NOT use organization_integrations. They resolve through the foundation platform_service_providers table (Foundation 1C.2) — credentials seeded from env / Secrets Manager with optional per-org override rows (e.g., a clinic with its own verified SES sender domain on either tenancy mode). Same encryption posture (P12), different scope (platform-default vs clinic-owned). Daily.co video at § 6.5 is a Cat A consumer of platform_service_providers, not organization_integrations.

ColumnTypeNotes
idUUID PK
organization_idUUID FK
titleTEXTHuman-readable name
integration_service_idUUID FK → integration_services(id)Catalog reference (catalog itself ships at 1C.5)
credentials_encryptedBYTEA NOT NULLOAuth tokens / API keys, AES-256-GCM (P12)
created_at, updated_atTIMESTAMPTZ

RLS. Admin only for read + write. Field-level encryption applied at repo layer.

organization_settings

Per-org operational and compliance knobs. 1:1 with organizations (PK = organization_id). Auto-created with defaults via trigger on organizations INSERT. See org-settings.md.

ColumnTypeNotes
organization_idUUID PK FK → organizations(id) ON DELETE CASCADE1:1
marketing_email_enabledBOOLEAN NOT NULL DEFAULT FALSEOrg-level kill-switch. Layered on top of per-patient consent (P17).
marketing_sms_enabledBOOLEAN NOT NULL DEFAULT FALSESame shape, SMS channel.
audit_retention_monthsINT NULLOverride of platform default (≥ 6 yr per CLAUDE.md). NULL = platform default. CHECK ≥ 72.
default_timezoneTEXT NULLIANA (e.g., Europe/Bucharest). Org-wide fallback for scheduling display when neither location nor specialist supplies one. NULL = platform default (Europe/Bucharest for the RO launch). See P23 for the full resolution chain.
support_localeTEXT NULLISO 639-1; locale for support emails when different from organizations.language_code.
feature_flagsJSONB NOT NULL DEFAULT '{}'Internal staged-rollout flags (engineering kill-switches). Not plan-driven entitlements — those live in organization_subscription_entitlements (Area 16).
created_at, updated_atTIMESTAMPTZ

RLS. SELECT for org members. UPDATE gated by organizations.update_settings. INSERT/DELETE blocked at policy layer (trigger-only).

organization_billing

Per-org billing pointers and contact. 1:1 with organizations. Auto-created on org provisioning. Regulated financial-data class — read access is itself audited.

ColumnTypeNotes
organization_idUUID PK FK1:1
current_tier_idUUID FK → plans(id) NULLDenormalized pointer to the org's current base plan for fast admin-UI lookup. Canonical source is organization_subscriptions (Area 16).
billing_emailTEXT NULLWhere invoices and dunning go. Distinct from organizations.email.
billing_contact_nameTEXT NULL
billing_address_line1, billing_address_line2, billing_city, billing_postal_codeTEXT NULLStructured fields, not freeform — required for tax invoicing in RO.
billing_countryTEXT NULLISO 3166-1 alpha-2.
tax_id_encryptedBYTEA NULLCUI for RO clinics. AES-256-GCM (P12) — tax IDs are PII in EU jurisdictions.
currencyTEXT NOT NULL DEFAULT 'RON'Billing currency for this org's invoices.
external_customer_idTEXT NULLStripe / Chargebee customer ID. NULL until billing system wires up.
payment_providerTEXT NOT NULL DEFAULT 'manual'`manual
created_at, updated_atTIMESTAMPTZ

RLS. SELECT/UPDATE gated by organizations.manage_billing. INSERT/DELETE blocked.

organization_entitlements

Per-org regulated entitlement gates. The only read surface for clinical/regulated code (see middleware-composition.md § Regulated boundary). 1:1 with organizations. Defaults all FALSE — fail-closed regulatory posture.

ColumnTypeNotes
organization_idUUID PK FK1:1
telerehab_enabledBOOLEAN NOT NULL DEFAULT FALSEUnlocks treatment plans, exercise prescription, telerehab patient flows.
treatment_plans_enabledBOOLEAN NOT NULL DEFAULT FALSESubset of telerehab — a clinic can have plans without exercise videos.
video_consultations_enabledBOOLEAN NOT NULL DEFAULT FALSEDaily.co / WebRTC integration unlock.
pose_estimation_enabledBOOLEAN NOT NULL DEFAULT FALSECamera-based measurement (likely Class IIa per CLAUDE.md).
created_at, updated_atTIMESTAMPTZ

RLS. SELECT for org members (read on every clinical request via current_app_has_org_entitlement(entitlement_code)). No UPDATE policy — AppPool has zero write access. Only AdminPool (superadmin) writes. This is the single trust boundary into the regulated read surface. INSERT/DELETE blocked.

Audit. Every UPDATE uses action_context = 'org_entitlement_change' for distinct retention/alerting. New entitlements are column adds (typed, queryable, defaults FALSE).

locations

Physical locations (branches / sites) under an organization. Org owns 1..N locations. Org may have zero locations — a pure-telerehab clinic operates without any physical premises and all appointments carry location_id = NULL. Locations never cross orgs. Per-location entitlements are explicitly out of scope; entitlements stay org-wide. See P40: Locations as Logistics Layer.

ColumnTypeNotes
idUUID PK
organization_idUUID NOT NULL FK → organizations(id) ON DELETE CASCADE
slugTEXT NOT NULLLowercase, URL-safe (^[a-z0-9]+(-[a-z0-9]+)*$ at the service layer; auto-lowercased + trimmed on input). Unique per (organization_id, slug). Mutable — unlike organizations.slug (which lives in DNS hostnames), location slugs only appear in deep paths like /locations/main-floor; renaming costs at most a 404 on a stale bookmark. FKs use UUIDs.
nameTEXT NOT NULLDisplay name (e.g., Centru, Băneasa)
timezoneTEXT NULLIANA (e.g., Europe/Bucharest). NULL = inherit from organization_settings.default_timezone. See P23.
phoneTEXT NULLPublic contact at this location
emailTEXT NULLPublic contact at this location
address_line1TEXT NULLStructured — never freeform single-line.
address_line2TEXT NULL
cityTEXT NULL
countyTEXT NULL
postal_codeTEXT NULL
countryTEXT NULLFree TEXT — no ISO 3166-1 enforcement at this layer. Constraint can be added later non-breakingly when a UI form renders a country picker.
statusTEXT NOT NULL DEFAULT 'active''active' | 'inactive' | 'closed'. closed is terminal — service rejects transitions out (re-opening means a new row). inactive is reversible (renovation / lease pending / seasonal). Historical appointments referencing closed locations remain queryable.
closed_atTIMESTAMPTZ NULLAuto-stamped to clock_timestamp() when status flips to 'closed'. CHECK pins closed_at non-NULL iff status = 'closed'.
created_at, updated_atTIMESTAMPTZ
Unique(organization_id, slug)per-org slug uniqueness

Indexes. idx_locations_org ON locations(organization_id); partial idx_locations_active ON locations(organization_id) WHERE status = 'active' for booking-time lookups.

RLS. SELECT for org members. Mutations gated by locations.manage permission (granted to admin only by default in 000014 — not specialist, not customer_support). All staff in the org see all locations — no per-location RBAC scoping in v1; deferred until a real customer requires it (see P40).

Audit. Standard P10. No audit_log.location_id column — audit rows reach a location via the appointment / calendar / specialist row they reference.

Lifecycle (DELETE vs. close). Locations are configuration data, not clinical PHI — P13's "never hard-delete" applies to clinical tables, not configuration (see P13's "Tables that do NOT need it" list: services, calendars, form_templates, etc.). The DELETE endpoint is exposed for the rare "created in error, never used" case; the canonical retire-a-location flow is PATCH ... {status: "closed"} which preserves audit-trail clarity for any historical appointments / calendars / specialists already linked. Once Layer 2 ships and specialist_locations / calendars.location_id / appointments.location_id reference this table, DELETE will RESTRICT naturally on dependent rows — that's the intended steady-state behaviour.

Forward references (added in the same migration where each consuming table ships):

  • specialist_locations(specialist_id, location_id) — many-to-many; specialists rotate across locations.
  • specialist_weekly_hours.location_id NULL — NULL = remote/telerehab availability slot.
  • specialist_schedule_overrides.location_id NULL — same convention.
  • calendars.location_id NULL — calendars may be pinned to a location ("Centru initial assessment") or org-level virtual ("Telerehab follow-up").
  • appointments.location_id NULL — set for in-person; NULL for telerehab/video sessions.

The "one true availability per specialist" invariant — a specialist physically cannot be in two places at once — is enforced at the DB layer when the specialist availability tables ship. See P40.

principals

Root identity registry. Every actor in the system — human, AI agent, integration service account, scheduled system job — has exactly one row here. Doesn't carry profile data; profile data lives in the type-specific sibling table (humans, agents, service_accounts).

ColumnTypeNotes
idUUID PK
principal_typeTEXT NOT NULL'human' | 'agent' | 'service_account' | 'system' (CHECK)
parent_principal_idUUID FK NULL → principals(id) ON DELETE RESTRICTDelegation chain: "this principal acts on behalf of another" (e.g., agents acting under a specialist's standing authorization). Self-referential; no cascade so deleting the parent doesn't silently orphan delegated children. Unused until first delegation feature lights it up.
created_atTIMESTAMPTZ
deleted_atTIMESTAMPTZ NULLSoft-delete for departed actors; the row stays as a tombstone for audit referential integrity

There is no principals.organization_id. The tenant binding for non-human actors lives on the actor-type child tables (agents.organization_id, service_accounts.organization_id — both NOT NULL). Humans are multi-org via organization_memberships (and patient-side patients); the system singleton is platform-level. Putting the column on the child tables keeps the schema's column shape aligned with the actor's nature: humans never accidentally get an org binding, agents and service accounts always do — no trigger needed to enforce the asymmetry.

RLS. SELECT for self only — cross-tenant visibility of other principals runs through the actor-type child tables (humans / agents / service_accounts), each with its own policy keyed on the relationship that grants visibility. INSERT/UPDATE/DELETE only via AdminPool / trigger fan-out — no AppPool write policy. Same protection as roles / permissions.

Seeded. Migration creates a singleton 'system'-type principal (well-known UUID 00000000-0000-0000-0000-000000000001). Used as actor_id for unauthenticated paths, scheduled jobs, and external webhook handlers (Stripe, Daily.co, Twilio) — so audit rows always have a real actor, never NULL, never a fake row in humans.

humans

Externally-authenticated human profile (Clerk JWT today; the verifier package is provider-agnostic — see auth/doc.go). Replaces the legacy users table. Primary key is principal_id (FK to principals.id, ON DELETE CASCADE). All auth-aware code paths key off humans.

ColumnTypeNotes
principal_idUUID PK FK → principals(id) ON DELETE CASCADESame UUID as the principal row
provider_subject_idTEXT UNIQUENullable until provisioned. Provider-agnostic — JWT sub claim for Clerk / OIDC verifiers, or whatever a future provider surfaces.
provider_org_idTEXT NULLAuth-provider organisation identifier. Reservation column — always NULL today; populated only when dedicated mode ships its per-tenant Clerk org provisioner. See features/platform/tenant-isolation.md.
emailTEXT NOT NULLUnique per (email, provider_org_id) via partial composite unique index humans_email_provider_org_unique (NULLS NOT DISTINCT). Today provider_org_id IS NULL for every row so the effective uniqueness is global on email; the composite shape future-proofs for dedicated mode without a migration.
confirmed, blockedBOOLEAN
last_activityTIMESTAMPTZBump on every authenticated request (P35)
created_at, updated_atTIMESTAMPTZ

Active-org derivation. No cached "current org" column. The "default org on first sign-in with no hostname context" is derived as MAX(last_used_at) across the principal's organization_memberships (staff) and patients (patient) rows. Patients are multi-org via patients, never through organization_memberships; the last_used_at column on patients mirrors the one on organization_memberships. Hostname-based routing ({slug}.clinic.restartix.pro / {slug}.portal.restartix.pro) carries the active-org choice for every authenticated request, so this derivation only matters at first login.

RLS. Self-read; admins read humans in their org via subquery (joining through organization_memberships); superadmin via AdminPool.

Atomic provisioning. The auth-provider webhook handler (Clerk today) inserts principals (type='human') + humans in one transaction. No trigger fan-out for identity — the provider → human path is a single domain operation.

agents

AI agent profile. Sibling table to humans. Single-org by design — agents.organization_id (NOT NULL FK to organizations(id)) is the canonical tenant binding. Per-org role grant lives in organization_memberships like staff humans, with a trigger constraint that non-human principals hold at most one membership and that membership's organization_id matches the corresponding agents.organization_id (or service_accounts.organization_id). Each (org, agent-name) pair is its own principal + agents row — agents are never shared across tenants.

ColumnTypeNotes
principal_idUUID PK FK → principals(id) ON DELETE CASCADESame UUID as the principal row
name, descriptionTEXT
model_providerTEXT NOT NULL'anthropic' | 'openai' | ... — denormalized today; reference to SOUP list (1.16+) added later
model_nameTEXT NOT NULLe.g. 'claude-opus-4-7'
model_versionTEXT NULLPinned version, NULL = latest
scopeTEXT NULLApp-interpreted scope marker; first concrete agent feature defines structured shape if needed
system_prompt_refTEXT NULLPointer (S3 key, row id, git ref) — storage decided per-feature
configurationJSONB NOT NULL DEFAULT '{}'Per-feature parameters
enabledBOOLEAN NOT NULL DEFAULT TRUEPause/resume without deleting
deleted_atTIMESTAMPTZ NULLSoft delete
created_at, updated_atTIMESTAMPTZ

RLS. SELECT: visible to org members (joins to principals to check organization_id = current_app_org_id()). INSERT/UPDATE/DELETE: AdminPool only until the first agent-management feature ships its own permission codes + policies.

Indexes. idx_agents_active ON agents(principal_id) WHERE deleted_at IS NULL; idx_agents_model ON agents(model_provider, model_name).

service_accounts

Integration profile (clinic-installed Zapier connectors, EHR sync tools, custom webhook senders). Sibling to humans. Single-org by the same convention as agents.

ColumnTypeNotes
principal_idUUID PK FK → principals(id) ON DELETE CASCADESame UUID as the principal row
name, descriptionTEXT
integration_kindTEXT NULL'zapier' | 'ehr_sync' | 'webhook_sender' | ... — loose enum, app-interpreted
api_key_hashBYTEA NOT NULL UNIQUESHA-256 of the high-entropy API key generated server-side at creation. Fast hash is the right algorithm for high-entropy keys; bcrypt/argon2 are for low-entropy passwords.
api_key_prefixTEXT NULLShort visible prefix for UI display, e.g. 'sa_live_a1b2'
expires_atTIMESTAMPTZ NULLNULL = no expiry
last_used_at, rotated_at, revoked_atTIMESTAMPTZ NULLLifecycle markers
deleted_atTIMESTAMPTZ NULLSoft delete
created_at, updated_atTIMESTAMPTZ

RLS. Same pattern as agents. Per-key scope and rate limits are deferred — the principal's role from organization_memberships is the entire authorization scope today; per-key restrictions ship with the first integration-management feature.

Indexes. idx_service_accounts_active ON service_accounts(principal_id) WHERE deleted_at IS NULL AND revoked_at IS NULL; idx_service_accounts_prefix ON service_accounts(api_key_prefix) WHERE api_key_prefix IS NOT NULL.

Future sibling tables (ship per-feature when a concrete need arrives):

  • Platform-level non-human actors — observability agents, cross-org metric aggregators. Today only superadmin humans hold platform-level grants (platform_memberships is human-only by CHECK). When the first observability feature ships, decide between dropping the human-only CHECK + adding non-superadmin platform roles, or a separate platform_actor_grants table. PII access for these is governed by Layer 1.25 column-level data classification, not by where their role lives.

organization_memberships

M:M staff membership with per-org role. Renamed from principal_organizations — staff-only by definition; patients access an org through patient_profiles + patients, not through this table (see decisions.md → Why patients are not memberships, and patient tiers are not roles). principal_id works for any non-patient actor type (humans get role grants today; agents and service accounts get them when they ship). One role per principal per org.

ColumnTypeNotes
principal_idUUID FK → principals(id)
organization_idUUID FK
role_idUUID FK → rolesPer-org role assignment
last_used_atTIMESTAMPTZ NULLReserved for P35 — bump on org-scoped requests; mirrored on patients for symmetric default-org derivation
invited_atTIMESTAMPTZ NULLReserved for future invitation flow
invited_byUUID FK NULL → principals(id)The principal that issued the invitation
accepted_atTIMESTAMPTZ NULLReserved for future invitation flow
created_at, updated_atTIMESTAMPTZ
PK(principal_id, organization_id)

roles

Per-org bundles of permissions, plus system templates. Versioning not needed — roles are mutable but each change is audited. Roles never apply to patients — patient entitlements live in patient_subscription_entitlements / patient_subscription_limits (Area 16).

ColumnTypeNotes
idUUID PK
organization_idUUID FK NULLNULL for system templates
codeTEXT NOT NULLe.g., admin, specialist, customer_support, or org-defined. No patient system role — patients are not in the role machinery.
name, descriptionTEXT
is_systemBOOLEAN NOT NULLTRUE for templates and their cloned-into-org copies
created_at, updated_atTIMESTAMPTZ
Unique(organization_id, code)per-org code uniqueness
Unique (partial)code WHERE organization_id IS NULLsystem templates share a flat namespace

permissions

Catalog of every permission the system knows about — for staff actions. Seeded by feature migrations; never UI-edited. The natural key (code) is the PK so RLS policies and role_permissions rows can reference it directly without a UUID join. No app.access_portal permission — portal access is granted by the existence of a patients row at the org, not by a permission grant. Patient-side entitlements (tier entitlements, tier limits) live in the catalog tables entitlements and limit_definitions in Area 16, which are shared with the org-side billing engine.

ColumnTypeNotes
codeTEXT PKresource.action, e.g. appointments.create. Stable identifier referenced from role_permissions and from current_app_has_permission(resource, action) policies.
resourceTEXT NOT NULLe.g., appointments
actionTEXT NOT NULLe.g., create, update, delete, manage_members, manage_domains, view, export
descriptionTEXT
created_atTIMESTAMPTZ
Index(resource, action)non-unique — uniqueness is implicit from the code = resource.action convention

role_permissions

M:M between roles and permissions. References permissions(code) directly, not a UUID id.

ColumnTypeNotes
role_idUUID FK
permission_codeTEXT FK → permissions(code)
created_atTIMESTAMPTZ
PK(role_id, permission_code)

platform_memberships

Platform-level grants (superadmin; future support_engineer). Renamed from platform_roles — symmetric with organization_memberships (both are membership tables; only the scope differs). RLS-enabled with no policies → AppPool has zero access; only AdminPool reads. Superadmin stays a human-only concept by constraint, not by table structure — service accounts and agents do not get superadmin grants.

ColumnTypeNotes
principal_idUUID FK → principals(id)CHECK constraint: the referenced principal must be type='human'
roleTEXT NOT NULLsuperadmin initially. Companion platform_role_permissions table lands when a second platform role is added.
granted_atTIMESTAMPTZ
granted_by_principal_idUUID FK → principals(id)Same CHECK constraint: granter must be a human
PK(principal_id, role)

Trigger. clear_organization_memberships_on_superadmin_grant removes any tenant memberships when a superadmin grant is inserted (one-hat rule).


Area 2: People (Patient Profiles, Patients, Caregivers, Specialists, Specialties)

The patient identity model is the most non-obvious part of the platform. Read patterns.md P6/P7/P8 before changing anything here.

Patients are not memberships. Patients access an org through patients, never through organization_memberships. Portal access is implicit from the existence of a patients row; there is no patient system role and no app.access_portal permission grant. See decisions.md → Why patients are not memberships, and patient tiers are not roles.

patient_profiles

Portable patient identity. No organization_id. Renamed from patient_persons. RLS via P4 variant 2.

ColumnTypeNotes
idUUID PK
human_idUUID FK NULL UNIQUE → humans(principal_id)Auth account; NULL for account-less patients (managed by family). Patients are humans by domain definition; the FK target enforces this without a CHECK constraint.
nameTEXT NOT NULL
date_of_birthDATE
sexTEXT`Male
phoneTEXTPlaintext — pii_basic. Phone search is required (caller-ID + partial). See decisions.md → Why most PII is plaintext.
occupation, residenceTEXT
blood_typeTEXT`A+
allergiesTEXT[]
chronic_conditionsTEXT[]
emergency_contact_nameTEXT
emergency_contact_phoneTEXTPlaintext — pii_basic, kept consistent with phone.
insurance_entriesJSONB NOT NULL DEFAULT '[]'Array of {provider, number, type}
created_at, updated_atTIMESTAMPTZ

RLS. Self + caregivers (via current_human_patient_profile_ids()); org staff can SELECT (with field-level masking unless patients.profile_shared = TRUE). DELETE never permitted.

patient_caregivers

Caregiver / family-account links. No organization_id. Renamed from patient_person_managerspatient_caregivers reads as the actual domain concept; the relationship enum already uses the word "caregiver".

ColumnTypeNotes
patient_profile_idUUID FK → patient_profiles(id)
caregiver_human_idUUID FK → humans(principal_id)Caregivers are humans by domain definition
relationshipTEXT NOT NULL`self
created_atTIMESTAMPTZ
PK(patient_profile_id, caregiver_human_id)

patients

Per-org link between an org and a patient_profile. Thin record. Existence of a row here grants the patient (or their caregiver) portal access at the org — no permission lookup, no role assignment.

ColumnTypeNotes
idUUID PK
organization_idUUID FK
patient_profile_idUUID FK → patient_profiles(id)
profile_sharedBOOLEAN NOT NULL DEFAULT FALSESet by signing the profile-sharing consent form (P8)
consumer_idTEXTExternal system ID (legacy/billing)
last_used_atTIMESTAMPTZ NULLBumped on portal requests (P35); mirror of organization_memberships.last_used_at. Used to derive default org on first sign-in.
deleted_atTIMESTAMPTZ NULLSoft delete (P13)
created_at, updated_atTIMESTAMPTZ
Partial unique index(patient_profile_id, organization_id) WHERE deleted_at IS NULLAt most one active row per (profile, org). Multiple soft-deleted historical rows can coexist — that's how per-clinic re-onboarding works: a withdrawn patient who returns gets a brand-new patients row + brand-new patient_subscriptions chain; the previous (soft-deleted) row stays as audit history. See decisions.md → Why per-clinic re-onboarding creates a fresh patients row.

RLS. Org staff + the patient themselves (via current_human_patient_profile_ids()).

RLS helper rename. current_human_patient_person_ids()current_human_patient_profile_ids() to match the table rename. Same body, returns the union of patient_profiles.id where the human is the auth-account owner or a registered caregiver.

specialists

Healthcare provider per org.

ColumnTypeNotes
idUUID PK
organization_idUUID FK
human_idUUID FK NULL UNIQUE → humans(principal_id)Linked auth account (NULL = calendar-only). Specialists are humans by domain definition.
name, title, descriptionTEXT
slugTEXT NOT NULL
minicrm_nameTEXTExternal system identifier
signature_url, avatar_urlTEXTS3 keys
scheduling_timezoneVARCHAR(64)IANA tz; NULL = unbookable (P23)
scheduling_activeBOOLEAN DEFAULT TRUE
deleted_atTIMESTAMPTZ NULLSoft delete
created_at, updated_atTIMESTAMPTZ

specialties

Medical specialty categories per org.

ColumnTypeNotes
idUUID PK
organization_idUUID FK
titleTEXT NOT NULL
slugTEXT NOT NULL
created_at, updated_atTIMESTAMPTZ
Unique(slug, organization_id)

specialist_specialties

M:M junction.

ColumnTypeNotes
specialist_idUUID FK
specialty_idUUID FK
organization_idUUID FKdenormalized for direct RLS
PK(specialist_id, specialty_id)

Area 3: Service Catalog (Services, Plans, Products)

What the org offers. Pure catalog — scheduling lives in Area 4.

services

ColumnTypeNotes
idUUID PK
organization_idUUID FK
name, slug, descriptionTEXT
categoryTEXT NOT NULL DEFAULT 'consultation'`consultation
specialty_idUUID FK NULL
duration_minutes, buffer_minutesINT
base_priceDECIMAL(10,2)
currencyTEXT DEFAULT 'RON'
is_addonBOOLEAN DEFAULT FALSECan be added during an existing appointment
is_public, publishedBOOLEAN DEFAULT FALSE
cover_url, video_urlTEXT
created_at, updated_atTIMESTAMPTZ
Unique(slug, organization_id)

service_specialists

ColumnTypeNotes
service_idUUID FK
specialist_idUUID FK
organization_idUUID FK
is_activeBOOLEAN DEFAULT TRUE
custom_priceDECIMAL(10,2) NULLOverrides service.base_price
PK(service_id, specialist_id)

service_forms

Forms auto-generated when an appointment is booked for this service.

ColumnTypeNotes
service_idUUID FK
form_template_idUUID FK
organization_idUUID FK
form_typeenum`survey
sort_orderINT
PK(service_id, form_template_id)

service_attachments

ColumnTypeNotes
idUUID PK
service_idUUID FK
organization_idUUID FK
file_url, file_name, file_typeTEXTS3 key
file_sizeBIGINTbytes
created_atTIMESTAMPTZ

service_plans

Multi-session packages and subscription plans.

ColumnTypeNotes
idUUID PK
organization_idUUID FK
service_idUUID FK NULLNULL for platform-level plans (e.g., "Full Access 1 Year")
name, descriptionTEXT
plan_typeTEXT NOT NULL DEFAULT 'session_based'`session_based
sessions_totalINT NULLRequired for session_based + hybrid
treatment_plan_assignments_totalINT NULLOptional counter for specialist-assigned treatment plans included with this enrollment. Used by tier inclusions (Area 16) for "X treatment plans / period" entitlements. NULL = not applicable.
validity_daysINT NULLSession-based expiry window
access_monthsINT NULLRequired for time_based + hybrid
telerehab_accessBOOLEAN DEFAULT FALSESpecialist can assign telerehab plans
library_accessBOOLEAN DEFAULT FALSEPatient can self-browse and self-assign
total_priceDECIMAL(10,2)
currencyTEXT DEFAULT 'RON'
is_publishedBOOLEAN DEFAULT FALSE
created_at, updated_atTIMESTAMPTZ
CHECKplan_type matches required config

patient_service_plans

Patient enrollment + progress tracking.

ColumnTypeNotes
idUUID PK
patient_idUUID FK
service_plan_idUUID FK
organization_idUUID FK
sessions_total, sessions_completed, sessions_cancelledINTFrozen from plan at enrollment
access_starts_at, access_expires_atTIMESTAMPTZFor time_based + hybrid
expires_atTIMESTAMPTZFor session_based + hybrid (enrolled_at + validity_days)
statusTEXT DEFAULT 'active'`active
enrolled_atTIMESTAMPTZ DEFAULT NOW()
source_tier_subscription_idUUID FK NULL → patient_subscriptions(id)NULL = patient purchased directly (existing flow). Set = auto-granted by a tier subscription (Area 16). Soft-expired when the source tier subscription ends.
created_at, updated_atTIMESTAMPTZ

products

Reference catalog of physical goods (no e-commerce).

ColumnTypeNotes
idUUID PK
organization_idUUID FK
name, slug, descriptionTEXT
priceDECIMAL(10,2)
currencyTEXT DEFAULT 'RON'
is_activeBOOLEAN DEFAULT TRUE
created_at, updated_atTIMESTAMPTZ
Unique(slug, organization_id)

service_plan_products

Junction: which products are bundled with a plan.

ColumnTypeNotes
idUUID PK
service_plan_idUUID FK
product_idUUID FK
organization_idUUID FK
quantityINT NOT NULL DEFAULT 1
created_atTIMESTAMPTZ
Unique(service_plan_id, product_id)

Area 4: Scheduling (Calendars, Hours, Overrides)

When and how services can be booked. Calendars are the bookable unit.

calendars

ColumnTypeNotes
idUUID PK
organization_idUUID FK
name, slug, descriptionTEXT
service_idUUID FK NOT NULLRequired — provides duration/price defaults
modalityTEXT NOT NULL DEFAULT 'online'`online
slots_open_at, slots_close_atTIMESTAMPTZ NULLBooking window
horizon_daysINT NOT NULL DEFAULT 30How far ahead patients can book
cooldown_minutesINT NOT NULL DEFAULT 1440Anti-spam
override_duration_minutes, override_buffer_minutesINT NULLOverride service defaults
override_priceDECIMAL(10,2) NULL
is_freeBOOLEAN DEFAULT FALSE
assignment_strategyTEXT NOT NULL DEFAULT 'priority'`priority
is_public, publishedBOOLEAN DEFAULT FALSE
created_at, updated_atTIMESTAMPTZ
Unique(slug, organization_id)

calendar_specialists

ColumnTypeNotes
calendar_idUUID FK
specialist_idUUID FK
organization_idUUID FK
priorityINT NULLNULL = manual-only
override_weekly_hoursJSONB NULLPer-calendar override (e.g., {"wed":[{"start":"10:00","end":"12:00"}]})
PK(calendar_id, specialist_id)

calendar_forms

Forms specific to this calendar (merged with service_forms on appointment creation).

ColumnTypeNotes
calendar_idUUID FK
form_template_idUUID FK
organization_idUUID FK
form_typeenum(same as service_forms.form_type)
sort_orderINT
PK(calendar_id, form_template_id)

specialist_weekly_hours

ColumnTypeNotes
idUUID PK
specialist_idUUID FK
day_of_weekenum`mon
start_time, end_timeTIME
Unique(specialist_id, day_of_week, start_time, end_time)

specialist_schedule_overrides

Date-specific availability overrides (vacations, extra hours).

ColumnTypeNotes
idUUID PK
specialist_idUUID FK
start_date, end_dateTIMESTAMPTZ
availabilityBOOLEAN NOT NULLTRUE = available, FALSE = unavailable
created_at, updated_atTIMESTAMPTZ

specialist_assignment_tracking

Round-robin counters per calendar.

ColumnTypeNotes
calendar_idUUID FK
specialist_idUUID FK
organization_idUUID FK
last_assigned_atTIMESTAMPTZ DEFAULT NOW()
assignment_countINT DEFAULT 0
Unique(calendar_id, specialist_id)

Area 5: Appointments

appointments

ColumnTypeNotes
idUUID PK
organization_idUUID FK
calendar_idUUID FK NULLNULL for direct registrations
service_idUUID FK NULLAlways set in practice (from calendar or direct)
patient_profile_idUUID FK NULLReferences patient_profiles (P6)
specialist_idUUID FK NULL
specialty_idUUID FK NULL
contact_emailTEXTFor pre-account notifications (status='booked'); name+phone live on patient_profiles
booking_client_idTEXTAnonymous session id from public form
additional_service_idsUUID[] DEFAULT '{}'Add-ons added during appointment
additional_product_idsUUID[] DEFAULT '{}'
patient_service_plan_idUUID FK NULLWhen this appointment is part of a multi-session plan
plan_session_numberINT NULL"Session 3 of 10"
titleTEXT NOT NULL
statusenum`booked
started_at, ended_atTIMESTAMPTZ NULLNULL for service-D-style registrations without time
deleted_atTIMESTAMPTZ NULLSoft delete (P13)
created_at, updated_atTIMESTAMPTZ

RLS. Org staff (admin/specialist/customer_support); specialist sees their own; patient sees their own (via current_human_patient_profile_ids()).

appointment_files

ColumnTypeNotes
idUUID PK
appointment_idUUID FK
organization_idUUID FK
file_url, file_name, file_typeTEXTS3
file_sizeBIGINT
created_atTIMESTAMPTZ

appointment_reviews

Patient feedback after done. Low ratings trigger alerts.

ColumnTypeNotes
idUUID PK
appointment_idUUID FK UNIQUEOne review per appointment
organization_idUUID FK
ratingINT NOT NULL CHECK 1-5
commentTEXT
alert_triggered, alert_acknowledgedBOOLEAN
created_atTIMESTAMPTZ

Area 6: Custom Fields + Profile Fields

See P19 in patterns.

custom_fields

ColumnTypeNotes
idUUID PK
organization_idUUID FK
entity_typeTEXT NOT NULL`patient
keyTEXT NOT NULLAdmin-chosen identifier
labelTEXT NOT NULLDisplay
field_typeTEXT NOT NULL`text
optionsJSONBfor select/radio/checkbox
descriptionTEXThelp text
is_privateBOOLEAN DEFAULT FALSESpecialist-only visibility (excluded from patient PDFs)
sort_orderINT DEFAULT 0
system_keyTEXT NULLStable identifier for PDF templates (immutable; enforced at app layer)
versionINT NOT NULL DEFAULT 1Current version (P18)
publishedBOOLEAN NOT NULL DEFAULT FALSEPublication state
published_atTIMESTAMPTZ NULL
created_at, updated_atTIMESTAMPTZ
Unique(organization_id, entity_type, key)
Unique(organization_id, system_key)

Versioning columns (version, published, published_at) are required here, paired with custom_field_versions (next entry). features/custom-fields/versioning.md describes the workflow.

custom_field_versions

Append-only history (P14a, P18).

ColumnTypeNotes
idUUID PK
custom_field_idUUID FK
versionINT NOT NULL
fields_snapshotJSONB NOT NULLFull field state at this version
published_atTIMESTAMPTZ NOT NULL
changed_byUUID FK NULL
created_atTIMESTAMPTZ
Unique(custom_field_id, version)

custom_field_values

Per-entity value storage.

ColumnTypeNotes
idUUID PK
organization_idUUID FK
custom_field_idUUID FK
entity_type, entity_idTEXT, UUIDPolymorphic (P24)
valueTEXTPlaintext for queryability
created_at, updated_atTIMESTAMPTZ
Unique(custom_field_id, entity_type, entity_id)

Area 7: Forms

form_templates

Form designs. Versioned (P18).

ColumnTypeNotes
idUUID PK
organization_idUUID FK
name, descriptionTEXT
typeenum form_template_type`survey
fieldsJSONB NOT NULL DEFAULT '[]'Field arrangement (references custom_field_id and/or profile_field_key)
versionINT NOT NULL DEFAULT 1
publishedBOOLEAN NOT NULL DEFAULT FALSE
published_atTIMESTAMPTZ NULL
pdf_template_idUUID FK NULLWhich PDF to use when this form is rendered
created_at, updated_atTIMESTAMPTZ

form_template_versions

Append-only history.

ColumnTypeNotes
idUUID PK
form_template_idUUID FK
versionINT NOT NULL
fields_snapshotJSONB NOT NULL
published_atTIMESTAMPTZ NOT NULL
Unique(form_template_id, version)

forms

Form instances. Snapshots template at creation. Immutable after signed (P14b).

ColumnTypeNotes
idUUID PK
organization_idUUID FK
appointment_idUUID FK NULL
form_template_idUUID FK NULL
template_versionINTVersion snapshotted
patient_profile_idUUID FK NULLOwner (P6)
title, descriptionTEXT
typeenum form_template_type
fieldsJSONB NOT NULLSnapshot of template fields at creation
valuesJSONB NOT NULL DEFAULT '{}'Submission data, GIN-indexed
filesJSONB DEFAULT '{}'File references keyed by field key
sort_orderINT DEFAULT 0
statusenum`pending
completed_at, signed_atTIMESTAMPTZ
created_at, updated_atTIMESTAMPTZ

Area 8: Segments (Patient Cohorts)

segments

ColumnTypeNotes
idUUID PK
organization_idUUID FK
name, descriptionTEXT
rulesJSONB NOT NULL DEFAULT '[]'Array of {source, ...}; sources: `form
match_modeTEXT NOT NULL DEFAULT 'all'`all (AND)
versionINT NOT NULL DEFAULT 1
created_at, updated_atTIMESTAMPTZ

segment_members

Materialized cache of evaluation results.

ColumnTypeNotes
segment_idUUID FK
patient_idUUID FK
organization_idUUID FK
matched_atTIMESTAMPTZ DEFAULT NOW()
PK(segment_id, patient_id)

segment_versions

Append-only history.

ColumnTypeNotes
idUUID PK
segment_idUUID FK
organization_idUUID FK
versionINT NOT NULL
rulesJSONB NOT NULL
match_modeTEXT NOT NULL
changed_byUUID FK NULL
created_atTIMESTAMPTZ
Unique(segment_id, version)

Area 9: Telerehab — Exercise Library

Dual-scope (P20): organization_id IS NULL for global, set for org-specific.

exercises

ColumnTypeNotes
idUUID PK
organization_idUUID FK NULLNULL = global, NOT NULL = org-specific
name, slug, descriptionTEXT
instructions_summaryTEXT
difficultyenum`beginner
estimated_duration_secondsINT
video_urlTEXTCDN URL (Bunny Stream / S3)
video_providerTEXT`bunny_stream
video_thumbnail_urlTEXT
video_duration_secondsINT
statusenum`draft
deleted_atTIMESTAMPTZ NULLSoft delete (P13)
cloned_from_idUUID FK NULLClone lineage
created_by_principal_idUUID FK NULL → principals(id)Any actor type can create — humans today, agents/service accounts when those ship
translationsJSONB NOT NULL DEFAULT '{}'(P21b) — only for organization_id IS NULL rows
created_at, updated_atTIMESTAMPTZ

exercise_categories, exercise_body_regions, exercise_equipment

Same dual-scope pattern. All three need translations JSONB (P21).

Common columns
idUUID PK
organization_idUUID FK NULL
name, slugTEXT
sort_orderINT
translationsJSONB

exercise_categories adds parent_id (hierarchical, description). exercise_body_regions adds body_area (upper_body | lower_body | core | full_body). exercise_equipment adds icon_url.

exercise_tags

Polymorphic junction (P24).

ColumnTypeNotes
exercise_idUUID FK
tag_typeTEXT`category
tag_idUUID FKresolved against the appropriate table
PK(exercise_id, tag_type, tag_id)

exercise_instructions

ColumnTypeNotes
idUUID PK
exercise_idUUID FK
sort_orderINT
title, contentTEXTcontent is markdown
image_urlTEXTS3
instruction_typeTEXT`preparation
translationsJSONB
created_at, updated_atTIMESTAMPTZ

exercise_contraindications

ColumnTypeNotes
idUUID PK
exercise_idUUID FK
condition_name, descriptionTEXT
severityTEXT`warning
translationsJSONB
created_atTIMESTAMPTZ

Area 10: Telerehab — Treatment Plans

Three scopes (global / org / custom-per-patient via created_for_patient_id).

treatment_plans

ColumnTypeNotes
idUUID PK
organization_idUUID FK NULLNULL = global
name, descriptionTEXT
typeenum`telerehab
visibilityTEXT NOT NULL DEFAULT 'internal'`internal
created_for_patient_idUUID FK NULLCustom plan for one patient
created_from_idUUID FK NULLClone source
condition_tagsTEXT[]GIN-indexed for library browsing
frequency_per_week, duration_weeksINT
total_sessionsINT GENERATEDfrequency_per_week * duration_weeks
specialist_id, specialty_idUUID FK NULL
requires_approvalBOOLEAN
post_session_form_template_idUUID FK NULL
service_plan_idUUID FK NULL
statusenum`draft
versionINT NOT NULL DEFAULT 1
publishedBOOLEAN NOT NULL DEFAULT FALSE
deleted_atTIMESTAMPTZ NULL
created_by_principal_idUUID FK NULL → principals(id)Any actor type can create — humans today, agents/service accounts when those ship
translationsJSONB(P21) — only for global rows
created_at, updated_atTIMESTAMPTZ

treatment_plan_versions

ColumnTypeNotes
idUUID PK
treatment_plan_idUUID FK
organization_idUUID FK NULLmirrors plan
versionINT NOT NULL
(snapshotted plan fields: name, description, type, frequency_per_week, duration_weeks, requires_approval, post_session_form_template_id)
sessions_snapshotJSONB NOT NULLFull sessions+exercises (immutable)
created_by_principal_idUUID FK → principals(id)Any actor type can author the version snapshot
created_atTIMESTAMPTZ
Unique(treatment_plan_id, version)

treatment_plan_sessions

Template sessions (live structure).

ColumnTypeNotes
idUUID PK
treatment_plan_idUUID FK
organization_idUUID FK NULL
session_numberINT NOT NULL
name, descriptionTEXT
estimated_duration_minutesINT
translationsJSONBfor global plans
created_at, updated_atTIMESTAMPTZ
Unique(treatment_plan_id, session_number)

treatment_plan_session_exercises

Per-session exercise config.

ColumnTypeNotes
idUUID PK
session_idUUID FK
exercise_idUUID FKON DELETE RESTRICT (prevents deleting referenced exercises)
organization_idUUID FK NULL
sort_orderINT
modeenum`duration
sets, reps, hold_seconds, duration_secondsINT
rest_between_sets_seconds, rest_after_exercise_secondsINT
notesTEXT
created_at, updated_atTIMESTAMPTZ

patient_treatment_plans

Patient enrollment.

ColumnTypeNotes
idUUID PK
patient_idUUID FK
treatment_plan_idUUID FK
treatment_plan_versionINT NOT NULLFrozen at assignment
organization_idUUID FK
assigned_by_specialist_idUUID FK NULLNULL = self-assigned
self_assignedBOOLEAN NOT NULL DEFAULT FALSE
disclaimer_form_instance_idUUID FK NULLRequired when self_assigned=TRUE
patient_service_plan_idUUID FK NULL
start_date, end_dateDATE
frequency_per_week, sessions_total, sessions_completed, sessions_skippedINTFrozen from version
statusenum treatment_plan_status
approved_at, approved_by_human_idTIMESTAMPTZ / UUID FK → humans(principal_id)Medical approval requires a licensed human; FK target enforces this
created_at, updated_atTIMESTAMPTZ

patient_session_completions

Per-session execution tracking.

ColumnTypeNotes
idUUID PK
patient_treatment_plan_idUUID FK
session_numberINT NOT NULL
organization_idUUID FK
started_at, completed_atTIMESTAMPTZ
duration_secondsINTactual
statusTEXT`in_progress
exercises_total, exercises_completed, exercises_skippedINT
post_session_form_idUUID FK NULLlinks to forms
pain_level_before, pain_level_afterINT 0-10
perceived_difficultyINT 1-5
notesTEXT
appointment_idUUID FK NULLFor in_clinic plans
created_at, updated_atTIMESTAMPTZ
Unique(patient_treatment_plan_id, session_number, started_at)

patient_exercise_logs

Per-exercise tracking within a session.

ColumnTypeNotes
idUUID PK
session_completion_idUUID FK
exercise_idUUID FK NULLON DELETE SET NULL
organization_idUUID FK
sort_orderINT
started_at, completed_atTIMESTAMPTZ
prescribed_modeenumsnapshot
prescribed_sets, prescribed_reps, prescribed_hold_seconds, prescribed_duration_secondsINT
actual_sets, actual_reps, actual_hold_seconds, actual_duration_secondsINT
video_watchedBOOLEAN
video_watch_secondsINT
video_watch_percentageDECIMAL(5,2)
pose_data_capturedBOOLEAN
pose_accuracy_scoreDECIMAL(5,2)
skippedBOOLEAN
skip_reasonTEXT
created_at, updated_atTIMESTAMPTZ

Area 11: Documents

pdf_templates

Block-based PDF designer output.

ColumnTypeNotes
idUUID PK
organization_idUUID FK
name, descriptionTEXT
template_typeTEXT`report
template_html, template_cssTEXTGo template syntax
editor_stateJSONB NOT NULL DEFAULT '{}'Block editor structure
layout_configJSONB NOT NULL DEFAULT '{}'pageSize, orientation, margins, etc.
versionINT NOT NULL DEFAULT 1
publishedBOOLEAN NOT NULL DEFAULT FALSE
components_usedJSONBList of component names
created_by, updated_byUUID FK NULL
created_at, updated_atTIMESTAMPTZ
Unique(organization_id, name)

pdf_template_versions

ColumnTypeNotes
idUUID PK
template_idUUID FK
organization_idUUID FK
versionINT NOT NULL
published_atTIMESTAMPTZ
(snapshotted template fields)
changed_byUUID FK NULL
change_notesTEXT
created_atTIMESTAMPTZ
Unique(template_id, version)

pdf_template_components

Reusable blocks (letterhead, footer, signature).

ColumnTypeNotes
idUUID PK
organization_idUUID FK
name, descriptionTEXT
component_html, component_cssTEXT
categoryTEXT`header
thumbnail_urlTEXT
variables_usedTEXT[]e.g., ['Organization.LogoURL', 'Patient.Name']
created_byUUID FK NULL
created_at, updated_atTIMESTAMPTZ
Unique(organization_id, name)

appointment_documents

Generated PDFs (reports + prescriptions unified by type).

ColumnTypeNotes
idUUID PK
organization_idUUID FK
appointment_idUUID FK
generated_by_principal_idUUID FK NULL → principals(id)The actor that generated the document. Humans today; agents acting under specialist delegation in the future. The medical responsibility lives in the form's specialist signature, not here.
form_idUUID FK NULLSource form (the report/prescription is a rendering of this form)
typeenum`report
pdf_template_idUUID FK NULL
pdf_template_versionINT NULLFrozen at generation
titleTEXT NOT NULL
document_urlTEXTS3
publishedBOOLEAN
metadataJSONBPDF generation metadata
created_at, updated_atTIMESTAMPTZ
Unique(appointment_id, type)one per type per appointment

appointment_document_files

Additional files attached to documents.

ColumnTypeNotes
idUUID PK
document_idUUID FK
organization_idUUID FK
file_url, file_name, file_typeTEXT
file_sizeBIGINT
created_atTIMESTAMPTZ

Historical note. Earlier feature specs proposed two competing designs — document_templates (HTML/CSS templates with margins) vs pdf_templates (block-based editor + JSONB state + components library). The block-based design won; document_templates was never implemented and the spec was deleted. appointment_documents is the only document-side table.


Area 12: Automations

automation_rules

ColumnTypeNotes
idUUID PK
organization_idUUID FK
name, descriptionTEXT
enabledBOOLEAN DEFAULT TRUE
trigger_eventenum automation_triggerSee P28 catalog
trigger_configJSONB DEFAULT '{}'Event-specific config (e.g., {hours_before: 24})
conditionsJSONB DEFAULT '{}'Rule conditions
actionsJSONB NOT NULLOrdered action list
execution_countINT DEFAULT 0
last_executed_atTIMESTAMPTZ
created_at, updated_atTIMESTAMPTZ

automation_executions

Append-only audit trail.

ColumnTypeNotes
idUUID PK
organization_idUUID FK
automation_rule_idUUID FK
trigger_eventenum
trigger_entity_type, trigger_entity_idTEXT, UUID
statusenum`success
actions_executedJSONBPer-action results
error_messageTEXT
executed_atTIMESTAMPTZ

Area 13: Webhooks

webhook_subscriptions

ColumnTypeNotes
idUUID PK
organization_idUUID FK
urlTEXT NOT NULLHTTPS endpoint
descriptionTEXT
eventsTEXT[] NOT NULLevent names or {"*"}
signing_secretTEXT NOT NULLserver-generated whsec_...
is_activeBOOLEAN DEFAULT TRUE
created_byUUID FK
created_at, updated_atTIMESTAMPTZ

webhook_events

Append-only delivery log.

ColumnTypeNotes
idUUID PK
organization_idUUID FK
subscription_idUUID FK
event_typeTEXT NOT NULL
payloadJSONB NOT NULLDelivered body
statusTEXT DEFAULT 'pending'`pending
attemptsINT DEFAULT 0
last_attempt_atTIMESTAMPTZ
last_status_code, last_errorINT, TEXT
next_retry_atTIMESTAMPTZ
created_atTIMESTAMPTZ

Area 14: Audit + Break-Glass

See P10, P14a, P15, P16.

audit_log

Append-only mutation history. Actor is a principal — could be a human, AI agent, integration service account, or system job. Range-partitioned monthly on created_at (P41) — initial partition seeded by the migration; cmd/audit-partition-roll extends the runway. PK is composite (id, created_at) because Postgres requires every unique index on a partitioned table to include the partition key; id remains logically unique.

ColumnTypeNotes
idUUID, PK part 1
organization_idUUID FK NULLNULL for platform-level events
actor_idUUID FK NULL → principals(id)The principal that performed the action. NULL only for the singleton system principal during seeding bootstrap.
actor_typeTEXT NOT NULLDenormalized from principals.principal_type'human' | 'agent' | 'service_account' | 'system'. Saves the join when "what kind of actor was this?" is the only question.
actionTEXT NOT NULL`CREATE
entity_typeTEXT NOT NULL
entity_idUUID NULL(was BIGINT in spec — changed to UUID for v7 PK consistency)
changesJSONBbefore/after diff, sensitive fields redacted (P11)
ip_addressINET
user_agentTEXT
request_pathTEXT
request_methodTEXTHTTP verb of the originating request
status_codeINT
request_idUUID NULLCorrelation with logs (P36)
action_contextTEXT`normal
break_glass_idUUID NULLSet by audit_log_insert from the session GUC current_app_break_glass_id() (bound by the RequireBreakGlass middleware after match). Logical FK to break_glass_sessions(id) — not enforced as a hard FK so audit rows survive break_glass_sessions cascade-deletes (rare). Lit up by Foundation 1B.11.
impersonation_idUUID NULLSet by audit_log_insert from the session GUC current_app_impersonation_id() (bound by the RequireImpersonation middleware after match). Logical FK to patient_impersonation_sessions(id) — not enforced as a hard FK so audit rows survive session cascade-deletes (rare). Lit up by Foundation 1B.13.
created_atTIMESTAMPTZ, PK part 2Partition key (P41)

AI provenance (model_version, inputs_hash, confidence) lives on the sibling audit_ai_provenance table — see below. The split keeps audit_log's compliance contract stable while AI-features metadata churns on its own table.

Indexes. idx_audit_org, idx_audit_actor (actor_id), idx_audit_entity (entity_type, entity_id), idx_audit_created, idx_audit_org_entity_time, idx_audit_status (status_code, created_at DESC), plus partial indexes on action_context, break_glass_id, impersonation_id, request_id (each WHERE col IS NOT NULL). A partial index on actor_type WHERE actor_type <> 'human' is added when the first non-human actor ships (no value today since every row is 'human').

audit_ai_provenance

Sibling to audit_log. One row per audit event that involved an AI model. Audit rows for purely human actions have no row here. Split out from audit_log so AI-features schema churn (adding prompt versions, tool-call inventories, model-output rationales, etc.) doesn't pollute the core audit table. Range-partitioned monthly on audit_log_created_at, mirroring audit_log's window so both tables hand off the same monthly slice together at archive time (P41). The provenance recorder captures id, created_at from the parent audit_log INSERT (via RETURNING) and passes both into this row.

ColumnTypeNotes
audit_log_idUUID, PK part 1Composite FK to parent — see below
audit_log_created_atTIMESTAMPTZ, PK part 2Partition key (P41); matches parent audit_log.created_at exactly
model_versionTEXT NOT NULLModel identifier (e.g., claude-opus-4-7)
inputs_hashBYTEA NOT NULLSHA-256 of inputs sent to the model
confidenceNUMERIC(4,3) NULLModel's confidence score (0..1, CHECK-constrained); NULL when the model doesn't expose one
created_atTIMESTAMPTZ

FK. (audit_log_id, audit_log_created_at) → audit_log(id, created_at) ON DELETE CASCADE. The composite shape is required because Postgres FKs to a partitioned parent must reference the parent's full unique key (P41).

RLS. Same policy as audit_log — gated on audit_log.view_org permission, joined through audit_log.organization_id. INSERT permitted (audit middleware writes the provenance row in the same transaction as the audit row when the action involved an AI model).

break_glass_sessions

Time-bound, audited platform-staff elevation against a target org. One row per (principal × org × scope × time-window). State, not events — partial unique (principal_id, organization_id, scope) WHERE closed_at IS NULL enforces active-session uniqueness; lazy expiry finalize on the admin pool keeps the index honest. AppPool DML REVOKE'd; AdminPool only. SELECT for own + org members with audit_log.view_org. Ships in Foundation 1B.11. Substantive rationale in decisions.md → Why clinic is controller, platform is processor. See P15.

ColumnTypeNotes
idUUID PK
principal_idUUID FK → principals(id)The elevating platform staff. Cascade-delete on principal hard-delete (rare).
organization_idUUID FK → organizations(id)The target org.
scopeVARCHAR(32) NOT NULLCHECK in (patient_list, patient_detail, audit_full, cross_org_lookup, org_management). Granular: a session for patient_list does NOT cover patient_detail. org_management covers Console writes against the clinic's HR surface (1B.11.x).
reason_categoryVARCHAR(32) NOT NULLCHECK in (support_ticket, security_incident, dsar_routing, fraud_investigation, platform_engineering).
reason_textTEXT NOT NULLFree-text justification, CHECK length(btrim) >= 10.
reason_refTEXT NULLOptional ticket / incident / DSAR reference.
opened_atTIMESTAMPTZ NOT NULL
expires_atTIMESTAMPTZ NOT NULLCHECK expires_at > opened_at AND expires_at <= opened_at + INTERVAL '4 hours'. Default 1h, max 4h.
closed_atTIMESTAMPTZ NULLExplicit close stamps NOW(); lazy expiry finalize stamps expires_at (system-closed at natural-end).
closed_by_principal_idUUID NULL FK → principals(id)Caller's principal for explicit close (self or break_glass.manage holder); NULL for system-closed by expiry.

patient_impersonation_sessions

Time-bound, audited clinic-internal session where staff acts on a patient's behalf. Per-clinic counterpart to break_glass_sessions. State, not events — partial unique (staff_principal_id, organization_id) WHERE closed_at IS NULL enforces "one impersonation at a time per staff per clinic"; lazy expiry finalize on the admin pool keeps the index honest. AppPool + RLS WITH CHECK (not the AdminPool-with-REVOKE shape break-glass uses) — the opening principal is an authenticated org member with patients.impersonate and full RLS context. SELECT for own (staff_principal_id self-match), org members with patients.manage, and target patient (via current_human_patient_profile_ids() join through patients). Cross-context exclusion with break-glass (one elevated session at a time per principal × org, bidirectional). Ships in Foundation 1B.13. See P16.

ColumnTypeNotes
idUUID PK
staff_principal_idUUID FK → principals(id)The clinic staff member opening the session.
target_patient_idUUID FK → patients(id)Per-org patient row (carries the org constraint via the FK chain). Cascade-delete on patient hard-delete (rare; soft-delete is the normal path).
organization_idUUID FK → organizations(id)Denormalized for RLS efficiency, mirrors patient_subscriptions. Must match target_patient_id's org via the WITH CHECK clause + FK chain.
reasonTEXT NOT NULLFree-text justification, CHECK length(btrim) >= 10. Rich vocabulary by design (vs. break-glass's closed enum) — clinic-internal reasons span more than support categories ("patient called in confused", "elderly patient needs help completing intake", etc.).
opened_atTIMESTAMPTZ NOT NULL
expires_atTIMESTAMPTZ NOT NULLCHECK expires_at > opened_at AND expires_at <= opened_at + INTERVAL '4 hours'. Default 1h, max 4h.
closed_atTIMESTAMPTZ NULLExplicit close stamps clock_timestamp() (not NOW() — same-tx Open+Close paths would violate the CHECK if NOW() returned tx-start); lazy expiry finalize stamps expires_at (system-closed at natural-end).
closed_by_principal_idUUID NULL FK → principals(id)Caller's principal for explicit close (self via update_self policy or patients.manage holder via update_manage policy); NULL for system-closed by expiry.

Area 15: Consents (Foundation 1B.9)

Single ledger spanning platform-scope (platform_terms, platform_privacy_notice) and org-scope (org_terms, org_privacy_notice, marketing_email, marketing_sms, analytics, ai_processing) purposes. Tier B form-driven medical consents (telemedicine, video_recording, biometric_capture, treatment_specific_*) register at F3.5 and write into the same table with source = 'form'. Ships in Foundation 1B.9. Substantive design rationale in decisions.md → Why clinic is controller, platform is processor. See P17.

Catalog of purpose codes. Platform-managed (AdminPool writes via migration; SELECT for everyone — purpose text is by definition public).

ColumnTypeNotes
codeTEXT PKe.g. platform_terms, org_privacy_notice, marketing_email, ai_processing, video_recording
scopeTEXT NOT NULLplatform | org. Platform-scope rows are accepted once per principal and apply across all orgs; org-scope rows are accepted per clinic.
nameTEXT NOT NULLHuman-readable label
descriptionTEXT
legal_basisTEXT NOT NULLcontract | legitimate_interest | consent | legal_obligation | vital_interest (GDPR Art. 6)
withdrawableBOOLEAN NOT NULLWhether the patient-initiated withdraw endpoint accepts a flip-off for this purpose. Mostly tracks legal_basis = 'consent', with one deliberate exception: org_terms (legal_basis = contract) is withdrawable because its withdrawal is the "leave clinic" action — the cascade trigger soft-deletes the per-org patients row, cancels the active subscription, and cascade-withdraws every other org-scope consent. platform_terms (also contract) stays non-withdrawable: account deletion (F11.1) is the only revocation path.
created_atTIMESTAMPTZ

Versioned policy text per purpose. Org-scope purposes can have org-specific overrides; platform-scope purposes always use the platform-default text.

ColumnTypeNotes
idUUID PK
purpose_codeTEXT FK → consent_purposes(code)
organization_idUUID FK NULLNULL = platform-default text. Set = org override (only valid when the purpose's scope = 'org').
versionINT NOT NULLBumped per publish
body_translationsJSONB{ "en": "...", "ro": "..." }
published_atTIMESTAMPTZ NOT NULL
published_by_principal_idUUID FK
Unique(purpose_code, organization_id, version)

For org_privacy_notice specifically, the version row is generated at publish time from the org's organization_privacy_notices row (template + placeholder values + toggleable sections → assembled markdown).

consents

The append-on-grant ledger. One row per grant; withdrawal is the only mutation (UPDATE sets withdrawn_at + withdrawn_by_principal_id). Re-grant after withdrawal = new row.

ColumnTypeNotes
idUUID PK
organization_idUUID FK NULLNULL = platform-scope grant; non-NULL = org-scope grant at that clinic
patient_profile_idUUID FKThe subject (patient identity, not the per-org patients row)
purpose_codeTEXT FK → consent_purposes(code)
purpose_versionINT NOT NULLThe consent_purpose_versions.version accepted at grant time
sourceTEXT NOT NULLsignup_checkbox | self_toggle | form | staff_action | api
source_form_idUUID FK NULLNULL except when source = 'form' (FK to F3 forms; provenance for Tier B medical consents)
granted_atTIMESTAMPTZ NOT NULL
granted_by_principal_idUUID FKThe grantor — usually the patient principal (self-toggle, signup) but may be a staff principal (source = 'staff_action')
granted_via_ipINET
withdrawn_atTIMESTAMPTZ NULLNULL = currently granted
withdrawn_by_principal_idUUID FK NULL
withdrawal_reasonTEXT NULL
created_atTIMESTAMPTZ
Index(patient_profile_id, organization_id, purpose_code, granted_at DESC)history-by-subject lookups
Index(organization_id, purpose_code) WHERE withdrawn_at IS NULLactive consents per org

RLS. Org staff with consents.view_org sees consents in their org for patients registered there. Patient sees their own across all orgs (via current_human_patient_profile_ids()). Platform-scope rows (organization_id IS NULL) are visible to the patient and to break-glass-elevated staff via Foundation 1B.11.

Withdrawal semantics. withdrawable is derived from the purpose's legal_basis: only legal_basis = 'consent' purposes accept patient-initiated withdrawal. platform_terms (contract basis) cannot be withdrawn except by account deletion (triggers GDPR erasure in F11.1). org_terms withdrawal at clinic A triggers patients.deleted_at at clinic A and cascades withdrawal of every org-scope consent at that org.

Area 15a: Privacy Notice Templates (Foundation 1B.10)

Platform provides a versioned template; the clinic fills placeholders + selects toggleable sections; the assembled markdown is published as the org's org_privacy_notice purpose-version. The clinic owns the legal artefact (controller); the platform provides the scaffolding (processor).

privacy_notice_templates

Platform catalog. AdminPool writes; SELECT for everyone.

ColumnTypeNotes
idUUID PK
versionINT NOT NULL
localeTEXT NOT NULLen, ro
body_with_placeholdersTEXT NOT NULLMarkdown with , , , etc.
toggleable_sectionsJSONB NOT NULL[{key, default, body}, ...] — e.g. video_recording, biometric_capture, cross_border_transfer
published_atTIMESTAMPTZ

organization_privacy_notices

Per-org assembled notice. One row per org; updated via clinic-admin editor (1C.2).

ColumnTypeNotes
idUUID PK
organization_idUUID FK
source_template_idUUID FK
source_template_versionINT NOT NULLSnapshot of template version at last publish
placeholder_valuesJSONB NOT NULL{ clinic_name: "...", dpo_email: "..." }
included_sectionsJSONB NOT NULL["video_recording", "cross_border_transfer"]
assembled_bodyTEXTFinal markdown — what the patient accepts
published_versionINT NULLFK target on consent_purpose_versions for the org_privacy_notice row generated at publish; NULL until first publish
reviewed_by_principal_idUUID FK NULLClinic admin who published
reviewed_atTIMESTAMPTZ NULL

Template version bumps. When the platform publishes a new privacy_notice_templates version, every organization_privacy_notices row whose source_template_version is older surfaces a "Review template update" prompt to that clinic's admin in 1C.2. The previously-assembled body keeps serving (no break in legality) until the clinic re-publishes.


Area 16: Plans, Subscriptions & Patient Tiers

The platform's commercial model has two surfaces — both ride on the same engine shape with shared atomic catalogs and parallel higher-level tables.

  • B2B (platform → clinic). Platform-defined tiers (no organization_id) sold to clinics. Subscription state in organization_subscriptions + organization_subscription_entitlements / _limits / _overrides (snapshots). Managed by superadmin.
  • B2C (clinic → patient). Clinic-defined patient_tiers (per-org) sold to patients. Subscription state in patient_subscriptions + patient_subscription_entitlements / _limits / _overrides (snapshots). Managed by clinic admin.
  • Shared atomic catalogs. entitlements and limit_definitions are platform-wide and used by both surfaces — an entitlement code or limit code means the same thing whether it appears on a platform plan or a patient tier. The shared catalog prevents definition drift.

Patient tiers do not ride RBAC. There is no patient_tiers.role_id; tier entitlements live in patient_tier_entitlements / patient_tier_limits (mirrors of tier_entitlements / tier_limits), snapshotted onto the subscription on subscribe (P37). See decisions.md → Why patients are not memberships, and patient tiers are not roles.

Entitlement projection for regulated entitlements (P38) applies to the org-side only. Per-patient regulated entitlement is gated by the org-side organization_entitlements (the clinic must be certified) plus the patient's own subscription entitlements (the patient must be on a tier that includes it).

Companion docs: plans-and-subscriptions.md (full design — resolution rules, entitlement projection, lifecycle); middleware-composition.md (how RequirePlanEntitlement / RequireOrgEntitlement / EnforceLimit compose); org-settings.md (where current_tier_id and the entitlement surface live).

Catalog tables (platform-wide, no organization_id)

Catalog tables are managed via migrations, not at runtime. RLS allows everyone to SELECT; only superadmin (AdminPool) writes.

tiers

The product catalog. Versioned for snapshot-on-subscribe (P37).

ColumnTypeNotes
idUUID PK
codeTEXT NOT NULL UNIQUEe.g. free, pro, dedicated, addon_telerehab, pack_video_minutes_1000. Used by external billing.
nameTEXT NOT NULL
descriptionTEXT
kindTEXT NOT NULL`base
billing_cycleTEXT NULL`monthly
base_priceDECIMAL(10,2) NULLInformational; canonical price comes from external billing. (P22)
currencyTEXT NOT NULL DEFAULT 'RON'
is_publicBOOLEAN NOT NULL DEFAULT FALSETRUE ⇒ appears in self-service signup.
versionINT NOT NULL DEFAULT 1Bumped on any entitlement/limit edit.
publishedBOOLEAN NOT NULL DEFAULT FALSEOnly published versions can be subscribed to.
published_atTIMESTAMPTZ NULL
deprecated_atTIMESTAMPTZ NULLWhen set, prevents new signups; existing subscribers continue.
translationsJSONB NOT NULL DEFAULT '{}'(P21) for name/description localization.
created_at, updated_atTIMESTAMPTZ

tier_versions

Append-only history (P14a). Snapshotted onto subscriptions at subscribe time.

ColumnTypeNotes
idUUID PK
tier_idUUID FK
versionINT NOT NULLMatches plans.version at the moment of publish.
published_atTIMESTAMPTZ NOT NULL
entitlements_snapshotJSONB NOT NULLArray of entitlement codes enabled at this version.
limits_snapshotJSONB NOT NULLArray of {code, cap_value, behavior}.
metadata_snapshotJSONB NOT NULLFrozen {name, description, base_price, currency, billing_cycle}.
changed_by_principal_idUUID FK NULL → principals(id)Superadmin (human) who published this version. The human-only constraint is enforced by platform_memberships, not by a CHECK here.
created_atTIMESTAMPTZ
Unique(tier_id, version)

entitlements

Catalog of every plan-gated entitlement. Mirrors permissions in shape — every code is documented here. Natural-key PK.

ColumnTypeNotes
codeTEXT PKe.g. custom_domain, automations, webhooks, treatment_plans, video_consultations.
nameTEXT NOT NULLDisplay name for billing UI.
descriptionTEXT
regulatedBOOLEAN NOT NULL DEFAULT FALSETRUE ⇒ entitlement must be projected onto organization_entitlements (P38).
created_at, updated_atTIMESTAMPTZ

limit_definitions

Catalog of every metered or capped resource.

ColumnTypeNotes
codeTEXT PKe.g. max_patients, max_storage_bytes, video_minutes_per_month.
nameTEXT NOT NULL
descriptionTEXT
unitTEXT NOT NULL`count
default_behaviorTEXT NOT NULL`hard_block
period_kindTEXT NOT NULL`lifetime
created_at, updated_atTIMESTAMPTZ

tier_entitlements

Which entitlements a plan unlocks. Junction.

ColumnTypeNotes
tier_idUUID FK
entitlement_codeTEXT FK → entitlements(code)
enabledBOOLEAN NOT NULL DEFAULT TRUEAllows a plan version to disable an entitlement without removing the row (audit trail across versions).
created_atTIMESTAMPTZ
PK(tier_id, entitlement_code)

tier_limits

What caps and meter behaviors a plan sets. Junction.

ColumnTypeNotes
tier_idUUID FK
limit_codeTEXT FK → limit_definitions(code)
cap_valueBIGINT NULLNULL ⇒ unlimited.
behavior_overrideTEXT NULLOverride limit_definitions.default_behavior. NULL ⇒ inherit.
created_atTIMESTAMPTZ
PK(tier_id, limit_code)

Per-org subscription tables

organization_subscriptions

N:M between an org and the plans it holds. One row per active plan attachment (base + each add-on + each usage pack are separate rows).

ColumnTypeNotes
idUUID PK
organization_idUUID FK(P1)
tier_idUUID FKPointer to the catalog plan.
tier_versionINT NOT NULLThe tier_versions.version snapshotted at subscribe time.
statusTEXT NOT NULL`trialing
started_atTIMESTAMPTZ NOT NULL
current_period_starts_at, current_period_ends_atTIMESTAMPTZ NULLNULL for usage_pack (no period).
cancel_at, canceled_atTIMESTAMPTZ NULL
payment_providerTEXT NOT NULL DEFAULT 'manual'`manual
external_subscription_idTEXT NULLNULL until billing wires up.
created_at, updated_atTIMESTAMPTZ
Index(organization_id, status)Hot path on every gated request.

RLS. SELECT gated by subscriptions.view_org. INSERT/UPDATE/DELETE by subscriptions.manage. Superadmin via AdminPool can write any org.

organization_subscription_entitlements (snapshot, P37)

Frozen at subscribe time. Plan edits never modify these rows. Renamed twice: subscription_featuresorganization_subscription_features (when patient subscriptions arrived and the organization_ prefix became load-bearing) → organization_subscription_entitlements (foundation 1C.9, 2026-05-06; resolves the architectural-vs-billing word collision per glossary.md → Entitlement).

ColumnTypeNotes
subscription_idUUID FK → organization_subscriptions(id)
entitlement_codeTEXT NOT NULLReferences the shared entitlements(code) catalog.
enabledBOOLEAN NOT NULL
created_atTIMESTAMPTZ
PK(subscription_id, entitlement_code)

organization_subscription_limits (snapshot, P37)

Frozen at subscribe time. Renamed from subscription_limits.

ColumnTypeNotes
subscription_idUUID FK → organization_subscriptions(id)
limit_codeTEXT NOT NULLReferences the shared limit_definitions(code) catalog.
cap_valueBIGINT NULLNULL ⇒ unlimited.
behaviorTEXT NOT NULLResolved from tier_limits at subscribe time.
created_atTIMESTAMPTZ
PK(subscription_id, limit_code)

organization_subscription_overrides

Sales-granted exceptions on top of the snapshot. Audited. Renamed from subscription_overrides.

ColumnTypeNotes
idUUID PK
subscription_idUUID FK → organization_subscriptions(id)
override_kindTEXT NOT NULL`entitlement
entitlement_code, entitlement_enabledTEXT, BOOLEAN NULLRequired when override_kind = 'entitlement'.
limit_code, cap_value, behavior_overrideTEXT, BIGINT, TEXT NULLRequired when override_kind = 'limit'.
granted_by_principal_idUUID FK → principals(id)Superadmin (human) who granted. Human-only constraint enforced by platform_memberships.
reasonTEXT NOT NULLAudit trail.
effective_fromTIMESTAMPTZ NOT NULL DEFAULT NOW()
expires_atTIMESTAMPTZ NULLNULL ⇒ until subscription ends.
revoked_at, revoked_by_principal_idTIMESTAMPTZ, UUID FK NULL → principals(id)
created_atTIMESTAMPTZ
CHECKone of (entitlement_code, limit_code) is set per override_kind

RLS. SELECT for subscriptions.view_org. INSERT/UPDATE/DELETE: superadmin only via AdminPool — clinic admins cannot grant their own overrides on the platform-tier subscription. The patient-side mirror (patient_subscription_overrides) has different gating because clinic admins DO grant patient-side overrides.

Patient tier tables (B2C — clinic-defined, parallel shape to org-side)

The patient tier engine mirrors the org-side plan engine (patient_tierstiers, patient_tier_versionstier_versions, etc.) with two structural differences: (1) per-org scope (organization_id NOT NULL); (2) clinic admin manages instead of superadmin. Tier entitlements are entitlements and limits, not roles — there is no patient_tiers.role_id.

patient_tiers

Per-org catalog of tiers a clinic offers patients. Versioned for snapshot-on-subscribe (P37). Mirror of tiers.

ColumnTypeNotes
idUUID PK
organization_idUUID FK(P1)
codeTEXT NOT NULLOrg-defined, e.g. basic, premium.
nameTEXT NOT NULL
descriptionTEXT
is_activeBOOLEAN NOT NULL DEFAULT TRUE
is_defaultBOOLEAN NOT NULL DEFAULT FALSEExactly one tier per org should have this TRUE; auto-assigned to new portal sign-ups. Partial unique index WHERE is_default = TRUE is the brute-force guarantee; service-layer atomic-swap is the user-friendly path.
sort_orderINT NOT NULL DEFAULT 0
versionINT NOT NULL DEFAULT 1Bumped on any entitlement/limit edit.
publishedBOOLEAN NOT NULL DEFAULT FALSEOnly published versions can be subscribed to.
published_atTIMESTAMPTZ NULL
external_price_hintDECIMAL(10,2) NULLInformational only. Source of truth is the clinic's external billing system.
currencyTEXT NOT NULL DEFAULT 'RON'
created_at, updated_atTIMESTAMPTZ
Unique(organization_id, code)

RLS. SELECT for org members + portal patients (so the sign-up screen can list active tiers). INSERT/UPDATE/DELETE gated by patient_tiers.manage (granted to admin template).

patient_tier_versions

Append-only history of published tier versions. Mirror of tier_versions.

ColumnTypeNotes
idUUID PK
tier_idUUID FK → patient_tiers(id)
organization_idUUID FK(P1; denormalized for RLS)
versionINT NOT NULLMatches patient_tiers.version at the moment of publish.
published_atTIMESTAMPTZ NOT NULL
entitlements_snapshotJSONB NOT NULLArray of entitlement codes enabled at this version.
limits_snapshotJSONB NOT NULLArray of {code, cap_value, behavior}.
metadata_snapshotJSONB NOT NULLFrozen {name, description, external_price_hint, currency}.
changed_by_principal_idUUID FK NULL → principals(id)Clinic admin (human) who published this version.
created_atTIMESTAMPTZ
Unique(tier_id, version)

patient_tier_entitlements

Which entitlements a patient tier unlocks. Junction. Mirror of tier_entitlements. References the shared entitlements(code) catalog.

ColumnTypeNotes
tier_idUUID FK → patient_tiers(id)
entitlement_codeTEXT FK → entitlements(code)Same catalog as the org-side plan engine.
enabledBOOLEAN NOT NULL DEFAULT TRUEAllows a tier version to disable an entitlement without removing the row.
created_atTIMESTAMPTZ
PK(tier_id, entitlement_code)

patient_tier_limits

What caps and meter behaviors a patient tier sets. Junction. Mirror of tier_limits. References the shared limit_definitions(code) catalog.

ColumnTypeNotes
tier_idUUID FK → patient_tiers(id)
limit_codeTEXT FK → limit_definitions(code)Same catalog as the org-side plan engine.
cap_valueBIGINT NULLNULL ⇒ unlimited.
behavior_overrideTEXT NULLOverride limit_definitions.default_behavior. NULL ⇒ inherit.
created_atTIMESTAMPTZ
PK(tier_id, limit_code)

patient_subscriptions (lands at Layer 2.5 — depends on patients)

Per-patient subscription to a tier. Mirror of organization_subscriptions.

ColumnTypeNotes
idUUID PK
organization_idUUID FK(P1)
patient_idUUID FK → patients(id)
tier_idUUID FK → patient_tiers(id)
tier_versionINT NOT NULLThe patient_tier_versions.version snapshotted at subscribe time.
statusTEXT NOT NULL`trialing
started_atTIMESTAMPTZ NOT NULLWhen the subscription first began (does not change across renewals).
current_period_starts_at, current_period_ends_atTIMESTAMPTZ NULLDrives the tier-inclusion rollover hook.
cancel_at, canceled_atTIMESTAMPTZ NULL
payment_providerTEXT NOT NULL DEFAULT 'external'`external
external_subscription_idTEXT NULL
created_at, updated_atTIMESTAMPTZ
Index(patient_id, status)One active subscription per patient is the service-layer invariant.

RLS. SELECT gated by patient_subscriptions.view_org. Patients see their own via current_human_patient_profile_ids() join through patients.patient_profile_id. Mutations gated by patient_subscriptions.manage.

No role-flip hook. Subscribing to a tier does not mutate any role assignment — patients have no role at the org. Tier perks are read out of the snapshot tables below; portal access is implicit from the existence of the patients row.

patient_subscription_entitlements (snapshot, P37)

Frozen at subscribe time. Tier edits never modify these rows. Mirror of organization_subscription_entitlements.

ColumnTypeNotes
subscription_idUUID FK → patient_subscriptions(id)
entitlement_codeTEXT NOT NULLShared entitlements(code) catalog.
enabledBOOLEAN NOT NULL
created_atTIMESTAMPTZ
PK(subscription_id, entitlement_code)

patient_subscription_limits (snapshot, P37)

Frozen at subscribe time. Mirror of organization_subscription_limits.

ColumnTypeNotes
subscription_idUUID FK → patient_subscriptions(id)
limit_codeTEXT NOT NULLShared limit_definitions(code) catalog.
cap_valueBIGINT NULLNULL ⇒ unlimited.
behaviorTEXT NOT NULLResolved from patient_tier_limits at subscribe time.
created_atTIMESTAMPTZ
PK(subscription_id, limit_code)

patient_subscription_overrides

Clinic-granted exceptions on top of the snapshot. Audited. Mirror of organization_subscription_overrides with one structural difference: gated by clinic admin (patient_subscriptions.manage), not platform admin. Common case: the clinic admin overrides a Basic-tier patient's monthly-appointments limit because of a clinical exception.

ColumnTypeNotes
idUUID PK
subscription_idUUID FK → patient_subscriptions(id)
override_kindTEXT NOT NULL`entitlement
entitlement_code, entitlement_enabledTEXT, BOOLEAN NULLRequired when override_kind = 'entitlement'.
limit_code, cap_value, behavior_overrideTEXT, BIGINT, TEXT NULLRequired when override_kind = 'limit'.
granted_by_principal_idUUID FK → principals(id)Clinic admin who granted.
reasonTEXT NOT NULLAudit trail.
effective_fromTIMESTAMPTZ NOT NULL DEFAULT NOW()
expires_atTIMESTAMPTZ NULLNULL ⇒ until subscription ends.
revoked_at, revoked_by_principal_idTIMESTAMPTZ, UUID FK NULL → principals(id)
created_atTIMESTAMPTZ
CHECKone of (entitlement_code, limit_code) is set per override_kind

RLS. SELECT and INSERT/UPDATE/DELETE gated by patient_subscriptions.manage (granted to admin + customer_support templates). The patient sees their own active overrides via current_human_patient_profile_ids() join through patients.patient_profile_id.

patient_tier_inclusions (lands at Layer 3.2 — depends on service_plans)

Counted entitlements bundled with a tier — specifically, service-plan templates auto-cloned into the patient's patient_service_plans when they subscribe. Distinct from patient_tier_entitlements / patient_tier_limits: tier entitlements and limits are abstract codes ("priority_support", "max_monthly_appointments"); inclusions bind to specific bookable service templates ("5 sessions of Service X per period"). Both shapes coexist on a tier.

ColumnTypeNotes
idUUID PK
organization_idUUID FK(P1)
tier_idUUID FK → patient_tiers(id)
service_plan_idUUID FK → service_plans(id)The template to clone into patient_service_plans when the tier subscription becomes active.
grant_periodTEXT NOT NULL`per_subscription_period
grant_quantityINT NOT NULL DEFAULT 1
carry_over_unusedBOOLEAN NOT NULL DEFAULT FALSEDefault FALSE ("use it or lose it"); per-row override allowed.
prorate_on_upgradeBOOLEAN NOT NULL DEFAULT FALSEDefault FALSE (full grant on upgrade); per-row override allowed.
created_at, updated_atTIMESTAMPTZ
Index(tier_id)Hot path on subscription state changes.

RLS. SELECT for org members. INSERT/UPDATE/DELETE gated by patient_tiers.manage (same permission gating the tier itself).

Hook. On patient_subscriptions state change, a domain service projects inclusions onto patient_service_plans (provision on activate/trialing, soft-expire on cancel/expire/past_due, regrant on period rollover, full grant or prorate on tier change). See plans-and-subscriptions.md § Tier → entitlement provisioning.

Layer dependencies

The schema lands layer by layer based on FK dependencies. Plans/subscriptions split across Layer 1, 2.5, and 3.2; the rest of the platform's core domain entities anchor each subsequent layer (cross-reference dependency-map.md for the canonical layer-by-area build order):

LayerTables
Layer 1 (foundation)Shared catalog tables (entitlements, limit_definitions); org-side catalog (tiers, tier_versions, tier_entitlements, tier_limits); org-side subscription tables (organization_subscriptions, organization_subscription_entitlements, organization_subscription_limits, organization_subscription_overrides); patient-side catalog (patient_tiers, patient_tier_versions, patient_tier_entitlements, patient_tier_limits)
Layer 2 (People)Core domain entities: humans, patient_profiles, patient_caregivers, patients, specialists, specialties
Layer 2.5 (after patients)patient_subscriptions, patient_subscription_entitlements, patient_subscription_limits, patient_subscription_overrides
Layer 3 (Service Catalog)Core domain entities: services, service_plans, service_specialists
Layer 3.2 (after service_plans)patient_tier_inclusions; patient_service_plans.source_tier_subscription_id FK column; service_plans.treatment_plan_assignments_total counter extension
Layer 4 (Forms)Core domain entities: custom_fields, form_templates, form_template_versions, forms; service_forms junction (depends on Layer 3 services)
Layer 5 (Scheduling)Core domain entities: calendars, calendar_specialists, specialist availability tables
Layer 6 (Appointments)Core domain entities: appointments (depends on patients, specialists, services, calendars, forms)

See plans-and-subscriptions.md § Layer 1 reservation for the full phase-by-phase reservation list.


ER Diagram (high-level)

                        ┌──────────────────┐
                        │  organizations   │◄────────────┐
                        └────────┬─────────┘             │
                                 │                       │
                ┌────────────────┼────────────────┐      │
                ▼                ▼                ▼      │
      ┌──────────────────────┐ ┌─────────────────┐ ┌──────────────────────┐
      │     principals       │ │  org_domains    │ │ org_integrations     │
      │ (root identity:      │ └─────────────────┘ └──────────────────────┘
      │  id, principal_type) │
      └──────────┬───────────┘
                 │ principal_id (PK = FK, ON DELETE CASCADE)

        ┌────────┼────────────────────────────────┐
        ▼        ▼                                ▼
 ┌──────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
 │   humans     │ │  agents (sibling,   │ │ service_accounts     │
 │ (shipped —   │ │   future actor —    │ │ (sibling, future     │
 │  human       │ │   table exists, no  │ │  actor — table       │
 │  profiles)   │ │   features yet)     │ │  exists, no features │
 └──────┬───────┘ └─────────────────────┘ │  yet)                │
        │                                 └──────────────────────┘
        │ organization_memberships (staff M:M with role)
        │ — principals.id, NOT humans-only; agents +
        │   service_accounts join here when their first
        │   feature ships

        ┌────┴────────────────────────────────────────────┐
        │                                                 │
        ▼                                                 ▼
 ┌──────────────┐                              ┌────────────────────┐
 │  patient_    │ ◄─── caregivers ─────────────┤ patient_caregivers │
 │  profiles    │                              └────────────────────┘
 └──────┬───────┘

        │ (per-org link — patients NOT in organization_memberships)

 ┌──────────────┐         ┌───────────────┐         ┌─────────────────┐
 │   patients   │────────▶│ appointments  │◄────────│   specialists   │
 └──────┬───────┘         └───────┬───────┘         └────────┬────────┘
        │                         │                          │
        │                         │                          │
        │                ┌────────┴──────┐                   │
        │                ▼               ▼                   ▼
        │         ┌────────────┐  ┌────────────┐    ┌────────────────┐
        │         │   forms    │  │ documents  │    │ specialties    │
        │         └────────────┘  └────────────┘    └────────────────┘

        │ (telerehab path)
        ├─────────────────────────────────────┐
        ▼                                     ▼
 ┌─────────────────────┐             ┌───────────────────────┐
 │ patient_treatment_  │────refs────▶│   treatment_plans     │
 │      plans          │             │  (global / org /      │
 └────────┬────────────┘             │      custom)          │
          │                          └────────┬──────────────┘
          ▼                                   │
 ┌─────────────────────┐                      ▼
 │ patient_session_    │             ┌───────────────────┐
 │   completions       │             │  treatment_plan_  │
 └────────┬────────────┘             │     sessions      │
          │                          └────────┬──────────┘
          ▼                                   │
 ┌─────────────────────┐                      ▼
 │ patient_exercise_   │────refs────▶┌────────────────────┐
 │      logs           │             │     exercises      │
 └─────────────────────┘             │  (global / org)    │
                                     └────────────────────┘

[parallel: services → calendars → appointments]
[parallel: custom_fields → form_templates → forms]
[parallel: automations + webhooks consume the event bus]
[parallel: audit_log captures everything]
[parallel: consents track per-clinic per-purpose]

Schema Reconciliation: Spec vs Current Implementation (CLOSED)

The per-feature schema.sql / schema.md files in apps/docs/features/*/ were the original schema source of truth. Layer 1.24 (principal model rename) and P26 (UUIDv7 PKs) changed them so heavily that they drifted from the migrations, and CLAUDE.md adopted the rule "architecture wins over feature specs." Once that rule landed, this document became canonical and the per-feature spec files were no longer load-bearing.

Resolution (executed):

  1. The 19 per-feature schema.{sql,md} files were deleted. Every "Resolution: spec is stale" entry below was made moot by deletion. Parent feature docs now link to the relevant Area N in this file.
  2. services/api/cmd/check-migrations lints new migrations for the same stale patterns the spec files used to carry: BIGSERIAL, BIGINT REFERENCES, users(id), has_role(, current_app_user_id, app.current_user_id, form_instances. Wired into make check. A copy-paste from a hypothetical surviving stale spec would now fail the build.
  3. The schema decisions originally captured here remain authoritative — they are documented in their natural homes:
    • UUIDv7 PKs: P26
    • Principal model (no users table): decisions.md → Why principals as the root identity, Area 1 of this file
    • pdf_templates wins over document_templates: Area 11 of this file
    • forms (not form_instances): Area 7 of this file
    • appointment_status defined upfront, no ALTER TYPE later: Area 5 of this file
    • No appointment_template_id legacy column: Area 5 (omitted by design)
    • Per-org permissions (current_app_has_permission, no role-string compares): P3 and rbac-permissions.md
    • audit_log.request_id, organization_memberships.last_used_at / invited_at / invited_by: reserved-columns.md (Layer 1.11, 1.12)
    • consents table: Area 15 of this file (Foundation 1B.9 owns implementation; F3.5 layers Tier B medical consents)
    • specialists.human_id UNIQUE, patient_profiles.human_id UNIQUE: Area 2 of this file (Layer 2.1, 2.3)
    • appointments.patient_profile_id nullable until the booking flow links it: Area 5 of this file

If a future contributor finds a missing constraint that used to live in the deleted spec files, add it to the relevant Area in this document — there is no other canonical home.


Open Decisions

The canonical list lives in implementation-plan.md → Open Decisions — kept there so each open item is attached to the layer that has to resolve it. Don't duplicate that table here; update it in the implementation plan and link back from any data-model entries that depend on the answer.

Already-resolved items relevant to this doc (so the schema lookups stop reading "still open"):

  • OpenAPI spec generation — resolved (Layer 1.7): spec-first via oapi-codegen (Go) + openapi-typescript (frontends), source at apps/docs/openapi.yaml. See decisions.md.
  • humans.last_activity write strategy — resolved (Layer 1.11): middleware-side throttled bump (60s in-process cache → admin-pool UPDATE on miss). See reference/activity-tracking.md.
  • organizations.last_activity_at column — resolved (Layer 1.11): not stored; derive from MAX(organization_memberships.last_used_at) WHERE organization_id = ? when needed.
  • Daily.co for video — resolved: see decisions.md.

How to Use This Doc

When starting work on a new feature:

  1. Find the area it belongs to and review the entities involved.
  2. Cross-check against patterns.md for every pattern those entities depend on.
  3. Confirm the build order in dependency-map.md — does anything blocking this feature still need to land?
  4. Update this doc when adding tables or columns. There is no per-feature schema doc anymore — this file is the canonical schema; services/api/cmd/check-migrations keeps new migrations in line with it.
  5. When in doubt, re-read data-isolation.md, audit-trail.md, and gdpr.md — those constraints are non-negotiable.

The implementation plan in apps/docs/implementation-plan.md references entities from this doc by name. Keep names stable across both docs.