@cosyte/dicom 0.0.18 → 0.1.0

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
@@ -1,5 +1,879 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ea6810a: Remove a retained private value this run did not enumerate, instead of shipping it under
8
+ `(0012,0062) = YES`.
9
+
10
+ `RetainSafePrivate` plus a `Profile` used to write private values into de-identified output that the
11
+ run never looked inside. `0.0.19` disclosed that (`DICOM_DEIDENT_PRIVATE_CARRIER_NOT_AUDITABLE` plus
12
+ a `report.unauditableSequences` entry stamped as kept) and said outright that the disclosure was not
13
+ a fix. It is fixed now: PS3.15 2026c §E.3.10 retains Private Attributes "known by the de-identifier
14
+ to be safe from identity leakage" and sends "all other Private Attributes" to removal or to the
15
+ `(0008,0307)` action this library does not implement, so a value nothing enumerated is removed.
16
+
17
+ **The over-redaction is the point rather than a corner case, and this is the size of it.** Three
18
+ classes of private value reach the output on something this run enumerated: one the run **walked as
19
+ Data Elements** and put through the Annex E action table (a private `SQ` whose items the parser
20
+ materialized), a **Private Creator `(gggg,00EE)` whose whole decoded value is a member of your
21
+ profile's private dictionary**, and a **zero-length** value. Decoding a value under the VR your
22
+ profile declares for it is not enumeration, and neither is the embedded-attribute scanner's silence,
23
+ so **an ordinary vendor scalar under an ordinary string VR that the file's own declaration does not
24
+ name safe is removed**. If you carried opaque vendor values through `RetainSafePrivate`, they are
25
+ gone unless your senders declare them: getting them back otherwise needs the content test that
26
+ separates a nested Data Set from a legitimate binary blob, which is an open product question. The
27
+ declaration route that keeps the fourth class is the other entry in this release, and what it
28
+ retains rests on the sender's assertion rather than on anything this run examined.
29
+
30
+ **Three things to act on if you consume the audit surfaces.**
31
+ 1. **New report surface.** `report.unenumerablePrivateRemovals` records each removal per instance:
32
+ the tag, the Data Set it lived in, `applied: "removed"` and `reason: "unenumerable"`. It is
33
+ complete and **never capped** at any input size, which is what lets you separate an unenumerable
34
+ removal from an Annex E one and from an emptied value; the matching warnings stay bounded.
35
+ `report.removedPrivateTags` names these removals too, unchanged in meaning.
36
+ 2. **`report.unauditableSequences` no longer produces its retired `kept` outcome** for a retained
37
+ private value. Its `applied` field was `"emptied" | "kept"` and is `"emptied"` alone now, so an
38
+ entry there means content is not in your output again. A comparison against the retired outcome
39
+ stops compiling, which is the intended way to find out.
40
+ 3. **`DICOM_DEIDENT_PRIVATE_CARRIER_NOT_AUDITABLE` changes meaning**, from "this value was shipped
41
+ unexamined" to "this attribute was removed unexamined". **No published warning code is renamed or
42
+ retired**, so a consumer narrowing on that code name keeps compiling and must re-read what it now
43
+ means rather than re-type it. This work adds no code; the code this release does add,
44
+ `DICOM_DEIDENT_PRIVATE_DECLARATION_NOT_RESOLVED`, belongs to the declaration entry beside it.
45
+
46
+ The measured matrix in `test/integration/deident-private-reservation.test.ts` flips its twenty
47
+ leaking cells from kept-verbatim to removed, on every combination of profile-declared VR and on-wire
48
+ VR it enumerates and on the VR-less Implicit VR LE encoding, and gains a mutation control that reds
49
+ if the retention decision is reverted.
50
+
51
+ - ea6810a: **This is 0.1.0, the first release whose public API we treat as settled.**
52
+
53
+ What is covered, and what you can build against:
54
+ - Reading DICOM Part 10 metadata: the four typed views (`patient`, `study`, `series`, `image`),
55
+ access to any element by tag with the Part 6 data dictionary compiled in, and lazy typed decode of
56
+ all 34 value representations, honoring Specific Character Set.
57
+ - The four native transfer syntaxes and every PS3.5 2026c section A.4 encapsulation syntax are read;
58
+ encapsulated pixel data comes back as raw fragments, and a DICOMDIR reads as a record tree.
59
+ - Lenient parsing: only four structural conditions are fatal, and every tolerated deviation is a
60
+ stable warning code with a byte offset. Vendor profiles (`ge`, `siemens`, `philips`, `strict`,
61
+ `lenient`, or your own through `defineProfile`) only ever tighten or annotate a parse.
62
+ - A spec-clean serializer that writes the source transfer syntax back, and metadata
63
+ de-identification with the PS3.15 Annex E Basic Profile and the metadata-affecting options,
64
+ returning a fresh dataset and an audit report.
65
+
66
+ What the version promises. The exported names, options, return shapes and warning codes are the
67
+ surface we keep stable. While the package is below 1.0, a breaking change bumps the minor version
68
+ (0.1 to 0.2) and is called out in this changelog with its migration; a fix that changes no public
69
+ value ships as a patch.
70
+
71
+ What is not covered yet. Pixel data is never decoded, in any transfer syntax. A de-identified output
72
+ is metadata-de-identified only: burned-in annotation is warned about, not removed, and the
73
+ residuals the README lists under known limitations are disclosed rather than closed. There is no
74
+ transcoding between transfer syntaxes, no DIMSE networking and no DICOMweb, and coded values are
75
+ surfaced but not validated against a terminology.
76
+
77
+ - ea6810a: Add `toObject`, `toISO` and `toDate`, one conversion surface over the decoded `DA`, `TM` and `DT`
78
+ values, so reading a timestamp out of an object no longer means writing a per-type projection at
79
+ every call site.
80
+
81
+ `parseDate`, `parseTime` and `parseDateTime` decode three separate wire syntaxes into three separate
82
+ shapes. The three new functions take any of them and answer in one shape: the calendar components,
83
+ an ISO-8601 string, or an absolute-instant `Date`. `DateParts` and `ToDateOptions` are exported
84
+ beside them. Nothing existing moves: the decoders, their `legacy` and `nonstandardOffset` flags and
85
+ `DicomDate` / `DicomTime` / `DicomDateTime` are all unchanged, and the package still takes no
86
+ dependency of any kind.
87
+
88
+ The same three names, with the same meanings, are exported by every `@cosyte/*` parser that decodes
89
+ a date, so they collide on import by design. A file that reads two of them aliases
90
+ (`import { toISO as dicomToISO } from "@cosyte/dicom"`) or namespace-imports; `README.md` and the
91
+ typed-values page both show the pattern.
92
+
93
+ Four decisions in the surface are worth knowing before you depend on it.
94
+ - **The key set is the precision.** A component the value did not state is absent from `DateParts`
95
+ rather than present and `undefined`, and nothing is zero-filled, so `Object.keys()` recovers what
96
+ the sender wrote. There is no `precision` key because the key set is one, and no `raw` or `valid`
97
+ key because parse bookkeeping is not a calendar component. `toISO` truncates the same way: a `DT`
98
+ that stated only an hour renders `2024-01-15T13`, never a padded-out `T13:00:00`.
99
+ - **The keys are singular where the decoded types are plural.** `DicomTime` and `DicomDateTime`
100
+ spell the time fields `hours` / `minutes` / `seconds`; `DateParts` spells them `hour` / `minute` /
101
+ `second`, and `month` is 1 to 12 rather than the JS `Date` 0 to 11. That is the shape
102
+ `Temporal.PlainDateTime.from` and luxon's `DateTime.fromObject` accept, so deleting
103
+ `offsetMinutes` leaves an object either constructor takes with no key rename.
104
+ - **`toDate` never guesses a zone.** A stated `&ZZXX` offset converts exactly and beats any
105
+ `assumeOffsetMinutes` the caller passes. With no stated offset, the caller's assumption is the
106
+ only route to an instant, an explicit `0` meaning "read this naive value as UTC". With neither,
107
+ the answer is `undefined`: the host machine's zone is never read and UTC is never assumed. A
108
+ non-finite `assumeOffsetMinutes` names no zone either, so it answers `undefined` rather than an
109
+ `Invalid Date`, and so does a finite one so large that applying it leaves the range a `Date`
110
+ represents. A `TM` states no year, so it is never an instant at all.
111
+ - **An impossible date converts to nothing, never to the day after it.** The decoders range-check
112
+ each component on its own, so a `DA` of `18700230` decodes with `valid: true` and `day: 30`. The
113
+ conversion surface reads them together and refuses the whole value: a month outside 1 to 12, a day
114
+ outside the one its month really has (full 4/100/400 leap rule), an out-of-range hour, minute or
115
+ second, or a component that is not a whole number. `second: 60` is refused with them, even though
116
+ `TM` and `DT` permit it for a leap second, because there is no ISO string for it a reader does not
117
+ move and no instant to build. The refusal is in the projection only: `parseDate`, `parseTime` and
118
+ `parseDateTime` are unchanged and still report what the sender wrote.
119
+
120
+ `millisecond` is derived from the digits in `raw`, taken verbatim and right-padded (`"5"` is 500,
121
+ `"0500"` is 50, `"123456"` is 123), and never from `fractionalSeconds`, which is a binary float.
122
+ `toISO` renders those digits exactly as written, neither padded to three nor rounded. A stated zero
123
+ offset renders `Z`, so `toISO` is deliberately not a byte round-trip of the wire value;
124
+ `serializeDicom` remains the route that reproduces the original bytes.
125
+
126
+ The retired dotted `YYYY.MM.DD` date converts identically to the canonical form, and the `legacy`
127
+ flag the decoder reports beside the value never reaches a result. All three functions answer
128
+ `undefined` for a value the decoders marked `valid: false`, for `null` and `undefined`, and for a
129
+ value that stated no component at all, and none of them throws for any input.
130
+
131
+ - ea6810a: Stop the de-identified header naming the sending site: replace the File Meta group and remove group 0004.
132
+
133
+ Two of PS3.15 2026c §E.1.1's unconditional bullets are about the parts of a DICOM File that are not
134
+ the Data Set, and neither was discharged. A file this library de-identified still carried the sending
135
+ site's `(0002,0016)` Source Application Entity Title, the source vendor's implementation identity,
136
+ every non-modeled `(0002,xxxx)` element the source stuffed into the group - Sending and Receiving AE
137
+ Title, Private Information Creator UID, Private Information - and every `(0004,xxxx)` Data Element,
138
+ all under a report that said the run succeeded. `rebuildFileMeta` changed exactly one field, the
139
+ Media Storage SOP Instance UID, and nothing anywhere filtered on group 0004.
140
+
141
+ **The File Meta group of de-identified output now describes this de-identifying application.**
142
+ §E.1.1: the File Meta Information "shall be replaced with a description of the de-identifying
143
+ application", because otherwise "identity information may leak through unmodified File Meta
144
+ Information or preamble ... includ[ing] information regarding Application Entity Titles, Presentation
145
+ Addresses, implementation information, and private information". So `(0002,0016)` is gone,
146
+ `(0002,0012)`/`(0002,0013)` name `@cosyte/dicom`, and every non-modeled `(0002,xxxx)` element is
147
+ dropped rather than re-emitted. What survives identifies the **object**: File Meta Information
148
+ Version, Media Storage SOP Class UID, Transfer Syntax UID, and the Media Storage SOP Instance UID
149
+ with its existing `RetainUIDs` behaviour unchanged. The replacement is a construction rather than an
150
+ edit, so it is unconditional on how well the source group parsed and on which Options are active.
151
+
152
+ **Group 0004 is removed at every depth**, as §E.1.1 requires "from any SOP Instance or DICOM File
153
+ other than a DICOMDIR File" - including inside Sequence Items, and including a `(0004,xxxx)` Sequence
154
+ with everything in it. No Annex E Option qualifies either rule.
155
+
156
+ **Two things to act on.**
157
+ 1. **The byte-for-byte File Meta round trip no longer holds for `deidentify()` output**, and that is
158
+ the point rather than a side effect. It still holds for parse-then-serialize, which is guarded by
159
+ its own test. If you relied on a non-modeled `(0002,xxxx)` element surviving a de-identify call,
160
+ read it off the parsed dataset before the call; it is not recoverable from the output.
161
+ 2. **New report surface and three new warning codes.** `report.fileMetaElementsDropped` and
162
+ `report.group0004Removals` record what went, each capped per run in the style of
163
+ `MAX_UNAUDITABLE_SEQUENCE_FINDINGS`, each beside an uncapped
164
+ `...Count` so a caller can count the loss at any input size and tell the two rules apart.
165
+ `DICOM_DEIDENT_FILE_META_REPLACED` and `DICOM_DEIDENT_GROUP_0004_REMOVED` are raised once per run,
166
+ not once per element, so neither string is multiplied by an element count the input chooses.
167
+
168
+ **A DICOMDIR keeps its `(0004,xxxx)` elements and is told what was not discharged.** An object whose
169
+ `(0002,0002)` is `1.2.840.10008.1.3.10` takes §E.1.1's carve-out. The rest of that bullet needs a
170
+ DICOMDIR model this library does not have, so every such run raises
171
+ `DICOM_DEIDENT_DICOMDIR_FILE_SET_NOT_DISCHARGED` - including one where the object carried no
172
+ `(0004,xxxx)` element at all, because what was not discharged has nothing to do with what the object
173
+ happened to carry. Do not treat that output as a conformant de-identified DICOMDIR.
174
+
175
+ - ea6810a: Disclose a retained private value this run kept without enumerating it, instead of stamping
176
+ `Patient Identity Removed = YES` in silence over it.
177
+
178
+ `RetainSafePrivate` plus a `Profile` is the route this entry is about, and this release adds a
179
+ second one beside it: the file's own Private Data Element Characteristics Sequence `(0008,0300)`,
180
+ covered by the declaration entry. PS3.15 2026c §E.3.10 licenses that retention for a Private
181
+ _Attribute_
182
+ "known by the de-identifier to be safe"; it says nothing about a Data Set the sender nested inside
183
+ that attribute's value, which PS3.5 §7.5.1 makes Data Elements and PS3.15 §E.1.1 still covers
184
+ "whether contained in the top level Data Set or embedded in an Item of a Sequence of Items". Where
185
+ the run neither walked that value nor emptied it, the obligation went undischarged and nothing said
186
+ so: on a **fully conformant** file, on 20 of the 30 cells of the matrix in
187
+ `test/integration/deident-private-reservation.test.ts`, a nested `(0010,0010)` reached the output
188
+ byte-verbatim with `ds.warnings` empty and `report.unauditableSequences` empty. Every one of those
189
+ cells now raises `DICOM_DEIDENT_PRIVATE_CARRIER_NOT_AUDITABLE` on `report.warnings` and adds a
190
+ `report.unauditableSequences` entry.
191
+
192
+ **The value is still kept, and that is a product decision rather than an unfinished one.** Emptying
193
+ these carriers needs a content test on exactly the VRs arbitrary bytes are for, so it also empties
194
+ conformant binary values on legitimate files; the leak stays a documented limit, and what changed is
195
+ that it is a disclosed one. Nothing about how any file is read or written changes.
196
+
197
+ `UnauditableSequenceFinding` gains `applied: "emptied" | "kept"`, and it is the field to read first:
198
+ that array had one meaning for its whole life - this content is _not_ in your output - and the new
199
+ class means the opposite. The two are budgeted on separate counters against the same cap, so a
200
+ crafted file carrying tens of thousands of retained private attributes cannot spend the budget that
201
+ reports dropped content and silence it. The new message names no byte count, because
202
+ `Element.rawBytes.length` is the Value Length off a header an under-declared length upstream may
203
+ have composed out of somebody's value - the bound `#91` took out of the two sibling factories - and
204
+ the private tag it does name renders `<withheld>` through `renderTag`'s membership test.
205
+
206
+ A Private Creator is deliberately not reported: it is retained only when its whole decoded value is
207
+ a member of the profile's private dictionary, which is an enumeration, and one carrying anything
208
+ else fails that lookup and is removed.
209
+
210
+ - ea6810a: Write `(0028,0303) Longitudinal Temporal Information Modified` on every `deidentify()` run, so a
211
+ de-identified object says what happened to its dates instead of leaving a recipient to guess.
212
+
213
+ PS3.15 2026c §E.2 requires the attribute with a Value of `REMOVED` "if none of the Retain
214
+ Longitudinal Temporal Information Options is applied", and §E.3.6 requires `UNMODIFIED` under the
215
+ Full Dates Option. This package wrote it in neither state through `0.0.19`, so a receiver reading
216
+ dates could not tell real ones from scrubbed ones - and guessing wrong hurts in both directions:
217
+ treating real dates as scrubbed under-protects the patient, and treating scrubbed dates as real
218
+ corrupts a longitudinal analysis. Both states are written now.
219
+ - **`REMOVED`** on a run with no Retain Longitudinal Temporal Information Option active, which is the
220
+ default.
221
+ - **`UNMODIFIED`** on a run with `RetainLongitudinalTemporal` active, which is this package's name
222
+ for §E.3.6's Full Dates Option.
223
+
224
+ **It is REPLACED rather than added to.** The attribute is `VM 1` and both clauses say the value
225
+ "shall be added to the Data Set with a Value of" one named state, so a `(0028,0303)` the source file
226
+ already carried is discarded - including one under another VR or holding a value the standard does
227
+ not define. That is the opposite of what `(0012,0063)` does with a prior method text, and the
228
+ asymmetry is the standard's: joining two temporal states into one single-valued attribute would leave
229
+ a recipient reading a state no run produced. Re-de-identifying an object always leaves exactly one
230
+ `(0028,0303)`, holding the latest run's state.
231
+
232
+ **The third state, `MODIFIED`, arrives in the same release**, under §E.3.6's With Modified Dates
233
+ Option. See the entry for `RetainLongitudinalTemporalModifiedDates`, which adds that Option's Table
234
+ E.1-1 column, the option name that selects it, and the disclosure that this library resolves the
235
+ column and performs no date transformation.
236
+
237
+ Nothing else moves. `(0012,0062)` and `(0012,0063)` are unchanged, the published warning-code set is
238
+ unchanged, no warning code is added or retired, and `DeidentifyReport` keeps its shape - like
239
+ `(0012,0062)`, this is a statement the object makes about itself and it is not reported there. The
240
+ declaration is written into the **top-level** Data Set only, which is where §E.2 and §E.3.6 put it;
241
+ `(0028,0303)` has no row in Table E.1-1, so a copy nested in a Sequence Item is retained by omission
242
+ like every other unlisted attribute and still says whatever the sender wrote.
243
+
244
+ ### Patch Changes
245
+
246
+ - ea6810a: `deidentify()` now writes `(0012,0064)` De-identification Method Code Sequence on every run, beside
247
+ the `(0012,0063)` text, so a consumer can read which Profile and Options produced an object from
248
+ codes instead of parsing English, and can tell the two PS3.15 §E.3.6 temporal branches apart by
249
+ `113106` and `113107`.
250
+
251
+ PS3.15 2026c §E.1.1 asks for "one or more codes from CID 7050 "De-identification Method"
252
+ corresponding to the Profile and Options used ... and/or a text string". The text already satisfied
253
+ the "and/or", so this is the machine-readable half rather than a conformance fix.
254
+ - **What is written.** A top-level `SQ` with an Item for each code from PS3.16 2026d CID 7050
255
+ (context group version `20170914`, UID `1.2.840.10008.6.1.925`): `113100` Basic Application
256
+ Confidentiality Profile first, then an Item for each active Option in `DEIDENTIFY_OPTIONS` order,
257
+ whatever order `retain` names them in. Each Item carries exactly Code Value, Coding Scheme
258
+ Designator `DCM` and Code Meaning.
259
+ - **Added to, never replaced.** A prior `(0012,0064)` the source carried keeps every Item, in order
260
+ and byte for byte, and this run's Items follow. A code a prior Item already records under the same
261
+ designator and value is not added again, so de-identifying an output again with the same options
262
+ is a fixed point.
263
+ - **New public export** `DEIDENTIFICATION_METHOD_CODES` (with the `DeidentificationMethodCode` type)
264
+ carries the CID 7050 rows, the context group UID and version, and the code each Option writes.
265
+ - **Two new stable warning codes on `report.warnings`**, never on `Dataset.warnings`:
266
+ `DICOM_DEIDENT_METHOD_CODES_PRIOR_RETAINED` when prior Items were kept (neither `(0012,0064)` nor
267
+ the code attributes in its Items has a Table E.1-1 row, so what a sender wrote there reaches the
268
+ output un-inspected, and no prior code is checked against CID 7050), and
269
+ `DICOM_DEIDENT_METHOD_CODES_PRIOR_REPLACED` when a prior `(0012,0064)` was not a Sequence of Items
270
+ this run could read and was replaced by this run's codes. Neither message carries the prior's bytes
271
+ or VR. `DICOM_DEIDENT_METHOD_PRIOR_RETAINED` keeps its published meaning, `(0012,0063)` only.
272
+
273
+ **Limits, stated with the capability.** `113101` Clean Pixel Data Option and `113102` Clean
274
+ Recognizable Visual Features Option are never written, because this layer does not touch pixels.
275
+ `113107` says the modified-dates column was resolved, not that any date was shifted: the same limit
276
+ `(0028,0303) = MODIFIED` carries, and `DICOM_DEIDENT_DATES_NOT_TRANSFORMED` still rides with it. An
277
+ Option's code means the Option was active, whether or not the object carried an attribute it acts
278
+ on, and a caller's `deidentificationMethod` text never changes the codes. Only the top-level
279
+ attribute is written or read. `(0012,0062)`, `(0012,0063)` and `(0028,0303)` are written exactly as
280
+ before.
281
+
282
+ - ea6810a: A DICOMDIR's Directory Record tree is now read, and `serializeDicom()` writes every record offset as
283
+ the byte offset of the record it named, so a DICOMDIR this library rewrites or de-identifies still
284
+ points each record at the right bytes. Until now a DICOMDIR parsed as an ordinary Data Set and every
285
+ write carried the source's offsets into a file whose layout had moved (a rebuilt File Meta group,
286
+ Data Sets put in ascending order, Items `deidentify()` re-encoded), so a viewer could step from one
287
+ patient's record into another's images.
288
+ - **New `Dataset.directory`** (types `DicomDirectory`, `DirectoryRecord`): for an object whose
289
+ `(0002,0002)` is `1.2.840.10008.1.3.10`, every Item of the Directory Record Sequence `(0004,1220)`
290
+ as a record with its `(0004,1430)` type, its `(0004,1500)` Referenced File ID components (verbatim)
291
+ and its lower-level records, plus the root entity `(0004,1200)` names. `undefined` for anything
292
+ else. Read under Implicit VR LE, Explicit VR LE and Explicit VR BE.
293
+ - **New `Item.fileOffset`**: where the parser found an Item's `(FFFE,E000)` tag, counted from the
294
+ first byte of the File Preamble, as PS3.3's Basic Directory IOD counts DICOMDIR offsets (a file read
295
+ without a preamble still counts the 132 bytes it lacks). `deidentify()` carries it to the Item it
296
+ rebuilds; it is the identity the writer ties each offset to. An offset names a record only when it
297
+ equals a Directory Record Sequence Item's `fileOffset`, never an Item tag found by scanning.
298
+ - **New Tier-2 codes, each raised only by a non-conformant DICOMDIR, with registry-only messages:**
299
+ `DICOM_DIRECTORY_OFFSET_UNRESOLVED` (an offset that is not a record's Item tag: inside a record, on
300
+ an Item of a Sequence nested in a record, on the Sequence header, past the end),
301
+ `DICOM_DIRECTORY_OFFSET_MALFORMED` (a Value Length other than 4),
302
+ `DICOM_DIRECTORY_RECORD_REVISITED` (an offset that would reach a record already reached; it is in
303
+ the tree once and the parse finishes) and `DICOM_DIRECTORY_OFFSET_DEFLATED` (a Deflated DICOMDIR
304
+ with a non-zero offset, which PS3.10 does not allow, since it requires a DICOMDIR File to use
305
+ Explicit VR Little Endian; no offset resolves). No
306
+ offset value, record index or record key reaches a message. `{ strict: true }` escalates them.
307
+ - **New `SERIALIZE_ERROR_CODES` members `DIRECTORY_OFFSET_UNRESOLVED` and
308
+ `DIRECTORY_OFFSET_DEFLATED`.** The writer refuses, and returns no bytes for, a DICOMDIR carrying an
309
+ offset it cannot tie to a record the Dataset holds (one that did not resolve on read, one left after
310
+ the Directory Record Sequence was removed or emptied, one on a hand-built Dataset with no records,
311
+ a value that is not one 32-bit unsigned integer) and a Deflated DICOMDIR with a non-zero offset. It
312
+ never writes the stale value or zero. Both messages are fixed strings with no digit in them.
313
+ - **`DICOM_DEIDENT_DICOMDIR_FILE_SET_NOT_DISCHARGED` keeps its code and its trigger** (every run on a
314
+ DICOMDIR); its message now names only the two File-set clauses of PS3.15 §E.1.1 this run does not
315
+ discharge, a DICOMDIR created from the de-identified files and the removal of the non-de-identified
316
+ DICOMDIR from the File-set, and no longer says the directory records were not de-identified or that
317
+ DICOMDIR is not modelled.
318
+ - **Limits, stated beside the capability in `docs-content/limitations.md`:** Referenced File IDs are
319
+ kept verbatim; no File-set view; record keys, `(0004,1202)` against the end of the root chain, the
320
+ File-set Consistency Flag and Private Record UIDs are not checked; the retired MRDR offset
321
+ `(0004,1504)` is written as read.
322
+
323
+ - ea6810a: The data dictionary and the de-identification action table are now generated from the **PS3.6 and
324
+ PS3.15 2026d** DocBook, re-pinned together under `vendor/nema/` by SHA-256. PS3.5 stays at 2026c.
325
+ - **`deidentify()` now removes Dose Calculation Model Name `(3004,007F)` under the Basic Profile
326
+ (`X`), and cleans it under `CleanDescriptors` (`C`)**, at the top level and inside Sequence Items
327
+ alike, and the report audits it. 2026d adds that attribute to Table E.1-1; against the 2026c table
328
+ it had no row, so it was kept verbatim under every Option with a report that said nothing about
329
+ it. No other Option changes its treatment.
330
+ - **`Dictionary.lookup("3004007F")`** now returns `DoseCalculationModelName`, VR `LO`, VM `1`, so an
331
+ Implicit VR Little Endian object reads that element as `LO` rather than `UN`.
332
+ - **The report name for `(0018,11BB)` changes case**, from "Acquisition Field Of View Label" to
333
+ "Acquisition Field of View Label", as 2026d prints it. Its action codes (`D`, and `C` under
334
+ `CleanDescriptors`) do not change. A caller matching `DeidentifiedAttribute.keyword` for that tag
335
+ on the old spelling needs the new one.
336
+
337
+ - ea6810a: `serializeDicom()` now writes objects under every Transfer Syntax PS3.5 2026c section A.4 names
338
+ for encapsulated Pixel Data (JPEG, JPEG-LS, JPEG 2000, HTJ2K, RLE, MPEG, HEVC, JPEG XL, Deflated
339
+ Image Frame and Encapsulated Uncompressed), the same generated list `parseDicom()` reads. It used to
340
+ refuse each one with `UNSUPPORTED_TRANSFER_SYNTAX`, so a JPEG or JPEG 2000 object run through
341
+ `deidentify()` could not be saved. The File Meta Transfer Syntax UID is kept, nothing is
342
+ transcoded, and no pixel is decoded.
343
+ - **The Data Set is written as Explicit VR Little Endian**, as section A.4 requires: the bytes after
344
+ the File Meta group are the bytes the same Data Set writes under Explicit VR LE.
345
+ - **Top-level Pixel Data `(7FE0,0010)` is written as `OB` of undefined length**: the input's Basic
346
+ Offset Table Item and every fragment Item byte for byte and in order (Item tag, Item Length and
347
+ value), then a Sequence Delimitation Item of length zero. The header is written this way whatever
348
+ VR and reserved bytes the input span carried. No offset table is interpreted or rebuilt and no
349
+ frame is assembled.
350
+ - **New `SERIALIZE_ERROR_CODES` member `INVALID_ENCAPSULATED_PIXEL_DATA`.** Under a section A.4
351
+ syntax the writer refuses, rather than repairs, a top-level Data Set the section does not allow:
352
+ no top-level Pixel Data; Float or Double Float Pixel Data `(7FE0,0008)` / `(7FE0,0009)`; Pixel Data
353
+ with a defined Value Length; a fragment stream not ended by its Sequence Delimitation Item
354
+ (including one `parseDicom()` read with `DICOM_PIXEL_DATA_FRAGMENTS_NOT_DELIMITED`, since a
355
+ delimiter appended to a stream that may be short would be a well-formed file missing frames);
356
+ anything other than an Item of defined length before the delimiter; an Item of odd length; a
357
+ fragment Item of length zero after the Basic Offset Table; no Basic Offset Table Item or no
358
+ fragment Item; and bytes after the delimiter. Nothing is returned on a refusal, and the message is a
359
+ fixed string that carries no length, byte or UID from the Dataset.
360
+ - **Unchanged:** the four native syntaxes serialize exactly as before; every other UID (JPIP
361
+ Referenced, SMPTE ST 2110, every retired UID) still throws `UNSUPPORTED_TRANSFER_SYNTAX`, and a
362
+ missing one `MISSING_TRANSFER_SYNTAX`. Pixel Data nested in a Sequence Item, such as an Icon Image
363
+ Sequence, is written as read and not checked. `parseDicom()` and `deidentify()` are untouched, and
364
+ burned-in annotation is still reported by `DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED`, never removed.
365
+ - The `UNSUPPORTED_TRANSFER_SYNTAX` message now says the writer also supports every section A.4
366
+ syntax. The code is unchanged.
367
+
368
+ - ea6810a: `parseDicom()` now reads objects under every encapsulated Pixel Data Transfer Syntax that PS3.5
369
+ 2026c section A.4 names.
370
+
371
+ Those are JPEG Baseline, Extended, Lossless and Lossless First-Order Prediction; RLE Lossless;
372
+ JPEG-LS Lossless and Near-Lossless; JPEG 2000 and HTJ2K; MPEG2; MPEG-4 AVC/H.264; HEVC/H.265 Main
373
+ and Main 10; JPEG XL; Deflated Image Frame Compression; and Encapsulated Uncompressed Explicit VR
374
+ LE. Such an object used to throw `UNSUPPORTED_TRANSFER_SYNTAX` and lose its
375
+ metadata with it. Section A.4 makes the whole Data Set Explicit VR Little Endian with only Pixel Data
376
+ `(7FE0,0010)` encapsulated, so it is read by the Explicit VR LE reader and its patient, study, series
377
+ and image views read exactly as the same Data Set does under Explicit VR LE. The list is read out of
378
+ the vendored, SHA-pinned PS3.5 by a new generator that joins `gen:all` and the byte-identical regen
379
+ gate, and each UID is checked against the PS3.6 registry as a non-retired Transfer Syntax.
380
+ - **New: `readPixelDataFragments(ds)`** returns a `PixelDataFragments`: the Basic Offset Table's
381
+ Item Value and every fragment's Item Value as raw bytes, in file order, with no Item header in any
382
+ of them. An empty Basic Offset Table reads as zero bytes. It returns `undefined` when Pixel Data is
383
+ absent or native. The buffers follow `copyValues` like `Element.rawBytes`.
384
+ - **No pixel is ever decoded.** Nothing is decompressed, no frame is assembled from fragments, and
385
+ neither the Basic Offset Table nor an Extended Offset Table is interpreted.
386
+ - **`serializeDicom()` refuses every one of these syntaxes** with `UNSUPPORTED_TRANSFER_SYNTAX`, a
387
+ de-identified object included, so a compressed object is read and not written.
388
+ - **An empty Basic Offset Table no longer raises `DICOM_EMPTY_ITEM_IN_SEQUENCE`.** Section A.4 makes
389
+ it the conformant form decoders must accept. A later empty fragment still raises it. A caller that
390
+ switched on that code for a conformant encapsulated file under Explicit VR LE no longer sees it.
391
+ - **New Tier-2 code `DICOM_PIXEL_DATA_FRAGMENTS_NOT_DELIMITED`** for a fragment stream that ends
392
+ with the input before its Sequence Delimitation Item. The metadata still parses and the fragment
393
+ list may be short; `{ strict: true }` refuses it. Its message carries no fragment byte, length or
394
+ count.
395
+ - **Still refused:** the JPIP Referenced syntaxes (their Pixel Data is a reference, not fragments),
396
+ the SMPTE ST 2110 syntaxes, and every retired UID, the retired JPEG processes included. The fatal
397
+ names the syntax by its PS3.6 registry name, never by the UID, and its message now summarises the
398
+ supported set instead of listing four UIDs.
399
+ - **Not diagnosed:** an encapsulation-syntax object with no top-level Pixel Data, or with native
400
+ top-level Pixel Data, parses without a code, as the same bytes do under Explicit VR LE.
401
+
402
+ - ea6810a: Docs: the README's first usage example and the quickstart's first example are now executed by the test suite, read straight out of the page they are printed on.
403
+
404
+ The README example claims its values as checked results now, against a synthetic CT object saved without its preamble, and one claim moved in the process: the study instance UID it shows is the one that object actually carries, not a placeholder. The quickstart's object is byte-identical to a fixture the test corpus builds, so the PHI scan reads it with the rest of the fixtures. Both examples are also compiled with the settings `tsc --init` writes for a new TypeScript project, so an example that does not compile fails the suite too. A changed value in either example fails the suite, and every install command the README and the installation page print is checked against the package's own name.
405
+
406
+ - ea6810a: Model CommonMark's HTML blocks in the PHI gate's override log, so a comment cannot exempt a scan
407
+ target.
408
+
409
+ `scripts/phi-scan.ts` refuses a `--allow-fixture <path>` bypass unless `phi-scan-overrides.md`
410
+ carries a `### <path>` heading, and its block structure was fenced code blocks and nothing else. A
411
+ fenced block shows its contents to a reviewer; an HTML comment shows nothing at all, so a heading
412
+ written inside `<!-- -->` was a live allow entry that exempted its target at exit 0 while the
413
+ rendered log looked empty. `overrideLogPaths` now models CommonMark 0.31.2 section 4.6's HTML
414
+ blocks, start conditions 1 to 6 and their end conditions, and ignores fences and headings inside
415
+ one exactly as section 4.6 requires.
416
+
417
+ Start condition 7 is deliberately out of scope, and its cost is measured in both directions rather
418
+ than assumed: it needs a complete tag AND the knowledge that the line does not interrupt a
419
+ paragraph, which is paragraph state this parser does not have. A heading under `<span>` on its own
420
+ line is still a live entry here where CommonMark hides it; after a paragraph line, where condition
421
+ 7 cannot fire, the two agree. Both arms are pinned by tests.
422
+
423
+ No direction is claimed. A block boundary is parity, and on a log carrying an odd number of fence
424
+ delimiters inside a comment the two readings' entry sets are disjoint and both non-empty, each
425
+ exempting at exit 0 a target the other refuses at exit 2. The tag lists section 4.6 closes over are
426
+ read out of the vendored spec and driven name by name through the `--allow-fixture` membership
427
+ oracle, so the tables are checked against the document rather than against whoever typed them, and
428
+ no count of either is written anywhere. `scripts/measure-phi-scan-html-blocks.ts` ships beside the
429
+ change so the figures can be re-run against any tree.
430
+
431
+ - ea6810a: `PN`, `LO`, `SH`, `UC`, `ST`, `LT` and `UT` values now decode ISO 2022 code extensions when
432
+ `(0008,0005)` Specific Character Set has more than one Value (and Value 1 is not `ISO_IR 192`,
433
+ `GB18030` or `GBK`). Every escape sequence in PS3.3's code-extension tables switches G0 or G1, so a
434
+ Japanese, Korean or Chinese Person Name comes back as the characters its sender encoded rather than
435
+ with escape bytes inside it or JIS bytes read as ASCII. PS3.5 2026d's worked examples H.3-2, I.2-1
436
+ and K.2-1 used to come back wrong with no warning; all four examples now decode to the names the
437
+ standard prints.
438
+ - **Reset points follow PS3.5 section 6.1.2.5.3.** Value 1's designations come back at the start of
439
+ the value, after CR, LF or FF, after the `\` of a multi-valued VR and after the `^` and `=` of
440
+ `PN`. A delimiter counts only as a single byte, never as half of a two-byte character.
441
+ - **`decodeText(bytes, terms)`** returns the same characters for a multi-valued term list, with the
442
+ text reset rules (CR, LF and FF).
443
+ - **New Tier-2 code `DICOM_CHARSET_ESCAPE_UNDECLARED`**: an escape sequence switches to a set
444
+ `(0008,0005)` does not declare. The bytes are decoded in the set it names.
445
+ - **New Tier-2 code `DICOM_CHARSET_BYTES_UNDECODABLE`**: bytes no designated set decodes (an
446
+ unrecognized escape sequence and what follows it, a GR byte with no G1 set, a code the set does
447
+ not define, or a set this runtime cannot decode) read as U+FFFD, never as another set's reading.
448
+ `Element.rawBytes` keeps them.
449
+ - **New Tier-2 code `DICOM_CHARSET_EXTENSION_NOT_RESET`**: a line, value or `PN` component ends
450
+ while G0 still holds a set other than Value 1's. The characters are kept and what follows decodes
451
+ from Value 1.
452
+ - The three codes ride on `Element.value.warnings`, at most once each per value, and never reach
453
+ `Dataset.warnings` or `onWarning`, so `{ strict: true }` does not refuse such a file. Their
454
+ messages carry the tag and VR only.
455
+ - **Unchanged:** a single-valued `(0008,0005)`, a multi-valued one whose Value 1 is `ISO_IR 192`,
456
+ `GB18030` or `GBK`, `resolveDecoderLabel`, `serializeDicom` and `deidentify()`. ISO-IR 14's
457
+ `0x5C` and `0x7E` still read as `\` and `~`.
458
+
459
+ - ea6810a: Read the file's own safe-private declaration, so a caller with a DICOM file from a vendor they hold
460
+ no profile for can de-identify it and still keep the private attributes that file itself declares
461
+ non-identifying.
462
+
463
+ PS3.15 2026c §E.3.10 lists four ways a Private Attribute may be "known by the de-identifier to be
464
+ safe from identity leakage", and names the in-file declaration **first**: "its presence in a block of
465
+ Private Data Elements with a Value of \"SAFE\" in Block Identifying Information Status (0008,0303) or
466
+ individually listed in Nonidentifying Private Elements (gggg,0004) (within Private Data Element
467
+ Characteristics Sequence (0008,0300)". This package resolved safety only through a caller `Profile`,
468
+ so `RetainSafePrivate` kept nothing without one and the technique values a quantitative read depends
469
+ on - §E.3.10's own examples are CT helical span pitch and PET SUV rescale factors - were removed from
470
+ every file whose vendor nobody had written a profile for.
471
+ - **Both branches of that bullet are read.** A block whose `(0008,0303)` is `SAFE` is known safe
472
+ whole; a block declared `MIXED` is known safe for exactly the elements its `(0008,0304)` list
473
+ names, identified by the lowest 8 bits of the tag within the block (PS3.3 2026c §C.12.1). The
474
+ Private Creator that reserves such a block is retained with it, which is what §E.3.10's "together
475
+ with the Private Creator IDs that are required to fully define the retained Private Attributes"
476
+ asks for. A block declared `UNSAFE` retains nothing, its creator included.
477
+ - **No profile is needed, and passing one only ever adds.** A profile retention survives a block the
478
+ declaration calls `UNSAFE` or says nothing about, and nothing outside the odd-group private block
479
+ an Item describes is retained on account of that Item.
480
+ - **A block is keyed by its Private Creator VALUE and never by a block number**, which is what
481
+ §C.12.1's own Note requires ("since instances may be modified and numeric block numbers
482
+ reassigned"). The block number an element sits in is resolved separately, against the reservations
483
+ of the Data Set that element lives in, because PS3.5 2026c §7.8.1 scopes a reservation to one Data
484
+ Set and Items do not inherit an enclosing one.
485
+ - **A declared-safe private Sequence is still parsed in its entirety**, with each nested attribute
486
+ handled on its own merits, exactly as §E.3.10's Sequence paragraph requires. The declaration
487
+ decides the carrier and never the Data Sets below it.
488
+ - **An Item that does not resolve retains nothing and is disclosed** under a new stable warning code,
489
+ `DICOM_DEIDENT_PRIVATE_DECLARATION_NOT_RESOLVED`, raised once per Item on `report.warnings` and
490
+ capped per run. An Item is unresolvable when a Type 1 Value is absent or empty, when `(0008,0303)`
491
+ carries a Value outside the three §C.12.1 enumerates, when `(0008,0301)` is not an odd group, when
492
+ a `MIXED` Item carries no usable `(0008,0304)` list, or when `(0008,0302)` names a creator no block
493
+ in that Data Set reserves. The message carries no token of the Item: the creator, the group and an
494
+ unenumerated status are all the sender's own values, and there is no string parameter for one to
495
+ travel through.
496
+
497
+ 🩺 **What this retains rests on the SENDER's assertion, and that is the cost rather than a footnote.**
498
+ The system that wrote the file said the block carries no identifying information; nothing here
499
+ re-derives it, so an opaque vendor value in a block declared `SAFE` reaches de-identified output
500
+ unexamined - the class removal closed for the profile route. §E.3.10 offers exactly one mitigation
501
+ and it is the caller's: if you do not trust the sender, do not pass `RetainSafePrivate`, and every
502
+ Private Attribute is removed ("When this Option is not specified, all Private Attributes shall be
503
+ removed"). The Option is off by default and this changes nothing about a run that leaves it off.
504
+
505
+ Everything that guarded the profile route still acts first and is untouched: a Data Set whose
506
+ reservations the file does not settle, an element outside the settled run, an on-wire VR that is not
507
+ one of the 34, and a value the embedded-attribute scanner reads as a swallowed Data Element run.
508
+ Deidentification Action (0008,0307), Deidentification Action Sequence (0008,0305) and Identifying
509
+ Private Elements (0008,0306) are **not** implemented and are not implemented by this: §E.3.10's
510
+ second branch is "removed **or** processed in the element-specific manner recommended by
511
+ Deidentification Action (0008,0307)", and removal stays the branch this library takes.
512
+
513
+ - ea6810a: Gate the public surface against internal project bookkeeping, and sweep the tree it would have failed.
514
+
515
+ `no-internal-refs.yml` and `scripts/check-no-internal-refs.ts` enforce the founder directive of
516
+ 2026-07-27 on every surface a consumer of `@cosyte/dicom` reads: the published markdown at the
517
+ repository root, `docs-content/`, the npm `description` and `keywords`, and all of `src/`. Work-item
518
+ and plan ids, phase and wave language, roadmap and plan citations, ADR numbers, meta-repo paths and
519
+ commentary about how the work is being run are findings; `CHANGELOG.md`, `.changeset/`, commit
520
+ messages, pull request text, `test/`, `.github/` and this repository's agent-context docs are
521
+ excluded, because those are the surfaces the identifiers belong on.
522
+
523
+ The gate self-tests before it reports: every rule has to match its own positive sample, a DICOM
524
+ reference corpus (`PS3.6`, `(0010,0010)`, `PN`, `UI`, `DICOM-SR`, `1.2.840.10008.1.2.1`,
525
+ `ICD-10-CM`, `CP-246`, `MRN-42` and the rest) has to survive every rule untouched, and the scope
526
+ function has to classify a fixed list of paths the way the rule says. It refuses to print a result
527
+ from a scan that read nothing, from a declared surface that selects no tracked file, or from a file
528
+ in scope it could not read or could not decode as UTF-8. There is no list of excused occurrences and
529
+ there must not be one.
530
+
531
+ The sweep is the other half, and it is why this is a `patch` rather than a chore: 530 findings, 526
532
+ of them in `src/` comment blocks that `tsup` copies verbatim into the published `dist/index.d.ts` and
533
+ `dist/index.d.cts`, against 4 in markdown anyone reviews. Comment and documentation TEXT is rewritten
534
+ and nothing else: no exported symbol, signature, message string or runtime behaviour changes, and the
535
+ build, the type declarations' shape, the lint ladder and all 1,586 tests are unchanged.
536
+
537
+ - ea6810a: Correct the troubleshooting remedy for `DICOM_DEIDENT_PRIVATE_CARRIER_NOT_AUDITABLE`: a value the file's own `(0008,0300)` declaration names safe is retained, not removed.
538
+
539
+ The remedy said that, beyond the three cases the package enumerates, every private value a `Profile` vouched for under `RetainSafePrivate` is removed, an ordinary scalar under an ordinary string VR included. That was false whenever the file's own Private Data Element Characteristics Sequence `(0008,0300)` names the value safe, in a block whose status is `SAFE` or listed individually in a `MIXED` one: `deidentify()` retains that value on the sender's word, and `report.unenumerablePrivateRemovals` does not record it. The guide now names that route and keeps the removal statement for everything outside it. No behaviour changes.
540
+
541
+ - ea6810a: Add `RetainLongitudinalTemporalModifiedDates`, the second PS3.15 §E.3.6 Retain Longitudinal Temporal
542
+ Information Option, so a caller running a longitudinal study can keep the temporal relationships
543
+ their analysis needs without keeping real dates.
544
+
545
+ §E.3.6 defines **two** mutually exclusive Options and Table E.1-1 gives them separate columns. This
546
+ package published one name, carrying the full-dates column, so the modified-dates column was
547
+ unreachable and `(0028,0303) Longitudinal Temporal Information Modified` could never say `MODIFIED`.
548
+ - **`RetainLongitudinalTemporalModifiedDates`** resolves Table E.1-1's `Rtn. Long. Modif. Dates Opt.`
549
+ column, which is the more protective of the two on every attribute where they differ: full dates
550
+ keeps the real value, modified dates cleans it. An attribute the modified-dates column publishes no
551
+ action for takes the Basic Profile action, never the full-dates one and never a keep.
552
+ - **`(0028,0303)` takes the value `MODIFIED`** on that branch, replacing whatever the source carried
553
+ there, exactly as `REMOVED` and `UNMODIFIED` do.
554
+ - **A Value naming the Option is added to `(0012,0063)` De-identification Method**, inside `LO`'s
555
+ 64-character per-Value maximum like every other name this library writes there.
556
+ - **The two temporal Options are mutually exclusive.** A call naming both is rejected with a
557
+ `DeidentifyError` carrying the existing `INVALID_OPTIONS` code. Either one alone is accepted, and
558
+ `RetainLongitudinalTemporal` is unchanged in both respects: it still resolves the full-dates column
559
+ and still writes `UNMODIFIED`.
560
+
561
+ **This library performs no date transformation, and says so at run time.** §E.3.6 has two halves: the
562
+ Table E.1-1 column, and a requirement that the dates themselves be modified with the manner of
563
+ modification described in a Conformance Statement. A library can deliver the first and neither of the
564
+ second, because PS3.2 Annex N scopes a Conformance Statement to a named product and version. So
565
+ **you** perform the transformation, and a `MODIFIED` on an object whose dates nobody shifted is a
566
+ defect this package cannot detect. Every run under the new Option raises the new stable warning code
567
+ `DICOM_DEIDENT_DATES_NOT_TRANSFORMED` on `report.warnings`, stating the half of §E.3.6 the run did
568
+ not discharge. Nothing here claims PS3.15 Annex E conformance for the Retain Longitudinal Temporal
569
+ Information With Modified Dates Option, which §E.1.1 makes all-or-nothing.
570
+
571
+ `DICOM_DEIDENT_DATES_NOT_TRANSFORMED` is added to `WARNING_CODES` and is raised by `deidentify()`
572
+ only: it is a statement about the run's options rather than about the file, so it never reaches
573
+ `Dataset.warnings` and a `{ strict: true }` parse of the same input is unaffected. No published code
574
+ is renamed or retired, no coded Item is added to `(0012,0064)`, and what the Basic Profile retains
575
+ with neither temporal Option active is unchanged.
576
+
577
+ - ea6810a: Claim the PHI gate's `CRLF` line split, and delete the disclosure that said it could not be claimed.
578
+
579
+ `#113` shipped `scripts/phi-scan.ts`'s `splitLines` with a disclosure that its `CRLF` half was
580
+ unobservable through either caller and claimed by no test, because `loadAllowList` trims and
581
+ `tripleHashValue` trims. Both premises are true and the conclusion is false: `overrideLogPaths`
582
+ hands the RAW line to `fenceRun` before anything trims it, and a closing fence run is `bare` only
583
+ when it is followed by spaces or tabs (CommonMark 0.31.2 section 4.5). On an override log written
584
+ with `CRLF`, a `CR`-blind split therefore leaves a `CR` after the closing run, the run reads as an
585
+ info string rather than as a close, the block never ends, and every entry below it is dropped.
586
+
587
+ Measured on one log carrying a fenced template and two live entries below it: the shipped script
588
+ exits 0 with both entries honoured, a `CR`-blind mutant exits 2 having dropped both, and with the
589
+ same log written `LF` the two agree. Both `--allow-fixture` directions are asked on each arm, so a
590
+ parser that made everything below an entry fails it too.
591
+
592
+ No direction is claimed for that mutant. A draft called it fail-closed, on the grounds that a `CR`
593
+ can only prevent a close and never cause one; the gate falsified that with an input, and it is
594
+ deleted rather than narrowed. On a log whose lines are `CRLF` except one opening fence the two
595
+ entry sets are disjoint rather than nested, and the mutant exempts at exit 0 a target the shipped
596
+ script refuses at exit 2. Fence state is parity, so a wrong answer moves entries in both directions.
597
+
598
+ The behaviour was already correct and is unchanged: comment-stripped, `scripts/phi-scan.ts` is
599
+ byte-identical on base and head. What changed is that four carriers of the false disclosure inside
600
+ this package are corrected.
601
+
602
+ The mutation figure that stood here is DELETED rather than restated: a later change in this same
603
+ release moved `overrideLogPaths` onto its own CommonMark splitter, so the `CR`-blind mutant of
604
+ `splitLines` no longer reds anything, and both changesets land in one generated `CHANGELOG.md`.
605
+
606
+ - ea6810a: Refuse a `###` run an invisible character separates from the path, in the PHI gate's override log.
607
+
608
+ `scripts/phi-scan.ts` refuses a `--allow-fixture <path>` bypass unless `phi-scan-overrides.md`
609
+ carries a `### <path>` heading. `tripleHashValue` separated the `###` run from the path with the
610
+ whole of `\s`, where CommonMark 0.31.2 section 4.2 says the opening run "must be followed by spaces
611
+ or tabs, or by the end of line". So a line whose separator was an invisible character was a live
612
+ allow entry where the document renders a paragraph, and that target was exempted at exit 0. Measured
613
+ live on three trees before the change, each against the same bytes without the flag, which exit 1.
614
+
615
+ It is one conjunct on the pattern the function replaced, so for every line the parser returns either
616
+ that pattern's answer or nothing: no line that was not already an entry becomes one, and every
617
+ difference is a refusal at exit 2. That is a property of the source rather than one a test can
618
+ prove, and it is left as one: the evidence beside it is the comment-stripped diff, a single added
619
+ early return, and the per-log relation the shipped instrument prints against a base, which reads as
620
+ a subset on the one log the two trees differ on.
621
+
622
+ Section 4.2's strip is deliberately NOT taken, and that is measured rather than assumed: the prose
623
+ says leading and trailing spaces or tabs, and `commonmark@0.31.2`, the reference implementation of
624
+ the pinned document version, strips with `String.prototype.trim`, which is the whole of `\s` and is
625
+ what this parser does. A draft that took the prose named `path<NBSP>` where this parser names
626
+ `path`, which exempted at exit 0 a target it refuses at exit 2, and it was deleted. The strip,
627
+ section 4.2's optional closing sequence and its inline parsing are each pinned as they are, with
628
+ their cost stated in both directions.
629
+
630
+ `scripts/measure-phi-scan-atx-heading.ts` ships beside the change so the figures can be re-run
631
+ against any tree. It carries a control the sibling instruments do not: an entry must be shown to
632
+ exempt a real hit, not merely to be accepted.
633
+
634
+ - ea6810a: Withhold the PHI gate's verdict over a target it enumerated and never opened, and put the install
635
+ hardening the estate baseline asks for actually in force.
636
+
637
+ `scripts/phi-scan.ts` filters an `--allow-fixture` target out of the run after enumerating it, and
638
+ until now the run then reported on whatever was left as if that were the whole corpus. The shape
639
+ that costs the most is not the obvious one: withdraw the ONLY violator and every surviving target is
640
+ clean, so the same argv that means "excuse this one fixture" produced a run indistinguishable from a
641
+ clean sweep. The scanner now tracks the paths a run declared against the paths it read, and a
642
+ non-empty difference exits **3** with a `WITHHELD:` line naming each path, after printing the hit
643
+ detail for everything it did read.
644
+
645
+ 3 rather than 1 or 2, and each is a different sentence. 1 says "PHI is here", and overloading it
646
+ would make a bypass look like a finding and a finding look like a bypass. 2 says "the scan never
647
+ ran" and is what an UNLOGGED `--allow-fixture` already returns, having opened nothing; a withheld
648
+ run opened every other target and its hit lines are real. The rule is computed off the two sets
649
+ rather than off the flag, so the boundary is "enumerated and not read": a bypass naming a path this
650
+ run never enumerated withdraws nothing and still exits 0.
651
+
652
+ Every path with no bypass on it is byte-for-byte unchanged, which is the property the pre-commit
653
+ hook and CI depend on: `pnpm measure:phi-scan-unread` against the pre-change scanner reports
654
+ `violations: 0` over 195 cells, identical exit codes and an identical hit-line multiset. The new
655
+ suite coverage carries a mutation control that removes the rule from a planted copy and asserts the
656
+ graded run falls back to the hits code, and refuses as vacuous if the line it removes is ever
657
+ reworded.
658
+
659
+ `package.json` gains the `js-yaml@>=4.0.0 <4.3.0` override the shared baseline requires, ordered so
660
+ the stronger `<4.3.1` pin this repo already carried is the one that applies; no resolved version
661
+ moves. `pnpm-workspace.yaml` declares `minimumReleaseAge: 1440` and `trustPolicy: no-downgrade`, and
662
+ `packageManager` moves to `pnpm@10.34.5` because a pnpm below `10.21.0` ignores those keys entirely,
663
+ which is a settings file that decorates rather than defends.
664
+
665
+ - ea6810a: `serializeDicom()` now writes every Data Set in ascending tag order, so a file this library writes
666
+ satisfies the element-ordering rule a strict receiver checks even when the file it was read from
667
+ did not. It used to write elements in the order the `Dataset` held them (parse order, plus anything
668
+ your code or `deidentify()` added afterwards), and to copy every Sequence through as read, so a
669
+ round trip propagated an out-of-order source instead of normalizing it.
670
+
671
+ PS3.5 2026c §7.1: "The Data Elements in a Data Set shall be ordered by increasing Data Element Tag
672
+ Number", and §7.5.1 says the same of the Data Set inside every Item.
673
+ - **What is ordered.** The root Data Set, and the Data Set of every Item of every Sequence the
674
+ writer can walk on the wire, at every depth up to `NESTING_DEPTH_LIMIT`, under all four supported
675
+ transfer syntaxes. That includes the elements `deidentify()` inserts, such as `(0012,0062)` and
676
+ `(0012,0063)`.
677
+ - **What does not change.** Items keep their order (§7.5). Only whole elements move, so no value
678
+ byte changes and every Item and Sequence length stays valid. The Sequence is walked from its own
679
+ bytes, never re-encoded from the parsed `items`, so nothing its bytes did not carry is written,
680
+ and it is re-ordered only where those `items` match its bytes, so the output reads back as the
681
+ source did. The input `Dataset` is not modified, and `Dataset.elements()` still returns parse
682
+ order.
683
+ - **What is still not ordered.** A tag repeated inside an Item is kept twice, in source order, so
684
+ that output still breaks the "at most once" of PS3.5 2026c §7.1. A Sequence whose Item stream
685
+ cannot be walked to exactly its end, one nested past the bound, one whose parsed `items` do not
686
+ match its bytes (a Sequence the parser did not descend, `DICOM_SQ_NOT_DESCENDED`, for one), and any
687
+ `UN`-carried Sequence (under Implicit VR LE that includes a private Sequence inside an Item, even
688
+ one a `Profile` resolved to `SQ`, since a default read resolves its tag to `UN`) are written as
689
+ read, unordered. An element the parser relocated because a length field lied is ordered where the
690
+ parser placed it.
691
+ - **What is written last, whatever its tag.** An element whose own bytes do not show where a reader
692
+ ends it: an undefined-length `UN` the parser could not read as a Sequence, or a Sequence, `UN` or
693
+ Pixel Data value missing its Sequence Delimitation Item. A reader takes whatever follows such a
694
+ value into it, so it stays after the ascending rest of its Data Set, at the root and inside an
695
+ Item. A Sequence nested past the bound also goes after the ascending rest of its Item, unless it
696
+ is a defined-length Sequence under Implicit VR LE, since seeing where a reader ends any other
697
+ would take the walk past the bound.
698
+ - **A source that was already in order** is written back with the same bytes after the File Meta
699
+ group, as before.
700
+
701
+ - ea6810a: Parse the PHI gate's override log with CommonMark's line ending, and pin the spec that says so.
702
+
703
+ A lone `CR` in `phi-scan-overrides.md` hid a fence OPENER from the gate's line split, so a
704
+ `### <path>` a human sees inside a rendered code block was a live allow entry and `--allow-fixture`
705
+ exempted that PHI scan target at exit 0. `overrideLogPaths` now splits per CommonMark 0.31.2 section
706
+ 2.1; the allow list keeps `/\r?\n/` deliberately, because `scripts/phi-allow-list.txt` is not a
707
+ markdown document. No direction is claimed for either split, on the override log or on the allow
708
+ list: each has a measured input whose two entry sets are disjoint, so neither is the conservative
709
+ one. The spec is vendored under `vendor/commonmark/` and re-hashed
710
+ as a precondition, so the section numbers the gate cites are derived rather than asserted.
711
+
712
+ - ea6810a: `parseDicom()` now reads objects under the four JPIP Referenced Transfer Syntaxes of PS3.5 2026c:
713
+ JPIP Referenced `1.2.840.10008.1.2.4.94` (section A.6), JPIP Referenced Deflate `…1.2.4.95`
714
+ (section A.7), JPIP HTJ2K Referenced `…1.2.4.204` (section A.11) and JPIP HTJ2K Referenced Deflate
715
+ `…1.2.4.205` (section A.12). Such an object used to throw `UNSUPPORTED_TRANSFER_SYNTAX` and lose its
716
+ metadata with it. Sections A.6 and A.11 make the whole Data Set Explicit VR Little Endian, so it is
717
+ read by the Explicit VR LE reader; sections A.7 and A.12 are that Data Set compressed per RFC 1951,
718
+ so it is inflated by the Deflated reader first, under the same decompression cap and inflate fatals
719
+ as Deflated Explicit VR LE. Its patient, study, series and image views read exactly as the same Data
720
+ Set does under Explicit VR LE.
721
+ - **The Pixel Data Provider URL is surfaced and never fetched.** A JPIP object carries no Pixel
722
+ Data; `ds.get("00287FE0")` returns its Pixel Data Provider URL as the `UR` element the file
723
+ carries, its `value` the URL text with only its trailing padding removed. Nothing in this package
724
+ fetches, resolves or validates it.
725
+ - **`serializeDicom()` refuses all four** with `UNSUPPORTED_TRANSFER_SYNTAX`: a JPIP object is read,
726
+ not written.
727
+ - **New `DEIDENTIFY_ERROR_CODES.UNSUPPORTED_TRANSFER_SYNTAX`.** `deidentify()` throws a
728
+ `DeidentifyError` with this code, and a fixed message, for a `Dataset` whose File Meta Transfer
729
+ Syntax UID is any of the four, whatever the options, and returns no dataset or report. The URL
730
+ has no PS3.15 Table E.1-1 row, so a de-identified copy would otherwise keep it by omission.
731
+ `DeidentifyError` is no longer thrown only for author-time misconfiguration: switch on `code`.
732
+ The refusal keys on the File Meta alone, so a non-JPIP object that carries `(0028,7FE0)` is
733
+ de-identified as before and keeps it.
734
+ - **Not diagnosed:** a JPIP object with no `(0028,7FE0)`, with a top-level Pixel Data, or with a
735
+ Photometric Interpretation outside the four Values section A.6 allows parses without a code.
736
+ - **Still refused:** the SMPTE ST 2110 syntaxes and every retired UID. The
737
+ `UNSUPPORTED_TRANSFER_SYNTAX` fatal's message now names the JPIP sections among the supported set.
738
+
739
+ - ea6810a: **`deidentify()` now removes a non-private attribute that neither this build's PS3.6 2026d data
740
+ dictionary nor PS3.15 2026d Table E.1-1 carries**, at the top level and inside every Sequence Item
741
+ it walks, whatever its VR. Until now a Table E.1-1 miss meant "keep", so a Standard Attribute from a
742
+ later edition, or one a sender invented, went into de-identified output verbatim with a report that
743
+ said nothing about it. The notes to Table E.1-1 name "new Standard Attributes" as a place identifying
744
+ information may be.
745
+ - **"Registered" means a literal PS3.6 row or a masked row the tag matches.** The `(50xx,....)` and
746
+ `(60xx,....)` families count only across the even groups PS3.5 2026c section 7.6 bounds them to,
747
+ so `(6002,0010)` Overlay Rows is kept and `(6020,0010)` is removed; every other masked row, such as
748
+ `(0028,04x0)`, is read as PS3.6 prints it. A registered attribute Table E.1-1 does not list is kept
749
+ exactly as before, `UN` VR included.
750
+ - **Private attributes, `(0004,xxxx)`, `(0002,xxxx)`, group lengths `(gggg,0000)` and every listed
751
+ attribute are unchanged.** An unregistered element whose on-wire VR is outside the 34 PS3.5
752
+ section 6.2 defines is now removed rather than emptied, and is no longer listed on
753
+ `report.undefinedVrElements`; a registered one still is. An unregistered Sequence is removed whole
754
+ and nothing inside it is walked or reported.
755
+ - **New report fields:** `report.unregisteredElementRemovals`, a list of the new exported
756
+ `UnregisteredElementRemoval` (a byte offset, plus the context path when nested, and deliberately
757
+ no tag or VR, since such a tag may be four bytes of some element's value), capped per run, and
758
+ `report.unregisteredElementRemovalCount`, the complete total.
759
+ - **New warning code:** `DICOM_DEIDENT_UNREGISTERED_ELEMENT_REMOVED`, raised once per run that
760
+ removed anything, on `report.warnings` only, with no tag, VR or count in its message.
761
+ - **The cost:** a conformant attribute from a PS3.6 edition newer than this build is removed too, and
762
+ there is no option to keep it in this release.
763
+
764
+ ## 0.0.19
765
+
766
+ ### Patch Changes
767
+
768
+ - 58c9f2e: Stop the PHI gate constructing any `RegExp` at all, and stop its override log honouring a template
769
+ line as a live bypass. Both were disclosed by the previous slice and turn out to be one mechanism.
770
+ V8 keeps the last successful match on the `RegExp` constructor, so a matched subject stays readable
771
+ from `RegExp.input` and `RegExp.lastMatch` anywhere in the process. The scan route stopped handing
772
+ target bytes to a pattern last time; the gate's own configuration did not, and that residual was
773
+ disclosed as a number rather than a description, every clean column of the instrument reading
774
+ `input 3772`. Re-measured before building to it: that is `scripts/phi-allow-list.txt` in UTF-16 code
775
+ units, not the 3,774 bytes the file is on disk.
776
+
777
+ A runtime census of every pattern the script runs found five live sites, all four config parsers,
778
+ and `loadOverrideLog` held two of them. On the route that reads the override log the retained
779
+ subject IS the fence-blind template line, held verbatim, which is what made this one slice and not
780
+ two. All five are now forward scanners, so the carve-out sentence that was refused in three wordings
781
+ is deleted rather than worded again. A scrub was available and refused once more: a bound that holds
782
+ only from where a cleanup is called is not a bound.
783
+
784
+ `overrideLogPaths` is the one parser deliberately narrower than the pattern it replaces, in the
785
+ fail-closed direction, because a dropped entry makes `--allow-fixture` refuse the run at exit 2
786
+ rather than exempt the target.
787
+ It is fence-aware, so the committed `### <path>` template inside the "Format" block is no longer an
788
+ allow entry, and an all-whitespace heading no longer registers a lone space as a path. The template
789
+ entry's inertness was re-measured rather than inherited and holds twice over: neither gating route
790
+ can produce a target that normalizes to a root-level path, and it was live only where a caller names
791
+ the file itself.
792
+
793
+ Everything else is equivalence, measured: 0 differing bytes across 34 cells and 9,283 hit lines
794
+ against a mutation control differing in 17, and 5 of 6 config routes byte-identical with the sixth
795
+ required to differ. Developer-facing only, no public API change.
796
+
797
+ - 7754a6c: Make the PHI gate's hit excerpt own its bytes, so a hit cannot retain the payload it was cut from.
798
+ `scripts/phi-scan.ts` bounded what a hit line prints, but not what a hit holds: V8 answers
799
+ `raw.slice(0, n)`, and a regexp match, with a string that points into its parent, so a 19 character
800
+ name matched inside an 8 MiB page kept that whole page alive for the rest of the run, and `hits`
801
+ lives for the whole run. Measured against `88be779`, retained memory grew by one whole file for
802
+ every hit-bearing file scanned and now does not grow at all: 178 MiB against 34 MiB over a 160 MiB
803
+ corpus, with a hit-free control of the same size reading 26 MiB on both.
804
+
805
+ The copy is inside the one factory that makes a hit value, so the bound is on the slot rather than
806
+ on a caller, and it is on both branches: a value already under the excerpt bound is a pointer too,
807
+ which is the route the text sweep's name recognizer takes. The round trip is `utf16le` rather than
808
+ `utf8` because `utf8` turns an unpaired surrogate into a replacement character, and a report naming
809
+ a value the file does not carry is the same class of wrong answer as a report printing too much.
810
+
811
+ The cost direction is stated rather than claimed away, and it is narrow: where one page is nothing
812
+ but violating tokens, a resident copy per hit replaces a resident pointer per hit, and the two are
813
+ within a few MiB of each other in either direction depending on token length.
814
+
815
+ Nothing printed changes: 42 of 42 grid cells across six routes, five value lengths and three cap
816
+ settings are byte-identical on exit code, stdout and stderr.
817
+ `pnpm measure:phi-scan-retention` re-derives both tables and the grid against any base.
818
+ Developer-facing only, no public API change.
819
+
820
+ - 01d0983: Stop the PHI gate's scan route handing a target's bytes to a regular expression. The tag and text
821
+ sweeps now leave nothing in V8's legacy `RegExp` statics, where the whole of a scanned page and an
822
+ unexcerpted matched name were readable after a scan. Reported hits are unchanged, byte for byte.
823
+ - 7fbc8e9: Spend the PHI gate's per-file print budget per recognizer, so one recognizer's flood cannot spend
824
+ another's share. `scripts/phi-scan.ts` printed the first `n` of a file's hits in scan order, and a
825
+ file's routes do not queue independently: the tag walk appends before the text sweep, so how many of
826
+ the text sweep's findings a reader saw was decided by how many the tag walk had already made. The
827
+ text sweep's PN recognizer is the only route that can see a caret-joined name in bytes the tag table
828
+ never typed, and it is the one at the end of the queue. Nothing was ever mis-counted, and this was
829
+ not a false green: the exit code, the total, the set of files named and the withheld count are
830
+ computed off the hits and were right throughout. What moved was which lines a reader saw.
831
+
832
+ The budget is now spent per entry of a closed recognizer table, per file, so whether a hit prints
833
+ depends on its own recognizer and its index among that file's hits from that recognizer, and on
834
+ nothing another recognizer found. The table is a type rather than an analysis of today's push sites:
835
+ budgeting on the printed reason string would have let one future recognizer interpolate a
836
+ payload-derived token into it and hand the payload a vote on how many classes exist. The `hits`
837
+ array is still deliberately not capped, because a cap there would make the totals a claim about what
838
+ was kept rather than about what was found.
839
+
840
+ Because a hit among a file's first `n` overall is among its own recognizer's first `n`, the printed
841
+ set is a superset of the previous scanner's at every cap: 72 grid cells over nine corpus shapes and
842
+ eight caps, zero cells where a line the old scanner printed goes unprinted, 29 cells printing
843
+ strictly more, and 23 cells where the old scanner printed nothing at all from a recognizer that had
844
+ hits. With the two scanners swapped the same grid reports 332 violations, so the zero is a
845
+ measurement rather than a blind check. `pnpm measure:phi-scan-monotonicity` re-derives it against
846
+ any base.
847
+
848
+ This is not monotonicity and is not described as it: under any finite budget `n+1` hits from one
849
+ entry print `n`, so adding a hit can always remove a line. Several sweeps also share an entry, which
850
+ costs more than the same sweep twice: the two text date passes share one, so over 200 ISO dates
851
+ followed by 200 compact ones the report prints 20 ISO lines and no compact one, and the compact pass
852
+ is the only route that sees a bare eight-digit date. That is measured, identical on the previous
853
+ scanner, unchanged here, and pinned by a test rather than argued away.
854
+
855
+ The cost is stated rather than claimed away: a file's report can be longer, bounded by the cap times
856
+ the closed table instead of by the cap, measured at 20 lines against 61 on the loudest grid shape at
857
+ the default budget. `--max-hit-lines 0` still prints every line. Developer-facing only, no public API
858
+ change.
859
+
860
+ - 88be779: Bound the value a PHI-gate hit line echoes. `scripts/phi-scan.ts` printed the whole violating value
861
+ on one stderr line, and how large that was is chosen by the payload: an element declares its own
862
+ length, so a `(0010,0010)` claiming the rest of the object put the rest of the object on one line
863
+ to report that a name was not on the allow-list. It was the only unbounded payload-derived field on
864
+ the line; the others are a path, a rendered tag, an offset and a literal reason.
865
+
866
+ A hit now carries an excerpt of at most 194, bounded where the hit is made rather than where it is
867
+ printed: `Hit.value` is no longer a `string`, and one factory is the only way to make one. 194 is
868
+ PS3.5 2026c Table 6.2-1's PN arithmetic (three component groups of 64, two delimiters), but that
869
+ table measures in characters and this scanner measures a latin1 decode, so 194 bounds what the
870
+ report prints and says nothing about what the standard admits: a conformant 194-character PN under
871
+ `ISO_IR 192` is cut here, and a test pins it. Every cut states exactly how much was withheld,
872
+ outside the quotes.
873
+
874
+ Detection is unchanged: the exit code, the totals and the set of files named come from the hits and
875
+ not from what was printed. Developer-facing only, no public API change.
876
+
3
877
  ## 0.0.18
4
878
 
5
879
  ### Patch Changes