@cosyte/dicom 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -74,7 +74,13 @@ interface DictionaryEntry {
74
74
  readonly repeatingGroup?: boolean;
75
75
  }
76
76
  /**
77
- * One DICOM UID as published in PS3.6 / PS3.4 (UID registry).
77
+ * One DICOM UID as published in PS3.6 Annex A (Tables A-1 and A-2).
78
+ *
79
+ * `name` is the normative UID Name, with two deliberate conveniences. Retirement is carried in
80
+ * `retired` rather than as the trailing `(Retired)` every retired Annex A row carries in its UID
81
+ * Name, and four transfer syntaxes carry the short form every DICOM toolkit prints rather than
82
+ * PS3.6's longer `...: Default Transfer Syntax for ...` form. Every other name is the normative
83
+ * text.
78
84
  *
79
85
  * @example
80
86
  * import { Dictionary } from "@cosyte/dicom";
@@ -108,6 +114,18 @@ interface UidEntry {
108
114
  * throw `TypeError` in strict mode (which all of `@cosyte/dicom`'s emitted code
109
115
  * runs under, given `"use strict"` is implicit for ES modules).
110
116
  *
117
+ * @example
118
+ * ```ts
119
+ * import { Dictionary } from "@cosyte/dicom";
120
+ *
121
+ * // Tag or keyword, either way round, and `undefined` rather than a throw on a miss.
122
+ * Dictionary.lookup("00100020")?.name; // "Patient ID"
123
+ * Dictionary.byKeyword("Modality")?.tag; // "00080060"
124
+ *
125
+ * // UIDs resolve to their registered name, so nobody hard-codes a SOP Class table.
126
+ * Dictionary.uid("1.2.840.10008.5.1.4.1.1.2")?.name; // "CT Image Storage"
127
+ * ```
128
+ *
111
129
  * @module
112
130
  */
113
131
 
@@ -141,9 +159,11 @@ declare function byKeyword(keyword: string): DictionaryEntry | undefined;
141
159
  /**
142
160
  * Look up a DICOM UID by its dotted-decimal value.
143
161
  *
144
- * Returns `undefined` for unknown UIDs or malformed input. Used by
145
- * `parseDicom()` (Phase 2) to render human-readable Transfer Syntax names
146
- * (DICT-06).
162
+ * Covers PS3.6 Annex A whole: transfer syntaxes, SOP and Meta SOP Classes,
163
+ * well-known SOP Instances and Frames of Reference, coding schemes and the
164
+ * rest, current and retired alike. Returns `undefined` for unknown UIDs or
165
+ * malformed input. Used by `parseDicom()` (Phase 2) to render human-readable
166
+ * Transfer Syntax names (DICT-06).
147
167
  *
148
168
  * @example
149
169
  * import { Dictionary } from "@cosyte/dicom";
@@ -309,24 +329,35 @@ interface ParseOptions {
309
329
  *
310
330
  * 🛑 **THE ESCALATED DIAGNOSTIC CARRIES SOURCE BYTES THAT THE WARNING DOES
311
331
  * NOT.** A `DicomParseWarning.message` is a frozen registry string with only
312
- * structural tokens filled in, so it is safe to log whole; the
313
- * `DicomParseError` this option raises in its place also carries `snippet`,
332
+ * structural tokens filled in, so it is safe to log whole **on a well-formed
333
+ * file, and it is not unconditionally safe**: the `{tag}` slot is filled by a
334
+ * shape check, which cannot refuse a tag a lying Value Length composed out of
335
+ * somebody's value (measured, `PRE-EXISTING`, disclosed rather than guarded
336
+ * because withholding the tag would take it off every private element in every
337
+ * conformant file). The `DicomParseError` this option raises in its place is a
338
+ * different and larger surface: it also carries `snippet`,
314
339
  * **16 raw bytes, unredacted** (D-10), read at the warning's own `byteOffset`.
315
- * **Which element those bytes belong to is not contracted**: that offset's
316
- * frame follows where the element was read - file-absolute at the root,
317
- * relative to the enclosing slice inside a defined-length Sequence or Item,
318
- * and into the inflated stream under Deflated Explicit VR LE - while the
319
- * snippet is cut from whichever buffer the parse is holding, so the two can
320
- * disagree and the bytes can be an unrelated element's value. Do not reason
321
- * from a code's message to what its snippet holds - **measure it, and treat
322
- * every one of them as document content.**
323
- *
324
- * That is the documented design of `snippet` rather than a defect in any one
325
- * code, and turning this option on does not change what any of them means -
326
- * but a message-only PHI review of the lenient path does not transfer to the
327
- * strict one. Log `err.code`, `err.byteOffset` and `err.message`; treat
340
+ * A message-only PHI review of the lenient path therefore does not transfer to
341
+ * the strict one. Log `err.code`, `err.byteOffset` and `err.message`; treat
328
342
  * `err.snippet` as PHI.
329
343
  *
344
+ * **The snippet is cut in the SAME FRAME the `byteOffset` is counted in**, so
345
+ * it is the bytes at the offset the diagnostic names: file-absolute at the
346
+ * root, relative to the enclosing slice inside a defined-length Sequence or
347
+ * Item, and into the inflated stream under Deflated Explicit VR LE. It was not
348
+ * always: until `DICOM-FATAL-MESSAGE-REGISTRY` the offset moved with the frame
349
+ * while the cut was always taken from the whole file, so inside a
350
+ * defined-length Item the 16 bytes were **an unrelated element's** - a
351
+ * diagnostic disclosing data from a part of the document the reader was never
352
+ * asked about. That is closed. **What is NOT closed, and never was a defect:
353
+ * the bytes are still raw source bytes.** Reading them as safe because the
354
+ * message beside them is registry-bound is the mistake this whole paragraph
355
+ * exists to prevent.
356
+ *
357
+ * **`byteOffset` still carries no frame-of-reference contract**, and closing
358
+ * the mismatch did not give it one: a nested offset is still not a key you can
359
+ * look up against the root. Measure it rather than reasoning about it.
360
+ *
330
361
  * Omit (do not pass `undefined`) to use the default.
331
362
  */
