@cosyte/dicom 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,44 +4,184 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
- ### Added
8
-
9
- - **`README.md` now opens with the shared Cosyte lockup, which follows the reader's color scheme**
10
- (`ASSETS-P8`). A `<picture>` block above the H1 carries the dark-ground org tile behind a
11
- `prefers-color-scheme: dark` media query and the light-ground tile as the inner `<img>`, so the
12
- mark sits on a ground that matches the page it is read on. It replaces the per-package banner,
13
- which baked the package name and the one-line tagline into pixels while the two lines directly
14
- beneath it repeated both: the lockup reads "Cosyte" where the H1 reads `@cosyte/dicom`, so the
15
- duplication goes and the heading stays. The alt text is content rather than decoration, because it
16
- is what a screen reader reads on the npm page, and it describes the mark itself rather than
17
- repeating the heading below it.
18
-
19
- **One stated reason is corrected rather than dropped.** The per-package banner was chosen as a
20
- plain markdown image, with no `<img>` and no `<picture>`, expressly because whether npm's markdown
21
- sanitizer preserves a `<picture>` was unverified. It has since been measured on a published
22
- package page: the `<img>` is hoisted out of its `<picture>` by npm's anchor wrapper, so the light
23
- cut renders there, and npmjs.com has no dark mode, so that is the correct cut. A renderer that
24
- strips `<source>` still renders the inner `<img>`, so the worst case is a light-ground mark on a
25
- dark page, never a missing or broken image. Both tile URLs were rechecked with `curl -I` as
26
- `200 image/png`, 10513 and 10455 bytes, before this landed, rather than taken from the `live` flag
27
- in `assets/published-urls.json`, whose own `$fields.status` note says to read `live` for what it
28
- is: a declaration made on evidence from another repo, never a fact checked there.
29
-
30
- ### Changed
7
+ ### Security
31
8
 
