Skip to main content
Version: v0.1.3

Scope and non-goals

This is the summary of what the package leaves out, and of the de-identification residuals it discloses. The known limitations page is the full "do not over-trust" list; read both before you point this package at real data.

@cosyte/dicom is metadata-first by design. Even at v1-complete, do not rely on it for:

  • Pixel data. No decode/decompression, no frame assembly from fragments, no Basic or Extended Offset Table interpretation, no rendering, no measurements: Pixel Data is exposed as raw bytes. An object under the supported encapsulation set, every syntax PS3.5 2026c section A.4 names (JPEG Baseline, Extended, Lossless, and Lossless First-Order Prediction; RLE Lossless; JPEG-LS; JPEG 2000 and HTJ2K; MPEG2; MPEG-4 AVC/H.264; HEVC/H.265; JPEG XL; Deflated Image Frame Compression; Encapsulated Uncompressed Explicit VR LE), parses, and readPixelDataFragments returns its Basic Offset Table and fragments as raw bytes; its pixels are never decoded. serializeDicom writes each of those syntaxes back, a de-identified object included, its fragments byte for byte, and refuses with INVALID_ENCAPSULATED_PIXEL_DATA, never repairing, a top-level Pixel Data section A.4 does not allow. A fragment stream that ends before its Sequence Delimitation Item parses with DICOM_PIXEL_DATA_FRAGMENTS_NOT_DELIMITED. Two misuses PS3.5 section A.4 forbids parse without a code: an encapsulation-syntax object with no top-level Pixel Data, and one whose top-level Pixel Data is native. A JPIP Referenced object carries no Pixel Data at all, only a Pixel Data Provider URL that is read and never fetched. The SMPTE ST 2110 syntaxes and every retired UID stay the fatal UNSUPPORTED_TRANSFER_SYNTAX. → @cosyte/dicom-pixel.
  • Burned-in PHI. v1 warns it cannot remove burned-in annotation; a "de-identified" output is metadata-de-identified only.
  • A sequence the parser could not open: content is dropped, not passed through. deidentify() recurses only into a sequence whose items the parser materialized. An SQ element with no items is now emptied: its bytes are Data Sets by PS3.5 §7.5.1 and PS3.15 §E.1.1 obliges the de-identifier to reach the listed attributes inside them, so a run that cannot enumerate them must not ship them. report.unauditableSequences names the carrier and the byte length dropped (the emptied class is capped at 64 entries of its own, budgeted separately from the retained class below so neither can spend the other's; the emptying itself is never capped), and DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE is raised. Expect data loss on such a file. The parse refusal is usually a sender-side encoding defect, but not always: a conformant file nested deeper than this library's own NESTING_DEPTH_LIMIT of 64 is refused the same way and loses that sequence too. A private SQ kept under RetainSafePrivate + a Profile was exempt in earlier releases and is not any more (DICOM-PRIVATE-SQ-CARVE-OUT): the profile vouches for the private attribute, which is what PS3.15 §E.3.10 licenses, not for an item stream nothing could read. The file's own declaration is a second licensed authority and gets exactly the same answer: §E.3.10 lists it first among the ways an attribute is known safe, and a SAFE block whose element is a Sequence is still parsed in its entirety with each nested attribute handled on its own merits, which is what §E.3.10's own Sequence paragraph requires. That closure keys on the parse tree, and the profile is a second authority (DICOM-PRIVATE-SQ-PARSE-VR, now closed): a private carrier the profile declares SQ is emptied and named in report.unauditableSequences even when the parse tree calls it something else, keeping the VR the file carried. The two disagree whenever the profile did not reach parseDicom (Implicit VR LE writes no VR for a private tag, so the element arrives UN) or the sender wrote a binary VR under Explicit VR, where the wire wins: the latter on a fully conformant file, with ds.warnings empty, so the report is the only channel. Pass your profile to parseDicom as well as to deidentify() and the sequence is walked and its non-PHI content kept rather than dropped. One shape is still exempt and still leaks: an undefined-length UN the CP-246 descent could not read as a sequence, where no profile named the attribute. It keeps vr === "UN", and the rule cannot be extended to UN in general because every ordinary UN element also has no items. Where a profile does declare that private attribute SQ, the rule above reaches it and empties it, because that test does not look at the length field. And the limit on that closure is not what an earlier wording here claimed (the two sets are incomparable, not nested), so that wording is deleted rather than adjusted again: the rule empties what a Profile declares SQ, and every other declaration falls through to the removal rule in the next bullet. The surface those declarations used to ship a value through is pinned as a measured matrix in test/integration/deident-private-reservation.test.ts rather than listed in prose, because the list has been wrong twice; every cell of it is now emptied here or removed there, and none of them carries a nested (0010,0010) Patient's Name into de-identified output. For all of these, the reliable test remains el.items === undefined on the element itself.
  • RetainSafePrivate REMOVES a private value this run did not enumerate, and the over-redaction is the point rather than a corner case. PS3.15 2026c §E.3.10 retains Private Attributes "known by the de-identifier to be safe from identity leakage" and requires that "all other Private Attributes shall be removed or processed in the element-specific manner recommended by Deidentification Action (0008,0307), if present within Private Data Element Characteristics Sequence (0008,0300)"; this library does not implement (0008,0307), so removal is the branch available to it. A value nothing enumerated is not known to be safe, so it goes. Enumeration means one of exactly four things: the run walked the value as Data Elements and put each of them through the action table (a private SQ whose items the parser materialized), the whole value was matched as a member of your profile's private dictionary (a Private Creator (gggg,00EE)), the value is zero-length and so encodes no Data Set, or the file's own declaration named it safe: a block with a Value of SAFE in Block Identifying Information Status (0008,0303), or an element listed in Nonidentifying Private Elements (0008,0304) of a MIXED block, both within Private Data Element Characteristics Sequence (0008,0300). 🩺 That fourth one is the SENDER's assertion and not this run's finding: the value is retained unexamined, on the word of the system that wrote the file. §E.3.10 gives one mitigation and it is the caller's: if you do not trust the sender, do not pass RetainSafePrivate, and every private attribute is removed. Decoding a value under the VR your profile declares for it is NOT enumeration, and neither is the embedded-attribute scanner's silence: that scanner reads string carriers only and decodes tiles in the file's own encoding, so a nested Data Set written in another transfer syntax passes it untouched on a perfectly scannable LO carrier, measured. So the retained class is exactly those four, and an ordinary vendor scalar under an ordinary string VR that the file's own declaration does not name safe is removed. If you carried opaque vendor values through RetainSafePrivate before and your senders declare nothing, they are gone: getting them back needs either a (0008,0300) declaration in the file, which is the sender's word and not this run's finding, or a content test that separates a nested Data Set from a legitimate binary blob, which is the open product question and not a flag on this path. Each removal is recorded per instance (the tag plus the Data Set it lived in), in report.unenumerablePrivateRemovals, with applied: "removed" and reason: "unenumerable", and named in report.removedPrivateTags as every removed private tag always has been. That record is complete and never capped, at any input size, because telling an unenumerable removal from an Annex E one is the guarantee the fail-safe's audit half rests on; the matching warnings stay bounded. Two audit-contract changes to act on if you switch on either: report.unauditableSequences no longer produces its retired kept outcome for a retained private value (its applied field was "emptied" | "kept" and is "emptied" alone now), so an entry there means content is not in your output, and DICOM_DEIDENT_PRIVATE_CARRIER_NOT_AUDITABLE changes meaning from "shipped unexamined" to "removed unexamined". No published code is renamed or retired (so narrowing on that name keeps compiling and has to be re-read rather than re-typed), and a code is added: DICOM_DEIDENT_PRIVATE_DECLARATION_NOT_RESOLVED, raised once per Item of (0008,0300) that does not resolve to a block of private Data Elements reserved in that Data Set, so a declaration you expected to keep something and that kept nothing is never silent. Earlier releases kept this class verbatim and merely disclosed it, under (0012,0062) Patient Identity Removed = YES, and the package said outright that this was a disclosure and not a fix.
  • An unrecognized Explicit VR is read (and written) long-form, and deidentify() still empties the element. PS3.5 2026c §6.2: "All new VRs defined in future versions of DICOM shall be of the same Data Element Structure as defined in [§7.1.2] with reserved bytes after the VR and a 32-bit unsigned integer VL." So two on-wire VR bytes outside the 34 this release knows get a 12-byte header, exactly as OB and UT do, on both the read and the write path. Earlier releases read the 8-byte form, which read the length out of the two bytes §6.2 reserves. What such a file did then was shape-specific (sometimes a whole-object refusal, sometimes a clean parse into a tree the sender did not write), which is why this package ships scripts/measure-unrecognized-vr.ts and no one-sentence account of it. The value is otherwise handled like any other: a declared length past the end of the buffer, or an undefined length, is refused exactly as it is for every other non-SQ VR. The trade is stated rather than buried: a sender that ignores PS3.5 §6.2 and writes an unrecognized VR short-form produced a readable object before and is refused now. deidentify() still empties such an element, because reading a header is not the same as knowing what its value means: Table E.1-1 acts per attribute, and nothing can say whether a VR from a later edition holds a name, a date or an opaque blob. report.undefinedVrElements names the byte offset and the byte length dropped (capped at 64 entries; the emptying itself is never capped) and DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE is raised. It names no tag, deliberately and uniquely among the report's findings: the header may still have been fabricated out of the middle of some element's value, in which case its tag bytes are document content, and nothing distinguishes that case from an honestly-written unrecognized VR, so the tag is withheld either way. An Implicit VR LE file cannot reach any of this: there the VR comes from the dictionary. UN is one of the 34 and is not affected. There is no exemption: unlike the sequence rule above, RetainSafePrivate does not keep such an element.
  • RetainSafePrivate retains nothing inside ANY Item of a sequence whose item stream over-runs its own declared length. An Item that declares more bytes than its enclosing Sequence does absorbs whatever follows the sequence, so a private data element the sender wrote at the root (with no Private Creator reserving its block there) can land beside a creator that is genuine Item content and be kept verbatim. Measured on an earlier published tarball it was, with report.removedPrivateTags reading [], the object stamped (0012,0062) Patient Identity Removed = YES and ds.warnings empty: a stamp that outran the redaction. (Measured on a later published release, this direction is fixed.) PS3.5 2026c §7.8.1 scopes a private block reservation to the Item ("The scope of the reservation is just within the Item"), so a file that contradicts itself about where the Item ends does not determine which reservation covers an element; PS3.15 2026c §E.3.10 retains only what is known safe and requires that "all other Private Attributes shall be removed or processed in the element-specific manner recommended by Deidentification Action (0008,0307), if present within Private Data Element Characteristics Sequence (0008,0300)" (a two-branch clause; this library does not implement (0008,0307), so removal is the branch available to it). So every private element the recursion reaches inside such a sequence, at every depth below it, is removed and named in report.removedPrivateTags. The unit is the SEQUENCE, not the individual Item: descendSequence decides once and applies it to every item, so a two-item sequence where only item 1 over-runs also loses a genuine block sitting in the honest item 0 (measured; the grid sweeps single-item fixtures, so that extra cost is NOT inside the priced 20). The one route this rule did not reach is closed (DICOM-PRIVATE-SQ-CARVE-OUT): a private SQ the profile vouches for was kept verbatim before the descent ran, so nothing inside it was examined; it is now walked like any other sequence, and a private element the file's own length fields pulled into its items is refused there. The direction is not fail-safe on its own and the opposite claim is retracted: it depends on where the sender put the Private Creator. Both absorb placements are pinned by tests, and so is the eject placement (below). Treat DICOM_ITEM_CROSSES_SEQUENCE_END as "this file's structure is in dispute", never as a statement about what was retained. The price is measured, not asserted: on scripts/measure-sq-bound-grid.ts, 58 cells lose a private value and 20 of them were not leaking anything (the creator and the data element were both genuine Item content). Retention on a file that does not contradict itself is unchanged, and no reading changes: no parser file is touched. This is not a parser bound: an over-declaring Item and an under-declaring Sequence are byte-identical, so nothing on the wire can separate them.
  • RetainSafePrivate also retains nothing a Data Set holds AFTER a sequence whose own contents contradict the extent it declared. The mirror direction: an Item that under-declares ejects its trailing elements out into the enclosing Data Set, and a Private Creator that lands there reserves a block for elements the sender never put beside it: the same false attestation. Measured on the published tarball of an earlier release: report.removedPrivateTags [], the value in the serialized output, (0012,0062) = YES, ds.warnings empty and no throw under { strict: true }. It applies in every Data Set, not only the root: an inner sequence ejecting a creator into the still-usable Item that encloses it is measured and pinned. The cut is positional, so a reservation the sender wrote ahead of the offending sequence is untouched, and the same reservation written after it is refused. Two predicates, because the parser records the same contradiction two ways: under Explicit VR the item stream over-runs and the span shows up as rawBytes.length > length; under Implicit VR LE that path slices the item stream, so nothing over-runs at all and the descent is refused instead (DICOM_SQ_NOT_DESCENDED, Element.items === undefined). The second predicate is broader than the ejection it is here for, deliberately: it says the parser could not walk the sequence, which other unwalkable item streams also reach. And two BOUNDS, not one, because a Data Set is a Map<Tag, Element>: an ejected element whose tag the Data Set already holds overwrites in place and inherits the earlier element's position, ahead of the sequence it came out of, so Element.byteOffset is checked beside the index. That collision also destroys the root's own value at parse time: PRE-EXISTING, unchanged here, and pinned by a test so it is not mistaken for something this rule handles. It was silent when that was written; DICOM_DUPLICATE_TAG_IN_DATA_SET reports it now (above), and the destruction itself is still unchanged. The price is measured: on scripts/measure-sq-bound-grid.ts all 22 cells that retained a private value at the root on a self-contradicting file whose honest control removes it go to 0, at the cost of 56 further root retentions on self-contradicting files whose honest control does keep the value (the column is de-identified OUTPUT lost a marker (cost), reading 78; the parse-tree LOST/GAINED a marker value counters beside it read 0). Retention on a file that does not contradict itself is unchanged (9 at the root, 6 in an Item), and no reading changes: 0 cells differ in any parse respect, 0 cells whose reading differs, and no parser file is touched.
  • An over-declared Value Length into a binary carrier still leaks, and it is a known trade. The mirror case (an over-declared length that swallows the following element into an OB/OW/US/UN value) is not detected, and a (0010,0020) inside it reaches de-identified output with no warning and no report entry. That is measured and still exactly true for this route, which is every default deidentify() run. The only carrier that gets a diagnostic is one that is itself a private attribute reached through RetainSafePrivate plus a Profile, and what such a diagnostic says is "this value was not enumerated, so the attribute was removed" or "this value was dropped" (emptied, where the profile declares it SQ), never "a swallow was detected here". Arbitrary bytes are exactly what those VRs are for, so no content test can decide it; the only candidate remedy was measured and empties conformant binary values (a legal LUT or blob deleted because 8 of its bytes read as a zero-length (0010,0020)). String carriers are covered, because there the same bytes are provably outside the VR's repertoire. If you accept files from a sender you do not control, treat a binary attribute's length as untrusted.
  • (0012,0063) De-identification Method is ADDED TO, so a prior de-identifier's text is carried into the output. PS3.15 2026c §E.1.1 says a text string describing the method "shall be inserted in or added to" that attribute, and this library used to replace it, destroying the provenance chain the attribute exists to carry, silently, on a file whose earlier pass may be the one a recipient was relying on. It now appends its own text as a further value of the 1-n attribute, after a \, copying the prior bytes through verbatim. The method string is itself a 1-n value: it is split on \ and only the values not already recorded are added. Trailing SPACE and NUL are padding rather than content (PS3.5 2026c Table 6.2-1's LO row, which describes a Value, and LO is 1-n), so they are ignored per value, on both sides when your string is matched against what is already recorded, and trimmed from the value written. Repeated de-identification is therefore a fixed point from the first pass, for every string: with or without a delimiter, and with a pad byte on any value, last or not. Leading spaces are yours and are written through untouched; a method string that is padding only records nothing. One bound, and it is not cosmetic: LO is a short-form VR whose Value Length field is 16 bits, so when the join would not fit, the prior value is replaced rather than added to and report.warnings carries DICOM_DEIDENT_METHOD_NOT_ADDED. Read that code as "the length ceiling was reached", never as "every fallback is disclosed": a (0012,0063) a file encoded under a VR other than LO is also replaced, and that case has its own code, DICOM_DEIDENT_METHOD_NOT_LO. An element the serializer cannot encode would take the whole de-identified object down, and truncating the sender's earlier records instead would be a policy the standard does not state. A deidentificationMethod longer than that ceiling on its own is unchanged from every earlier release and still fails to serialize. The cost is stated rather than glossed: (0012,0063) is not in Table E.1-1, so the Basic Profile keeps it exactly as it keeps every other registered attribute the table does not list, and a sender who wrote something identifying into it now sees that text in de-identified output where the replacement used to remove it as a side effect. That is the retained-by-omission posture of every registered attribute the table does not list, not a rule this insertion states; it is pinned by a residual test that asserts the cost. It is not silent: report.warnings carries DICOM_DEIDENT_METHOD_PRIOR_RETAINED whenever prior bytes from the source file survive into (0012,0063), because an object stamped (0012,0062) = YES carrying text nothing in the run inspected is a stamp that outran the redaction. It is not on report.retained: that field lists the Annex E option sets active for the run, and a kept attribute is not one of them. (0012,0062) Patient Identity Removed is still replaced with YES: the same paragraph of §E.1.1 uses the other verb for it ("shall be replaced or added to"), and the asymmetry is the standard's. A (0012,0063) a file encoded under a VR other than LO is replaced rather than appended to, and report.warnings now carries DICOM_DEIDENT_METHOD_NOT_LO when it is: the join concatenates LO values with the 5CH delimiter, which is not defined over arbitrary octets, so replacing is deliberate and only the silence was the defect. And the method text this library writes for itself is now multi-valued, one Value for the Profile and one per active Option. PS3.5 2026c Table 6.2-1 caps an LO at 64 characters per Value and (0012,0063) is 1-n; the single-value string it wrote before measured 76 characters with no options, 130 with three and 272 with all nine, so every object de-identified without a caller-supplied method, in every release up to and including this one, carried a value no LO may legally hold, in the one attribute a strict receiver reads to decide whether the object was de-identified at all. Re-de-identifying such an object keeps that 76-character Value, because a prior record is added to rather than rewritten (measured: a flat 138 bytes over four passes, values of 76 and 61, DICOM_DEIDENT_METHOD_PRIOR_RETAINED raised for the retention). Your own deidentificationMethod is not bounded for you either: split it on \ yourself if a strict receiver is in your path. Neither over-long Value is silent any more. Whenever the (0012,0063) this run writes carries a Value longer than 64 bytes, from whichever of those two sources, report.warnings carries DICOM_DEIDENT_METHOD_VALUE_OVER_LENGTH. It is a disclosure and not a bound: nothing is shortened, split or truncated, because either would invent a de-identification record nobody wrote. The measurement is over bytes, so it can never miss a Value that is genuinely over the maximum, and it over-reports on any conformant Value whose bytes outnumber its counted characters: PS3.5 2026c §6.2 specifies those lengths in characters rather than bytes and excludes Code Extension escape sequences from the count. The message carries no value, no length, no count, and no origin, and position.byteOffset locates the prior element where there was one and is 0 on the caller route, where there is no element to locate.
  • (0028,0303) = MODIFIED is produced when RetainLongitudinalTemporalModifiedDates is active, and it claims less than it looks like it claims. PS3.15 2026c §E.3.6 defines two mutually exclusive Retain Longitudinal Temporal Information Options (With Full Dates and With Modified Dates), and the second requires (0028,0303) Longitudinal Temporal Information Modified to say MODIFIED. That option name resolves Table E.1-1's modified-dates column and writes that Value; passing both temporal names in one call is rejected with a DeidentifyError. What it does not do is transform a date: this library performs no date transformation at all, on either branch. §E.3.6 requires both halves (the dates "shall be modified", and "the manner of date modification shall be described in the Conformance Statement"), and a library can deliver neither: it applies the column, and PS3.2 Annex N scopes a Conformance Statement to a named product and version, which a library is not. So you perform the transformation, and a MODIFIED on an object whose dates nobody shifted is a caller defect this package cannot detect. It is not silent about it: every run under that option carries DICOM_DEIDENT_DATES_NOT_TRANSFORMED on report.warnings, which states the half of §E.3.6 the run did not discharge, and nothing here claims PS3.15 Annex E conformance for the Modified Dates Option (§E.1.1 makes that claim all-or-nothing). If you shift dates yourself after the call under RetainLongitudinalTemporal, (0028,0303) will say UNMODIFIED and that is now wrong for your object: use the modified-dates option or set the attribute yourself, and describe the manner of modification in your Conformance Statement as §E.3.6 requires. And the declaration is the top-level Data Set's own. (0028,0303) is not in Table E.1-1, so a copy the sender nested inside a Sequence Item is retained by omission like every other registered attribute the table does not list, and still says whatever that sender wrote; read the Data Set's (0028,0303), never a nested one.
  • A standard attribute from a PS3.6 edition newer than this build is removed, conformant or not. deidentify() removes a non-private attribute that neither this build's PS3.6 2026d registry nor Table E.1-1 carries, at every depth and whatever its VR, because the notes to PS3.15 2026d Table E.1-1 name "new Standard Attributes" as a place identifying information may be and nothing on the wire separates a later edition's attribute from one a sender invented. The cost is content on a conformant newer-edition object, with no switch to keep it in this release. Each removal is recorded on report.unregisteredElementRemovals by byte offset and never by tag or VR (such a tag may be four bytes of some element's value), with the complete total on report.unregisteredElementRemovalCount and DICOM_DEIDENT_UNREGISTERED_ELEMENT_REMOVED once per run. It narrows the older gap and does not close it: an attribute PS3.6 registers and the pinned Table E.1-1 does not list is still kept as the source wrote it.
  • DICOMDIR: a record tree, not a File-set. ds.directory reads a DICOMDIR's records and the writer rewrites their offsets, but no DICOMDIR is built from files and no non-de-identified DICOMDIR is removed from a File-set, so a de-identified DICOMDIR is not File-set conformant (DICOM_DEIDENT_DICOMDIR_FILE_SET_NOT_DISCHARGED says so on every run). Referenced File IDs (0004,1500) are kept verbatim, so a path that carries identifying text keeps it. An offset that names no record is refused on write (DIRECTORY_OFFSET_UNRESOLVED), never written stale or as zero, and so is any non-zero offset of a Deflated DICOMDIR (DIRECTORY_OFFSET_DEFLATED). Record keys, (0004,1202) against the root chain and the retired MRDR offset are not checked.
  • Networking & web. No DIMSE (C-STORE/FIND/MOVE, MWL, MPPS); no DICOMweb (QIDO/WADO/STOW). → @cosyte/dicom-net, @cosyte/dicomweb.
  • Transcoding. No transfer-syntax conversion. The serializer re-emits in the dataset's source syntax only.
  • Terminology resolution. Coded values are surfaced (designator + canonical source) but not validated against SNOMED/LOINC/etc.

Roadmap​

v1 is metadata-feature-complete. Future companion packages (separate repos, demand-sequenced):

  • @cosyte/dicom-pixel: pixel decode/decompression, frame extraction, burned-in-annotation cleaning.
  • @cosyte/dicom-net: DIMSE network services.
  • @cosyte/dicomweb: QIDO / WADO / STOW REST clients.