@cosyte/dicom 0.0.4 → 0.0.6

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
@@ -4,6 +4,304 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ### Security
8
+
9
+ - **A defined-length sequence under Implicit VR Little Endian was never opened, so PHI nested inside
10
+ one survived `deidentify()` into serialized output while the report said it had been removed**
11
+ (`DICOM-IMPLICIT-SQ-NOT-DESCENDED`, pre-existing and live on the published `0.0.5`). The
12
+ Implicit VR LE parser delegated to the sequence parser **only** in the undefined-length
13
+ (`0xFFFFFFFF`) branch, so `Element.items` was `undefined` for every defined-length sequence under
14
+ that transfer syntax. Nothing that walks items could see inside one, and `deidentify()` recurses
15
+ into a kept sequence only when its items exist: a `(0010,0010)` Patient's Name in a defined-length
16
+ item reached `serializeDicom()` output verbatim, with the `DeidentifyReport` naming only the root
17
+ attribute. **The report therefore asserted a scrub it had not performed**, which is the worse half
18
+ of the defect: an incomplete audit that reads as a complete one is what a caller trusts before
19
+ sharing an object. The identical fixture under Explicit VR LE scrubbed clean, because that path
20
+ already descended both length forms.
21
+
22
+ PS3.5 2026c states the obligation twice, about two different length fields, and the one this broke
23
+ is section **7.5.2** "Delimitation of The Sequence of Items", which governs the `SQ` element's own
24
+ length: "The encoder of a Sequence of Items may choose either one of the two ways of encoding. Both
25
+ ways of encoding shall be supported by decoders of the Sequence of Items." Section 7.5.1 "Item
26
+ Encoding Rules" says the same of each Item's length field. The two choices are independent, so both
27
+ are now descended in every combination. The de-identified output of the
28
+ Implicit VR LE fixture and its Explicit VR LE twin are now the same report, attribute for
29
+ attribute and context path for context path.
30
+
31
+ **Three smaller consequences of a sequence now being read**, each the descent working rather than a
32
+ separate decision: `Element.vm` is the item count instead of the scalar placeholder `1`;
33
+ `Element.value` yields the real items instead of an empty sequence; and warnings raised **inside**
34
+ a defined-length sequence, which previously could not exist because its bytes were never parsed,
35
+ now appear on `ds.warnings` - so under `{ strict: true }` a file whose nested content was always
36
+ non-conformant now throws. The sequence element itself no longer carries `specificCharacterSet`,
37
+ matching every other sequence element in the package; items still inherit the enclosing charset.
38
+
39
+ - **A warning message no longer echoes anything the file said** (`PHI-WARNING-MESSAGE-LEAK`). Three
40
+ diagnostics reproduced consumer-controlled bytes verbatim, and one of them carried onto the dataset
41
+ `deidentify()` labels safe to share:
42
+ - `DICOM_UNSUPPORTED_CHARSET` interpolated the `(0008,0005)` term. That value is **multi-valued on
43
+ the backslash**, so any component of it, at any position, reached the message whole. It is the
44
+ measured leak, and `deidentify()` copies `Dataset.warnings` across, so it rode onto the shared
45
+ artifact.
46
+ - `DICOM_PRIVATE_CREATOR_UNKNOWN` interpolated the Private Creator string, which is an `LO` a
47
+ sender authored and which this warning fires on precisely because nothing vouches for it.
48
+ - The `UNSUPPORTED_TRANSFER_SYNTAX` fatal interpolated the `(0002,0010)` UID into `err.message`,
49
+ and so into `err.stack` and into whatever an error reporter ships off-box. The writer's
50
+ equivalent did the same for a `Dataset` a caller built.
51
+
52
+ Every message now comes from a **frozen registry** keyed by the warning code. Factories take a
53
+ position and structural constants only: a tag this parser composed, a VR checked against the closed
54
+ 34-VR set, and input-derived numbers. There is no string parameter to pass a value through, which
55
+ is the property that distinguishes every `@cosyte/*` parser that does not leak from every one that
56
+ did. A token failing its check renders `<withheld>` rather than being echoed.
57
+
58
+ Where a token has to be named, it comes from a closed set this package controls: the unsupported
59
+ transfer syntax reads as the dictionary's own label for the UID (`JPEG Baseline (Process 1)`) when
60
+ PS3.6 publishes one, `DICOM_UNSUPPORTED_CHARSET` names the **1-based value index** of the offending
61
+ component rather than the component, and the inflate failure reports zlib's error _code_ instead of
62
+ forwarding zlib's message.
63
+
64
+ - **Two model fields are bounded, because a diagnostic fix does not protect a downstream package.**
65
+ `@cosyte/hl7` bounded its messages, went green, and `@cosyte/deid` still leaked off `Segment.type`.
66
+ The same two shapes here now bound on **membership**, not shape, because DICOM offers no shape to
67
+ test (`src/parser/tokens.ts` argues why):
68
+ - `Element.specificCharacterSet` holds only terms PS3.3's closed table names; anything else reads
69
+ `<withheld>`. Decoding is unchanged: an unmappable term was already skipped.
70
+ - `Element.privateCreator` holds only creators the active `Profile`'s overlay names. **With no
71
+ profile, nothing is recognized and the field reads `<withheld>`** - a real reduction in what it
72
+ tells a caller, and deliberate. The raw creator is still available as the `(gggg,00EE)` element's
73
+ own `rawBytes`, as a value, where value discipline applies.
74
+
75
+ `RetainSafePrivate` is unaffected in either direction: `deidentify()` now re-derives the block
76
+ reservation from the creator elements of **each Data Set** it walks, so passing a `Profile` at
77
+ de-identification works whether or not one was passed at parse. **One behaviour does change against
78
+ 0.0.5:** a private data element inside a sequence item whose creator is declared only at the root is
79
+ now removed rather than retained, because the item has no reservation of its own. That is the
80
+ conformant reading and the fail-safe direction, but a sender that declares a creator once at the
81
+ root and writes private data into Per-Frame Functional Groups items will lose those elements under
82
+ `RetainSafePrivate`. Declare the creator in the Data Set that uses it. Per Data Set is load-bearing, not a
83
+ detail: PS3.5 §7.5 makes each Sequence Item its own Data Set and §7.8.1 scopes a reservation to the
84
+ Data Set the creator appears in, so the same block number names different vendors at the root and
85
+ inside an item. Resolving one against the other retained an item's private element on the root's
86
+ reservation and wrote its value into the serialized output.
87
+
88
+ ### Fixed
89
+
90
+ - **A malformed defined-length sequence degrades instead of failing the object, and says so.** Under
91
+ Implicit VR Little Endian there is no VR on the wire: `SQ` is this parser's inference from PS3.6,
92
+ not something the sender wrote. So an element whose defined-length value turns out not to be an
93
+ `(FFFE,E000)` item stream keeps its declared byte range on `Element.rawBytes`, leaves the rest of
94
+ the object readable, and raises the new Tier-2 `DICOM_SQ_NOT_DESCENDED` warning. The undefined-length
95
+ form keeps its existing Tier-3 fatal, and that asymmetry is deliberate: a defined length leaves a
96
+ complete alternative reading of the value, and an undefined one leaves none. **The warning exists
97
+ because silence is the defect above**: an undescended sequence is invisible to `deidentify()`, so a
98
+ caller has to be told the audit did not reach inside it. It is not the whole story, though: see the
99
+ next entry for the shape that stays silent.
100
+
101
+ - **Only one of the two un-auditable shapes warns, and the docs now say which.** `deidentify()`
102
+ recurses on `Element.items`, so a sequence the parser could not open is kept verbatim and appears
103
+ nowhere in the report. The refusal added above raises `DICOM_SQ_NOT_DESCENDED`; an undefined-length
104
+ `UN` value the CP-246 descent could not read as a sequence raises **nothing** and keeps `vr ===
105
+ "UN"`. That silence is `PRE-EXISTING` and unchanged. The reliable check is `el.items === undefined`
106
+ on the element, not `ds.warnings`.
107
+
108
+ - **Residual, pre-existing and now reachable from one more shape.** A refused descent drops its
109
+ warnings from `ds.warnings`, but an `onWarning` callback has already been handed them: the emit
110
+ chokepoint delivers to the callback before the rollback can undo the push. The two disagree for
111
+ exactly those warnings, and `ds.warnings` is the accurate record. Unchanged from the CP-246 path,
112
+ disclosed rather than fixed (buffering emissions is a larger change than this slice).
113
+
114
+ - **A test named "explicit-length SQ also descends" proved nothing, and that is how this shipped.**
115
+ Its two assertions were the VR, which dictionary resolution answers whether or not anything
116
+ descends, and `vm === 1`, which was the **scalar placeholder** every non-sequence element already
117
+ carried. Both were green against a parser that never opened the sequence. It now asserts the items.
118
+
119
+ - **A private block reserved in one Data Set resolved the Implicit VR of a private element in
120
+ another, so the same bytes decoded as a different value than the file declared**
121
+ (`DICOM-PARSE-CREATORS-SCOPE`). The parser kept **one** private-creator reservation map for the
122
+ whole file, so a block number claimed by different vendors at the root and inside a Sequence Item
123
+ resolved to whichever creator was read **last**, wherever it sat. That map feeds Implicit-VR
124
+ resolution, which is why this is a wrong decode and not a mislabelling: in a file where an item
125
+ claims block `0x11` and the root afterwards writes `(0029,1101)` without claiming it, the root
126
+ element took the **item's** vendor, and with a `Profile` naming that creator's element byte as `SS`
127
+ the bytes `FF FF` read as **`-1`** rather than as raw bytes. No warning fired. Reservations are now
128
+ scoped to the Data Set the Private Creator Data Element appears in, at every depth the parser
129
+ recurses to: an item does not inherit the enclosing Data Set's blocks, an item's own do not reach a
130
+ sibling item, and none of them survive back out to the enclosing Data Set once the sequence closes.
131
+
132
+ PS3.5 2026c section 7.5.1 is the basis: "Each Item Value shall contain a DICOM Data Set composed of
133
+ Data Elements", closing by delegating to section 7.8 for "rules for incorporating Private Data
134
+ Elements into Sequence Items", where a Private Creator Data Element "shall be used to reserve a
135
+ block of Elements with Group Number gggg". Its first Note states the nesting case outright: each
136
+ item needs to claim the corresponding private block of Elements. Both documents are the vendored,
137
+ SHA-pinned copies under `vendor/nema/part05/`.
138
+
139
+ **Three behaviour changes against `0.0.5`, all confined to files that mix private data with
140
+ sequences.** (1) A private data element in a Sequence Item whose block is claimed only in an
141
+ enclosing Data Set now resolves to `UN` plus `DICOM_PRIVATE_TAG_NO_CREATOR`, where it previously
142
+ took the enclosing claim's VR. The bytes stay on `Element.rawBytes`; what is withheld is a typed
143
+ decode the file never licensed. Declare the creator in the Data Set that uses it. (2)
144
+ `Element.privateCreator` is `undefined` on those elements rather than naming a vendor from another
145
+ Data Set - and that half applies under **every** transfer syntax, including the Explicit VR ones
146
+ where the VR is on the wire and only the attribution was wrong. (3) **Under `{ strict: true }` the
147
+ same file now throws**, because `DICOM_PRIVATE_TAG_NO_CREATOR` is a Tier-2 warning and strict
148
+ promotes Tier-2 warnings to a thrown `DicomParseError`. Nothing about strict changed; the warning
149
+ is simply now emitted where the standard says the reservation does not reach. The default lenient
150
+ parse keeps the file and records the warning.
151
+
152
+ - **An undefined-length `UN` under Implicit VR LE is descended as a sequence instead of failing the
153
+ whole parse.** `parseImplicitLE` treated any resolved VR other than `SQ` at length `0xFFFFFFFF` as
154
+ unrecoverable structural corruption, so one private element the reader could not attribute cost the
155
+ entire object: no patient, no study, no modality, a thrown `INVALID_FILE_META`. It now takes the
156
+ same CP-246 route the Explicit VR path already took for exactly this shape (`VR=UN` plus undefined
157
+ length), promoting the element to `SQ` with a `DICOM_UN_PARSED_AS_SQ` warning when the bytes really
158
+ are items. PS3.5 section 7.5.1 requires decoders to support both item encodings, and losing vendor
159
+ context is not corruption. Bytes that are genuinely not a sequence still throw exactly as before:
160
+ the descent restores parser state and drops its warnings on failure. This matters most alongside
161
+ the scoping fix above, which is what can now turn a profile-resolved `SQ` into a `UN`.
162
+ `DICOM_UN_PARSED_AS_SQ`'s message now says the element _has_ `VR=UN` rather than _declared_ it,
163
+ because on this new path nothing was declared: the `UN` is what VR resolution returned.
164
+
165
+ This is the read-path half of the defect whose de-identify half shipped above. The harm differs
166
+ and that is why they are separate: there, an out-of-scope reservation **retained** a private
167
+ element and wrote it into serialized output; here it hands correct bytes the wrong meaning.
168
+
169
+ - **`DICOM_UN_PARSED_AS_SQ` printed the literal string `UN` where its message promised a tag.** The
170
+ descent primitive is handed a byte range rather than a tag, but both call sites hold one, so it is
171
+ threaded through rather than dropped.
172
+ - **A warning raised inside a Deflated dataset cut its strict-mode snippet from the wrong buffer.**
173
+ The inner descent forwarded to the outer emission chokepoint, which closes over the **compressed**
174
+ source, while the forwarded `position.byteOffset` indexes the **inflated** stream. The result was a
175
+ confidently wrong 16 bytes. The descent now builds its own chokepoint over the inflated buffer; it
176
+ shares the outer `warnings` array, `onWarning` and `strict`, so warning accumulation, callback
177
+ ordering and profile posture are unchanged. **One thing does change and it is worth saying plainly:
178
+ the snippet on that path now carries the real inflated bytes**, where before it carried whatever
179
+ happened to sit at that offset in the compressed source. It was accidentally uninformative and is
180
+ now correctly PHI, under the same `snippet`-is-raw-input contract every other path already has.
181
+ - **`DICOM_NONZERO_RESERVED_BYTES` reported the two reserved bytes as one 16-bit number**, which had
182
+ to pick an endianness the reserved field does not have: it read unambiguously and was wrong under
183
+ one of the two Explicit VR syntaxes. They are now reported separately, in wire order.
184
+
185
+ - **`README.md` documented an element-access API that does not exist.** `Dataset.get` / `has` /
186
+ `getAll` take the 8-character `(group,element)` tag (`"00080060"`, case-insensitive) and nothing
187
+ else, but the README's quickstart, feature list, access-pattern section, typed-value examples and
188
+ Philips vendor note all showed `ds.get("Modality")` and `ds.get("(0008,0060)")`. Both forms return
189
+ `undefined`, and because the `Tag` type is `string` the compiler catches neither: the first line a
190
+ reader copied out of the quickstart silently produced nothing. Measured against the built package
191
+ rather than read off the source. The identical defect was found and corrected in
192
+ `docs-content/intro.md` before `0.0.3` (see below) and the README was not swept with it, which is
193
+ the reason to state the rule once here: `get` takes a tag, and a keyword is resolved to its tag
194
+ through `Dictionary.byKeyword(...)?.tag` first. Every example is corrected and the section is
195
+ retitled from "By keyword or tag" to "By tag". `docs-content/spec-notes-model.md` carried a
196
+ neighbouring defect and is corrected with it: it said `getAll` "returns every element at a
197
+ repeating tag", where a `Dataset` holds at most one element per tag, so it returns 0 or 1.
198
+ - **`README.md` claimed compressed transfer syntaxes were readable at the structural level.** It
199
+ said the supported set was the four v1 syntaxes "and any compressed syntax at the structural level
200
+ (fragments preserved)". The dispatch table holds exactly four entries and any other Transfer
201
+ Syntax UID is the fatal `UNSUPPORTED_TRANSFER_SYNTAX`, so a JPEG, JPEG-LS, JPEG2000, RLE or HTJ2K
202
+ object does not parse at all. Confirmed by parsing a synthetic object in `1.2.840.10008.1.2.4.50`
203
+ and catching the throw. Deflated Explicit VR LE is the one compressed syntax that is supported: it
204
+ deflates the dataset stream rather than the pixels, and the corrected text says so rather than
205
+ calling every compressed syntax unreadable. This mattered most in the "index a folder of studies"
206
+ recipe, whose closing line promised that "nothing here throws on a quirky file" while a real
207
+ archive is full of pixel-compressed objects. That recipe now names all four Tier-3 conditions a
208
+ folder walk meets (`UNSUPPORTED_TRANSFER_SYNTAX`, `INVALID_FILE_META` for a truncated or
209
+ partly-copied file, `NOT_DICOM_PART_10` for a non-DICOM file in the folder, `EMPTY_INPUT` for a
210
+ zero-byte one) and says to catch `DicomParseError` per file and skip. The non-goals entry no
211
+ longer implies a compressed object is read structurally either.
212
+ - **`README.md` counted four typed errors where five are exported.** The Error Handling section
213
+ introduced "four typed errors" and then documented `DicomParseError`, `DicomValueError`,
214
+ `DicomSerializeError`, `ProfileDefinitionError` and `DeidentifyError`. The count is now five. The
215
+ same paragraph said warnings are "never thrown", which a profile's `escalate` list contradicts. It
216
+ now says warnings are data unless you ask otherwise, and names `escalate` as the route that asks.
217
+ - **`docs-content/` stated the published version as `0.0.1`, two published releases stale.**
218
+ `installation.md`'s status note and `troubleshooting.md`'s scope entry both named `0.0.1` while
219
+ npm served `0.0.4`. Neither quotes a version any more. Both send the reader to the registry, which
220
+ is the only thing that cannot go stale, by naming the command that reads it.
221
+ `troubleshooting.md` also stopped sending readers to `CLAUDE.md`, an internal file that does not
222
+ ship in the tarball.
223
+
224
+ ### Changed
225
+
226
+ - **Four documentation claims stated the reverse of the source and are corrected.**
227
+ `spec-notes-tolerance.md`, `troubleshooting.md`, `cookbook.md` and `README.md` all said warnings and
228
+ errors were "PHI-free by construction" while three factories interpolated values, and two of them
229
+ added that a `DicomParseError` "retains no raw input snippet" while `DicomParseError.snippet`
230
+ carries up to 16 bytes of the source as hex and its own JSDoc says so. The messages are now safe and
231
+ the docs say why; the snippet is documented as the PHI it is. `troubleshooting.md` also records that
232
+ **value-decode warnings never reach `ds.warnings`** at all (decode is lazy, so they ride on
233
+ `el.value.warnings`), so a logger reading only the dataset array sees none of them.
234
+ - **The `DeidentifyReport` is no longer described as value-free without qualification.** It is, apart
235
+ from `uidMap`, whose **keys are the source UIDs read out of the file** and kept so replacement stays
236
+ consistent across a study. A Study or SOP Instance UID is a unique identifying number; that field is
237
+ PHI and the rest of the report is not.
238
+
239
+ - **`CHANGELOG.md` now records what each released version contained.** Every entry in this file sat
240
+ under `[Unreleased]` while npm served `0.0.1`, `0.0.3` and `0.0.4`, and `CHANGELOG.md` is in
241
+ `package.json#files`, so the file shipped inside the tarball telling a consumer that the version
242
+ they had installed was unreleased. That is a false claim on a distributed surface rather than
243
+ untidy bookkeeping. No entry's substance is rewritten: each is moved to the version that actually
244
+ shipped it, reconstructed from the tags, the GitHub releases, and the changesets each
245
+ "Version Packages" commit consumed. `0.0.2` gets no heading because it was never published: it was
246
+ version-bumped on 2026-07-27 and the publish that followed did not run, so its four changesets
247
+ reached npm inside `0.0.3` and are recorded there. That is also why npm's version list skips
248
+ `0.0.2`.
249
+
250
+ ### Added
251
+
252
+ - **`DICOM_SQ_NOT_DESCENDED`**, a Tier-2 warning code (25 total, was 24). Public surface: the locked
253
+ `WARNING_CODES` snapshot was updated deliberately, and a profile may `escalate` or `suppress` it
254
+ like any other code.
255
+
256
+ - **A slot table of consumer-controlled positions in the format**
257
+ (`test/integration/phi-diagnostic-surface.test.ts`), bound to `assertNoDiagnosticPhiLeak` from
258
+ `@cosyte/test-utils` (pin bumped to `^0.0.2`; a caret on a `0.0.x` resolves to that version exactly,
259
+ so the old pin would have tested against a kit without the runner and passed). Thirty-eight slots,
260
+ one test each, across all four transfer syntaxes and encapsulated pixel data, plus the de-identify
261
+ surface and the serialized "safe to share" bytes.
262
+
263
+ **It does not make the diagnostic surface PHI-free, and the difference matters.**
264
+ `DicomParseError.snippet` is 16 raw source bytes as hex on every fatal and every strict-mode
265
+ escalation. That is deliberate and long-standing (D-10), the docs name it as PHI, and no slot here
266
+ can go red on it: the runner matches verbatim echoes, and hex is a re-encoding. What the table
267
+ proves is that no _message_, position, thrown value or model identifier carries a planted byte.
268
+
269
+ **It was run red on the base commit and named seven leaking slots**, which is the point of it. The
270
+ suite it joins could not have done that: `test/property/_arbitraries.ts` blocks the leaking path
271
+ three independent ways, and the sharpest is that its `TEXT_ALPHABET` excludes the backslash "so a
272
+ single-valued element stays single-valued" - the exact byte `(0008,0005)` splits on before the
273
+ leaking branch. It also never generates `(0008,0005)` or a Private Creator element at all.
274
+
275
+ Two things the table pins that a marker search alone would not. Every slot names the diagnostic
276
+ **code** it must reach, so a probe that was quietly normalized away cannot pass as evidence; the
277
+ marker is invalid in `UI` and `CS`, the two VRs most worth probing here, and what makes it reach
278
+ those branches anyway is that this parser validates no VR character set on read. And one test
279
+ compares every emitted message against the **registry template** rather than searching it for the
280
+ marker, so any interpolation of anything at all breaks it, whatever was planted.
281
+
282
+ - **`README.md` now opens with the shared Cosyte lockup, which follows the reader's color scheme**
283
+ (`ASSETS-P8`). A `<picture>` block above the H1 carries the dark-ground org tile behind a
284
+ `prefers-color-scheme: dark` media query and the light-ground tile as the inner `<img>`, so the
285
+ mark sits on a ground that matches the page it is read on. It replaces the per-package banner,
286
+ which baked the package name and the one-line tagline into pixels while the two lines directly
287
+ beneath it repeated both: the lockup reads "Cosyte" where the H1 reads `@cosyte/dicom`, so the
288
+ duplication goes and the heading stays. The alt text is content rather than decoration, because it
289
+ is what a screen reader reads on the npm page, and it describes the mark itself rather than
290
+ repeating the heading below it.
291
+
292
+ **One stated reason is corrected rather than dropped.** The per-package banner was chosen as a
293
+ plain markdown image, with no `<img>` and no `<picture>`, expressly because whether npm's markdown
294
+ sanitizer preserves a `<picture>` was unverified. It has since been measured on a published
295
+ package page: the `<img>` is hoisted out of its `<picture>` by npm's anchor wrapper, so the light
296
+ cut renders there, and npmjs.com has no dark mode, so that is the correct cut. A renderer that
297
+ strips `<source>` still renders the inner `<img>`, so the worst case is a light-ground mark on a
298
+ dark page, never a missing or broken image. Both tile URLs were rechecked with `curl -I` as
299
+ `200 image/png`, 10513 and 10455 bytes, before this landed, rather than taken from the `live` flag
300
+ in `assets/published-urls.json`, whose own `$fields.status` note says to read `live` for what it
301
+ is: a declaration made on evidence from another repo, never a fact checked there.
302
+
303
+ ## [0.0.4] - 2026-07-30
304
+
7
305
  ### Added
