PHI in Foundry using open source models, part 1: Building a PHI gate

Nov 11, 2026

Ronit Virwani

21 min read

Our benchmark passed. The note didn't.

We were building a gate to strip PHI from clinical notes before an external model read them, and we had a table that said it worked.

Patient names, every spelling: 100%. Dates, phones, member IDs: 100%. Relatives: 98.6%. False alarms: 0.1%. Five hundred synthetic chart sentences with planted identifiers, seeded, repeatable.

Then we put an eyelid-surgery demo chart through the gate.

DIAGNOSIS: [LOCATION_2], bilateral, causing functional visual impairment.
VISUAL FIELDS ([DATE_1:DOS-125D]): [LOCATION_1] 30-2 superior field...

[LOCATION_2] had been Dermatochalasis, the diagnosis. [LOCATION_1] had been Humphrey, the visual field analyzer.

Our detector thought both were places.

In the next two notes a CEAP class became an organisation. So did "INITIAL DIAGNOSTIC". So did "NPO". The gate was removing identifiers, and some of the medicine with them.

A model reading that output to judge medical necessity would be judging a chart with the diagnosis missing. Its answer would be wrong, and we'd have gone looking for the bug in the evaluator.

The damage happened before the evaluator saw anything.

Why we needed the gate

MedKong is a kit of deployable modules for healthcare revenue cycle operations (eligibility, prior authorization, charge capture, coding review, claim QA, denials, posting and AR) plus a review system for Medicare Administrative Contractors. AKOS deploys each module as versioned infrastructure into your environment; you run it, govern it and extend it. This story comes from the prior-authorization module, at the step that reads the chart.

Medicare requires prior authorization for a short list of outpatient procedures: eyelid surgery, vein ablation, spinal injections, a few more. The hospital sends the contractor a packet. A nurse reads it against a published checklist, twenty-nine items for an eyelid case, and every yes has to point at a sentence in the chart.

MedKong does that reading on the hospital's side first. Most of its checks are rules on structured data. Some have to read the notes: is this requirement documented, and which sentence supports it. A quoted sentence is accepted only if it exists in the note, character for character. A convincing explanation with no passage behind it doesn't earn a yes.

We wanted Jev, an external model, to read the same notes as an independent check. That meant preparing clinical text for a recipient on the other side of our boundary.

A consult note carries the patient's name, date of birth, Medicare number, a daughter's name in the plan, the surgeon who signed, the clinic's fax line, and a date on almost every event. HIPAA's Safe Harbor rule lists eighteen kinds of identifier. A single note usually has half of them, scattered through the narrative, not just in the header.

But removing everything that looks suspicious doesn't work either. The evaluator still needs the diagnosis, the device, the findings, the treatment history.

Then there's time. A requirement might ask whether conservative treatment ran three months before the procedure. Remove the dates and the evidence to answer that goes with them.

So the gate had two jobs: remove the person and keep the medicine. We had measured the first one much more carefully than the second.

The gate in one picture

We put the gate immediately before the model call, with real values restored immediately after.

Not at ingestion. The rule-based steps before the model need real dates and real member IDs, and nothing leaves the platform through those steps anyway. One gate, one place, and both of MedKong's workflows (the hospital's determination and the contractor-side review) funnel through it.

We also stopped copying structured fields into anything model-facing. Patient id, plan id, inquiry id, note ids, attachment RIDs, filenames, full dates: the model doesn't need any of them to read a sentence. We use those values for one thing, finding their own occurrences in the narrative, and give every source a request-local alias like NOTE_1 or attachment-2.pdf instead.

Half the work is deciding what never needs to be serialized.

What's left goes through five layers:

The five layers of the PHI gate, from ontology values to the sealed request

Five layers. The first three find; overlaps are resolved once; dates become day counts; the last layer checks the exact request.

LayerWhat it does
1. Known valuesFinds the identifiers we already hold in the Ontology, in every spelling a chart uses. Never guesses. Wins every overlap.
2. PatternsFinds shaped values: phones, MBIs, SSNs, addresses, ages over 89, dates in ten formats. Plus two cheap rules that caught more than any model: the word after "Dr." and the word after "daughter".
3. Clinical NERFinds the people, places and organisations nobody told us about. The only layer that needs a model.
4. DatesTurns calendar dates into day counts from the planned date of service. The anchor is never sent.
5. Outbound validationScans the assembled request, keys and values, then serializes and seals it. The Jev client accepts nothing else.