332
363
  readonly strict?: boolean;
@@ -907,6 +938,21 @@ declare class Element {
907
938
  * `(0002,0102)` Private Information - is captured here as raw bytes (the
908
939
  * on-wire value, even-length per PS3.5 §6.2) rather than dropped. `value` is a
909
940
  * defensive copy, so the view never aliases the parsed input buffer.
941
+ *
942
+ * @example
943
+ * ```ts
944
+ * import { parseDicom } from "@cosyte/dicom";
945
+ * const ds = parseDicom(buf);
946
+ * // Anything the typed FileMeta fields do not model, in ascending tag order.
947
+ * for (const raw of ds.fileMeta?.extraElements ?? []) {
948
+ * raw.tag; // e.g. "00020017" Sending Application Entity Title
949
+ * raw.vr; // the VR the source wrote (File Meta is always Explicit VR LE)
950
+ * raw.value.length; // even, per PS3.5 2026c section 7.1.1
951
+ * }
952
+ * ```
953
+ *
954
+ * `(0002,0016)` Source Application Entity Title is deliberately NOT the example: it is one of the
955
+ * typed fields, so it never reaches this array.
910
956
  */
911
957
  interface FileMetaRawElement {
912
958
  /** 8-char uppercase hex tag, e.g. `"00020100"`. */
@@ -2348,16 +2394,23 @@ declare const DEIDENTIFY_OPTIONS: readonly DeidentifyOption[];
2348
2394
  */
2349
2395
  type AppliedAction = "removed" | "emptied" | "dummied" | "uid-remapped" | "cleaned" | "kept";
2350
2396
  /**
2351
- * One audited attribute outcome. Carries only structural facts - tag, keyword,
2352
- * the resolved Annex E action code, and the SQ context path - **never** a
2353
- * decoded value, so a report is always safe to log.
2397
+ * One audited attribute outcome. `tag`, `keyword`, `action`, `applied` and
2398
+ * `repeatingGroup` carry **never** a decoded value: `keyword`, `action` and
2399
+ * `repeatingGroup` come from the Part 6 and Annex E tables, and `tag` is bound to
2400
+ * a tag those tables carry a row for, which is membership in a closed table
2401
+ * rather than a shape test.
2402
+ *
2403
+ * **🩺 `contextPath` is NOT in that class and this docstring used to say it was.**
2404
+ * It is a chain of tags read off the wire, bound by nothing - see
2405
+ * {@link DeidentifiedAttribute.contextPath} and the note on
2406
+ * {@link DeidentifyReport}.
2354
2407
  *
2355
2408
  * @example
2356
2409
  * ```ts
2357
2410
  * import { deidentify, parseDicom, type DeidentifiedAttribute } from "@cosyte/dicom";
2358
2411
  * const { report } = deidentify(parseDicom(buf));
2359
2412
  * report.attributes.forEach((a: DeidentifiedAttribute) => {
2360
- * console.log(a.keyword, a.action, a.applied); // structural facts only - safe to log
2413
+ * console.log(a.keyword, a.action, a.applied); // composed from tables - safe to log
2361
2414
  * });
2362
2415
  * ```
2363
2416
  */
@@ -2367,7 +2420,45 @@ interface DeidentifiedAttribute {
2367
2420
  /** The resolved single action after collapsing any conditional code. */
2368
2421
  readonly action: Exclude<AnnexEActionCode, `${string}/${string}`>;
2369
2422
  readonly applied: AppliedAction;
2370
- /** Tag/index chain for an attribute inside a sequence; omitted at the root. */
2423
+ /**
2424
+ * Tag/index chain for an attribute inside a sequence; omitted at the root.
2425
+ *
2426
+ * **🩺 Each segment is `TAG[index]`, and the `TAG` half is read off the wire
2427
+ * with no table behind it.** It is whatever tag the descent walked, so it is
2428
+ * bound by neither a shape test nor membership in a closed one - which is what
2429
+ * separates it from `tag`, `keyword`, `action` and `repeatingGroup`. On a file
2430
+ * where an under-declared Value Length desynchronized the reader onto four
2431
+ * bytes sitting **inside** somebody's value, those four bytes become a segment
2432
+ * here. It is not the only identifier on this report read off the wire -
2433
+ * {@link DeidentifyReport.removedPrivateTags} and
2434
+ * {@link UnauditableSequenceFinding.tag} are too - but those two are disclosed
2435
+ * as such, and this one was documented as structural.
2436
+ *
2437
+ * Measured on a synthetic `LO` carrier holding `"MRS BRAIN SMITHSON"` that
2438
+ * under-declares by four: the reader resynchronizes onto a fabricated `SQ`
2439
+ * header, descends it, and the report reads `contextPath: ["53484E4F[0]"]` -
2440
+ * `"HSON"` in wire order, recovered by writing the two halves back with
2441
+ * `writeUInt16LE`. **No warning is raised and every finding array on the
2442
+ * report is empty.** Change the surname and the published segment changes with
2443
+ * it. `PRE-EXISTING`, on every release that has shipped the field.
2444
+ *
2445
+ * **🛑 IT IS NOT THE ONLY PLACE THOSE BYTES SURFACE, AND AN EARLIER DRAFT OF
2446
+ * THIS NOTE SAID IT WAS. A GRADED PASS REFUTED THAT AND IT MUST NOT COME
2447
+ * BACK.** On the same file the de-identified `Dataset` still carries the
2448
+ * fabricated `(5348,4E4F)`, so `serializeDicom` writes its header back out in
2449
+ * full - `"HSON"` included - inside an object stamped
2450
+ * `(0012,0062) Patient Identity Removed = YES`. That re-emission belongs to
2451
+ * the disclosed under-declare carrier class, not to this field, and neither is
2452
+ * a bound on the other: **redacting `contextPath` from a log does not make the
2453
+ * object safe to share.** Pinned in
2454
+ * `test/integration/phi-diagnostic-surface.test.ts`.
2455
+ *
2456
+ * It is published anyway, on the same footing as
2457
+ * {@link DeidentifyReport.removedPrivateTags}: **where** an attribute sat is
2458
+ * the whole audit value of the field, and withholding it would destroy that on
2459
+ * every well-formed file in order to bound a malformed one. **Treat it as PHI
2460
+ * when the source is untrusted.**
2461
+ */
2371
2462
  readonly contextPath?: readonly string[];
2372
2463
  /**
2373
2464
  * Present when the action came from a Table E.1-1 row that names a
@@ -2389,9 +2480,29 @@ interface DeidentifiedAttribute {
2389
2480
  * (PS3.15 §E.1 "all instances"; §E.3.5 is the standard's own precedent for
2390
2481
  * removing identifying information embedded inside a string attribute).
2391
2482
  *
2392
- * Every field is structural: `tag` and `vr` are the carrier's, and `hidden`
2393
- * holds tags composed from four bytes each. No decoded value appears here, so
2394
- * this is safe to log.
2483
+ * `tag` and `vr` are the carrier's own and are structural. **`hidden` was not,
2484
+ * and it is bound now.** Every tag in an embedded run is composed from four
2485
+ * bytes that were sitting *inside* the carrier's value - that is the whole
2486
+ * reason this type exists - and a run needs only ONE actionable attribute to be
2487
+ * reported, so through `0.0.13` the rest of the run was listed beside it.
2488
+ * Measured: a `CS` carrier over-declaring across a fabricated `"SMIT"` header
2489
+ * beside a genuine `(0010,0020)` reported `hidden: ["4D535449", "00100020"]`,
2490
+ * and `4D535449` is `"SMIT"` in wire order.
2491
+ *
2492
+ * **An entry is now one of the 652 literal rows of PS3.15 Table E.1-1 that this
2493
+ * run's options left actionable.** That is a **membership** bound rather than a
2494
+ * shape one - the posture this package already takes for a VR and for a Private
2495
+ * Creator - so what survives names a published table entry rather than a
2496
+ * document byte, the same trade rendering a VR makes with the 34. **A
2497
+ * repeating-group mask hit is NOT in that set and is excluded**: `(50xx,xxxx)`
2498
+ * Curve Data leaves the whole 16-bit element number free, so a mask match proves
2499
+ * a rule exists without making the membership finite. A graded pass caught a
2500
+ * draft of this filter that admitted it.
2501
+ *
2502
+ * **🛑 THAT IS NOT AN ALL-CLEAR OVER THIS TYPE.** `contextPath` below is
2503
+ * unbound and unchanged, `hidden` is uncapped, and
2504
+ * {@link DeidentifyReport} names the report's other value-bearing fields. A
2505
+ * `DeidentifyReport` is still not safe to log whole.
2395
2506
  *
2396
2507
  * @example
2397
2508
  * ```ts
@@ -2407,14 +2518,44 @@ interface EmbeddedAttributeFinding {
2407
2518
  readonly tag: Tag;
2408
2519
  /** The carrier's VR. Always one of the string VRs; binary VRs are not scanned. */
2409
2520
  readonly vr: VR;
2410
- /** The tags of the Data Elements found inside the carrier's value, in wire order. */
2521
+ /**
2522
+ * The tags found inside the carrier's value **that this run acts on and that a
2523
+ * published table names**, in wire order. **Not every tag in the run** - see
2524
+ * this type's own remarks.
2525
+ *
2526
+ * **🩺 IT MAY BE EMPTY, AND EMPTY DOES NOT MEAN "NOTHING WAS HIDDEN HERE".**
2527
+ * A run whose only actionable members are private attributes, Curve Data or
2528
+ * Overlay elements reports a finding with no tags: the carrier was still
2529
+ * emptied, and the accompanying `DICOM_DEIDENT_EMBEDDED_ATTRIBUTE_REMOVED`
2530
+ * warning still counts the whole run. The presence of the finding is the fact;
2531
+ * this list is the part of it that can be named.
2532
+ */
2411
2533
  readonly hidden: readonly Tag[];
2412
- /** Tag/index chain when the carrier is inside a sequence item; omitted at the root. */
2534
+ /**
2535
+ * Tag/index chain when the carrier is inside a sequence item; omitted at the
2536
+ * root. **Built by the same descent as
2537
+ * {@link DeidentifiedAttribute.contextPath} and carrying the same caveat:
2538
+ * each segment's tag is read off the wire, bound by nothing, so on a
2539
+ * desynchronized read it can be four bytes of a value.** Read that field's
2540
+ * note before logging this one.
2541
+ */
2413
2542
  readonly contextPath?: readonly string[];
2414
2543
  }
2415
2544
  /**
2416
- * One `SQ` element that was emptied because the parser never materialized its
2417
- * items, so the de-identifier had no Data Sets to walk.
2545
+ * One Sequence-of-Items carrier that was emptied because this run had no item
2546
+ * stream to walk, so the de-identifier had no Data Sets to reach.
2547
+ *
2548
+ * **Two producers, and the second is not a parsed `SQ`.** The ordinary one is an
2549
+ * `SQ` element whose `items` the parser never materialized. The other is a
2550
+ * private data element retained under `RetainSafePrivate` whose `Profile` entry
2551
+ * declares it `SQ` while the parse tree says otherwise - `UN` under Implicit VR
2552
+ * LE when the profile was passed to `deidentify()` but not to `parseDicom`, or
2553
+ * whatever binary VR the sender wrote under Explicit VR, which wins in the
2554
+ * parser. The profile is the authority that retained the element, and it has
2555
+ * said the value is a Sequence of Items; with no items on the tree, the §E.1.1
2556
+ * obligation below falls on the carrier just the same. Such an element keeps its
2557
+ * parsed VR in the output and is emptied rather than re-typed to `SQ`
2558
+ * (`DICOM-PRIVATE-SQ-PARSE-VR`).
2418
2559
  *
2419
2560
  * PS3.5 2026c §7.5.1 "Item Encoding Rules" states that "Each Item Value shall
2420
2561
  * contain a DICOM Data Set composed of Data Elements", so an `SQ` element's
@@ -2433,12 +2574,26 @@ interface EmbeddedAttributeFinding {
2433
2574
  * the carrier, not as a rule about this case.)
2434
2575
  *
2435
2576
  * Both fields are structural: `tag` is the carrier's and `byteLength` is the
2436
- * declared size of the value that was dropped. No decoded value appears here,
2437
- * so this is safe to log.
2438
- *
2439
- * The parser always announces the underlying refusal first, on
2577
+ * recorded span of the value that was dropped. **No _decoded_ value appears
2578
+ * here, and that is not the same as "safe to log".** On the second producer
2579
+ * `tag` can be four bytes of another element's value: a length under-declared
2580
+ * upstream resynchronizes the reader mid-value, and if the bytes it lands on
2581
+ * spell a private block this caller's profile declares `SQ`, followed by a VR
2582
+ * that is one of the 34, the fabricated header is what you get here. The
2583
+ * package's answer to that class is normally `report.undefinedVrElements`,
2584
+ * which names a byte offset and **no tag**, and it still answers the case where
2585
+ * the fabricated VR is outside the 34 - but it cannot answer this one, because
2586
+ * a fabricated `OB` header and a genuine one are byte-identical. So this shares
2587
+ * the standing exception `report.removedPrivateTags` and `uidMap` already have:
2588
+ * a `DeidentifyReport` is **not** a value-free surface. Treat it as document
2589
+ * content, at the sensitivity of the file it came from.
2590
+ *
2591
+ * For the first producer the parser announces the underlying refusal on
2440
2592
  * `Dataset.warnings`: `DICOM_SQ_NOT_DESCENDED` for a defined-length Implicit VR
2441
- * LE value whose dictionary-resolved `SQ` was not a valid item stream.
2593
+ * LE value whose dictionary-resolved `SQ` was not a valid item stream. **Do not
2594
+ * generalise that to the second.** There the file may be entirely conformant -
2595
+ * an honest defined-length `OB` carrier raises nothing at all - so this report
2596
+ * field, not `Dataset.warnings`, is where that drop is visible.
2442
2597
  *
2443
2598
  * @example
2444
2599
  * ```ts
@@ -2454,7 +2609,14 @@ interface UnauditableSequenceFinding {
2454
2609
  readonly tag: Tag;
2455
2610
  /** Byte length of the value field that was dropped. Structural, never a value. */
2456
2611
  readonly byteLength: number;
2457
- /** Tag/index chain when the carrier is inside a sequence item; omitted at the root. */
2612
+ /**
2613
+ * Tag/index chain when the carrier is inside a sequence item; omitted at the
2614
+ * root. **Built by the same descent as
2615
+ * {@link DeidentifiedAttribute.contextPath} and carrying the same caveat:
2616
+ * each segment's tag is read off the wire, bound by nothing, so on a
2617
+ * desynchronized read it can be four bytes of a value.** Read that field's
2618
+ * note before logging this one.
2619
+ */
2458
2620
  readonly contextPath?: readonly string[];
2459
2621
  }
2460
2622
  /**
@@ -2514,8 +2676,14 @@ interface UnauditableSequenceFinding {
2514
2676
  * finding rather than an inconsistency.
2515
2677
  *
2516
2678
  * `byteOffset` locates the element instead - a position this parser counted.
2517
- * Nothing here renders a document byte: an offset the parser counted, a decoded
2518
- * length, and the structural `contextPath`. Safe to log.
2679
+ *
2680
+ * **🩺 THAT WITHHOLDING IS NOT WHOLE, AND THIS PARAGRAPH USED TO CLAIM IT WAS.**
2681
+ * It said "nothing here renders a document byte ... and the structural
2682
+ * `contextPath`. Safe to log." `contextPath` is not structural: its segments are
2683
+ * tags read off the wire by the same descent, so the header this type refuses to
2684
+ * name by `tag` can be named by the `contextPath` of a finding one level down.
2685
+ * See {@link DeidentifiedAttribute.contextPath} for the measurement. `byteOffset`
2686
+ * and `byteLength` are unaffected and the reasoning above them still stands.
2519
2687
  *
2520
2688
  * @example
2521
2689
  * ```ts
@@ -2546,27 +2714,68 @@ interface UndefinedVrFinding {
2546
2714
  * as "structural, never a value" the way its siblings are described.
2547
2715
  */
2548
2716
  readonly byteLength: number;
2549
- /** Tag/index chain when the carrier is inside a sequence item; omitted at the root. */
2717
+ /**
2718
+ * Tag/index chain when the carrier is inside a sequence item; omitted at the
2719
+ * root. **Built by the same descent as
2720
+ * {@link DeidentifiedAttribute.contextPath} and carrying the same caveat:
2721
+ * each segment's tag is read off the wire, bound by nothing, so on a
2722
+ * desynchronized read it can be four bytes of a value.** Read that field's
2723
+ * note before logging this one.
2724
+ */
2550
2725
  readonly contextPath?: readonly string[];
2551
2726
  }
