@cosyte/dicom 0.0.19 → 0.1.1

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