Tokens are numbered and consistent across the request. The patient is [PATIENT_1] in every note. Three spellings don't become three people.

A synthetic note, through all five:

BEFORE
Maria Santos, DOB 04/11/1953, reports worsening ptosis.
Her daughter Sofia Santos drives her to appointments.
Exam date: 05/01/2026. Humphrey 30-2 superior field 38% OD.
Interpreted by Dr. Emily Carter, MD.

AFTER
[PATIENT_1], DOB [DOB], reports worsening ptosis.
Her daughter [PERSON_1] drives her to appointments.
Exam date: [DATE_1:DOS-92D]. Humphrey 30-2 superior field 38% OD.
Interpreted by [CLINICIAN_1], MD.

A synthetic note going through the gate one identifier at a time, ending on the sealed request

One synthetic note, one identifier at a time.

The names and the calendar are gone. Ptosis, Humphrey and the field measurement are still there.

That's the intended output. Here are the five mistakes we made getting to it, in the order we made them.

Mistake 1: we replaced as we found

Our first redactor worked in a loop. Find the patient's name, replace it. Find the date of birth, replace it. Keep going.

It passed the early tests.

It was also wrong in two ways that only show up together. Every replacement shifts the position of everything after it, so a span map built from the original offsets drifts by the length difference of every token before it. And a later pattern can match text we inserted ourselves. Our age rule saw our own 90+ replacement and flagged it as another identifier.

The second scan blocked the note. Correct behaviour, inconvenient bug.

We changed the redactor to find first and replace once. Every layer reports what it found as { start, end, kind, entityKey } against the untouched original. Then overlaps are resolved in one pass: the longer match wins; on a tie, known values beat patterns and patterns beat the model. Then the output is built once, left to right, recording for every token where it sat in the original and where it sits now.

The span map falls out of that. It's correct by construction, which matters on the return trip: an accepted evidence quote has to map back to the original passage, and if it could map to two different passages we reject it as ambiguous rather than guess. Model-written rationales stay tokenized. We don't give invented sentences real names.