32
- - **`CHANGELOG.md` now records what each released version contained.** Every entry in this file sat
33
- under `[Unreleased]` while npm served `0.0.1`, `0.0.3` and `0.0.4`, and `CHANGELOG.md` is in
34
- `package.json#files`, so the file shipped inside the tarball telling a consumer that the version
35
- they had installed was unreleased. That is a false claim on a distributed surface rather than
36
- untidy bookkeeping. No entry's substance is rewritten: each is moved to the version that actually
37
- shipped it, reconstructed from the tags, the GitHub releases, and the changesets each
38
- "Version Packages" commit consumed. `0.0.2` gets no heading because it was never published: it was
39
- version-bumped on 2026-07-27 and the publish that followed did not run, so its four changesets
40
- reached npm inside `0.0.3` and are recorded there. That is also why npm's version list skips
41
- `0.0.2`.
9
+ - **A defined-length sequence under Implicit VR Little Endian was never opened, so PHI nested inside
10
+ one survived `deidentify()` into serialized output while the report said it had been removed**
11
+ (`DICOM-IMPLICIT-SQ-NOT-DESCENDED`, pre-existing and live on the published `0.0.5`). The
12
+ Implicit VR LE parser delegated to the sequence parser **only** in the undefined-length
13
+ (`0xFFFFFFFF`) branch, so `Element.items` was `undefined` for every defined-length sequence under
14
+ that transfer syntax. Nothing that walks items could see inside one, and `deidentify()` recurses
15
+ into a kept sequence only when its items exist: a `(0010,0010)` Patient's Name in a defined-length
16
+ item reached `serializeDicom()` output verbatim, with the `DeidentifyReport` naming only the root
17
+ attribute. **The report therefore asserted a scrub it had not performed**, which is the worse half
18
+ of the defect: an incomplete audit that reads as a complete one is what a caller trusts before
19
+ sharing an object. The identical fixture under Explicit VR LE scrubbed clean, because that path
20
+ already descended both length forms.
21
+
22
+ PS3.5 2026c states the obligation twice, about two different length fields, and the one this broke
23
+ is section **7.5.2** "Delimitation of The Sequence of Items", which governs the `SQ` element's own
24
+ length: "The encoder of a Sequence of Items may choose either one of the two ways of encoding. Both
25
+ ways of encoding shall be supported by decoders of the Sequence of Items." Section 7.5.1 "Item
26
+ Encoding Rules" says the same of each Item's length field. The two choices are independent, so both
27
+ are now descended in every combination. The de-identified output of the
28
+ Implicit VR LE fixture and its Explicit VR LE twin are now the same report, attribute for
29
+ attribute and context path for context path.
30
+
31
+ **Three smaller consequences of a sequence now being read**, each the descent working rather than a
32
+ separate decision: `Element.vm` is the item count instead of the scalar placeholder `1`;
33
+ `Element.value` yields the real items instead of an empty sequence; and warnings raised **inside**
34
+ a defined-length sequence, which previously could not exist because its bytes were never parsed,
35
+ now appear on `ds.warnings` - so under `{ strict: true }` a file whose nested content was always
36
+ non-conformant now throws. The sequence element itself no longer carries `specificCharacterSet`,
37
+ matching every other sequence element in the package; items still inherit the enclosing charset.
38
+
39
+ - **A warning message no longer echoes anything the file said** (`PHI-WARNING-MESSAGE-LEAK`). Three
40
+ diagnostics reproduced consumer-controlled bytes verbatim, and one of them carried onto the dataset
41
+ `deidentify()` labels safe to share:
42
+ - `DICOM_UNSUPPORTED_CHARSET` interpolated the `(0008,0005)` term. That value is **multi-valued on
43
+ the backslash**, so any component of it, at any position, reached the message whole. It is the
44
+ measured leak, and `deidentify()` copies `Dataset.warnings` across, so it rode onto the shared
45
+ artifact.
46
+ - `DICOM_PRIVATE_CREATOR_UNKNOWN` interpolated the Private Creator string, which is an `LO` a
47
+ sender authored and which this warning fires on precisely because nothing vouches for it.
48
+ - The `UNSUPPORTED_TRANSFER_SYNTAX` fatal interpolated the `(0002,0010)` UID into `err.message`,
49
+ and so into `err.stack` and into whatever an error reporter ships off-box. The writer's
50
+ equivalent did the same for a `Dataset` a caller built.
51
+
52
+ Every message now comes from a **frozen registry** keyed by the warning code. Factories take a
53
+ position and structural constants only: a tag this parser composed, a VR checked against the closed
54
+ 34-VR set, and input-derived numbers. There is no string parameter to pass a value through, which
55
+ is the property that distinguishes every `@cosyte/*` parser that does not leak from every one that
56
+ did. A token failing its check renders `<withheld>` rather than being echoed.
57
+
58
+ Where a token has to be named, it comes from a closed set this package controls: the unsupported
59
+ transfer syntax reads as the dictionary's own label for the UID (`JPEG Baseline (Process 1)`) when
60
+ PS3.6 publishes one, `DICOM_UNSUPPORTED_CHARSET` names the **1-based value index** of the offending
61
+ component rather than the component, and the inflate failure reports zlib's error _code_ instead of
62
+ forwarding zlib's message.
63
+
64
+ - **Two model fields are bounded, because a diagnostic fix does not protect a downstream package.**
65
+ `@cosyte/hl7` bounded its messages, went green, and `@cosyte/deid` still leaked off `Segment.type`.
66
+ The same two shapes here now bound on **membership**, not shape, because DICOM offers no shape to
67
+ test (`src/parser/tokens.ts` argues why):
68
+ - `Element.specificCharacterSet` holds only terms PS3.3's closed table names; anything else reads
69
+ `<withheld>`. Decoding is unchanged: an unmappable term was already skipped.
70
+ - `Element.privateCreator` holds only creators the active `Profile`'s overlay names. **With no
71
+ profile, nothing is recognized and the field reads `<withheld>`** - a real reduction in what it
72
+ tells a caller, and deliberate. The raw creator is still available as the `(gggg,00EE)` element's
73
+ own `rawBytes`, as a value, where value discipline applies.
74
+
75
+ `RetainSafePrivate` is unaffected in either direction: `deidentify()` now re-derives the block
76
+ reservation from the creator elements of **each Data Set** it walks, so passing a `Profile` at
77
+ de-identification works whether or not one was passed at parse. **One behaviour does change against
78
+ 0.0.5:** a private data element inside a sequence item whose creator is declared only at the root is
79
+ now removed rather than retained, because the item has no reservation of its own. That is the
80
+ conformant reading and the fail-safe direction, but a sender that declares a creator once at the
81
+ root and writes private data into Per-Frame Functional Groups items will lose those elements under
82
+ `RetainSafePrivate`. Declare the creator in the Data Set that uses it. Per Data Set is load-bearing, not a
83
+ detail: PS3.5 §7.5 makes each Sequence Item its own Data Set and §7.8.1 scopes a reservation to the
84
+ Data Set the creator appears in, so the same block number names different vendors at the root and
85
+ inside an item. Resolving one against the other retained an item's private element on the root's
86
+ reservation and wrote its value into the serialized output.
42
87
 
