All work
Healthcare SaaS · Postgres-first

MEPP 2.0
Vendors never see the patient

A system of record for prescribing and provisioning medical equipment. Clinicians, coordinators and outside vendors share one order — but patient identity stays inside a tenant-scoped, encrypted store.

MEPP trust boundary at a glanceClinicianrequestTENANT BOUNDARYAPItenant-scopedEncrypted PHIAES-256-GCMDe-identifiedprojectionVendor

01 · The problem

An equipment order is a privacy problem wearing a logistics costume.

Provisioning needs outside vendors. Vendors need the equipment, the quantity and the window — not the person.

On the order record

  • patient_name
  • patient_address
  • diagnosis_code
  • clinician_notes
  • order_ref
  • equipment_sku
  • quantity
  • delivery_window

What the vendor projection carries

  • patient_name
  • patient_address
  • diagnosis_code
  • clinician_notes
  • order_ref
  • equipment_sku
  • quantity
  • delivery_window

Field names are illustrative of the design intent. Hatched bars are values the vendor projection never selects.

01

Three parties, one order

A clinician prescribes, a coordinator provisions, an external vendor fulfils. Only one of them should ever hold identity.

02

Sharing by convention fails

If the vendor payload is filtered in application code, one forgotten field is a disclosure. The stripping has to be structural.

03

Encrypted data still needs finding

Coordinators search patients by name all day. Encryption that breaks lookup gets removed within a week.

02 · Architecture

One boundary, and a second door that identity cannot walk through.

Everything that can read a patient sits inside a tenant-scoped enclosure. Vendors are served by a separate projection that never selects identity columns.

MEPP architecture: tenant boundary and de-identified vendor pathClinicianweb clientAPI · TypeScriptTENANT BOUNDARY · app.tenant_idPostgreSQL · FORCE RLSnon-BYPASSRLS app roleEncrypted PHIAES-256-GCMBlind indexkeyed HMACDe-identified projectionno identity columnsVendor · fulfilment

Dashed enclosure = the tenant trust boundary enforced by row-level security keyed on app.tenant_id.

RLS lives in SQL

Policies are hand-written in migrations, not generated by the ORM. The database is the last word on tenancy.

FORCE ROW LEVEL SECURITY

Applied on a role without BYPASSRLS, so the application role cannot escape its own policies.

Encrypt at the application

PHI is sealed with AES-256-GCM in utils/phi-encryption.ts before it reaches a column.

Projection, not filtering

The vendor view is a separate de-identified read path. There is no identity to forget to strip.

03 · The hard part

Searching a database that holds no readable identity.

Encryption and lookup pull in opposite directions. The blind index is how the order search survived the encryption.

Two derivations of one patient name: ciphertext and blind indexPatient nameplaintext inputAES-256-GCMrandom nonce per call0x9f3a…c10xb7e0…4dsame name, twice → different bytesRetrievable, not searchableequality lookup impossibleSAME INPUT · SECOND DERIVATIONHMAC · keyeddeterministic digest0x4e11…a9same name, always → same digestIndex-speed lookupB-tree on the digest

The constraint

Patient names had to be encrypted at rest and still be typed into a search box by a coordinator.

Why encryption breaks search

AES-GCM randomises its nonce, so the same name encrypts to different ciphertext every time. Nothing matches.

The resolution

Store two derivations of the same value: ciphertext for retrieval, a keyed HMAC digest as a blind index for equality.

The result: index-speed patient lookup over a database that stores no readable identity.

04 · Order lifecycle

Nineteen states, and no way to move between them quietly.

The order lifecycle is a two-level state machine. Every accepted transition leaves evidence the application layer cannot rewrite.

Order state transition mechanicsTransition requestGuardis this move legal?RejectedunchangedNew statetwo levelsEFFECTSAudit triggerwritten by the databaseVersion rowappend-only order editssecurity_incidentsrecorded, never swallowed
19 states · two levels31 hand-authored SQL migrations112 testsOpenAPI generated from the routes

05 · What actually exists

The honest inventory.

Counted from the repository, not estimated. What follows is what is built — not what is running.

200

commits

440

files

112

tests

31

SQL migrations

0

CI pipelines

Where it stands

Status
Backend complete. Stalled since June 2026.
Deployment
Never deployed. No users, no production data.
Data
Synthetic records only, throughout.
Team
Built solo, with no CI to catch me.

Privacy that survives contact with a search box.

Happy to walk through the RLS policies, the blind-index derivation, or why the vendor projection is a separate read path rather than a filter.

Let's build the next healthcare system.

I'm running a multi-site hospital equipment-tracking and analytics system in production, and I'm open to healthcare data, analytics, AI, and application engineering roles — remote, hybrid, or on-site. The best way in is a short conversation.

Start a conversation

Find me on