2552
2727
  /**
2553
2728
  * The audit trail returned alongside the de-identified dataset.
2554
2729
  *
2555
2730
  * Most fields are composed from static tables: Part 6 keywords, Annex E action
2556
- * codes, structural `TAG[index]` sequence paths, and registry warning messages.
2557
- * **Two are not, and both are named here rather than in a footnote.**
2558
- *
2559
- * 1. **`uidMap`** - its keys are the source UIDs read out of the file, kept so a
2560
- * caller can make UID replacement consistent across a study or an archive. A
2561
- * Study or SOP Instance UID is a unique identifying number, so treat it as
2562
- * PHI.
2563
- * 2. **`removedPrivateTags`** - see the field's own note. On a *well-formed*
2564
- * file these are the sender's own private tag numbers and carry nothing; on a
2565
- * malformed one a tag can be four bytes of a value, and it is measured, not
2566
- * theoretical.
2731
+ * codes, the active option names.
2732
+ * **Several fields are not, and they are named here rather than in a footnote.**
2733
+ * 🛑 **Do not quote a COUNT of them, here or anywhere else.** The count read
2734
+ * "one" and then "two" and then "three", and was wrong every time it was read,
2735
+ * because each correction bumped the numeral without re-deriving the list.
2736
+ * **The list below carried its own numerals until one had to be added, which is
2737
+ * the same disease one step removed, so they are gone.** Treat it as the current
2738
+ * reading of a surface that has grown, not as a proof of exhaustiveness.
2739
+ *
2740
+ * - **`uidMap`** - its keys are the source UIDs read out of the file, kept so a
2741
+ * caller can make UID replacement consistent across a study or an archive. A
2742
+ * Study or SOP Instance UID is a unique identifying number, so treat it as
2743
+ * PHI.
2744
+ * - **`removedPrivateTags`** - see the field's own note. On a *well-formed*
2745
+ * file these are the sender's own private tag numbers and carry nothing; on a
2746
+ * malformed one a tag can be four bytes of a value, and it is measured, not
2747
+ * theoretical.
2748
+ * - **`unauditableSequences[].tag`** - see
2749
+ * {@link UnauditableSequenceFinding}. Same shape as `removedPrivateTags` by a
2750
+ * narrower route: a private carrier a {@link Profile} declares `SQ` is named
2751
+ * there, and an under-declared length upstream can resynchronize the reader
2752
+ * onto four bytes that spell such a block. The package's usual answer to a
2753
+ * fabricated header, `undefinedVrElements`, carries a byte offset and no tag,
2754
+ * and still answers it whenever the fabricated VR is outside the 34 PS3.5
2755
+ * §6.2 defines. It cannot when the fabricated VR is one of them, because those
2756
+ * two files are byte-identical.
2757
+ * - **`contextPath`, on all four findings that carry one** - see
2758
+ * {@link DeidentifiedAttribute.contextPath}, which holds the measurement. The
2759
+ * segment tags come off the wire with no table behind them, so a fabricated
2760
+ * `SQ` header the reader descended is named there, `PRE-EXISTING`, with **no
2761
+ * warning and no finding array to correlate it with**. This is the field the
2762
+ * rest of this docstring, the tolerance table and the troubleshooting guide
2763
+ * all called structural. **It is a logging hazard and nothing more: on that
2764
+ * same file the de-identified object itself re-emits the fabricated header, so
2765
+ * redacting this field does not make the object safe.**
2766
+ *
2767
+ * `embeddedAttributes[].hidden` **left that list** in
2768
+ * `DICOM-DIAGNOSTIC-PHI-RESIDUALS` - an entry is now a literal PS3.15 Table
2769
+ * E.1-1 row, so it is not value-bearing. Its own disclosure had been reworded
2770
+ * twice by then, and this repo deletes a disclosure at that point rather than
2771
+ * writing a third; what is true of the field is stated once, on
2772
+ * {@link EmbeddedAttributeFinding}. The field is **still uncapped**.
2567
2773
  *
2568
2774
  * So "the report is safe to log apart from `uidMap`" is **not** an accurate
2569
2775
  * description of this type, and was corrected rather than kept convenient.
2776
+ * **This is the only copy of that list.** Two others lived in module docstrings,
2777
+ * still naming `hidden` after it left and never naming `contextPath` at all; a
2778
+ * graded pass found them and they were deleted rather than resynced.
2570
2779
  *
2571
2780
  * @example
2572
2781
  * ```ts
@@ -2620,9 +2829,11 @@ interface DeidentifyReport {
2620
2829
  * sequence is still emptied; an array exactly that long means "at least this
2621
2830
  * many", so read it as truncated rather than as a total.
2622
2831
  *
2623
- * **It is not a complete list of what went un-audited, either**: a private
2624
- * `SQ` a {@link Profile} vouches for under `RetainSafePrivate` is kept
2625
- * verbatim and never appears here.
2832
+ * **It covers private sequences too, since `DICOM-PRIVATE-SQ-CARVE-OUT`.** A
2833
+ * private `SQ` a {@link Profile} vouches for under `RetainSafePrivate` used to
2834
+ * be kept verbatim and never appear here; it is now emptied and listed on the
2835
+ * same terms as any other, because the profile's licence under PS3.15 §E.3.10
2836
+ * is over a private attribute and not over an item stream nothing could read.
2626
2837
  */
