Healthcare de-identification for Node.js and TypeScript: a HIPAA-grounded policy engine that emits a value-free manifest.
@cosyte/deid applies a de-identification policy (HIPAA Safe Harbor by default) to a
structurally-located model of a healthcare document and returns a transformed model plus a value-free
audit of everything it acted on. It is a consumer of the @cosyte/* parsers, not a parser sibling:
it borrows the archetype's disciplines (typed diagnostics, immutable output, a policy/profile system)
but inverts the reflex: where a parser is liberal on input, a de-identifier is conservative. Third-party runtime dependencies: zero (every primitive is node:crypto).
The honesty line. Results are "Safe-Harbor-transformed per the configured policy", never "de-identified" and never "HIPAA-compliant". Safe Harbor is implemented mechanically; the actual-knowledge condition (§164.514(b)(2)(ii)) is the consumer's; Expert Determination (§164.514(b)(1)) is supported, never rendered or certified. The certification is always the consumer's.
Status:
0.1, published on npm. Below 1.0, a breaking change raises the minor version and the changelog says what broke. This release ships the format-agnostic core plus six format bindings: the HL7 v2 adapter (@cosyte/deid/hl7), the C-CDA adapter (@cosyte/deid/ccda), the FHIR R4 adapter (@cosyte/deid/fhir), the X12 EDI adapter (@cosyte/deid/x12), the NCPDP Telecom adapter (@cosyte/deid/ncpdp), and the DICOM adapter (@cosyte/deid/dicom), plus the longitudinal layer, the corpus registry (createDeidRegistry) for cross-document consistency and the formalized key contract, and the Expert-Determination support report (buildExpertDeterminationSupportReport) that structures the manifest for a statistician without ever rendering a determination. NCPDP SCRIPT is not supported, and an entry point handed one refuses it outright rather than returning a partial pass.
npm install @cosyte/deidimport { deidentify, SAFE_HARBOR_CATEGORIES } from "@cosyte/deid";
// Synthetic inputs: placeholders in the shape of an HL7 v2 name and date of birth, not a person.
const name = "ZZFAMILY^ZZGIVEN";
const dob = "19900215";
const { document, manifest } = deidentify(
{
loci: [
{ path: "PID-5", kind: "identifier", category: SAFE_HARBOR_CATEGORIES.NAMES, value: name },
{ path: "PID-7", kind: "date", category: SAFE_HARBOR_CATEGORIES.DATES, value: dob },
{ path: "OBX-5", kind: "clinical", value: "5.4 mmol/L" },
],
},
{},
);
console.log(document.loci.map((locus) => locus.value));
console.log(manifest.map((entry) => `${entry.locus} ${entry.category} ${entry.disposition}`));[ null, '1990', '5.4 mmol/L' ]
[ 'PID-5 NAMES removed', 'PID-7 DATES transformed' ]
The name is removed, the date keeps only its year, and the clinical value is retained untouched (the
over-scrub guard). The manifest records the category, locus and disposition of each decision, never
the value. examples/ has runnable programs for
the HL7 v2 adapter, the fail-closed cases and the Expert Determination support report.
Pseudonymization and keyed hashing use a keyed HMAC-SHA-256; the key is the consumer's and never leaves the process. The built-in Safe Harbor policy uses none of them: a surrogate derived from the individual's own value is a re-identification code §164.514(c)(1) does not permit, so medical record, health plan beneficiary and account numbers are removed and the profile needs no key at all. Keyed surrogates live behind a preset that does not claim Safe Harbor:
import {
deidentify,
createDeidContext,
profileOptions,
LIMITED_DATA_SET_PROFILE,
} from "@cosyte/deid";
const context = createDeidContext({ key: process.env.DEID_KEY!, patientId: "patient-1" });
deidentify(model, profileOptions(LIMITED_DATA_SET_PROFILE, context));
// Under THAT preset the MRN becomes a consistent, non-reversible surrogate; the key never appears in
// the output or manifest, the locus carries `reidentificationCode: true`, and the support report
// lists it in the keyed-surrogate residual inventory an expert must reason about.A policy carrying the safe-harbor label that assigns a category a transform whose output is
derived from that category's own value is refused with a typed DEID_POLICY_INVALID fatal naming
the category and the transform, at mint time and at the point of use alike. A policy that does not
claim the label keeps its keyed surrogate: nothing is silently strengthened behind the caller's back.
The @cosyte/deid/hl7 adapter locates PHI structurally in the parsed @cosyte/hl7
model, never by regex over the raw bytes, and returns a transformed Hl7Message plus the value-free
manifest. @cosyte/hl7 is an optional peer dependency: install it alongside @cosyte/deid to use
this subpath; the core stays dependency-free.
npm install @cosyte/deid @cosyte/hl7import { parseHL7 } from "@cosyte/hl7";
import { deidentifyHl7 } from "@cosyte/deid/hl7";
import { createDeidContext } from "@cosyte/deid";
const context = createDeidContext({ key: process.env.DEID_KEY! });
const { document, manifest } = deidentifyHl7(parseHL7(rawMessage), { context });
document.toString(); // spec-clean, de-identified HL7 wire
// PID-5 (name), NK1/GT1/IN1/IN2 relatives, SSN, phone, MRN and account → removed;
// DOB → year; address → safe 3-digit ZIP. OBX-5/NTE free text and Z-segments fail closed (blocked).
// Admit/discharge/observation/diagnosis dates → year; visit and order numbers blocked as (R).
// Structured clinical OBX values, units, codes, and statuses survive untouched.What it covers. The structured PHI loci of PID (patient) and NK1 / GT1 / IN1 / IN2
(relatives / guarantor / insured), typed by the @cosyte/hl7 model. Fail closed everywhere else: a
recognized segment is retained only if it is on an explicit clinical/administrative retain-list,
so a known patient-identity segment absent from the map (e.g. MRG prior name + MRN on a merge, FAM,
ACC) is blocked, never passed through, and Z-segments / structure unknown to the parser are blocked.
OBX-5 is retained only when OBX-2 positively types it as a structured clinical value (numeric /
coded / a time of day); narrative (TX/FT), ambiguous String (ST), and any empty/unknown OBX-2 fail
closed, as do NTE-3 comments, and a date/time value type makes OBX-5 a date the pass acts on.
Structured clinical values, units, codes, and statuses survive untouched.
The individual's employer is a Safe Harbor subject, not an unrelated organisation.
§164.514(b)(2)(i) removes the identifiers of the individual "or of relatives, employers, or
household members", so the employer positions the v2.5.1 financial segments type are acted on and
recorded like any other mapped locus: the guarantor's employer name (GT1-16), address
(GT1-17, reduced to the safe 3-digit ZIP) and phone (GT1-18); the guarantor employee and
employer identification numbers (GT1-19, GT1-29); the insured's group employer id and name
(IN1-10, IN1-11); the insured's employer name (IN2-3); the employer contact person's name
and phone (IN2-49, IN2-50); and the insured's employer phone (IN2-64). IN2-70 types an
organisation rather than a value, so it goes through the same party-role test the X12 adapter applies
to an NM1 / N1 party and fails closed: an employer is never outside the scope clause, so the
organisation's name and its identifier both go. The mirror control holds: the coded employment status
(GT1-20) and the insurer's own company id, name, address and phone (IN1-3/4/5/7) are
untouched, because none of them is the individual's, a relative's or an employer's identity.
Inside a retained segment, the identifying loci are carved back out: under a Safe-Harbor-labelled
policy the admit (PV1-44), discharge (PV1-45), observation (OBR-7) and diagnosis (DG1-5) dates keep only
their year, and the visit number (PV1-19) with the placer and filler order numbers (OBR-2/3, ORC-2/3)
are removed. A profile that names their retention class, as the limited-data-set preset does, keeps
them unchanged and recorded. PV1-19 is a CX list routed by its CX-5 identifier-type code, like PID-3:
only a VN-typed or untyped visit number is the encounter identifier, while an MR/AN/SS-typed one
is transformed as the medical record / account / social security number it is, under both profiles.
The postal-address allowance of §164.514(e)(2)(ii) is the one place a profile can ask this adapter
to keep more geography than the safe 3-digit ZIP. That clause removes "postal address information,
other than town or city, State, and zip code", which makes it the only partial exclusion in the
limited data set's list of sixteen. A profile naming the limited-data-set-geography retention class,
as the limited-data-set preset does, keeps the town or city (XAD.3), the State (XAD.4) and the
whole zip code (XAD.5) of every mapped address (PID-11, NK1-4, NK1-32, GT1-5,
GT1-17, IN1-19), each recorded as a DEID_RESIDUAL_RETAINED residual at its own component, and
drops the street address along with every other geographic component: the county or parish, the census
tract, the country. The county-code field (PID-12) and the birth place (PID-23) are removed
under every profile, because the clause names neither. The three-digit / 000 rule is
§164.514(b)(2)(i)(B), Safe Harbor's, so a restricted-prefix ZIP is kept in full under this class and
is still suppressed under Safe Harbor. Nothing widens by omission: without the class an address is
reduced exactly as it always was, and an address whose zip code is not a whole zip code falls back to
that generalization, which drops the whole address rather than keeping part of it. This adapter is the
only one that reads retention classes; under C-CDA, FHIR, X12, NCPDP and DICOM an address is reduced
exactly as Safe Harbor reduces it.
Every other date inside a segment the pass hands through is acted on and recorded too: every
position the HL7 v2.5.1 segment definitions type as a date or date/time, at its own unit (a field, a
component of a composite, one repetition at a time), plus an OBX-5 the message types as a date. That
reaches ORC-9 and ORC-15, the EVN / PV2 / PR1 / RXA / RXD / FT1 / TXA / SPM timestamps, the date
components of a range or other composite, and the OBX segment's own reference-range, observation and
analysis timestamps (OBX-12 / OBX-14 / OBX-19), which survive the value-type branch that decides OBX-5.
The classification is structural and version-fixed: an eight-digit numeric result is not a date, and
MSH-12 moves no position.
Known limitations (this release). Free text is block-by-default (no built-in scrub; opt-in BYO redaction: see Free text); every non-date field of a retained segment that the carve-outs do not name is not de-identified, which still includes the provider names in PV1-7/8 and OBR-16, the guarantor's employer organisation name at GT1-51, and the date components carried inside a person-name or address composite; a position only a version other than v2.5.1 types as a date is a stated residual; the address generalization keeps only the Safe Harbor 3-digit ZIP, unless the profile names the §164.514(e)(2)(ii) geographic retention class described above.
Those positions are now counted and located, which is a different thing from being cleaned. Every
value-bearing position a pass hands through that no locus rule names is recorded as an unexamined
residual on result.unexaminedResiduals, with its structural locus, a count and the fact that nothing
examined it, and never a value. Hand that list to the support report alongside the manifest and the
report says how many there were; hand it an empty list and the report says the inventory was measured
and empty, which reads differently from a pass that measured nothing at all.
Counting is not removal, and an unexamined position is not an allegation. Nothing is scrubbed, generalized or blocked on account of the count, and a position no rule examined has no established Safe Harbor category, so it joins none of the 18 and moves no category total. A clinical code, a dose unit and an order status all sit at positions like these. The two fail-safes are worth knowing: a position whose locus cannot be expressed is still counted, under a withheld locus token, and a structure whose positions cannot be enumerated fails the pass rather than contribute a zero a reader would take for a clearance.
What counts as a position is derived from what each parser's model can carry, not from the places a
value usually sits, so the enumeration reaches the easily-overlooked ones: XML character data delivered
as a CDATA section rather than as text, the comments and processing instructions a document is
re-serialized with, a FHIR primitive's _-sibling element id, and whatever a partly rewritten structure
keeps (the state and country of a generalized address travel exactly as they arrived, with anything
riding inside them). Conversely a position the pass removes is not counted: the number measures what
left the pass untouched. Limitations states the count's two edges.
Employer surfaces that remain residual, in every format. Two, named so a consumer can tell a
covered surface from an uncovered one. An employer named only in free text (an OBX-5 narrative, an
NTE comment, a C-CDA section <text>, a FHIR note) is reached only by an opt-in BYO redactor, never
by this library. And an employer carried as a separate organisation resource in a FHIR graph is not
reached either: in FHIR an employer arrives as a Reference (whose display is already blocked) to a
standalone Organization whose name the map retains as administrative data, so classifying it would
be a cross-resource role derivation rather than a typed position. The employer positions above are the
covered ones: they are typed at the position by X12 and by HL7 v2, which is what makes them decidable.
The @cosyte/deid/ccda adapter locates PHI structurally in a parsed @cosyte/ccda
document (a <name> under recordTarget/patientRole/patient is the patient's name because the CDA
standard says so), and returns a transformed CcdaDocument plus the value-free manifest. @cosyte/ccda
is an optional peer dependency; the adapter reaches the CDA DOM only through its hardened
parseSecureXml and re-serializes the node it hands back, so the core stays third-party-dependency-free.
npm install @cosyte/deid @cosyte/ccdaimport { parseCcda } from "@cosyte/ccda";
import { deidentifyCcda } from "@cosyte/deid/ccda";
import { createDeidContext } from "@cosyte/deid";
const context = createDeidContext({ key: process.env.DEID_KEY! });
const { document, manifest } = deidentifyCcda(parseCcda(xml), { context });
document.toString(); // spec-clean, de-identified C-CDA XML
// recordTarget/guardian/author/informant/custodian names, telecom, ids, addresses, birthTime, and
// participation/encounter dates → transformed; section narrative <text> and unknown elements fail
// closed. Coded clinical entries (codes, values, units, statuses, dosing periods) survive untouched.What it covers. The structured PHI loci of the CDA header participations: recordTarget
(patient) + nested guardian, and author / dataEnterer / informant / authenticator /
legalAuthenticator / participant / custodian / documentationOf / componentOf (relatives /
providers / contacts). Person <name> / <telecom> removed; person-role <id> removed under Safe
Harbor, assigning root retained (a consistent surrogate only under a preset that does not claim the
label); <addr> reduced to the safe 3-digit ZIP; <birthTime> and
participation / encounter dates generalized to year. Fail closed everywhere else: section narrative
<text> blocks and the unstructured nonXMLBody are blocked; a value-bearing element that is neither
mapped PHI nor recognized coded structure is blocked; foreign / sdtc elements are blocked. The clinical
structuredBody entries are retained untouched (the over-scrub guard): a <name> there is a drug
or material name, not a person.
Known limitations (this release). Narrative is block-by-default (no built-in scrub; opt-in BYO
redaction: see Free text); within the retained
clinical body, entry service dates, entry ids, in-entry performer names, and family-history
relative demographics are not de-identified (mirroring the HL7 adapter's boundary); the document
id/code/title envelope is retained (like HL7's MSH).
The @cosyte/deid/fhir adapter locates PHI structurally in a parsed @cosyte/fhir
resource (a name under a Patient is the patient's name because FHIR says so), and returns a
transformed resource model plus the value-free manifest. @cosyte/fhir is an optional peer
dependency; the adapter reaches FHIR data only through its exported model and parseResource /
serializeResource codec, so the core stays third-party-dependency-free.
npm install @cosyte/deid @cosyte/fhirimport { parseResource, serializeResource } from "@cosyte/fhir";
import { deidentifyFhir } from "@cosyte/deid/fhir";
import { createDeidContext } from "@cosyte/deid";
const context = createDeidContext({ key: process.env.DEID_KEY! });
const { resource } = parseResource(json);
const { document, manifest } = deidentifyFhir(resource, { context });
serializeResource(document); // spec-clean, de-identified FHIR JSON
// Patient/RelatedPerson/Practitioner/Person names, telecom, photo → removed; address → safe 3-digit ZIP;
// birthDate + every date → year; identifiers removed under Safe Harbor (surrogated by system only
// under a preset that does not claim the label).
// Narrative text.div, extension values, and Reference.display fail closed; contained resources and
// Bundle entries are walked. Clinical resources (Observation values, codes, units, statuses) survive.
// Outside a person resource a HumanName or an Address is acted on wherever it sits,
// Organization.contact.name and Location.address included: the element's datatype decides.FHIR is a graph of typed resources, so the map splits by role:
| Scope | Loci | Transform |
|---|---|---|
Person resources (Patient / RelatedPerson / Practitioner / Person + nested Patient.contact) |
name, telecom, photo, address, birthDate, dates |
name/telecom/photo removed; address → safe 3-digit ZIP (or 000); dates → year |
| Every resource, by DATATYPE | any HumanName, any Address (Organization.contact.name, Location.address, Organization.address, a choice-type locationAddress) |
name removed; address → the same safe 3-digit ZIP (or 000) a person resource's address gets |
| Every resource (universal PHI vectors) | identifier, dates, narrative text.div, extension values, Reference.display |
identifier removed under Safe Harbor (a surrogate by system only under a preset that does not claim the label); dates → year; narrative / extension values / reference labels blocked |
Clinical resources (Observation, Condition, …) |
codes, values, units, statuses, reference wiring | retained untouched (the over-scrub guard) |
A Reference.display (a person label) is blocked; a Coding.display (a coded term like Sodium) is
retained: the two are told apart structurally. Contained resources and Bundle entries are walked, with
each resource's role re-derived at its own resourceType.
Why the datatype and not the resource type. Which resource carries a person's name is a choice the
producing system made: the same home address arrives at Patient.address from one sender and at
Location.address from a home-health sender. A rule keyed on the enclosing resource therefore hands you
coverage that depends on that choice. Positive classification is closed: an element is a HumanName
only when every property it carries is one R4 defines on HumanName, and at least one of them is a
marker property (family / given / prefix / suffix), and that marker holds the exact value
shape R4 gives it - family a single string, the other three repeating. An Address the same way,
with line repeating and city / district / state / postalCode / country single. All three
halves earn their place: they keep the wider sweep off an organisation's own name (a plain
string, never a HumanName), off a CodeableConcept carrying only its text, and off every clinical
code, value, unit and status.
Reading the shape per marker is what separates a marker from an R4 element that merely shares its
name, and R4 supplies two such collisions. The several elements called country are coded concepts
where Address.country is a string. And Questionnaire.item, PlanDefinition.action and
RequestGroup.action each carry a prefix that is a single string, where a name's prefix repeats;
the latter two backbones make every child optional, so { "prefix": "1." } alone is a conformant
numbered workflow step, and the shape is the only thing that tells it from a person. The rule leans on
no sibling those other elements happen to require: that would be an assumption about the document, and
this pass validates no conformance.
Fail closed governs the person sweep and the frontier: a bare unrecognized string at a person
resource's top level is blocked (an allow-list can never satisfy Safe Harbor category (R)); a display
that is not on a Coding is treated as a Reference person-label and blocked, including a display-only
({ display }) or type+display reference that names no target; every extension value (a complex
valueAddress / valueHumanName, a modifierExtension, a nested extension, or a primitive-level
_-sibling extension) is dropped; and free-text loci (note Annotations, contentString, an uncoded
valueString) are blocked (the FHIR analogue of the HL7 adapter's OBX-5-ST / NTE fail-closed default).
Fail closed on a newly reached element too. A swept Address this pass cannot read faithfully is
removed whole rather than partly retained: a postalCode that is not a whole zip code (a
four-digit 0110 still has three leading digits, and generalizing it would keep a fragment of
something that was never a ZIP), or an unexpected JSON shape at a part Safe Harbor would let it keep,
takes the street, the city, the state, the country and the ZIP with it, and the disposition is
recorded. At an element name R4 types as one of the two datatypes - name, address, the
choice-type locationAddress, an open valueAddress / valueHumanName - any complex the
classifier cannot pin down is blocked whole rather than descended into: a { text } or
{ use, text } representation with no part to key on, a { family, given, … } carrying some property
R4 does not define, and equally a { streetAddress, town, zip } whose every property is foreign to
both datatypes. The standard promised a name or an address there and the pass could not read the one
it was given, so the boundary is readability rather than which keys happen to be present: adding an
unrecognized sibling to an element that was already unreadable never unblocks it. Exactly two
conformant R4 backbones share one of those element names - MedicinalProduct.name and
SubstanceSpecification.name - and both are excluded positively, by the property R4 makes 1..1 on
each together with that backbone's own closed property set. A plain string at one of those names is
not a complex and is never a candidate, which is what leaves Organization.name and
Endpoint.address untouched. The disposition is recorded either way. That block is scoped to those
positions on purpose; the residual it leaves is stated below.
Known limitations (this release). Extension values are block-only (no profile-aware retention, a
us-core-* demographic extension is dropped). Reference
wiring (Reference.reference pointers, resource logical ids) is preserved structurally; coordinated
pseudonymization of resource ids across a corpus is not performed. Free-text prose loci
(note, contentString, uncoded valueString) fail closed by default, or run through an opt-in BYO
redactor (see Free text); a built-in semantic (NLP)
narrative scrub and contentAttachment binary content remain out of scope for this release.
Five residual surfaces, the first three because FHIR types no person at the position:
- A
ContactPointoutside a person resource. A phone or an email on anOrganization, aLocationor anEndpointis passed through. Widening to telecom here would put a payer's or a facility's own switchboard number in scope, which the HL7 v2 adapter deliberately keeps (IN1-7, the insurer's own phone, is untouched there). - An organisation's own
name. It is a plain string, not aHumanName, so no datatype rule reaches it, and it is administrative content rather than a person's identity. - The individual's employer carried as a separate
Organizationresource. Unlike X12 and HL7 v2, FHIR types no employer role at the position, so reaching it would be a cross-resource role derivation rather than a typed read. TheReference.displaythat names it is blocked either way. - A name or an address carrying a property R4 does not define, or carrying its only marker at a
value shape R4 does not give that marker, at an element name R4 does not type as one of the two
datatypes. Positive classification is closed and shape-read, so an unrecognized sibling or the
wrong shape stops it, and the fail-closed block above is scoped to the typed element names. At any
other name the same evidence is routinely something else -
{ "prefix": "1." }is a conformantRequestGroup.action, not a person - and blocking it would destroy conformant clinical and structural content that no re-run restores. Such a value is passed through and counted as an unexamined residual position, like anything else no rule names. At a typed element name neither half is a residual: the standard promised a name or an address there, so whatever the classifier declines is blocked whole. - A name or an address INSIDE a person resource, at a property the demographic map does not list.
The datatype sweep runs outside a person resource only, because inside one the map above has already
decided
name,telecom,photoandaddress. A vendorPatient.aliascarrying{ family, given }is therefore passed through, where the same bytes on anOrganizationare removed. Counted as unexamined like the rest.
The @cosyte/deid/x12 adapter locates PHI structurally in a parsed @cosyte/x12
interchange (HIPAA 005010: 837 claims, 835 remittance, 270/271 eligibility, …). @cosyte/x12 is an
optional peer dependency; the adapter reaches EDI data only through its exported model and re-emits
with its byte-faithful serializeX12.
import { parseX12 } from "@cosyte/x12";
import { deidentifyX12 } from "@cosyte/deid/x12";
import { createDeidContext } from "@cosyte/deid";
const context = createDeidContext({ key: process.env.DEID_KEY! });
const { x12, manifest } = deidentifyX12(parseX12(raw), { context });
// `x12` is the de-identified interchange; `manifest` is the value-free audit.| Locus | Handling |
|---|---|
NM1 (subscriber / patient / dependent) |
name (03 to 07) removed; id (09) routed by the 08 qualifier: SSN removed, member removed under Safe Harbor |
NM1 / N1 (employer, entity code 36) |
name + id removed on the same footing as a patient-side party: §164.514(b)(2)(i) names the individual's employers |
NM1 (recognized provider / organization) |
retained (provider identity is not the individual's PHI, mirroring the HL7 adapter), and the role code it was classified on is recorded at its locus |
NM1 (unknown entity code) |
fails closed: name + id blocked |
N1 / SBR |
N1 payer/provider org retained (patient-side/unknown party fails closed); SBR-03 group/policy removed under Safe Harbor, SBR-04 group name removed |
N3 / N4 |
street + city removed, ZIP → safe 3-digit, state retained (unmapped N4-06 location id fails closed) |
DMG-02, DTP-03, DTM-02 |
dates → year |
PER |
contact name + communication numbers removed |
REF |
patient / member / group / SSN identifier removed under Safe Harbor; admin/provider reference retained; unknown qualifier fails closed |
CLM-01 / CLP-01 |
patient account number removed under Safe Harbor (pseudonymized only under a preset that does not claim it) |
Clinical / financial (HI, SV*, SVC, AMT, CAS…) |
retained untouched: diagnosis / procedure codes, amounts, quantities survive byte-identical |
The @cosyte/deid/ncpdp adapter locates PHI structurally in a parsed @cosyte/ncpdp
Telecommunication (vD.0) transaction. @cosyte/ncpdp is an optional peer dependency.
import { parseTelecom } from "@cosyte/ncpdp/telecom";
import { deidentifyTelecom } from "@cosyte/deid/ncpdp";
import { createDeidContext } from "@cosyte/deid";
const context = createDeidContext({ key: process.env.DEID_KEY! });
const { telecom, manifest } = deidentifyTelecom(parseTelecom(raw), { context });The Patient (01), Insurance (04), and Coordination-of-Benefits (05) segments and the header Date of
Service carry the individual's identity: name / phone / street / city removed, ZIP → 3-digit, DOB and
dates → year, patient / cardholder / group ids removed under Safe Harbor. The Prescriber (03) id is removed
(a deliberate asymmetry with the X12 adapter's provider-retention stance). A free-text field
(544-FY DUR, 504-F4 message) and any unmapped / unknown segment fail closed; the clinical /
financial segments (NDC drug codes, quantities, days-supply, pricing, DUR codes) are retained untouched.
NCPDP SCRIPT (ePrescribing XML) is REFUSED, not half-handled: its parser's serializeScript emits
only modeled fields (a round-trip drops unmodeled XML) and its Patient model has no address / phone /
patient-id field, so a faithful structural de-id is not achievable through the current public surface,
and shipping a partial pass would be a false-safety hazard the fail-closed posture forbids. Hand a
SCRIPT document to either NCPDP entry point and you get a typed DEID_FORMAT_UNSUPPORTED error naming
the format and the parser-surface reason, and no transformed document, manifest or partial output
of any kind, rather than whatever the Telecom parser would have made of those bytes. Telecom callers
are unaffected.
One transaction per Telecom message in this release. deidentifyTelecom handles a message that
carries one transaction. A message whose header declares more than one is not handled yet: with
@cosyte/ncpdp 0.1.x installed the call throws that parser's multi-transaction build error and
returns nothing, and an older @cosyte/ncpdp returns only the first transaction with the header count
unchanged. Split a multi-transaction message before you de-identify it. A later release de-identifies
every transaction or refuses the message with a typed DEID_* error.
The @cosyte/deid/dicom adapter delegates rather than reimplements: @cosyte/dicom
already ships the PS3.15 Annex E de-identification (the Basic Application Level Confidentiality
Profile), so this adapter orchestrates that pass under the unified policy and folds its value-free report
into the unified manifest. @cosyte/dicom is an optional peer dependency.
import { parseDicom } from "@cosyte/dicom";
import { deidentifyDicom, deidentifyDicomBuffer } from "@cosyte/deid/dicom";
const { dataset, manifest, burnedInAnnotationHazard } = deidentifyDicom(parseDicom(part10Bytes));
const { bytes } = deidentifyDicomBuffer(part10Bytes); // parse → de-id → re-serialize in one callThe full Basic Profile applies by default (no key needed): Patient Name/ID/Birth Date, institution,
referring physician, dates and the enumerated Annex E attributes are removed; Study / Series / SOP
Instance UIDs are consistently remapped so image/series/study relationships survive; private tags are
removed (fail-closed, kept only via a known-safe retain list, empty by default); clinical/technical
values and pixel bytes are retained untouched. The output carries Patient Identity Removed = YES.
The declaration is machine-readable, not only prose. Beside the De-identification Method text at
(0012,0063), the pass writes the CID 7050 coded terms for the profile and for every option it
applied into De-identification Method Code Sequence (0012,0064), so a receiving archive branches on
a code rather than parsing a sentence. Every option it withheld is declared by its coded term on the
result (optionDeclarations) and never in that sequence, because a term there means "used". A profile or
option the vocabulary cannot name, or a declaration the run cannot read back out of its own serialized
bytes, aborts the pass rather than publishing an approximate claim. A sequence the input carried is
dropped, with a value-free warning: no de-identification rule inspects its contents, so none of it may
ride inside output stamped Patient Identity Removed = YES.
Replacement-UID referential integrity is scoped, and the scope is on the result. With no shared
uidMap it is guaranteed only within the single call (uidReferentialIntegrity.scope is
"single-call"); supply one and it reaches every call that shares it.
Pixel PHI is flagged, never cleaned. This is a metadata-only de-identifier (metadataOnly is
always true): it cannot inspect pixels, so recognizable text burned into the image (Safe Harbor
category Q) is not removed. When Pixel Data may carry burned-in annotation, the result sets
burnedInAnnotationHazard === true and emits DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED: do not release
such an image on metadata alone. Pixel cleaning is a future @cosyte/dicom-pixel.
For research and analytics, the same patient must stay linkable across a whole corpus after de-id. A
registry (createDeidRegistry) holds your key and keeps the same patient's dates shifting by the
same offset, intervals preserved, and the same identifier mapping to the same pseudonym, across every
document and every run.
import {
createDeidRegistry,
deidentify,
defineDeidPolicy,
SAFE_HARBOR_CATEGORIES,
} from "@cosyte/deid";
const registry = createDeidRegistry({ key: process.env.DEID_KEY! });
const research = defineDeidPolicy({
name: "research", // date-shift may NOT wear the "safe-harbor" label, it is Expert-Determination
transforms: { [SAFE_HARBOR_CATEGORIES.DATES]: "date-shift" },
});
const ctx = registry.forPatient("patient-1"); // reuse for every document of this patient
deidentify(model, { policy: research, context: ctx }); // dates shift consistently, intervals intact
registry.pseudonym("MRN-1"); // same MRN → same surrogate corpus-wideThe key contract. You supply the key; there is no weak default (an absent key is a fatal
DEID_NO_KEY, never a silent fallback). Rotating the key is intentional linkage breakage: a new
key un-links a corpus from records made under the old one. The library holds no persistent key store.
Date-shift retains dates in shifted form, and a keyed surrogate is derived from the individual's own
value, so both are Expert-Determination-supporting, not Safe Harbor. The library rejects any
policy claiming the safe-harbor label that carries either (DEID_POLICY_INVALID), naming the
offending category and transform.
Narrative loci (HL7 OBX-5 / NTE, C-CDA section <text>, FHIR note / div, X12 MSG / NTE,
NCPDP free text) can carry any of the 18 categories in prose, with no structural handle on where. The
default is fail-closed: with no redactor, every free-text locus is blocked (value withheld). The
library ships no NLP model and no built-in regex scrub: a naive pass over clinical prose is a
false-safety hazard.
To redact free text rather than block it, bring your own redactor: a function wrapping your regex /
pattern engine or clinical-NER de-id model. The engine invokes it at each free-text locus and writes its
output back in place, recording the locus as consumer-asserted (DEID_FREETEXT_CONSUMER_REDACTED).
import { deidentifyHl7 } from "@cosyte/deid/hl7";
import { type FreeTextRedactor } from "@cosyte/deid";
const redactor: FreeTextRedactor = ({ text }) => ({ text: myNerModel.scrub(text) });
const { document } = deidentifyHl7(parseHL7(raw), { context, redactor });The fail-closed contract holds regardless of the redactor. No redactor → block; the redactor throws
→ block; the redactor returns nothing → block; the redactor returns { text } → written back in place.
A redactor is never allowed to leak free text through on failure.
The honesty boundary. A returned redaction is trusted as consumer-asserted: the engine does not re-scan it for residual PHI, and "no findings" from a BYO redactor is not an attestation. A BYO redactor's completeness is the consumer's responsibility (Expert-Determination territory). The structural PHI removal the adapters perform, and the clinical over-scrub guard, are unchanged: the redactor handles the free prose only.
HIPAA has two routes to de-identification: Safe Harbor (mechanical, implemented here) and Expert
Determination (§164.514(b)(1), a qualified statistician's risk judgment). @cosyte/deid supports
the latter and never renders it. buildExpertDeterminationSupportReport(manifest) structures the
value-free manifest into what an expert reasons about: per-locus dispositions, coverage across all 18
categories, and two residual inventories, then hands it over. The
retained-quasi-identifier inventory holds pieces of the original value that survived (year-only
dates, safe 3-digit ZIP prefixes, exact ages ≤ 89, and any whole value a profile's retention set
kept). The keyed-surrogate residual inventory is its sibling and holds every locus flagged
reidentificationCode: a replacement derived from the value under your key, where no plaintext
survives but the linkage does. They are kept apart on purpose, because a determiner reasons about the
two very differently.
import { buildExpertDeterminationSupportReport } from "@cosyte/deid";
const { manifest } = deidentifyHl7(parseHL7(raw), { context });
const report = buildExpertDeterminationSupportReport(manifest, { policy: "safe-harbor" });
report.determination; // => null: the library never renders oneThe hard boundary. The report never says the output "is de-identified", never computes or
fabricates a re-identification risk score, and reaches no conclusion: determination is always
null and a prominent disclaimer leads. It is value-free (loci / categories / dispositions / counts,
never a value). The one quasi-identifier statistic it can surface (the smallest equivalence-class size,
a k-anonymity indicator) is computed only over class sizes you supply (the library has no view
of quasi-identifier values) and is stamped a descriptive input, never a verdict.
formatExpertDeterminationSupportReport(report) renders the same facts as Markdown for a statistician.
Two named presets ship, and defineDeidProfile() derives a per-site profile that can only ever
tighten the base, never loosen it.
import {
SAFE_HARBOR_PROFILE,
LIMITED_DATA_SET_PROFILE,
defineDeidProfile,
profileOptions,
SAFE_HARBOR_CATEGORIES,
createDeidContext,
} from "@cosyte/deid";
// The fail-closed default (dates → year, MRN/beneficiary/account REMOVED, the (R) catch-all blocked).
// It uses no keyed transform, so it needs no key.
SAFE_HARBOR_PROFILE.standard; // => "safe-harbor"
// A longitudinal research preset: dates are DATE-SHIFTED, not generalized, and MRN / beneficiary /
// account keep a CONSISTENT KEYED SURROGATE so linkage survives. It also keeps the postal address
// parts §164.514(e)(2)(ii) NAMES (town or city, State, the WHOLE zip code) under the HL7 v2 pass, with
// the street and every other geographic component removed and each kept part recorded. On DATES it is
// deliberately STRICTER than §164.514(e)(2), which names no date at all: it shifts them by choice.
// Deliberately less protective than Safe Harbor → NOT labelled "safe-harbor", requires a keyed
// per-patient context, and is NOT a certified de-identification (nor, on its own, a §164.514(e)
// Limited Data Set; that needs a Data Use Agreement, which is yours, and which this library neither
// holds nor checks).
LIMITED_DATA_SET_PROFILE.requiresContext; // => true
// A per-site profile may only move a category to an equal-or-STRONGER transform; a weakening override
// is a fatal DEID_PROFILE_INVALID.
const strict = defineDeidProfile({
name: "site-strict",
transforms: { [SAFE_HARBOR_CATEGORIES.GEOGRAPHIC]: "redact" }, // generalize → redact (stronger): OK
});
const ctx = createDeidContext({ key: process.env.DEID_KEY! });
const opts = profileOptions(strict, ctx); // pass straight to any adapter@cosyte/deid transforms per a policy and evidences what it did: it never certifies. Read
docs-content/limitations.md before relying on it for anything that
leaves your control: the fail-closed posture, structured-core-only (free text is block-by-default),
DICOM metadata-only (burned-in pixels flagged, not cleaned), NCPDP SCRIPT refused, NCPDP
Telecom one transaction per message, the BYO
free-text redactor is the consumer's responsibility, and the Expert-Determination report makes no
determination.
- Policy engine:
safe-harborbuilt in;defineDeidPolicy()to deviate deliberately. - Five transforms: redact, generalize (date→year, ZIP→3-digit/
000, age→90+), keyed date-shift, keyed-HMAC pseudonymize, keyed hash. - 18 Safe Harbor categories: §164.514(b)(2)(i)(A) to (R), including the open-ended catch-all (R).
- Fail-closed rule: an unrecognized structure, an un-locatable identifier or free text is blocked, never passed through; clinical values are retained untouched.
- Value-free manifest: category + transform + locus + count + disposition + code + a boolean
reidentificationCode(trueonly where a keyed surrogate was emitted) + apartyRolecode (only where a party was left in place because its role sits outside the scope clause), never a value, never the key, never the date-shift offset. The locus is the one field built out of the document: a per-format adapter names the position with the identifier that sits there, so each identifier is checked against the shape its position promises and a non-conforming one is refused asWITHHELD_LOCUS_TOKEN(<withheld>) rather than echoed.
MIT © Cosyte