@cosyte/dicom 0.0.16 → 0.0.18
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 +357 -0
- package/README.md +4 -2
- package/dist/index.cjs +163 -91
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +258 -121
- package/dist/index.d.ts +258 -121
- package/dist/index.mjs +163 -92
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,362 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.18
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 21d42f5: Scan a file by its CONTENT in the repository's PHI gate (`scripts/phi-scan.ts`), which is developer
|
|
8
|
+
tooling and not part of the published surface.
|
|
9
|
+
|
|
10
|
+
`scanTarget` branched on the file's extension before it read a byte: `.json`, `.txt`, `.md` and
|
|
11
|
+
`.csv` ran the text sweep and the base64 decode and returned, so the DICOM-aware sweep never ran on
|
|
12
|
+
one whatever its bytes were. A file whose raw bytes are a Part 10 object was therefore never scanned
|
|
13
|
+
as one if it happened to be named `.md`, which a de-identification report, a bug repro or a fixture
|
|
14
|
+
saved under the wrong extension plausibly is.
|
|
15
|
+
|
|
16
|
+
What that cost is narrower than "a second opinion", and the fixture is built to show it: only the
|
|
17
|
+
tag table can see a **single-component** `(0010,0010)`, because that shape carries no caret for the
|
|
18
|
+
text sweep's person-name pass to match. Measured on `08ed3ee` over one object, the same bytes exited
|
|
19
|
+
1 as `.dcm`, `.bin` and `.dat`, and 0 with `OK - no hits` as `.md`, `.txt`, `.json` and `.csv`.
|
|
20
|
+
Preamble-less, the same.
|
|
21
|
+
|
|
22
|
+
The remedy is the DELETION of the branch, and that is what makes it safe rather than a net leak.
|
|
23
|
+
`scanDicom` gives up quietly at an undefined-length Sequence (PS3.5 2026c section 7.5.2 defines
|
|
24
|
+
`0xFFFFFFFF` as one of two Sequence delimitations, both of which decoders shall support, and section
|
|
25
|
+
7.1 orders tags ascending, so `(0008,1110)` precedes `(0010,0010)` in a conformant file), so routing
|
|
26
|
+
a file to it INSTEAD of the text sweep has previously taken a name from exit 1 to exit 0. The removed
|
|
27
|
+
branch's two calls were the text sweep and the base64 decode; the branch that replaces it makes the
|
|
28
|
+
same two unconditionally and adds one conditional DICOM sweep. Hits are only ever appended, so the
|
|
29
|
+
hit set, the totals and the exit code are a strict superset on every input.
|
|
30
|
+
|
|
31
|
+
That superset was checked mechanically over 11 objects times 7 extensions, 77 cells: 65 identical,
|
|
32
|
+
12 strictly more reported, 0 cells that lost an exit code or a reported value. Every fixture was
|
|
33
|
+
verified through this package's own parser before any zero it produced was believed. The scan of the
|
|
34
|
+
repository's real corpus is byte-identical, exit code and output alike.
|
|
35
|
+
|
|
36
|
+
Two residuals are unchanged and disclosed rather than closed: the DICOM sweep still reports nothing
|
|
37
|
+
about the bytes it never read, and the hit array is still unbounded in memory. The matrix, the
|
|
38
|
+
superset check and the reason the first of those is a separate slice are in
|
|
39
|
+
`documentation/agent-notes/dicom-phi-scan-name-dispatch.md`.
|
|
40
|
+
|
|
41
|
+
- 028fe85: Close a silent halt in the repository's PHI gate (`scripts/phi-scan.ts`), which is developer
|
|
42
|
+
tooling and not part of the published surface.
|
|
43
|
+
|
|
44
|
+
`scanDicom` gives up quietly at the first Data Element header it cannot read, and an
|
|
45
|
+
undefined-length Sequence is one of those (PS3.5 2026c section 7.5.2 defines it as one of two
|
|
46
|
+
delimitations, both of which decoders shall support). The text sweep behind it ran only when
|
|
47
|
+
`isDicom` answered false, so a **preamble-ful** Part 10 object had nothing behind the halt: the
|
|
48
|
+
identical dataset was reported without a preamble and missed with one, and the gate printed
|
|
49
|
+
`OK - no hits` over a name-bearing `(0010,0010)`. The preamble is not part of the dataset, so it
|
|
50
|
+
cannot decide who is owed a text sweep.
|
|
51
|
+
|
|
52
|
+
The text sweep now runs on every binary target, and `scanEmbeddedObjects` runs it on the object it
|
|
53
|
+
decodes as well, where the base64 hides the name from the enclosing page. Both are additions beside
|
|
54
|
+
`scanDicom`, never replacements for it.
|
|
55
|
+
|
|
56
|
+
The accepted cost is false positives over binary values. Which recognizer produces them, and how
|
|
57
|
+
many, is a property of the payload's byte histogram rather than of the scanner: over 8 MiB of
|
|
58
|
+
synthetic pixel data the measured count runs from zero to five figures. The table, and the residuals
|
|
59
|
+
left open, are in `documentation/agent-notes/dicom-scandicom-silent-halt.md`.
|
|
60
|
+
|
|
61
|
+
Also closes a pre-existing refusal on the same route: the base64 run matcher was a greedy regular
|
|
62
|
+
expression, and one multi-megabyte run overflowed V8's backtrack stack, exiting 2 rather than
|
|
63
|
+
scanning. It is a forward scan now, yielding the same runs.
|
|
64
|
+
|
|
65
|
+
- 99da18f: Make the repository's PHI gate (`scripts/phi-scan.ts`) report the bytes its DICOM sweep never read.
|
|
66
|
+
This is developer tooling and not part of the published surface.
|
|
67
|
+
|
|
68
|
+
`scanDicom` stops at the first Data Element header it cannot read and said nothing about what came
|
|
69
|
+
after it, so a run printed `OK - no hits` over a file whose tag table it had abandoned partway
|
|
70
|
+
through. The way in is conformant: PS3.5 2026c section 7.5.2 defines an undefined-length Sequence as
|
|
71
|
+
one of two delimitations, both of which decoders shall support, and section 7.1 orders tags
|
|
72
|
+
ascending, so `(0008,1110)` precedes `(0010,0010)`. Encapsulated pixel data has the same shape.
|
|
73
|
+
|
|
74
|
+
Each halt is now recorded per file and printed as a `PARTIAL:` line, and the word `OK` is withdrawn
|
|
75
|
+
from the clean line when any file has one. The line carries a path, two counts and a reason from a
|
|
76
|
+
closed table: no tag, no VR, no value and no byte of the object, because the bytes at a halt are
|
|
77
|
+
exactly the bytes that did not read as a header. The tally is bounded by the number of files rather
|
|
78
|
+
than by anything an object can choose, so it needs no cap and cannot be pushed off the report by a
|
|
79
|
+
file loud enough to bury its own hits.
|
|
80
|
+
|
|
81
|
+
The exit code deliberately does not move. Nothing was found, nothing refused the scan, the same
|
|
82
|
+
disclosure would fire on input section 7.5.2 makes legal, and folding it into exit 2 would mask a
|
|
83
|
+
real hit whenever both were present. A CI job that reads only the exit code therefore still cannot
|
|
84
|
+
see this, which is disclosed rather than claimed away.
|
|
85
|
+
|
|
86
|
+
Proved over 13 objects x 5 carriers x 3 cap settings: exit code and hit lines identical in all 195
|
|
87
|
+
cells, 60 outputs identical, 105 strictly larger, 30 differing only by the withdrawn `OK` line, 0
|
|
88
|
+
violations. The generator ships as `scripts/measure-phi-scan-unread.ts` so the table can be
|
|
89
|
+
re-derived. What the disclosure does and does not carry, and the residuals left open, are in
|
|
90
|
+
`documentation/agent-notes/dicom-phi-scan-unread-tail.md`.
|
|
91
|
+
|
|
92
|
+
- 783f667: Let the repository's PHI gate (`scripts/phi-scan.ts`) finish writing its report before it exits.
|
|
93
|
+
This is developer tooling and not part of the published surface.
|
|
94
|
+
|
|
95
|
+
The script ended `process.exit(run())`. `process.exit()` tears the process down without waiting for
|
|
96
|
+
stdio that libuv has accepted but not yet written, and under every caller that matters this script's
|
|
97
|
+
stderr is a **pipe**: `spawnSync` in this repo's own suite, and the shell pipeline a CI job runs it
|
|
98
|
+
in. A pipe write that cannot complete immediately is queued and flushed on a later loop turn, which
|
|
99
|
+
`process.exit()` never allows, so `report()` returning was not the same as its bytes having left the
|
|
100
|
+
process.
|
|
101
|
+
|
|
102
|
+
**The failure mode is a gate defect, not a cosmetic one.** The exit code is computed from
|
|
103
|
+
`hits.length` and was always right, so a truncated report is a run that REFUSES while under-naming
|
|
104
|
+
what it found, and the bytes it drops are the END of the report: the last hit lines and the total,
|
|
105
|
+
which is the part a reader trusts to say how much there was.
|
|
106
|
+
|
|
107
|
+
This was red on `main`. `ci / verify (24, ubuntu-latest)` is a required check and it failed on
|
|
108
|
+
`08ed3ee` and `21d42f5`, both times on `countHitLines(...) === 200` reading 193, and on `#106`'s PR
|
|
109
|
+
head reading 191, with `verify (22)` green throughout and the exit code correct in every case.
|
|
110
|
+
|
|
111
|
+
The scan is deterministic, so the hit COUNT cannot vary between runs: the fixture is 200 distinct
|
|
112
|
+
tokens and the recognizers are plain global regexes over the whole file. What varied is what
|
|
113
|
+
arrived. Measured on `21d42f5` with the reader stalled, 5,000 small stderr writes: `process.exit(1)`
|
|
114
|
+
delivered 784 lines, about one 64 KiB pipe, on 3 of 3 runs; `process.exitCode = 1` delivered 5,000
|
|
115
|
+
on 3 of 3. Node 22.23.1 and 24.19.0 behaved identically there, so the version split is scheduling
|
|
116
|
+
and not stdio semantics. The CI failure itself reproduced on the real script and the real fixture
|
|
117
|
+
once the reader was made to lose (single-page pipes plus CPU contention): **30 of 60 runs truncated,
|
|
118
|
+
counts including 190, 191 and 192, exit 1 every time; 0 of 60 with this change.**
|
|
119
|
+
|
|
120
|
+
Not calling `process.exit()` has a cost, and it is paid here rather than discovered later: it makes
|
|
121
|
+
a late stdio error reachable. A write to a pipe whose reader has gone fails with `EPIPE` on a later
|
|
122
|
+
tick, after `run()` has returned, so `run()`'s try/catch cannot see it and Node's default
|
|
123
|
+
unhandled-`'error'` path exits 1, the one code that means "PHI was found". Without a listener a
|
|
124
|
+
clean corpus went 0 to 1 and an invocation error went 2 to 1. Both stdio streams now discard a late
|
|
125
|
+
error, which is exactly what `process.exit()` did by making it unreachable, so the exit code stays
|
|
126
|
+
exactly what `run()` returned. **The residual, disclosed and not closed: with a reader that never
|
|
127
|
+
drains at all this script now waits instead of exiting, where `process.exit()` ended it by dropping
|
|
128
|
+
the report.** That is introduced, on `node` and under `tsx` alike. A timeout would re-introduce the
|
|
129
|
+
defect this closes, so it is disclosed rather than answered.
|
|
130
|
+
|
|
131
|
+
It changes what ARRIVES, never what is WRITTEN. Over a draining reader, base and fixed emit
|
|
132
|
+
byte-identical stdout, stderr and exit code across 12 cases spanning all three of the contract's
|
|
133
|
+
exit codes, the per-file print cap at its default and at 0, 1 and 3, the net-leak control and both
|
|
134
|
+
cells of the finding that `report()` is not monotone in `hits` at the cap. Nothing about the cap or
|
|
135
|
+
its non-monotonicity is changed or claimed away here; the printed set simply arrives.
|
|
136
|
+
|
|
137
|
+
Still open and NOT fixed here: `test/helpers/run-script.ts` inherits `spawnSync`'s 1 MiB
|
|
138
|
+
`maxBuffer`, which truncates at roughly 15,400 lines in both exit modes. That is the parent dropping
|
|
139
|
+
bytes it was handed rather than the child failing to hand them over, and it is a different defect.
|
|
140
|
+
|
|
141
|
+
Detail, controls and the superset check: `documentation/agent-notes/dicom-phi-scan-exit-flush.md`.
|
|
142
|
+
|
|
143
|
+
- b784c38: Name the frame a `DicomParseError`'s byte offset is counted in, and correct the universal that was
|
|
144
|
+
defending the bound around it.
|
|
145
|
+
|
|
146
|
+
`err.byteOffset` was the sole locator on the two Tier-3 messages that lost their remaining-bytes
|
|
147
|
+
count, and it is slice-relative inside a defined-length Sequence Item: on one fixture the same
|
|
148
|
+
defect reports `0`, `24` or `40` depending on where inside the Item the offending element sits, while
|
|
149
|
+
that Item's slice begins at absolute offset 202. Nothing on the class said which coordinate system
|
|
150
|
+
the number was in, and the wrong reading is not detectable by a consumer. It is a valid index into a
|
|
151
|
+
buffer that returns a real element, just not the one the diagnostic named. On a fixture engineered
|
|
152
|
+
for the earlier snippet fix, reading the item-relative number against the file lands inside a planted
|
|
153
|
+
`"MR BRAIN SMITHSON "`.
|
|
154
|
+
|
|
155
|
+
**`DicomParseError.offsetFrame` is new and required**, drawn from the new `OFFSET_FRAMES` export
|
|
156
|
+
(`"input"`, `"inflated-dataset"`, `"value-slice"`). **Only `"input"` means the offset indexes the
|
|
157
|
+
buffer you passed in.** The frame is in the `Error.message` suffix as well as on the class, so that
|
|
158
|
+
suffix reads `(offset=N frame=F)` on every Tier-3 fatal and every `{ strict: true }` escalation: a
|
|
159
|
+
string match on it stops matching. `err.code` is unchanged and which files throw is unchanged.
|
|
160
|
+
|
|
161
|
+
The frame's **name** is published and its **origin** is not. Where a slice begins is the sum of the
|
|
162
|
+
declared lengths that reached it, so an error naming its own frame's origin would hand back by
|
|
163
|
+
subtraction the wire field these same messages withhold. Internally the frame's bytes and its name
|
|
164
|
+
travel as one object, so a frame change is one assignment and no call site can move the bytes and
|
|
165
|
+
leave the label behind. It does not make a deliberately mismatched pair impossible, and is not
|
|
166
|
+
described as doing so.
|
|
167
|
+
|
|
168
|
+
**The universal used to justify withholding a declared length, in the README, two package docs and
|
|
169
|
+
two places in the parser, was false and is corrected rather than reworded a second time.** A fatal "about a length field that lies" does not fire
|
|
170
|
+
only when a length field is lying: a spec-clean object cut short by two bytes raises
|
|
171
|
+
`ELEMENT_LENGTH_EXCEEDS_BUFFER` with every declared length honest, and one cut short inside its File
|
|
172
|
+
Meta group raises `FILE_META_GROUP_LENGTH_OVERRUNS` with `(0002,0000)` untouched. Both are measured.
|
|
173
|
+
The bound is right in either reading because the withheld number is four bytes a sender wrote either
|
|
174
|
+
way; the universal was never what made it right.
|
|
175
|
+
|
|
176
|
+
Also replaces an arity pin that did not pin what it claimed. It read
|
|
177
|
+
`Function.prototype.length`, which stops counting at the first defaulted parameter, so a factory that
|
|
178
|
+
had grown exactly the slot the pin refuses would still have read `2` and stayed green.
|
|
179
|
+
|
|
180
|
+
`UNSUPPORTED_TRANSFER_SYNTAX` keeps the behaviour every released version has: its `snippet` slot
|
|
181
|
+
carries PS3.6's own name for the unsupported UID rather than raw bytes, so it is the one fatal whose
|
|
182
|
+
snippet is not a cut of the frame it names. `DicomParseWarning.position` and `Element.byteOffset`
|
|
183
|
+
still carry no frame, and `err.snippet` is still 16 raw source bytes. All three are pre-existing, disclosed, and not closed here. The measurements,
|
|
184
|
+
the residuals, and every clean result with the positive it is pinned beside are in
|
|
185
|
+
`documentation/agent-notes/dicom-diagnostic-locator-frame.md`.
|
|
186
|
+
|
|
187
|
+
- 08ed3ee: Cap the repository's PHI gate (`scripts/phi-scan.ts`) at a per-file number of printed hit lines,
|
|
188
|
+
which is developer tooling and not part of the published surface.
|
|
189
|
+
|
|
190
|
+
`report()` wrote one stderr line per hit with no bound, and the preceding slice widened what reaches
|
|
191
|
+
it: the text sweep now runs over every Part 10 object's bytes, and its recognizers fire on image
|
|
192
|
+
noise at a rate that is a property of the payload's byte histogram. Measured on `b784c38` over 8 MiB
|
|
193
|
+
of synthetic pixel data uniform over `0x41-0x60`, one run wrote 71,447 hits as 6,037,715 bytes of
|
|
194
|
+
stderr.
|
|
195
|
+
|
|
196
|
+
The cap is on **printing only**. The exit code and the reported totals are derived from the hits and
|
|
197
|
+
not from what was printed, so a withheld line cannot turn a refusal into a clean run. It is applied
|
|
198
|
+
**per file**, never globally, so a loud file cannot push a later file's hits off the report. A file
|
|
199
|
+
with lines withheld says so, with the exact remainder. `--max-hit-lines <n>` sets the bound and
|
|
200
|
+
`--max-hit-lines 0` prints every line, reproducing the previous output byte for byte.
|
|
201
|
+
|
|
202
|
+
## 0.0.17
|
|
203
|
+
|
|
204
|
+
### Patch Changes
|
|
205
|
+
|
|
206
|
+
- 8982a16: Fix the repo's PHI gate skipping a preamble-less DICOM object on disk (`DICOM-SCANTARGET-PREAMBLELESS`).
|
|
207
|
+
|
|
208
|
+
`scripts/phi-scan.ts`'s `scanTarget` gated a `.dcm`, a `.bin` and any unknown extension on `isDicom`
|
|
209
|
+
(the 128-byte preamble plus `DICM`) before handing the bytes to `scanDicom`. A preamble-less stream,
|
|
210
|
+
whose File Meta group starts at byte 0, failed that gate and fell through to the text sweep, so the
|
|
211
|
+
DICOM-aware scan never ran on one and the gate printed `OK - no hits` over it. The text sweep is not
|
|
212
|
+
a narrower scan but a different one: it matches a person name only in `FAMILY^GIVEN` form, so a
|
|
213
|
+
single-component `(0010,0010)` is invisible to it, and a `DT` value's date head is not a standalone
|
|
214
|
+
eight-digit token either.
|
|
215
|
+
|
|
216
|
+
The DICOM route now asks `fileMetaStart`, which knows both shapes and is what `scanDicom` and the
|
|
217
|
+
doc-corpus route already used. **The text route is not an `else`.** Detection moved in one direction
|
|
218
|
+
only, and that is deliberate: `scanDicom` stops at the first header it cannot read, including an
|
|
219
|
+
undefined-length Sequence, which PS3.5 2026c §7.5.2 defines as one of two delimitations that decoders
|
|
220
|
+
shall both support, and which §7.1's ascending tag order places ahead of `(0010,0010)` in a
|
|
221
|
+
conformant file. So a recognized object is now swept by both routes rather than handed
|
|
222
|
+
from one to the other, which makes the branch a strict superset of the old behaviour on every input:
|
|
223
|
+
`isDicom` true is unchanged, a preamble-less object gains the DICOM sweep on top of the text sweep it
|
|
224
|
+
already had, and an unrecognized file is unchanged. One value can now be reported twice, once under
|
|
225
|
+
its tag and once as `(text)`.
|
|
226
|
+
|
|
227
|
+
Still open, `PRE-EXISTING` and unchanged: a preamble-ful Part 10 object gets no text sweep behind
|
|
228
|
+
`scanDicom`, so an early halt on one is silent; and a text extension is still dispatched by name, so
|
|
229
|
+
a `.md` whose raw bytes are a DICOM object is not scanned as one.
|
|
230
|
+
|
|
231
|
+
Gate-only: no runtime, API or parser behaviour changes, and no published surface moves.
|
|
232
|
+
|
|
233
|
+
- 1028317: Disclose an over-long `(0012,0063)` Value that `deidentify()` did not compose (`DICOM-LO-LENGTH-AND-SILENT-REPLACE`).
|
|
234
|
+
|
|
235
|
+
PS3.5 2026c Table 6.2-1 caps an `LO` at "64 chars maximum", and that row describes a **Value**:
|
|
236
|
+
`(0012,0063)` is `1-n`, so the bound falls on each value between `5CH` delimiters and never on the
|
|
237
|
+
Value Field. Reading it the other way is the misreading that left `#74`'s hole, and a field of 619
|
|
238
|
+
bytes made of ten Values of 61 is conformant. The text this library composes for itself has been
|
|
239
|
+
inside the maximum on all 512 option subsets since `#75`. The two Values it does **not** compose can
|
|
240
|
+
be over, and both were written through with `report.warnings` saying nothing about their length: a
|
|
241
|
+
caller's `deidentificationMethod`, and a value the source file already carried and PS3.15 2026c
|
|
242
|
+
E.1.1 obliges this to keep.
|
|
243
|
+
|
|
244
|
+
**The likeliest writer of the second one is this library.** Every object any published release
|
|
245
|
+
de-identified without a caller-supplied method carries a 76-character Value, and re-de-identifying
|
|
246
|
+
one keeps it: measured flat at 138 bytes over four passes, Values of 76 and 61. The **retention** has
|
|
247
|
+
been disclosed since `DICOM_DEIDENT_METHOD_PRIOR_RETAINED`; the **length** was disclosed by nothing,
|
|
248
|
+
on that route or on the caller's. So "a sender's non-conformant `LO` is the sender's" does not apply
|
|
249
|
+
to the common case, and the earlier changesets that described the retention are not corrected by
|
|
250
|
+
this: they were about which bytes survive, not about how long they are.
|
|
251
|
+
|
|
252
|
+
`report.warnings` now carries `DICOM_DEIDENT_METHOD_VALUE_OVER_LENGTH` whenever the `(0012,0063)`
|
|
253
|
+
this run writes carries a Value longer than 64 bytes. **It is a disclosure and not a bound: nothing
|
|
254
|
+
is shortened, split or truncated**, because splitting or truncating either Value would invent a
|
|
255
|
+
de-identification record nobody made, and keeping a prior record intact is what E.1.1's provenance
|
|
256
|
+
carrier is for. The code is measured over the value actually written, so one check covers the caller
|
|
257
|
+
route, the retained-prior route and both replacement fallbacks, and it is raised on **every** pass
|
|
258
|
+
rather than only the first, which is what a re-de-identified object needs.
|
|
259
|
+
|
|
260
|
+
**Widened by union, never by replacement.** No existing branch moved and no existing code stopped
|
|
261
|
+
firing: `DICOM_DEIDENT_METHOD_NOT_ADDED`, `DICOM_DEIDENT_METHOD_NOT_LO` and
|
|
262
|
+
`DICOM_DEIDENT_METHOD_PRIOR_RETAINED` are unchanged on every shape, pinned by a matrix that strips
|
|
263
|
+
the new code and compares against the base's answers, and a sweep of all 512 option subsets shows
|
|
264
|
+
the new code raised **0** times on the text this library composes for itself.
|
|
265
|
+
|
|
266
|
+
**The measurement is over BYTES and that is a deliberate over-approximation, disclosed rather than
|
|
267
|
+
argued away.** No repertoire encodes a character in fewer than one byte, so a Value of 64 bytes or
|
|
268
|
+
fewer can never hold more than 64 characters and this cannot miss a Value that is genuinely over.
|
|
269
|
+
**The converse fails whenever a Value's bytes outnumber its counted characters.** PS3.5 2026c §6.2,
|
|
270
|
+
in the paragraph above Table 6.2-1, is the rule: those lengths are "expressly specified in characters
|
|
271
|
+
rather than bytes ... because the mapping from a character to the number of bytes used for that
|
|
272
|
+
character's encoding may be dependent on the character set used", and "Escape Sequences used for
|
|
273
|
+
Code Extension shall not be included in the count of characters". Measured rather than enumerated:
|
|
274
|
+
40 characters of `ISO_IR 192` is 120 bytes, and `\ISO 2022 IR 100` with `ESC 2/13 4/1` plus 64
|
|
275
|
+
single-byte characters is 67 bytes carrying 64 counted characters and parses with no warnings. Both
|
|
276
|
+
raise the code, and both pins use files that really declare the repertoire rather than fixtures that
|
|
277
|
+
assert bytes are characters. Read the code as "a Value in this attribute is over 64 bytes", which is
|
|
278
|
+
what is measured, never as "the attribute is non-conformant".
|
|
279
|
+
|
|
280
|
+
The message is the frozen registry string with nothing substituted: **no value, no length, no count
|
|
281
|
+
of how many Values are over, and no origin.** 64 is a constant of the VR; the offending Value's own
|
|
282
|
+
length is a measurement over document content, which is the number `DICOM-DIAGNOSTIC-PHI-RESIDUALS`
|
|
283
|
+
bound out of six other messages, and which of the two sources a Value came from is not decidable
|
|
284
|
+
when they are equal. **`position.byteOffset` locates the prior element and is `0` when there was
|
|
285
|
+
none**: this is the first method code that can be raised on a Data Set carrying no `(0012,0063)` at
|
|
286
|
+
all, so on the caller route the offset is a sentinel rather than a location, and both routes are
|
|
287
|
+
pinned so the `0` reads as an absence. Emitted by `deidentify()` only, so it never reaches the
|
|
288
|
+
parser's `{ strict: true }` escalation and cannot refuse a conformant file.
|
|
289
|
+
|
|
290
|
+
Adding a warning code is a public-surface change and the `WARNING_CODES` snapshot moves with it.
|
|
291
|
+
|
|
292
|
+
Still open and unchanged, a backlog line rather than a rider on this one: `(0012,0063)` records the
|
|
293
|
+
**union** of the options ever applied rather than a per-run history, so a later pass with fewer
|
|
294
|
+
options leaves no trace an earlier one had more. The direction is conservative (the union
|
|
295
|
+
over-states retention, never understates it), and changing it would rewrite what every run writes
|
|
296
|
+
and reopen the unbounded growth the fixed-point rule and the ceiling guard exist to stop.
|
|
297
|
+
|
|
298
|
+
- 4bc6930: Point the repo's PHI gate at the corpus it was named for (`PHI-SCAN-WALK-ROOT-SCOPE`).
|
|
299
|
+
|
|
300
|
+
`scripts/phi-scan.ts` rooted its walk at `test/fixtures/`, and every file this package writes there
|
|
301
|
+
is gitignored because the suite regenerates it on each run. So the fixture corpus contributed exactly
|
|
302
|
+
zero files and an all-mode run opened thirteen: the README and the twelve pages under
|
|
303
|
+
`docs-content/`. Against 226 tracked files at `8982a16`, 213 were outside the all-mode walk, 82 of
|
|
304
|
+
them under `test/`; one of those 82 was still reachable by `--staged`, so the figure for neither
|
|
305
|
+
route is 212 and 81.
|
|
306
|
+
|
|
307
|
+
That mattered here more than the number suggests, because this package ships no committed `.dcm`
|
|
308
|
+
files at all: every fixture it owns is built in a `.ts` source by `test/helpers/build-dicom.ts`, so
|
|
309
|
+
the whole committed fixture corpus was in the 81 tracked files the walk root excluded. The root is
|
|
310
|
+
now `test/`, which replaces `test/fixtures/` rather than joining it so the roots stay disjoint, and
|
|
311
|
+
`--staged` covers the same three roots. Head opens 95 of 229 tracked files. All 81 newly opened files
|
|
312
|
+
were hand-read before this landed and none carries patient-identifying content; the synthetic fixture
|
|
313
|
+
names and dates the gate now sees are added to `scripts/phi-allow-list.txt` as exact entries, never
|
|
314
|
+
as prefixes. That file is global and has no path scoping, so those entries are excused in every
|
|
315
|
+
corpus the scanner opens and not only in the one that needed them: the two worth naming are
|
|
316
|
+
`DATE:19800101` and `DATE:20240115`. Path scoping is an allow-list format change and is deliberately
|
|
317
|
+
not made here.
|
|
318
|
+
|
|
319
|
+
Enumerating buys the recognizer floor and nothing else, so the second half shipped with it.
|
|
320
|
+
`scanEmbeddedObjects` decoded base64 DICOM objects only for a name in `TEXT_EXTENSIONS`. Measured on
|
|
321
|
+
`8982a16` with one object and one name-bearing PatientName: found as `probe.md`, found as
|
|
322
|
+
`probe.dcm`, and `OK - no hits` as `probe.ts`. The decode now runs on the non-`isDicom` branch too,
|
|
323
|
+
in addition to the text sweep and never instead of it.
|
|
324
|
+
|
|
325
|
+
Four shapes let a declared root go unopened while the gate printed clean, all measured on
|
|
326
|
+
`8982a16` and all now refusals with exit 2, the code this script's own contract gives an invocation
|
|
327
|
+
error: a missing root (exit 0), a dangling symlink at a root (exit 0, because `existsSync` follows
|
|
328
|
+
the link and answers false so the walk returned before `readdirSync`), a symlink at a real directory
|
|
329
|
+
(exit 0, followed and walked), and a regular file at a root (exit 0 at `test`, and an uncaught
|
|
330
|
+
`ENOTDIR` exiting 1 at `test/fixtures`). Existence is not observation, so the emptied-root half is
|
|
331
|
+
closed separately by reconciling the walked set against `git ls-files`: every tracked path under a
|
|
332
|
+
declared root must be opened, gitignored or corpus-exempt, and anything else refuses. No scanned-file
|
|
333
|
+
count is printed, deliberately: a count counts the roots that did exist.
|
|
334
|
+
|
|
335
|
+
Still open and disclosed rather than claimed away: the reconciliation compares path sets, not the
|
|
336
|
+
bytes git carries at those paths, so a working tree that mirrors the tracked names still exits 0 over
|
|
337
|
+
decoy contents. A test pins that escape rather than a fix. Widening the root makes it narrower, not
|
|
338
|
+
closed. Also unchanged: `src/`, `vendor/`, `scripts/` and the root files are still outside the
|
|
339
|
+
declared scope, because a generated DICOM tag table produces hundreds of matches on tag numbers that
|
|
340
|
+
satisfy the `YYYYMMDD` shape and a pinned standards document is full of real publication dates.
|
|
341
|
+
Admitting them is a product call with its own false-positive surface.
|
|
342
|
+
|
|
343
|
+
The one corpus exemption is the one that was already here, `test/fixtures/phi-scan/README.md`, which
|
|
344
|
+
documents the deliberate violator values. It is now ONE LITERAL PATH rather than the old
|
|
345
|
+
skip-any-`readme.md`-the-walk-meets rule, so widening the root did not widen it by a file, and it is
|
|
346
|
+
printed on stdout every run. It stays on the `all` route alone: `--staged` has never applied it, and
|
|
347
|
+
teaching it to would subtract a detection the base had on the route the pre-commit hook runs, so the
|
|
348
|
+
two routes disagree about exactly that one file as they did on base, with `--staged` the stricter.
|
|
349
|
+
No new exemption was added: the values the scanner's own tests must have it reject are assembled at
|
|
350
|
+
runtime in `test/helpers/phi-scan-violators.ts`, so that file is scanned like any other.
|
|
351
|
+
|
|
352
|
+
One error path is fixed rather than disclosed, because widening the walk root enlarged it: `main()`
|
|
353
|
+
had no top-level catch, so an unexpected throw (a `readdirSync` `EACCES` on an unreadable
|
|
354
|
+
subdirectory) exited 1, the code that means "PHI was found". It now exits 2 and says the scan did not
|
|
355
|
+
complete. Also disclosed: the reconciliation is vacuous on an empty index, since a legitimately empty
|
|
356
|
+
`git ls-files` answer is indistinguishable from nothing to check.
|
|
357
|
+
|
|
358
|
+
Gate-only: no runtime, API or parser behaviour changes, and no published surface moves.
|
|
359
|
+
|
|
3
360
|
## 0.0.16
|
|
4
361
|
|
|
5
362
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -338,7 +338,9 @@ 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, no wire-length parameter and no count of the bytes left in the buffer**, so none of them can be interpolated
|
|
341
|
+
Thrown by `parseDicom` on one of the 4 Tier-3 fatal codes. Carries the byte position, the **frame** that position is counted in, 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. The reason is not that such a fatal fires only on a lying length field: it fires on an honestly truncated file too, where nothing is fabricated and the transport simply lost bytes. Measured on a spec-clean object cut short by two bytes, `ELEMENT_LENGTH_EXCEEDS_BUFFER` raises with every declared length in the file honest. The bound covers both because the withheld numbers are four bytes a sender wrote either way, and because on the desynchronized reading those four bytes are somebody's name: an under-declaring `ST` carrier holding `"MR BRAIN SMITHSON "` once rendered `declared length=1330858068`, which is `"THSO"`.
|
|
342
|
+
|
|
343
|
+
`err.byteOffset` locates the element, and **`err.offsetFrame` says which coordinate system that number is counted in** (`OFFSET_FRAMES`: `"input"`, `"inflated-dataset"`, `"value-slice"`). Only in `"input"` is it an index into the buffer you passed in. A defined-length Sequence Item is parsed from a slice, so an offset raised inside one counts from that Item, and the same file reports `0`, `24` or `40` for the same defect depending on where in the Item it sits. **Where a slice begins is deliberately not published**, because the distance between two frames is a declared Value Length off the wire. The frame is in the `Error.message` suffix too, which now reads `(offset=N frame=F)`, so a string match on that suffix stops matching. `err.code` is unchanged. `DicomParseWarning.position` still carries no frame beyond its `deflated` flag, and `Element.byteOffset` carries none at all: both are pre-existing and neither is closed here. On every fatal but one the snippet is raw input, cut in the frame that offset is counted in: treat it as PHI and redact it at your own boundary. The exception is `UNSUPPORTED_TRANSFER_SYNTAX`, where the slot carries PS3.6's own name for the unsupported UID (`"RLE Lossless"`) when the registry publishes one, and 16 raw bytes only when it does not. That is deliberate, it predates the frame, and it is named here rather than left to a universal that would be false on the first code a compressed object reaches.
|
|
342
344
|
|
|
343
345
|
```ts
|
|
344
346
|
import { parseDicom, DicomParseError, FATAL_CODES } from "@cosyte/dicom";
|
|
@@ -377,7 +379,7 @@ Thrown by `serializeDicom` for `MISSING_TRANSFER_SYNTAX` (the dataset names no t
|
|
|
377
379
|
- **`RetainSafePrivate` retains nothing inside ANY Item of a sequence whose item stream over-runs its own declared length.** An Item that declares more bytes than its enclosing Sequence does absorbs whatever follows the sequence, so a private data element the sender wrote at the **root** - with no Private Creator reserving its block there - can land beside a creator that is genuine Item content and be kept verbatim. Measured on the published `0.0.8` tarball it was, with `report.removedPrivateTags` reading `[]`, the object stamped `(0012,0062) Patient Identity Removed = YES` and `ds.warnings` empty: a stamp that outran the redaction. (**There is no `0.0.9` on the registry** - `package.json` carried it and the publish never happened, an npm `E404` - so earlier wording here that said "through `0.0.9`" named a version that does not exist. Measured on the current published `0.0.10`, this direction is fixed.) PS3.5 2026c §7.8.1 scopes a private block reservation to the Item ("The scope of the reservation is just within the Item"), so a file that contradicts itself about where the Item ends does not determine which reservation covers an element; PS3.15 2026c §E.3.10 retains only what is **known** safe and requires that "all other Private Attributes shall be removed **or processed in the element-specific manner recommended by Deidentification Action (0008,0307), if present within Private Data Element Characteristics Sequence (0008,0300)**" (a two-branch clause; this library does not implement `(0008,0307)`, so removal is the branch available to it). So every private element the recursion **reaches** inside such a sequence, at every depth below it, is removed and named in `report.removedPrivateTags`. **The unit is the SEQUENCE, not the individual Item**: `descendSequence` decides once and applies it to every item, so a two-item sequence where only item 1 over-runs also loses a genuine block sitting in the honest item 0 (measured; the grid sweeps single-item fixtures, so that extra cost is NOT inside the priced 20). **The one route this rule did not reach is closed** (`DICOM-PRIVATE-SQ-CARVE-OUT`): a **private `SQ`** the profile vouches for was kept verbatim before the descent ran, so nothing inside it was examined; it is now walked like any other sequence, and a private element the file's own length fields pulled into its items is refused there. **The direction is not fail-safe on its own and the opposite claim is retracted**: it depends on where the sender put the Private Creator. Both **absorb** placements are pinned by tests, and so is the **eject** placement (below). Treat `DICOM_ITEM_CROSSES_SEQUENCE_END` as "this file's structure is in dispute", never as a statement about what was retained. **The price is measured, not asserted**: on `scripts/measure-sq-bound-grid.ts`, 58 cells lose a private value and **20 of them were not leaking anything** (the creator and the data element were both genuine Item content). Retention on a file that does not contradict itself is unchanged, and **no reading changes** - no parser file is touched. This is not a parser bound: an over-declaring Item and an under-declaring Sequence are byte-identical, so nothing on the wire can separate them.
|
|
378
380
|
- **`RetainSafePrivate` also retains nothing a Data Set holds AFTER a sequence whose own contents contradict the extent it declared.** The mirror direction: an Item that _under_-declares **ejects** its trailing elements out into the enclosing Data Set, and a Private Creator that lands there reserves a block for elements the sender never put beside it - the same false attestation. **Measured on the published `0.0.10` tarball, which is the current registry version**: `report.removedPrivateTags` `[]`, the value in the serialized output, `(0012,0062) = YES`, `ds.warnings` empty and no throw under `{ strict: true }`. It applies **in every Data Set, not only the root**: an inner sequence ejecting a creator into the still-usable Item that encloses it is measured and pinned. The cut is **positional**, so a reservation the sender wrote **ahead of** the offending sequence is untouched, and the same reservation written **after** it is refused. **Two predicates, because the parser records the same contradiction two ways**: under Explicit VR the item stream over-runs and the span shows up as `rawBytes.length > length`; under Implicit VR LE that path slices the item stream, so nothing over-runs at all and the descent is refused instead (`DICOM_SQ_NOT_DESCENDED`, `Element.items === undefined`). The second predicate is **broader than the ejection it is here for, deliberately**: it says the parser could not walk the sequence, which other unwalkable item streams also reach. **And two BOUNDS, not one, because a Data Set is a `Map<Tag, Element>`**: an ejected element whose tag the Data Set already holds overwrites in place and inherits the earlier element's position, ahead of the sequence it came out of, so `Element.byteOffset` is checked beside the index. **That collision also destroys the root's own value at parse time** - `PRE-EXISTING`, unchanged here, and pinned by a test so it is not mistaken for something this rule handles. It was silent when that was written; `DICOM_DUPLICATE_TAG_IN_DATA_SET` reports it now (below), and the destruction itself is still unchanged. **The price is measured**: on `scripts/measure-sq-bound-grid.ts` all **22** cells that retained a private value at the root on a self-contradicting file whose honest control removes it go to **0**, at the cost of **56** further root retentions on self-contradicting files whose honest control does keep the value (the column is `de-identified OUTPUT lost a marker (cost)`, reading 78; the parse-tree `LOST`/`GAINED a marker value` counters beside it read 0). Retention on a file that does not contradict itself is unchanged (9 at the root, 6 in an Item), and **no reading changes**: 0 cells differ in any parse respect, 0 cells whose reading differs, and no parser file is touched.
|
|
379
381
|
- **An over-declared Value Length into a binary carrier still leaks, and it is a known trade.** The mirror case - an _over_-declared length that swallows the following element into an `OB`/`OW`/`US`/`UN` value - is **not** detected, and a `(0010,0020)` inside it reaches de-identified output with no warning and no report entry. Arbitrary bytes are exactly what those VRs are for, so no content test can decide it; the only candidate remedy was measured and empties conformant binary values (a legal LUT or blob deleted because 8 of its bytes read as a zero-length `(0010,0020)`). String carriers **are** covered, because there the same bytes are provably outside the VR's repertoire. If you accept files from a sender you do not control, treat a binary attribute's length as untrusted.
|
|
380
|
-
- **`(0012,0063)` De-identification Method is ADDED TO, so a prior de-identifier's text is carried into the output.** PS3.15 2026c §E.1.1 says a text string describing the method "shall be inserted in or added to" that attribute, and this library used to **replace** it - destroying the provenance chain the attribute exists to carry, silently, on a file whose earlier pass may be the one a recipient was relying on. It now appends its own text as a further value of the `1-n` attribute, after a `\`, copying the prior bytes through verbatim. **The method string is itself a `1-n` value**: it is split on `\` and only the values not already recorded are added. **Trailing SPACE and NUL are padding rather than content** (PS3.5 2026c Table 6.2-1's `LO` row, which describes a **Value**, and `LO` is `1-n`), so they are ignored **per value, on both sides** when your string is matched against what is already recorded, and trimmed from the value written. Repeated de-identification is therefore a fixed point **from the first pass**, for every string: with or without a delimiter, and with a pad byte on any value, last or not. Leading spaces are yours and are written through untouched; a method string that is padding only records nothing. **One bound, and it is not cosmetic**: `LO` is a short-form VR whose Value Length field is 16 bits, so when the join would not fit, the prior value is **replaced** rather than added to and `report.warnings` carries `DICOM_DEIDENT_METHOD_NOT_ADDED`. Read that code as "the length ceiling was reached", never as "every fallback is disclosed": a `(0012,0063)` a file encoded under a VR other than `LO` is also replaced, and that one has its own code, `DICOM_DEIDENT_METHOD_NOT_LO`. An element the serializer cannot encode would take the whole de-identified object down, and truncating the sender's earlier records instead would be a policy the standard does not state. A `deidentificationMethod` longer than that ceiling on its own is unchanged from every earlier release and still fails to serialize. **The cost is stated rather than glossed**: `(0012,0063)` is **not in Table E.1-1**, so the Basic Profile keeps it exactly as it keeps every other unlisted attribute, and a sender who wrote something identifying into it now sees that text in de-identified output where the replacement used to remove it as a side effect. That is the retained-by-omission posture of every unlisted attribute, not a rule this insertion states; it is pinned by a residual test that asserts the cost. **It is not silent**: `report.warnings` carries `DICOM_DEIDENT_METHOD_PRIOR_RETAINED` whenever prior bytes from the source file survive into `(0012,0063)`, because an object stamped `(0012,0062) = YES` carrying text nothing in the run inspected is a stamp that outran the redaction. It is **not** on `report.retained` - that field lists the Annex E option sets active for the run, and a kept attribute is not one of them. `(0012,0062)` Patient Identity Removed is still **replaced** with `YES` - the same paragraph of §E.1.1 uses the other verb for it ("shall be replaced or added to"), and the asymmetry is the standard's. A `(0012,0063)` a file encoded under a VR other than `LO` is replaced rather than appended to, and `report.warnings` now carries `DICOM_DEIDENT_METHOD_NOT_LO` when it is: the join concatenates `LO` values with the `5CH` delimiter, which is not defined over arbitrary octets, so replacing is deliberate and only the silence was the defect. **And the method text this library writes for itself is now multi-valued, one Value for the Profile and one per active Option.** PS3.5 2026c Table 6.2-1 caps an `LO` at **64 characters per Value** and `(0012,0063)` is `1-n`; the single-value string it wrote before measured **76** characters with no options, **130** with three and **272** with all nine, so every object de-identified without a caller-supplied method, in every release up to and including this one, carried a value no `LO` may legally hold - in the one attribute a strict receiver reads to decide whether the object was de-identified at all. **Re-de-identifying such an object keeps that 76-character Value**, because a prior record is added to rather than rewritten (measured: a flat 138 bytes over four passes, values of 76 and 61, `DICOM_DEIDENT_METHOD_PRIOR_RETAINED` raised for the retention
|
|
382
|
+
- **`(0012,0063)` De-identification Method is ADDED TO, so a prior de-identifier's text is carried into the output.** PS3.15 2026c §E.1.1 says a text string describing the method "shall be inserted in or added to" that attribute, and this library used to **replace** it - destroying the provenance chain the attribute exists to carry, silently, on a file whose earlier pass may be the one a recipient was relying on. It now appends its own text as a further value of the `1-n` attribute, after a `\`, copying the prior bytes through verbatim. **The method string is itself a `1-n` value**: it is split on `\` and only the values not already recorded are added. **Trailing SPACE and NUL are padding rather than content** (PS3.5 2026c Table 6.2-1's `LO` row, which describes a **Value**, and `LO` is `1-n`), so they are ignored **per value, on both sides** when your string is matched against what is already recorded, and trimmed from the value written. Repeated de-identification is therefore a fixed point **from the first pass**, for every string: with or without a delimiter, and with a pad byte on any value, last or not. Leading spaces are yours and are written through untouched; a method string that is padding only records nothing. **One bound, and it is not cosmetic**: `LO` is a short-form VR whose Value Length field is 16 bits, so when the join would not fit, the prior value is **replaced** rather than added to and `report.warnings` carries `DICOM_DEIDENT_METHOD_NOT_ADDED`. Read that code as "the length ceiling was reached", never as "every fallback is disclosed": a `(0012,0063)` a file encoded under a VR other than `LO` is also replaced, and that one has its own code, `DICOM_DEIDENT_METHOD_NOT_LO`. An element the serializer cannot encode would take the whole de-identified object down, and truncating the sender's earlier records instead would be a policy the standard does not state. A `deidentificationMethod` longer than that ceiling on its own is unchanged from every earlier release and still fails to serialize. **The cost is stated rather than glossed**: `(0012,0063)` is **not in Table E.1-1**, so the Basic Profile keeps it exactly as it keeps every other unlisted attribute, and a sender who wrote something identifying into it now sees that text in de-identified output where the replacement used to remove it as a side effect. That is the retained-by-omission posture of every unlisted attribute, not a rule this insertion states; it is pinned by a residual test that asserts the cost. **It is not silent**: `report.warnings` carries `DICOM_DEIDENT_METHOD_PRIOR_RETAINED` whenever prior bytes from the source file survive into `(0012,0063)`, because an object stamped `(0012,0062) = YES` carrying text nothing in the run inspected is a stamp that outran the redaction. It is **not** on `report.retained` - that field lists the Annex E option sets active for the run, and a kept attribute is not one of them. `(0012,0062)` Patient Identity Removed is still **replaced** with `YES` - the same paragraph of §E.1.1 uses the other verb for it ("shall be replaced or added to"), and the asymmetry is the standard's. A `(0012,0063)` a file encoded under a VR other than `LO` is replaced rather than appended to, and `report.warnings` now carries `DICOM_DEIDENT_METHOD_NOT_LO` when it is: the join concatenates `LO` values with the `5CH` delimiter, which is not defined over arbitrary octets, so replacing is deliberate and only the silence was the defect. **And the method text this library writes for itself is now multi-valued, one Value for the Profile and one per active Option.** PS3.5 2026c Table 6.2-1 caps an `LO` at **64 characters per Value** and `(0012,0063)` is `1-n`; the single-value string it wrote before measured **76** characters with no options, **130** with three and **272** with all nine, so every object de-identified without a caller-supplied method, in every release up to and including this one, carried a value no `LO` may legally hold - in the one attribute a strict receiver reads to decide whether the object was de-identified at all. **Re-de-identifying such an object keeps that 76-character Value**, because a prior record is added to rather than rewritten (measured: a flat 138 bytes over four passes, values of 76 and 61, `DICOM_DEIDENT_METHOD_PRIOR_RETAINED` raised for the retention). Your own `deidentificationMethod` is **not** bounded for you either: split it on `\` yourself if a strict receiver is in your path. **Neither over-long Value is silent any more.** Whenever the `(0012,0063)` this run writes carries a Value longer than 64 bytes, from whichever of those two sources, `report.warnings` carries `DICOM_DEIDENT_METHOD_VALUE_OVER_LENGTH`. It is a disclosure and not a bound: nothing is shortened, split or truncated, because either would invent a de-identification record nobody wrote. The measurement is over **bytes**, so it can never miss a Value that is genuinely over the maximum, and it over-reports on any conformant Value whose bytes outnumber its counted characters: PS3.5 2026c §6.2 specifies those lengths in characters rather than bytes and excludes Code Extension escape sequences from the count. The message carries no value, no length, no count and no origin, and `position.byteOffset` locates the prior element where there was one and is `0` on the caller route, where there is no element to locate.
|
|
381
383
|
- **Networking & web.** No DIMSE (C-STORE/FIND/MOVE, MWL, MPPS); no DICOMweb (QIDO/WADO/STOW). → `@cosyte/dicom-net`, `@cosyte/dicomweb`.
|
|
382
384
|
- **Transcoding.** No transfer-syntax conversion. The serializer re-emits in the dataset's source syntax only.
|
|
383
385
|
- **Terminology resolution.** Coded values are surfaced (designator + canonical source) but not validated against SNOMED/LOINC/etc.
|