2627
2838
  readonly unauditableSequences: readonly UnauditableSequenceFinding[];
2628
2839
  /**
@@ -2651,14 +2862,17 @@ interface DeidentifyReport {
2651
2862
  * {@link UndefinedVrFinding}: the tag of a fabricated header is itself part of
2652
2863
  * some element's value.
2653
2864
  *
2654
- * Unlike its sibling this list has **no carve-out**, and the reason is
2655
- * structural rather than a promise: `keepOrEmpty` is the **only** path that
2656
- * writes a source value into de-identified output unchanged, and the test sits
2657
- * at the top of it. Every other outcome - `X` remove, `Z`/`C` empty, `D` dummy,
2658
- * `U` remap, and a private tag the Basic Profile drops - already replaces the
2659
- * value. So a `RetainSafePrivate` element a {@link Profile} vouches for still
2660
- * reaches this test and is still emptied, which is where the sibling
2661
- * `SQ`-with-no-items rule has a real carve-out and this one does not.
2865
+ * This list has **no carve-out**, and the reason is structural rather than a
2866
+ * promise: `keepOrEmpty` is the **only** path that writes a source value into
2867
+ * de-identified output unchanged, and the test sits at the top of it. Every
2868
+ * other outcome - `X` remove, `Z`/`C` empty, `D` dummy, `U` remap, and a
2869
+ * private tag the Basic Profile drops - already replaces the value. So a
2870
+ * `RetainSafePrivate` element a {@link Profile} vouches for still reaches this
2871
+ * test and is still emptied. Its sibling
2872
+ * {@link DeidentifyReport.unauditableSequences} had a real one until
2873
+ * `DICOM-PRIVATE-SQ-CARVE-OUT`; neither has one now, and they arrive at that by
2874
+ * different routes - this one because nothing bypasses `keepOrEmpty`, that one
2875
+ * because a vouched-for private `SQ` is routed into the ordinary `SQ` branches.
2662
2876
  */
