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 principals → humans → patient_profiles → patients, implemented in services/api/migrations/core/000006_patient_identity.up.sql and live in production.
Corrections applied on this page:
patient_persons→patient_profiles. Not a rename only: the shipped table has aUUIDPK and a nullablehuman_idFK tohumans(principal_id).patient_person_managers→patient_caregivers. Different shape — composite PK(patient_profile_id, caregiver_human_id), no surrogate id, nomanager_user_id.- There is no
userstable and nouser_idcolumn. The login account is aprincipalsrow of typehumanwith ahumansprofile. See decisions.md → Why principals as the root identity. - All PKs are
UUID(UUIDv7 generated Go-side,gen_random_uuid()as the DB default) — neverbigserial/bigint. See P26. - The RLS helper is
current_human_patient_profile_ids() RETURNS UUID[], notcurrent_user_patient_person_ids() → SETOF UUID. There is nocurrent_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 encryptedbytea— see decisions.md → Why most PII is plaintext (and what isn't). - There is no profile-sharing consent.
patients.profile_shared, theprofile_sharingpurpose and its flip trigger were REMOVED 2026-08-20. A clinic reads the portable profile because it holds apatientsrow 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:
patient_profilesrows wherehuman_id = current_app_principal_id()patient_caregivers.patient_profile_idrows wherecaregiver_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.
The org-patient link
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.
Why there is no profile-sharing consent
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
patientsrow at the reading org.patients.FindByID,patients.RenderableProfileFieldsandforms.ResolveProfileValueseach require one (or the caller's own profile, viacurrent_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 byTestPatientProfile_ScopedByRegistration. - RLS on
patient_profiles(patient_profiles_select_org_staff), which decides which rows a staff session may see at all. patients.viewon the staff routes.- The CNP keeps its own restriction, and it is a different kind:
patients.view_national_idon the/national-idreveal endpoint, plus theaudit.ActionReadrow 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:
| Data | Crosses clinics? | Why |
|---|---|---|
| Portable profile (name, DOB, blood type, allergies, insurance) | Yes — on registration | This 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 — never | These 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.sqlis 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
| Column | Type | Notes |
|---|---|---|
id | UUID PK | UUIDv7 Go-side; gen_random_uuid() DB default (P26) |
human_id | UUID UNIQUE NULL | FK → humans(principal_id) ON DELETE SET NULL. NULL = account-less |
name | TEXT NOT NULL | Full name |
date_of_birth | DATE | |
sex | TEXT | CHECK: Male / Female / Other / Prefer not to say |
phone | TEXT | Plaintext — pii_basic. Caller-ID and partial-digit lookup are required clinic features; random-nonce AES-GCM makes them impossible |
occupation | TEXT | |
residence | TEXT | |
blood_type | TEXT | CHECK: A+ A- B+ B- O+ O- AB+ AB- |
allergies | TEXT[] | clinical class |
chronic_conditions | TEXT[] | clinical class |
emergency_contact_name | TEXT | |
emergency_contact_phone | TEXT | Plaintext, same rationale as phone |
insurance_entries | JSONB NOT NULL DEFAULT '[]' | Array of {provider, number, type}; shape validated app-side |
created_at / updated_at | TIMESTAMPTZ | set_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
| Column | Type | Notes |
|---|---|---|
patient_profile_id | UUID NOT NULL | FK → patient_profiles(id) ON DELETE CASCADE |
caregiver_human_id | UUID NOT NULL | FK → humans(principal_id) ON DELETE CASCADE |
relationship | TEXT NOT NULL | CHECK: self / parent / child / spouse / sibling / caregiver / other |
created_at | TIMESTAMPTZ |
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.
patients — org-patient link columns
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
organization_id | UUID NOT NULL | FK → organizations(id) — tenant isolation |
patient_profile_id | UUID NOT NULL | FK → patient_profiles(id) |
consumer_id | TEXT | External system identifier (legacy MiniCRM etc.) |
last_used_at | TIMESTAMPTZ | Activity tracker (P35), throttled bump — not audit-logged |
deleted_at | TIMESTAMPTZ | Soft delete (P13). Hard delete never permitted |
created_at / updated_at | TIMESTAMPTZ |
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
-- 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 BOOLEANBoth 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):
| Op | Policy |
|---|---|
| SELECT | id = ANY(current_human_patient_profile_ids()) — self + caregivers |
| SELECT | org staff: EXISTS (patients p WHERE p.patient_profile_id = id AND p.organization_id = current_app_org_id() AND p.deleted_at IS NULL) |
| INSERT | human_id = current_app_principal_id() — self-signup only. Account-less profiles are created via AdminPool |
| UPDATE | id = ANY(current_human_patient_profile_ids()) (USING + WITH CHECK) |
| DELETE | No 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 all — REVOKE INSERT, UPDATE, DELETE, TRUNCATE; AdminPool only.
patients (org-scoped):
| Op | Policy |
|---|---|
| SELECT | staff: organization_id = current_app_org_id() AND current_app_has_permission('patients', 'view') |
| SELECT | self: patient_profile_id = ANY(current_human_patient_profile_ids()) AND (current_app_org_id() IS NULL OR org matches) |
| INSERT | organization_id = current_app_org_id() AND current_app_has_permission('patients', 'manage') |
| UPDATE | same as INSERT (USING + WITH CHECK) — soft delete runs through here |
| DELETE | No policy. REVOKE DELETE, TRUNCATE |
Two details worth not "simplifying" away:
- The staff SELECT gate is
patients.view, not a barecurrent_app_role() <> ''. Patient sessions also carrycurrent_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_permissionreturns 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
patientsrow 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:
| Code | Granted to (system role templates) | What it allows |
|---|---|---|
patients.view | admin, specialist, customer_support | List and read patients at the org |
patients.manage | admin, customer_support | Create, update, archive (soft-delete) patient records |
patients.offboard | admin | Withdraw org_terms on a patient's behalf, triggering the off-boarding cascade |
patients.impersonate | see 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)
| Endpoint | Gate | Notes |
|---|---|---|
GET /v1/organizations/{orgId}/patients | patients.view | Paginated, ?q= trigram/unaccent search on patient_profiles.name; returns the patients row + joined name |
POST /v1/organizations/{orgId}/patients | patients.manage | Links an existing patient_profile_id to the org. 409 on duplicate |
GET /v1/organizations/{orgId}/patients/{patientId} | patients.view | Returns the patients row + joined name, date_of_birth |
DELETE /v1/organizations/{orgId}/patients/{patientId} | patients.manage | Soft delete (archive). 409 if already archived |
POST /v1/portal/onboard | authenticated patient | Self-service onboarding — provisions profile + patients row + subscription |
POST /v1/me/patient-profile | authenticated patient | Profile setup during the handoff/onboarding flow |
GET / PATCH /v1/me/patient-profile | authenticated patient | The 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):
| Data | Their clinic | Another clinic | Notes |
|---|---|---|---|
| Name | ✅ | ❌ | Needs a patients row at the reading org |
| DOB, sex, phone, occupation, residence | ✅ | ❌ | Same scope — the portable profile |
| Blood type, allergies, chronic conditions | ✅ | ❌ | Same scope |
| Emergency contact, insurance | ✅ | ❌ | Same scope |
| CNP | ✅ * | ❌ | * patients.view_national_id + an audit row |
| Protocols / programs at other clinics | ❌ | ❌ | Org-scoped, never crosses |
| Session runs at other clinics | ❌ | ❌ | Org-scoped, never crosses |
| Consents granted at other clinics | ❌ | ❌ | Org-scoped, never crosses |
| Subscriptions at other clinics | ❌ | ❌ | Org-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.