@cosyte/dicom 0.0.11 → 0.0.13

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,11 +1,517 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to `@cosyte/dicom` will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
3
+ ## 0.0.13
4
+
5
+ ### Patch Changes
6
+
7
+ - b7a77fe: Build every Tier-3 fatal message from a frozen registry, and cut the `{ strict: true }` snippet in
8
+ the frame its offset names (`DICOM-FATAL-MESSAGE-REGISTRY`).
9
+
10
+ Tier-2 warnings have been registry-bound for several releases; Tier-3 messages were still assembled
11
+ at the throw site out of template literals, and four of them printed four bytes of the document each.
12
+ The messages that interpolated most were the ones raised **when a length field is lying**, which is
13
+ the condition that makes a reader read bytes inside somebody's value as a Data Element header, so the
14
+ tag and the length they printed were that value. Measured on a synthetic `"MR BRAIN SMITHSON "`:
15
+ `Element 41524E49 declared length=1330858068` is `"RAIN"` then `"THSO"`, eight consecutive payload
16
+ bytes, each recoverable with one typed read.
4
17
 
5
- ## [Unreleased]
18
+ The bound is the factory signature, matching the three Tier-2 codes that paid for this lesson before
19
+ it: the token type has **no tag field and no wire-length field**, so there is no slot for one to
20
+ travel through, and `err.byteOffset` identifies the element instead. A VR still renders when it names
21
+ one of the 34; a byte count still renders when it is bounded by the buffer being read.
22
+
23
+ Separately, `DicomParseError.snippet` is 16 raw source bytes cut at the diagnostic's own
24
+ `byteOffset`. That offset moves with the frame the element was read in, but the cut was always taken
25
+ from the whole file, so a strict-mode escalation raised inside a defined-length Sequence Item cut the
26
+ file at an item-relative number and returned **an unrelated element's** bytes. The parse context's
27
+ buffer now follows the frame at all four places this parser changes one. **The snippet is still
28
+ unredacted source bytes and is still PHI.**
29
+
30
+ **⚠ Some fatal messages are reworded, so a consumer string-matching one stops matching.** No count is
31
+ given, deliberately: a first draft said "six", a graded pass measured nine, and the honest remedy for
32
+ a count corrected once is to delete it rather than increment it. Diff `FATAL_MESSAGES` against the
33
+ previous release's template literals if you need the set. **`err.code` is unchanged on every path and
34
+ which files throw is unchanged.** Narrow on the code, never on the prose.
35
+
36
+ Three residuals are named rather than closed, each with an asserted test row: the same fabricated
37
+ header still reaches the Tier-2 `DICOM_PRIVATE_TAG_NO_CREATOR` message;
38
+ `report.embeddedAttributes[].hidden` still lists a fabricated tag alongside the real one that made
39
+ its run reportable; and because of the first, `ds.warnings[].message` is **not** unconditionally safe
40
+ to log, which the docs now say. Narrowing any of the three is a product call, not a fix.
41
+
42
+ - 3617034: 🩺 `DeidentifyReport`'s `contextPath` was documented as structural and it is not: a segment is
43
+ `TAG[index]` and the tag half is read off the wire, bound by neither a shape test nor a closed table.
44
+ A file whose under-declared Value Length desynchronizes the reader onto four bytes inside somebody's
45
+ value, followed by `SQ`, gets that fabricated sequence descended and its fabricated tag published in
46
+ every `contextPath` beneath it. Measured on a synthetic `LO` carrier holding `"MRS BRAIN SMITHSON"`:
47
+ `contextPath: ["53484E4F[0]"]`, which is `"HSON"` in wire order, with no warning raised and every
48
+ finding array empty. **Redacting it is a logging fix and not an object fix**: on that same file the
49
+ de-identified object still carries the fabricated `(5348,4E4F)` and the serializer writes `"HSON"`
50
+ back out under a `Patient Identity Removed = YES` stamp, which is the already-disclosed
51
+ under-declared carrier class and not this field. The claim is corrected in the type, the tolerance table and the
52
+ troubleshooting guide, and `contextPath` is added to the report's list of fields that are not
53
+ value-free; no guard was widened, because withholding the tag would destroy the audit on every
54
+ well-formed file to bound a malformed one. `PRE-EXISTING`; no runtime behaviour changes. **Treat
55
+ `contextPath` as PHI when the source is untrusted.**
56
+
57
+ The `DICOM_ITEM_CROSSES_SEQUENCE_END` disclosure no longer says `contextPath` names "an item it was
58
+ never in" - that asserts which of two byte-identical files you have. It is deleted rather than
59
+ reworded a third time, and replaced by the two measurements that were already pinned.
60
+
61
+ - 369abbe: 🩺 A private `SQ` a `Profile` vouched for under `RetainSafePrivate` is no longer written into de-identified output verbatim (`DICOM-PRIVATE-SQ-CARVE-OUT`, `PRE-EXISTING`, live through the published `0.0.10`).
62
+
63
+ `keepsPrivate` decided retention before the descent and routed a "yes" to `keepOrEmpty`, the only path in the module that writes a source value into output unchanged. So the whole vendor sequence was kept without anything inside it being examined: Table E.1-1 attributes the vendor encoded in its items, UIDs inside it, and any private element the file's own length fields pulled into it, all with `report.removedPrivateTags` reading `[]` and the object stamped `(0012,0062) Patient Identity Removed = YES`. On a fully conformant file, a `(0010,0010)` Patient's Name inside such a carrier was copied straight through.
64
+
65
+ PS3.15 2026c §E.3.10 licenses retention for "Private Attributes that are known by the de-identifier to be safe from identity leakage", which is knowledge about one Private Attribute and not about a Data Set nested in its value; PS3.5 2026c §7.5.1 makes an Item Value exactly that, and PS3.15 2026c §E.1.1 obliges protecting Table E.1-1 attributes "whether contained in the top level Data Set or embedded in an Item of a Sequence of Items".
66
+
67
+ The retention decision is unchanged and no guard is widened. A vouched-for private `SQ` now takes the same two branches every other `SQ` takes: it is walked when its items exist, and emptied with `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` plus a `report.unauditableSequences` entry when the parser never materialized them. A non-`SQ` private element is untouched, and there is no new public surface.
68
+
69
+ The price is PS3.5 2026c §7.8.1's per-Data-Set reservation scope, which now applies inside the carrier: a nested private element whose block is reserved only at the root is removed and named, while one whose Private Creator is inside the Item, as §7.8.1 requires, is kept. No reading changes: 0 of 83,037 grid cells differ in any parse respect against base `495c9fc`.
70
+
71
+ - 92c0373: 🩺 A private carrier a `Profile` declared `SQ` is no longer written into de-identified output verbatim when the parse tree resolved it to something else (`DICOM-PRIVATE-SQ-PARSE-VR`, `PRE-EXISTING`, live through the published `0.0.10`).
72
+
73
+ `keepRetainedPrivate` branched on `el.vr === "SQ"`, and the parse tree and the profile disagree about the same bytes in two ordinary, conformant situations. Under Implicit VR LE a private tag carries no VR on the wire (PS3.5 2026c §7.1.3), so `SQ` there is an inference the parser draws from a `Profile` it was given, and a profile passed only to `deidentify()` leaves the element `UN`. Under Explicit VR the wire's VR wins in the parser, so a sender who writes a profile-declared `SQ` attribute as `OB` or `UN` yields that instead, with an honest defined length wrapping a well-formed `(FFFE,E000)` item stream. Both shapes were measured shipping a `(0010,0010)` Patient's Name into output stamped `(0012,0062) Patient Identity Removed = YES`, and the Explicit VR one raises nothing at all on `ds.warnings`.
74
+
75
+ The remedy is a second authority rather than a content test: the same `Profile` that vouched for the element declares its VR. A retained private element the profile declares `SQ` whose parse tree carries no items is emptied through the channel a parsed `SQ` with no items already used, `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` plus a `report.unauditableSequences` entry, keeping the VR the file actually carried instead of re-typing the element to `SQ`. `keepsPrivate` and the retention decision are unchanged, no parser file is touched, and there is no new public surface.
76
+
77
+ It reaches the CP-246 `UN` too wherever a profile named it, because the test does not look at the length field; the undefined-length `UN` residual is a statement about elements no profile named. Two conjuncts ahead of it keep properties the module already had. An element whose on-wire VR is not one of the 34 still takes the tag-free `DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` route, which keeps a header fabricated from bytes inside some element's value off the diagnostic **when its fabricated VR is outside the 34, and only then**: fabricate `OB` and the tag does reach `report.unauditableSequences`. That is disclosed rather than guarded, because a fabricated `OB` header and a genuine one are byte-identical, and because on that same input the previous behaviour kept the carrier verbatim and shipped the whole nested name. `DeidentifyReport` is not a value-free surface: its value-bearing fields are now a list on the type with no count anywhere, and re-deriving that list disclosed a fourth nobody had named, `embeddedAttributes[].hidden`, whose entries are four bytes found inside a value and whose own documentation said "safe to log" (`PRE-EXISTING`, byte-identical on every release that has shipped the field, disclosed rather than narrowed here). And a zero-length value is left alone so a second `deidentify()` pass reports no second drop. `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE`'s message no longer reads "is VR=SQ", because it is shared by both producers and the second one's premise is that the parse tree and the profile disagree about the VR.
78
+
79
+ The cost: a caller who passes a profile to `deidentify()` but not to `parseDicom` now loses that vendor sequence's content instead of shipping it unexamined. Pass the same profile to `parseDicom` and the sequence is walked and its non-PHI content retained. Deliberately not closed, and still open rather than decided: a carrier whose profile entry declares a binary VR (`OB`/`OW`/`UN`) over a well-formed item stream, which would need a content test on exactly the VRs arbitrary bytes are for.
80
+
81
+ - ad675da: The README lockup now links to cosyte.com (`ASSETS`).
82
+
83
+ The `<picture>` block above the H1 is wrapped in an anchor to https://cosyte.com, per the founder
84
+ requirement of 2026-08-06. Nothing inside the block moved: the `<source>`, the `<img>`, the alt text
85
+ and both tile URLs are byte-identical.
86
+
87
+ What the anchor does was measured on both surfaces by `fhir`, not assumed, because fourteen READMEs
88
+ carry this shape. On GitHub the anchor works and the colour-scheme switch keeps working, because the
89
+ `<img>` stays a direct child of `<picture>`, which is the condition the HTML spec puts on `<source>`
90
+ applying at all. On an npm package page the anchor is lost: npm wraps a README image in its own
91
+ anchor to the image file, a nested anchor is not representable, so the parser closes ours early and
92
+ the image ends up linked to the image file rather than to cosyte.com. Shipped anyway by founder
93
+ decision of 2026-08-07: on npm that is no worse than the unlinked lockup it replaces, and GitHub is
94
+ where these READMEs are read.
95
+
96
+ - 0a8c6e3: Correct the disclosed extent of the surviving `RetainSafePrivate` retain-route leak
97
+ (`DICOM-RETAIN-ROUTE-RESIDUALS`). Six artifacts described it as "a private carrier whose profile
98
+ entry declares a binary VR". The predicate has two conjuncts and that wording named neither: the
99
+ profile does not declare `SQ`, and the embedded-attribute scanner cannot read the value (it reads
100
+ string carriers only, and decodes tiles in the file's own encoding). The two sets are incomparable,
101
+ not nested - a profile entry declaring `LO` over a carrier the sender wrote `OB` ships the identical
102
+ nested `(0010,0010)`, while one declaring `OB` over a carrier written `LO` is emptied. The
103
+ prose enumeration is deleted rather than reworded, and a measured matrix pins the surface instead:
104
+ declared VR against encoding, and against wire VR under Explicit VR only, because Implicit VR LE
105
+ writes no VR at all. No `src/` predicate changes: the behaviour is `PRE-EXISTING` and identical
106
+ before and after. The matrix also strengthens the `DICOM-PRIVATE-SQ-PARSE-VR` closure beside it,
107
+ proving it on five distinct inputs rather than on the one cell that opened it.
108
+ - c0fa362: The UID registry is now sourced from the normative PS3.6 Annex A, and every release from here on writes its own section into `CHANGELOG.md`.
109
+
110
+ **`Dictionary.uid` resolves the whole UID registry, not a subset of it.** The transfer syntax and well-known UID names used to be a hand-typed table inside the dictionary generator, merged with a SOP Class list, and nothing compared either one against the normative text. That table is gone. The generator now reads PS3.6 2026c Annex A directly (Table A-1 "UID Values" and Table A-2 "Well-known Frames of Reference") from the SHA-256-pinned DocBook already vendored here, and overlays it per field exactly as the element registry is overlaid: the normative source wins on name, type and retirement for a UID both carry, its own additions are taken, and an entry only the mirror carries is kept rather than dropped, because the standard retires UIDs rather than deleting them.
111
+
112
+ The registry goes from 268 entries to 494, and **every one of the 268 comes through byte identical**: no shipped name, type or retirement flag moved. What changed is coverage. Six transfer syntaxes the current edition defines, including the High-Throughput JPEG 2000 family and Deflated Image Frame Compression, previously resolved to `undefined`. Nothing was ever mis-read, because a transfer syntax is dispatched by UID value and never by name, but a caller asking the dictionary what it was holding got no answer.
113
+
114
+ **The two deliberate departures from the normative spelling are preserved, and they are now derived rather than typed.** Retirement stays a structured `retired` boolean instead of the trailing `(Retired)` every retired Annex A row carries at the end of its UID Name. Four transfer syntaxes keep the short form every DICOM toolkit prints instead of the longer `...: Default Transfer Syntax for ...` form, and that short form is cut from the normative name at build time rather than written out by hand, so it cannot drift away from it: a name that stops carrying the clause fails the build instead of silently keeping a stale string. Measured on this edition, those four are exactly the Annex A names that carry such a clause, so the departure is the complete set rather than a subset. Two rows that the standard retired and left unnamed in the same edition are excluded rather than shipped with an empty name, because an entry whose name is empty reads as a successful lookup and a caller's fallback stops firing.
115
+
116
+ **And the changelog is generated.** `.changeset/config.json` set `changelog: false` for this package's whole published history, so no release ever wrote a version heading and the file was maintained by hand under one `[Unreleased]` heading that nothing rolled over. Ten published versions shipped a changelog inside the tarball describing already-released work as unreleased. The fix is the flag rather than the prose: correcting the text by hand leaves the mechanism that produced it. The hand-written history is preserved verbatim, unsorted and unreworded, under a `Released before this file was generated` divider, and every release from here on prepends its own section above it.
117
+
118
+ Both halves are graded by tests rather than asserted. The UID registry is re-parsed from the pinned DocBook independently of the generator and compared against what the package actually exports, so the check cannot pass by agreeing with the generator. The changelog contract runs the real release tool against the real file in a throwaway repository, proves the archived history survives it byte for byte, and proves the region above the divider carries only what a release generates: a fabricated version section is refused wherever it is placed, which closes a gap a sibling package measured as open.
119
+
120
+ ## Released before this file was generated
121
+
122
+ Every release section above this heading is written by
123
+ [Changesets](https://github.com/changesets/changesets) from the changesets in `.changeset/`, newest
124
+ release first. The release writes its own version heading, so nothing above this line is maintained
125
+ by hand, and a change is recorded here by adding a changeset rather than by editing this file.
126
+
127
+ Everything below this heading was maintained by hand, in the
128
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format, on the
129
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ladder this package still follows. Most
130
+ of it sat under a single `[Unreleased]` heading that no release ever rolled over, which is why ten
131
+ published versions of this package shipped a `CHANGELOG.md` describing already-released work as
132
+ unreleased, inside the tarball, for as long as they did. That heading is gone; its entries are not.
133
+
134
+ The text is left exactly as it was written rather than re-sorted into version sections. The file
135
+ never recorded which release each of those entries went out in, so assigning them now would be a
136
+ guess presented as a record, and this is the text that installed copies already carry on disk. Only
137
+ the `[Unreleased]` heading itself and the preamble above it were removed.
6
138
 
7
139
  ### Fixed
8
140
 
141
+ - **🩺 `DeidentifyReport`'s `contextPath` was documented as structural, and it is not: it can publish
142
+ four bytes of a value, silently** (`DICOM-ITEM-CROSSES-RESIDUALS`). A segment is `TAG[index]`, and
143
+ the tag half is whatever tag the descent walked, read straight off the wire, bound by neither a
144
+ shape test nor a closed table. `attributes[].tag` is bound - it is only populated for a tag Annex E
145
+ carries a row for - and that contrast is the finding. **It is not the only report identifier read
146
+ off the wire**: `removedPrivateTags` and `unauditableSequences[].tag` are as well. Those two were
147
+ already disclosed as such; this one was documented as structural. So a file whose
148
+ under-declared Value Length desynchronizes the reader onto four bytes sitting **inside** somebody's
149
+ value, where those four bytes are followed by `SQ`, gets that fabricated sequence descended and its
150
+ fabricated tag published in the `contextPath` of everything beneath it.
151
+
152
+ Measured on a synthetic `LO` carrier holding `"MRS BRAIN SMITHSON"` that under-declares by four:
153
+ the report reads `contextPath: ["53484E4F[0]"]`, which is `"HSON"` in wire order, recovered by
154
+ writing the two halves back with `writeUInt16LE`. Change the surname to `"DAVIDSON"` and the
155
+ published segment changes with it. **No warning is raised and every finding array on the report is
156
+ empty**, so a consumer following the old guidance had no signal at all. A conformant file's segment
157
+ is the tag the sender wrote, which is why the field is still published.
158
+
159
+ **🛑 REDACTING `contextPath` IS A LOGGING FIX AND NOT AN OBJECT FIX, AND A GRADED PASS REFUTED THE
160
+ FIRST DRAFT FOR SAYING OTHERWISE.** That draft claimed in six places that this field was "the only
161
+ trace of that header in the entire output". It is not: the de-identified `Dataset` still carries
162
+ the fabricated `(5348,4E4F)`, so `serializeDicom` writes its header back out in full, `"HSON"`
163
+ included, under `(0012,0062) Patient Identity Removed = YES` - and the re-emitted bytes track the
164
+ surname exactly as the log field does. That re-emission is the already-disclosed under-declared
165
+ carrier class, not this field's doing, and **neither one is a bound on the other.** Both rows are
166
+ now pinned.
167
+
168
+ **The claim was corrected and no guard was widened**, on the same footing as `removedPrivateTags`:
169
+ _where_ an attribute sat is the whole audit value of the field, and withholding it would destroy
170
+ that on every well-formed file in order to bound a malformed one. **`contextPath` is now named on
171
+ the report's list of fields that are not value-free** - a list whose numerals were deleted rather
172
+ than incremented, per this package's own rule - and corrected in the type, the tolerance table and
173
+ the troubleshooting guide, which all called it structural. **Treat it as PHI when the source is
174
+ untrusted.** `PRE-EXISTING` on every release that has shipped the field; no runtime behaviour
175
+ changes.
176
+
177
+ The shared PHI runner has swept this field from the start and could never have gone red on it: it
178
+ hunts a **verbatim** marker and a tag is a re-encoding, the same blind spot already documented for
179
+ `DicomParseError.snippet`. The pin is a purpose-built measurement with a name-bearing payload, a
180
+ mutation control and a conformant negative control.
181
+
182
+ - **The `DICOM_ITEM_CROSSES_SEQUENCE_END` disclosure no longer claims `contextPath` names "an item it
183
+ was never in".** That sentence asserts which of two byte-identical files you have, which is the one
184
+ thing this package has repeatedly measured the wire as not carrying: the partner file, built from
185
+ the opposite intention, is the same bytes with the element genuinely inside the item, where the
186
+ identical `contextPath` is right. It had already been narrowed once in the troubleshooting guide
187
+ and left standing everywhere else; this package's rule for a disclosure reworded twice is to delete
188
+ it, so it is deleted rather than given a third wording. **What replaces it is the pair of
189
+ measurements that were already there** - one shape puts the element in the item with a
190
+ `contextPath`, the other leaves it at the root with none - **and neither is labelled the mistake.**
191
+ No reading, no warning and no report field moved.
192
+
193
+ - **🩺 Tier-3 fatal messages are built from a frozen registry, and four of them were printing four
194
+ bytes of the document each** (`DICOM-FATAL-MESSAGE-REGISTRY`). Tier-2 warnings have been
195
+ registry-bound since the warning-message slice; Tier-3 messages were still assembled at the throw
196
+ site out of template literals. That reads as harmless and is not, because the messages that
197
+ interpolated most were the ones raised **when a length field is lying** - the condition that makes
198
+ a reader read bytes inside somebody's value as a Data Element header, so the tag and the length it
199
+ then prints are that value.
200
+
201
+ Measured on a synthetic `"MR BRAIN SMITHSON "` carried in an `ST` whose Value Length under-declares:
202
+ `Element 41524E49 declared length=1330858068` is `"RAIN"` then `"THSO"`, eight consecutive payload
203
+ bytes in two fields, each recoverable with one `readUInt16LE` / `readUInt32LE`. Also
204
+ `Unexpected tag 524D4220 inside sequence` and `Item length=1109414477`, both `"MR B"`, and
205
+ `Unexpected FFFE marker FFFEE00D (length=1109414477)`, the same four bytes again.
206
+
207
+ **The bound is the factory signature, exactly as it was for the three Tier-2 codes that paid for
208
+ this lesson before it.** The new `FatalTokens` type has **no tag field and no wire-length field**,
209
+ so there is no slot for one to travel through, and a shape check would not have helped: a tag has
210
+ only a shape, so a renderer for one cannot refuse a fabricated tag. `err.byteOffset` identifies the
211
+ element instead, and it is a count the parser kept. What a fatal message may still carry is named
212
+ one registry entry at a time: a VR checked against the closed 34-VR set, a residual byte count
213
+ bounded by the buffer being read, a library constant, PS3.6's own registry name for an unsupported
214
+ Transfer Syntax UID, and a zlib error code checked against zlib's nine-name table.
215
+
216
+ **⚠ SOME FATAL MESSAGES ARE REWORDED, SO A CONSUMER STRING-MATCHING ONE STOPS MATCHING. NO COUNT IS
217
+ GIVEN, AND THAT IS DELIBERATE** - the first draft of this notice said "six", a graded pass measured
218
+ nine, and this repo's rule is to **delete a count rather than increment it**. The set is derivable
219
+ in one command and can never go stale: diff `FATAL_MESSAGES` in `src/parser/fatals.ts` against the
220
+ template literals in `git show 0a8c6e3 -- src/parser/`. **`err.code` is unchanged on every path,
221
+ and which files throw is unchanged.** Narrow on the code, never on the prose.
222
+
223
+ - **🩺 The `{ strict: true }` snippet returned an unrelated element's bytes inside a defined-length
224
+ Sequence or Item.** `PRE-EXISTING`, found and disclosed by the `(0012,0063)` slice rather than
225
+ fixed, and closed here. `DicomParseError.snippet` is 16 raw source bytes cut at the diagnostic's own
226
+ `byteOffset`; that offset moves with the frame the element was read in, but the cut was always taken
227
+ from the whole file. So an escalation raised inside a defined-length Item cut the **file** at an
228
+ **item-relative** number and handed back whatever sat there - a diagnostic disclosing part of an
229
+ element the reader was never asked about, which in a de-identification library is a PHI surface
230
+ rather than a cosmetic offset bug. The parse context's buffer now follows the frame at all four
231
+ places this parser changes one; the deflate boundary already did, and the three sequence boundaries
232
+ did not.
233
+
234
+ **This does not make `snippet` safe to log, and nothing here should be read that way.** It is still
235
+ 16 unredacted source bytes (the documented design), and making the frame honest makes them more
236
+ certainly the named element's own content, not less.
237
+
238
+ **Three residuals are named rather than closed, each with an asserted row in
239
+ `test/integration/fatal-diagnostic-surface.test.ts` so no artifact can read this entry as an
240
+ all-clear.** (1) The identical fabricated-header shape still reaches a **Tier-2** message: the same
241
+ desynchronized read lands on an odd group, so `DICOM_PRIVATE_TAG_NO_CREATOR` names the fabricated
242
+ tag (`4E495320`, `"IN S"`), through the shape-checking `renderTag` that cannot refuse one. (2)
243
+ `report.embeddedAttributes[].hidden` lists every tag in a run the embedded scanner found inside a
244
+ kept value, and a run needs only **one** actionable attribute to be reported, so a fabricated header
245
+ beside a real one is listed too: measured at `4D535449`, `"SMIT"` in wire order, beside the genuine
246
+ `00100020`. (3) Because of (1), **`ds.warnings[].message` is not unconditionally safe to log**, and
247
+ the docs that said so are corrected in this release rather than the guard being widened. All three
248
+ are the same product call as the one already filed for `report.removedPrivateTags`: on a well-formed
249
+ file these are the real tags of real attributes, and withholding them would destroy the field's
250
+ audit value to close a shape only a crafted file produces.
251
+
252
+ - **🩺 The surviving PHI leak on the `RetainSafePrivate` retain route was disclosed as the WRONG SET,
253
+ in six artifacts at once** (`DICOM-RETAIN-ROUTE-RESIDUALS`). `PRE-EXISTING` and unchanged in
254
+ behaviour: **no `src/` predicate moves in this release entry**, and every cell described below
255
+ leaks identically before and after it. What changed is the claim.
256
+
257
+ Every artifact that described what the private-`SQ` closure leaves open said it was "a private
258
+ carrier whose **profile entry declares a binary VR** (`OB`/`OW`/`UN`)". That is not the predicate,
259
+ and the true set is **neither narrower nor wider than it - the two are incomparable**. A profile
260
+ entry declaring `LO` over a carrier the sender wrote `OB` leaks; a profile entry declaring `OB`
261
+ over a carrier the sender wrote `LO` does not. **There are two conjuncts and the deleted wording
262
+ named neither.** `keepRetainedPrivate` asks one question - does the `Profile` declare this
263
+ attribute `SQ`? - and every other answer falls through to the ordinary keep path, where the only
264
+ thing between the value and de-identified output is the embedded-attribute scanner. That scanner
265
+ reads **string carriers only**, and it decodes candidate Data Element headers in the **file's own
266
+ encoding**. So the value survives exactly when the profile does not say `SQ` **and** the scanner
267
+ cannot read it: a string carrier holding Explicit-VR-shaped bytes inside an Implicit VR LE file is
268
+ in the set for the same reason a binary carrier is. In every such case the carrier reaches output
269
+ **byte-identical to the file's own value**, stamped `(0012,0062) Patient Identity Removed = YES`,
270
+ with `report.unauditableSequences` and `ds.warnings` both empty, because none of these files is
271
+ malformed: the Value Length is honest and the value is a well-formed `(FFFE,E000)` item stream.
272
+
273
+ **The enumeration is deleted rather than reworded a third time, and a measured matrix replaces it.**
274
+ `test/integration/deident-private-reservation.test.ts` now sweeps the profile's declared VR against
275
+ the encoding and, under Explicit VR only, the wire VR, with a name-bearing payload, and asserts the
276
+ **emptied** set exactly - so the leaking set is whatever is left and cannot go stale in prose. It
277
+ pins the leaking cells as kept verbatim and silent rather than inferring it from a substring
278
+ search. **There is deliberately no wire-VR axis under Implicit VR LE**, which writes no VR at all
279
+ (PS3.5 2026c §7.1.3); crossing one in measures a single file four times. The same sweep strengthens
280
+ the closure it sits beside: a profile-declared `SQ` is emptied to zero bytes and named on all four
281
+ Explicit wire VRs and on the VR-less Implicit encoding, where the case it replaced measured one
282
+ `OB`/Explicit cell. Non-vacuity is by fixture and by mutation: the payload carries a real name,
283
+ every Explicit cell really does present a distinct VR to the parser, and disabling the closure's
284
+ branch reds the matrix.
285
+
286
+ **Two remedies exist and both are product calls, so neither is taken here.** A content test on
287
+ binary carriers is already priced in `src/deident/embedded.ts` at 11 grid cells to 0 while emptying
288
+ all 5 conformant binary tiling controls (`DICOM-DEIDENT-OVER-REDACTION`, open). Refusing retention
289
+ wherever the profile's declared VR and the parse tree disagree needs no content test, but a `UN`
290
+ carrier whose profile declares `LO` is the most ordinary real-world private element there is - an
291
+ intermediary without the private dictionary rewrote the VR, PS3.5 2026c §6.2.2 - so it would drop
292
+ vendor values out of a large share of conformant files. Both are recorded as evidence, neither is
293
+ recommended, and the call is not made here.
294
+
295
+ - **🩺 A private carrier a `Profile` declared `SQ` was still written into de-identified output
296
+ verbatim whenever the parse tree resolved it to anything else** (`DICOM-PRIVATE-SQ-PARSE-VR`).
297
+ `PRE-EXISTING`, live through the published `0.0.10` and through the carve-out below, which closed
298
+ only what the parse tree happened to resolve. `keepRetainedPrivate` branched on `el.vr === "SQ"`,
299
+ and the parse tree and the profile disagree about the same bytes in **two ordinary, conformant
300
+ situations**: under **Implicit VR LE a private tag carries no VR on the wire** (PS3.5 2026c
301
+ §7.1.3), so `SQ` there is an inference the parser draws from a `Profile` _it_ was given and a
302
+ profile passed only to `deidentify()` leaves the element `UN`; and under **Explicit VR the wire's
303
+ VR wins in the parser**, so a sender who writes a profile-declared `SQ` attribute as `OB` or `UN`
304
+ yields that instead - with an honest defined length wrapping a well-formed `(FFFE,E000)` item
305
+ stream. Both were measured shipping a `(0010,0010)` Patient's Name into output stamped
306
+ `(0012,0062) Patient Identity Removed = YES` with an empty `report.unauditableSequences`.
307
+
308
+ **The remedy adds a second authority, not a content test.** The `Profile` that vouched for the
309
+ element also declares its VR, and that declaration is what `RetainSafePrivate` is trusting in the
310
+ first place. A retained private element the profile declares `SQ` whose parse tree carries no
311
+ items is now emptied through the same channel a parsed `SQ` with no items already used -
312
+ `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` plus a `report.unauditableSequences` entry - **keeping the
313
+ VR the file actually carried** rather than re-typing the element to `SQ`. Nothing inspects the
314
+ value's bytes, `keepsPrivate` and the retention decision are unchanged, no parser file is touched,
315
+ and there is no new public surface. `UnauditableSequenceFinding`'s documentation now names both
316
+ producers, because the field is no longer only about a parsed `SQ`.
317
+
318
+ **🛑 One of the two shapes is silent on `ds.warnings`.** The Implicit VR LE file raises
319
+ `DICOM_IMPLICIT_VR_FOR_PRIVATE_TAG_WITHOUT_VR`; the Explicit VR `OB` file is fully conformant and
320
+ raises nothing at all. `report.unauditableSequences`, not `ds.warnings`, is this class's channel.
321
+
322
+ **The cost, stated here rather than discovered later.** A caller who passes a profile to
323
+ `deidentify()` but not to `parseDicom` now **loses** that vendor sequence's content instead of
324
+ shipping it unexamined. **Pass the same profile to `parseDicom`** and the sequence is walked, its
325
+ Table E.1-1 attributes de-identified and the rest retained.
326
+
327
+ **It reaches the CP-246 `UN` too, wherever a profile named it.** The test does not look at the
328
+ length **field**, so an undefined-length `UN` whose CP-246 descent this parser refused is emptied
329
+ when a `Profile` declares that private attribute `SQ`. The undefined-length `UN` residual is a
330
+ statement about elements **no profile named**, and survives only there. (Read that as stated: the
331
+ predicate does have a length conjunct, `el.length > 0`, and a refused CP-246 descent carries
332
+ `0xFFFFFFFF`, so it passes.)
333
+
334
+ **Two properties of the module are kept by conjuncts ahead of that test, and each is pinned by its
335
+ own test.** An element whose on-wire VR is not one of the 34 is still answered by the tag-free
336
+ `DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` route. And a zero-length value is left alone, so
337
+ de-identifying an already de-identified object reports no second drop.
338
+
339
+ **🩺 The first of those is a PARTIAL bound, and it is disclosed rather than grown.** It keeps the
340
+ tag off the diagnostic only when the fabricated header's own VR bytes fall outside the 34. A
341
+ length under-declared upstream can resynchronize the reader onto four bytes that spell a genuine
342
+ private block, and if those bytes are followed by `OB` rather than something unrecognized, the
343
+ fabricated tag reaches `report.unauditableSequences` and the warning. There is nothing to key on:
344
+ a fabricated `OB` header and a genuine one are byte-identical. The direction is still a strict
345
+ improvement, because the previous behaviour kept that carrier **verbatim** and shipped the whole
346
+ nested name, and this one empties it. It joins `report.removedPrivateTags`, which can echo the
347
+ same four bytes from a fabricated odd-group header on both trees: **`DeidentifyReport` is not a
348
+ value-free surface and must not be treated as one.** Pinned as the second row of the
349
+ fabricated-header test.
350
+
351
+ **The report's value-bearing fields are now a LIST on `DeidentifyReport`, with no count anywhere.**
352
+ The count read one, then two, then three, and was wrong each time, because each correction bumped
353
+ the numeral instead of re-deriving the list. Re-deriving it found a fourth that no release had
354
+ disclosed: `embeddedAttributes[].hidden`, whose entries are composed from four bytes found
355
+ **inside** a value, so on any file that populates that field they are document content by
356
+ construction, and whose own documentation said "safe to log". That is `PRE-EXISTING` and
357
+ byte-identical on every release that has shipped the field; it is **disclosed** here and on the
358
+ type, and narrowing or capping it is its own change.
359
+
360
+ `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE`'s message no longer reads "is VR=SQ". It is shared by both
361
+ producers, and the second one's premise is that the parse tree and the profile disagree about the
362
+ VR, so naming one stated a fact the file contradicts.
363
+
364
+ **Deliberately not closed**, and see the entry above for why this paragraph no longer enumerates
365
+ the surviving shape: everything the profile does **not** declare `SQ`, over a value that happens
366
+ to be a well-formed item stream, is still kept verbatim. Closing any of it needs a content test on
367
+ exactly the VRs arbitrary bytes are for. That is the same reasoning
368
+ `DICOM-BINARY-CARRIER-OVERDECLARE` was accepted on, but it is **a different route** - that
369
+ decision priced a measured over-declare swallow, and this is an honest length reached through
370
+ `RetainSafePrivate`. It remains open and undecided, and is pinned as a measured matrix rather than
371
+ described in prose.
372
+
373
+ Against base `src/` at `369abbe`, replaced wholesale: **5 of 1,081** tests run red over the full
374
+ suite; suite 1074 to 1080 passing plus the 1 `todo`. Two of the seven new or rewritten tests are
375
+ green on both trees **by design**, because they are controls: that a retained private element the
376
+ profile declares `LO` is still kept **verbatim**, and that a fabricated header keeps the tag-free
377
+ diagnostic (base already withheld it). Every conjunct's non-vacuity was proven by mutation rather
378
+ than asserted. `scripts/measure-sq-bound-grid.ts` was **not** re-run and holds no
379
+ private-`SQ` cell in any family; this remedy is reachable only from inside `keepRetainedPrivate`.
380
+
381
+ - **🩺 A private `SQ` a `Profile` vouched for under `RetainSafePrivate` was written into
382
+ de-identified output verbatim, so nothing inside it was ever examined for PHI**
383
+ (`DICOM-PRIVATE-SQ-CARVE-OUT`). `PRE-EXISTING`, live through the published `0.0.10`, found by
384
+ `#66`'s `conformance-refuter`. `keepsPrivate` decides retention before the descent, and that part
385
+ was never wrong; what was wrong is that a "yes, retain" routed the element to `keepOrEmpty`, **the
386
+ only path in the module that writes a source value into output unchanged**. So a vendor sequence
387
+ the profile named was blitted whole: Table E.1-1 attributes the vendor encoded in its items, UIDs
388
+ inside it, and any private element the file's own length fields pulled into it all survived, with
389
+ `report.removedPrivateTags` reading `[]` and the object stamped `(0012,0062) Patient Identity
390
+ Removed = YES`.
391
+
392
+ **The sharper half needs no malformed file at all.** On a fully conformant file, with `ds.warnings`
393
+ empty, a `(0010,0010)` Patient's Name written inside a vouched-for private `SQ` was copied straight
394
+ through. That case is now pinned with a name-bearing payload and non-vacuity assertions, alongside
395
+ the absorb shape the original residual held.
396
+
397
+ **What bounds the profile's licence is the spec, not a judgement call here.** PS3.15 2026c §E.3.10
398
+ retains "Private Attributes that are known by the de-identifier to be safe from identity leakage".
399
+ A profile entry is knowledge about **one Private Attribute**. It is not knowledge about a Data Set
400
+ nested inside that attribute's value, and PS3.5 2026c §7.5.1 makes an Item Value exactly that ("a
401
+ DICOM Data Set composed of Data Elements"). PS3.15 2026c §E.1.1 then obliges an implementation
402
+ claiming the Basic Profile to protect Table E.1-1 attributes "whether contained in the top level
403
+ Data Set or embedded in an Item of a Sequence of Items", and a private carrier is one of those
404
+ Sequences. A vendor cannot vouch for `(0010,0010)`; it is not a Private Attribute.
405
+
406
+ **The remedy widens no guard.** `keepsPrivate` still decides retention and the profile is still the
407
+ only vouching authority. A vouched-for private `SQ` is simply routed into the two branches every
408
+ other `SQ` in the module already takes: `descendSequence` when its items exist,
409
+ `emptyUnauditableSequence` (with `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` and
410
+ `report.unauditableSequences`) when the parser never materialized them. A non-`SQ` private element
411
+ is untouched. **No new public surface**: no Tier-2 code, no report field, no snapshot change.
412
+
413
+ **The price, measured and pinned.** Walking the carrier means PS3.5 2026c §7.8.1's per-Data-Set
414
+ reservation scope applies inside it, and Items do not inherit the enclosing Data Set's
415
+ reservations. A vendor who nests a private element in a private `SQ` and reserves its block only at
416
+ the **root** loses it, named on `report.removedPrivateTags` rather than dropped silently. A vendor
417
+ who writes the Private Creator **inside the Item**, as §7.8.1 requires, keeps it. Both rows are
418
+ pinned as a pair.
419
+
420
+ **No reading changes, and the grid cannot see this remedy.** `scripts/measure-sq-bound-grid.ts`
421
+ builds its `priv|` family's private data element as `LO` behind a public `(0008,1115)` carrier, so
422
+ it holds **no private-`SQ` cell**. Against base `495c9fc` over **83,037 cells**: 0 cells differing
423
+ in any parse respect, 0 whose reading differs, 0 changed, 0 PHI regressions, 0 de-identified
424
+ outputs lost a marker, and every `priv:` counter identical. Read that as evidence of blast radius,
425
+ never as a safety measurement. The regression net is the unit tests: against base `src/` at
426
+ `495c9fc`, **5 of the 57** tests across
427
+ `test/integration/deident-private-reservation.test.ts` and
428
+ `test/integration/deident-unauditable-sequence.test.ts` run red: the **4** that assert this
429
+ closure, plus the control row of the `DICOM-PRIVATE-SQ-PARSE-VR` residual, which asserts the same
430
+ closure on a file the parser did resolve. The residual's own leaking row is green on base by
431
+ design. Full suite 1071 to 1074 passing. The two residuals that asserted the leaking behaviour
432
+ were rewritten to assert the closure, which is what those pins existed for.
433
+
434
+ **The bound below is CLOSED by the entry above (`DICOM-PRIVATE-SQ-PARSE-VR`), which ships in this
435
+ same release; it is kept because it is why that remedy needed a second authority.** The branch
436
+ keyed on the PARSED VR, not on the VR the profile declares. Under Implicit VR LE a private tag carries no
437
+ VR on the wire, so `SQ` there is an inference the parser draws from a `Profile` it was given. Pass
438
+ the profile to `parseDicom` and the element arrives as an `SQ` with items and is walked; pass it
439
+ only to `deidentify()` and the identical bytes arrive as `UN` with no items, take the non-`SQ`
440
+ branch, and are kept verbatim as before, under `(0012,0062) = YES`, with
441
+ `DICOM_IMPLICIT_VR_FOR_PRIVATE_TAG_WITHOUT_VR` the only signal. That is
442
+ `DICOM-PRIVATE-SQ-PARSE-VR`, `PRE-EXISTING`, its own item and pinned as a residual test. It is
443
+ **not** the undefined-length `UN` residual below: that carrier's length is defined, so CP-246
444
+ never runs. **Pass your profile to `parseDicom` as well as to `deidentify()`.** The
445
+ `creatorsInScope` note claiming `RetainSafePrivate` "behaves identically whether the profile
446
+ arrived at parse or at de-identification" is retracted for the same reason; it was true only while
447
+ every retained private element was kept verbatim.
448
+
449
+ **Still open at the time this entry was written, and `DICOM-PRIVATE-SQ-PARSE-VR` has since been
450
+ closed by the entry above:** the undefined-length `UN`
451
+ whose CP-246 descent was refused
452
+ (it keeps `vr === "UN"`, so the rule cannot reach it without emptying every unknown-VR element in
453
+ every file); and the 11 leaf-carrier cells of `DICOM-BINARY-CARRIER-OVERDECLARE` (11 to 11 on the
454
+ grid, with the conformant tiling-control counter unmoved at 7 to 7).
455
+
456
+ - **🩺 The `(0012,0063)` De-identification Method this library writes for itself exceeded the 64-character
457
+ maximum PS3.5 gives an `LO` Value, on every file it ever de-identified**
458
+ (`DICOM-LO-LENGTH-AND-SILENT-REPLACE`). `PRE-EXISTING`; measured through the built package on
459
+ `da1f209`: **76** characters with no options, **130** with `RetainUIDs + RetainSafePrivate +
460
+ RetainDeviceIdentity`, **272** with all nine, and **all 512 option subsets over the maximum**. PS3.5
461
+ 2026c **Table 6.2-1**, `LO` row, is "64 chars maximum" and that row describes a **Value**;
462
+ `(0012,0063)` is `1-n`, so the bound falls per value. Reading it as a bound on the Value Field is the
463
+ same misreading that left the fixed-point hole above (where §6.4, a clause about the **encoder**, was
464
+ read as a bound on a **comparison**), and it matters because the attribute concerned is the one a
465
+ receiver reads to decide whether an object was de-identified at all.
466
+
467
+ The default method text is now **multi-valued**: one Value naming the Profile
468
+ (`@cosyte/dicom Basic Application Level Confidentiality Profile`, **61** characters) and one Value per
469
+ active Annex E Option, joined with `\`. No option name exceeds **28** characters, so no subset can
470
+ breach the maximum - proved by sweeping **all 512 subsets / 2,816 value cells** rather than by
471
+ argument: **0 over the maximum**, against **512 of 512** on the commit before. The Value Field is
472
+ still 111 and 247 bytes for those two option sets, which is legal and is asserted alongside the
473
+ per-value figure so a remedy that merely shortened the text could not pass. Options are emitted in
474
+ `DEIDENTIFY_OPTIONS` order rather than the caller's, so the same option set always writes the same
475
+ bytes. The fixed point is unaffected and re-measured over six real wire round trips: **62** bytes flat
476
+ by default and **248** flat with all nine.
477
+
478
+ **Your own `deidentificationMethod` is not bounded for you** and neither is a prior value the source
479
+ file wrote: splitting or truncating either would invent a de-identification record nobody made. Both
480
+ are pinned as residual tests rather than left to be rediscovered. Split your string on `\` yourself if
481
+ a strict receiver is in your path.
482
+
483
+ - **🩺 A `(0012,0063)` a file encoded under a VR other than `LO` was replaced silently**, and now raises
484
+ the new Tier-2 code **`DICOM_DEIDENT_METHOD_NOT_LO`** on `report.warnings`. The replacement itself is
485
+ deliberate and unchanged: the provenance chain is built by concatenating `LO` values with the `5CH`
486
+ delimiter, which is a text operation and not defined over the arbitrary octets an `OB` or `UN` value
487
+ holds, so guessing an encoding for them was refused. What was wrong was that the sender's earlier
488
+ de-identification record left the object with `report.warnings` **empty**, under
489
+ `(0012,0062) = YES`. It is a separate code from `DICOM_DEIDENT_METHOD_NOT_ADDED` because the causes
490
+ are unrelated - the chain outgrew the VR, or the bytes were never in that VR at all - and a consumer
491
+ that has to tell them apart now can. A prior value that is empty or padding only raises neither,
492
+ because nothing was lost. The code carries **no value, no length and no VR**: two bytes read out of a
493
+ fabricated header are document content, which is how four letters of a surname once reached
494
+ `DICOM_NONZERO_RESERVED_BYTES`.
495
+
496
+ - **🩺 `{ strict: true }` renders source bytes the warning it replaces deliberately withholds.**
497
+ `PRE-EXISTING`, **disclosed rather than changed**: the escalation raises a `DicomParseError` whose
498
+ `snippet` is 16 raw source bytes read from the file at the warning's own `byteOffset` (D-10), while
499
+ `err.message` stays the frozen registry string. Measured on
500
+ `DICOM_DUPLICATE_FILE_META_ELEMENT`, whose group is never nested and whose offset is therefore
501
+ unambiguously the dropped copy's header: a `(0002,0016)` Source AE Title of `AE-SMITHSON` comes
502
+ back as `02 00 16 00 41 45 0c 00 41 45 2d 53 4d 49 54 48`, five letters of the surname. **Nothing
503
+ more general is claimed, and two graded passes are why**: which element a snippet's bytes belong to
504
+ is **not contracted** anywhere in this package: that offset's frame follows where the element was
505
+ read (file-absolute at the root, relative to the enclosing slice inside a defined-length Sequence or
506
+ Item, into the inflated stream under Deflated Explicit VR LE), while the snippet is cut from
507
+ whichever buffer the parse is holding, so the two can disagree. Do not reason from a code's message to what its snippet holds;
508
+ measure it, and treat every snippet as document content. Redacting `snippet` is a decision about every Tier-3
509
+ fatal in the library, not a rider on a File Meta disclosure, so the **claim** was corrected instead:
510
+ the test block asserting "the diagnostic is not itself a PHI surface" read `warning.message` and
511
+ nothing else, and is now titled for what it proves, with the strict path pinned beside it as the
512
+ residual it is. `ParseOptions.strict` and the "Keeping PHI out of logs" guide now say so where the
513
+ decision is made. Review the two paths separately.
514
+
9
515
  - **🩺 Repeated de-identification was not a fixed point: `(0012,0063)` grew by the whole method
10
516
  string on every pass, for any `deidentificationMethod` ending in a SPACE or a NUL**
11
517
  (`DICOM-DEIDENT-NOT-A-FIXED-POINT`). **`INTRODUCED` by the entry below and never published** - it
@@ -742,7 +1248,10 @@ Removed = YES` with `report.warnings` empty **and** `report.retained` `[]` - an
742
1248
  followed by a root `(0010,0020)` Patient ID that is 18 bytes on the wire (an 8-byte Explicit VR
743
1249
  short-form header plus a 9-character value padded to 10). The Patient ID is **absent from the root**
744
1250
  and present instead as an attribute of the item; the `DeidentifyReport` names it with a
745
- `contextPath` pointing at a sequence item it was never in. It is swallowed once and relocated, not
1251
+ `contextPath` pointing at that sequence item. (This entry said "pointing at a sequence item it was
1252
+ never in" until `DICOM-ITEM-CROSSES-RESIDUALS` deleted that wording: it asserts which of two
1253
+ byte-identical files you have. See the entry at the top of this file.) It is swallowed once and
1254
+ relocated, not
746
1255
  read twice: the parser resumes where the descent actually ended. The parse was silent about all of
747
1256
  it, including under `{ strict: true }`, and that silence is what is fixed. The mis-structure itself
748
1257
  is **not repaired here** and is pinned as a residual by a test.