@cosyte/dicom 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,344 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.16
4
+
5
+ ### Patch Changes
6
+
7
+ - ce2975a: Fold the `Known limitations` sidebar category into `Troubleshooting`, so the shipped
8
+ `docs-content/sidebars.json` conforms to the documentation IA spine (`DOCS-STALE-BEHIND-IA040`)
9
+
10
+ No source, no public API and no parse behaviour changes. `docs-content/limitations.md` is not
11
+ renamed and not moved, and the page keeps its URL, so every existing link to it still resolves. One
12
+ sentence of its prose changes, for the reason given under THE PAGE NO LONGER DESCRIBES ITS OWN
13
+ POSITION below.
14
+
15
+ WHAT SHIPS. The top level of `docs-content/sidebars.json` goes from
16
+
17
+ intro, Installation, Quickstart, [Known limitations], Core Concepts, Guides, Troubleshooting
18
+
19
+ to
20
+
21
+ intro, Installation, Quickstart, Core Concepts, Guides, Troubleshooting[troubleshooting, limitations]
22
+
23
+ `Known limitations` was never on the canonical spine, which is
24
+ `[Overview, Installation, Quickstart, Core Concepts, Guides, API Reference, Troubleshooting]`.
25
+ The spine's own definition of Troubleshooting is "common error symptoms plus how to debug, plus
26
+ Known Limitations if the package surfaces them", so this is the label's canonical home rather than a
27
+ workaround for the lint. The two docs now sit under one heading in the order a reader meets them:
28
+ `troubleshooting` (what went wrong) then `limitations` (what will never work).
29
+
30
+ WHY IT WAS URGENT. `docs.cosyte.com` builds the released corpus and lints each package's SHIPPED
31
+ sidebar against that spine, in strict mode, where a non-canonical top-level label is a hard error.
32
+ A finding inside an ARCHIVED release is downgraded to non-gating info, because immutable bytes have
33
+ no remedy diff. The same finding against the CURRENT release gates, because the remedy is exactly
34
+ this: cut the next release. So the label sat dormant through `v0.0.14` and became blocking the
35
+ moment `v0.0.15` was published and became current. It was not a new defect, and it stopped the whole
36
+ site rebuilding, not just this package's pages.
37
+
38
+ WHICH MEANS THE FIX ONLY LANDS ON A RELEASE. `docs` reads `docs-content/` out of the release
39
+ artifact (`docs-content.tar.gz`), never out of `main`, so this changeset is load-bearing rather than
40
+ bookkeeping: without a new version the corrected sidebar never reaches the site.
41
+
42
+ THE CATEGORY IS NOT FORCED OPEN. The deleted category carried `"collapsed": false`, and
43
+ `Troubleshooting` deliberately keeps the Docusaurus default instead of inheriting it. That flag
44
+ existed to keep a one-item category permanently expanded so `limitations` read like a top-level
45
+ page, a need that disappears once the page lives inside a real two-item category. `Core Concepts`
46
+ keeps its `"collapsed": false` because it is the five-part reading spine of the package, not a
47
+ destination a reader navigates to on purpose.
48
+
49
+ THE PAGE NO LONGER DESCRIBES ITS OWN POSITION. `limitations.md` opened by claiming the boundary
50
+ "lives high in the navigation rather than at the end of a troubleshooting page", which this change
51
+ makes false in the one place a reader can check it: the page is now the last item of the last
52
+ category, immediately after the troubleshooting page. That sentence is rewritten to say what is
53
+ actually load-bearing, that the boundary is a page of its own rather than a closing paragraph, and
54
+ that the introduction and the cookbook link into it. Left alone it would have frozen into an
55
+ immutable release artifact and been correctable only by cutting yet another release, which is the
56
+ same failure this change exists to end.
57
+
58
+ NO `API Reference` CATEGORY IS AUTHORED HERE, and none may be. The docs site injects it at the
59
+ canonical position (immediately before Troubleshooting) from the package's generated API set;
60
+ hand-authoring it is a separate hard error in the same lint.
61
+
62
+ ## 0.0.15
63
+
64
+ ### Patch Changes
65
+
66
+ - 9273a9d: Two Tier-3 fatal messages stop rendering the bytes left in the buffer, because in a Sequence Item
67
+ that count is the Item's own declared length (`DICOM-DIAGNOSTIC-PHI-RESIDUALS`)
68
+
69
+ The ninth instance of "a diagnostic about a PHI leak is itself a PHI surface", and the first whose
70
+ shift is not a constant.
71
+
72
+ WHAT SHIPS. Through `0.0.14` `ELEMENT_LENGTH_EXCEEDS_BUFFER` and `FILE_META_GROUP_LENGTH_OVERRUNS`
73
+ each filled an `{n}` slot from `buffer.length - cursor.position`. Both slots are gone, and so are the
74
+ parameters: `elementLengthExceedsBuffer(buffer, offset)` and `fileMetaGroupLengthOverruns(buffer,
75
+ offset)` take two arguments now, so no call site can put either number back. `{n}` still exists in the
76
+ registry and is filled by exactly two entries whose number nobody on the wire chose:
77
+ `SQ_NESTING_DEPTH_EXCEEDED` renders this library's own `NESTING_DEPTH_LIMIT`, and
78
+ `INFLATED_PAYLOAD_EXCEEDS_CAP` renders the cap the caller passed in. No parse moves, no file that
79
+ threw stops throwing, `err.code` is unchanged everywhere and `err.byteOffset` still locates the
80
+ element.
81
+
82
+ BREAKING FOR STRING-MATCHERS: the two message texts change. A consumer matching on either string
83
+ stops matching. Neither code changed.
84
+
85
+ WHY A COUNT BOUNDED BY BYTES PRESENT IS STILL THE SENDER'S NUMBER. The old defence was that
86
+ `buffer.length - cursor.position` cannot exceed the input, which bounds the number's MAGNITUDE and
87
+ says nothing about its CONTENT. `parseSequence` parses a defined-length Item from a SLICE, so inside
88
+ one the buffer IS that Item and `buffer.length` IS the Item's 32-bit Value (Item) Length off its own
89
+ header. The message publishes `byteOffset` beside the count and `cursor.position` is that offset plus
90
+ the header just read, so an addition returns the declared length. A raw 32-bit length field a sender
91
+ wrote is already the class this registry refuses everywhere else, and shifting one by an amount the
92
+ reader can compute does not change its class.
93
+
94
+ MEASURED, WITH THE DECLARED LENGTH READ BACK OFF THE WIRE RATHER THAN ASSERTED AGAINST A LITERAL. A
95
+ synthetic file declaring an Item Length of 21320: the message read 21312 with the over-declaring
96
+ element first in the Item, 21288 behind one 24-byte element, and 21272 behind one 40-byte element.
97
+ THE SHIFT IS `cursor.position`, SO IT IS VARIABLE, which is why the remedy is the factory signature
98
+ rather than a filter: a filter would have to know the frame, and the factory cannot.
99
+
100
+ WHAT THE DETECTOR CAN AND CANNOT SEE HERE, AND IT IS THE HALF THAT TRANSFERS. `#92` added a
101
+ `length-less-item-header` arm to the re-encoding detector in
102
+ `test/integration/fatal-diagnostic-surface.test.ts` that subtracts exactly 8, and said on the constant
103
+ that it covers ONE offset. Pointed at this leak it behaves exactly as documented: it returns the 21312
104
+ shape and reads CLEAN on 21288 and 21272, which are the same leak on the same fixture. Both results
105
+ are pinned as rows, beside a DIRECT render of 21320 that the `length` arm does catch, so the clean
106
+ results are the arms' limit rather than a payload carrying nothing. A ZERO FROM THIS DETECTOR IS A
107
+ GAP, NOT A CLEARANCE. The arm is still not widened to a range, for the reason `#92` gave: a hunt with
108
+ nothing to hunt has no non-vacuity control, and what clears the class is the signature.
109
+
110
+ THE COST IS STATED RATHER THAN GLOSSED. `FILE_META_GROUP_LENGTH_OVERRUNS` is raised at the root and
111
+ nowhere else, where `buffer.length` is the caller's own input and the count leaked nothing. It loses
112
+ the number anyway. A bound that holds only because of where a function happens to be called from is
113
+ not a bound, and the sibling that shares the expression is raised inside a slice, so leaving one slot
114
+ open is exactly the shape `#88` measured relocating a leak onto a sibling rendering the identical
115
+ fixture. Both diagnostics are correspondingly less informative: neither says how far the read got.
116
+
117
+ NOT IN SCOPE, AND NAMED SO IT IS NOT READ AS CLOSED. `report.removedPrivateTags`,
118
+ `report.unauditableSequences[].tag`, `uidMap`, `contextPath` and the two `byteLength` fields are model
119
+ fields on a type whose own docs say it is not a value-free surface, not messages, and a bound empties
120
+ them on every well-formed file. `DicomParseError.snippet` is still 16 raw source bytes and is still
121
+ documented as PHI. `ds.warnings[].message` is still NOT unconditionally safe to log.
122
+
123
+ - 2cf2ab0: `DICOM_ITEM_CROSSES_SEQUENCE_END` stops printing how many bytes remained inside the sequence, because
124
+ that count was the sequence's own declared length shifted by an amount the reader can compute
125
+ (`DICOM-DIAGNOSTIC-PHI-RESIDUALS`)
126
+
127
+ The eighth instance of "a diagnostic about a PHI leak is itself a PHI surface". Its defence and its
128
+ pinning test were both green, and both were green BY FIXTURE, which is the more valuable half.
129
+
130
+ WHAT SHIPS. Through `0.0.14` the message read "`{n2}` bytes remained inside the sequence", filled from
131
+ `Math.max(0, endLimit - cursor.position)`. `endLimit` is `valueStart + explicitLength`, the enclosing
132
+ SEQUENCE's declared Value Length read off its own header, and `cursor.position` is the crossing item's
133
+ header start plus 8, so the rendered count IS that declared length less the bytes of the sequence
134
+ already consumed. An addition the reader can compute reverses it: on the sequence's FIRST item the
135
+ shift is just the 8-byte Item header (PS3.5 2026c section 7.1.1's 4-byte tag plus section 7.5.1's
136
+ 4-byte Item Length), and with one 24-byte item ahead of it the same fixture renders 20275 against the
137
+ same declared 20307, a shift of 32. Both shapes are pinned; "less 8" as a universal was refused by a
138
+ graded pass. `itemCrossesSequenceEnd` takes
139
+ `(position, tag)` now; the Item's own declared length was already bound out of that signature, and
140
+ this is the second number to go the same way. The tag slot still carries the constant `FFFEE000`, and
141
+ `position.byteOffset` still locates the item. Which codes fire is unchanged and no parse moves.
142
+
143
+ A RAW WIRE NUMBER SHIFTED BY A CONSTANT THE READER CAN COMPUTE IS THE RAW WIRE NUMBER. The registry's
144
+ own rule allowed a number this parser "derived - a count it kept, an offset it counted, a remainder
145
+ the buffer bounds". The third clause is deleted rather than reworded: the `endLimit < buffer.length`
146
+ conjunct at the emit site bounds the rendered number's MAGNITUDE and says nothing about its CONTENT,
147
+ and that reading is what admitted this leak for four releases.
148
+
149
+ GREEN BY FIXTURE, TWICE, ON THE SAME PAYLOAD CLASS. The comment and the pinning row both rested on
150
+ that conjunct, and the fixture behind them only ever fabricated the sequence length out of four
151
+ PRINTABLE bytes. Every such window exceeds 538,976,288, so `endLimit` landed past the buffer and the
152
+ conjunct refused - but a length that big is unreachable by construction, because the buffer has to
153
+ hold that many bytes for the parse to get there. The reachable class has zero high-order bytes and
154
+ therefore a SHORT decimal. A TEST THAT PASSES BECAUSE ITS FIXTURE CANNOT REACH THE FAILING CASE IS
155
+ NOT EVIDENCE. The row is kept, because what it measures is true; what it was read as concluding is
156
+ retracted.
157
+
158
+ AND THE DETECTOR READ CLEAN ON IT, WHICH IS THE GAP THIS RELEASE ALSO CLOSES. Every arm of the
159
+ re-encoding detector in `test/integration/fatal-diagnostic-surface.test.ts` hunted a rendering EQUAL
160
+ to a typed read of a payload window, so a rendering SHIFTED by a constant was invisible to all of
161
+ them even with the digit floor removed. Measured on the `"SO\0\0"` payload that file already carries:
162
+ the shipped template returned no findings under the whole detector, while a DIRECT render of the same
163
+ length returned the `length` hit, so the detector was working and the miss was structural. A
164
+ `length-less-item-header` arm returns `20299 == "SO\0\0"`. THAT ARM COVERS ONE OFFSET AND IS NOT A
165
+ GENERAL NET, which is stated on the constant rather than left to be discovered: it hunts the first-item
166
+ shift of 8, so on the 32-byte shape above it would still have read clean. It is not widened to
167
+ a range, for the same reason the missing 2-byte-as-`uint16` arm beside it is still named rather than
168
+ armed: a hunt with nothing to hunt has no non-vacuity control, and what clears this class is the
169
+ factory signature, not the arm. A GUARD WITH A FLOOR HAS NOT CLEARED ANYTHING BELOW THE FLOOR, AND A
170
+ DETECTOR WITH NO OFFSET ARM HAS NOT CLEARED A SHIFTED RENDERING.
171
+
172
+ THIS ONE REACHES `ds.warnings` ON A SURVIVING PARSE, unlike the three instances before it, which
173
+ reached `onWarning` on a file the parse then refused or `report.warnings` on the de-identify channel.
174
+ So it lands on the channel a consumer is most likely to log. Measured on a synthetic, name-bearing
175
+ fixture whose planted letters are read back OFF THE WIRE rather than asserted against a literal:
176
+ `"SO"` rendered 20299 of a declared 20307, `"ON"` 20039 of 20047, `"TH"` 18508 of 18516, and the two
177
+ low bytes of each declared length are two letters of the planted surname.
178
+
179
+ WHAT IT COSTS, STATED RATHER THAN MINIMISED. A consumer reading the message no longer learns how much
180
+ of the sequence was left when the item over-ran it. Unlike the two `deidentify()` codes closed
181
+ alongside it, this number has no model field, so nothing publishes it any more; the parsed sequence
182
+ and `position.byteOffset` are what remain. The warning still says the file's two length fields
183
+ disagree, which is the disclosure it exists for.
184
+
185
+ THE EXCEPTION LIST STOPS BEING COPIED, AND NO COUNT OF THE COPIES IS QUOTED. "Which numeric slots are
186
+ exempt from the signature bound" is stated where the strings are - the `WARNING_MESSAGES` docblock in
187
+ `src/parser/warnings.ts` - and `README.md`, `docs-content/limitations.md`,
188
+ `docs-content/troubleshooting.md`, `docs-content/spec-notes-tolerance.md` and `ParseOptions.strict`'s
189
+ JSDoc name that docblock instead of carrying their own. A numeral is deliberately absent: this lineage
190
+ has already corrected such a count twice, and the rule it wrote is that a count corrected twice is
191
+ deleted rather than incremented. The pending `dicom-membership-render-tag.md` keeps its own statement,
192
+ which is not false; only the single clause this collapse falsified was deleted from it, and
193
+ `documentation/agent-notes.md` still argues the same exception in full, which is the record it exists
194
+ to be. Name the sink, do not restate its cost.
195
+
196
+ STILL A PRODUCT CALL AND UNTOUCHED: `report.removedPrivateTags`, `report.unauditableSequences[].tag`,
197
+ `report.uidMap`, `contextPath` and the two `byteLength` fields are model fields, not messages. A bound
198
+ on any of them empties the field on every well-formed file.
199
+
200
+ FIGURES. Base `ce33ec4`. Head, whole suite: 73 files, 1,223 passing + 1 todo, 0 red. Head tests
201
+ against base `src/` (replaced by file copy, not overlaid): 7 of 1,224 red across 3 files. Split before
202
+ quoting. FOUR are behavioural, because base really put the number in a live message: the two closure
203
+ pins, whose digit runs on base contain `20299` and `20275` respectively; the row asserting the message
204
+ carries neither length, which on base contains the remaining `16`; and the printable-class row, whose
205
+ live control message on base differs from the frozen template. TWO assert the registry template has no
206
+ `{n2}` slot. ONE is the factory-arity row, which fails on its arity line before reaching any message
207
+ assertion and is not evidence that base leaked. Re-measured after the graded pass, which is what added
208
+ the multi-item row.
209
+
210
+ - b8a3fb5: A tag in a warning message is checked for membership in PS3.6's element registry now, and the
211
+ parser's own codes no longer render a raw number read off an element header. This closes the fifth
212
+ and sixth instances of "a diagnostic about a PHI leak is itself a PHI surface".
213
+
214
+ **⚠ BREAKING FOR STRING-MATCHERS, AND FOR ANYONE WHO PARSES A TAG BACK OUT OF A MESSAGE.**
215
+ `w.code` is unchanged everywhere, which codes fire is unchanged, and no parse moves. What changes is
216
+ the prose of six registry entries and what two of the slots contain.
217
+
218
+ **`renderTag` is a MEMBERSHIP test.** It renders a tag only when PS3.6's element registry carries a
219
+ **literal row** for it, and `<withheld>` otherwise. Through `0.0.14` it validated a tag's shape, and
220
+ a shape test admits all 2^32 tags, so it could not refuse a tag a lying Value Length composed out of
221
+ somebody's value. Measured on a synthetic, name-bearing payload: an `ST` carrying
222
+ `"MR BRAIN SMITHSON "` whose Value Length under-declares by 12 desynchronizes the Explicit VR LE
223
+ reader onto a fabricated header whose declared length is odd, and `DICOM_ODD_LENGTH_VALUE_PADDED`
224
+ rendered four bytes of the name as its tag (`4E495320`, `"IN S"` in wire order) **and four more as
225
+ its decimal length** - eight consecutive payload bytes in one message, each reversible with one typed
226
+ read. `renderVr` bounds two bytes against the 34 VRs PS3.5 2026c section 6.2 defines; this is the
227
+ same trade against a set of 5,221.
228
+
229
+ **A repeating-group family row does not count as membership, and that distinction is load-bearing.**
230
+ `(50xx,xxxx)` Curve Data leaves the whole 16-bit element number free, so a family test would admit
231
+ 16 x 65,536 tags whose free bits are raw document bytes: `"\fPAR"` composes `500C5241` and returns
232
+ all four payload bytes with one typed read. Only tags the registry names one at a time are rendered.
233
+
234
+ **A raw wire number is bound out of the factory signature on the parser's codes, because there is
235
+ nothing to check.** A
236
+ declared Value Length has neither a shape nor a membership a renderer could test, so there is no
237
+ `renderLength` and there must not be one. `DICOM_ODD_LENGTH_VALUE_PADDED` no longer prints the odd
238
+ length. `DICOM_NONZERO_RESERVED_BYTES` no longer prints its two reserved bytes: that code already
239
+ withheld its **tag** on the reasoning that its trigger is "this header may not be a header", and it
240
+ then printed two bytes off the same header as decimals. Measured on the same payload, six
241
+ under-declare deltas each put two letters of the name into that message. **No detector in this
242
+ package had ever hunted a single byte rendered as a decimal**, which is why this instance had not
243
+ been filed. `DICOM_PIXEL_DATA_LENGTH_MISMATCH` loses its declared length too, although this build has
244
+ no call site for it: with none, the change costs nothing now and no later measurement would catch it
245
+ once a phase switches the code on. `DICOM_GROUP_LENGTH_IN_DATASET` loses its tag for a third reason,
246
+ which the membership rule produced rather than a judgement: PS3.6 carries exactly one literal row
247
+ ending `0000`, and it is File Meta, so that slot could never have rendered anything but `<withheld>`.
248
+
249
+ **The exceptions are named rather than counted, and rather than left as an unstated absolute.**
250
+ `(0002,0000)`'s own declared
251
+ File Meta group length is still printed, and that one is argued as well as measured: `parseFileMeta`
252
+ runs once per parse, from `parseDicom`, at the post-`DICM` offset, and is never nested, so those four
253
+ bytes are that attribute's own Value Field at a structurally determined offset that no Data Set value
254
+ can be read into. The desynchronized-read sweep reaches that code zero times.
255
+
256
+ **What it costs you, stated rather than minimised.** On any file, well-formed or not, a message about
257
+ a **private** element, a **Group Length** `(gggg,0000)`, or a **repeating-group member** such as
258
+ `(6000,3000)` Overlay Data no longer names its tag. The element is still in the Data Set under that
259
+ tag, and `position.byteOffset` locates the header, with the frame-of-reference caveat every offset in
260
+ this package carries. `DICOM_VR_MISMATCH` is unaffected by construction: it fires only where the
261
+ dictionary already has an entry for the tag.
262
+
263
+ **The "safe to log" sentence is deleted rather than reworded a third time**, in `README.md`,
264
+ `docs-content/limitations.md`, `docs-content/troubleshooting.md`,
265
+ `docs-content/spec-notes-tolerance.md`, `docs-content/cookbook.md` and `ParseOptions.strict`'s JSDoc.
266
+ It was written as "safe to log whole on any well-formed file", then corrected to "safe on a
267
+ well-formed file and not unconditionally safe", and this package deletes a disclosure it has reworded
268
+ twice. Every carrier now states the mechanism - which slot is a membership test, which is a signature
269
+ bound - and states no verdict.
270
+
271
+ **Not taken, deliberately.** `report.removedPrivateTags` is untouched: it is a private-tag field by
272
+ definition, so no closed table can ever vouch for its contents and a bound would empty it on every
273
+ well-formed file. That is a product call rather than a defect. `report.unauditableSequences[].tag`,
274
+ `report.uidMap` and `contextPath` are model fields rather than messages and are equally untouched.
275
+ A `DicomParseError` still carries `snippet`, 16 raw source bytes as hex.
276
+
277
+ - ce33ec4: The two `deidentify()` diagnostics stop rendering a raw wire length, and the PHI detector's digit
278
+ floor that hid it is gone (`DICOM-DIAGNOSTIC-PHI-RESIDUALS`)
279
+
280
+ The seventh instance of "a diagnostic about a PHI leak is itself a PHI surface", plus the tripwire gap
281
+ that made it invisible. The gap is the more valuable half.
282
+
283
+ THE DETECTOR WAS WIDENED BEFORE ANY CODE WAS TOUCHED. The `length` arm of the re-encoding detector in
284
+ `test/integration/fatal-diagnostic-surface.test.ts` skipped any rendering under seven digits, and the
285
+ sentence beside it stated the defect without seeing it: "every 4-byte window of a printable-ASCII
286
+ payload exceeds 1,000,000,000, so nothing in this fixture set is skipped by the floor." That is true
287
+ and it is the whole problem. A declared Value Length is only reachable through a parse if the buffer
288
+ really holds that many bytes, so every fabricated length a fixture can drive through this library has
289
+ zero high-order bytes and therefore a SHORT decimal. `"SO\0\0"` renders `20307`: five digits, two of
290
+ them letters of a surname, structurally under the floor. The floor was not a conservative filter on an
291
+ arm that worked. It excluded the entire class of length leak that can actually happen. A GUARD WITH A
292
+ FLOOR HAS NOT CLEARED ANYTHING BELOW THE FLOOR.
293
+
294
+ The floor is removed and the collision it was for is answered by matching instead of skipping, so the
295
+ widening is strictly additive: a rendering of seven digits or more keeps the original substring
296
+ search, and a shorter one must equal a whole maximal digit run of the message.
297
+
298
+ WHAT THE WIDENED DETECTOR FOUND, POINTED AT THE `deidentify()` CHANNEL. That channel is the one the
299
+ standing desync sweep states it can never reach, because both codes are emitted by `deidentify()` and
300
+ by nothing else while every desync fixture dies at a Tier-3 fatal first. It returned the two `{n}`
301
+ slots this release closes and nothing else. It also returned `00080008`, a literal PS3.6 row that
302
+ `renderTag`'s membership test renders on every file by design, which is scoped out of the sweep
303
+ explicitly rather than left to pass quietly.
304
+
305
+ THE REMEDY IS THE FACTORY SIGNATURE, AS IT WAS THE LAST FIVE TIMES.
306
+ `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` and `DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` rendered `{n}`
307
+ from `Element.rawBytes.length`, which is not a count this parser invented: it EQUALS the declared
308
+ Value Length off the element header. `sequenceNotAuditable` now takes `(position, tag)` and
309
+ `undefinedVrNotAuditable` takes `(position)`. A raw length has neither a shape nor a membership for a
310
+ renderer to test, so there is no `renderLength` and there must not be one. The second code is the
311
+ sharper case: it withheld its tag and its VR on the stated ground that the header may be fabricated,
312
+ then printed the length off that same header.
313
+
314
+ WHAT IT COSTS, STATED RATHER THAN MINIMISED. A consumer reading only the message no longer sees how
315
+ many bytes were emptied. The number is still on `report.unauditableSequences[].byteLength` and
316
+ `report.undefinedVrElements[].byteLength`, and `{tag}` and the byte offset still locate the element.
317
+ Those two model fields JOINED the `DeidentifyReport` not-value-free list rather than always having
318
+ been on it: binding the message left them as the number's only publisher, which is a smaller surface
319
+ and not a closed one.
320
+
321
+ STILL A PRODUCT CALL AND UNTOUCHED: `report.removedPrivateTags`, `report.unauditableSequences[].tag`,
322
+ `report.uidMap`, `contextPath` and the two `byteLength` fields are model fields, not messages. A bound
323
+ on any of them empties the field on every well-formed file, where the content is exactly the audit
324
+ information it exists to carry.
325
+
326
+ A MIS-TITLED ROW IS CORRECTED. `deident-unauditable-sequence.test.ts` carried a row titled "carrying
327
+ no value" for the very code disclosed as printing a header-derived length; its body only asserted that
328
+ values planted elsewhere were absent, which a message built from a frozen registry cannot carry in any
329
+ case. The title now matches what the code does and the row asserts the number's absence, with a
330
+ non-vacuity control that rebuilds the shipped template.
331
+
332
+ AND ONE MORE FIXTURE THAT HAD NEVER RUN. The standing desync sweep built its fixtures inside the `try`
333
+ that swallows the parse failures they are designed to end in, so the `-20` delta, which under-declares
334
+ an 18-byte payload past zero, threw during construction on both syntaxes and was counted as swept.
335
+ Construction moved outside the `try`.
336
+
337
+ FIGURES. Base `b8a3fb5`. Head, whole suite: 73 files, 1,220 passing + 1 todo, 0 red. Head tests
338
+ against base `src/` (replaced, not overlaid): 5 of 1,221 red across 3 files. THREE are behavioural;
339
+ TWO are the factory-arity rows, which fail on the arity line and whose message assertions would have
340
+ passed on base, so they grade the new bound and are not evidence that base leaked.
341
+
3
342
  ## 0.0.14