8
306
 
9
307
  - **The element registry is now generated from NEMA's PS3.6 DocBook, the normative publication**
@@ -49,69 +347,56 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
49
347
  _content_, documented in `vendor/nema/README.md`. What is gated in CI is unchanged and offline:
50
348
  the committed dictionary must be byte-for-byte what the pinned inputs produce.
51
349
 
52
- - **Em-dash brand gate in CI (`EMDASH-CONFORMANCE`).** The founder directive of 2026-07-24
53
- (`knowledgebase/06-brand/voice-and-tone.md`, "No em dashes. Ever.") bans `U+2014` outright across
54
- every cosyte surface and names commit messages explicitly, and the meta-repo's
55
- `documentation/conventions.md` has stated for weeks that the rule is CI-gated. It now actually is,
56
- here: `scripts/check-no-emdash.sh` (`pnpm check:no-emdash`) plus a dedicated
57
- `.github/workflows/no-emdash.yml` job that scans **both** halves the rule covers, the tracked files
58
- **and** the PR title, body, and commit messages. The workflow carries the non-default `edited`
59
- pull-request trigger, which is load-bearing: this repo squash-merges under
60
- `squash_merge_commit_title: COMMIT_OR_PR_TITLE` and `squash_merge_commit_message: COMMIT_MESSAGES`
61
- (read off the repo, not assumed), so the subject that lands on `main` is the PR title, or the lone
62
- commit's subject when the branch has exactly one, and the body is the branch commit messages.
63
- Without `edited` a title changed after the last push would never be re-checked. The PR body does
64
- **not** land; the gate scans it anyway, as deliberate over-strictness on a surface that costs
65
- nothing to cover. It is a separate workflow rather than a job in `ci.yml` because that trigger
66
- would otherwise re-run the whole Node 22 + 24 matrix on every typo fix, and because the shared
67
- `cosyte/.github` pipeline runs no arbitrary repo script.
68
- **This one did change content, unlike the earlier ports.** An ecosystem survey had measured
69
- markdown only (0 of 25 `.md` files) and read dicom as already clean. Measuring all 178 tracked
70
- files found six live em dashes in four non-markdown files, all removed here (see Changed, below).
71
- Measure every tracked file, because that is what the scan covers.
72
- The script is the **text-only** variant, taken from `ncpdp` (PR #34, `39212bb`) rather than from
73
- the older `knowledgebase` copy, so it carries `ncpdp`'s two fixes to the shared shape instead of
74
- inheriting the holes: a tracked file named `-` was read by `grep` as standard input (which `xargs`
75
- points at `/dev/null`) and so was never opened, and `-d skip` silently passed a tracked symlink to
76
- a directory. Both are re-proved here rather than taken on faith: with the `./` path prefix removed
77
- the gate prints OK and exits 0 over a live em dash in a file named `-`, and with `-d skip` restored
78
- it goes green over an unread symlink.
79
- It deliberately omits `grep -I`, and in this repo that is the most load-bearing flag choice in the
80
- file. `src/dataset/vr/charset.ts` holds a functional NUL byte inside `/[\x00 ]+$/u`, the regex that
81
- strips DICOM's own padding, so grep classifies it binary. It carries no em dash today and scans
82
- green, which is the whole answer to the belief that this port was blocked: the red would come from
83
- a match, never from the NUL. **If it ever gains one, the gate reds.** Measured with GNU grep 3.8:
84
- grep exits 0, writes nothing to stdout, and writes `binary file matches` to stderr, so the hit
85
- never reaches the hit list and the stderr capture fails the run instead. With `-I` added, the same
86
- edit goes green, which is why `-I` stays out. The failure message names that case explicitly rather
87
- than blaming an I/O error that did not happen.
88
- A gate that prints OK when it did not read its input is worse than no gate, so nine routes by which
89
- a dead or blind scan could still report green are each checked red, not assumed: a corrupt git
90
- index, an unreadable tracked file, a tracked file named `-q`, a C-quoted non-ASCII path, a
91
- mis-encoded text file, an empty tracked-file list, a tracked file named `-`, a tracked symlink to a
92
- directory, and the NUL-bearing source file gaining an em dash.
93
- Known limits are documented in the script header and inherited knowingly from the shared shape
94
- rather than patched in this copy alone: encoded-form matching is literal (so `&#x2014` without the
95
- semicolon, and lowercase `%e2%80%94`, pass), stderr capture binds to the scanning `grep` rather
96
- than to the filters ahead of it, an em dash encoded in a non-UTF-8 charset is not matched (a live
97
- possibility here, given `(0008,0005)` Specific Character Set fixtures), and the scan reads file
98
- **contents** only, so a tracked path that itself carries an em dash passes. Tooling only: no
99
- runtime, public-API, or parse-behavior change.
100
- - **`docs-content/` now covers the full canonical Diátaxis spine** (`DOCS-CONTENT-P6`). Beyond the
101
- existing Overview (`intro`), the sidebar gains **Installation** and **Quickstart** (tutorials),
102
- five **Core Concepts** notes (the object model, the tolerance/warning model, the typed value
103
- layer, the safety-critical views, and the source-profile system), a **Guides** cookbook (four
104
- recipes: re-serialize, de-identify, read raw pixel data, triage warnings), and a
105
- **Troubleshooting & known limitations** reference. Every documented capability is grounded in the
106
- package's actually-shipped surface; the metadata-first boundary is stated explicitly, with the
107
- permanent non-goals named in Troubleshooting (**no pixel decode, no DIMSE networking, no
108
- DICOMweb**, no pixel-level de-identification).
109
- - **Doc/code-agreement gate (`test/docs-content.test.ts`).** Every ` ```ts runnable ` snippet in
110
- `docs-content/` is extracted, compiled, and executed against the **built** package via
111
- `docSnippetSuite()` from `@cosyte/vitest-config/snippets`, with its inline `// =>` assertions
112
- checked, so a documented example can never silently drift from the code. All examples use
113
- synthetic, base64-encoded Part 10 objects (invented patient, fake UIDs); no real PHI, no `.dcm`
114
- file on disk.
350
+ ### Changed
351
+
352
+ - **The PS3.5 repeating-group bound is now derived from pinned normative documents rather than
353
+ transcribed (`DICOM-PS35-NOT-VENDORED`). No runtime impact: the emitted bound is identical and no
354
+ public API moves.** `deidentify()` expands PS3.15 Table E.1-1's `(50xx,xxxx)`, `(60xx,3000)` and
355
+ `(60xx,4000)` rows over the concrete groups PS3.5 section 7.6 admits, sixteen even groups per mask
356
+ (`5000`-`501E`, `6000`-`601E`), not 256. That bound is a safety limit in **both** directions, and it
357
+ was the last input here that lived as a **quotation in a source file** while PS3.6 and PS3.15 were
358
+ SHA-pinned and re-hashed before use, so the generator's guard could catch a new mask _prefix_ but
359
+ never a changed _bound_.
360
+ `vendor/nema/part05/` pins `part05.xml` (PS3.5 **2026c**) and `vendor/nema/part05-2004/` pins
361
+ `04_05pu.pdf`, both re-hashed as a precondition, and `scripts/generate-repeating-groups.ts` emits
362
+ `src/dictionary/generated/repeating-groups.ts`, which `src/dictionary/repeating-groups.ts`
363
+ re-exports. **Two documents, because the bound is split across two editions:** the current one
364
+ states the overlay bound normatively and excludes the odd `6001`-`601F`, but says nothing about the
365
+ curve bound, having retired curve encoding and delegated it to PS3.5-2004 by URL in section 7.6's
366
+ own Note. So the 2004 PDF is the authority the edition in force names, not a convenience copy. The
367
+ generator **proves that delegation** (section 7.6 must link exactly the vendored URL) instead of
368
+ assuming it, and because both editions state the overlay bound it **requires them to agree**.
369
+ `gen:repeating-groups` runs first in `gen:all`, since the Annex E generator expands family rows
370
+ through this bound.
371
+ **The values did not change; their falsifiability did.** Moving the overlay bound in either
372
+ vendored edition reds the cross-check, moving the curve bound in the 2004 edition moves the emitted
373
+ artifact (caught by the byte-identical regen gate), and removing the delegation link stops the 2004
374
+ document being used at all. Each was confirmed non-vacuous by disabling the guard and watching the
375
+ test fail. Reading a 2004 PDF needs a PDF reader; the one in that generator is deliberately minimal
376
+ (Node `zlib` only, one sentence recovered, matched against a precise expected shape) and is not to
377
+ be grown into a general parser. There is no staleness clock, for the same reasons as PS3.6 and
378
+ PS3.15.
379
+ - **Recorded what the vendor pin is actually current against (`DICOM-DICT-CURRENCY`).**
380
+ `vendor/innolitics/README.md` gains a measured Currency section and drops the "re-pin monthly"
381
+ policy, which nothing ran and which measured the wrong thing. The pin is exactly current against
382
+ upstream: `git ls-remote` resolves `master` to the pinned SHA and all four vendored files are
383
+ byte-identical to upstream at it. Upstream itself is the stale link, having last changed
384
+ `attributes.json` on 2024-04-18 ("Update standard to rev2024b"), so the tag tables are grounded in
385
+ PS3.6 2024b while the current edition is PS3.6 2026c. The drift is now measured rather than
386
+ assumed, against the NEMA DocBook source for 2026c, and every non-additive difference is named in
387
+ that file. Figures on `86ab6c1` (`origin/main` at measurement time; this slice does not touch
388
+ `tags.ts` or `keywords.ts`): all **5,129** committed tags are shared with PS3.6 2026c, and across
389
+ them there are **zero VR differences**, zero name differences and zero VM differences, with 2
390
+ keyword and 2 retirement-status differences. **Every tag this dictionary carries is still in
391
+ PS3.6 2026c**; the drift is otherwise purely additive, 180 tags the standard has gained. VR is
392
+ what turns bytes into a value, so zero VR drift is the result that matters for reading a real
393
+ study. Nothing in the dictionary is hand-edited to close the gap: it is generated, and the
394
+ remaining differences are recorded for the next re-pin. One method note lives with the
395
+ measurement, because getting it wrong is easy and was got wrong on the first run: DICOM tag values
396
+ are hexadecimal and their case is not semantic, so tag keys must be compared case-insensitively.
397
+ PS3.6 prints them uniformly uppercase and `tags.ts` agrees on 1,540 of its 5,129 keys, but
398
+ `tags.ts` lowercases the 8 repeating-group keys whose trailing digits are hex letters and no
399
+ others, so a verbatim comparison mis-classifies exactly those 8.
115
400
 
116
401
  ### Fixed
117
402
 
@@ -274,56 +559,84 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
274
559
  and a chained clean would leave the working tree empty and the build broken. The gate does its own
275
560
  delete rather than trusting the script under test.
276
561
 
277
- ### Changed
562
+ ## [0.0.3] - 2026-07-27
278
563
 
279
- - **The PS3.5 repeating-group bound is now derived from pinned normative documents rather than
280
- transcribed (`DICOM-PS35-NOT-VENDORED`). No runtime impact: the emitted bound is identical and no
281
- public API moves.** `deidentify()` expands PS3.15 Table E.1-1's `(50xx,xxxx)`, `(60xx,3000)` and
282
- `(60xx,4000)` rows over the concrete groups PS3.5 section 7.6 admits, sixteen even groups per mask
283
- (`5000`-`501E`, `6000`-`601E`), not 256. That bound is a safety limit in **both** directions, and it
284
- was the last input here that lived as a **quotation in a source file** while PS3.6 and PS3.15 were
285
- SHA-pinned and re-hashed before use, so the generator's guard could catch a new mask _prefix_ but
286
- never a changed _bound_.
287
- `vendor/nema/part05/` pins `part05.xml` (PS3.5 **2026c**) and `vendor/nema/part05-2004/` pins
288
- `04_05pu.pdf`, both re-hashed as a precondition, and `scripts/generate-repeating-groups.ts` emits
289
- `src/dictionary/generated/repeating-groups.ts`, which `src/dictionary/repeating-groups.ts`
290
- re-exports. **Two documents, because the bound is split across two editions:** the current one
291
- states the overlay bound normatively and excludes the odd `6001`-`601F`, but says nothing about the
292
- curve bound, having retired curve encoding and delegated it to PS3.5-2004 by URL in section 7.6's
293
- own Note. So the 2004 PDF is the authority the edition in force names, not a convenience copy. The
294
- generator **proves that delegation** (section 7.6 must link exactly the vendored URL) instead of
295
- assuming it, and because both editions state the overlay bound it **requires them to agree**.
296
- `gen:repeating-groups` runs first in `gen:all`, since the Annex E generator expands family rows
297
- through this bound.
298
- **The values did not change; their falsifiability did.** Moving the overlay bound in either
299
- vendored edition reds the cross-check, moving the curve bound in the 2004 edition moves the emitted
300
- artifact (caught by the byte-identical regen gate), and removing the delegation link stops the 2004
301
- document being used at all. Each was confirmed non-vacuous by disabling the guard and watching the
302
- test fail. Reading a 2004 PDF needs a PDF reader; the one in that generator is deliberately minimal
303
- (Node `zlib` only, one sentence recovered, matched against a precise expected shape) and is not to
304
- be grown into a general parser. There is no staleness clock, for the same reasons as PS3.6 and
305
- PS3.15.
306
- - **Recorded what the vendor pin is actually current against (`DICOM-DICT-CURRENCY`).**
307
- `vendor/innolitics/README.md` gains a measured Currency section and drops the "re-pin monthly"
308
- policy, which nothing ran and which measured the wrong thing. The pin is exactly current against
309
- upstream: `git ls-remote` resolves `master` to the pinned SHA and all four vendored files are
310
- byte-identical to upstream at it. Upstream itself is the stale link, having last changed
311
- `attributes.json` on 2024-04-18 ("Update standard to rev2024b"), so the tag tables are grounded in
312
- PS3.6 2024b while the current edition is PS3.6 2026c. The drift is now measured rather than
313
- assumed, against the NEMA DocBook source for 2026c, and every non-additive difference is named in
314
- that file. Figures on `86ab6c1` (`origin/main` at measurement time; this slice does not touch
315
- `tags.ts` or `keywords.ts`): all **5,129** committed tags are shared with PS3.6 2026c, and across
316
- them there are **zero VR differences**, zero name differences and zero VM differences, with 2
317
- keyword and 2 retirement-status differences. **Every tag this dictionary carries is still in
318
- PS3.6 2026c**; the drift is otherwise purely additive, 180 tags the standard has gained. VR is
319
- what turns bytes into a value, so zero VR drift is the result that matters for reading a real
320
- study. Nothing in the dictionary is hand-edited to close the gap: it is generated, and the
321
- remaining differences are recorded for the next re-pin. One method note lives with the
322
- measurement, because getting it wrong is easy and was got wrong on the first run: DICOM tag values
323
- are hexadecimal and their case is not semantic, so tag keys must be compared case-insensitively.
324
- PS3.6 prints them uniformly uppercase and `tags.ts` agrees on 1,540 of its 5,129 keys, but
325
- `tags.ts` lowercases the 8 repeating-group keys whose trailing digits are hex letters and no
326
- others, so a verbatim comparison mis-classifies exactly those 8.
564
+ This release carries two version bumps' worth of change. `0.0.2` was bumped on the same day and
565
+ never published, so the four changesets it consumed reached npm here, in `0.0.3`, together with
566
+ the regen-gate repair below.
567
+
568
+ ### Added
569
+
570
+ - **Em-dash brand gate in CI (`EMDASH-CONFORMANCE`).** The founder directive of 2026-07-24
571
+ (`knowledgebase/06-brand/voice-and-tone.md`, "No em dashes. Ever.") bans `U+2014` outright across
572
+ every cosyte surface and names commit messages explicitly, and the meta-repo's
573
+ `documentation/conventions.md` has stated for weeks that the rule is CI-gated. It now actually is,
574
+ here: `scripts/check-no-emdash.sh` (`pnpm check:no-emdash`) plus a dedicated
575
+ `.github/workflows/no-emdash.yml` job that scans **both** halves the rule covers, the tracked files
576
+ **and** the PR title, body, and commit messages. The workflow carries the non-default `edited`
577
+ pull-request trigger, which is load-bearing: this repo squash-merges under
578
+ `squash_merge_commit_title: COMMIT_OR_PR_TITLE` and `squash_merge_commit_message: COMMIT_MESSAGES`
579
+ (read off the repo, not assumed), so the subject that lands on `main` is the PR title, or the lone
580
+ commit's subject when the branch has exactly one, and the body is the branch commit messages.
581
+ Without `edited` a title changed after the last push would never be re-checked. The PR body does
582
+ **not** land; the gate scans it anyway, as deliberate over-strictness on a surface that costs
583
+ nothing to cover. It is a separate workflow rather than a job in `ci.yml` because that trigger
584
+ would otherwise re-run the whole Node 22 + 24 matrix on every typo fix, and because the shared
585
+ `cosyte/.github` pipeline runs no arbitrary repo script.
586
+ **This one did change content, unlike the earlier ports.** An ecosystem survey had measured
587
+ markdown only (0 of 25 `.md` files) and read dicom as already clean. Measuring all 178 tracked
588
+ files found six live em dashes in four non-markdown files, all removed here (see Changed, below).
589
+ Measure every tracked file, because that is what the scan covers.
590
+ The script is the **text-only** variant, taken from `ncpdp` (PR #34, `39212bb`) rather than from
591
+ the older `knowledgebase` copy, so it carries `ncpdp`'s two fixes to the shared shape instead of
592
+ inheriting the holes: a tracked file named `-` was read by `grep` as standard input (which `xargs`
593
+ points at `/dev/null`) and so was never opened, and `-d skip` silently passed a tracked symlink to
594
+ a directory. Both are re-proved here rather than taken on faith: with the `./` path prefix removed
595
+ the gate prints OK and exits 0 over a live em dash in a file named `-`, and with `-d skip` restored
596
+ it goes green over an unread symlink.
597
+ It deliberately omits `grep -I`, and in this repo that is the most load-bearing flag choice in the
598
+ file. `src/dataset/vr/charset.ts` holds a functional NUL byte inside `/[\x00 ]+$/u`, the regex that
599
+ strips DICOM's own padding, so grep classifies it binary. It carries no em dash today and scans
600
+ green, which is the whole answer to the belief that this port was blocked: the red would come from
601
+ a match, never from the NUL. **If it ever gains one, the gate reds.** Measured with GNU grep 3.8:
602
+ grep exits 0, writes nothing to stdout, and writes `binary file matches` to stderr, so the hit
603
+ never reaches the hit list and the stderr capture fails the run instead. With `-I` added, the same
604
+ edit goes green, which is why `-I` stays out. The failure message names that case explicitly rather
605
+ than blaming an I/O error that did not happen.
606
+ A gate that prints OK when it did not read its input is worse than no gate, so nine routes by which
607
+ a dead or blind scan could still report green are each checked red, not assumed: a corrupt git
608
+ index, an unreadable tracked file, a tracked file named `-q`, a C-quoted non-ASCII path, a
609
+ mis-encoded text file, an empty tracked-file list, a tracked file named `-`, a tracked symlink to a
610
+ directory, and the NUL-bearing source file gaining an em dash.
611
+ Known limits are documented in the script header and inherited knowingly from the shared shape
612
+ rather than patched in this copy alone: encoded-form matching is literal (so `&#x2014` without the
613
+ semicolon, and lowercase `%e2%80%94`, pass), stderr capture binds to the scanning `grep` rather
614
+ than to the filters ahead of it, an em dash encoded in a non-UTF-8 charset is not matched (a live
615
+ possibility here, given `(0008,0005)` Specific Character Set fixtures), and the scan reads file
616
+ **contents** only, so a tracked path that itself carries an em dash passes. Tooling only: no
617
+ runtime, public-API, or parse-behavior change.
618
+ - **`docs-content/` now covers the full canonical Diátaxis spine** (`DOCS-CONTENT-P6`). Beyond the
619
+ existing Overview (`intro`), the sidebar gains **Installation** and **Quickstart** (tutorials),
620
+ five **Core Concepts** notes (the object model, the tolerance/warning model, the typed value
621
+ layer, the safety-critical views, and the source-profile system), a **Guides** cookbook (four
622
+ recipes: re-serialize, de-identify, read raw pixel data, triage warnings), and a
623
+ **Troubleshooting & known limitations** reference. Every documented capability is grounded in the
624
+ package's actually-shipped surface; the metadata-first boundary is stated explicitly, with the
625
+ permanent non-goals named in Troubleshooting (**no pixel decode, no DIMSE networking, no
626
+ DICOMweb**, no pixel-level de-identification).
627
+ - **Doc/code-agreement gate (`test/docs-content.test.ts`).** Every ` ```ts runnable ` snippet in
628
+ `docs-content/` is extracted, compiled, and executed against the **built** package via
629
+ `docSnippetSuite()` from `@cosyte/vitest-config/snippets`, with its inline `// =>` assertions
630
+ checked, so a documented example can never silently drift from the code. All examples use
631
+ synthetic, base64-encoded Part 10 objects (invented patient, fake UIDs); no real PHI, no `.dcm`
632
+ file on disk.
633
+ - **Trademark notice (`TRADEMARKS.md`).** This package names third-party systems to describe what it
634
+ interoperates with; the notice records that cosyte is not affiliated with, endorsed by, or
635
+ sponsored by any of them, that every reference is descriptive, and that the built-in profiles are
636
+ authored from public sources only. Added to `files` so it ships inside the published tarball, not
637
+ just on GitHub. Documentation only: no runtime or API change.
638
+
639
+ ### Changed
327
640
 
328
641
  - **Removed the six em dashes the new gate found (`EMDASH-CONFORMANCE`).** Four tracked files, none
329
642
  of them markdown: `.github/CODEOWNERS` (2), `.github/workflows/release.yml` (2),
@@ -379,7 +692,8 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
379
692
  therefore "a no-op until that flag is removed". `package.json` carries no `private` field, the repo
380
693
  is public, and the package ships on npm at `0.0.1`, so the comment described the release pipeline as
381
694
  inert when it is live. Documentation only, no behavior change, but a misleading note on a workflow
382
- that really does publish is worth more than a stale one elsewhere.
695
+ that really does publish is worth more than a stale one elsewhere. (`0.0.1` was the published
696
+ version on that date.)
383
697
 
384
698
  - **Corrected stale publish-status language in `docs-content/` (`README-ORG-SWEEP`).**
385
699
  `installation.md` claimed the package was "not yet published to npm" and that the install command
@@ -387,57 +701,13 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
387
701
  published … not on npm; the first provenance publish is gated on the coordinated public launch"
388
702
  non-goal. `@cosyte/dicom` is published on npm at `0.0.1` and public. Both now state the truth
389
703
  (published, public, still pre-alpha on the `0.0.x`-until-first-alpha ladder), and the troubleshooting
390
- bullet becomes an honest pin-your-version pre-alpha caveat rather than a stale non-goal.
391
-
392
- - **`private: true` removed: `@cosyte/dicom` can publish.** The flag dated to the very first
393
- scaffold commit and was never explained; `changeset publish` silently skips a private package, so
394
- this repo was the one parser that could not reach npm even once its pipeline worked. It also
395
- contradicted the `publishConfig: { access: "public" }` in the same file. Removed as part of the
396
- coordinated public launch (`PUB-FLIP`).
704
+ bullet becomes an honest pin-your-version pre-alpha caveat rather than a stale non-goal. (`0.0.1`
705
+ was the published version on that date; `npm view @cosyte/dicom version` is the current one.)
397
706
 
398
- - **The release can actually bump the version.** `package.json` had no `version` script, so the
399
- shared pipeline's `pnpm run version` failed with `Command "version" not found` and the release
400
- aborted before opening a "Version Packages" PR. Adds `scripts/sync-version.mjs` (the `hl7`
401
- reference, retargeted at `src/version.ts`) and the `version` script that runs it after
402
- `changeset version`, so the bump and the `VERSION` constant land in the same commit.
403
- - **`VERSION` is no longer typed as a string literal.** It was declared `export const VERSION =
404
- "0.0.0"`, giving it the literal type `"0.0.0"`, so the exported type would change on every
405
- release, making each version bump a breaking type change. Now annotated `: string`, matching the
406
- `hl7` reference. Type-only; the runtime value is unchanged. Done now because the package is
407
- unpublished. After the first publish this would itself be a breaking change.
408
-
409
- - **The Release workflow can actually start.** `.github/workflows/release.yml` calls the shared
410
- `cosyte/.github` pipeline, which requests `contents`/`id-token`/`pull-requests: write`, but declared
411
- no `permissions:` of its own, so it inherited the repo default of `contents: read`. A called
412
- workflow may only downgrade the caller's `GITHUB_TOKEN`, never escalate it, so GitHub rejected the
413
- workflow at startup (~1s, no jobs, no logs). Every Release run from June 2026 until now failed this
414
- way, unnoticed, because a `startup_failure` produces no logs to read. The caller job now declares
415
- the three scopes explicitly. CI-only: no runtime or API change.
416
-
417
- ### Security
418
-
419
- - **Dev-dependency advisory remediation (no runtime impact: the published
420
- artifact is unchanged).** Added scoped `pnpm.overrides` pinning two
421
- transitive **dev/build-time** packages to their patched releases: `esbuild`
422
- (`>=0.27.3 <0.28.1` → `0.28.1`; GHSA dev-server path-traversal, not
423
- reachable here: the library builds via `tsup`/`vitest` and never runs
424
- `esbuild serve`) and the `@changesets/parse` copy of `js-yaml`
425
- (`>=4.0.0 <4.2.0` → `4.2.0`; GHSA-h67p-54hq-rp68 merge-key DoS). The
426
- `js-yaml@3.14.2` pulled by `read-yaml-file@1.1.0` (via
427
- `@manypkg/get-packages` → `@changesets/cli`) is **intentionally left**: it
428
- calls `yaml.safeLoad`, removed/throwing in js-yaml 4, so it cannot be
429
- force-upgraded without breaking the release tooling, and it only parses
430
- trusted local repo YAML at release time. This is the shared canonical
431
- override block, enforced suite-wide by the `@cosyte/config` drift check.
707
+ ## [0.0.1] - 2026-07-17
432
708
 
433
709
  ### Added
434
710
 
435
- - **Trademark notice (`TRADEMARKS.md`).** This package names third-party systems to describe what it
436
- interoperates with; the notice records that cosyte is not affiliated with, endorsed by, or
437
- sponsored by any of them, that every reference is descriptive, and that the built-in profiles are
438
- authored from public sources only. Added to `files` so it ships inside the published tarball, not
439
- just on GitHub. Documentation only: no runtime or API change.
440
-
441
711
  - **Lossless File Meta round-trip.** The parser now retains non-modeled
442
712
  `(0002,xxxx)` File Meta elements (e.g. `(0002,0017)`/`(0002,0018)`
443
713
  Sending/Receiving AE Title, `(0002,0100)` Private Information Creator UID,
@@ -519,28 +789,6 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
519
789
  built only from the code + the offending Transfer Syntax UID (structural facts), never a decoded
520
790
  value, so it is always safe to log. New public exports: `serializeDicom`, `DicomSerializeError`,
521
791
  `SERIALIZE_ERROR_CODES`, `SerializeErrorCode`.
522
-
523
- ### Known limitations
524
-
525
- - **File Meta round-trip is over the modeled surface, not byte-exact.** Only the typed `FileMeta`
526
- fields round-trip; any other `(0002,xxxx)` element a source file carried (e.g. `(0002,0100)` Private
527
- Information Creator UID) is dropped at _parse_ time (the Phase 2 `FileMeta` view does not model it)
528
- and so cannot be re-emitted. The preamble is normalized to zeros and odd-length values are padded
529
- even. The output stays spec-clean but is not a byte-identical copy of a non-conformant input.
530
-
531
- ### Tests
532
-
533
- - **Enhanced multi-frame coverage (DICOM-COV).** Closed the Per-Frame-else-Shared branch gaps left by
534
- the Phase 4 functional-group resolver (`functional-groups.ts`: ~53% → 100% branch): both optional
535
- macros (Pixel Value Transformation `(0028,9145)`, Frame VOI LUT `(0028,9132)`), Pixel Measures
536
- `spacingBetweenSlices`, shared-only resolution (no Per-Frame Functional Groups Sequence), the
537
- lenient inner-attribute-absence paths (a macro item present but its attributes omitted ⇒ typed-absent,
538
- never coerced), and all three `MISSING_REQUIRED_FUNCTIONAL_GROUP` throws (Pixel Measures / Plane
539
- Position / Plane Orientation). Synthetic fixtures only; no public-surface change. Per-directory
540
- coverage now sits genuinely ≥ 90 on every gated directory (global branches 93.2%).
541
-
542
- ### Added
543
-
544
792
  - **Safety-critical domain helpers (Phase 4).** New `Dataset` accessors `patient` / `study` /
545
793
  `series` / `image` return typed, fail-safe views over the DICOM §4 safety-critical attributes
546
794
  (memoized on first access). `patient` surfaces the `{id, issuerOfId, issuerQualifiers}` identity
@@ -605,3 +853,68 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
605
853
  - CI/release workflows reduced to thin callers of the reusable `cosyte/.github` pipelines
606
854
  (`ci.yml` runs the shared PHI scan; `release.yml` targets `@cosyte/dicom`). The repo-specific
607
855
  byte-identical dictionary-regen workflow is kept and bumped to Node 22.
856
+
857
+ ### Fixed
858
+
859
+ - **`private: true` removed: `@cosyte/dicom` can publish.** The flag dated to the very first
860
+ scaffold commit and was never explained; `changeset publish` silently skips a private package, so
861
+ this repo was the one parser that could not reach npm even once its pipeline worked. It also
862
+ contradicted the `publishConfig: { access: "public" }` in the same file. Removed as part of the
863
+ coordinated public launch (`PUB-FLIP`).
864
+
865
+ - **The release can actually bump the version.** `package.json` had no `version` script, so the
866
+ shared pipeline's `pnpm run version` failed with `Command "version" not found` and the release
867
+ aborted before opening a "Version Packages" PR. Adds `scripts/sync-version.mjs` (the `hl7`
868
+ reference, retargeted at `src/version.ts`) and the `version` script that runs it after
869
+ `changeset version`, so the bump and the `VERSION` constant land in the same commit.
870
+ - **`VERSION` is no longer typed as a string literal.** It was declared `export const VERSION =
871
+ "0.0.0"`, giving it the literal type `"0.0.0"`, so the exported type would change on every
872
+ release, making each version bump a breaking type change. Now annotated `: string`, matching the
873
+ `hl7` reference. Type-only; the runtime value is unchanged. Done now because the package is
874
+ unpublished. After the first publish this would itself be a breaking change.
875
+
876
+ - **The Release workflow can actually start.** `.github/workflows/release.yml` calls the shared
877
+ `cosyte/.github` pipeline, which requests `contents`/`id-token`/`pull-requests: write`, but declared
878
+ no `permissions:` of its own, so it inherited the repo default of `contents: read`. A called
879
+ workflow may only downgrade the caller's `GITHUB_TOKEN`, never escalate it, so GitHub rejected the
880
+ workflow at startup (~1s, no jobs, no logs). Every Release run from June 2026 until now failed this
881
+ way, unnoticed, because a `startup_failure` produces no logs to read. The caller job now declares
882
+ the three scopes explicitly. CI-only: no runtime or API change.
883
+
884
+ ### Security
885
+
886
+ - **Dev-dependency advisory remediation (no runtime impact: the published
887
+ artifact is unchanged).** Added scoped `pnpm.overrides` pinning two
888
+ transitive **dev/build-time** packages to their patched releases: `esbuild`
889
+ (`>=0.27.3 <0.28.1` → `0.28.1`; GHSA dev-server path-traversal, not
890
+ reachable here: the library builds via `tsup`/`vitest` and never runs
891
+ `esbuild serve`) and the `@changesets/parse` copy of `js-yaml`
892
+ (`>=4.0.0 <4.2.0` → `4.2.0`; GHSA-h67p-54hq-rp68 merge-key DoS). The
893
+ `js-yaml@3.14.2` pulled by `read-yaml-file@1.1.0` (via
894
+ `@manypkg/get-packages` → `@changesets/cli`) is **intentionally left**: it
895
+ calls `yaml.safeLoad`, removed/throwing in js-yaml 4, so it cannot be
896
+ force-upgraded without breaking the release tooling, and it only parses
897
+ trusted local repo YAML at release time. This is the shared canonical
898
+ override block, enforced suite-wide by the `@cosyte/config` drift check.
899
+
900
+ ### Tests
901
+
902
+ - **Enhanced multi-frame coverage (DICOM-COV).** Closed the Per-Frame-else-Shared branch gaps left by
903
+ the Phase 4 functional-group resolver (`functional-groups.ts`: ~53% → 100% branch): both optional
904
+ macros (Pixel Value Transformation `(0028,9145)`, Frame VOI LUT `(0028,9132)`), Pixel Measures
905
+ `spacingBetweenSlices`, shared-only resolution (no Per-Frame Functional Groups Sequence), the
906
+ lenient inner-attribute-absence paths (a macro item present but its attributes omitted ⇒ typed-absent,
907
+ never coerced), and all three `MISSING_REQUIRED_FUNCTIONAL_GROUP` throws (Pixel Measures / Plane
908
+ Position / Plane Orientation). Synthetic fixtures only; no public-surface change. Per-directory
909
+ coverage now sits genuinely ≥ 90 on every gated directory (global branches 93.2%).
910
+
911
+ ### Known limitations
912
+
913
+ - **File Meta round-trip is over the modeled surface, not byte-exact.** Only the typed `FileMeta`
914
+ fields round-trip; any other `(0002,xxxx)` element a source file carried (e.g. `(0002,0100)` Private
915
+ Information Creator UID) is dropped at _parse_ time (the Phase 2 `FileMeta` view does not model it)
916
+ and so cannot be re-emitted. The preamble is normalized to zeros and odd-length values are padded
917
+ even. The output stays spec-clean but is not a byte-identical copy of a non-conformant input.
918
+ **Superseded within this same release** by the lossless File Meta round-trip above, which landed
919
+ after the serializer and before `0.0.1` was cut: as published, `0.0.1` preserves and re-emits non-modeled
920
+ `(0002,xxxx)` elements. The entry is kept because it is what the serializer shipped with.