Layer 1 does the heavy lifting here, so it's worth saying what "every spelling" means. John O'Brien, O'Brien, John, J. O'Brien, OBrien, O Brien, the surname alone, the possessive, and the OCR look-alikes a scanned PDF produces (0'Br1en). DOB in ten formats. The MBI with and without dashes. Clinician names with Dr. in front or MD after. Values under three characters are skipped and the skip is recorded as a caveat on the run.

And the second scan stays. Layers 1 and 2 run again over the redacted output. Any hit means the redactor has a bug, so the source is blocked and the requirements that depended on it come back NOT_EVALUATED with a reason. The scan exists to catch us, not the note.

The lesson: collect everything you found against the original, resolve once, build once. Then write the test that runs your detector over its own output. It's the cheapest insurance in the project.

Mistake 2: we trusted the model's offsets

The detector runs in Python. The gate runs in TypeScript.

Python string indexes count Unicode code points. JavaScript string indexes count UTF-16 code units. They agree on almost everything, and they disagree the moment a character outside the Basic Multilingual Plane appears before a detected span. An emoji in a patient-portal message is the easy case.

When the two sides disagree, the detector finds the right name and the gate redacts the wrong slice. n O'Brie stays in the text, and nothing says so.

We made the module return UTF-16 offsets, computed the boring way, plus the matched text:

def _utf16(text: str, index: int) -> int:
    # Python counts Unicode code points; JavaScript counts UTF-16 code units.
    return len(text[:index].encode("utf-16-le")) // 2

The receiving side checks text.slice(start, end) === matched on every span.

Now an offset error is visible. Today our implementation drops a span that fails that check and logs the count. That is a limitation, not a feature: a dropped span doesn't remove the name it was pointing at. The right behaviour is to block the source the same way a detector outage does, and that change is on the list.

If the detector itself is unreachable, the request is blocked. We don't quietly send a result produced by the deterministic layers alone.

The lesson: whatever language boundary your model sits behind, return the matched text next to the offsets and verify the slice on the other side. Then decide, in advance, what a failed slice means. "Logged" is not a policy.

Mistake 3: we fought the compute module

Presidio is Microsoft's open source PII framework: recognizers, a swappable NLP engine, offsets back. We ran it as a Foundry compute module in Functions mode, built from the in-platform Python Compute Module template. Libraries go in conda_recipe/meta.yaml, you commit, you tag a version, Foundry builds the image. No local Docker.

A compute module and not a Python function because the detector is a process that keeps a model warm, with its own resources, its own startup logs, and a hard cap on inputs per call. A function that reloads 1.4 GB of weights per invocation isn't a detector. It's a timeout.

Three deployment failures are worth passing on. The first two cost about an hour each. The third cost an afternoon.

The module came up with no functions. Conda's spaCy build didn't pull click. The import failed. The Query tab said:

Unknown query type: find_phi. Known query runners: dict_keys([])

It looks like routing. It's an import traceback forty lines up in the startup log.

We started a runner the template already runs. We called start_compute_module() ourselves, the way an example from a different deployment path showed. Foundry's runner already loads app.py and serves the decorated functions, so our call started a second loop that never returned. The replica went UNRESPONSIVE after about ninety seconds, with no traceback, every time.

We loaded the model on the first request. This is the one I still think about, because the platform's error pointed us the wrong way and we followed it.

Lazy loading is what you'd do anywhere else. Why pay forty seconds of startup for a replica that might never get a request. The module came up green. First call, nothing. Then the replica restarted and the stage details said "container restart (OOM/resource issues)". So we went to the memory graph. Flat at 15%. We doubled the memory anyway. Same thing.

We chased memory for an afternoon.

What was actually happening: the runner finishes importing app.py on the main thread and then hands requests to a worker thread. At that point Python considers the interpreter to be shutting down. The first time anything in that worker tries to import a module that registers a cleanup hook, you get can't register atexit after shutdown. tldextract does that, and Presidio's URL recognizer pulls it in. A 1.4 GB model load in that same thread never finishes. No traceback. The platform saw a replica that stopped answering and reached for the only explanation it had.

The fix is one rule. Every import, the model load, and one warm-up inference happen at the top of the file, on the main thread, before the runner sees a request. Forty seconds at startup, every time. We'll take it.

import os
os.environ.setdefault("OMP_NUM_THREADS", "1")        # before numpy and torch import
os.environ.setdefault("HF_HUB_OFFLINE", "1")         # never try to reach Hugging Face
import concurrent.futures.thread                     # first-time import on the main thread, on purpose
import torch, transformers

MODEL = _load_from(os.environ["DEID_MODEL_DIR"])     # load at import
_warm = MODEL.analyze("Warm-up sentence with Jane Doe in it.", 0.5)
logger.info("model ready", extra={"warm_spans": len(_warm)})   # counts, never text

After that, "ready" means the model has loaded and answered once. Not that the process exists.

The lesson: read the startup log from the top after every tag, follow the lifecycle of the template you're actually using, and do every expensive thing on the main thread before the first request. Record the template and package versions next to the fix. The next engineer needs those with the error message.

Mistake 4: we measured the wrong thing

Our synthetic benchmark was useful. Sentence templates with slots, pools of made-up values in every format a chart uses, lowercase, split across a line break, OCR-garbled, a seed so runs repeat. We planted the identifiers, so we knew where every one was.

It found real bugs in an afternoon. Phones with no separators. Multi-word surnames matched only on the last word. An account-number label with a typo. Relatives went from 73.6% to 98.6% before we touched a model, from the "word after daughter" rule alone.

Then the benchmark said 0.1% false alarms while demo charts were losing diagnoses.

My sentences were plain English. Real notes were:

CEAP clinical class C2s with C3 edema
MRD1 1.0 mm OU
DIAGNOSIS: Dermatochalasis

The benchmark's author, me, doesn't write like a surgeon.

The model behind our Presidio setup was spaCy's general English model, the default, trained on news and web text. In news text a capitalised token next to digits is usually an organisation, and a capitalised word after a colon is usually a place. The model was doing exactly what it was trained to do. Our benchmark couldn't see the failure because the benchmark barely contained the kind of text that triggers it.

Here's the honest part. We shipped that version internally. We wrote it up. We told the team the gate was ready for single-sentence payloads and close for whole notes. The numbers supported it.

If nobody had asked to see two full charts before and after, we'd have put an evaluator in front of documentation we'd damaged.

We looked because someone asked. That's not a process.

Now it is. Before any model swap or rule change, the same three demo inquiries go through, and someone reads the after-text like a reviewer. A percentage doesn't replace that.

A model that knows medicine

Two ways out. Word lists (Census surnames, place names, a medical vocabulary from your own policy text) plus context rules, which is the 2008 PhysioNet deid approach. It works, it's a maintenance job, and its lists are GPL. Or swap the model for one trained on clinical notes, which fixes the cause.

We swapped. obi/deid_roberta_i2b2: RoBERTa-large fine-tuned on the i2b2 2014 de-identification corpus, MIT licence, eleven PHI labels. We map them to the three kinds the gate wants. PATIENT and STAFF both count as people, because a wrong role doesn't make a name safe to keep. LOC is a location. HOSP and PATORG are organisations. DATE, AGE, ID, PHONE and EMAIL are dropped; the deterministic layers already own those.

Getting the weights into an offline container took longer than changing the inference call. The module has no internet at build or run. So: download once outside Foundry, keep only model.safetensors (the repo ships the weights twice; the .bin copy is another 1.4 GB and loads through pickle), package the folder as a conda package, upload it to a Foundry Artifacts repository, add that repository as a backing library source, list the package in meta.yaml. The compute module loads from disk. The model is now a versioned artifact with no runtime dependency on anyone's CDN.

The clinical model packaged as a conda artifact in a Foundry Artifacts repository, version 0.1.0, noarch

The model weights as a versioned conda package. Nothing is fetched from Hugging Face at build or run.

With 2 CPU, 6 GB, one warm replica and concurrency 1, startup takes about 40 seconds and a sentence takes about a quarter of a second. A whole case, notes and PDFs, is a few seconds.

Screenshot-2026-10-05-at-7.31.15-PM.png

Same 500 sentences, same three demo inquiries, after the swap:

General modelClinical model
Patient names, DOB, member IDs, dates, phones, ids, clinicians100%100%
Relatives98.6%100%
Cities85.0%97.5%
Employers82.5%77.5%
False alarms, synthetic0.1%0.0%
Clinical terms wrongly removed across three demo inquiries91

Employers got worse. Long institution names are only partly tagged, and "3M" and "7-Eleven" still walk through. The row that matters is the last one. The eyelid consult came back with Dermatochalasis and Humphrey where they belong, and the three demo inquiries produced the same verdicts through the gate as without it.

The same demo consult through the general model and the clinical model

Same note, two models. The general model removed the diagnosis and the analyzer as places. The clinical model kept both.

That's useful evidence. Three demo cases don't establish anything about real charts.

The lesson: a metrics table tells you what you missed. Only reading real output tells you what you broke. Make the reading a step, not a favour.

Mistake 5: we assumed the model read the whole note

After the swap we found one more.

RoBERTa reads 512 tokens at a time. A consult note with the history, the exam and the plan is often three times that. The transformers pipeline helpers advertise overflow handling, and in the release we had, the tokenizer's overflow path handed back only the first window and dropped the rest.

A name on page three was invisible to the detector. Nothing in a successful response said the rest of the note hadn't been read.

Our test notes were short.

We now tokenize the whole document once, build overlapping windows ourselves (512 tokens, 128 overlap), run one window at a time so memory doesn't grow with note length, map every prediction back to document offsets, and merge overlapping predictions into one span with one entity.

A 1,500-token note: before, only the first window is read; after, four overlapping windows cover it all

Before, only the first 512 tokens reached the model and the name on page three was missed. After, four overlapping windows cover the whole note.

encoding = tokenizer.backend_tokenizer.encode(text, add_special_tokens=False)
content_length = max_length - special_token_count - 2    # margin: retokenizing a substring can add a piece
step = content_length - STRIDE
for window_start in range(0, len(encoding.ids), step):
    offsets = encoding.offsets[window_start:window_start + content_length]
    doc_start, doc_end = offsets[0][0], offsets[-1][1]
    window = tokenizer.backend_tokenizer.encode(text[doc_start:doc_end], add_special_tokens=True)
    ...  # run the model on this window; add doc_start to every offset
    if window_start + content_length >= len(encoding.ids): break
return _merge_overlapping(detected)

Then we wrote the test we should have had from the start: plant a synthetic name beyond the first window and assert the detector finds it.

The lesson: "does it read the whole note" deserves its own test. A high score on short sentences doesn't answer it. Same for a name split across a line break, OCR noise, a span on a window edge, a replacement that matches a later rule. Those examples are closer to the work than another hundred clean sentences.

Dates need their own decision

We haven't solved the timing problem by removing dates. We've moved it.

Every calendar date becomes a day count from the planned date of service: 05/01/2026 becomes [DATE_1:DOS-92D]. The date of service itself becomes [DOS]. Date of birth is always [DOB], never a relative interval, because that would give away the exact age. The anchor is never sent. If the anchor is missing, the strategy falls back to year-only and records that timing checks may be limited.

That keeps the relationships the evaluator needs. It does not, by itself, make the output de-identified.

HIPAA offers two routes. Safe Harbor allows only the year. Expert Determination requires a qualified expert to assess and document the re-identification risk of what's released, considering who receives it and what else they could reasonably know. The assessment has to cover the whole payload, including the day counts and the narrative that's left. HHS guidance.

Real patient notes don't go to Jev under this strategy until that assessment and the approvals are in place. The code makes the transformation possible. It doesn't make the compliance decision.

Check the request you actually send

The last layer runs after the request is assembled.

It takes the request as an object and walks every string value and every key, looking for every known value and spelling, every raw id and real filename, the DOB and the anchor in every format, and every pattern. A plain search over the JSON text would miss a name split across a newline, because JSON escapes it. When the check is clean, it runs JSON.stringify itself and seals the result, so the string that was checked is exactly the string that gets sent.

const { identifiers } = buildIdentifiers({ patient, inquiry, notes, attachments });

const stripped = await deidentify(sources, {
    identifiers,
    nameFinder: presidioNameFinder(findPhiViaClient(client)),   // layer 3, one call for all texts
    dateStrategy: relativeToDos(inquiry.plannedDos),             // layer 4
    protectedCodes: [inquiry.hcpcCode, inquiry.icd10Code],       // never redact the procedure code
});

const requestBody = assembleRequest({ sources: stripped, requirements, policyText });

const checked = validateOutbound(requestBody, identifiers, { publicTexts: [policyText] });
if (!checked.ok) throw new Error("blocked");             // counts per kind only, never the values
await sendToJev(checked.request);                         // accepts nothing else

The Jev client accepts only the sealed type. Types disappear at runtime, so the validator also keeps a private registry of every request it issued, and the client checks isIssued(request) before fetch. A cast doesn't get through.

This stops mistakes on that client path. It doesn't stop an engineer from creating another outbound path, and it doesn't turn a missed name into a found one.

So write the promise narrowly. The gate removes every known identifier and every configured pattern, uses a model to find more, and blocks on the failures it can see. Controlled testing found zero matches for the planted identifiers in anything model-facing. Automated detection can still miss a name nobody told it about.

"Zero matches in these tests" and "no PHI can leave" are different claims. Someone will quote the second one back at you.

What's still open

The gate is good enough to test with. It isn't finished.

Employer names are the weak spot. The clinical model catches about three in four, and short ones like "3M" slip through. All-caps report headers sometimes get tagged as organisations too, which costs a little medicine, not privacy.

Scanned PDFs with no text layer don't go through the gate, because there's no text to check. The model only sees that the file exists, under its alias. Photos never go to any model. Pre-op photos for eyelid surgery are full-face photos, and those stay with a human.

And every number in this post comes from synthetic sentences and three demo charts. That's enough to find bugs. It isn't enough to promise anything about real charts. That test comes later, under proper access.

The boundary was still a promise

After all of this, a security lead asked the obvious question.

"What stops a future function from skipping the gate?"

The honest answer was code review.

The function could read patient records. It could also call Jev. Our checks controlled how our code used those two abilities, but the permissions allowed both, and a permission outlasts a code review.

That wasn't enough for either of us.

Part 2 is what we did next: Foundry Markings, two service identities, and a workflow where the thing calling Jev has no permission to read a patient record at all. It's also where Jev turned out to be 21× faster than Gemini on the same six requirements, and where Gemini hit the 60-second function limit three times in a row.

The gate still matters. So does whether the sender can get around it.

What am I missing?

If you've built a de-identification workflow and hit an edge case that isn't in the checklist, write me. Especially if you've solved partial institution names, all-caps headers, or found a cleaner way to get overflow windows out of transformers than building them by hand.

Same if you think day-count dates are a bad idea. That's a conversation worth having before any real note leaves.

If you're building this in Foundry and want a second pair of eyes before the deploy, talk to us. Bring the before-and-after output. That's where our most useful review started.

Written by

Ronit Virwani

Ronit Virwani

AI Engineer

LinkedIn

Contact us

Ready to revolutionize your Industry or Organization?

Fill out the form with as much detail as possible. The more information you provide, the better we can tailor our questions and solutions to fit your unique needs. Let's take the first step towards creating something extraordinary together.

By submitting this form, you are agreeing to the privacy policy.