@cosyte/dicom 0.0.12 → 0.0.14

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,636 @@
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.14
4
+
5
+ ### Patch Changes
6
+
7
+ - 0c53142: Test infrastructure: the generator suites prove their pins in a sandbox, so nothing mutates the
8
+ vendored standards while another worker is reading them.
9
+
10
+ **The pin is not relaxed. The mutation is relocated.** Both generator suites prove their vendored
11
+ DocBook pins are preconditions rather than comments, and the only way to prove that is to defeat one:
12
+ repoint `vendor/nema/<part>/SHA.txt` at a mutant document, and, for the check that re-hashes the
13
+ CONTENT rather than the pointer, overwrite the bytes at the pinned path itself. Vitest runs test files
14
+ in parallel, so for as long as a mutation was live every other worker saw it. The documentation
15
+ citation gate re-hashes PS3.5, PS3.6 and PS3.15 at **module load**, which made it a fresh concurrent
16
+ reader of two of the parts those suites mutate, and a reader that throws at module load takes its
17
+ whole file down rather than one case. (Two, not all of them: the gate does not read `part05-2004`,
18
+ which the repeating-groups suite mutates, and nothing mutates `part06`, which the gate does read.)
19
+ The generators now run against a `mkdtemp` copy of `scripts/`, `src/` and `vendor/`, so there is
20
+ nothing for a concurrent reader to observe.
21
+
22
+ **Neither generator took a new input, which is what makes this affordable.** Both resolve their own
23
+ repository root from `import.meta.url`, so relocating the script relocates the document it reads and
24
+ the artifact it writes. The byte-identical regen gate keeps depending on a script with no vendor-root
25
+ argument and no output-path argument; the new `root` option is on the test helper that spawns them,
26
+ not on the scripts. The artifact comparison still reads the **committed**
27
+ `src/dictionary/generated/` file as its baseline, so "regenerates what is committed" still means that.
28
+
29
+ **The window was measured, and so was the fix.** A probe running the citation gate's `readPinned` in
30
+ a loop over all four vendored part directories, against ten runs of the two generator suites, logged
31
+ 1,542 read cycles and 945 anomalous observations in four classes: `SHA.txt` holding a non-hash,
32
+ `SHA.txt` naming a directory not on disk, the file at the pinned path not hashing to its pin, and the
33
+ one that matters most, **a pin that verified against a document that is not the committed one**. 742
34
+ of those 945 are on the two parts the citation gate actually reads. A mutant is written into a directory
35
+ named by its own hash, so re-hashing it succeeds: an integrity check cannot see that at all, and the
36
+ reader resolves clauses against a mutated standard and reports green. A SHA pin rewritten mid-run
37
+ means the thing being corrupted is the integrity check itself, which is why teaching a reader to
38
+ tolerate a writer was never on the table. Same probe after the change: 1,437 read cycles, zero
39
+ observations of any class. End to end, looping the reader file against ten runs of the two mutating
40
+ suites: ten of 31 reader runs failed before, zero of 30 after. Both controls were run, because a clean
41
+ after-figure is also what a dead probe prints.
42
+
43
+ **This also closes a flake the repeating-groups suite had disclosed and declined to fix**, and its
44
+ disclosure is deleted rather than reworded: the two generator suites raced each other through
45
+ `src/dictionary/generated/`, which reaches the whole test run and not just those two files, because
46
+ the shipped library imports both artifacts. It was declined then because the obvious fix was an
47
+ output-path override on a script the regen gate depends on. Relocating the tree costs the generators
48
+ no new input at all, so that trade is not the one on the table any more.
49
+
50
+ No library code changed, and no public surface moved.
51
+
52
+ - 023b22d: A diagnostic about a PHI leak is itself a PHI surface: two of the four measured instances are closed,
53
+ and the claim that tied them together is corrected on every surface that carried it.
54
+
55
+ **The three private-tag Tier-2 codes take no tag parameter at all.**
56
+ `DICOM_PRIVATE_TAG_NO_CREATOR`, `DICOM_IMPLICIT_VR_FOR_PRIVATE_TAG_WITHOUT_VR` and
57
+ `DICOM_PRIVATE_CREATOR_UNKNOWN` are built from `position` alone. Measured on a synthetic,
58
+ name-bearing payload: an `ST` carrying `"MR BRAIN SMITHSON "` whose Value Length under-declares by 12
59
+ desynchronizes the Implicit VR LE reader onto a fabricated header at an odd group, and the first two
60
+ of those codes rendered its tag as `4E495320` - `"IN S"` in wire order, four letters from inside the
61
+ name - on the same parse, from the same branch of `resolveImplicitVR`. `renderTag` validates a tag's
62
+ shape and therefore cannot refuse a fabricated one, so the bound is the **factory signature**, the
63
+ same remedy `DICOM_NONZERO_RESERVED_BYTES`, `DICOM_ITEM_CROSSES_SEQUENCE_END`,
64
+ `DICOM_DUPLICATE_TAG_IN_DATA_SET` and `DICOM_DUPLICATE_FILE_META_ELEMENT` already take. **What is
65
+ specific here is why it is a bound and not a product call:** all three fire only on an **odd** group,
66
+ and an odd group is the one class of tag no closed table this library holds can vouch for - PS3.6's
67
+ registry is even-group and a `Profile`'s private dictionary is keyed by a creator string this code
68
+ fires because it does not have. The third of the three is bound by that argument rather than by a
69
+ measurement, and is described that way. **The cost, stated:** on a well-formed file with an unclaimed
70
+ private block the tag is no longer in the message. It is still the element's key in the parsed Data
71
+ Set, and `position.byteOffset` locates the header.
72
+
73
+ **`report.embeddedAttributes[].hidden` carries only the tags the run acted on.** The embedded scanner
74
+ listed **every** tag in a run it found inside a kept carrier's Value Field, and a run needs only one
75
+ actionable attribute to be reported - so a fabricated header sitting beside a real one was listed
76
+ too. Measured: a `CS` carrier over-declaring across a fabricated `"SMIT"` header beside a genuine
77
+ `(0010,0020)` reported `hidden: ["4D535449", "00100020"]`. An entry is now one of the **652 literal
78
+ rows** of PS3.15 Table E.1-1 that this run's options left actionable, and **two drafts of that filter
79
+ were refuted before the sentence was true**. Filtering on the resolved Annex E action alone admits
80
+ every odd group, because the Basic Profile removes private attributes as a class. Adding "and an even
81
+ group" still admits every **repeating-group mask hit**: `annexE()` falls through to the family rows,
82
+ and `(50xx,xxxx)` Curve Data leaves the whole 16-bit element number free - 16 groups x 65,536
83
+ elements against 652 literal rows - so `"\fPAR"` composes `500C5241` and returns all four payload
84
+ bytes with one typed read. A mask match proves a rule exists; it does not make the membership finite.
85
+ The shipped test is "the tag has a literal row", which subsumes the odd-group case, since no literal
86
+ row is odd-group. **Two consequences that travel with the field:** it can now be **empty on a real
87
+ finding** (a run whose only actionable members are private attributes, Curve Data or Overlay elements
88
+ names none of them, and the carrier is still emptied and still counted), and it is **still uncapped**.
89
+ `DICOM_DEIDENT_EMBEDDED_ATTRIBUTE_REMOVED`'s `{n}` is unchanged and still counts the whole run, so
90
+ narrowing `hidden` did not silently re-scope a shipped message; its closing sentence was reworded,
91
+ because it pointed at a field that can now be empty.
92
+
93
+ **`report.removedPrivateTags` is deliberately unchanged.** It is a private-tag field, so no table can
94
+ vouch for its entries and a bound would empty it on every well-formed file - which is what it exists
95
+ to record. That is a product call, and the earlier reading that grouped all three as one call is
96
+ retracted: the test that decides it is whether a closed table can vouch for the tag, not whether the
97
+ tag is real on a good file.
98
+
99
+ **The "safe to log" claim is corrected, and a fifth instance was found correcting it.** Sweeping every
100
+ under-declare delta on both transfer syntaxes for every 4-byte window of the payload rendered as a
101
+ tag, every 4-byte window rendered as a `readUInt32LE` decimal and every 2-byte window rendered as a
102
+ VR, found a third leaking Tier-2 code on the base tree: **`DICOM_ODD_LENGTH_VALUE_PADDED` under
103
+ Explicit VR LE renders a fabricated tag AND a fabricated 32-bit declared length** - eight consecutive
104
+ payload bytes in one message, each reversible with one typed read. It is `PRE-EXISTING`, it is **not
105
+ closed here**, and it is why `ds.warnings[].message` is still not unconditionally safe: that code
106
+ fires on any tag, so the remedy is a membership `renderTag` plus a separate answer for the raw
107
+ length, which is a package-wide decision rather than a rider. It is pinned by an asserted row.
108
+ The claim was also attached to the wrong field: every fixture that produces the leak dies before a
109
+ `Dataset` exists, so the carrier is `onWarning` and the `{ strict: true }` `DicomParseError`, not a
110
+ surviving `ds.warnings` - and that no measured fixture put one on a surviving `ds.warnings` is stated
111
+ as a fact about those fixtures, never as a guarantee. Carriers of the old wording were found by
112
+ folding newlines rather than by a line-based search, since a sentence that wraps is invisible to one:
113
+ `README.md`, `limitations.md`, `troubleshooting.md`, `spec-notes-tolerance.md`, `cookbook.md` and the
114
+ JSDoc on `EmbeddedAttributeFinding`, `DeidentifyReport` and the three factories.
115
+
116
+ **Consumer-visible:** four warning message strings are reworded (no code, no `position` and no
117
+ `err.code` changes), and `embeddedAttributes[].hidden` may be shorter or empty on a file where it was
118
+ populated before. A consumer that string-matched those messages or read `hidden` as the whole run
119
+ should read the code and the warning's count instead.
120
+
121
+ - 3f41849: Docs, cookbook and examples: the roadmap's final phase, plus the gates that keep them honest.
122
+
123
+ **A cookbook that covers the jobs a metadata parser is actually handed.** Four new recipes, every one
124
+ executable in CI: extract metadata and index a folder of studies, build routing keys (hierarchy UIDs,
125
+ Accession Number, Patient ID paired with its issuer), read pixel-interpretation metadata safely, and
126
+ bridge to FHIR `ImagingStudy` and HL7 v2. The FHIR recipe works from the `ImagingStudy` "Mappings for
127
+ DICOM" tab and says which FHIR that is: the URL is the continuous build, not a balloted release, so
128
+ the recipe tells you to pin the mappings page for the FHIR version your integration targets.
129
+
130
+ **A clause citation written with its part beside it is checked against the SHA-pinned normative
131
+ documents, and the gate's coverage is stated rather than rounded up.** A new gate re-hashes
132
+ the vendored PS3.5, PS3.6 and PS3.15 2026c DocBook sources as a precondition, then runs two checks of
133
+ different strength. A clause of a vendored **prose** part (PS3.5, PS3.15) written with its label next
134
+ to its part (`PS3.N §X`, `PS3.N section X`, `PS3.N Annex X`) is resolved by collecting **every**
135
+ candidate section carrying that label and requiring exactly one, so zero and two are both refusals and
136
+ a first-match read cannot take the table of contents. **The gate's coverage is stated rather than
137
+ rounded up to "every citation": a label the text writes away from its part**, a second label in a list
138
+ or a bare `section X` whose part was named a sentence earlier, **is not seen**, and the cookbook says
139
+ so where a reader will meet it. Each clause the text leans
140
+ on for a normative statement is additionally required to carry that sentence **in its own body, not in
141
+ a subsection** (a body that swallowed subsections would certify `§7.5` for a `§7.5.2` fact and `§E.1`
142
+ for an `§E.1.1` one, which are the two confusions this package has already paid for; both are pinned
143
+ as controls). PS3.6 is cited for attribute identity rather than for prose, and is checked by its own
144
+ case: every `(gggg,eeee) Some Name` pair written anywhere in the docs must be the registry's own name
145
+ for that tag. **A numbered clause of a part this repository does not vendor may no longer be cited at
146
+ all**: two such citations were in the README and are replaced by prose that names the part and says
147
+ what is and is not claimed.
148
+
149
+ **`@example` on every public export is a gate rather than a convention.** Two exports were missing one
150
+ and now have it. The checker walks the public barrel through the compiler, so a namespace export and a
151
+ type count the same as a function does, and it carries a mutation control that proves it can go red.
152
+
153
+ **The PHI scanner reads doc fixtures.** The documentation ships DICOM objects as base64-encoded Part 10
154
+ buffers inline in markdown, and until now the scanner never opened one: to a text sweep a base64 run is
155
+ a single alphanumeric token with no `FAMILY^GIVEN` and no `YYYYMMDD` in it. `README.md` and
156
+ `docs-content/**` are now a second corpus, embedded objects are decoded and walked as DICOM, and both
157
+ the preamble-bearing and the preamble-less shape are recognized, the latter being what the cookbook
158
+ ships to demonstrate `DICOM_MISSING_PREAMBLE`. **The run floor is not the filter, and a first draft
159
+ that treated it as one shipped blind**: it required 120 base64 characters on the reasoning that a Part
160
+ 10 object is big, and the cookbook's preamble-less fixture encodes to 88, so the one file the route's
161
+ own comments named as its reason was the one file it never opened while every test still passed. The
162
+ floor is now the shortest run that could encode a single Data Element header, the decode does the
163
+ filtering, and a regression case takes the **shortest real object out of the shipped cookbook** rather
164
+ than building its own. **It found real content the moment it ran**: every
165
+ sample object on the site carried a Study Date inside the 120-year window. Each is now `19000101`, and
166
+ the docs say why rather than leaving it as an unexplained oddity.
167
+
168
+ **A prominent "do not over-trust" page.** Known limitations moves out of the end of a long
169
+ troubleshooting page and into its own entry near the top of the navigation, linked from the README's
170
+ opening and from Getting started. It is an index rather than a second copy: scope non-goals, the open
171
+ PHI residuals (the retain-route leak whose extent is a matrix, the over-long `LO` in `(0012,0063)` that
172
+ this library is the likeliest writer of, `contextPath` being inert and not safe to log), the structural
173
+ facts no reader can resolve, and what is deliberately never defaulted.
174
+
175
+ **Two owed corrections.** The README described a Tier-2 warning message as "never composed from the
176
+ document", and `ParseOptions.strict`'s JSDoc called one "safe to log whole". Neither is true without a
177
+ qualifier: the registry's tag slot is filled by a shape check, which cannot refuse a tag a lying Value
178
+ Length composed out of somebody's value. Both now say safe on a well-formed file and not
179
+ unconditionally safe, which is what the measurement supports.
180
+
181
+ ## 0.0.13
182
+
183
+ ### Patch Changes
184
+
185
+ - b7a77fe: Build every Tier-3 fatal message from a frozen registry, and cut the `{ strict: true }` snippet in
186
+ the frame its offset names (`DICOM-FATAL-MESSAGE-REGISTRY`).
187
+
188
+ Tier-2 warnings have been registry-bound for several releases; Tier-3 messages were still assembled
189
+ at the throw site out of template literals, and four of them printed four bytes of the document each.
190
+ The messages that interpolated most were the ones raised **when a length field is lying**, which is
191
+ the condition that makes a reader read bytes inside somebody's value as a Data Element header, so the
192
+ tag and the length they printed were that value. Measured on a synthetic `"MR BRAIN SMITHSON "`:
193
+ `Element 41524E49 declared length=1330858068` is `"RAIN"` then `"THSO"`, eight consecutive payload
194
+ bytes, each recoverable with one typed read.
195
+
196
+ The bound is the factory signature, matching the three Tier-2 codes that paid for this lesson before
197
+ it: the token type has **no tag field and no wire-length field**, so there is no slot for one to
198
+ travel through, and `err.byteOffset` identifies the element instead. A VR still renders when it names
199
+ one of the 34; a byte count still renders when it is bounded by the buffer being read.
200
+
201
+ Separately, `DicomParseError.snippet` is 16 raw source bytes cut at the diagnostic's own
202
+ `byteOffset`. That offset moves with the frame the element was read in, but the cut was always taken
203
+ from the whole file, so a strict-mode escalation raised inside a defined-length Sequence Item cut the
204
+ file at an item-relative number and returned **an unrelated element's** bytes. The parse context's
205
+ buffer now follows the frame at all four places this parser changes one. **The snippet is still
206
+ unredacted source bytes and is still PHI.**
207
+
208
+ **⚠ Some fatal messages are reworded, so a consumer string-matching one stops matching.** No count is
209
+ given, deliberately: a first draft said "six", a graded pass measured nine, and the honest remedy for
210
+ a count corrected once is to delete it rather than increment it. Diff `FATAL_MESSAGES` against the
211
+ previous release's template literals if you need the set. **`err.code` is unchanged on every path and
212
+ which files throw is unchanged.** Narrow on the code, never on the prose.
213
+
214
+ Three residuals are named rather than closed, each with an asserted test row: the same fabricated
215
+ header still reaches the Tier-2 `DICOM_PRIVATE_TAG_NO_CREATOR` message;
216
+ `report.embeddedAttributes[].hidden` still lists a fabricated tag alongside the real one that made
217
+ its run reportable; and because of the first, `ds.warnings[].message` is **not** unconditionally safe
218
+ to log, which the docs now say. Narrowing any of the three is a product call, not a fix.
219
+
220
+ - 3617034: 🩺 `DeidentifyReport`'s `contextPath` was documented as structural and it is not: a segment is
221
+ `TAG[index]` and the tag half is read off the wire, bound by neither a shape test nor a closed table.
222
+ A file whose under-declared Value Length desynchronizes the reader onto four bytes inside somebody's
223
+ value, followed by `SQ`, gets that fabricated sequence descended and its fabricated tag published in
224
+ every `contextPath` beneath it. Measured on a synthetic `LO` carrier holding `"MRS BRAIN SMITHSON"`:
225
+ `contextPath: ["53484E4F[0]"]`, which is `"HSON"` in wire order, with no warning raised and every
226
+ finding array empty. **Redacting it is a logging fix and not an object fix**: on that same file the
227
+ de-identified object still carries the fabricated `(5348,4E4F)` and the serializer writes `"HSON"`
228
+ back out under a `Patient Identity Removed = YES` stamp, which is the already-disclosed
229
+ under-declared carrier class and not this field. The claim is corrected in the type, the tolerance table and the
230
+ troubleshooting guide, and `contextPath` is added to the report's list of fields that are not
231
+ value-free; no guard was widened, because withholding the tag would destroy the audit on every
232
+ well-formed file to bound a malformed one. `PRE-EXISTING`; no runtime behaviour changes. **Treat
233
+ `contextPath` as PHI when the source is untrusted.**
234
+
235
+ The `DICOM_ITEM_CROSSES_SEQUENCE_END` disclosure no longer says `contextPath` names "an item it was
236
+ never in" - that asserts which of two byte-identical files you have. It is deleted rather than
237
+ reworded a third time, and replaced by the two measurements that were already pinned.
238
+
239
+ - 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`).
240
+
241
+ `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.
242
+
243
+ 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".
244
+
245
+ 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.
246
+
247
+ 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`.
248
+
249
+ - 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`).
250
+
251
+ `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`.
252
+
253
+ 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.
254
+
255
+ 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.
256
+
257
+ 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.
258
+
259
+ - ad675da: The README lockup now links to cosyte.com (`ASSETS`).
260
+
261
+ The `<picture>` block above the H1 is wrapped in an anchor to https://cosyte.com, per the founder
262
+ requirement of 2026-08-06. Nothing inside the block moved: the `<source>`, the `<img>`, the alt text
263
+ and both tile URLs are byte-identical.
264
+
265
+ What the anchor does was measured on both surfaces by `fhir`, not assumed, because fourteen READMEs
266
+ carry this shape. On GitHub the anchor works and the colour-scheme switch keeps working, because the
267
+ `<img>` stays a direct child of `<picture>`, which is the condition the HTML spec puts on `<source>`
268
+ applying at all. On an npm package page the anchor is lost: npm wraps a README image in its own
269
+ anchor to the image file, a nested anchor is not representable, so the parser closes ours early and
270
+ the image ends up linked to the image file rather than to cosyte.com. Shipped anyway by founder
271
+ decision of 2026-08-07: on npm that is no worse than the unlinked lockup it replaces, and GitHub is
272
+ where these READMEs are read.
273
+
274
+ - 0a8c6e3: Correct the disclosed extent of the surviving `RetainSafePrivate` retain-route leak
275
+ (`DICOM-RETAIN-ROUTE-RESIDUALS`). Six artifacts described it as "a private carrier whose profile
276
+ entry declares a binary VR". The predicate has two conjuncts and that wording named neither: the
277
+ profile does not declare `SQ`, and the embedded-attribute scanner cannot read the value (it reads
278
+ string carriers only, and decodes tiles in the file's own encoding). The two sets are incomparable,
279
+ not nested - a profile entry declaring `LO` over a carrier the sender wrote `OB` ships the identical
280
+ nested `(0010,0010)`, while one declaring `OB` over a carrier written `LO` is emptied. The
281
+ prose enumeration is deleted rather than reworded, and a measured matrix pins the surface instead:
282
+ declared VR against encoding, and against wire VR under Explicit VR only, because Implicit VR LE
283
+ writes no VR at all. No `src/` predicate changes: the behaviour is `PRE-EXISTING` and identical
284
+ before and after. The matrix also strengthens the `DICOM-PRIVATE-SQ-PARSE-VR` closure beside it,
285
+ proving it on five distinct inputs rather than on the one cell that opened it.
286
+ - 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`.
287
+
288
+ **`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.
289
+
290
+ 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.
291
+
292
+ **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.
293
+
294
+ **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.
4
295
 
5
- ## [Unreleased]
296
+ 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.
297
+
298
+ ## Released before this file was generated
299
+
300
+ Every release section above this heading is written by
301
+ [Changesets](https://github.com/changesets/changesets) from the changesets in `.changeset/`, newest
302
+ release first. The release writes its own version heading, so nothing above this line is maintained
303
+ by hand, and a change is recorded here by adding a changeset rather than by editing this file.
304
+
305
+ Everything below this heading was maintained by hand, in the
306
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format, on the
307
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ladder this package still follows. Most
308
+ of it sat under a single `[Unreleased]` heading that no release ever rolled over, which is why ten
309
+ published versions of this package shipped a `CHANGELOG.md` describing already-released work as
310
+ unreleased, inside the tarball, for as long as they did. That heading is gone; its entries are not.
311
+
312
+ The text is left exactly as it was written rather than re-sorted into version sections. The file
313
+ never recorded which release each of those entries went out in, so assigning them now would be a
314
+ guess presented as a record, and this is the text that installed copies already carry on disk. Only
315
+ the `[Unreleased]` heading itself and the preamble above it were removed.
6
316
 
7
317
  ### Fixed
8
318
 
319
+ - **🩺 `DeidentifyReport`'s `contextPath` was documented as structural, and it is not: it can publish
320
+ four bytes of a value, silently** (`DICOM-ITEM-CROSSES-RESIDUALS`). A segment is `TAG[index]`, and
321
+ the tag half is whatever tag the descent walked, read straight off the wire, bound by neither a
322
+ shape test nor a closed table. `attributes[].tag` is bound - it is only populated for a tag Annex E
323
+ carries a row for - and that contrast is the finding. **It is not the only report identifier read
324
+ off the wire**: `removedPrivateTags` and `unauditableSequences[].tag` are as well. Those two were
325
+ already disclosed as such; this one was documented as structural. So a file whose
326
+ under-declared Value Length desynchronizes the reader onto four bytes sitting **inside** somebody's
327
+ value, where those four bytes are followed by `SQ`, gets that fabricated sequence descended and its
328
+ fabricated tag published in the `contextPath` of everything beneath it.
329
+
330
+ Measured on a synthetic `LO` carrier holding `"MRS BRAIN SMITHSON"` that under-declares by four:
331
+ the report reads `contextPath: ["53484E4F[0]"]`, which is `"HSON"` in wire order, recovered by
332
+ writing the two halves back with `writeUInt16LE`. Change the surname to `"DAVIDSON"` and the
333
+ published segment changes with it. **No warning is raised and every finding array on the report is
334
+ empty**, so a consumer following the old guidance had no signal at all. A conformant file's segment
335
+ is the tag the sender wrote, which is why the field is still published.
336
+
337
+ **🛑 REDACTING `contextPath` IS A LOGGING FIX AND NOT AN OBJECT FIX, AND A GRADED PASS REFUTED THE
338
+ FIRST DRAFT FOR SAYING OTHERWISE.** That draft claimed in six places that this field was "the only
339
+ trace of that header in the entire output". It is not: the de-identified `Dataset` still carries
340
+ the fabricated `(5348,4E4F)`, so `serializeDicom` writes its header back out in full, `"HSON"`
341
+ included, under `(0012,0062) Patient Identity Removed = YES` - and the re-emitted bytes track the
342
+ surname exactly as the log field does. That re-emission is the already-disclosed under-declared
343
+ carrier class, not this field's doing, and **neither one is a bound on the other.** Both rows are
344
+ now pinned.
345
+
346
+ **The claim was corrected and no guard was widened**, on the same footing as `removedPrivateTags`:
347
+ _where_ an attribute sat is the whole audit value of the field, and withholding it would destroy
348
+ that on every well-formed file in order to bound a malformed one. **`contextPath` is now named on
349
+ the report's list of fields that are not value-free** - a list whose numerals were deleted rather
350
+ than incremented, per this package's own rule - and corrected in the type, the tolerance table and
351
+ the troubleshooting guide, which all called it structural. **Treat it as PHI when the source is
352
+ untrusted.** `PRE-EXISTING` on every release that has shipped the field; no runtime behaviour
353
+ changes.
354
+
355
+ The shared PHI runner has swept this field from the start and could never have gone red on it: it
356
+ hunts a **verbatim** marker and a tag is a re-encoding, the same blind spot already documented for
357
+ `DicomParseError.snippet`. The pin is a purpose-built measurement with a name-bearing payload, a
358
+ mutation control and a conformant negative control.
359
+
360
+ - **The `DICOM_ITEM_CROSSES_SEQUENCE_END` disclosure no longer claims `contextPath` names "an item it
361
+ was never in".** That sentence asserts which of two byte-identical files you have, which is the one
362
+ thing this package has repeatedly measured the wire as not carrying: the partner file, built from
363
+ the opposite intention, is the same bytes with the element genuinely inside the item, where the
364
+ identical `contextPath` is right. It had already been narrowed once in the troubleshooting guide
365
+ and left standing everywhere else; this package's rule for a disclosure reworded twice is to delete
366
+ it, so it is deleted rather than given a third wording. **What replaces it is the pair of
367
+ measurements that were already there** - one shape puts the element in the item with a
368
+ `contextPath`, the other leaves it at the root with none - **and neither is labelled the mistake.**
369
+ No reading, no warning and no report field moved.
370
+
371
+ - **🩺 Tier-3 fatal messages are built from a frozen registry, and four of them were printing four
372
+ bytes of the document each** (`DICOM-FATAL-MESSAGE-REGISTRY`). Tier-2 warnings have been
373
+ registry-bound since the warning-message slice; Tier-3 messages were still assembled at the throw
374
+ site out of template literals. That reads as harmless and is not, because the messages that
375
+ interpolated most were the ones raised **when a length field is lying** - the condition that makes
376
+ a reader read bytes inside somebody's value as a Data Element header, so the tag and the length it
377
+ then prints are that value.
378
+
379
+ Measured on a synthetic `"MR BRAIN SMITHSON "` carried in an `ST` whose Value Length under-declares:
380
+ `Element 41524E49 declared length=1330858068` is `"RAIN"` then `"THSO"`, eight consecutive payload
381
+ bytes in two fields, each recoverable with one `readUInt16LE` / `readUInt32LE`. Also
382
+ `Unexpected tag 524D4220 inside sequence` and `Item length=1109414477`, both `"MR B"`, and
383
+ `Unexpected FFFE marker FFFEE00D (length=1109414477)`, the same four bytes again.
384
+
385
+ **The bound is the factory signature, exactly as it was for the three Tier-2 codes that paid for
386
+ this lesson before it.** The new `FatalTokens` type has **no tag field and no wire-length field**,
387
+ so there is no slot for one to travel through, and a shape check would not have helped: a tag has
388
+ only a shape, so a renderer for one cannot refuse a fabricated tag. `err.byteOffset` identifies the
389
+ element instead, and it is a count the parser kept. What a fatal message may still carry is named
390
+ one registry entry at a time: a VR checked against the closed 34-VR set, a residual byte count
391
+ bounded by the buffer being read, a library constant, PS3.6's own registry name for an unsupported
392
+ Transfer Syntax UID, and a zlib error code checked against zlib's nine-name table.
393
+
394
+ **⚠ SOME FATAL MESSAGES ARE REWORDED, SO A CONSUMER STRING-MATCHING ONE STOPS MATCHING. NO COUNT IS
395
+ GIVEN, AND THAT IS DELIBERATE** - the first draft of this notice said "six", a graded pass measured
396
+ nine, and this repo's rule is to **delete a count rather than increment it**. The set is derivable
397
+ in one command and can never go stale: diff `FATAL_MESSAGES` in `src/parser/fatals.ts` against the
398
+ template literals in `git show 0a8c6e3 -- src/parser/`. **`err.code` is unchanged on every path,
399
+ and which files throw is unchanged.** Narrow on the code, never on the prose.
400
+
401
+ - **🩺 The `{ strict: true }` snippet returned an unrelated element's bytes inside a defined-length
402
+ Sequence or Item.** `PRE-EXISTING`, found and disclosed by the `(0012,0063)` slice rather than
403
+ fixed, and closed here. `DicomParseError.snippet` is 16 raw source bytes cut at the diagnostic's own
404
+ `byteOffset`; that offset moves with the frame the element was read in, but the cut was always taken
405
+ from the whole file. So an escalation raised inside a defined-length Item cut the **file** at an
406
+ **item-relative** number and handed back whatever sat there - a diagnostic disclosing part of an
407
+ element the reader was never asked about, which in a de-identification library is a PHI surface
408
+ rather than a cosmetic offset bug. The parse context's buffer now follows the frame at all four
409
+ places this parser changes one; the deflate boundary already did, and the three sequence boundaries
410
+ did not.
411
+
412
+ **This does not make `snippet` safe to log, and nothing here should be read that way.** It is still
413
+ 16 unredacted source bytes (the documented design), and making the frame honest makes them more
414
+ certainly the named element's own content, not less.
415
+
416
+ **Three residuals are named rather than closed, each with an asserted row in
417
+ `test/integration/fatal-diagnostic-surface.test.ts` so no artifact can read this entry as an
418
+ all-clear.** (1) The identical fabricated-header shape still reaches a **Tier-2** message: the same
419
+ desynchronized read lands on an odd group, so `DICOM_PRIVATE_TAG_NO_CREATOR` names the fabricated
420
+ tag (`4E495320`, `"IN S"`), through the shape-checking `renderTag` that cannot refuse one. (2)
421
+ `report.embeddedAttributes[].hidden` lists every tag in a run the embedded scanner found inside a
422
+ kept value, and a run needs only **one** actionable attribute to be reported, so a fabricated header
423
+ beside a real one is listed too: measured at `4D535449`, `"SMIT"` in wire order, beside the genuine
424
+ `00100020`. (3) Because of (1), **`ds.warnings[].message` is not unconditionally safe to log**, and
425
+ the docs that said so are corrected in this release rather than the guard being widened. All three
426
+ are the same product call as the one already filed for `report.removedPrivateTags`: on a well-formed
427
+ file these are the real tags of real attributes, and withholding them would destroy the field's
428
+ audit value to close a shape only a crafted file produces.
429
+
430
+ - **🩺 The surviving PHI leak on the `RetainSafePrivate` retain route was disclosed as the WRONG SET,
431
+ in six artifacts at once** (`DICOM-RETAIN-ROUTE-RESIDUALS`). `PRE-EXISTING` and unchanged in
432
+ behaviour: **no `src/` predicate moves in this release entry**, and every cell described below
433
+ leaks identically before and after it. What changed is the claim.
434
+
435
+ Every artifact that described what the private-`SQ` closure leaves open said it was "a private
436
+ carrier whose **profile entry declares a binary VR** (`OB`/`OW`/`UN`)". That is not the predicate,
437
+ and the true set is **neither narrower nor wider than it - the two are incomparable**. A profile
438
+ entry declaring `LO` over a carrier the sender wrote `OB` leaks; a profile entry declaring `OB`
439
+ over a carrier the sender wrote `LO` does not. **There are two conjuncts and the deleted wording
440
+ named neither.** `keepRetainedPrivate` asks one question - does the `Profile` declare this
441
+ attribute `SQ`? - and every other answer falls through to the ordinary keep path, where the only
442
+ thing between the value and de-identified output is the embedded-attribute scanner. That scanner
443
+ reads **string carriers only**, and it decodes candidate Data Element headers in the **file's own
444
+ encoding**. So the value survives exactly when the profile does not say `SQ` **and** the scanner
445
+ cannot read it: a string carrier holding Explicit-VR-shaped bytes inside an Implicit VR LE file is
446
+ in the set for the same reason a binary carrier is. In every such case the carrier reaches output
447
+ **byte-identical to the file's own value**, stamped `(0012,0062) Patient Identity Removed = YES`,
448
+ with `report.unauditableSequences` and `ds.warnings` both empty, because none of these files is
449
+ malformed: the Value Length is honest and the value is a well-formed `(FFFE,E000)` item stream.
450
+
451
+ **The enumeration is deleted rather than reworded a third time, and a measured matrix replaces it.**
452
+ `test/integration/deident-private-reservation.test.ts` now sweeps the profile's declared VR against
453
+ the encoding and, under Explicit VR only, the wire VR, with a name-bearing payload, and asserts the
454
+ **emptied** set exactly - so the leaking set is whatever is left and cannot go stale in prose. It
455
+ pins the leaking cells as kept verbatim and silent rather than inferring it from a substring
456
+ search. **There is deliberately no wire-VR axis under Implicit VR LE**, which writes no VR at all
457
+ (PS3.5 2026c §7.1.3); crossing one in measures a single file four times. The same sweep strengthens
458
+ the closure it sits beside: a profile-declared `SQ` is emptied to zero bytes and named on all four
459
+ Explicit wire VRs and on the VR-less Implicit encoding, where the case it replaced measured one
460
+ `OB`/Explicit cell. Non-vacuity is by fixture and by mutation: the payload carries a real name,
461
+ every Explicit cell really does present a distinct VR to the parser, and disabling the closure's
462
+ branch reds the matrix.
463
+
464
+ **Two remedies exist and both are product calls, so neither is taken here.** A content test on
465
+ binary carriers is already priced in `src/deident/embedded.ts` at 11 grid cells to 0 while emptying
466
+ all 5 conformant binary tiling controls (`DICOM-DEIDENT-OVER-REDACTION`, open). Refusing retention
467
+ wherever the profile's declared VR and the parse tree disagree needs no content test, but a `UN`
468
+ carrier whose profile declares `LO` is the most ordinary real-world private element there is - an
469
+ intermediary without the private dictionary rewrote the VR, PS3.5 2026c §6.2.2 - so it would drop
470
+ vendor values out of a large share of conformant files. Both are recorded as evidence, neither is
471
+ recommended, and the call is not made here.
472
+
473
+ - **🩺 A private carrier a `Profile` declared `SQ` was still written into de-identified output
474
+ verbatim whenever the parse tree resolved it to anything else** (`DICOM-PRIVATE-SQ-PARSE-VR`).
475
+ `PRE-EXISTING`, live through the published `0.0.10` and through the carve-out below, which closed
476
+ only what the parse tree happened to resolve. `keepRetainedPrivate` branched on `el.vr === "SQ"`,
477
+ and the parse tree and the profile disagree about the same bytes in **two ordinary, conformant
478
+ situations**: under **Implicit VR LE a private tag carries no VR on the wire** (PS3.5 2026c
479
+ §7.1.3), so `SQ` there is an inference the parser draws from a `Profile` _it_ was given and a
480
+ profile passed only to `deidentify()` leaves the element `UN`; and under **Explicit VR the wire's
481
+ VR wins in the parser**, so a sender who writes a profile-declared `SQ` attribute as `OB` or `UN`
482
+ yields that instead - with an honest defined length wrapping a well-formed `(FFFE,E000)` item
483
+ stream. Both were measured shipping a `(0010,0010)` Patient's Name into output stamped
484
+ `(0012,0062) Patient Identity Removed = YES` with an empty `report.unauditableSequences`.
485
+
486
+ **The remedy adds a second authority, not a content test.** The `Profile` that vouched for the
487
+ element also declares its VR, and that declaration is what `RetainSafePrivate` is trusting in the
488
+ first place. A retained private element the profile declares `SQ` whose parse tree carries no
489
+ items is now emptied through the same channel a parsed `SQ` with no items already used -
490
+ `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` plus a `report.unauditableSequences` entry - **keeping the
491
+ VR the file actually carried** rather than re-typing the element to `SQ`. Nothing inspects the
492
+ value's bytes, `keepsPrivate` and the retention decision are unchanged, no parser file is touched,
493
+ and there is no new public surface. `UnauditableSequenceFinding`'s documentation now names both
494
+ producers, because the field is no longer only about a parsed `SQ`.
495
+
496
+ **🛑 One of the two shapes is silent on `ds.warnings`.** The Implicit VR LE file raises
497
+ `DICOM_IMPLICIT_VR_FOR_PRIVATE_TAG_WITHOUT_VR`; the Explicit VR `OB` file is fully conformant and
498
+ raises nothing at all. `report.unauditableSequences`, not `ds.warnings`, is this class's channel.
499
+
500
+ **The cost, stated here rather than discovered later.** A caller who passes a profile to
501
+ `deidentify()` but not to `parseDicom` now **loses** that vendor sequence's content instead of
502
+ shipping it unexamined. **Pass the same profile to `parseDicom`** and the sequence is walked, its
503
+ Table E.1-1 attributes de-identified and the rest retained.
504
+
505
+ **It reaches the CP-246 `UN` too, wherever a profile named it.** The test does not look at the
506
+ length **field**, so an undefined-length `UN` whose CP-246 descent this parser refused is emptied
507
+ when a `Profile` declares that private attribute `SQ`. The undefined-length `UN` residual is a
508
+ statement about elements **no profile named**, and survives only there. (Read that as stated: the
509
+ predicate does have a length conjunct, `el.length > 0`, and a refused CP-246 descent carries
510
+ `0xFFFFFFFF`, so it passes.)
511
+
512
+ **Two properties of the module are kept by conjuncts ahead of that test, and each is pinned by its
513
+ own test.** An element whose on-wire VR is not one of the 34 is still answered by the tag-free
514
+ `DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` route. And a zero-length value is left alone, so
515
+ de-identifying an already de-identified object reports no second drop.
516
+
517
+ **🩺 The first of those is a PARTIAL bound, and it is disclosed rather than grown.** It keeps the
518
+ tag off the diagnostic only when the fabricated header's own VR bytes fall outside the 34. A
519
+ length under-declared upstream can resynchronize the reader onto four bytes that spell a genuine
520
+ private block, and if those bytes are followed by `OB` rather than something unrecognized, the
521
+ fabricated tag reaches `report.unauditableSequences` and the warning. There is nothing to key on:
522
+ a fabricated `OB` header and a genuine one are byte-identical. The direction is still a strict
523
+ improvement, because the previous behaviour kept that carrier **verbatim** and shipped the whole
524
+ nested name, and this one empties it. It joins `report.removedPrivateTags`, which can echo the
525
+ same four bytes from a fabricated odd-group header on both trees: **`DeidentifyReport` is not a
526
+ value-free surface and must not be treated as one.** Pinned as the second row of the
527
+ fabricated-header test.
528
+
529
+ **The report's value-bearing fields are now a LIST on `DeidentifyReport`, with no count anywhere.**
530
+ The count read one, then two, then three, and was wrong each time, because each correction bumped
531
+ the numeral instead of re-deriving the list. Re-deriving it found a fourth that no release had
532
+ disclosed: `embeddedAttributes[].hidden`, whose entries are composed from four bytes found
533
+ **inside** a value, so on any file that populates that field they are document content by
534
+ construction, and whose own documentation said "safe to log". That is `PRE-EXISTING` and
535
+ byte-identical on every release that has shipped the field; it is **disclosed** here and on the
536
+ type, and narrowing or capping it is its own change.
537
+
538
+ `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE`'s message no longer reads "is VR=SQ". It is shared by both
539
+ producers, and the second one's premise is that the parse tree and the profile disagree about the
540
+ VR, so naming one stated a fact the file contradicts.
541
+
542
+ **Deliberately not closed**, and see the entry above for why this paragraph no longer enumerates
543
+ the surviving shape: everything the profile does **not** declare `SQ`, over a value that happens
544
+ to be a well-formed item stream, is still kept verbatim. Closing any of it needs a content test on
545
+ exactly the VRs arbitrary bytes are for. That is the same reasoning
546
+ `DICOM-BINARY-CARRIER-OVERDECLARE` was accepted on, but it is **a different route** - that
547
+ decision priced a measured over-declare swallow, and this is an honest length reached through
548
+ `RetainSafePrivate`. It remains open and undecided, and is pinned as a measured matrix rather than
549
+ described in prose.
550
+
551
+ Against base `src/` at `369abbe`, replaced wholesale: **5 of 1,081** tests run red over the full
552
+ suite; suite 1074 to 1080 passing plus the 1 `todo`. Two of the seven new or rewritten tests are
553
+ green on both trees **by design**, because they are controls: that a retained private element the
554
+ profile declares `LO` is still kept **verbatim**, and that a fabricated header keeps the tag-free
555
+ diagnostic (base already withheld it). Every conjunct's non-vacuity was proven by mutation rather
556
+ than asserted. `scripts/measure-sq-bound-grid.ts` was **not** re-run and holds no
557
+ private-`SQ` cell in any family; this remedy is reachable only from inside `keepRetainedPrivate`.
558
+
559
+ - **🩺 A private `SQ` a `Profile` vouched for under `RetainSafePrivate` was written into
560
+ de-identified output verbatim, so nothing inside it was ever examined for PHI**
561
+ (`DICOM-PRIVATE-SQ-CARVE-OUT`). `PRE-EXISTING`, live through the published `0.0.10`, found by
562
+ `#66`'s `conformance-refuter`. `keepsPrivate` decides retention before the descent, and that part
563
+ was never wrong; what was wrong is that a "yes, retain" routed the element to `keepOrEmpty`, **the
564
+ only path in the module that writes a source value into output unchanged**. So a vendor sequence
565
+ the profile named was blitted whole: Table E.1-1 attributes the vendor encoded in its items, UIDs
566
+ inside it, and any private element the file's own length fields pulled into it all survived, with
567
+ `report.removedPrivateTags` reading `[]` and the object stamped `(0012,0062) Patient Identity
568
+ Removed = YES`.
569
+
570
+ **The sharper half needs no malformed file at all.** On a fully conformant file, with `ds.warnings`
571
+ empty, a `(0010,0010)` Patient's Name written inside a vouched-for private `SQ` was copied straight
572
+ through. That case is now pinned with a name-bearing payload and non-vacuity assertions, alongside
573
+ the absorb shape the original residual held.
574
+
575
+ **What bounds the profile's licence is the spec, not a judgement call here.** PS3.15 2026c §E.3.10
576
+ retains "Private Attributes that are known by the de-identifier to be safe from identity leakage".
577
+ A profile entry is knowledge about **one Private Attribute**. It is not knowledge about a Data Set
578
+ nested inside that attribute's value, and PS3.5 2026c §7.5.1 makes an Item Value exactly that ("a
579
+ DICOM Data Set composed of Data Elements"). PS3.15 2026c §E.1.1 then obliges an implementation
580
+ claiming the Basic Profile to protect Table E.1-1 attributes "whether contained in the top level
581
+ Data Set or embedded in an Item of a Sequence of Items", and a private carrier is one of those
582
+ Sequences. A vendor cannot vouch for `(0010,0010)`; it is not a Private Attribute.
583
+
584
+ **The remedy widens no guard.** `keepsPrivate` still decides retention and the profile is still the
585
+ only vouching authority. A vouched-for private `SQ` is simply routed into the two branches every
586
+ other `SQ` in the module already takes: `descendSequence` when its items exist,
587
+ `emptyUnauditableSequence` (with `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` and
588
+ `report.unauditableSequences`) when the parser never materialized them. A non-`SQ` private element
589
+ is untouched. **No new public surface**: no Tier-2 code, no report field, no snapshot change.
590
+
591
+ **The price, measured and pinned.** Walking the carrier means PS3.5 2026c §7.8.1's per-Data-Set
592
+ reservation scope applies inside it, and Items do not inherit the enclosing Data Set's
593
+ reservations. A vendor who nests a private element in a private `SQ` and reserves its block only at
594
+ the **root** loses it, named on `report.removedPrivateTags` rather than dropped silently. A vendor
595
+ who writes the Private Creator **inside the Item**, as §7.8.1 requires, keeps it. Both rows are
596
+ pinned as a pair.
597
+
598
+ **No reading changes, and the grid cannot see this remedy.** `scripts/measure-sq-bound-grid.ts`
599
+ builds its `priv|` family's private data element as `LO` behind a public `(0008,1115)` carrier, so
600
+ it holds **no private-`SQ` cell**. Against base `495c9fc` over **83,037 cells**: 0 cells differing
601
+ in any parse respect, 0 whose reading differs, 0 changed, 0 PHI regressions, 0 de-identified
602
+ outputs lost a marker, and every `priv:` counter identical. Read that as evidence of blast radius,
603
+ never as a safety measurement. The regression net is the unit tests: against base `src/` at
604
+ `495c9fc`, **5 of the 57** tests across
605
+ `test/integration/deident-private-reservation.test.ts` and
606
+ `test/integration/deident-unauditable-sequence.test.ts` run red: the **4** that assert this
607
+ closure, plus the control row of the `DICOM-PRIVATE-SQ-PARSE-VR` residual, which asserts the same
608
+ closure on a file the parser did resolve. The residual's own leaking row is green on base by
609
+ design. Full suite 1071 to 1074 passing. The two residuals that asserted the leaking behaviour
610
+ were rewritten to assert the closure, which is what those pins existed for.
611
+
612
+ **The bound below is CLOSED by the entry above (`DICOM-PRIVATE-SQ-PARSE-VR`), which ships in this
613
+ same release; it is kept because it is why that remedy needed a second authority.** The branch
614
+ keyed on the PARSED VR, not on the VR the profile declares. Under Implicit VR LE a private tag carries no
615
+ VR on the wire, so `SQ` there is an inference the parser draws from a `Profile` it was given. Pass
616
+ the profile to `parseDicom` and the element arrives as an `SQ` with items and is walked; pass it
617
+ only to `deidentify()` and the identical bytes arrive as `UN` with no items, take the non-`SQ`
618
+ branch, and are kept verbatim as before, under `(0012,0062) = YES`, with
619
+ `DICOM_IMPLICIT_VR_FOR_PRIVATE_TAG_WITHOUT_VR` the only signal. That is
620
+ `DICOM-PRIVATE-SQ-PARSE-VR`, `PRE-EXISTING`, its own item and pinned as a residual test. It is
621
+ **not** the undefined-length `UN` residual below: that carrier's length is defined, so CP-246
622
+ never runs. **Pass your profile to `parseDicom` as well as to `deidentify()`.** The
623
+ `creatorsInScope` note claiming `RetainSafePrivate` "behaves identically whether the profile
624
+ arrived at parse or at de-identification" is retracted for the same reason; it was true only while
625
+ every retained private element was kept verbatim.
626
+
627
+ **Still open at the time this entry was written, and `DICOM-PRIVATE-SQ-PARSE-VR` has since been
628
+ closed by the entry above:** the undefined-length `UN`
629
+ whose CP-246 descent was refused
630
+ (it keeps `vr === "UN"`, so the rule cannot reach it without emptying every unknown-VR element in
631
+ every file); and the 11 leaf-carrier cells of `DICOM-BINARY-CARRIER-OVERDECLARE` (11 to 11 on the
632
+ grid, with the conformant tiling-control counter unmoved at 7 to 7).
633
+
9
634
  - **🩺 The `(0012,0063)` De-identification Method this library writes for itself exceeded the 64-character
10
635
  maximum PS3.5 gives an `LO` Value, on every file it ever de-identified**
11
636
  (`DICOM-LO-LENGTH-AND-SILENT-REPLACE`). `PRE-EXISTING`; measured through the built package on
@@ -801,7 +1426,10 @@ Removed = YES` with `report.warnings` empty **and** `report.retained` `[]` - an
801
1426
  followed by a root `(0010,0020)` Patient ID that is 18 bytes on the wire (an 8-byte Explicit VR
802
1427
  short-form header plus a 9-character value padded to 10). The Patient ID is **absent from the root**
803
1428
  and present instead as an attribute of the item; the `DeidentifyReport` names it with a
804
- `contextPath` pointing at a sequence item it was never in. It is swallowed once and relocated, not
1429
+ `contextPath` pointing at that sequence item. (This entry said "pointing at a sequence item it was
1430
+ never in" until `DICOM-ITEM-CROSSES-RESIDUALS` deleted that wording: it asserts which of two
1431
+ byte-identical files you have. See the entry at the top of this file.) It is swallowed once and
1432
+ relocated, not
805
1433
  read twice: the parser resumes where the descent actually ended. The parse was silent about all of
806
1434
  it, including under `{ strict: true }`, and that silence is what is fixed. The mis-structure itself
807
1435
  is **not repaired here** and is pinned as a residual by a test.