Skip to content

Patients

A patient is a real-world person receiving care. Their portable profile travels with them across clinics. Their clinical history stays private to each clinic.

REWRITTEN AGAINST THE SHIPPED SCHEMA — 2026-08-02

This page previously described a design generation that was never built: a users table, patient_persons, patient_person_managers, bigserial/bigint PKs, and a current_user_patient_person_ids() RLS helper. None of those exist in any migration.

The shipped model is principalshumanspatient_profilespatients, implemented in services/api/migrations/core/000006_patient_identity.up.sql and live in production.

Corrections applied on this page:

  • patient_personspatient_profiles. Not a rename only: the shipped table has a UUID PK and a nullable human_id FK to humans(principal_id).
  • patient_person_managerspatient_caregivers. Different shape — composite PK (patient_profile_id, caregiver_human_id), no surrogate id, no manager_user_id.
  • There is no users table and no user_id column. The login account is a principals row of type human with a humans profile. See decisions.md → Why principals as the root identity.
  • All PKs are UUID (UUIDv7 generated Go-side, gen_random_uuid() as the DB default) — never bigserial/bigint. See P26.
  • The RLS helper is current_human_patient_profile_ids() RETURNS UUID[], not current_user_patient_person_ids() → SETOF UUID. There is no current_app_user_id().
  • Staff RLS gates on permission codes (current_app_has_permission('patients', 'view')), never on a role string.
  • Phone and emergency-contact phone are plaintext TEXT, not encrypted bytea — see decisions.md → Why most PII is plaintext (and what isn't).
  • There is no profile-sharing consent. patients.profile_shared, the profile_sharing purpose and its flip trigger were REMOVED 2026-08-20. A clinic reads the portable profile because it holds a patients row against it. See P8 — retired.
  • Custom fields and Segments are not built — no tables, no Go domain. Segments is explicitly out of scope. See the sections below.

What this enables

  • A patient registers once and brings their profile — demographics, blood type, allergies, insurance — to every clinic they visit on the platform, without re-entering anything
  • A daughter can manage care for her elderly father who has no email and no account (schema shipped; see the caregiver section for what is and isn't wired up)
  • Each clinic has its own patient list with its own clinical history — programs, sessions, consents, and subscriptions from Clinic A are never visible to Clinic B
  • Patient records are never hard-deleted, only soft-deleted — preserving the clinical history GDPR Art. 17(3)(c) exempts from erasure
  • Admins can temporarily act on behalf of a patient (impersonation) to provide support — fully audited, see Impersonation →

How it works

The design separates two concerns:

patient_profiles  (who the person is — owned by the patient, portable, no organization_id)
    └── patients @ Clinic A  (that they are a patient here — owned by the clinic)
    └── patients @ Clinic B  (that they are a patient here — owned by the clinic)

When a specialist at Clinic B looks up their patient, they see the person's name, date of birth, blood type, allergies and insurance — without the patient re-entering any of it. They never see anything from Clinic A's programs, sessions, or consents, and a clinic the patient is NOT registered at sees nothing at all.

Patients are not memberships. There is no patient role and no app.access_portal permission. The existence of a non-deleted patients row at an org is what grants portal access at that org. Staff membership is the separate organization_memberships table. See decisions.md → Why patients are not memberships.

The portable profile

patient_profiles is the patient's universal health identity. It holds:

  • Core identity: name
  • Demographics: date of birth, sex, phone, occupation, residence
  • Universal health facts: blood type, allergies, chronic conditions
  • Emergency contact: name and phone
  • Insurance: a JSONB list of {provider, number, type} entries

The row has no organization_id — it is the documented RLS exception (P6). This data is owned by the patient, not the clinic. A clinic sees it because the patient is registered there — see Why there is no profile-sharing consent below.

Caregivers and account-less patients

An auth account and a patient identity are separate things. patient_profiles.human_id is nullable: a patient managed entirely by family has no login of their own. One logged-in human can act on behalf of several patient profiles — their own plus anyone they are a registered caregiver for.

Ana signs in
  principals.id            = 0191f3c2-8a41-7d3e-9c5b-1c2f8a4b6d01   (principal_type = 'human')
  humans.principal_id      = 0191f3c2-8a41-7d3e-9c5b-1c2f8a4b6d01   (email, locale, timezone — no name column)

  ├── herself   → patient_profiles.id = 0191f3c3-1b02-7a5c-8e11-9d4a2b7c3e55
  │                 human_id = 0191f3c2-8a41-7d3e-9c5b-1c2f8a4b6d01
  │                 name     = 'Ana Popescu'

  └── her father → patient_profiles.id = 0191f3c3-4d18-7f60-b3a7-6e8c0f1d9a22
                    human_id = NULL          ← no account of his own
                    name     = 'Ștefan Popescu'
                    + patient_caregivers (patient_profile_id = 0191f3c3-4d18-…,
                                          caregiver_human_id = 0191f3c2-8a41-…,
                                          relationship       = 'parent')

Ana's per-org rows are separate from her father's:

patients
  id = 0191f3c4-a001-7c22-9f31-2b5d7e0a4c18   organization_id = <Clinic A>   patient_profile_id = 0191f3c3-1b02-…   (Ana at Clinic A)
  id = 0191f3c4-a002-7d44-8e73-51ac9b3f2d07   organization_id = <Clinic B>   patient_profile_id = 0191f3c3-1b02-…   (Ana at Clinic B)
  id = 0191f3c4-b010-7e19-a4c8-33f1d6b8e592   organization_id = <Clinic A>   patient_profile_id = 0191f3c3-4d18-…   (her father at Clinic A)

Everything clinical — protocols, session runs, consents, subscriptions — references the patient_profiles id (or the per-org patients id), never human_id. So when the father later creates his own account, setting patient_profiles.human_id on his row hands him his complete history: nothing else changes.

current_human_patient_profile_ids() is the RLS helper that makes this work. It returns the UNION of:

  1. patient_profiles rows where human_id = current_app_principal_id()
  2. patient_caregivers.patient_profile_id rows where caregiver_human_id = current_app_principal_id()

and returns an empty array for a blocked or soft-deleted principal. It is human-only by design — agents and service accounts never act as patients or caregivers. See P7.

Caregiver reads are wired; caregiver writes are not

The schema, the RLS helper, and every read path that honours it are shipped and exercised across sessions, protocols, stats, accessoffers, and the auth middleware. What does not exist yet is any way to create a caregiver link: patient_caregivers has no INSERT/UPDATE/DELETE policy (AppPool has zero write access — REVOKE INSERT, UPDATE, DELETE in 000006), and no production Go code writes the table. Adding a caregiver is an AdminPool-only operation with no endpoint or UI today.

patients is a thin table recording that a person is a patient at a specific org. It holds nothing about the person — just the relationship (organization_id, patient_profile_id), an external-system reference (consumer_id), an activity timestamp (last_used_at, P35), and the soft-delete marker.

This row is the disclosure boundary. Every staff-facing read of the portable profile requires it, which is what keeps Clinic B out of Clinic A's patient's record.

A partial unique index enforces at most one active row per (patient_profile_id, organization_id), while soft-deleted historical rows accumulate freely. A patient who leaves a clinic and later re-onboards there gets a brand-new patients row and a fresh subscription chain; the old row stays as the historical record. See decisions.md → Why per-clinic re-onboarding creates a fresh patients row.

Removed 2026-08-20. patients.profile_shared, the profile_sharing consent purpose and the trigger that flipped one from the other are all gone. The full rationale lives in P8 — retired; the short version is that the gate defaulted closed, so the ordinary path left a treating clinic unable to see its patient's allergies, and consent was the wrong legal basis for treatment data in the first place (Art. 9(2)(h), not 9(2)(a); Recital 43 on why consent in a care setting is not freely given).

Registering the patient is the disclosure. The portable profile is the intake form, prefilled from the patient's own account so they don't retype their date of birth at every clinic they attend. That is convenience, not a second act of sharing — and a clinic that may hold a diagnosis may hold the identity it belongs to.

What still bounds the read, and must not be removed with the gate:

  • A patients row at the reading org. patients.FindByID, patients.RenderableProfileFields and forms.ResolveProfileValues each require one (or the caller's own profile, via current_human_patient_profile_ids()). A clinic with no row reads nothing — that is the Art. 26 boundary, and it is now the only one. Pinned by TestPatientProfile_ScopedByRegistration.
  • RLS on patient_profiles (patient_profiles_select_org_staff), which decides which rows a staff session may see at all.
  • patients.view on the staff routes.
  • The CNP keeps its own restriction, and it is a different kind: patients.view_national_id on the /national-id reveal endpoint, plus the audit.ActionRead row that endpoint writes. Permission and audit, not consent.

The patient always sees their own full profile via GET /v1/me/patient-profile.

The clinical consents are untouched. telerehab, telemedicine, biometric_capture and video_recording each still gate their own feature, as do the marketing and analytics preferences. Those are distinct processing activities; this one never was.

Leaving a clinic

Withdrawing the org_terms consent at a clinic is the patient's "end the relationship here" action. A trigger cascade (000008_consents.up.sql) soft-deletes the patients row at that org, cancels active subscriptions there, and cascade-withdraws every other org-scope consent with withdrawal_reason = 'cascade:org_terms_withdrawn'.

The patient can always do this themselves. Staff can only do it on the patient's behalf with patients.offboard — a permission strictly more privileged than consents.manage, granted to admin only by default, precisely so customer support cannot terminate a clinical relationship through the consent-toggle UI.

What crosses clinic boundaries — and what doesn't

The portable profile and clinical records sit in two different legal categories:

DataCrosses clinics?Why
Portable profile (name, DOB, blood type, allergies, insurance)Yes — on registrationThis is the patient's own identity data. They carry it themselves, like handing an insurance card to a new doctor. Registering at a clinic is what hands it over.
Clinical records (protocols, session runs, consents, subscriptions, …)No — neverThese are created by the clinic in the context of a care relationship. The clinic is the data controller for this information; the platform is processor.

If a patient needs to share a report from Clinic A with Clinic B, they download the PDF from their portal and upload it at the new clinic — the same workflow used in traditional healthcare.

The platform does not broker cross-clinic document sharing. Under GDPR it would constitute a cross-controller transfer and push the platform toward joint controllership (Art. 26) — the failure mode the cross-tenant rule in CLAUDE.md exists to prevent. Under HIPAA (relevant only if US clinics ever onboard) cross-provider sharing requires formal written patient authorization, 45 CFR § 164.508 — and facilitating it would classify the platform as a Health Information Exchange, bringing significantly heavier regulation.

Org-specific custom fields

NOT BUILT

Beyond the portable profile, clinics were designed to define their own patient fields — referral source, preferred training surface, VIP status. No custom_fields or custom_field_values table exists in any migration, and there is no Go domain. See Custom Fields → for the design spec and platform-completion.md for where it lands.

One settled constraint worth carrying forward: a custom field of type national_id (Romanian CNP) never reaches a generic value store. CNP is pii_regulated → encrypted BYTEA via internal/core/crypto, stored once on the patient-owned patient_profiles, and revealed only through the permissioned, audited /national-id endpoint. Settled 2026-08-02; see glossary.md.


Technical Reference

Everything below is intended for developers. services/api/migrations/core/000006_patient_identity.up.sql is the source of truth; this is a summary.

Data model

principals               root identity for every actor (UUID PK, principal_type = 'human' for patients)
  └── humans             human profile (PK = principal_id; provider_subject_id, email, blocked, locale, timezone)
        └── patient_profiles   portable profile (UUID PK; human_id UNIQUE NULL → humans; NO organization_id)
              ├── patient_caregivers   (patient_profile_id, caregiver_human_id) — family / proxy links
              └── patients             per-org clinical link (UUID PK; organization_id + patient_profile_id)
                    ├── patient_subscriptions   per-patient tier subscription (000007)
                    └── patient_impersonation_sessions.target_patient_id (000013)

Note that humans carries no name column — the patient's name lives on patient_profiles.name, staff names come from elsewhere.

patient_profiles — portable profile columns

ColumnTypeNotes
idUUID PKUUIDv7 Go-side; gen_random_uuid() DB default (P26)
human_idUUID UNIQUE NULLFK → humans(principal_id) ON DELETE SET NULL. NULL = account-less
nameTEXT NOT NULLFull name
date_of_birthDATE
sexTEXTCHECK: Male / Female / Other / Prefer not to say
phoneTEXTPlaintextpii_basic. Caller-ID and partial-digit lookup are required clinic features; random-nonce AES-GCM makes them impossible
occupationTEXT
residenceTEXT
blood_typeTEXTCHECK: A+ A- B+ B- O+ O- AB+ AB-
allergiesTEXT[]clinical class
chronic_conditionsTEXT[]clinical class
emergency_contact_nameTEXT
emergency_contact_phoneTEXTPlaintext, same rationale as phone
insurance_entriesJSONB NOT NULL DEFAULT '[]'Array of {provider, number, type}; shape validated app-side
created_at / updated_atTIMESTAMPTZset_updated_at trigger

Indexes: human_id (partial, non-null), phone (partial btree for exact caller-ID match), and GIN (immutable_unaccent(name) gin_trgm_ops) — the diacritic-folding trigram index that powers the clinic patient picker's ?q= typeahead so "Stefan" matches "Ștefan".

Column classification for every field lives in data-classification.md → Patient identity.

patient_caregivers

ColumnTypeNotes
patient_profile_idUUID NOT NULLFK → patient_profiles(id) ON DELETE CASCADE
caregiver_human_idUUID NOT NULLFK → humans(principal_id) ON DELETE CASCADE
relationshipTEXT NOT NULLCHECK: self / parent / child / spouse / sibling / caregiver / other
created_atTIMESTAMPTZ

Composite PK (patient_profile_id, caregiver_human_id). No organization_id — the link is between two human-scoped concepts and lives outside any org context. Org-level access is still gated on the patient's own patients row at that org, never on a caregiver-of link.

ColumnTypeNotes
idUUID PK
organization_idUUID NOT NULLFK → organizations(id) — tenant isolation
patient_profile_idUUID NOT NULLFK → patient_profiles(id)
consumer_idTEXTExternal system identifier (legacy MiniCRM etc.)
last_used_atTIMESTAMPTZActivity tracker (P35), throttled bump — not audit-logged
deleted_atTIMESTAMPTZSoft delete (P13). Hard delete never permitted
created_at / updated_atTIMESTAMPTZ

Indexes: organization_id, patient_profile_id, a partial organization_id WHERE deleted_at IS NULL for the middleware access check, a partial last_used_at for the "online patients" counter, and the partial unique index patients_profile_org_active_uniq (patient_profile_id, organization_id) WHERE deleted_at IS NULL.

RLS helpers

sql
-- Every patient_profile id the current human can act on:
-- their own profile + every profile they're a registered caregiver for.
-- Returns an empty array for a blocked / soft-deleted principal.
current_human_patient_profile_ids() RETURNS UUID[]

-- "is_member_of(org)" for patients: does the current human have a
-- non-deleted `patients` row at this org, directly or as caregiver?
current_human_is_patient_at(p_org UUID) RETURNS BOOLEAN

Both are STABLE SECURITY DEFINER with SET search_path = pg_catalog, public, pg_temp. SECURITY DEFINER is load-bearing on the first: its body queries patient_profiles, whose own SELECT policy calls it — direct recursion that would blow the stack from an AppPool connection.

The transaction-context wrapper set_app_patient_context(principal_id, org_id) is the patient-session counterpart to set_app_staff_context. It validates that the principal is a human, is active, and has a non-deleted patients row at the org (self or caregiver), then sets app.current_principal_id, app.current_actor_type, app.current_org_id, and app.current_role = '' — patients have no role.

Access control (RLS policies)

patient_profiles (no organization_id — the documented exception):

OpPolicy
SELECTid = ANY(current_human_patient_profile_ids()) — self + caregivers
SELECTorg staff: EXISTS (patients p WHERE p.patient_profile_id = id AND p.organization_id = current_app_org_id() AND p.deleted_at IS NULL)
INSERThuman_id = current_app_principal_id() — self-signup only. Account-less profiles are created via AdminPool
UPDATEid = ANY(current_human_patient_profile_ids()) (USING + WITH CHECK)
DELETENo policy. REVOKE DELETE, TRUNCATE from the app role

patient_caregivers: SELECT for the caregiver themselves or the patient the link points at. No mutation policies at allREVOKE INSERT, UPDATE, DELETE, TRUNCATE; AdminPool only.

patients (org-scoped):

OpPolicy
SELECTstaff: organization_id = current_app_org_id() AND current_app_has_permission('patients', 'view')
SELECTself: patient_profile_id = ANY(current_human_patient_profile_ids()) AND (current_app_org_id() IS NULL OR org matches)
INSERTorganization_id = current_app_org_id() AND current_app_has_permission('patients', 'manage')
UPDATEsame as INSERT (USING + WITH CHECK) — soft delete runs through here
DELETENo policy. REVOKE DELETE, TRUNCATE

Two details worth not "simplifying" away:

  • The staff SELECT gate is patients.view, not a bare current_app_role() <> ''. Patient sessions also carry current_app_org_id() under the post-1.26 model; without the permission gate a patient on a clinic's portal could enumerate every patient at that clinic. Patients hold no permissions (no membership → current_app_has_permission returns FALSE), so the permission check is what actually separates them.
  • The patient self-SELECT is org-gated. Without it, a patient on Clinic B's portal would see their Clinic A patients row alongside the Clinic B one — a tenant bleed through the shared portable identity. Cross-org "all my clinics" surfaces run with no org context, and on those the same policy returns the union.

Permissions

Seeded in 000006, plus impersonation in 000013:

CodeGranted to (system role templates)What it allows
patients.viewadmin, specialist, customer_supportList and read patients at the org
patients.manageadmin, customer_supportCreate, update, archive (soft-delete) patient records
patients.offboardadminWithdraw org_terms on a patient's behalf, triggering the off-boarding cascade
patients.impersonatesee Impersonation →Open a patient impersonation session

Custom per-org roles pick from the same catalog; a no-permission custom role sees zero patient rows, which is the intended behaviour.

API surface (as built)

EndpointGateNotes
GET /v1/organizations/{orgId}/patientspatients.viewPaginated, ?q= trigram/unaccent search on patient_profiles.name; returns the patients row + joined name
POST /v1/organizations/{orgId}/patientspatients.manageLinks an existing patient_profile_id to the org. 409 on duplicate
GET /v1/organizations/{orgId}/patients/{patientId}patients.viewReturns the patients row + joined name, date_of_birth
DELETE /v1/organizations/{orgId}/patients/{patientId}patients.manageSoft delete (archive). 409 if already archived
POST /v1/portal/onboardauthenticated patientSelf-service onboarding — provisions profile + patients row + subscription
POST /v1/me/patient-profileauthenticated patientProfile setup during the handoff/onboarding flow
GET / PATCH /v1/me/patient-profileauthenticated patientThe patient's own full portable profile

Not built: any restore-from-archive endpoint, any ?include_deleted list variant (the data.view_deleted permission exists but the patients list does not consult it), any endpoint that creates a brand-new account-less patient_profiles row on a clinic's behalf, and any caregiver-management endpoint. Full request/response shapes: API Reference →. Flow detail: Onboarding →.

GDPR erasure

Erasure is anonymization, not deletion — Art. 17(3)(c) exempts medical records. internal/core/gdpr/anonymize.go provides the Anonymize(ctx, table, id, []ColumnOverwrite) primitive that rewrites named columns on a single row. It has no callers yet: the erasure orchestration (which rows, in what order, with which columns overwritten) is a later layer. Soft delete on patients is a different operation — it archives the clinical relationship and is reversible in principle; anonymization destroys the PII permanently.

audit_log rows are append-only and never anonymized.

What each clinic sees

What each side can see (see Why there is no profile-sharing consent for the reasoning):

DataTheir clinicAnother clinicNotes
NameNeeds a patients row at the reading org
DOB, sex, phone, occupation, residenceSame scope — the portable profile
Blood type, allergies, chronic conditionsSame scope
Emergency contact, insuranceSame scope
CNP✅ ** patients.view_national_id + an audit row
Protocols / programs at other clinicsOrg-scoped, never crosses
Session runs at other clinicsOrg-scoped, never crosses
Consents granted at other clinicsOrg-scoped, never crosses
Subscriptions at other clinicsOrg-scoped, never shared

Segments

OUT OF SCOPE

Rule-based patient segmentation is not built — no tables, no domain — and is explicitly out of scope for the current platform-completion plan; it belongs to a later patient-data-segmentation feature. See platform-completion.md. The design spec survives at Segments → as a record of intent only; note it predates the current schema and refers to tables that do not exist.