@cosyte/dicom 0.0.3 → 0.0.5

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
@@ -6,6 +6,348 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
6
6
 
7
7
  ### Added
8
8
 
9
+ - **`README.md` now opens with the shared Cosyte lockup, which follows the reader's color scheme**
10
+ (`ASSETS-P8`). A `<picture>` block above the H1 carries the dark-ground org tile behind a
11
+ `prefers-color-scheme: dark` media query and the light-ground tile as the inner `<img>`, so the
12
+ mark sits on a ground that matches the page it is read on. It replaces the per-package banner,
13
+ which baked the package name and the one-line tagline into pixels while the two lines directly
14
+ beneath it repeated both: the lockup reads "Cosyte" where the H1 reads `@cosyte/dicom`, so the
15
+ duplication goes and the heading stays. The alt text is content rather than decoration, because it
16
+ is what a screen reader reads on the npm page, and it describes the mark itself rather than
17
+ repeating the heading below it.
18
+
19
+ **One stated reason is corrected rather than dropped.** The per-package banner was chosen as a
20
+ plain markdown image, with no `<img>` and no `<picture>`, expressly because whether npm's markdown
21
+ sanitizer preserves a `<picture>` was unverified. It has since been measured on a published
22
+ package page: the `<img>` is hoisted out of its `<picture>` by npm's anchor wrapper, so the light
23
+ cut renders there, and npmjs.com has no dark mode, so that is the correct cut. A renderer that
24
+ strips `<source>` still renders the inner `<img>`, so the worst case is a light-ground mark on a
25
+ dark page, never a missing or broken image. Both tile URLs were rechecked with `curl -I` as
26
+ `200 image/png`, 10513 and 10455 bytes, before this landed, rather than taken from the `live` flag
27
+ in `assets/published-urls.json`, whose own `$fields.status` note says to read `live` for what it
28
+ is: a declaration made on evidence from another repo, never a fact checked there.
29
+
30
+ ### Changed
31
+
32
+ - **`CHANGELOG.md` now records what each released version contained.** Every entry in this file sat
33
+ under `[Unreleased]` while npm served `0.0.1`, `0.0.3` and `0.0.4`, and `CHANGELOG.md` is in
34
+ `package.json#files`, so the file shipped inside the tarball telling a consumer that the version
35
+ they had installed was unreleased. That is a false claim on a distributed surface rather than
36
+ untidy bookkeeping. No entry's substance is rewritten: each is moved to the version that actually
37
+ shipped it, reconstructed from the tags, the GitHub releases, and the changesets each
38
+ "Version Packages" commit consumed. `0.0.2` gets no heading because it was never published: it was
39
+ version-bumped on 2026-07-27 and the publish that followed did not run, so its four changesets
40
+ reached npm inside `0.0.3` and are recorded there. That is also why npm's version list skips
41
+ `0.0.2`.
42
+
43
+ ### Fixed
44
+
45
+ - **`README.md` documented an element-access API that does not exist.** `Dataset.get` / `has` /
46
+ `getAll` take the 8-character `(group,element)` tag (`"00080060"`, case-insensitive) and nothing
47
+ else, but the README's quickstart, feature list, access-pattern section, typed-value examples and
48
+ Philips vendor note all showed `ds.get("Modality")` and `ds.get("(0008,0060)")`. Both forms return
49
+ `undefined`, and because the `Tag` type is `string` the compiler catches neither: the first line a
50
+ reader copied out of the quickstart silently produced nothing. Measured against the built package
51
+ rather than read off the source. The identical defect was found and corrected in
52
+ `docs-content/intro.md` before `0.0.3` (see below) and the README was not swept with it, which is
53
+ the reason to state the rule once here: `get` takes a tag, and a keyword is resolved to its tag
54
+ through `Dictionary.byKeyword(...)?.tag` first. Every example is corrected and the section is
55
+ retitled from "By keyword or tag" to "By tag". `docs-content/spec-notes-model.md` carried a
56
+ neighbouring defect and is corrected with it: it said `getAll` "returns every element at a
57
+ repeating tag", where a `Dataset` holds at most one element per tag, so it returns 0 or 1.
58
+ - **`README.md` claimed compressed transfer syntaxes were readable at the structural level.** It
59
+ said the supported set was the four v1 syntaxes "and any compressed syntax at the structural level
60
+ (fragments preserved)". The dispatch table holds exactly four entries and any other Transfer
61
+ Syntax UID is the fatal `UNSUPPORTED_TRANSFER_SYNTAX`, so a JPEG, JPEG-LS, JPEG2000, RLE or HTJ2K
62
+ object does not parse at all. Confirmed by parsing a synthetic object in `1.2.840.10008.1.2.4.50`
63
+ and catching the throw. Deflated Explicit VR LE is the one compressed syntax that is supported: it
64
+ deflates the dataset stream rather than the pixels, and the corrected text says so rather than
65
+ calling every compressed syntax unreadable. This mattered most in the "index a folder of studies"
66
+ recipe, whose closing line promised that "nothing here throws on a quirky file" while a real
67
+ archive is full of pixel-compressed objects. That recipe now names all four Tier-3 conditions a
68
+ folder walk meets (`UNSUPPORTED_TRANSFER_SYNTAX`, `INVALID_FILE_META` for a truncated or
69
+ partly-copied file, `NOT_DICOM_PART_10` for a non-DICOM file in the folder, `EMPTY_INPUT` for a
70
+ zero-byte one) and says to catch `DicomParseError` per file and skip. The non-goals entry no
71
+ longer implies a compressed object is read structurally either.
72
+ - **`README.md` counted four typed errors where five are exported.** The Error Handling section
73
+ introduced "four typed errors" and then documented `DicomParseError`, `DicomValueError`,
74
+ `DicomSerializeError`, `ProfileDefinitionError` and `DeidentifyError`. The count is now five. The
75
+ same paragraph said warnings are "never thrown", which a profile's `escalate` list contradicts. It
76
+ now says warnings are data unless you ask otherwise, and names `escalate` as the route that asks.
77
+ - **`docs-content/` stated the published version as `0.0.1`, two published releases stale.**
78
+ `installation.md`'s status note and `troubleshooting.md`'s scope entry both named `0.0.1` while
79
+ npm served `0.0.4`. Neither quotes a version any more. Both send the reader to the registry, which
80
+ is the only thing that cannot go stale, by naming the command that reads it.
81
+ `troubleshooting.md` also stopped sending readers to `CLAUDE.md`, an internal file that does not
82
+ ship in the tarball.
83
+
84
+ ## [0.0.4] - 2026-07-30
85
+
86
+ ### Added
87
+
88
+ - **The element registry is now generated from NEMA's PS3.6 DocBook, the normative publication**
89
+ (`DICOM-UPSTREAM-EDITION-LAG`). `vendor/nema/part06/` pins `part06.xml` (PS3.6 **2026c**) by
90
+ SHA-256, and `scripts/generate-dictionary.ts` applies it as a **per-field overlay** over the
91
+ Innolitics mirror: for a tag both sources carry, PS3.6 wins on name, keyword, VR, VM and
92
+ retirement; a tag PS3.6 carries and the mirror does not is added; a tag the mirror carries and
93
+ PS3.6 does not is **kept**, because PS3.6 retires elements rather than deleting them, so an absence
94
+ is far more likely to be a parse gap here than a withdrawal there and dropping the entry would turn
95
+ a decoded element into an unknown one (that set is empty today, and the generator prints its size).
96
+ The overlay is scoped to the four registry tables (6-1, 7-1, 8-1, 9-1). UIDs stay on the
97
+ `sops.json` + curated path deliberately: `uids.ts` carries the four short forms every DICOM toolkit
98
+ uses instead of PS3.6's "...: Default Transfer Syntax for ..." clauses, and retirement as a
99
+ structured `retired` boolean rather than a trailing " (Retired)" glued into the name, and an
100
+ overlay would undo both. PS3.15 Annex E is a different part and a different generator, unchanged.
101
+ This is the durable fix for the edition lag recorded in `vendor/innolitics/README.md`: the mirror's
102
+ pin is exactly current against its own upstream, but that upstream last refreshed its data on
103
+ 2024-04-18, so the tables sat on PS3.6 2024b against a current 2026c and no re-pinning discipline
104
+ could have moved them. The registry no longer comes from a source whose regeneration cadence is not
105
+ ours.
106
+ **The pin is a precondition, not a comment.** The generator recomputes the SHA-256 of the file it
107
+ reads and refuses to generate on a mismatch, reads the edition from the document's own
108
+ `<subtitle>` rather than asserting it, and fails loudly rather than emitting a thinner dictionary
109
+ on a row that is not six cells, a malformed `(gggg,eeee)` tag cell, a non-identifier keyword, an
110
+ unrecognized VR token, or a total under 5,000 registry rows. Two traps in the DocBook are handled
111
+ explicitly and covered by tests: the keyword column carries **13,470 ZERO WIDTH SPACE** line-break
112
+ hints in 2026c, one of which left in produces a keyword that looks right and never matches; and the
113
+ sixth column carries `DICOS` and `DICONDE` dictionary markers alongside `RET (edition)`, so reading
114
+ it as a boolean would retire **391** live tags. Only a leading `RET` retires. Row accounting is
115
+ the part that had to be got right rather than merely asserted: every `<tr>` in a registry table
116
+ must resolve to a matched body row or a header row, which covers unrecognized row markup, a second
117
+ `<tbody>`, and a row outside any `<tbody>` in one check. Counting row opens inside the already
118
+ truncated first-`<tbody>` slice, as an earlier draft did, structurally cannot see the rows it
119
+ dropped: splitting Table 6-1 in two made that version read 207 rows instead of 5,309. Cell text
120
+ is likewise stripped of markup to a fixpoint and then refused if any `<` or `>` survives, rather
121
+ than stripped in a single pass that can leave a residue which reassembles into another tag.
122
+ Every run prints the overlay it applied (shared / added / mirror-only, and the fields overridden
123
+ broken out by field, **with every VR override listed individually**), so a future re-pin of either
124
+ source shows what moved instead of a 5,000-line diff. There is deliberately **no staleness clock**:
125
+ a date-based gate would fire the day it was written, demand an action nobody can take on demand
126
+ (NEMA publishes when NEMA publishes), red unrelated pull requests, and train people to bump a date
127
+ instead of re-deriving anything. "Has NEMA published a new edition" is one command against
128
+ _content_, documented in `vendor/nema/README.md`. What is gated in CI is unchanged and offline:
129
+ the committed dictionary must be byte-for-byte what the pinned inputs produce.
130
+
131
+ ### Changed
132
+
133
+ - **The PS3.5 repeating-group bound is now derived from pinned normative documents rather than
134
+ transcribed (`DICOM-PS35-NOT-VENDORED`). No runtime impact: the emitted bound is identical and no
135
+ public API moves.** `deidentify()` expands PS3.15 Table E.1-1's `(50xx,xxxx)`, `(60xx,3000)` and
136
+ `(60xx,4000)` rows over the concrete groups PS3.5 section 7.6 admits, sixteen even groups per mask
137
+ (`5000`-`501E`, `6000`-`601E`), not 256. That bound is a safety limit in **both** directions, and it
138
+ was the last input here that lived as a **quotation in a source file** while PS3.6 and PS3.15 were
139
+ SHA-pinned and re-hashed before use, so the generator's guard could catch a new mask _prefix_ but
140
+ never a changed _bound_.
141
+ `vendor/nema/part05/` pins `part05.xml` (PS3.5 **2026c**) and `vendor/nema/part05-2004/` pins
142
+ `04_05pu.pdf`, both re-hashed as a precondition, and `scripts/generate-repeating-groups.ts` emits
143
+ `src/dictionary/generated/repeating-groups.ts`, which `src/dictionary/repeating-groups.ts`
144
+ re-exports. **Two documents, because the bound is split across two editions:** the current one
145
+ states the overlay bound normatively and excludes the odd `6001`-`601F`, but says nothing about the
146
+ curve bound, having retired curve encoding and delegated it to PS3.5-2004 by URL in section 7.6's
147
+ own Note. So the 2004 PDF is the authority the edition in force names, not a convenience copy. The
148
+ generator **proves that delegation** (section 7.6 must link exactly the vendored URL) instead of
149
+ assuming it, and because both editions state the overlay bound it **requires them to agree**.
150
+ `gen:repeating-groups` runs first in `gen:all`, since the Annex E generator expands family rows
151
+ through this bound.
152
+ **The values did not change; their falsifiability did.** Moving the overlay bound in either
153
+ vendored edition reds the cross-check, moving the curve bound in the 2004 edition moves the emitted
154
+ artifact (caught by the byte-identical regen gate), and removing the delegation link stops the 2004
155
+ document being used at all. Each was confirmed non-vacuous by disabling the guard and watching the
156
+ test fail. Reading a 2004 PDF needs a PDF reader; the one in that generator is deliberately minimal
157
+ (Node `zlib` only, one sentence recovered, matched against a precise expected shape) and is not to
158
+ be grown into a general parser. There is no staleness clock, for the same reasons as PS3.6 and
159
+ PS3.15.
160
+ - **Recorded what the vendor pin is actually current against (`DICOM-DICT-CURRENCY`).**
161
+ `vendor/innolitics/README.md` gains a measured Currency section and drops the "re-pin monthly"
162
+ policy, which nothing ran and which measured the wrong thing. The pin is exactly current against
163
+ upstream: `git ls-remote` resolves `master` to the pinned SHA and all four vendored files are
164
+ byte-identical to upstream at it. Upstream itself is the stale link, having last changed
165
+ `attributes.json` on 2024-04-18 ("Update standard to rev2024b"), so the tag tables are grounded in
166
+ PS3.6 2024b while the current edition is PS3.6 2026c. The drift is now measured rather than
167
+ assumed, against the NEMA DocBook source for 2026c, and every non-additive difference is named in
168
+ that file. Figures on `86ab6c1` (`origin/main` at measurement time; this slice does not touch
169
+ `tags.ts` or `keywords.ts`): all **5,129** committed tags are shared with PS3.6 2026c, and across
170
+ them there are **zero VR differences**, zero name differences and zero VM differences, with 2
171
+ keyword and 2 retirement-status differences. **Every tag this dictionary carries is still in
172
+ PS3.6 2026c**; the drift is otherwise purely additive, 180 tags the standard has gained. VR is
173
+ what turns bytes into a value, so zero VR drift is the result that matters for reading a real
174
+ study. Nothing in the dictionary is hand-edited to close the gap: it is generated, and the
175
+ remaining differences are recorded for the next re-pin. One method note lives with the
176
+ measurement, because getting it wrong is easy and was got wrong on the first run: DICOM tag values
177
+ are hexadecimal and their case is not semantic, so tag keys must be compared case-insensitively.
178
+ PS3.6 prints them uniformly uppercase and `tags.ts` agrees on 1,540 of its 5,129 keys, but
179
+ `tags.ts` lowercases the 8 repeating-group keys whose trailing digits are hex letters and no
180
+ others, so a verbatim comparison mis-classifies exactly those 8.
181
+
182
+ ### Fixed
183
+
184
+ - **`deidentify()` never removed and never reported the repeating-group attributes PS3.15 marks `X`**
185
+ (`DICOM-REPEATING-GROUP-DEID-GAP`). Table E.1-1 states three of its rows as a group mask rather
186
+ than a single tag: `(50xx,xxxx)` Curve Data, `(60xx,3000)` Overlay Data and `(60xx,4000)` Overlay
187
+ Comments, all three marked **X** (remove). The matcher keyed attributes by exact tag, which cannot
188
+ express a mask, so all three rows were unreachable; `deidentify()` reads "not in the table" as
189
+ "not listed, keep", so a file carrying `(6000,4000)` free text came back with a **clean report and
190
+ the text still in it**. Overlay comments are a classic burned-in-identifier carrier, and
191
+ `DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED` gives no coverage here: it keys on `(7FE0,0010)` +
192
+ `(0028,0301)`, which describe the image, not the overlay planes. This shipped at `0.0.3`, and
193
+ `deid`'s `/dicom` adapter delegates here, so it had the same hole.
194
+ The three rows are now generated from the pinned PS3.15 DocBook as pattern rules and consulted on
195
+ an exact-tag miss. A matched element is **removed and reported**: the report entry carries the
196
+ concrete tag, the family's attribute name, and a new `DeidentifiedAttribute.repeatingGroup` field
197
+ naming the mask that matched, so an audit distinguishes a mask hit from a single-tag hit. The
198
+ `CleanGraphics` column is honoured on these rows exactly as the table states it. **The exact-tag
199
+ table wins** over a mask that would also cover the tag, as the more specific statement the standard
200
+ makes about it; 2026c publishes no such overlap and the generator counts and prints it every run.
201
+ **The mask covers the groups PS3.5 bounds it to, not any four hex digits.** PS3.5 §7.6: repeating
202
+ groups "shall only be allowed in the even numbered Groups 6000-601E", and PS3.5-2004 §7.6, which
203
+ the current edition's note delegates to for curves, states "even Groups (5000-501E,eeee)". Sixteen
204
+ groups per mask, not 256, and the same section says of the odd ones that there is "no implication
205
+ of repeating semantics". Both bounds are load-bearing in opposite directions: a hex wildcard would
206
+ remove attributes the standard never marked (data loss on a call asked to be conservative), and an
207
+ exact tag matched nothing. Odd groups in the overlay range are private and still go through the
208
+ private-attribute path.
209
+ **The generator now refuses a masked row on a prefix PS3.5 does not define as a repeating group**,
210
+ rather than printing it and carrying on, which is what let these three rows go missing. Proven by
211
+ mutation rather than asserted: against a Table E.1-1 carrying an injected `(7Fxx,0010)` family row
212
+ the pre-remedy generator exits 0 and drops it, the post-remedy one exits 1 and names the prefix. A
213
+ second mutation moves the Overlay Comments Basic Profile code `X` -> `K` and asserts the emitted
214
+ rule moves with it, so the action codes are read from the document rather than assumed. Every run
215
+ prints the rules emitted, the concrete groups each covers, the private family row it does not
216
+ emit, and the shadowing count.
217
+ - **`RetainLongitudinalTemporal` now documents which E.3.6 sub-option a caller actually gets.**
218
+ Behaviour is unchanged: PS3.15 §E.3.6 is two options and this package exposes one name carrying the
219
+ **full-dates** column, the less protective branch. It is `K` on all **169** rows where
220
+ modified-dates says `C`. The option's JSDoc and `docs-content/troubleshooting.md` now say so, and
221
+ say that date shifting is not performed at this layer. Splitting the option is a public-surface
222
+ change and is deliberately not made here.
223
+ - **`deidentify()` retained 35 patient-identifying attributes and reported nothing**
224
+ (`DICOM-ANNEX-E-DEID-LAG`). The PS3.15 Annex E action table came from a third-party mirror alone,
225
+ pinned at a snapshot whose Table E.1-1 data is 2024b-era. Current PS3.15 publishes 652 concrete
226
+ attributes; that snapshot carried 617. Because `annexE()` returns `undefined` for a tag it does not
227
+ carry and `deidentify()` reads `undefined` as "not listed, keep", every missing attribute came
228
+ through the call verbatim, and the `DeidentifyReport`, whose whole job is to say what was done,
229
+ said nothing about them. The caller's only signal was a clean return. This shipped at `0.0.3`.
230
+ **32 of the 35 are marked X** (remove) by the standard; the other three are `(0040,B020)` Waveform
231
+ Annotation Sequence (`X/D`), `(0070,0006)` Unformatted Text Value (`D`), and `(300A,0054)` Table Top
232
+ Position Alignment UID (`U`). The removals include `(0010,0011)` through `(0010,0016)`,
233
+ the person-names-to-use and pronoun block, of which `(0010,0012)` **Name to Use is a patient's
234
+ preferred name and survived verbatim**; `(0010,0041)` through `(0010,0047)`, the gender-identity
235
+ and sex-parameters-for-clinical-use attributes; `(0010,2161)` `EthnicGroupCodeSequence` and
236
+ `(0010,2162)` `EthnicGroups`, the two attributes that replaced the retired `(0010,2160)`
237
+ `EthnicGroup`; the four `(0008,130x)` diagnosis code sequences; and the waveform, montage,
238
+ acquisition-context and display-URI attributes at `(003A,xxxx)`, `(0040,Axxx)`, `(0040,Bxxx)` and
239
+ `(0040,E012)`. `(0032,1033)` `RequestingService` also gains the `CleanDescriptors: C` column the
240
+ mirror had dropped.
241
+ The fix is the same authority rule the data dictionary already uses, pointed at PS3.15.
242
+ `vendor/nema/part15/` pins `part15.xml` (PS3.15 **2026c**) by SHA-256, and
243
+ `scripts/generate-annex-e.ts` applies Table E.1-1 as a **per-field overlay** over the mirror: for a
244
+ tag both sources carry, PS3.15 wins on attribute name, Basic Profile action code, and all nine
245
+ metadata-affecting option columns; a tag PS3.15 carries and the mirror does not is added; a tag the
246
+ mirror carries and PS3.15 does not is **kept**, because the standard retires rather than deletes,
247
+ so an absence is far more likely to be a parse gap here than a withdrawal there and dropping the
248
+ entry would turn an attribute the de-identifier acts on into one it silently keeps. That set is
249
+ empty today, and its size prints on every run so the assumption stays observable. Nothing is
250
+ hand-corrected: every value is derived from the fetched normative bytes and reproduced by every
251
+ regen. No Basic Profile code changed for a tag both sources carried, which is the reassuring half
252
+ of the finding: the mirror was not wrong about what it had, only silent about what it lacked.
253
+ **The pin is a precondition, not a comment.** The generator recomputes the SHA-256 of the file it
254
+ reads and refuses to generate on a mismatch, reads the edition from the document's own
255
+ `<subtitle>` rather than asserting it, and fails loudly rather than emitting a thinner table on a
256
+ header row whose 15 column labels are not where the generator's indices expect them, a body row that
257
+ is not 15 cells, a tag cell it does not recognize, an action code outside Table E.1-1a in any
258
+ non-empty action column, an empty Basic Profile cell, a `<tr>` that resolves to neither a matched
259
+ body row nor a header row, or a total under 600 concrete rows. The header check is the one that
260
+ catches a column **reorder**, which a cell count cannot see and which would read one option's action
261
+ code as another's. Every run prints the overlay
262
+ it applied, **with every action-code override listed individually**, because a changed action code
263
+ is the one difference that decides whether an identifier survives.
264
+ Two exclusions are deliberate and now **printed on every run** instead of being assumed. Four rows
265
+ of Table E.1-1 state a family rather than a single tag (`(50xx,xxxx)` Curve Data, `(60xx,3000)`
266
+ Overlay Data, `(60xx,4000)` Overlay Comments, and `(gggg,eeee) where gggg is odd` Private
267
+ Attributes); an exact-tag map cannot key them, private attributes are removed through their own
268
+ path, and the three repeating-group rows remain a stated gap. And PS3.15's E.3.6 is two options,
269
+ full dates and modified dates, against this package's single `RetainLongitudinalTemporal`, which
270
+ carries the full-dates column; the two columns diverge on **169** of the 652 rows in 2026c, and
271
+ that count now prints rather than living in a comment that guessed "usually not".
272
+ There is deliberately **no staleness clock**, for the same reasons as PS3.6: a date gate fires the
273
+ day it is written, demands an action nobody can take on demand, and reds unrelated pull requests.
274
+ What CI gates is unchanged and offline: the committed table must be byte-for-byte what the pinned
275
+ inputs produce.
276
+ - **Three defects in the shipped data dictionary, all resolved from normative bytes**
277
+ (`DICOM-UPSTREAM-EDITION-LAG`). None is hand-corrected; each is what PS3.6 2026c prints, and each
278
+ is reproduced by every regen.
279
+ - **`(0010,2160)` `EthnicGroup` was marked current although PS3.6 retired it in 2025a**, and its
280
+ replacements were absent entirely. It is now `retired: true`, and `(0010,2161)`
281
+ `EthnicGroupCodeSequence` (`SQ`, VM 1) and `(0010,2162)` `EthnicGroups` (`UC`, VM 1-n) are
282
+ present. This is a real demographic-attribute change: an application reading only `EthnicGroup`
283
+ silently misses what current instances actually carry. Retired does not mean removed, so the
284
+ keyword still resolves and an older study still reads; the caller is told both what the tag is
285
+ and that it is no longer current.
286
+ - **`(3004,0012)` `DoseValue` was marked retired although PS3.6 defines it.** The `RET (2022d)`
287
+ marker belongs to the preceding row, `(3004,0010)` `RTDoseROISequence`, which remains retired.
288
+ `DoseValue` is now `retired: false`. A dose attribute wrongly flagged retired is the dangerous
289
+ direction of that error: it is the one that talks a caller out of reading a real RT dose.
290
+ - **`(003A,0320)` and `(003A,0325)` carried truncated keywords**, `SummarizedFilterLookupTable` and
291
+ `AnalogFilterType`, so `byKeyword` missed on the spelling PS3.6 publishes. They are now
292
+ `SummarizedFilterLookupTableSequence` and `AnalogFilterTypeCodeSequence`. The truncated forms are
293
+ **gone rather than kept as aliases**: they were never PS3.6 keywords, and carrying them would
294
+ preserve the defect under a nicer name.
295
+ - Alongside these, **180 tags PS3.6 has gained are now known**, so `Dictionary.lookup` names them
296
+ and Implicit VR parsing resolves their VR from the dictionary instead of falling back to `UN`.
297
+ The registry goes from **5,129 to 5,309** tags and `byKeyword` from **5,035 to 5,214** keywords.
298
+ Zero VR, name and VM differences existed on the 5,129 shared tags and zero tags were dropped, so
299
+ **no previously-decoded element decodes differently**; the change is corrections plus additions.
300
+
301
+ - **174 SOP Class UID names in `UIDS` were wrong (`DICOM-DICT-CURRENCY`).** The dictionary generator
302
+ appended `" Storage"` to every name it took from the vendor `sops.json`, but that field is already
303
+ the full PS3.6 Table A-1 UID Name. The result was a doubled suffix on the 164 names that already
304
+ ended in "Storage", so `UIDS["1.2.840.10008.5.1.4.1.1.2"].name` read **"CT Image Storage Storage"**,
305
+ and an equally wrong tail on 10 of the other 11 ("Digital X-Ray Image Storage - For Presentation
306
+ Storage"). Every one shipped in `0.0.1` through `0.0.3`. The suffix is gone and the names now come
307
+ through verbatim.
308
+ The rule was wrong for all 175 entries it touched but landed on the right string for exactly one,
309
+ so removing it needed a compensating correction rather than a second blanket rule:
310
+ `1.2.840.10008.5.1.4.1.1.79.1` is the single UID whose vendor name genuinely lacks the suffix, and
311
+ it is now pinned in the generator's curated table to the "Macular Grid Thickness and Volume Report
312
+ Storage" that PS3.6 2026c Table A-1 gives it.
313
+ One further name is corrected: `1.2.840.10008.1.2.6.1`, the retired RFC 2557 transfer syntax, read
314
+ "RFC 2557 MIME Encapsulation" where PS3.6 prints "RFC 2557 MIME encapsulation".
315
+ Measured against PS3.6 2026c on the branch head: of the **261** UIDs shared with Table A-1,
316
+ **240 match the `UID Name` column byte for byte**; a further **17** differ only in that Table A-1
317
+ writes retirement into the name as a trailing " (Retired)" where this dictionary carries a
318
+ structured `retired` boolean, which gives **257** when those are read as matches and **zero**
319
+ retirement-flag disagreements; the remaining **4** are the deliberate transfer-syntax short forms
320
+ tabulated in `vendor/innolitics/README.md`; **0** are unexplained. All **7** well-known frames of
321
+ reference match Table A-2 byte for byte. `uids.ts` changes on 175 lines in total.
322
+ Name only: no UID value, `type`, or `retired` flag changed. Transfer-syntax dispatch keys off the
323
+ UID **value**, never the name, so no parse or de-identification behavior changes. The one place a
324
+ name reaches a caller at runtime is the human-readable `snippet` on the fatal
325
+ `UNSUPPORTED_TRANSFER_SYNTAX` error, which is improved by the correction rather than altered in
326
+ meaning.
327
+ - **The byte-identical regen gate could go green while generating nothing (`DICOM-DICT-CURRENCY`).**
328
+ Two ways, both fixed by making the gate delete `src/dictionary/generated/` (except the hand-written
329
+ `README.md`) before running `pnpm gen:all`, so it measures what the generators produce instead of
330
+ what happens to be on disk. A stale orphan, an artifact a generator used to emit and no longer
331
+ does, was never rewritten and never diffed, so it passed indefinitely. And gutting `gen:all` to a
332
+ no-op wrote nothing, left every committed artifact untouched, and produced an empty `git diff`:
333
+ permanently green. `package.json` also joins the workflow's `paths` filters, without which that
334
+ second case never triggered the gate at all. Both now red, proved by seeding each defect and
335
+ watching the run fail. The delete is `-mindepth 1` rather than `-type f`, so a stale symlink or
336
+ subdirectory cannot become an orphan the gate is blind to, and the step asserts that only
337
+ `README.md` survives instead of printing an expectation nothing checks. `pnpm gen:clean` exposes
338
+ the same delete for local use but is deliberately **not** chained into `gen:all`: the generators
339
+ throw on malformed or missing vendor input, which is the normal state part-way through a re-pin,
340
+ and a chained clean would leave the working tree empty and the build broken. The gate does its own
341
+ delete rather than trusting the script under test.
342
+
343
+ ## [0.0.3] - 2026-07-27
344
+
345
+ This release carries two version bumps' worth of change. `0.0.2` was bumped on the same day and
346
+ never published, so the four changesets it consumed reached npm here, in `0.0.3`, together with
347
+ the regen-gate repair below.
348
+
349
+ ### Added
350
+
9
351
  - **Em-dash brand gate in CI (`EMDASH-CONFORMANCE`).** The founder directive of 2026-07-24
10
352
  (`knowledgebase/06-brand/voice-and-tone.md`, "No em dashes. Ever.") bans `U+2014` outright across
11
353
  every cosyte surface and names commit messages explicitly, and the meta-repo's
@@ -69,6 +411,11 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
69
411
  checked, so a documented example can never silently drift from the code. All examples use
70
412
  synthetic, base64-encoded Part 10 objects (invented patient, fake UIDs); no real PHI, no `.dcm`
71
413
  file on disk.
414
+ - **Trademark notice (`TRADEMARKS.md`).** This package names third-party systems to describe what it
415
+ interoperates with; the notice records that cosyte is not affiliated with, endorsed by, or
416
+ sponsored by any of them, that every reference is descriptive, and that the built-in profiles are
417
+ authored from public sources only. Added to `files` so it ships inside the published tarball, not
418
+ just on GitHub. Documentation only: no runtime or API change.
72
419
 
73
420
  ### Changed
74
421
 
@@ -126,7 +473,8 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
126
473
  therefore "a no-op until that flag is removed". `package.json` carries no `private` field, the repo
127
474
  is public, and the package ships on npm at `0.0.1`, so the comment described the release pipeline as
128
475
  inert when it is live. Documentation only, no behavior change, but a misleading note on a workflow
129
- that really does publish is worth more than a stale one elsewhere.
476
+ that really does publish is worth more than a stale one elsewhere. (`0.0.1` was the published
477
+ version on that date.)
130
478
 
131
479
  - **Corrected stale publish-status language in `docs-content/` (`README-ORG-SWEEP`).**
132
480
  `installation.md` claimed the package was "not yet published to npm" and that the install command
@@ -134,57 +482,13 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
134
482
  published … not on npm; the first provenance publish is gated on the coordinated public launch"
135
483
  non-goal. `@cosyte/dicom` is published on npm at `0.0.1` and public. Both now state the truth
136
484
  (published, public, still pre-alpha on the `0.0.x`-until-first-alpha ladder), and the troubleshooting
137
- bullet becomes an honest pin-your-version pre-alpha caveat rather than a stale non-goal.
485
+ bullet becomes an honest pin-your-version pre-alpha caveat rather than a stale non-goal. (`0.0.1`
486
+ was the published version on that date; `npm view @cosyte/dicom version` is the current one.)
138
487
 
139
- - **`private: true` removed: `@cosyte/dicom` can publish.** The flag dated to the very first
140
- scaffold commit and was never explained; `changeset publish` silently skips a private package, so
141
- this repo was the one parser that could not reach npm even once its pipeline worked. It also
142
- contradicted the `publishConfig: { access: "public" }` in the same file. Removed as part of the
143
- coordinated public launch (`PUB-FLIP`).
144
-
145
- - **The release can actually bump the version.** `package.json` had no `version` script, so the
146
- shared pipeline's `pnpm run version` failed with `Command "version" not found` and the release
147
- aborted before opening a "Version Packages" PR. Adds `scripts/sync-version.mjs` (the `hl7`
148
- reference, retargeted at `src/version.ts`) and the `version` script that runs it after
149
- `changeset version`, so the bump and the `VERSION` constant land in the same commit.
150
- - **`VERSION` is no longer typed as a string literal.** It was declared `export const VERSION =
151
- "0.0.0"`, giving it the literal type `"0.0.0"`, so the exported type would change on every
152
- release, making each version bump a breaking type change. Now annotated `: string`, matching the
153
- `hl7` reference. Type-only; the runtime value is unchanged. Done now because the package is
154
- unpublished. After the first publish this would itself be a breaking change.
155
-
156
- - **The Release workflow can actually start.** `.github/workflows/release.yml` calls the shared
157
- `cosyte/.github` pipeline, which requests `contents`/`id-token`/`pull-requests: write`, but declared
158
- no `permissions:` of its own, so it inherited the repo default of `contents: read`. A called
159
- workflow may only downgrade the caller's `GITHUB_TOKEN`, never escalate it, so GitHub rejected the
160
- workflow at startup (~1s, no jobs, no logs). Every Release run from June 2026 until now failed this
161
- way, unnoticed, because a `startup_failure` produces no logs to read. The caller job now declares
162
- the three scopes explicitly. CI-only: no runtime or API change.
163
-
164
- ### Security
165
-
166
- - **Dev-dependency advisory remediation (no runtime impact: the published
167
- artifact is unchanged).** Added scoped `pnpm.overrides` pinning two
168
- transitive **dev/build-time** packages to their patched releases: `esbuild`
169
- (`>=0.27.3 <0.28.1` → `0.28.1`; GHSA dev-server path-traversal, not
170
- reachable here: the library builds via `tsup`/`vitest` and never runs
171
- `esbuild serve`) and the `@changesets/parse` copy of `js-yaml`
172
- (`>=4.0.0 <4.2.0` → `4.2.0`; GHSA-h67p-54hq-rp68 merge-key DoS). The
173
- `js-yaml@3.14.2` pulled by `read-yaml-file@1.1.0` (via
174
- `@manypkg/get-packages` → `@changesets/cli`) is **intentionally left**: it
175
- calls `yaml.safeLoad`, removed/throwing in js-yaml 4, so it cannot be
176
- force-upgraded without breaking the release tooling, and it only parses
177
- trusted local repo YAML at release time. This is the shared canonical
178
- override block, enforced suite-wide by the `@cosyte/config` drift check.
488
+ ## [0.0.1] - 2026-07-17
179
489
 
180
490
  ### Added
181
491
 
182
- - **Trademark notice (`TRADEMARKS.md`).** This package names third-party systems to describe what it
183
- interoperates with; the notice records that cosyte is not affiliated with, endorsed by, or
184
- sponsored by any of them, that every reference is descriptive, and that the built-in profiles are
185
- authored from public sources only. Added to `files` so it ships inside the published tarball, not
186
- just on GitHub. Documentation only: no runtime or API change.
187
-
188
492
  - **Lossless File Meta round-trip.** The parser now retains non-modeled
189
493
  `(0002,xxxx)` File Meta elements (e.g. `(0002,0017)`/`(0002,0018)`
190
494
  Sending/Receiving AE Title, `(0002,0100)` Private Information Creator UID,
@@ -266,28 +570,6 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
266
570
  built only from the code + the offending Transfer Syntax UID (structural facts), never a decoded
267
571
  value, so it is always safe to log. New public exports: `serializeDicom`, `DicomSerializeError`,
268
572
  `SERIALIZE_ERROR_CODES`, `SerializeErrorCode`.
269
-
270
- ### Known limitations
271
-
272
- - **File Meta round-trip is over the modeled surface, not byte-exact.** Only the typed `FileMeta`
273
- fields round-trip; any other `(0002,xxxx)` element a source file carried (e.g. `(0002,0100)` Private
274
- Information Creator UID) is dropped at _parse_ time (the Phase 2 `FileMeta` view does not model it)
275
- and so cannot be re-emitted. The preamble is normalized to zeros and odd-length values are padded
276
- even. The output stays spec-clean but is not a byte-identical copy of a non-conformant input.
277
-
278
- ### Tests
279
-
280
- - **Enhanced multi-frame coverage (DICOM-COV).** Closed the Per-Frame-else-Shared branch gaps left by
281
- the Phase 4 functional-group resolver (`functional-groups.ts`: ~53% → 100% branch): both optional
282
- macros (Pixel Value Transformation `(0028,9145)`, Frame VOI LUT `(0028,9132)`), Pixel Measures
283
- `spacingBetweenSlices`, shared-only resolution (no Per-Frame Functional Groups Sequence), the
284
- lenient inner-attribute-absence paths (a macro item present but its attributes omitted ⇒ typed-absent,
285
- never coerced), and all three `MISSING_REQUIRED_FUNCTIONAL_GROUP` throws (Pixel Measures / Plane
286
- Position / Plane Orientation). Synthetic fixtures only; no public-surface change. Per-directory
287
- coverage now sits genuinely ≥ 90 on every gated directory (global branches 93.2%).
288
-
289
- ### Added
290
-
291
573
  - **Safety-critical domain helpers (Phase 4).** New `Dataset` accessors `patient` / `study` /
292
574
  `series` / `image` return typed, fail-safe views over the DICOM §4 safety-critical attributes
293
575
  (memoized on first access). `patient` surfaces the `{id, issuerOfId, issuerQualifiers}` identity
@@ -352,3 +634,68 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
352
634
  - CI/release workflows reduced to thin callers of the reusable `cosyte/.github` pipelines
353
635
  (`ci.yml` runs the shared PHI scan; `release.yml` targets `@cosyte/dicom`). The repo-specific
354
636
  byte-identical dictionary-regen workflow is kept and bumped to Node 22.
637
+
638
+ ### Fixed
639
+
640
+ - **`private: true` removed: `@cosyte/dicom` can publish.** The flag dated to the very first
641
+ scaffold commit and was never explained; `changeset publish` silently skips a private package, so
642
+ this repo was the one parser that could not reach npm even once its pipeline worked. It also
643
+ contradicted the `publishConfig: { access: "public" }` in the same file. Removed as part of the
644
+ coordinated public launch (`PUB-FLIP`).
645
+
646
+ - **The release can actually bump the version.** `package.json` had no `version` script, so the
647
+ shared pipeline's `pnpm run version` failed with `Command "version" not found` and the release
648
+ aborted before opening a "Version Packages" PR. Adds `scripts/sync-version.mjs` (the `hl7`
649
+ reference, retargeted at `src/version.ts`) and the `version` script that runs it after
650
+ `changeset version`, so the bump and the `VERSION` constant land in the same commit.
651
+ - **`VERSION` is no longer typed as a string literal.** It was declared `export const VERSION =
652
+ "0.0.0"`, giving it the literal type `"0.0.0"`, so the exported type would change on every
653
+ release, making each version bump a breaking type change. Now annotated `: string`, matching the
654
+ `hl7` reference. Type-only; the runtime value is unchanged. Done now because the package is
655
+ unpublished. After the first publish this would itself be a breaking change.
656
+
657
+ - **The Release workflow can actually start.** `.github/workflows/release.yml` calls the shared
658
+ `cosyte/.github` pipeline, which requests `contents`/`id-token`/`pull-requests: write`, but declared
659
+ no `permissions:` of its own, so it inherited the repo default of `contents: read`. A called
660
+ workflow may only downgrade the caller's `GITHUB_TOKEN`, never escalate it, so GitHub rejected the
661
+ workflow at startup (~1s, no jobs, no logs). Every Release run from June 2026 until now failed this
662
+ way, unnoticed, because a `startup_failure` produces no logs to read. The caller job now declares
663
+ the three scopes explicitly. CI-only: no runtime or API change.
664
+
665
+ ### Security
666
+
667
+ - **Dev-dependency advisory remediation (no runtime impact: the published
668
+ artifact is unchanged).** Added scoped `pnpm.overrides` pinning two
669
+ transitive **dev/build-time** packages to their patched releases: `esbuild`
670
+ (`>=0.27.3 <0.28.1` → `0.28.1`; GHSA dev-server path-traversal, not
671
+ reachable here: the library builds via `tsup`/`vitest` and never runs
672
+ `esbuild serve`) and the `@changesets/parse` copy of `js-yaml`
673
+ (`>=4.0.0 <4.2.0` → `4.2.0`; GHSA-h67p-54hq-rp68 merge-key DoS). The
674
+ `js-yaml@3.14.2` pulled by `read-yaml-file@1.1.0` (via
675
+ `@manypkg/get-packages` → `@changesets/cli`) is **intentionally left**: it
676
+ calls `yaml.safeLoad`, removed/throwing in js-yaml 4, so it cannot be
677
+ force-upgraded without breaking the release tooling, and it only parses
678
+ trusted local repo YAML at release time. This is the shared canonical
679
+ override block, enforced suite-wide by the `@cosyte/config` drift check.
680
+
681
+ ### Tests
682
+
683
+ - **Enhanced multi-frame coverage (DICOM-COV).** Closed the Per-Frame-else-Shared branch gaps left by
684
+ the Phase 4 functional-group resolver (`functional-groups.ts`: ~53% → 100% branch): both optional
685
+ macros (Pixel Value Transformation `(0028,9145)`, Frame VOI LUT `(0028,9132)`), Pixel Measures
686
+ `spacingBetweenSlices`, shared-only resolution (no Per-Frame Functional Groups Sequence), the
687
+ lenient inner-attribute-absence paths (a macro item present but its attributes omitted ⇒ typed-absent,
688
+ never coerced), and all three `MISSING_REQUIRED_FUNCTIONAL_GROUP` throws (Pixel Measures / Plane
689
+ Position / Plane Orientation). Synthetic fixtures only; no public-surface change. Per-directory
690
+ coverage now sits genuinely ≥ 90 on every gated directory (global branches 93.2%).
691
+
692
+ ### Known limitations
693
+
694
+ - **File Meta round-trip is over the modeled surface, not byte-exact.** Only the typed `FileMeta`
695
+ fields round-trip; any other `(0002,xxxx)` element a source file carried (e.g. `(0002,0100)` Private
696
+ Information Creator UID) is dropped at _parse_ time (the Phase 2 `FileMeta` view does not model it)
697
+ and so cannot be re-emitted. The preamble is normalized to zeros and odd-length values are padded
698
+ even. The output stays spec-clean but is not a byte-identical copy of a non-conformant input.
699
+ **Superseded within this same release** by the lossless File Meta round-trip above, which landed
700
+ after the serializer and before `0.0.1` was cut: as published, `0.0.1` preserves and re-emits non-modeled
701
+ `(0002,xxxx)` elements. The entry is kept because it is what the serializer shipped with.