Skip to content

Patient Onboarding Flow

Stale in places post Layer-1.24 Go cleanup. Schema/column references that mention humans.clerk_user_id should now read humans.provider_subject_id. The flow shape is unchanged. Pending a holistic rewrite alongside the next patient-flow change. Verify against the code before quoting. Authoritative pointer: decisions.md → Auth chain shape.

Overview

Patient onboarding is the process of provisioning a complete patient identity at a clinic — portable profile, per-org link, default-tier subscription, and the consents required to use the platform — in a single atomic transaction.

There are two onboarding paths, mounted on different surfaces:

PathEndpointCallerWhen to use
Patient self-servicePOST /v1/portal/onboardThe patient (authenticated via Clerk)Patient lands on a clinic's portal subdomain, signs up, and onboards themselves at that clinic.
Clinic admin onboardingPOST /v1/organizations/{org_id}/patientsClinic staff with patients.manageStaff onboards a patient on their behalf — typical when a patient calls in or visits the clinic without using the portal.

Both paths converge on the same downstream state: one patient_profiles row (created or reused), one patients row at the org, one patient_subscriptions row pointing at the org's default tier, and the consent rows required by foundation 1B.9.

Identity model

The platform uses the principal model from foundation 1B.1. There is no users table.

principals          → root identity (UUID PK; type='human' for patients)
humans              → human profile (PK = principal_id; clerk_user_id, email, blocked, etc.)
patient_profiles    → portable patient profile (UUID PK; human_id FK to humans;
                      no organization_id; portable across clinics)
patient_caregivers  → optional links allowing a caregiver human to manage a patient profile
patients            → per-org link (UUID PK; patient_profile_id, organization_id,
                      profile_shared, consumer_id, last_used_at, deleted_at)
patient_subscriptions → per-patient tier subscription (UUID PK; patient_id, tier_id,
                        status, snapshot tables for features/limits)

Existence of a patients row at an org is what grants portal access at that org — there is no patient system role, no app.access_portal permission. See decisions.md → Why patients are not memberships.

A patient profile is portable: one human can be a patient at multiple clinics with the same patient_profiles row. Each clinic has its own patients row pointing at that profile. By default a clinic only sees the patient's name; the rest of the portable profile (DOB, blood type, allergies, insurance) becomes visible at that clinic only after the patient grants the profile_sharing consent.


Patient self-service: POST /v1/portal/onboard

The patient lands on clinicname.portal.restartix.pro, signs up via Clerk, and the portal calls this endpoint to provision the rest of their identity at this clinic.

Mounting

The route is mounted outside OrganizationContext middleware. A fresh human with no patients row would be 403'd by that gate; portal onboarding is the bootstrap that creates the patients row in the first place. The endpoint reads X-Organization-ID from the request header and resolves the target org directly.

Idempotency

Re-onboarding the same human at the same org returns 200 OK with the existing chain — no duplicate rows, no audit entries. Idempotency is keyed by (human_id, organization_id).

Step-by-step

1. Validate input + auth
   ├─ Bearer token (Clerk JWT) → resolves to a humans row
   ├─ X-Organization-ID header → resolves the target organization
   └─ Body: optional patient_profile fields (date_of_birth, sex, residence, etc.)

2. Idempotency check
   └─ If a patients row already exists for (human_id, organization_id):
        return existing chain (profile + patient + subscription) — 200 OK
        no audit log rows written

3. Resolve or create patient_profile
   ├─ If this human already has a patient_profiles row (registered at another clinic):
   │    reuse it — same UUID, same portable data
   └─ Otherwise:
        create patient_profiles
        ├─ human_id (FK to humans.principal_id)
        ├─ phone_encrypted (AES-256-GCM via 1A.3)
        ├─ emergency_contact_phone_encrypted
        └─ other portable fields from request body

4. Create patients row (per-org link)
   ├─ patient_profile_id (from step 3)
   ├─ organization_id (from header)
   ├─ profile_shared = false (flips when profile_sharing consent is granted)
   └─ consumer_id = NULL (set later by clinic admin if there's a legacy ID)

5. Create patient_subscriptions row
   ├─ patient_id (from step 4)
   ├─ tier_id ← org's default patient_tier (from patient_tiers WHERE
   │            organization_id = ? AND is_default = TRUE)
   ├─ tier_version (snapshot ref)
   ├─ status = 'active'
   └─ Snapshot patient_subscription_entitlements + patient_subscription_limits
      from the tier's current published version (P14b — frozen at subscribe time)