43
88
  ### Fixed
44
89
 
90
+ - **A malformed defined-length sequence degrades instead of failing the object, and says so.** Under
91
+ Implicit VR Little Endian there is no VR on the wire: `SQ` is this parser's inference from PS3.6,
92
+ not something the sender wrote. So an element whose defined-length value turns out not to be an
93
+ `(FFFE,E000)` item stream keeps its declared byte range on `Element.rawBytes`, leaves the rest of
94
+ the object readable, and raises the new Tier-2 `DICOM_SQ_NOT_DESCENDED` warning. The undefined-length
95
+ form keeps its existing Tier-3 fatal, and that asymmetry is deliberate: a defined length leaves a
96
+ complete alternative reading of the value, and an undefined one leaves none. **The warning exists
97
+ because silence is the defect above**: an undescended sequence is invisible to `deidentify()`, so a
98
+ caller has to be told the audit did not reach inside it. It is not the whole story, though: see the
99
+ next entry for the shape that stays silent.
100
+
101
+ - **Only one of the two un-auditable shapes warns, and the docs now say which.** `deidentify()`
102
+ recurses on `Element.items`, so a sequence the parser could not open is kept verbatim and appears
103
+ nowhere in the report. The refusal added above raises `DICOM_SQ_NOT_DESCENDED`; an undefined-length
104
+ `UN` value the CP-246 descent could not read as a sequence raises **nothing** and keeps `vr ===
105
+ "UN"`. That silence is `PRE-EXISTING` and unchanged. The reliable check is `el.items === undefined`
106
+ on the element, not `ds.warnings`.
107
+
108
+ - **Residual, pre-existing and now reachable from one more shape.** A refused descent drops its
109
+ warnings from `ds.warnings`, but an `onWarning` callback has already been handed them: the emit
110
+ chokepoint delivers to the callback before the rollback can undo the push. The two disagree for
111
+ exactly those warnings, and `ds.warnings` is the accurate record. Unchanged from the CP-246 path,
112
+ disclosed rather than fixed (buffering emissions is a larger change than this slice).
113
+
114
+ - **A test named "explicit-length SQ also descends" proved nothing, and that is how this shipped.**
115
+ Its two assertions were the VR, which dictionary resolution answers whether or not anything
116
+ descends, and `vm === 1`, which was the **scalar placeholder** every non-sequence element already
117
+ carried. Both were green against a parser that never opened the sequence. It now asserts the items.
118
+
119
+ - **A private block reserved in one Data Set resolved the Implicit VR of a private element in
120
+ another, so the same bytes decoded as a different value than the file declared**
121
+ (`DICOM-PARSE-CREATORS-SCOPE`). The parser kept **one** private-creator reservation map for the
122
+ whole file, so a block number claimed by different vendors at the root and inside a Sequence Item
123
+ resolved to whichever creator was read **last**, wherever it sat. That map feeds Implicit-VR
124
+ resolution, which is why this is a wrong decode and not a mislabelling: in a file where an item
125
+ claims block `0x11` and the root afterwards writes `(0029,1101)` without claiming it, the root
126
+ element took the **item's** vendor, and with a `Profile` naming that creator's element byte as `SS`
127
+ the bytes `FF FF` read as **`-1`** rather than as raw bytes. No warning fired. Reservations are now
128
+ scoped to the Data Set the Private Creator Data Element appears in, at every depth the parser
129
+ recurses to: an item does not inherit the enclosing Data Set's blocks, an item's own do not reach a
130
+ sibling item, and none of them survive back out to the enclosing Data Set once the sequence closes.
131
+
132
+ PS3.5 2026c section 7.5.1 is the basis: "Each Item Value shall contain a DICOM Data Set composed of
133
+ Data Elements", closing by delegating to section 7.8 for "rules for incorporating Private Data
134
+ Elements into Sequence Items", where a Private Creator Data Element "shall be used to reserve a
135
+ block of Elements with Group Number gggg". Its first Note states the nesting case outright: each
136
+ item needs to claim the corresponding private block of Elements. Both documents are the vendored,
137
+ SHA-pinned copies under `vendor/nema/part05/`.
138
+
139
+ **Three behaviour changes against `0.0.5`, all confined to files that mix private data with
140
+ sequences.** (1) A private data element in a Sequence Item whose block is claimed only in an
141
+ enclosing Data Set now resolves to `UN` plus `DICOM_PRIVATE_TAG_NO_CREATOR`, where it previously
142
+ took the enclosing claim's VR. The bytes stay on `Element.rawBytes`; what is withheld is a typed
143
+ decode the file never licensed. Declare the creator in the Data Set that uses it. (2)
144
+ `Element.privateCreator` is `undefined` on those elements rather than naming a vendor from another
145
+ Data Set - and that half applies under **every** transfer syntax, including the Explicit VR ones
146
+ where the VR is on the wire and only the attribution was wrong. (3) **Under `{ strict: true }` the
147
+ same file now throws**, because `DICOM_PRIVATE_TAG_NO_CREATOR` is a Tier-2 warning and strict
148
+ promotes Tier-2 warnings to a thrown `DicomParseError`. Nothing about strict changed; the warning
149
+ is simply now emitted where the standard says the reservation does not reach. The default lenient
150
+ parse keeps the file and records the warning.
151
+
152
+ - **An undefined-length `UN` under Implicit VR LE is descended as a sequence instead of failing the
153
+ whole parse.** `parseImplicitLE` treated any resolved VR other than `SQ` at length `0xFFFFFFFF` as
154
+ unrecoverable structural corruption, so one private element the reader could not attribute cost the
155
+ entire object: no patient, no study, no modality, a thrown `INVALID_FILE_META`. It now takes the
156
+ same CP-246 route the Explicit VR path already took for exactly this shape (`VR=UN` plus undefined
157
+ length), promoting the element to `SQ` with a `DICOM_UN_PARSED_AS_SQ` warning when the bytes really
158
+ are items. PS3.5 section 7.5.1 requires decoders to support both item encodings, and losing vendor
159
+ context is not corruption. Bytes that are genuinely not a sequence still throw exactly as before:
160
+ the descent restores parser state and drops its warnings on failure. This matters most alongside
161
+ the scoping fix above, which is what can now turn a profile-resolved `SQ` into a `UN`.
162
+ `DICOM_UN_PARSED_AS_SQ`'s message now says the element _has_ `VR=UN` rather than _declared_ it,
163
+ because on this new path nothing was declared: the `UN` is what VR resolution returned.
164
+
165
+ This is the read-path half of the defect whose de-identify half shipped above. The harm differs
166
+ and that is why they are separate: there, an out-of-scope reservation **retained** a private
167
+ element and wrote it into serialized output; here it hands correct bytes the wrong meaning.
168
+
169
+ - **`DICOM_UN_PARSED_AS_SQ` printed the literal string `UN` where its message promised a tag.** The
170
+ descent primitive is handed a byte range rather than a tag, but both call sites hold one, so it is
171
+ threaded through rather than dropped.
172
+ - **A warning raised inside a Deflated dataset cut its strict-mode snippet from the wrong buffer.**
173
+ The inner descent forwarded to the outer emission chokepoint, which closes over the **compressed**
174
+ source, while the forwarded `position.byteOffset` indexes the **inflated** stream. The result was a
175
+ confidently wrong 16 bytes. The descent now builds its own chokepoint over the inflated buffer; it
176
+ shares the outer `warnings` array, `onWarning` and `strict`, so warning accumulation, callback
177
+ ordering and profile posture are unchanged. **One thing does change and it is worth saying plainly:
178
+ the snippet on that path now carries the real inflated bytes**, where before it carried whatever
179
+ happened to sit at that offset in the compressed source. It was accidentally uninformative and is
180
+ now correctly PHI, under the same `snippet`-is-raw-input contract every other path already has.
181
+ - **`DICOM_NONZERO_RESERVED_BYTES` reported the two reserved bytes as one 16-bit number**, which had
182
+ to pick an endianness the reserved field does not have: it read unambiguously and was wrong under
183
+ one of the two Explicit VR syntaxes. They are now reported separately, in wire order.
184
+
45
185
  - **`README.md` documented an element-access API that does not exist.** `Dataset.get` / `has` /
