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.

Current behaviour (2026-09-23) — read this before the rest. The Portal has ONE joining flow, /welcome (/onboard redirects to it); the design is Onboarding & Legacy Linking. What changed underneath, all enforced by the API:

  • Who may join: a clinic with portal_self_signup_enabled admits anyone signed in; one without it admits the people it invited — an accepted patient invitation, or an active share link it issued. The invitation used to be checked only after the sign-up gate, so invited patients at a closed clinic could not join.
  • With what: creating a profile requires name, phone and date of birth, and joining refuses a profile missing any of them (409 profile_incomplete).
  • Not a minor, alone: a date of birth under 18 is refused with 403 minor_self_signup — the documents are contract-basis; a parent or guardian registers the child as a family member.
  • No auto-subscription: joining creates the patients row; a subscription exists only when an invitation or share link pins a tier. Statements below that say otherwise predate the 2026-05-31 decision.

Overview ​

Patient onboarding is the process of provisioning a complete patient identity at a clinic — portable profile, per-org link, a subscription only when an invitation or share link pins a tier (onboarding never auto-subscribes), 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,
                      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, and that row is what discloses the profile to that clinic — a clinic without one sees nothing. There is no second per-field gate above it (see P8 — retired).


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)
   └─ 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) — 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
  }
}

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",
      "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 the profile as the new clinic may see it — which, because the patients row now exists, is the full portable record. That is the point of the profile being portable: the patient does not re-enter their date of birth at every clinic.


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": "andrei@example.com",
    "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)

Removed 2026-08-20, along with patients.profile_shared, the profile_sharing purpose and the trigger between them. Rationale in P8 — retired.

The short version: the flag defaulted to FALSE and the consent rendered as an optional toggle beside marketing email, so the ordinary sign-up left the treating clinic unable to see the patient's allergies — and consent was the wrong legal basis for treatment data anyway (Art. 6(1)(b) + Art. 9(2)(h), not Art. 9(2)(a)).

Creating the patients row IS the disclosure. Onboarding at a clinic hands that clinic the portable profile, exactly as filling in a paper intake form would. What the patient does not want a clinic to hold, they clear from their profile — which is the data minimisation an all-or-nothing toggle could never express.

Clinical records a clinic creates (appointments, signed forms, treatment plans) stay with the clinic regardless: they are the clinic's records, not the patient's portable data. Leaving a clinic (withdrawing org_terms) soft-deletes the patients row, which ends the clinic's access to the portable profile going forward — it is 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.