@cosyte/astm 0.0.21 → 0.1.0

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
@@ -9,8 +9,419 @@ this file is maintained by hand (Changesets handles the version bump and publish
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ **What 0.1 means for you.** This release is the first whose public API we treat as settled: the
13
+ exported names, options, return shapes and warning codes are the surface we keep stable. What it
14
+ covers is reading ASTM E1394 (CLSI LIS02-A2) records with the delimiters each header declares,
15
+ checksum-verified E1381 (CLSI LIS01-A2) framing with a pure receiver state machine for the
16
+ `ENQ`/`ACK`/`NAK`/`EOT` exchange, spec-clean emit of records and frames that writes only the values
17
+ you supply, LOINC mapping through a LIVD catalog you supply, and date conversions that never assume
18
+ a timezone. While the package is below 1.0, a breaking change bumps the minor version and is called
19
+ out here with its migration; a fix that changes no public value ships as a patch. Upgrading from
20
+ 0.0.x is itself breaking in the LIVD mapping and in what `primaryCode()` returns, as the entries
21
+ below set out. Not covered yet: no named per-vendor profile ships (the built-in set is `default` and
22
+ `referenceCorpus`), no LOINC, SNOMED or LIVD data is bundled and no LOINC is validated, the transfer
23
+ protocol's timers are yours to drive, and three behaviors (forward scoping of redeclared delimiters,
24
+ the Latin-1 wire encoding and the reserved-byte set) are reasoned from this package's reader rather
25
+ than cited to the standard's purchase-gated text.
26
+
27
+ **The pending changeset set is classified `minor`, not `patch`, so the next release is a minor
28
+ release.** Eight changesets are pending and five of them arrived carrying `patch`. Three of those
29
+ five were reclassified against their own text: one removes public values and changes what an
30
+ exported function returns, and two add public values. None of the three is a fix that adds nothing
31
+ and removes nothing, which is the only thing a `patch` may claim, and only those three bump lines
32
+ moved. The other two keep `patch`: one changes repository tooling and install configuration, the
33
+ other changes what a release carries beside the package, and neither touches a published value or
34
+ emits a byte differently, so `patch` is what each one's own text supports. Two more arrived
35
+ carrying `minor` already, each written against this same rule, so both are applied as written rather
36
+ than reclassified. The eighth is the release statement summarized above, which carries `minor` as the
37
+ class of the release it describes and changes no public value. A set carrying a `patch` beside a
38
+ `minor` still resolves to the minor channel.
39
+
40
+ The breaking change below therefore ships in the minor channel of the pre-1.0 ladder, which is where
41
+ a break belongs before `1.0.0`, rather than as a patch. Every break a consumer of the last published
42
+ version would see is enumerated with its migration in `documentation/release-readiness.md`, together
43
+ with the public export surface this release certifies as settled. No version number is written in
44
+ this section's entries, because Changesets owns the bump and `scripts/sync-version.mjs` mirrors it.
45
+
46
+ The entries the pending set carries are the date conversion surface entry, the LIVD units entry, the
47
+ LIVD publication metadata entry and the vocabulary-attribution entry under Added, and the LIVD
48
+ catalog entry, the PHI scanner entry and the dependency inventory entry under Changed. This section
49
+ also holds entries written
50
+ before them, which is where they have always been kept: no released entry was moved, reworded or
51
+ deleted by the reclassification, because rewriting a shipped changelog destroys the traceability it
52
+ exists for.
53
+
54
+ ### Changed
55
+
56
+ - **Every published release now carries a machine-readable dependency inventory as a downloadable
57
+ asset.** No published value, type, warning code or emitted byte moves: this entry is release
58
+ infrastructure only, and nothing under `src/` is touched. The inventory is a CycloneDX document
59
+ naming this package and the version being built alongside every dependency that build resolved,
60
+ runtime and build-time alike, transitively, each with the exact version it resolved to. It is read
61
+ from the lockfile rather than from the manifest's two lists, because the manifest states ranges
62
+ while a build resolves versions, so the artifact answers the question a reader of a dependency
63
+ claim actually has. A team evaluating this package for a regulated laboratory can download it from
64
+ the release page and feed it to their own tooling without cloning anything or reproducing an
65
+ install. `scripts/check-dependency-inventory.ts` both produces and grades it, from the checkout,
66
+ with no registry credential and no network call, and it is generated into the gitignored
67
+ `dist-artifacts/` rather than committed, so it can never describe a version other than the one it
68
+ ships beside. The grading runs in `prepublishOnly`, which is BEFORE the registry upload: an
69
+ inventory that cannot be produced at all, or that is empty, unparseable, missing this package's
70
+ identity or version, or short of a dependency the build resolved, is refused by name and aborts
71
+ the publish, so a bad inventory costs a failed run rather than a published version nothing
72
+ describes. A refusal also removes any older artifact at that path rather than leaving one to be
73
+ read as this build's. Attaching it to the release is a second job in the release workflow, because
74
+ the shared pipeline attaches a fixed two-name asset list; that job runs after the publish, since
75
+ the release does not exist before one, and it fails its run rather than skipping quietly if the
76
+ asset does not arrive. What the inventory does not claim: it states what was resolved and nothing
77
+ about whether that is safe. It is not a vulnerability scan, a licence assessment, an attestation
78
+ or a signature, it carries component names and versions only, and npm provenance remains the
79
+ separate thing it already was.
80
+ - **The PHI scanner refuses a run that enumerated a target and never read it, and the install
81
+ settings that hold a resolution back are now in force rather than merely written down.** No
82
+ published value, type, warning code or emitted byte moves: this entry is repository tooling and
83
+ supply chain configuration only. `scripts/phi-scan.ts` reconciles the targets a run ENUMERATED
84
+ against the ones it actually READ, on every route rather than on the whole-tree sweep alone. The
85
+ whole-file bypass is subtracted from each route's finished target list, so before this a run
86
+ naming two paths and withdrawing one opened one file and answered for both, at the same exit code
87
+ the same arguments produce over a corpus whose only violator is the withdrawn one, and a caller
88
+ could not tell those two runs apart. The run now exits with the scanner's invocation-error code,
89
+ names every path nobody opened, and still prints any hit it had already found, because a refusal
90
+ must not swallow a real finding. Detection is untouched: the loci it reads, the format-agnostic
91
+ floor and the allow list are exactly as they were, and a run passing no bypass is byte-identical
92
+ on all three routes, asserted against a copy of the scanner without the reconciliation rather
93
+ than assumed. Alongside it, `pnpm-workspace.yaml` declares a 1440 minute minimum release age and
94
+ a no-downgrade trust policy and the pinned package manager moves to a release that enforces both
95
+ (an older one ignores the keys entirely, which is a settings file that decorates rather than
96
+ defends), and the `js-yaml` resolution override is superseded rather than joined, because the
97
+ stale selector pinned a lower version over an overlapping range. The 3.x resolution reached
98
+ through the release tooling is a known residual and is deliberately left where it is.
99
+ - **The unrecognized-flag and unrecognized-status warning messages name the same attribution the
100
+ interpreted value reports.** `ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG` now names the code system
101
+ identifier and version the flag was graded against, and `ASTM_RECORD_UNDEFINED_RESULT_STATUS` now
102
+ states that no citable published source binds the status letter set. Both read the same constants
103
+ the interpreted values read, so message and value cannot drift apart. Both warning **codes** are
104
+ unchanged, and both messages remain value-free: position and fixed prose only, never the field
105
+ text, the result value, or any other field content.
106
+ - **BREAKING: a populated first component no longer bypasses the consumer's LIVD catalog, and no
107
+ public value reports one as a LOINC** (defect 9). The Universal Test ID's first component is a
108
+ LOINC slot, and the governing mapping guide puts transmitting LOINC directly from IVD instruments
109
+ explicitly out of scope: the analyte arrives as a vendor-defined code and a LOINC is what a
110
+ consumer maps to afterwards. This package answered the analyte-identity question from that slot
111
+ anyway. `recognizeUniversalTestId` tagged **any** non-empty first component
112
+ `inline-loinc-candidate` with no evidence whatsoever, `primaryCode()` preferred it over the vendor
113
+ local code, and `mapTestId` returned `{ status: "inline-loinc" }` on it and **never reached
114
+ `catalog.lookup`**, so the catalog a consumer supplied was bypassed for every record with a
115
+ populated first component and `R|1|Glucose|28.6|U/L||N||F` reported a LOINC candidate of
116
+ `Glucose`. A right value under the wrong analyte is a wrong clinical result, so the direction of
117
+ that default was the defect.
118
+
119
+ **No LOINC shape test was added, and none is coming.** This package performs no LOINC validation
120
+ of any kind and never decides what a first-component value "looks like": `2345-7` and `Glucose`
121
+ there are treated identically and every route below is positional, decided by which components are
122
+ populated. Believing component 1 only when it is "LOINC-shaped" would need a definition of that
123
+ shape, which is exactly the unverified claim this change is written to avoid.
124
+
125
+ Public values **removed or renamed**, each with its replacement:
126
+ - `UniversalTestId.loincCandidate` is **removed**; `UniversalTestId.unvalidatedWireValue` replaces
127
+ it, carrying the same verbatim component 1 value under a name that vouches for nothing.
128
+ - The `inline-loinc-candidate` token is **removed** from `UniversalTestIdProvenance`. A populated
129
+ component 1 **with** a vendor local code is now `local-code`, since the local code is the
130
+ identifier; **without** one it is the new token `unvalidated-wire-value-only`, which reports
131
+ that the value was seen without claiming it is a code, so such a record stays distinguishable
132
+ from one whose Universal Test ID was empty.
133
+ - The `LivdMapping` variant `{ status: "inline-loinc", loinc, source: "wire" }` is **removed**:
134
+ no disposition reports a wire value as a LOINC. Its positional replacement is the new
135
+ `{ status: "no-vendor-code" }`, a populated component 1 with no vendor local code to look up.
136
+
137
+ **The four retained dispositions keep their documented meanings exactly.** `mapped` still means
138
+ the consumer's catalog vouched for this LOINC and nothing else, never that the library adjudicated
139
+ the wire against it; `ambiguous` still means more than one distinct candidate with none chosen;
140
+ `unmapped` is still a miss; `no-code` is still nothing to map. Only which inputs reach them
141
+ changed. No pre-existing assertion covering those four outcomes for a record with an **empty**
142
+ first component was edited.
143
+
144
+ **`primaryCode()` keeps its name and changes its behavior**, which is a break with no compile
145
+ error behind it: audit every call site. It now returns the vendor local code and nothing else, so
146
+ where it used to answer with a first-component value it returns `undefined`, the same absence it
147
+ already returned for a record carrying nothing usable and which its callers already had to handle.
148
+ It reports no key rather than a key this library cannot vouch for.
149
+
150
+ New on `LivdAnnotation`, alongside the disposition rather than inside it, so both are inspectable
151
+ without reading prose:
152
+ - `unvalidatedWireValue`, the verbatim first-component value, carried on **every** disposition
153
+ (a miss, an ambiguity and a disagreement all keep it) and absent only when component 1 is empty.
154
+ - `wireValueDisagreesWithCatalog`, `true` if and only if the catalog vouched for exactly one LOINC,
155
+ component 1 is populated, and the two are not byte-identical. **`false` in every other case and
156
+ never absent**, including on the ordinary `R|1|^^^687|...` record, so no standing false
157
+ disagreement ships. It is deliberately not computed against an ambiguous candidate list, which
158
+ would corroborate exactly one candidate. It reports the difference and nothing else: both values
159
+ stay surfaced, neither is marked correct, neither is rewritten, and no field claims the
160
+ difference was settled.
161
+ - `reportedCode` is now always the code the catalog was consulted **with**, verbatim, and is
162
+ absent when nothing was looked up.
163
+
164
+ Two fail-safe corners are now stated and tested rather than left to be found. A consumer-supplied
165
+ catalog whose `lookup` throws propagates to the caller unchanged: a consumer's own crash must not
166
+ be indistinguishable from "your code is not in the catalog", so it is never reported as a miss and
167
+ no partially annotated result is returned. And a hit whose LOINC is a zero-length string is
168
+ reported as a miss, exactly as if the catalog held no entry, never as a vouched-for empty LOINC.
169
+
170
+ A record with no vendor local code emits **no** warning of its own, and emits neither
171
+ `ASTM_LIVD_UNMAPPED_CODE` nor `ASTM_LIVD_AMBIGUOUS_MAPPING`: no lookup happened, and a warning
172
+ asserting one did would claim more than happened. Neither warning's code, message or payload
173
+ changed.
174
+
12
175
  ### Added