4
343
 
5
344
  ### Patch Changes
package/README.md CHANGED
@@ -309,7 +309,7 @@ At an RSNA-era interoperability test, ~80% of real-world patient CDs failed stri
309
309
  | 2 | Warning | Recoverable deviation | `DICOM_MISSING_PREAMBLE` |
310
310
  | 3 | Fatal (always) | Unrecoverable structural error | `NOT_DICOM_PART_10` |
311
311
 
312
- Tier-2 warnings are plain data on `ds.warnings`. Each carries a stable string `code`, a `message` looked up from a frozen registry, and a `position` with the byte offset where it occurred, so you can react programmatically. **The message is safe to log on a well-formed file and is not unconditionally safe**: the registry's `{tag}` slot is filled by a shape check, and a shape check cannot refuse a tag that a lying Value Length composed out of somebody's value. Measured: an `ST` carrying a name whose Value Length under-declares desynchronizes the Explicit VR LE reader onto a fabricated header whose declared length is odd, and `DICOM_ODD_LENGTH_VALUE_PADDED` renders four bytes of that payload as the tag and four more as the decimal length. It is disclosed rather than guarded, because that code fires on any tag and withholding it would take the tag off every element that pads an odd length in every real-world file. **Every fixture that reaches it dies before a `Dataset` exists**, so the message travels on `onWarning` or on the `{ strict: true }` error rather than on a surviving `ds.warnings` - a fact about those fixtures, not a promise about the parser. The three **private-tag** codes take no tag parameter at all: an odd group is the one class of tag no closed table here can vouch for. `w.code` and `w.position` carry nothing from the document. If you log messages from untrusted files verbatim, treat a tag or a length in one as document-derived.
312
+ Tier-2 warnings are plain data on `ds.warnings`. Each carries a stable string `code`, a `message` looked up from a frozen registry, and a `position` with the byte offset where it occurred, so you can react programmatically. **What a message may contain is stated here as a mechanism rather than as a verdict**, because the verdict form of this paragraph was corrected twice and is deleted rather than tried a third time. The only substitutions into a registry template are structural. `{tag}` renders **only when PS3.6's element registry carries a literal row for that tag**, and `<withheld>` otherwise, so a tag that is private, a Group Length `(gggg,0000)`, a repeating-group member such as `(6000,3000)` Overlay Data, or four bytes a lying Value Length composed out of somebody's value is not echoed. `{vr}` renders only one of the 34 VRs PS3.5 2026c §6.2 defines. **A raw number a header carries is bound out of the factory signature rather than checked, where it is bound at all** - a declared Value Length has neither a shape nor a membership to test - so `DICOM_ODD_LENGTH_VALUE_PADDED` no longer prints the odd length and `DICOM_NONZERO_RESERVED_BYTES` no longer prints the two reserved bytes. **A raw number SHIFTED by a constant the reader can compute is that raw number**, so it is bound the same way: `DICOM_ITEM_CROSSES_SEQUENCE_END` no longer prints how many bytes remained inside the sequence, because that count is the sequence's own declared Value Length less the bytes of the sequence already consumed, and an addition puts it back. **The exceptions are named in one place that is not a record of a past change, and are deliberately not restated here** - the `WARNING_MESSAGES` docblock in [`src/parser/warnings.ts`](./src/parser/warnings.ts). No count of the copies is quoted, here or there: this package deletes a count it has corrected twice rather than incrementing it. `w.code` and `w.position` carry nothing from the document. **This is a statement about `w.message` and not about the rest of the output**: `DicomParseError.snippet` is still 16 raw source bytes, and `DeidentifyReport`'s value-bearing fields are named on the type. **The cost is real and is not minimised**: on a well-formed file a message about a private, overlay or group-length element no longer names its tag. The element is still in the Data Set under that tag and `position.byteOffset` locates the header.
313
313
 