46
186
  `getAll` take the 8-character `(group,element)` tag (`"00080060"`, case-insensitive) and nothing
47
187
  else, but the README's quickstart, feature list, access-pattern section, typed-value examples and
@@ -81,6 +221,85 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
81
221
  `troubleshooting.md` also stopped sending readers to `CLAUDE.md`, an internal file that does not
82
222
  ship in the tarball.
83
223
 
224
+ ### Changed
225
+
226
+ - **Four documentation claims stated the reverse of the source and are corrected.**
227
+ `spec-notes-tolerance.md`, `troubleshooting.md`, `cookbook.md` and `README.md` all said warnings and
228
+ errors were "PHI-free by construction" while three factories interpolated values, and two of them
229
+ added that a `DicomParseError` "retains no raw input snippet" while `DicomParseError.snippet`
230
+ carries up to 16 bytes of the source as hex and its own JSDoc says so. The messages are now safe and
231
+ the docs say why; the snippet is documented as the PHI it is. `troubleshooting.md` also records that
232
+ **value-decode warnings never reach `ds.warnings`** at all (decode is lazy, so they ride on
233
+ `el.value.warnings`), so a logger reading only the dataset array sees none of them.
234
+ - **The `DeidentifyReport` is no longer described as value-free without qualification.** It is, apart
235
+ from `uidMap`, whose **keys are the source UIDs read out of the file** and kept so replacement stays
236
+ consistent across a study. A Study or SOP Instance UID is a unique identifying number; that field is
237
+ PHI and the rest of the report is not.
238
+
239
+ - **`CHANGELOG.md` now records what each released version contained.** Every entry in this file sat
240
+ under `[Unreleased]` while npm served `0.0.1`, `0.0.3` and `0.0.4`, and `CHANGELOG.md` is in
241
+ `package.json#files`, so the file shipped inside the tarball telling a consumer that the version
242
+ they had installed was unreleased. That is a false claim on a distributed surface rather than
243
+ untidy bookkeeping. No entry's substance is rewritten: each is moved to the version that actually
244
+ shipped it, reconstructed from the tags, the GitHub releases, and the changesets each
245
+ "Version Packages" commit consumed. `0.0.2` gets no heading because it was never published: it was
246
+ version-bumped on 2026-07-27 and the publish that followed did not run, so its four changesets
247
+ reached npm inside `0.0.3` and are recorded there. That is also why npm's version list skips
248
+ `0.0.2`.
249
+
250
+ ### Added
251
+
252
+ - **`DICOM_SQ_NOT_DESCENDED`**, a Tier-2 warning code (25 total, was 24). Public surface: the locked
253
+ `WARNING_CODES` snapshot was updated deliberately, and a profile may `escalate` or `suppress` it
254
+ like any other code.
255
+
256
+ - **A slot table of consumer-controlled positions in the format**
257
+ (`test/integration/phi-diagnostic-surface.test.ts`), bound to `assertNoDiagnosticPhiLeak` from
258
+ `@cosyte/test-utils` (pin bumped to `^0.0.2`; a caret on a `0.0.x` resolves to that version exactly,
259
+ so the old pin would have tested against a kit without the runner and passed). Thirty-eight slots,
260
+ one test each, across all four transfer syntaxes and encapsulated pixel data, plus the de-identify
261
+ surface and the serialized "safe to share" bytes.
262
+
263
+ **It does not make the diagnostic surface PHI-free, and the difference matters.**
264
+ `DicomParseError.snippet` is 16 raw source bytes as hex on every fatal and every strict-mode
265
+ escalation. That is deliberate and long-standing (D-10), the docs name it as PHI, and no slot here
266
+ can go red on it: the runner matches verbatim echoes, and hex is a re-encoding. What the table
267
+ proves is that no _message_, position, thrown value or model identifier carries a planted byte.
268
+
269
+ **It was run red on the base commit and named seven leaking slots**, which is the point of it. The
270
+ suite it joins could not have done that: `test/property/_arbitraries.ts` blocks the leaking path
271
+ three independent ways, and the sharpest is that its `TEXT_ALPHABET` excludes the backslash "so a
272
+ single-valued element stays single-valued" - the exact byte `(0008,0005)` splits on before the
273
+ leaking branch. It also never generates `(0008,0005)` or a Private Creator element at all.
274
+
275
+ Two things the table pins that a marker search alone would not. Every slot names the diagnostic
276
+ **code** it must reach, so a probe that was quietly normalized away cannot pass as evidence; the
277
+ marker is invalid in `UI` and `CS`, the two VRs most worth probing here, and what makes it reach
278
+ those branches anyway is that this parser validates no VR character set on read. And one test
279
+ compares every emitted message against the **registry template** rather than searching it for the
280
+ marker, so any interpolation of anything at all breaks it, whatever was planted.
281
+
282
+ - **`README.md` now opens with the shared Cosyte lockup, which follows the reader's color scheme**
283
+ (`ASSETS-P8`). A `<picture>` block above the H1 carries the dark-ground org tile behind a
284
+ `prefers-color-scheme: dark` media query and the light-ground tile as the inner `<img>`, so the
285
+ mark sits on a ground that matches the page it is read on. It replaces the per-package banner,
286
+ which baked the package name and the one-line tagline into pixels while the two lines directly
287
+ beneath it repeated both: the lockup reads "Cosyte" where the H1 reads `@cosyte/dicom`, so the
288
+ duplication goes and the heading stays. The alt text is content rather than decoration, because it
289
+ is what a screen reader reads on the npm page, and it describes the mark itself rather than
290
+ repeating the heading below it.
291
+
292
+ **One stated reason is corrected rather than dropped.** The per-package banner was chosen as a
293
+ plain markdown image, with no `<img>` and no `<picture>`, expressly because whether npm's markdown
294
+ sanitizer preserves a `<picture>` was unverified. It has since been measured on a published
295
+ package page: the `<img>` is hoisted out of its `<picture>` by npm's anchor wrapper, so the light
296
+ cut renders there, and npmjs.com has no dark mode, so that is the correct cut. A renderer that
297
+ strips `<source>` still renders the inner `<img>`, so the worst case is a light-ground mark on a
298
+ dark page, never a missing or broken image. Both tile URLs were rechecked with `curl -I` as
299
+ `200 image/png`, 10513 and 10455 bytes, before this landed, rather than taken from the `live` flag
300
+ in `assets/published-urls.json`, whose own `$fields.status` note says to read `live` for what it
301
+ is: a declaration made on evidence from another repo, never a fact checked there.
302
+
84
303
  ## [0.0.4] - 2026-07-30
