src/deid
@cosyte/synth/deid, the @cosyte/deid pairing loop: a deterministic,
seeded co-validation harness for the synth ⇄ deid pair.
For each covered format it generates a spec-clean synthetic artifact through @cosyte/synth's own
generators, enumerates the distinctive synthetic PHI sentinels planted at the patient loci,
de-identifies it through @cosyte/deid, and verifies that every planted sentinel is gone from
the de-identified output while the clinical payload survives. A surviving sentinel is a hard failure;
an over-scrubbed clinical value is a hard failure.
Scope & honesty. This proves the pair works on synth's own output: it
is not an independent audit of @cosyte/deid against real-world documents. The sentinels are
synthetic-by-construction (never realistic); a sentinel deid blocks rather than redacts still
passes (blocked = gone). The loop covers the five formats both packages support
(DEID_LOOP_COVERED_FORMATS); NCPDP SCRIPT, ASTM, and DICOM are skipped and
named (DEID_LOOP_SKIPPED), never silently. @cosyte/deid is an optional peer dependency
(the mllp/ncpdp vendoring pattern); importing this subpath pulls it in.
Interfaces
CcdaDeidLoopOptions
Options accepted by ccdaDeidLoop.
Properties
documentType?
readonlyoptionaldocumentType?:CcdaDeidDocumentType
The document type. Defaults to "ccd".
seed?
readonlyoptionalseed?:number
The seed. Defaults to 0.
DeidCoverageRow
A per-format row of a DeidCoverageSummary.
Properties
artifacts
readonlyartifacts: readonlystring[]
The concrete artifacts run for this format.
clinicalProbed
readonlyclinicalProbed:number
Total clinical tokens probed for over-scrub.
clinicalScrubbed
readonlyclinicalScrubbed:number
Total clinical tokens over-scrubbed, 0 on a clean pass.
format
readonlyformat:string
The covered format.
pass
readonlypass:boolean
true iff every run for this format passed.
planted
readonlyplanted:number
Total distinctive PHI sentinels planted across the runs.
survivors
readonlysurvivors:number
Total surviving sentinels, 0 on a clean pass.
DeidCoverageSummary
The aggregate coverage summary across a set of loop runs.
Properties
allPass
readonlyallPass:boolean
true iff every run passed.
byFormat
readonlybyFormat: readonlyDeidCoverageRow[]
One row per format.
skipped
readonlyskipped: readonly [Readonly<{format:"ncpdp-script";reason:"@cosyte/deid ships no NCPDP SCRIPT locus map; synth generates SCRIPT but the loop cannot pair it."; }>,Readonly<{format:"astm";reason:"@cosyte/deid ships no ASTM adapter; synth generates ASTM but the loop cannot pair it."; }>,Readonly<{format:"dicom";reason:"@cosyte/synth does not generate DICOM, so there is nothing to pair."; }>]
The named, deliberately-skipped format paths (from DEID_LOOP_SKIPPED).
totalClinicalScrubbed
readonlytotalClinicalScrubbed:number
Total clinical over-scrubs across every run, 0 on a clean pass.
totalPlanted
readonlytotalPlanted:number
Total sentinels planted across every run.
totalSurvivors
readonlytotalSurvivors:number
Total surviving sentinels across every run, 0 on a clean pass.
DeidLoopResult
The verdict of one pass through the pairing loop for one artifact.
pass is true iff at least one sentinel was planted, no planted sentinel survived
de-identification, and no probed clinical value was over-scrubbed.
Example
import { hl7DeidLoop } from "@cosyte/synth/deid";
const r = hl7DeidLoop({ seed: 42 });
r.pass; // => true
r.survivors.length; // => 0
Properties
artifact
readonlyartifact:string
The concrete artifact label (e.g. "ORU^R01", "837P", "CCD").
clinicalProbed
readonlyclinicalProbed: readonlystring[]
The non-PHI clinical code tokens probed for over-scrub (present in the spec-clean artifact).
clinicalScrubbed
readonlyclinicalScrubbed: readonlystring[]
Probed clinical tokens missing after de-identification (over-scrub), must be empty.
deidentified
readonlydeidentified:string
The serialized de-identified artifact (@cosyte/deid's output).
format
readonlyformat:DeidLoopFormat
The covered format.
original
readonlyoriginal:string
The spec-clean serialized artifact (before de-identification).
pass
readonlypass:boolean
true iff sentinels were planted, none survived, and nothing clinical was over-scrubbed.
planted
readonlyplanted: readonlyDeidSentinel[]
Every distinctive synthetic PHI sentinel synth planted at a patient locus of the artifact.
seed
readonlyseed:number
The seed, the same seed yields the same artifact and the same verdict.
survivors
readonlysurvivors: readonlyDeidSentinel[]
Planted sentinels still present in the de-identified output, must be empty.
DeidSentinel
A single planted sentinel: one distinctive, synthetic-by-construction PHI token that
@cosyte/synth placed at a patient locus of a generated artifact, tracked so the loop can prove
@cosyte/deid removed it. The token is always drawn from a synthetic-safety provider:
a fake-name-pool name, a 900-range SSN, a 555-01xx phone, a synthetic-assigning-authority
identifier, so a sentinel can never be, or collide with, a real person's PHI.
Example
const s: DeidSentinel = { token: "Examplewood", locus: "PID-5", category: "names" };
Properties
category?
readonlyoptionalcategory?:string
The Safe Harbor category @cosyte/deid assigned to the locus, when known.
locus
readonlylocus:string
Where it lives, a format-neutral path (e.g. "PID-5", "recordTarget", "NM1[0]-3").
token
readonlytoken:string
The exact synthetic token planted at the locus, a literal substring of the spec-clean artifact.
FhirDeidLoopOptions
Options accepted by fhirDeidLoop.
Properties
seed?
readonlyoptionalseed?:number
The seed. Defaults to 0.
Hl7DeidLoopOptions
Options accepted by hl7DeidLoop.
Properties
kind?
readonlyoptionalkind?:Hl7MessageKind
The HL7 v2 message family to generate. Defaults to "ORU^R01" (a PHI- and clinically-dense report).
seed?
readonlyoptionalseed?:number
The seed: the same seed yields the same artifact and the same verdict. Defaults to 0.
NcpdpTelecomDeidLoopOptions
Options accepted by ncpdpTelecomDeidLoop.
Properties
seed?
readonlyoptionalseed?:number
The seed. Defaults to 0.
transaction?
readonlyoptionaltransaction?:NcpdpTelecomVariant
The Telecom transaction. Defaults to "B1" (a claim, the PHI- and clinically-densest).
X12DeidLoopOptions
Options accepted by x12DeidLoop.
Properties
seed?
readonlyoptionalseed?:number
The seed. Defaults to 0.
variant?
readonlyoptionalvariant?:X12DeidVariant
The transaction family. Defaults to "837P".
Type Aliases
CcdaDeidDocumentType
CcdaDeidDocumentType =
"ccd"|"referral"
The C-CDA document types the loop can pair.
DeidLoopFormat
DeidLoopFormat =
"hl7"|"fhir"|"ccda"|"x12"|"ncpdp-telecom"
The set of cosyte formats the loop covers: every format for which both @cosyte/synth generates
and @cosyte/deid ships an adapter.
NcpdpTelecomVariant
NcpdpTelecomVariant =
"B1"|"B2"|"B3"
The NCPDP Telecom transactions the loop can pair.
X12DeidVariant
X12DeidVariant =
"837P"|"837I"|"837D"|"271"|"835"
The X12 transaction families the loop can pair.
Variables
DEID_LOOP_COVERED_FORMATS
constDEID_LOOP_COVERED_FORMATS: readonly ["hl7","fhir","ccda","x12","ncpdp-telecom"]
The cosyte formats the pairing loop covers: every format for which both @cosyte/synth
generates and @cosyte/deid ships an adapter.
Example
import { DEID_LOOP_COVERED_FORMATS } from "@cosyte/synth/deid";
DEID_LOOP_COVERED_FORMATS; // ["hl7","fhir","ccda","x12","ncpdp-telecom"]
DEID_LOOP_POLICY_NAME
constDEID_LOOP_POLICY_NAME:"synth-deid-loop-removal"="synth-deid-loop-removal"
The name of the removal-oriented policy the loop runs @cosyte/deid under.
Example
import { DEID_LOOP_POLICY_NAME } from "@cosyte/synth/deid";
DEID_LOOP_POLICY_NAME; // => "synth-deid-loop-removal"
DEID_LOOP_SKIPPED
constDEID_LOOP_SKIPPED: readonly [Readonly<{format:"ncpdp-script";reason:"@cosyte/deid ships no NCPDP SCRIPT locus map; synth generates SCRIPT but the loop cannot pair it."; }>,Readonly<{format:"astm";reason:"@cosyte/deid ships no ASTM adapter; synth generates ASTM but the loop cannot pair it."; }>,Readonly<{format:"dicom";reason:"@cosyte/synth does not generate DICOM, so there is nothing to pair."; }>]
The format paths the loop deliberately skips, each with the honest reason, so a coverage gap is named, never silent.
Example
import { DEID_LOOP_SKIPPED } from "@cosyte/synth/deid";
DEID_LOOP_SKIPPED.map((s) => s.format); // ["ncpdp-script","astm","dicom"]
Functions
assembleVerdict()
assembleVerdict(
parts):DeidLoopResult
Assemble a DeidLoopResult from the planted sentinels, the de-identified output, and the
clinical probe: computing the pass verdict (sentinels planted, none survived, nothing over-scrubbed).
Parameters
parts
The pieces gathered by a per-format loop.
artifact
string
clinicalCodes
readonly string[]
deidentified
string
format
original
string
phiResidue?
string
The de-identified values that remain at the former PHI loci, what the survivor sweep reads (see
sweepSurvivors). Defaults to deidentified when omitted (a whole-document sweep), but the
per-format loops supply the locus-scoped residue so provider/organization identity a de-identifier
legitimately retains never reads as a false survivor.
planted
readonly DeidSentinel[]
seed
number
Returns
The immutable verdict.
Example
const result = assembleVerdict({
format: "hl7", artifact: "ORU^R01", seed: 1,
planted, original, deidentified, clinicalCodes,
});
ccdaDeidLoop()
ccdaDeidLoop(
options?):DeidLoopResult
Run the pairing loop for C-CDA R2.1: generate a spec-clean document through @cosyte/ccda's
buildCcda, plant PHI sentinels at the recordTarget patient participation, de-identify through
@cosyte/deid/ccda, and verify removal + clinical retention.
Sentinels are read from the <recordTarget> element of the serialized XML (not @cosyte/deid's DOM
extractor) so that this zero-dependency subpath constructs no XML DOM; scoping to recordTarget keeps
the sentinels patient-PHI only (author/custodian provider identity is legitimately retained).
Parameters
options?
CcdaDeidLoopOptions = {}
The seed and document type. See CcdaDeidLoopOptions.
Returns
The DeidLoopResult for the document.
Example
import { ccdaDeidLoop } from "@cosyte/synth/deid";
ccdaDeidLoop({ seed: 5, documentType: "ccd" }).survivors; // => []
clinicalRetention()
clinicalRetention(
original,deidentified,clinicalCodes):object
The over-scrub side of the loop: of the clinical (non-PHI) code tokens that appear in the spec-clean artifact, which are missing after de-identification. A de-identifier must keep clinical content; a non-empty result means it over-scrubbed. Only structured code tokens present before de-identification are probed (a code that appears solely inside a free-text narrative a de-identifier legitimately blocks is not a structured-content loss).
Only distinctive codes (four or more characters) are probed: a short 2- to 3-digit code (e.g. a CVX
vaccine code) can appear by coincidence inside a PHI value a de-identifier removes: a timestamp, an
SSN, and would read as a false over-scrub. Distinctive codes (LOINC 4548-4, SNOMED 44054006,
11-digit NDCs, CPT 99213) do not collide this way. An artifact whose clinical codes are all short
simply yields an empty probe (the guard is skipped, never falsely tripped).
Parameters
original
string
The spec-clean serialized artifact.
deidentified
string
The serialized de-identified artifact.
clinicalCodes
readonly string[]
Candidate structured clinical code tokens (e.g. LOINC/ICD/NDC from the example pools) to probe.
Returns
object
{ probed, scrubbed }: the distinctive codes present before, and the subset absent after.
probed
readonlyprobed: readonlystring[]
scrubbed
readonlyscrubbed: readonlystring[]
Example
const { probed, scrubbed } = clinicalRetention(before, after, ["4548-4", "44054006"]);
scrubbed; // => [] (clinical content retained)
deidLoopPolicy()
deidLoopPolicy():
DeidPolicy
Build the removal-oriented de-identification policy the loop runs @cosyte/deid under: HIPAA Safe
Harbor, but with the three keyed-by-default identifier categories (MRN, health-plan-beneficiary,
account) switched from pseudonymize to redact.
Two reasons this is the right policy for a co-validation loop: (1) it needs no key context, so the
loop is a pure function of the seed (the default Safe Harbor policy pseudonymizes those three
categories, which requires the consumer's HMAC key); and (2) it makes the removal contract crisp,
every PHI locus is removed or generalized away, never replaced by a surrogate that a naive sweep
might mistake for a survivor. pseudonymize also removes the original value, so this is a stricter,
not weaker, check.
Returns
DeidPolicy
A frozen DeidPolicy suitable for the policy option of any @cosyte/deid adapter.
Example
import { deidLoopPolicy } from "@cosyte/synth/deid";
import { deidentifyHl7 } from "@cosyte/deid/hl7";
const { document } = deidentifyHl7(msg, { policy: deidLoopPolicy() });
fhirDeidLoop()
fhirDeidLoop(
options?):DeidLoopResult
Run the pairing loop for FHIR R4: generate a spec-clean Bundle (US-Core Patient + a clinical
spine) through @cosyte/fhir's model, plant PHI sentinels at the Patient demographics, de-identify
through @cosyte/deid/fhir, and verify removal + clinical retention. A Bundle (not a bare
Patient) is used so there is clinical content to prove is not over-scrubbed.
Parameters
options?
FhirDeidLoopOptions = {}
The seed. See FhirDeidLoopOptions.
Returns
The DeidLoopResult for the bundle.
Example
import { fhirDeidLoop } from "@cosyte/synth/deid";
fhirDeidLoop({ seed: 7 }).pass; // => true
hl7DeidLoop()
hl7DeidLoop(
options?):DeidLoopResult
Run the pairing loop for HL7 v2: generate a spec-clean message through @cosyte/hl7's builder,
plant PHI sentinels at the PID, de-identify through @cosyte/deid/hl7, and verify removal +
clinical retention.
Parameters
options?
Hl7DeidLoopOptions = {}
The seed and message family. See Hl7DeidLoopOptions.
Returns
The DeidLoopResult for the message.
Example
import { hl7DeidLoop } from "@cosyte/synth/deid";
const { pass, survivors } = hl7DeidLoop({ seed: 42, kind: "ORU^R01" });
// pass === true, survivors.length === 0
identifierSentinels()
identifierSentinels(
loci,original): readonlyDeidSentinel[]
Enumerate the distinctive synthetic PHI sentinels from a de-identifier's patient-scoped identifier loci, decomposed to literal tokens of the spec-clean artifact.
The loci come from @cosyte/deid's own per-format extractor (extractHl7Loci / extractFhirLoci /
extractX12Loci / extractTelecomLoci), which locates PHI structurally, so provider and
organization names, which a de-identifier legitimately retains, are never extracted and never become
false sentinels. Only identifier-kind loci are used (names, SSNs, phones, emails, member/account
ids); dates (generalized to a year, not removed) and ZIPs (generalized to three digits) are handled by
the format's own contract, not asserted here.
Requiring each token to be a literal substring of original keeps the removal check non-vacuous:
a sentinel the sweep later looks for is one that is provably present before de-identification.
Parameters
loci
readonly GenericLocus[]
The located candidate values from a @cosyte/deid extractor.
original
string
The spec-clean serialized artifact the loci were extracted from.
Returns
readonly DeidSentinel[]
The distinctive, literal, synthetic PHI sentinels, de-duplicated by token.
Example
import { extractHl7Loci } from "@cosyte/deid/hl7";
const sentinels = identifierSentinels(extractHl7Loci(msg).loci, msg.toString());
ncpdpTelecomDeidLoop()
ncpdpTelecomDeidLoop(
options?):DeidLoopResult
Run the pairing loop for NCPDP Telecom (pharmacy claims): generate a spec-clean transaction
through @cosyte/ncpdp, plant PHI sentinels at the patient/cardholder segments, de-identify through
@cosyte/deid/ncpdp, and verify removal + clinical retention. NCPDP SCRIPT is out of scope:
@cosyte/deid ships no SCRIPT adapter (DEID_LOOP_SKIPPED).
Parameters
options?
NcpdpTelecomDeidLoopOptions = {}
The seed and transaction. See NcpdpTelecomDeidLoopOptions.
Returns
The DeidLoopResult for the transaction.
Example
import { ncpdpTelecomDeidLoop } from "@cosyte/synth/deid";
ncpdpTelecomDeidLoop({ seed: 3, transaction: "B1" }).pass; // => true
recordTargetSentinels()
recordTargetSentinels(
xml): readonlyDeidSentinel[]
Enumerate the patient sentinels of a C-CDA document by reading the <recordTarget> element of the
serialized XML: the patient participation. Scoping to recordTarget (rather than author /
custodian, which carry provider identity a de-identifier retains) is what keeps the sentinels
patient-PHI only. Used for C-CDA because @cosyte/deid's C-CDA extractor operates on a raw DOM, which
this zero-dependency module does not construct.
Parameters
xml
string
The serialized spec-clean C-CDA document.
Returns
readonly DeidSentinel[]
The distinctive synthetic patient sentinels (given/family names, the patient id extensions).
Example
import { serializeCcda } from "@cosyte/ccda";
const sentinels = recordTargetSentinels(serializeCcda(generateCcd({ seed: 1 })));
summarizeDeidCoverage()
summarizeDeidCoverage(
results):DeidCoverageSummary
Summarize a set of loop runs into a per-format coverage report: the "coverage summary per format" the harness reports.
Parameters
results
readonly DeidLoopResult[]
The loop results to aggregate.
Returns
The DeidCoverageSummary.
Example
import { hl7DeidLoop, fhirDeidLoop, summarizeDeidCoverage } from "@cosyte/synth/deid";
const summary = summarizeDeidCoverage([hl7DeidLoop(), fhirDeidLoop()]);
summary.allPass; // => true
sweepSurvivors()
sweepSurvivors(
phiResidue,sentinels): readonlyDeidSentinel[]
Sweep the PHI-locus residue of a de-identified artifact for surviving planted sentinels: the heart of the removal check. A sentinel survives if its exact token still appears in the values that remain at the (former) PHI loci after de-identification; a non-empty result is a hard failure (real PHI-shaped data leaked through de-identification).
Why the PHI-locus residue and not the whole document. @cosyte/synth draws patient and
provider/organization names, addresses, and phones from the same synthetic pools, and a de-identifier
legitimately retains provider identity. So the same synthetic token can sit at both a patient locus
(removed) and a provider locus (retained); a whole-document sweep would read the retained provider copy
as a false survivor. Sweeping only the residue at the loci that were PHI, re-read from the
de-identifier's own output, is locus-scoped and collision-proof: a token still present there is a
genuine leak. It remains independent of the de-identifier's manifest: it reads the actual serialized
output, so a de-identifier that locates a locus but fails to strip it is still caught.
Parameters
phiResidue
string
The de-identified values that remain at the former PHI loci (for the model formats,
the re-extracted identifier-locus values joined; for C-CDA, the de-identified <recordTarget> block).
sentinels
readonly DeidSentinel[]
The sentinels planted in the corresponding spec-clean artifact.
Returns
readonly DeidSentinel[]
The subset of sentinels still present in phiResidue, empty on a clean pass.
Example
sweepSurvivors(deidPhiResidue, planted); // => [] (all removed)
x12DeidLoop()
x12DeidLoop(
options?):DeidLoopResult
Run the pairing loop for X12 005010: generate a spec-clean transaction through @cosyte/x12's
builders, plant PHI sentinels across the subscriber/patient loops, de-identify through
@cosyte/deid/x12, and verify removal + clinical retention.
Parameters
options?
X12DeidLoopOptions = {}
The seed and transaction family. See X12DeidLoopOptions.
Returns
The DeidLoopResult for the transaction.
Example
import { x12DeidLoop } from "@cosyte/synth/deid";
x12DeidLoop({ seed: 1, variant: "837P" }).survivors; // => []