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 · Three kinds of output · File by file · Small cells · Masking · Before anything leaves
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.
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 |
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 |
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.
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.
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:
- Is the recipient covered by the same protocol that authorized the data collection?
- 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.
- 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.