13
176
 
177
+ - **A LIVD catalog says which publication it came from and which LOINC version the mapping was made
178
+ against, and that version rides on every annotation.** A consumer could record the vendor rows and
179
+ nothing about the publication behind them, so a mapping this library annotated was not
180
+ reproducible and the attribution statement the LOINC license requires had nowhere to live beside
181
+ the mappings it applies to.
182
+ - `LivdPublication`, an **optional second argument** to `defineLivdCatalog` and readable back as
183
+ `LivdCatalog.publication`: `publisher`, `publicationVersion`, `loincVersion` and
184
+ `loincCopyright`, all optional. Each declared value is preserved **verbatim**: nothing trimmed,
185
+ case folded, reordered, reformatted or defaulted. Absent, empty and whitespace only are one
186
+ case, no value declared, stored as absent rather than as a blank.
187
+ - `LivdAnnotation.catalogLoincVersion` carries that LOINC version on **every** annotation the
188
+ catalog produced, whatever the lookup answered (`mapped`, `unmapped`, `ambiguous`,
189
+ `no-vendor-code`, `no-code`), so a mapping records what it was made against and can be
190
+ reproduced. It is provenance about the CATALOG: no field says a LOINC was checked against it,
191
+ because none was.
192
+ - `ASTM_LIVD_CATALOG_NO_LOINC_VERSION` joins `LIVD_WARNING_CODES`, with `AstmLivdCatalogWarning`,
193
+ `LivdCatalogIdentity`, `LIVD_CATALOG_IDENTITY_UNDECLARED` and the builder
194
+ `livdCatalogMissingLoincVersion`. A catalog defined with no LOINC version surfaces one on
195
+ `LivdCatalog.warnings` and is **still built**: it is indexed and answers every lookup exactly as
196
+ it would have. The warning is raised once, where the catalog is defined, never per record, so
197
+ the per-record stream `applyLivd` returns keeps the same codes, in the same order, in the same
198
+ number.
199
+ - The warning's message is a **constant** and carries no consumer-supplied text: the metadata is
200
+ text this library cannot vouch for, and interpolating it would put whatever a consumer stored
201
+ into every log that prints the message. `AstmLivdCatalogWarning.catalog` carries the publisher
202
+ and publication version the catalog declared instead, and a catalog that declared neither is
203
+ reported as having declared no identity, positively, with no name, ordinal or borrowed row value
204
+ invented to stand in for one.
205
+ - **None of it is validated and none of it is a claim by this package.** No version format check,
206
+ no copyright-text check, no publisher check: this package performs **no LOINC validation of any
207
+ kind**, so each value is surfaced exactly as supplied and `catalogLoincVersion` never means a
208
+ LOINC was checked, found or conformed against that version. **Carrying an attribution statement
209
+ is not discharging it**: the license obligation stays the consumer's.
210
+ - Additive and source compatible. The second argument is optional, so the existing
211
+ single-argument call is unchanged; `publication` and `warnings` are optional members, so a
212
+ catalog a consumer implemented by hand still satisfies the interface and still annotates without
213
+ raising; and `catalogLoincVersion` is conditional, so an annotation from a catalog declaring no
214
+ LOINC version keeps the exact key set it had. The one visible widening is `LivdWarningCode`,
215
+ which gains the new registry member, so an exhaustive `switch` over it needs one more arm.
216
+ - **`toObject`, `toISO` and `toDate`: the shared `@cosyte` date conversion surface, with the
217
+ timezone honesty ASTM forces.** A parsed `AstmDate` could be rendered (`astmDateToLocalISO`) but
218
+ not read as components and not converted to an instant, and the name it was rendered under was
219
+ this package's alone, so a consumer holding dates from two `@cosyte` parsers wrote two different
220
+ conversions. The three names, their return shapes and their timezone rule are now identical across
221
+ the sibling parsers.
222
+ - `toObject(value)` returns a **frozen** object carrying only the components the value stated
223
+ (`{ year, month, day }` for `"20240315"`), with a spec-native 1-to-12 month and singular
224
+ `hour`/`minute`/`second` keys. Nothing is zero-filled, no key holds `undefined`, and the parse
225
+ bookkeeping (`raw`, `precision`, `truncated`) stays on `AstmDate` rather than crossing over: a
226
+ digit run that cut a component in half reports only its complete components, and the dangling
227
+ digit reaches no field. `Object.keys()` is therefore the value's precision. Deleting
228
+ `offsetMinutes`, which an ASTM value never carries, leaves an object
229
+ `Temporal.PlainDateTime.from` and luxon's `DateTime.fromObject` accept unchanged; neither
230
+ library is a dependency, the shape is the interoperability.
231
+ - `toISO(value)` renders ISO-8601 truncated to the stated precision and appends no `Z` and no
232
+ offset, because ASTM states none. It returns the same string `astmDateToLocalISO` returns for
233
+ every value `parseAstmDate` produces whose components are a real calendar date; that function is
234
+ unchanged, still exported, and still the repo-native name. On a value whose components are not,
235
+ it still renders the digits it was handed (`"1988-13-01"`) and `toISO` answers `undefined`
236
+ instead, which is the only place the two differ.
237
+ - `toDate(value, options?)` returns a `Date` **only** when the caller supplies
238
+ `options.assumeOffsetMinutes`, signed minutes east of UTC, with an explicit `0` meaning "read
239
+ this value as UTC". An ASTM timestamp is local to the instrument that wrote it, so without that
240
+ assumption there is no instant to give and the answer is `undefined`: the host machine's zone is
241
+ never read and UTC is never assumed, both of which would silently shift a date by a day in every
242
+ negative-offset zone. Components below the stated precision fill to their lowest legal value for
243
+ the instant only, leaving the value's own precision untouched, and a four-digit year below 100
244
+ stays that year (`"00500101"` is the year 50, never 1950). An offset that names no usable zone
245
+ is refused rather than applied, with `undefined` and never an `Invalid Date`: one that is not a
246
+ finite number, and one large enough that applying it lands outside the range a JS `Date`
247
+ represents. An `Invalid Date` satisfies the declared return type and defeats it, since it is
248
+ indistinguishable from a real instant without testing `getTime()` for `NaN`.
249
+ - The `DateParts` and `ToDateOptions` types, and a README section covering all three together with
250
+ the aliasing pattern the shared names force on a consumer importing two `@cosyte` parsers in one
251
+ file.
252
+ - A component that is not on the calendar is refused by all three rather than normalised.
253
+ `parseAstmDate` is lenient by design and reads `"19881301"` into month 13 rather than dropping
254
+ the field, and that is unchanged, so the conversions are where it is answered: a month outside 1
255
+ to 12, a day past the end of its own month (29 February in a common year included), an hour past
256
+ 23 and a minute or second past 59 each yield `undefined`. A JS `Date` would instead roll month 13
257
+ into January of the following year and return a date of birth a year out with nothing to say so.
258
+ The refusal is of the whole value, never of the offending component alone.
259
+ - None of the three ever throws: `undefined`, `null`, a value this parser refused, a value stating
260
+ no component, and a value whose components are not a real calendar date all answer `undefined`.
261
+ Nothing else moved. No existing export was removed, renamed or changed in behaviour, no
262
+ dependency of any kind was added, and the engine floor is unchanged.
263
+
264
+ - **A LIVD catalog can now say what tells two candidate LOINCs apart, and the reported units choose
265
+ between them.** One vendor analyte code mapping to several LOINCs is the ordinary case, not an
266
+ edge: the governing mapping guide gives the commonest chemistry analytes as its own worked
267
+ examples (a serum glucose reported as a mass concentration versus a substance concentration; a
268
+ urine analyte reported as a spot concentration versus a 24 hour excretion rate) and its remedy is
269
+ a mapping per unit. Until now `LivdEntry` had nowhere to record any of that, so a catalog covering
270
+ more than one reporting unit returned `ambiguous` for a large share of ordinary analytes.
271
+ - `LivdEntry` gains three **optional** attributes, each carried **verbatim** with no trimming,
272
+ case folding or normalization: `vendorSpecimenDescription`, `vendorResultDescription` and
273
+ `representativeUnit`.
274
+ - `LivdCatalog.lookup` gains an **optional** second parameter, the units the record reported.
275
+ `applyLivd` and `lookupLivdForRecord` pass field 5 of an `R` record, verbatim; an `O` record has
276
+ no units field, so nothing is passed for one. A hand-written catalog whose `lookup` declares
277
+ only the vendor code still satisfies the interface and answers exactly as before.
278
+ - Where a vendor code carries several distinct candidate LOINCs, the candidate whose
279
+ `representativeUnit` is **exactly equal** to the reported units is answered `mapped`. **The
280
+ comparison is verbatim and case sensitive and it is not UCUM**: nothing is normalized, case
281
+ folded, scaled or converted on either side, so `MG/DL` does not match `mg/dL` and `mg/L` never
282
+ matches `ug/dL`. UCUM requires a program declaring full conformance to compare unit expressions
283
+ by their semantics, so every unit-selected answer carries the new `unitComparison`
284
+ (`comparison: "verbatim-case-sensitive"`, `ucumSemantic: false`, both units verbatim, and a
285
+ note) rather than letting a matched unit read as a conformance claim this package does not make.
286
+ - Refusing still beats guessing, and nothing about `ambiguous` was narrowed. The units matching no
287
+ candidate, matching more than one, or not being readable at all (absent, empty, whitespace only,
288
+ or lost to a truncated or malformed record) are all `ambiguous` with every candidate surfaced
289
+ and no LOINC chosen. An unreadable units field reads as **no units reported**: a typed
290
+ annotation is still produced, nothing raises and no record is omitted. An ambiguous answer now
291
+ also carries `candidateDetails` (each candidate row's LOINC and LIVD attributes, one per catalog
292
+ row, including rows that are not unit qualified) and a `reason` naming which refusal it was.
293
+ - A candidate whose `representativeUnit` is absent, empty or whitespace only is **not unit
294
+ qualified**: never selected by a unit comparison, and still surfaced among the candidates. A
295
+ vendor code carrying exactly **one** candidate LOINC is answered whether or not the units agree,
296
+ with no `unitComparison`, because no unit chose anything there. An answer that read `mapped`
297
+ before this change can never become `ambiguous`.
298
+ - A `mapped` answer's `representativeUnit` is provenance about the **catalog row**, not a
299
+ restatement of what the record reported. Where a vendor code carries a single candidate LOINC
300
+ across several rows spelling the unit differently, the answer takes the first row's attributes
301
+ (exactly as it has always taken the first row's `loincLongName`), so that field can name a unit
302
+ the record did not report. Only `unitComparison` asserts the two were equal, and it is present
303
+ only where a unit actually chose between candidates: branch on that field, not on this one.
304
+ - `vendorSpecimenDescription` and `vendorResultDescription` are **never matched on**. Both are
305
+ free text, and the guide states directly that this information is not intended to be parsed by
306
+ software that automates the mapping, so they are stored and surfaced for a human and nothing
307
+ else. Only the representative unit ever selects.
308
+ - **Additive throughout.** All three attributes are optional and every new answer field is
309
+ conditional, so a catalog carrying none of them answers byte-identically to how it answered
310
+ before, for `mapped`, `unmapped` and `ambiguous` alike. No LOINC is validated or shape-tested,
311
+ no unit is converted, no data is migrated and no consumer catalog is rewritten. New exported
312
+ types: `LivdCandidate`, `LivdUnitComparison`, `LivdAmbiguityReason`.
313
+
314
+ - **Every interpreted abnormal flag and result status reports the vocabulary it was graded against,
315
+ and `HU`/`LU` are recognized.** The `R` record's flag (field 7) and status (field 9) letters come
316
+ from vocabularies maintained on other bodies' schedules, while the record grammar around them is
317
+ archived and frozen, so this package's exposure to a moving vocabulary was real and invisible from
318
+ the outside: a consumer holding `{ recognized: false, meaning: "undefined" }` could not tell a code
319
+ **no published vocabulary defines** from one **this library had not caught up to**. That
320
+ distinction is now carried on the value itself.
321
+ - `AbnormalFlag.vocabulary` names the code system by `system` and `version`, present whether or not
322
+ the letter was recognized. It is the HL7 v3 ObservationInterpretation code system, which carries
323
+ the concepts kept aligned with the HL7 v2 Table 0078 interpretation codes previously named in
324
+ prose only. **The version records what this library compared against**, not what the sender
325
+ meant, and it is not a conformance claim about any instrument.
326
+ - `ResultStatus.vocabulary` is always the explicit **no citable published source**
327
+ (`attributed: false`, with fixed prose in `reason`). The normative text that would bind the
328
+ status letter set is purchase-gated and was not read; a neighbouring HL7 table whose letters
329
+ partly agree is deliberately **not** adopted in its place, because asserting that binding is not
330
+ something the public record supports. Saying the set is unattributed is the honest answer and is
331
+ distinguishable from an attribution that is simply absent; it never carries an identifier.
332
+ - `HU` (`"significantly-high"`) and `LU` (`"significantly-low"`) are recognized. Both are active
333
+ concepts in the graded vocabulary and were absent from the sixteen letters this package
334
+ recognized, so a feed sending either got a warning and `"undefined"`. They are distinct from
335
+ `H`/`L`, from `HH`/`LL` and from the directional `U`/`D`, all six of which keep their readings
336
+ unchanged. The recognized set is eighteen: `L`, `H`, `LL`, `HH`, `<`, `>`, `N`, `A`, `AA`, `U`,
337
+ `D`, `B`, `W`, `S`, `R`, `I`, `HU`, `LU`.
338
+ - New public values: `ABNORMAL_FLAG_CODES` (the recognized set as a list),
339
+ `ABNORMAL_FLAG_VOCABULARY`, `RESULT_STATUS_VOCABULARY`, `describeVocabulary`, and the
340
+ `NamedVocabulary` / `UnattributedVocabulary` / `VocabularyAttribution` types.
341
+ - **No tolerance was widened.** Recognition stays exact match after the existing trim: no case
342
+ folding, so `hu` and `f` are reported unrecognized with their raw text intact. No other concept
343
+ from that code system is adopted, including the deprecated `H>` and `L<` whose replacements
344
+ `HU`/`LU` are; those stay unrecognized, which is the fail-safe direction. An unrecognized flag is
345
+ still never `normal` and an absent status is still never `final`.
346
+ - **Nothing reaches the wire.** The attribution is interpretation output; a parsed and
347
+ re-serialized record emits byte-identical output, pinned by literal expected strings in
348
+ `test/records/golden-round-trip.test.ts`. No new dependency.
349
+
350
+ - **The PHI sweep now reads the bytes git carries, as a union with the working-tree walk.** All mode
351
+ reconciled the paths it walked against the paths git tracks, and a comparison of path _sets_ is
352
+ satisfied by a working tree that carries different content at those paths. Four states were
353
+ reproduced on the previous release, each printing `[phi-scan] OK: no hits` and exiting `0` over a
354
+ synthetic stream carrying a patient name, a mother's maiden name, a birthdate and a dashed SSN:
355
+ decoy content at a tracked path; a tracked path outside every walk root; a tracked symbolic link
356
+ or nested repository outside every walk root; and an empty index, against which every check passes
357
+ vacuously. All four now report or refuse. The mechanism is written down in exactly one place,
358
+ `buildTargetsForIndex` in `scripts/phi-scan.ts`, and every other surface states only what a green
359
+ means.
360
+ - **It is a union, never a replacement.** No scan root was narrowed and no clause dropped; a file
361
+ the walk reads is still read from disk with exactly the views it had, and a blob whose bytes the
362
+ walk has provably already scanned is skipped, so nothing is reported twice. The skip is a byte
363
+ comparison rather than a timestamp, a size or a hash, because those are exactly what a decoy
364
+ defeats.
365
+ - **The scan reported eighteen tracked files outside every walk root that no route had opened.**
366
+ One carried this package's own published contact address, which is already public in every
367
+ release's registry metadata and is not patient data; it is declared in
368
+ `scripts/phi-allow-list.txt` with the cost of that declaration written beside it, because an
369
+ allowed email domain is global and applies on every route.
370
+ - **A positive control proves the sweep opens the corpus it clears.** This package's own manifest
371
+ is placed at the same out-of-root path in a throwaway tree and the declaration is then struck:
372
+ the same corpus reports the address at exit `1`. A green over an unopened corpus is
373
+ indistinguishable from a green over a corpus that was read, so a case showing the scanner
374
+ passing proves nothing on its own.
375
+ - **What it deliberately does not do**, so a green is not read as wider than it is: it does not
376
+ credit a scan root (a root emptied on disk still refuses, though the index carries its files);
377
+ it does not reach `--staged`, whose scope decides what a _commit_ is blocked on; and markdown
378
+ stays excluded, which is the walk's own rule rather than a new one. Working-tree bytes at a path
379
+ outside every scan root are still read by neither route, unchanged from before and now stated.
380
+ - **Line endings are deliberately not normalized before the byte comparison.** Doing so would
381
+ compare a derived form of the two byte strings, and content differing only in what the
382
+ normalizer erases would then be skipped.
383
+
384
+ - **The two-file contract between `CLAUDE.md` and `documentation/agent-notes.md` is now gated**
385
+ (`pnpm check:agent-notes`, `scripts/check-agent-notes.ts`), with the teeth in
386
+ `test/scripts/agent-notes.test.ts` so it rides the required `ci / verify` matrix rather than
387
+ adding a workflow that would have to be made a required context separately. It asserts that every
388
+ pointer resolves, that no section a pointer reaches is empty, that the record stays reachable from
389
+ the always-read file, and that the always-read file still links the record by path. It asserts
390
+ **no** ecosystem-wide contract: several cosyte repos carry no such record at all, and a gate
391
+ phrased as though every repo did would be an overclaim.
392
+ - **The matcher was re-derived against this tree rather than ported, and that was the whole job.**
393
+ Both sibling spellings score **zero** here: the path-qualified form used by `ccda` and `mllp`,
394
+ and the basename-qualified form used by `terminology`. Every pointer in this repository is a
395
+ third spelling neither sibling gate matches, a backtick-quoted anchor with no filename at all. A
396
+ verbatim port would have reported "all resolving" over a corpus it never opened. Both sibling
397
+ forms are matched anyway so a pasted pointer is never invisible, and the live form falling to
398
+ zero is a refusal rather than a pass.
399
+ - **The anchor space is this record's explicit `<a id>` tags, not GitHub heading slugs.** Not one
400
+ pointer here resolves to a heading slug, because the headings carry long titles with dates and
401
+ status in them. A gate checking only slugs, which is what the siblings check, would have
402
+ reported every pointer in the repository as dangling.
403
+ - **An early draft was vacuous rather than wrong, and only a positive control found it.** Treating
404
+ an explicit anchor and the heading it precedes as separate sections made the empty-section
405
+ assertion cover nothing: the anchor looked empty and the heading looked unreferenced, so both
406
+ were skipped and a deliberately emptied section still printed `OK`. Anchors are now bound to the
407
+ heading they name.
408
+ - **The one declared non-pointer is pinned to the single sentence it describes**, and refuses both
409
+ when it matches nothing (a phantom skip) and when it matches more than once. The second case
410
+ fired during this change's own build.
411
+ - The corpus is `git ls-files` reconciled as sets, with **no exclusion list, no binary skip and no
412
+ NUL skip**; this repository tracks NUL-bearing TypeScript sources, most of them tests, that a NUL
413
+ partition would drop in silence. A pointer is matched if and only if the file spells it in ASCII
414
+ bytes, pinned in both directions by test. Every tracked file is scanned, so a backticked anchor
415
+ is a live pointer wherever it is written, **including `README.md` and `docs-content/`, which
416
+ ships in an immutable release asset**: reference a section by title on any surface that ships.
417
+ - **CI caught a defect the local run structurally could not, and the gate was right.** Because the
418
+ corpus is the index, a new file is invisible until staged: `verify.sh` ran green while both new
419
+ files were untracked, and CI went red once they were tracked, because the gate's own test file
420
+ spelled pointers in its fixtures that were then scanned against the real record. Both the checker
421
+ and its test now **build** pointer strings at run time rather than spelling them, pinned by a
422
+ test; exempting those paths would have been the exclusion list the gate refuses to have. **Re-run
423
+ a corpus-scanning gate after staging, never only before.**
424
+
14
425
  - **The PHI commit-gate now reads a stream written as a source string literal, and refuses a sweep
15
426
  that did not observe its corpus.** Two separate holes, closed together because closing one alone
16
427
  buys only the cross-cutting SSN/email floor.