2663
2877
  readonly undefinedVrElements: readonly UndefinedVrFinding[];
2664
2878
  /**
@@ -2839,8 +3053,9 @@ declare class DeidentifyError extends Error {
2839
3053
  * nine *metadata-affecting* Annex E Options, driven by the generated Table E.1-1
2840
3054
  * action map ({@link annexE}). It is a **pure** function: the input {@link Dataset}
2841
3055
  * is never mutated; a fresh `Dataset` (with a rebuilt element map and File Meta)
2842
- * is returned alongside a {@link DeidentifyReport} whose two non-value-free
2843
- * fields are named on that type: `uidMap` and `removedPrivateTags`.
3056
+ * is returned alongside a {@link DeidentifyReport} whose non-value-free fields
3057
+ * are named on that type. **The list is deliberately not copied here** - see
3058
+ * `./index.ts` for what a third copy of it cost.
2844
3059
  *
2845
3060
  * **What it does**
2846
3061
  * - Resolves each attribute's action (basic profile, overridden by an active
@@ -2877,10 +3092,22 @@ declare class DeidentifyError extends Error {
2877
3092
  * replacement (which needs domain context the metadata layer lacks).
2878
3093
  * - Pixel-level options (`CleanPixelData`, `CleanRecognizableVisual`) are out of
2879
3094
  * scope; burned-in text is warned, never cleaned.
2880
- * - A private data element kept under `RetainSafePrivate` is kept *verbatim* - if
2881
- * it is itself a sequence carrying standard PHI attributes, that nested content
2882
- * is not recursed. The profile vouches the element is safe; nest accordingly.
2883
- * - A **standard** (non-private) `SQ` whose `items` the parser did not
3095
+ * - A private data element kept under `RetainSafePrivate` is kept *verbatim*
3096
+ * **unless it carries a Sequence of Items**. A private `SQ` the profile
3097
+ * vouches for is retained as an element but its items are **walked**, so
3098
+ * standard PHI attributes nested inside it are de-identified like any others
3099
+ * (`DICOM-PRIVATE-SQ-CARVE-OUT`; it was kept whole and unexamined through
3100
+ * `0.0.10`). The profile vouches that the *private attribute* is safe, which
3101
+ * PS3.15 §E.3.10 is about; it cannot vouch for a Data Set nested in its value,
3102
+ * which §E.1.1 covers "whether contained in the top level Data Set or embedded
3103
+ * in an Item of a Sequence of Items". **"Carries a Sequence" is decided by the
3104
+ * profile as well as by the parse tree**, because the two disagree about the
3105
+ * same bytes whenever the profile did not reach `parseDicom` (Implicit VR LE
3106
+ * writes no VR) or the sender wrote a binary VR (Explicit VR, where the wire
3107
+ * wins). A carrier the profile declares `SQ` and the tree did not resolve is
3108
+ * emptied rather than kept, keeping its parsed VR
3109
+ * (`DICOM-PRIVATE-SQ-PARSE-VR`). See {@link keepRetainedPrivate}.
3110
+ * - A `SQ` whose `items` the parser did not
2884
3111
  * materialize is **emptied**, not kept:
2885
3112
  * its value is by PS3.5 §7.5.1 a stream of Data Sets, and a run that cannot
2886
3113
  * enumerate them cannot discharge §E.1.1's obligation inside them, so the
@@ -2891,16 +3118,18 @@ declare class DeidentifyError extends Error {
2891
3118
  * `(0010,0020)` Patient IDs among them - into output stamped
2892
3119
  * `(0012,0062) PatientIdentityRemoved = YES`
2893
3120
  * (`DICOM-DEIDENT-RAWBYTES-PASSTHROUGH`, live through `0.0.6`).
2894
- * - Two things are still kept verbatim, deliberately. A **private** `SQ`
2895
- * retained under `RetainSafePrivate` + a {@link Profile}, where the profile has
2896
- * vouched for the element by creator and tag - the pre-existing "kept
2897
- * verbatim" limitation above, unchanged. And an undefined-length **`UN`** whose
2898
- * CP-246 descent was refused: it keeps `vr === "UN"`, and since every ordinary
2899
- * `UN` element also has no items, the test above cannot be applied there
2900
- * without emptying every unknown-VR element in every file. That one is
2901
- * measured and **still leaks** (`PRE-EXISTING`); the reliable consumer-side
2902
- * test remains `el.items === undefined` on a `UN` element you are trusting a
2903
- * report about.
3121
+ * - One thing is still kept verbatim, and it is **not** the private `SQ`: an
3122
+ * undefined-length **`UN`** whose CP-246 descent was refused. It keeps
3123
+ * `vr === "UN"`, and since every ordinary `UN` element also has no items, the
3124
+ * test above cannot be applied there without emptying every unknown-VR element
3125
+ * in every file. That one is measured and **still leaks** (`PRE-EXISTING`);
3126
+ * the reliable consumer-side test remains `el.items === undefined` on a `UN`
3127
+ * element you are trusting a report about. `RetainSafePrivate` no longer
3128
+ * exempts anything from the rule above it - a vouched-for private `SQ` with no
3129
+ * materialized items is emptied on the same terms as any other. The one place
3130
+ * a `UN` **is** answered is the retained-private route, and only because a
3131
+ * profile named it: a private `UN` whose profile entry declares `SQ` is
3132
+ * emptied there. That is a statement about that route and not about `UN`.
2904
3133
  * - An element whose **on-wire VR is not one of the 34** PS3.5 §6.2 defines is
2905
3134
  * **emptied**, not kept (`DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` +
2906
3135
  * `report.undefinedVrElements`). Such an element is what an *under*-declared
@@ -2929,12 +3158,14 @@ declare class DeidentifyError extends Error {
2929
3158
  * which removal is the branch available here (`(0008,0307)` is not
2930
3159
  * implemented). Every private element the recursion **reaches** in such an
2931
3160
  * Item, at any depth below it, is removed and named in
2932
- * `report.removedPrivateTags`. **There is one carve-out, and it is the same one
2933
- * `#54` was refused for asserting away**: `keepsPrivate` decides *before* the
2934
- * descent, so a **private `SQ`** the profile vouches for is kept **verbatim**,
2935
- * its items are never walked, and this rule is never consulted inside it -
2936
- * measured, and it still leaks a private value absorbed from the root
2937
- * (`PRE-EXISTING`, identical on `164eb39`, its own item). That costs content
3161
+ * `report.removedPrivateTags`. **The carve-out `#54` was refused for asserting
3162
+ * away is CLOSED** (`DICOM-PRIVATE-SQ-CARVE-OUT`): `keepsPrivate` still decides
3163
+ * before the descent, but a **private `SQ`** it vouches for now routes through
3164
+ * {@link keepRetainedPrivate} into the ordinary `SQ` branches instead of being
3165
+ * kept verbatim, so its items **are** walked and this rule **is** consulted
3166
+ * inside them. Read that as "the retention decision no longer decides the fate
3167
+ * of the Data Sets below it", never as "the class is closed" - the residual
3168
+ * list in `CLAUDE.md` is the census. That costs content
2938
3169
  * on a malformed file and nothing at all on a conformant one; without it a
2939
3170
  * private value the sender wrote **outside** the sequence was retained on the
2940
3171
  * Item's reservation and written into output stamped `PatientIdentityRemoved =
@@ -2950,10 +3181,10 @@ declare class DeidentifyError extends Error {
2950
3181
  * inner sequence ejecting a creator into the still-usable Item that encloses it
2951
3182
  * is measured and pinned. The cut is positional, so a reservation the sender
2952
3183
  * wrote **ahead of** the offending sequence is untouched. Every private element
2953
- * after that point is removed and named in `report.removedPrivateTags`, and the
2954
- * **private-`SQ` carve-out above still applies**: a private `SQ` inside the
2955
- * settled run that the profile vouches for is kept verbatim and never walked.
2956
- * See {@link settledBound}.
3184
+ * after that point is removed and named in `report.removedPrivateTags`. A
3185
+ * private `SQ` inside the settled run that the profile vouches for is retained
3186
+ * and **walked**, so the rule reaches its items too - it no longer stops at the
3187
+ * carrier. See {@link settledBound} and {@link keepRetainedPrivate}.
2957
3188
  *
2958
3189
  * @module
2959
3190
  */