6. Write required consent rows (foundation 1B.9)
   ├─ platform_terms — current version, source='signup_checkbox',
   │                    legal_basis='contract', non-withdrawable
   ├─ platform_privacy_notice — current version, source='signup_checkbox',
   │                             legal_basis='legitimate_interest', non-withdrawable
   ├─ org_terms — if the clinic has published a custom one (current version,
   │              source='signup_checkbox')
   ├─ org_privacy_notice — current published version (source='signup_checkbox')
   └─ Optional toggles (marketing_email / marketing_sms / analytics /
      ai_processing / profile_sharing) — written if the patient ticked them
      at sign-up; source='signup_checkbox'; can be flipped later via
      self-toggle from /v1/me/consents

7. Audit log
   └─ Three rows: patient_profiles.CREATE, patients.CREATE,
      patient_subscriptions.CREATE — actor_id = the human's principal,
      actor_type = 'human'

8. Publish event (foundation 1B.8 + 1A.9)
   └─ patient.onboarded
      ├─ patient_id (UUID)
      ├─ patient_profile_id (UUID)
      ├─ organization_id (UUID)
      ├─ human_id (UUID)
      └─ profile_was_existing (boolean — true if step 3 reused a profile)

9. Return response
   └─ patient + patient_profile + patient_subscription

The whole flow runs inside one AdminPool transaction. If any step fails, the entire operation rolls back — no partial chains.

Request

bash
POST /v1/portal/onboard
Content-Type: application/json
Authorization: Bearer <clerk_jwt>
X-Organization-ID: 9f8e7d6c-5b4a-3210-fedc-ba9876543210

{
  "patient_profile": {
    "date_of_birth": "1985-03-12",
    "sex": "male",
    "residence": "București",
    "phone": "+40712345678"
  },
  "consent_grants": {
    "platform_terms": true,
    "platform_privacy_notice": true,
    "org_terms": true,
    "org_privacy_notice": true,
    "marketing_email": false,
    "marketing_sms": false,
    "analytics": true,
    "ai_processing": false,
    "profile_sharing": false
  }
}

The required platform + org consents must be true for the request to succeed. Optional toggles default to false and can be flipped from settings later.

Response (new patient)

json
{
  "data": {
    "patient_profile": {
      "id": "11111111-1111-1111-1111-111111111111",
      "human_id": "22222222-2222-2222-2222-222222222222",
      "date_of_birth": "1985-03-12",
      "sex": "male",
      "residence": "București"
    },
    "patient": {
      "id": "33333333-3333-3333-3333-333333333333",
      "patient_profile_id": "11111111-1111-1111-1111-111111111111",
      "organization_id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
      "profile_shared": false,
      "consumer_id": null,
      "created_at": "2026-04-30T10:00:00Z"
    },
    "patient_subscription": {
      "id": "44444444-4444-4444-4444-444444444444",
      "patient_id": "33333333-3333-3333-3333-333333333333",
      "tier_id": "55555555-5555-5555-5555-555555555555",
      "tier_version": 1,
      "status": "active",
      "current_period_starts_at": "2026-04-30T10:00:00Z",
      "current_period_ends_at": null
    },
    "consents_recorded": [
      "platform_terms",
      "platform_privacy_notice",
      "org_terms",
      "org_privacy_notice",
      "analytics"
    ],
    "profile_was_existing": false
  }
}

Response (existing profile, new clinic)

If the human is already a patient at another clinic, profile_was_existing is true and patient_profile.id is the existing UUID. The new patients row, patient_subscriptions row, and org-scope consent rows are still created. Platform-scope consents (platform_terms, platform_privacy_notice) are not duplicated — the platform consents from the human's first sign-up still apply across all clinics.

The patient_profile block returns only id, human_id, and name until profile_sharing consent is granted. Other portable fields (DOB, allergies, etc.) are filtered server-side based on patients.profile_shared.


Clinic admin onboarding: POST /v1/organizations/{org_id}/patients

Used when clinic staff onboard a patient on the patient's behalf — phone bookings, walk-ins, legacy data import, etc. Requires the patients.manage permission (granted to admin + customer_support by default).

