From de957664746ad6d625c725d3430bfdf4bb036017 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Fri, 25 Sep 2026 23:31:54 +0000 Subject: [PATCH 1/2] State the one-transaction limit of the NCPDP Telecom adapter deidentifyTelecom handles a Telecom message that carries one transaction. With @cosyte/ncpdp 0.1.x a multi-transaction message makes the call throw that parser's build error and return nothing; an older @cosyte/ncpdp returns only the first transaction with the header count unchanged. The README's NCPDP section, its limitations summary and docs-content/limitations.md now say so, and tell a caller to split such a message first. The fix is filed for review. Two en dashes in the README become plain text. Claude-Session: https://claude.ai/code/session_01U24bnHKNxQWMxmfN7ny7w9 --- README.md | 14 +++++++++++--- docs-content/limitations.md | 30 +++++++++++++++++++----------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 711ee17..78452f6 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ const { x12, manifest } = deidentifyX12(parseX12(raw), { context }); | Locus | Handling | | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **`NM1`** (subscriber / patient / dependent) | name (`03–07`) **removed**; id (`09`) routed by the `08` qualifier: SSN **removed**, member **removed** under Safe Harbor | +| **`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 | @@ -461,6 +461,13 @@ the format and the parser-surface reason, and **no** transformed document, manif 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. + ## De-identify a DICOM study The `@cosyte/deid/dicom` adapter **delegates rather than reimplements**: [`@cosyte/dicom`](https://github.com/cosyte/dicom) @@ -642,7 +649,8 @@ 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`](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**, the BYO +**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**. @@ -651,7 +659,7 @@ determination**. - **Policy engine**: `safe-harbor` built 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)–(R), including the open-ended catch-all (R). +- **18 Safe Harbor categories**: §164.514(b)(2)(i)(A) to (R), including the open-ended catch-all (R). - **Fail-closed rule**: anything uncertain is blocked, never passed through; clinical values are retained untouched. - **Value-free manifest**: category + transform + locus + count + disposition + code + a boolean diff --git a/docs-content/limitations.md b/docs-content/limitations.md index ab20451..ec27684 100644 --- a/docs-content/limitations.md +++ b/docs-content/limitations.md @@ -26,17 +26,17 @@ Read this page before you rely on the library for anything that leaves your cont ## What it does NOT do -| It does **not**… | Because… | -| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Certify HIPAA de-identification | The library **transforms and evidences**; it never certifies. Output is _"Safe-Harbor-transformed per the configured policy,"_ never _"de-identified."_ | -| Discharge the §164.514(b)(2)(ii) **actual-knowledge** clause | That is an organizational judgment about what a recipient knows: the library surfaces the residual (kept year, safe-3-digit ZIP) so a human can apply it, but cannot make it. | -| Render or certify **Expert Determination** (§164.514(b)(1)) | _"The risk is very small"_ is a qualified statistician's contextual judgment about a dataset **and its recipient**. The [ED support report](#expert-determination) emits value-free facts as **input**; `determination` is always `null` and it computes no risk score. | -| De-identify **free text / narrative** | Free-text loci (HL7 OBX-5/NTE, C-CDA narrative ``, FHIR notes/`div`, X12 MSG/NTE, NCPDP free text) are **blocked by default**. A [BYO redactor](#free-text) is **consumer-asserted**, never the library's guarantee; a naive built-in regex scrub is deliberately **refused** as a false-safety hazard. | -| Clean **DICOM burned-in pixels** or full-face images (category Q) | v1 is **metadata-only** (delegated PS3.15 Annex E). Burned-in annotation raises `DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED` and `burnedInAnnotationHazard`; pixel decode is a future `@cosyte/dicom-pixel`. The two pixel options of CID 7050 are declared **withheld** on the result and never written to `(0012,0064)`. **Do not release an image on metadata alone.** | -| Guarantee **DICOM replacement-UID consistency across calls** by default | Referential integrity of replacement UIDs is guaranteed **only within a single call** unless the caller supplies a shared `uidMap`, which then extends it to every call sharing that cache. The declared scope is on the result (`uidReferentialIntegrity.scope`), so it is read rather than inferred. | -| Handle **NCPDP SCRIPT** ePrescribing | **Refused, not deferred in prose.** The current parser surface (lossy serialize + an address-less `Patient` model) cannot support a faithful structural de-id, so an NCPDP entry point handed a SCRIPT document raises a typed `DEID_FORMAT_UNSUPPORTED` naming the format and the parser-surface reason and returns **no** document, manifest or partial output at all. NCPDP **Telecom** is supported and is unaffected. | -| Guarantee against a determined re-identification attack | De-identification reduces risk to the regulatory bar; it is not a cryptographic guarantee. **Key custody is the consumer's**: a leaked HMAC key or date-shift offset re-identifies. | -| Do anything the manifest does not record | If it is not in the manifest, the library did not do it. The manifest is the complete, value-free audit. | +| It does **not**… | Because… | +| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Certify HIPAA de-identification | The library **transforms and evidences**; it never certifies. Output is _"Safe-Harbor-transformed per the configured policy,"_ never _"de-identified."_ | +| Discharge the §164.514(b)(2)(ii) **actual-knowledge** clause | That is an organizational judgment about what a recipient knows: the library surfaces the residual (kept year, safe-3-digit ZIP) so a human can apply it, but cannot make it. | +| Render or certify **Expert Determination** (§164.514(b)(1)) | _"The risk is very small"_ is a qualified statistician's contextual judgment about a dataset **and its recipient**. The [ED support report](#expert-determination) emits value-free facts as **input**; `determination` is always `null` and it computes no risk score. | +| De-identify **free text / narrative** | Free-text loci (HL7 OBX-5/NTE, C-CDA narrative ``, FHIR notes/`div`, X12 MSG/NTE, NCPDP free text) are **blocked by default**. A [BYO redactor](#free-text) is **consumer-asserted**, never the library's guarantee; a naive built-in regex scrub is deliberately **refused** as a false-safety hazard. | +| Clean **DICOM burned-in pixels** or full-face images (category Q) | v1 is **metadata-only** (delegated PS3.15 Annex E). Burned-in annotation raises `DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED` and `burnedInAnnotationHazard`; pixel decode is a future `@cosyte/dicom-pixel`. The two pixel options of CID 7050 are declared **withheld** on the result and never written to `(0012,0064)`. **Do not release an image on metadata alone.** | +| Guarantee **DICOM replacement-UID consistency across calls** by default | Referential integrity of replacement UIDs is guaranteed **only within a single call** unless the caller supplies a shared `uidMap`, which then extends it to every call sharing that cache. The declared scope is on the result (`uidReferentialIntegrity.scope`), so it is read rather than inferred. | +| Handle **NCPDP SCRIPT** ePrescribing | **Refused, not deferred in prose.** The current parser surface (lossy serialize + an address-less `Patient` model) cannot support a faithful structural de-id, so an NCPDP entry point handed a SCRIPT document raises a typed `DEID_FORMAT_UNSUPPORTED` naming the format and the parser-surface reason and returns **no** document, manifest or partial output at all. NCPDP **Telecom** is supported and is unaffected. | +| Guarantee against a determined re-identification attack | De-identification reduces risk to the regulatory bar; it is not a cryptographic guarantee. **Key custody is the consumer's**: a leaked HMAC key or date-shift offset re-identifies. | +| Do anything the manifest does not record | If it is not in the manifest, the library did not do it. The manifest is the complete, value-free audit. | ## Fail-closed posture @@ -348,3 +348,11 @@ report.disclaimer.includes("NOT a determination"); // => true The library's promise is narrow and honest: **structured-field, fail-closed, policy-driven Safe-Harbor transformation with a value-free manifest, never a leaked patient, never a destroyed clinical value, and never a claim of "de-identified."** + +## NCPDP Telecom: one transaction per message + +`deidentifyTelecom` handles a Telecom message that carries one transaction. A message whose header +declares more than one is not handled in this release: 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 such a message first. A later +release de-identifies every transaction or refuses the message with a typed `DEID_*` error. From 1169910e5197e4b0e44a42195e7405381ac17887 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Fri, 25 Sep 2026 23:32:46 +0000 Subject: [PATCH 2/2] Re-pad the X12 table after the en dash became plain text Claude-Session: https://claude.ai/code/session_01U24bnHKNxQWMxmfN7ny7w9 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 78452f6..4ecc3d1 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ const { x12, manifest } = deidentifyX12(parseX12(raw), { context }); | 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`** (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 |