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.

Shipped in Wave A3 · 2026-04-21 · commit 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

FieldValue
WaveA3 — Identity slim
Shipped2026-04-21
Commit4e7dd28
Tests908 (573 unit + 322 integration + 3 real-Postgres-skipped)
Spec/docs/schema/patient-domain.md
Route prefixapi.hisera.com/v1/patients/*
Dependenciescanvas-client for Canvas Patient writes; organization-service for membership checks

Database Schema

patient_directory

ColumnTypeNotes
iduuidPrimary key. Internal surrogate.
mrntextOur MRN. 10-char Crockford base32, CSPRNG-backed. Case-insensitive unique. Mirrored to Canvas.Patient.identifier.
organization_iduuidSet once at creation. Never reassigned through normal flows.
canvas_patient_idtextNull→value only. 409 on non-null overwrite.
stripe_customer_idtextNull→value only.
clerk_idtextNull→value only.
legacy_bask_mrntextNull→value only. Pre-Canvas Bask patient ID, kept for migration joins.
email_lowertextMutable. Used for duplicate-check lookup.
created_attimestamptzImmutable.
updated_attimestamptzTouched on each mutation.
archived_attimestamptzNullable. Soft-delete marker.

intake_snapshots

INSERT-only. Never updated, never deleted. Append every completed intake (new patient or re-intake) as a verbatim payload.

ColumnTypeNotes
iduuidPrimary key.
patient_iduuidFK to patient_directory.id.
organization_iduuidDenormalized for query efficiency.
intake_typetextglp-1 / nad / microdose / org-specific types.
submitted_attimestamptzClient-submit time.
payloadjsonbVerbatim intake body as submitted.
payload_hashtextSHA-256 of payload for dedup detection.
source_ipinetFor forensics.
user_agenttextFor forensics.
created_attimestamptzRow 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 base320-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

POST /patients

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.

POST /patients/email-lookup

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.

GET /patients/:id

Read by internal id. requireOrgAccess on the patient's organization_id. Returns directory fields only; no clinical data.

GET /patients/by-canvas-id/:canvasPatientId

Reverse lookup for webhook dispatch — given a Canvas Patient id, return the directory row.

GET /patients/by-mrn/:mrn

Lookup by our MRN. Case-insensitive.

PATCH /patients/:id

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.

POST /patients/:id/archive

Soft-delete. Sets archived_at and emits patient.archived. webhook-receiver consumes the event via CanvasSyncWorker and pushes Patient.active = false to Canvas.

POST /patients/:id/intake-snapshots

Append a re-intake snapshot. INSERT-only. Does not mutate the directory row.

GET /patients/:id/intake-snapshots

Compliance read. Lists all snapshots for a patient, newest first. requireOrgAccess.

GET /patients/:id/intake-snapshots/:snapshotId

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:

  1. Generate a candidate MRN (retry on DB collision).
  2. Call canvas-client.patient.create(…) with the MRN as Patient.identifier. Capture the returned Canvas Patient.id.
  3. Open Postgres transaction. INSERT into patient_directory with canvas_patient_id already set. INSERT into intake_snapshots. INSERT into outbox with patient.created. Commit.
  4. Return the new patient.
Failure case: Canvas write succeeds, Postgres commit fails.

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.

MRN collision retry is bounded. Five attempts. After that we 503, on the theory that a genuine exhaustion or a pathological DB problem needs a human.

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:

Why this matters (C-1 fix). A naive implementation returning "yes, this email exists at org X" would let any tenant enumerate which patients exist at competitor tenants. The endpoint explicitly never leaks which org a cross-org match lives in; it only says whether the email exists in your org and whether it exists anywhere (as a yes/no for the duplicate-detection UX).
{
  "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:

FieldRuleOn overwrite attempt
canvas_patient_idnull→value only409 immutable_identifier
stripe_customer_idnull→value only409
clerk_idnull→value only409
legacy_bask_mrnnull→value only409
email_lowerMutable
Any other fieldNot PATCH-able400
Role gate. PATCH requires org_admin, org_user, or superadmin. support is blocked — it is a read-only role.

Archive Flow

  1. Operator calls POST /patients/:id/archive (or patient requests deletion).
  2. Service sets archived_at = now(), INSERTs outbox with patient.archived.
  3. Outbox worker ships to EventBridge.
  4. webhook-receiver's CanvasSyncWorker (SQS consumer) receives the event and calls canvas-client.patient.update(id, { active: false }).
  5. 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

EventWhenPayload 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:

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