Differences from self-service

  • Caller is staff, not the patient. Audit rows attribute to the staff principal.
  • Patient may not have a Clerk account yet. The endpoint can create a humans row from email + phone without provisioning Clerk; the patient gets a Clerk invite separately when they first need to sign in.
  • Consent capture is delegated — staff records that the patient verbally agreed to the platform terms / clinic privacy notice. Tier B medical consents (treatment, video recording, biometric) still require signed forms (F3.5) once the patient interacts with those flows.

Request

bash
POST /v1/organizations/9f8e7d6c-5b4a-3210-fedc-ba9876543210/patients
Content-Type: application/json
Authorization: Bearer <staff_token>

{
  "patient_profile": {
    "name": "Andrei Popescu",
    "email": "[email protected]",
    "phone": "+40712345678",
    "date_of_birth": "1985-03-12"
  },
  "consumer_id": "legacy-imported-1234",
  "staff_recorded_consents": {
    "platform_terms": true,
    "org_terms": true,
    "org_privacy_notice": true
  }
}

staff_recorded_consents produces ledger rows with source='staff_action', recording the staff principal as granted_by_principal_id and the patient as the subject. The reason for the staff-action is captured in the audit row.

Response

Same shape as the portal endpoint, plus:

  • clerk_invite_sent: true | false — whether a Clerk sign-up invite was emailed
  • consents_pending: ["platform_privacy_notice", "marketing_email", ...] — purposes the patient still needs to accept themselves on first portal sign-in (the re-consent middleware in 1B.9 will prompt them)

A patient's portable profile is by default visible only to clinics where patients.profile_shared = TRUE. The flip happens when the patient grants the profile_sharing consent (org-scope, withdrawable, Tier A toggle in 1B.9):

patient grants profile_sharing → consents row inserted
                              → trigger sets patients.profile_shared = TRUE
                              → portable profile now visible to that clinic's staff

patient withdraws profile_sharing → consents row gets withdrawn_at set
                                  → trigger sets patients.profile_shared = FALSE
                                  → clinic's staff sees only the patient's name again

Clinical records the clinic created while profile_shared = TRUE (appointments, signed forms, treatment plans) remain at the clinic — they're the clinic's records, not the patient's portable data. Withdrawal stops new access, not retroactive forgetting.


Caregiver onboarding (deferred)

A patient who can't manage their own account (a child, an elderly parent without a smartphone) is provisioned by a caregiver. The caregiver — themselves a human with their own humans row — holds a patient_caregivers link granting them management rights over the patient's profile.

Schema is shipped (foundation 1B.6); the caregiver-onboarding admin endpoint is deferred until a real product use case (see foundation 1B.8 open items).


Permissions and RLS

ActionPermissionPoolNotes
Patient self-onboard(none — auth-only)AdminPool (transaction)Mounted outside OrganizationContext
Clinic admin onboardpatients.manageAdminPool (transaction)Granted to admin + customer_support
Read patient list at orgpatients.viewAppPool (RLS-scoped)Granted to specialist + customer_support + admin
Patient reads own profile(RLS via current_human_patient_profile_ids())AppPoolNo permission grant needed
Update consumer_idpatients.manageAppPoolSoft-delete via deleted_at; RLS blocks DELETE

See reference/rbac-permissions.md → Patients for the full grant matrix.


Encryption

Per foundation 1A.3:

  • patient_profiles.phone_encrypted BYTEA — AES-256-GCM, KMS-backed in production
  • patient_profiles.emergency_contact_phone_encrypted BYTEA — same
  • Encryption is applied at the repository boundary; handlers see plaintext

Audit

Three rows per non-idempotent onboard:

json
[
  {
    "action": "CREATE",
    "entity_type": "patient_profile",
    "entity_id": "11111111-1111-1111-1111-111111111111",
    "actor_id": "<the human's principal_id, or staff principal for admin onboard>",
    "actor_type": "human",
    "organization_id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210"
  },
  {
    "action": "CREATE",
    "entity_type": "patient",
    "entity_id": "33333333-3333-3333-3333-333333333333"
  },
  {
    "action": "CREATE",
    "entity_type": "patient_subscription",
    "entity_id": "44444444-4444-4444-4444-444444444444"
  }
]

Plus N rows for the consent grants written in step 6, with entity_type='consent'.

The idempotent path produces zero audit rows — re-onboarding the same human at the same org returns the existing chain without writes.