Patient Service
Canvas-first slim rewrite. Two tables. Zero clinical data. Canvas owns medical records; this service owns only identifiers and the HIPAA-grade verbatim intake archive.
4e7dd28.
908 tests passing (573 unit + 322 integration + 3 real-Postgres-skipped). Replaces the pre-Canvas patient-service which carried clinical fields. The new service is a directory plus an append-only compliance log — nothing else.
Overview
Patient-service is the identifier map. Given one of {MRN, Canvas Patient ID, Stripe customer ID, Clerk user ID, legacy Bask MRN, lowercase email}, it returns the patient-directory row that ties them together. It also holds intake_snapshots — the verbatim intake payloads we keep for HIPAA / clinical compliance.
It owns no clinical data. Name, DOB, address, conditions, allergies, medications — all in Canvas. If a sidecar needs those, it fetches live via @yourera/canvas-client.
Canonical spec: /docs/schema/patient-domain.md in the platform monorepo.
Why slim?
Pre-Canvas, patient-service stored clinical fields that were also in Canvas — causing drift, doubled PHI surface area, and reconciliation headaches. The Canvas-first rewrite deletes all of that. Two tables, clear ownership. Clinical truth lives in Canvas; this service is the join key.
Status
| Field | Value |
|---|---|
| Wave | A3 — Identity slim |
| Shipped | 2026-04-21 |
| Commit | 4e7dd28 |
| Tests | 908 (573 unit + 322 integration + 3 real-Postgres-skipped) |
| Spec | /docs/schema/patient-domain.md |
| Route prefix | api.hisera.com/v1/patients/* |
| Dependencies | canvas-client for Canvas Patient writes; organization-service for membership checks |
Database Schema
patient_directory
| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key. Internal surrogate. |
mrn | text | Our MRN. 10-char Crockford base32, CSPRNG-backed. Case-insensitive unique. Mirrored to Canvas.Patient.identifier. |
organization_id | uuid | Set once at creation. Never reassigned through normal flows. |
canvas_patient_id | text | Null→value only. 409 on non-null overwrite. |
stripe_customer_id | text | Null→value only. |
clerk_id | text | Null→value only. |
legacy_bask_mrn | text | Null→value only. Pre-Canvas Bask patient ID, kept for migration joins. |
email_lower | text | Mutable. Used for duplicate-check lookup. |
created_at | timestamptz | Immutable. |
updated_at | timestamptz | Touched on each mutation. |
archived_at | timestamptz | Nullable. Soft-delete marker. |
intake_snapshots
INSERT-only. Never updated, never deleted. Append every completed intake (new patient or re-intake) as a verbatim payload.
| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key. |
patient_id | uuid | FK to patient_directory.id. |
organization_id | uuid | Denormalized for query efficiency. |
intake_type | text | glp-1 / nad / microdose / org-specific types. |
submitted_at | timestamptz | Client-submit time. |
payload | jsonb | Verbatim intake body as submitted. |
payload_hash | text | SHA-256 of payload for dedup detection. |
source_ip | inet | For forensics. |
user_agent | text | For forensics. |
created_at | timestamptz | Row write time. |
Also present: outbox (transactional outbox) and audit_events (admin-action log), shared-pattern tables.
MRN Generation
Our MRN is 10 characters of Crockford base32 — 0-9A-Z minus
I, L, O, U. CSPRNG-backed (crypto.randomBytes).
Case-insensitive unique at the DB level (CITEXT or lowercased functional index).
On collision, we retry up to a small bound (typically 5) before surfacing a 503. The 10-char space is
large enough that collisions at our volume are theoretical, but the retry is a defense against birthday
effects and future scale. The MRN is mirrored to Canvas as Patient.identifier[].value with
system https://yourera.com/mrn.
HTTP Endpoints
Atomic create. Generates MRN, writes Canvas Patient via canvas-client, writes
patient_directory + intake_snapshots + outbox in one
Postgres transaction. See Atomic Create Semantics for failure handling.
Privacy-preserving duplicate check. Requires caller to be a member of body.organizationId.
Returns { exists, in_your_org, status }. See Email Lookup Privacy
— this endpoint has a specific contract that prevents cross-org PHI enumeration.
Read by internal id. requireOrgAccess on the patient's organization_id. Returns directory fields only; no clinical data.
Reverse lookup for webhook dispatch — given a Canvas Patient id, return the directory row.
Lookup by our MRN. Case-insensitive.
Identifier backfill only. See Identifier Backfill Rules for what's mutable. Support role is blocked — read-only. 409 on attempted overwrite of an already-set immutable identifier.
Soft-delete. Sets archived_at and emits patient.archived.
webhook-receiver consumes the event via CanvasSyncWorker and pushes
Patient.active = false to Canvas.
Append a re-intake snapshot. INSERT-only. Does not mutate the directory row.
Compliance read. Lists all snapshots for a patient, newest first. requireOrgAccess.
Compliance read of a single snapshot.
Atomic Create Semantics
POST /patients is the most delicate endpoint. It touches two systems (Canvas + Postgres) and
must either complete fully or cleanly fail. The shape:
- Generate a candidate MRN (retry on DB collision).
- Call
canvas-client.patient.create(…)with the MRN asPatient.identifier. Capture the returnedCanvas Patient.id. - Open Postgres transaction. INSERT into
patient_directorywithcanvas_patient_idalready set. INSERT intointake_snapshots. INSERT intooutboxwithpatient.created. Commit. - Return the new patient.
We write a Canvas orphan log row (a separate best-effort table) so ops can see the stranded Canvas Patient and reconcile manually. The orphan is rare in practice (transaction is small) but the log is a firm requirement of the design — we do not silently leak Canvas resources.
Email Lookup Privacy
POST /patients/email-lookup is called by intake-gateway at step 1 to tell the patient
"we already have you on file, resume?". The privacy contract:
- Caller must include
{ email, organizationId }. - Caller must be a member of
organizationId(enforced viarequireOrgAccess). - Response is the same shape regardless of which orgs the email matches in.
{
"exists": true,
"in_your_org": true,
"status": "active"
}
The patient-facing UI branches: resume (if in_your_org and active), sign-in (if in_your_org and has clerk_id), contact support (if exists elsewhere or archived).
Identifier Backfill Rules
PATCH /patients/:id is deliberately narrow. It exists only to fill in identifiers that get
minted asynchronously after create:
| Field | Rule | On overwrite attempt |
|---|---|---|
canvas_patient_id | null→value only | 409 immutable_identifier |
stripe_customer_id | null→value only | 409 |
clerk_id | null→value only | 409 |
legacy_bask_mrn | null→value only | 409 |
email_lower | Mutable | — |
| Any other field | Not PATCH-able | 400 |
org_admin, org_user, or superadmin.
support is blocked — it is a read-only role.
Archive Flow
- Operator calls
POST /patients/:id/archive(or patient requests deletion). - Service sets
archived_at = now(), INSERTsoutboxwithpatient.archived. - Outbox worker ships to EventBridge.
- webhook-receiver's
CanvasSyncWorker(SQS consumer) receives the event and callscanvas-client.patient.update(id, { active: false }). - Canvas reflects inactive; the intake-snapshots remain for compliance.
We do not hard-delete. Snapshots are an INSERT-only compliance log and must be retained for the HIPAA retention window; the directory row is retained as the join key.
Intake Snapshots
intake_snapshots is the append-only store of verbatim intake payloads. Every completed
intake — initial and re-intake — writes one row. The payload is exactly what the intake
client submitted: no normalization, no stripping, no re-serialization beyond the JSON round-trip. If a
field was collected, it's in the snapshot.
This is the archive the compliance team reads during audits. Canvas holds the clinically normalized view (Observations, Conditions, Consent, etc.); this table holds the raw payload the patient actually submitted at that moment in time. Both are needed: Canvas answers what is clinically current?, snapshots answer what did the patient tell us in 2026?.
Emitted Events
| Event | When | Payload highlights |
|---|---|---|
patient.created |
New patient via POST /patients |
{ patientId, canvasPatientId, mrn, organizationId, contactEmailForMatch }.
Includes contactEmailForMatch for notification-service lead-to-patient
match (acceptable per AWS HIPAA BAA — email alone is not a clinical fact).
|
patient.identifier_updated |
PATCH filled a previously-null identifier |
Diff over canvas_patient_id / stripe_customer_id / clerk_id only.
email_lower and legacy_bask_mrn do NOT emit (email is
mutable and not interesting downstream; bask mrn is a migration artifact).
|
patient.archived |
Archive flow | { patientId, canvasPatientId, organizationId, archivedAt }. Consumed by webhook-receiver CanvasSyncWorker to flip Canvas Patient.active = false. |
Testing
908 tests total:
- 573 unit — pure logic (MRN generator, identifier backfill validator, email-lookup response shaping).
- 322 integration — request-level tests with canvas-client stubbed and outbox verified.
- 3 real-Postgres-skipped — run locally against a real DB for the atomic-create transaction path and collision retry. Skipped in CI to keep the tree hermetic.
Key coverage: the atomic-create orphan path (simulated Postgres commit failure), MRN collision retry bounds, every 409 on immutable identifiers, the email-lookup privacy matrix (in-org / cross-org / not found for each caller role), archive flow end-to-end.
TODOs & Future Waves
- Patient merge. When a patient creates two accounts by mistake (different emails, same human), we need a merge operation. Deferred — operationally rare and needs Canvas-side
Patient.linksupport. - Snapshot pruning policy. We're INSERT-only indefinitely. An eventual archive after N years policy will need legal sign-off.
- Bulk export. No export endpoint today. Compliance gets snapshots via direct DB query; a first-class API with signed-URL delivery is planned.
- Change-of-organization.
organization_idis single-org-for-life. If we ever support rehoming (e.g. an org acquisition), this service will need an explicit migration endpoint with Canvas-sidemanagingOrganizationupdates. - Legacy Bask reconciliation.
legacy_bask_mrnexists for the Bask→Canvas migration window. Once that window closes, we'll plan a column drop.