314
314
  ```ts
315
315
  import { parseDicom, WARNING_CODES } from "@cosyte/dicom";
@@ -338,7 +338,7 @@ The library throws five typed errors, all exported from the package barrel. Warn
338
338
 
339
339
  ### `DicomParseError`
340
340
 
341
- Thrown by `parseDicom` on one of the 4 Tier-3 fatal codes. Carries the byte position, a registry-composed message, and a 16-byte hex `snippet` of the source. **The message is looked up in a frozen registry and its factories take no tag and no wire-length parameter**, so neither can be interpolated: a fatal about a length field that lies fires precisely when bytes inside somebody's value are being read as a header, which is what used to make those two fields document content. `err.byteOffset` locates the element instead. The snippet is raw input and is cut in the frame that offset is counted in: treat it as PHI and redact it at your own boundary.
341
+ Thrown by `parseDicom` on one of the 4 Tier-3 fatal codes. Carries the byte position, a registry-composed message, and a 16-byte hex `snippet` of the source. **The message is looked up in a frozen registry and its factories take no tag, no wire-length parameter and no count of the bytes left in the buffer**, so none of them can be interpolated: a fatal about a length field that lies fires precisely when bytes inside somebody's value are being read as a header, which is what used to make the tag and the wire length document content. The last of the three went in this release and **changes two message texts**, so a string match on them stops matching: a defined-length Sequence Item is parsed from a slice, so inside one the remaining-bytes count is that Item's own declared length less an offset the same message publishes. `err.code` is unchanged and `err.byteOffset` locates the element instead. The snippet is raw input and is cut in the frame that offset is counted in: treat it as PHI and redact it at your own boundary.
342
342
 
343
343
  ```ts
344
344
  import { parseDicom, DicomParseError, FATAL_CODES } from "@cosyte/dicom";