Skip to content

Participant identifiers and protected health information

EHR exports arrive identified: names, birth dates, medical record numbers (MRNs), addresses and dates of care are all in them. Under the HIPAA Safe Harbor rule, MRNs and other record numbers are direct identifiers (45 CFR 164.514(b)(2)), and FHIR resource IDs derived from them should be treated the same way. Registry Forge processes the records inside an environment your protocol and Institutional Review Board (IRB) approve, and sorts its outputs by what they still hold.

Which ID is which

ID Where it comes from Where it appears
Registry participant ID uuid_mapping.csv (patient_id) or the folder name under inputs/ patient_id in the bundle and every identified or restricted row-level output
Health system patient ID inside each delivered file source_patient_id, source_patient_ids
MRN inside each delivered file mrn
PT-NNNN assigned in a run, in order of participant ID cohort summary participants table, drug repurposing files, record viewer with identifiers hidden
PP-NNNN assigned by the Phenopacket export de-identified Phenopackets; identified/crosswalk.csv links them back
EX-NNNN, SN-NNNN assigned within one review file excerpts in review dashboards; source-record examples

Pseudonyms are assigned fresh in each run and change when the set of participants changes. A crosswalk is written only where one is needed, and only to a restricted folder.

Back to top

Three kinds of output

run_all.py writes each output to one of three places:

Place Holds Handling
RUN/ (core outputs) names, birth dates, MRNs, dates and full text Identified. Stays in the approved environment; approved staff only
RUN/restricted/ participant IDs or pseudonyms with dates, codes and text excerpts Restricted. Stays in the approved environment
RUN/reports/ counts, with counts below k held back along with any figure that would reveal one; no IDs, names, record dates or text Can be considered for sharing after a person has reviewed it in full

Back to top

File by file

Every output file and what it holds
File Where Identifiers it holds
dashboard_data.json, dashboard_data.xlsx, csv_exports/ RUN/ Registry IDs, names, birth dates, MRNs, dates, full text
dashboard_data.prefilter.json RUN/ As above, before test records were removed
patient_master.csv and the FHIR and C-CDA versions RUN/ Registry IDs with demographics on every row
code_inventory.csv RUN/ No IDs, but exact small counts and local code labels
note_extractions.csv RUN/ Registry IDs, document dates and text snippets
pipeline_run.log RUN/ File names and counts; treat as restricted
dashboard.html anywhere Nothing on its own; it shows whatever bundle is loaded into it
device_codes.csv, device_extractions.csv restricted/ Registry IDs, dates and snippets
exposure_codes.csv, exposure_extractions.csv restricted/ Registry IDs, dates and snippets
*_review.html dashboards (with --with-excerpts) restricted/ Masked excerpts that can still hold names
drug_repurposing/drug_repurposing_cohort.csv restricted/ PT-NNNN pseudonyms with calendar dates
drug_repurposing/cure_id_review_worksheet.csv restricted/ PT-NNNN pseudonyms, age groups and outcomes, one row per exposed participant
drug_repurposing/off_label_snippets.md restricted/ SN-NNNN labels with years and short source text
phenopackets_output/deidentified/ restricted/ PP-NNNN subject IDs and ages instead of dates; still row-level data on rare disease
phenopackets_output/identified/ (with --also-write-identified) restricted/ Registry IDs, calendar dates and the crosswalk
omop_output_<release>/PERSON.csv restricted/ Registry IDs in person_source_value; other OMOP tables link to it through person_id
note_dashboard.html, cohort_eda_report.html, device_dashboard.html, exposure_dashboard.html reports/ Counts only
drug_repurposing_report.html, drug_repurposing_summary.csv reports/ Counts only

Back to top

Small cells

Every report holds back participant counts from 1 to k-1 (k is 5 unless --k sets another value) and shows them as "held back for cell size". Where counts add up to a total that is also shown, a second count is held back so the first cannot be found by subtraction; in the drug repurposing summary, paired counts (deaths in the exposed and unexposed groups) are held back together. The drug repurposing module holds back small participant counts in its console log too; the other modules' logs report record counts, so treat logs as restricted. The identified working files (code_inventory.csv, the bundle) keep exact counts because the team needs them; anything built from them for sharing needs the same rules.

Back to top

What masking does not cover

The review dashboards mask dates, phone numbers, email addresses and long ID-like numbers in excerpts. They do not detect names or addresses, which is why those files are restricted. The record viewer hides structured identifiers by default, but the text of documents is shown as written.

Back to top

Before anything leaves

A person reviews the whole file, including every label and free-text field, and checks it against the protocol, the IRB approval and any data use agreement. Small-cell suppression lowers the risk of identifying someone from an aggregate; it is not enough on its own to clear a file for sharing, and repeated releases from the same registry need to be considered together.

Questions to settle with your IRB or privacy office before sharing row-level data:

  1. Is the recipient covered by the same protocol that authorized the data collection?
  2. Is the recipient a covered entity, a business associate, or an outside researcher? Each needs a different agreement: a business associate agreement (BAA), a data use agreement for a limited data set, or de-identified data.
  3. Is the purpose treatment, payment, operations, or research? Research sharing without IRB authorization or a data use agreement generally needs de-identified data.

OMOP tables. Before an omop_output_<release>/ folder goes to another site or a federated network, replace person_source_value in PERSON.csv with a pseudonym and keep the mapping in the restricted folder:

import pandas as pd
p = pd.read_csv('restricted/omop_output_<release>/PERSON.csv')
ids = sorted(p['person_source_value'].dropna().unique())
mapping = {pid: f'PT-{i + 1:04d}' for i, pid in enumerate(ids)}
p['person_source_value'] = p['person_source_value'].map(mapping).fillna('')
p.to_csv('omop_share/PERSON.csv', index=False)
pd.DataFrame(mapping.items(), columns=['participant_id', 'pseudo_id']).to_csv(
    'restricted/omop_crosswalk.csv', index=False)

The OMOP tables still hold calendar dates, so pseudonymizing PERSON.csv alone does not de-identify them.

Phenopackets. The default output is already de-identified, but each file still describes one person with a rare disease. Review it against the receiving network's rules before submission.

Back to top