85
304
 
86
305
  ### Added
package/README.md CHANGED
@@ -45,9 +45,9 @@ That's the pitch: no config, no schema upload, no spec lookup. The parser accept
45
45
  - **One-line metadata extraction**: `ds.patient`, `ds.study`, `ds.series`, `ds.image`: typed, fail-safe views over the safety-critical attributes. No `(group,element)` tags to memorise.
46
46
  - **Two access patterns**: named views, or structural `ds.get("00100010")` by 8-character `(group,element)` tag (resolve a keyword to its tag with `Dictionary.byKeyword`), plus `ds.elements()` to walk everything.
47
47
  - **Lazy typed value decode**: `element.value` decodes raw bytes into a discriminated `DicomValue` across all 34 VRs (numbers, `bigint`s, person names, dates/times, sequences, raw `binary`), honoring `(0008,0005)` Specific Character Set through nested items.
48
- - **Real-world tolerance, Postel's Law**: a lenient reader emits 24 stable warning codes for what it tolerated; only 4 truly-structural conditions are fatal. The serializer always writes spec-clean Part 10.
48
+ - **Real-world tolerance, Postel's Law**: a lenient reader emits 25 stable warning codes for what it tolerated; only 4 truly-structural conditions are fatal. The serializer always writes spec-clean Part 10.
49
49
  - **Source/vendor profile system**: `defineProfile()` + 5 built-ins (`ge`, `siemens`, `philips`, `strict`, `lenient`) that only ever _tighten or annotate_ a parse, resolving vendor private tags by the file's live Private Creator string, never a wrong decode.
50
- - **Metadata-level de-identification**: `deidentify()` applies the PS3.15 Annex E Basic Profile + the nine metadata Options, returning a fresh dataset and a value-free audit report.
50
+ - **Metadata-level de-identification**: `deidentify()` applies the PS3.15 Annex E Basic Profile + the nine metadata Options, returning a fresh dataset and an audit report that is value-free apart from the source UIDs in `report.uidMap`.
51
51
  - **Spec-clean serializer**: `serializeDicom(ds)` round-trips a dataset back to Part 10 bytes in its source transfer syntax (no transcode), with correct File Meta group length, even-length padding, byte-exact sequence passthrough, and lossless File Meta: non-modeled `(0002,xxxx)` elements are preserved and re-emitted in tag order.
52
52
  - **Strict TypeScript, dual ESM + CJS, Node ≥ 22**: `noUncheckedIndexedAccess`, no `any`, JSDoc + `@example` on every public export feeding your editor's IntelliSense. Zero runtime dependencies today.
53
53
 
@@ -197,7 +197,7 @@ if (img.isEnhancedMultiFrame) {
197
197
 
198
198
  ### De-identify before sharing
199
199
 
200
- `deidentify()` applies the PS3.15 Annex E Basic Application Level Confidentiality Profile (replacing, emptying, or removing every attribute the standard lists as identifying) and returns a fresh dataset plus a value-free report.
200
+ `deidentify()` applies the PS3.15 Annex E Basic Application Level Confidentiality Profile (replacing, emptying, or removing every attribute the standard lists as identifying) and returns a fresh dataset plus a report that is value-free apart from the source UIDs in `report.uidMap`.
201
201
 
202
202
  ```ts
203
203
  import { parseDicom, deidentify, serializeDicom } from "@cosyte/dicom";
@@ -272,7 +272,7 @@ const ds = parseDicom(buf, { profile: profiles.siemens });
272
272
 
273
273
  A profile bundles three things that only ever **tighten or annotate** a parse, never loosen it past the lenient default:
274
274
 
275
- - **Private-dictionary overlay**: resolves the Implicit VR of vendor private data elements by the file's _live_ Private Creator string (e.g. `"SIEMENS CSA HEADER"`), keyed canonically as `"GGGGxxLL"` (PS3.5 §7.8.1), never a hard-coded block number. (This is why Agfa IMPAX re-assigning blocks still resolves.) An unknown creator degrades to `UN` plus a `DICOM_PRIVATE_CREATOR_UNKNOWN` warning.
275
+ - **Private-dictionary overlay**: resolves the Implicit VR of vendor private data elements by the file's _live_ Private Creator string (e.g. `"SIEMENS CSA HEADER"`), keyed canonically as `"GGGGxxLL"` (PS3.5 §7.8.1), never a hard-coded block number. (This is why Agfa IMPAX re-assigning blocks still resolves.) An unknown creator degrades to `UN` plus a `DICOM_PRIVATE_CREATOR_UNKNOWN` warning. The lookup is scoped to one Data Set, and every Sequence Item is its own (PS3.5 §7.5.1, §7.8.1): a block claimed at the root does not resolve an element inside an item, and an element whose block was never claimed in its own Data Set reads `UN` plus `DICOM_PRIVATE_TAG_NO_CREATOR` rather than borrowing a neighbour's VR. Declare the creator in each item that writes private data.
276
276
  - **Escalations**: Tier-2 warning codes promoted to a thrown `DicomParseError` (a stricter posture for known-unsafe deviations).
277
277
  - **Suppressions**: benign, high-volume warning codes silenced for a known-quirky source.
278
278
 
@@ -303,7 +303,7 @@ At an RSNA-era interoperability test, ~80% of real-world patient CDs failed stri
303
303
  | 2 | Warning | Recoverable deviation | `DICOM_MISSING_PREAMBLE` |
304
304
  | 3 | Fatal (always) | Unrecoverable structural error | `NOT_DICOM_PART_10` |
305
305
 
306
- Tier-2 warnings are plain data on `ds.warnings`. Each carries a stable string `code`, a PHI-free `message`, and a `position` with the byte offset where it occurred, so you can react programmatically:
306
+ Tier-2 warnings are plain data on `ds.warnings`. Each carries a stable string `code`, a `message` looked up from a frozen registry (never composed from the document), and a `position` with the byte offset where it occurred, so you can react programmatically:
307
307
 
308
308
  ```ts
309
309
  import { parseDicom, WARNING_CODES } from "@cosyte/dicom";
@@ -316,7 +316,7 @@ for (const w of ds.warnings) {
316
316
  }
317
317
  ```
318
318
 
319
- The 24 Tier-2 codes (`DICOM_MISSING_PREAMBLE`, `DICOM_FILE_META_GROUP_LENGTH_MISMATCH`, `DICOM_UN_PARSED_AS_SQ`, `DICOM_ODD_LENGTH_VALUE_PADDED`, `DICOM_PRIVATE_CREATOR_UNKNOWN`, `DICOM_VR_MISMATCH`, `DICOM_DA_LEGACY_FORMAT`, … ) live in [`src/parser/warnings.ts`](./src/parser/warnings.ts). Narrow on `w.code === WARNING_CODES.…` for typo-free comparisons, or pass `{ onWarning }` to `parseDicom` to stream them.
319
+ The 25 Tier-2 codes (`DICOM_MISSING_PREAMBLE`, `DICOM_FILE_META_GROUP_LENGTH_MISMATCH`, `DICOM_UN_PARSED_AS_SQ`, `DICOM_ODD_LENGTH_VALUE_PADDED`, `DICOM_PRIVATE_CREATOR_UNKNOWN`, `DICOM_VR_MISMATCH`, `DICOM_DA_LEGACY_FORMAT`, … ) live in [`src/parser/warnings.ts`](./src/parser/warnings.ts). Narrow on `w.code === WARNING_CODES.…` for typo-free comparisons, or pass `{ onWarning }` to `parseDicom` to stream them.
320
320
 
321
321
  The 4 Tier-3 fatal codes (`NOT_DICOM_PART_10`, `INVALID_FILE_META`, `UNSUPPORTED_TRANSFER_SYNTAX`, `EMPTY_INPUT`) always throw a `DicomParseError`; they represent input the parser cannot meaningfully recover.
322
322
 
@@ -328,7 +328,7 @@ The library throws five typed errors, all exported from the package barrel. Warn
328
328
 
329
329
  ### `DicomParseError`
330
330
 
331
- Thrown by `parseDicom` on one of the 4 Tier-3 fatal codes. Carries the byte position and a PHI-free message.
331
+ Thrown by `parseDicom` on one of the 4 Tier-3 fatal codes. Carries the byte position, a registry-composed message, and a 16-byte hex `snippet` of the source. The snippet is raw input: treat it as PHI and redact it at your own boundary.
332
332
 
333
333
  ```ts
334
334
  import { parseDicom, DicomParseError, FATAL_CODES } from "@cosyte/dicom";
@@ -362,6 +362,7 @@ Thrown by `serializeDicom` for `MISSING_TRANSFER_SYNTAX` (the dataset names no t
362
362
 
363
363
  - **Pixel data.** No decode/decompression, no rendering, no measurements: Pixel Data is exposed as raw bytes. And v1 does not read a **compressed object at all**, not even structurally: a transfer syntax outside the four listed below is the fatal `UNSUPPORTED_TRANSFER_SYNTAX`, so JPEG / JPEG-LS / JPEG2000 / RLE / HTJ2K objects do not parse. → `@cosyte/dicom-pixel`.
364
364
  - **Burned-in PHI.** v1 **warns** it cannot remove burned-in annotation; a "de-identified" output is **metadata-de-identified only**.
365
+ - **A sequence the parser could not open.** `deidentify()` recurses only into a sequence whose items the parser materialized, so anything nested inside one it could not read is neither cleaned nor listed in the report. Two shapes reach that state and **only one of them warns**: a defined-length Implicit VR LE value whose dictionary-resolved `SQ` is not an item stream raises `DICOM_SQ_NOT_DESCENDED`, while an undefined-length `UN` value the CP-246 descent could not read as a sequence is **silent** (it keeps `vr === "UN"` and raises nothing). So `ds.warnings` is worth reading but is not sufficient: the reliable test is `el.items === undefined` on an `SQ` or `UN` element you are trusting the report about.
365
366
  - **Networking & web.** No DIMSE (C-STORE/FIND/MOVE, MWL, MPPS); no DICOMweb (QIDO/WADO/STOW). → `@cosyte/dicom-net`, `@cosyte/dicomweb`.
366
367
  - **Transcoding.** No transfer-syntax conversion. The serializer re-emits in the dataset's source syntax only.
367
368
  - **Terminology resolution.** Coded values are surfaced (designator + canonical source) but not validated against SNOMED/LOINC/etc.