@cosyte/astm 0.0.22 → 0.1.1

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,347 @@ 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
+ - **Published through npm trusted publishing, with provenance.** The code, API and behavior are
57
+ unchanged from 0.1.0. Only the README and some doc comments differ: a few doc comments now write
58
+ number ranges with "to".
59
+ - **Every published release now carries a machine-readable dependency inventory as a downloadable
60
+ asset.** No published value, type, warning code or emitted byte moves: this entry is release
61
+ infrastructure only, and nothing under `src/` is touched. The inventory is a CycloneDX document
62
+ naming this package and the version being built alongside every dependency that build resolved,
63
+ runtime and build-time alike, transitively, each with the exact version it resolved to. It is read
64
+ from the lockfile rather than from the manifest's two lists, because the manifest states ranges
65
+ while a build resolves versions, so the artifact answers the question a reader of a dependency
66
+ claim actually has. A team evaluating this package for a regulated laboratory can download it from
67
+ the release page and feed it to their own tooling without cloning anything or reproducing an
68
+ install. `scripts/check-dependency-inventory.ts` both produces and grades it, from the checkout,
69
+ with no registry credential and no network call, and it is generated into the gitignored
70
+ `dist-artifacts/` rather than committed, so it can never describe a version other than the one it
71
+ ships beside. The grading runs in `prepublishOnly`, which is BEFORE the registry upload: an
72
+ inventory that cannot be produced at all, or that is empty, unparseable, missing this package's
73
+ identity or version, or short of a dependency the build resolved, is refused by name and aborts
74
+ the publish, so a bad inventory costs a failed run rather than a published version nothing
75
+ describes. A refusal also removes any older artifact at that path rather than leaving one to be
76
+ read as this build's. Attaching it to the release is a second job in the release workflow, because
77
+ the shared pipeline attaches a fixed two-name asset list; that job runs after the publish, since
78
+ the release does not exist before one, and it fails its run rather than skipping quietly if the
79
+ asset does not arrive. What the inventory does not claim: it states what was resolved and nothing
80
+ about whether that is safe. It is not a vulnerability scan, a licence assessment, an attestation
81
+ or a signature, it carries component names and versions only, and npm provenance remains the
82
+ separate thing it already was.
83
+ - **The PHI scanner refuses a run that enumerated a target and never read it, and the install
84
+ settings that hold a resolution back are now in force rather than merely written down.** No
85
+ published value, type, warning code or emitted byte moves: this entry is repository tooling and
86
+ supply chain configuration only. `scripts/phi-scan.ts` reconciles the targets a run ENUMERATED
87
+ against the ones it actually READ, on every route rather than on the whole-tree sweep alone. The
88
+ whole-file bypass is subtracted from each route's finished target list, so before this a run
89
+ naming two paths and withdrawing one opened one file and answered for both, at the same exit code
90
+ the same arguments produce over a corpus whose only violator is the withdrawn one, and a caller
91
+ could not tell those two runs apart. The run now exits with the scanner's invocation-error code,
92
+ names every path nobody opened, and still prints any hit it had already found, because a refusal
93
+ must not swallow a real finding. Detection is untouched: the loci it reads, the format-agnostic
94
+ floor and the allow list are exactly as they were, and a run passing no bypass is byte-identical
95
+ on all three routes, asserted against a copy of the scanner without the reconciliation rather
96
+ than assumed. Alongside it, `pnpm-workspace.yaml` declares a 1440 minute minimum release age and
97
+ a no-downgrade trust policy and the pinned package manager moves to a release that enforces both
98
+ (an older one ignores the keys entirely, which is a settings file that decorates rather than
99
+ defends), and the `js-yaml` resolution override is superseded rather than joined, because the
100
+ stale selector pinned a lower version over an overlapping range. The 3.x resolution reached
101
+ through the release tooling is a known residual and is deliberately left where it is.
102
+ - **The unrecognized-flag and unrecognized-status warning messages name the same attribution the
103
+ interpreted value reports.** `ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG` now names the code system
104
+ identifier and version the flag was graded against, and `ASTM_RECORD_UNDEFINED_RESULT_STATUS` now
105
+ states that no citable published source binds the status letter set. Both read the same constants
106
+ the interpreted values read, so message and value cannot drift apart. Both warning **codes** are
107
+ unchanged, and both messages remain value-free: position and fixed prose only, never the field
108
+ text, the result value, or any other field content.
109
+ - **BREAKING: a populated first component no longer bypasses the consumer's LIVD catalog, and no
110
+ public value reports one as a LOINC** (defect 9). The Universal Test ID's first component is a
111
+ LOINC slot, and the governing mapping guide puts transmitting LOINC directly from IVD instruments
112
+ explicitly out of scope: the analyte arrives as a vendor-defined code and a LOINC is what a
113
+ consumer maps to afterwards. This package answered the analyte-identity question from that slot
114
+ anyway. `recognizeUniversalTestId` tagged **any** non-empty first component
115
+ `inline-loinc-candidate` with no evidence whatsoever, `primaryCode()` preferred it over the vendor
116
+ local code, and `mapTestId` returned `{ status: "inline-loinc" }` on it and **never reached
117
+ `catalog.lookup`**, so the catalog a consumer supplied was bypassed for every record with a
118
+ populated first component and `R|1|Glucose|28.6|U/L||N||F` reported a LOINC candidate of
119
+ `Glucose`. A right value under the wrong analyte is a wrong clinical result, so the direction of
120
+ that default was the defect.
121
+
122
+ **No LOINC shape test was added, and none is coming.** This package performs no LOINC validation
123
+ of any kind and never decides what a first-component value "looks like": `2345-7` and `Glucose`
124
+ there are treated identically and every route below is positional, decided by which components are
125
+ populated. Believing component 1 only when it is "LOINC-shaped" would need a definition of that
126
+ shape, which is exactly the unverified claim this change is written to avoid.
127
+
128
+ Public values **removed or renamed**, each with its replacement:
129
+ - `UniversalTestId.loincCandidate` is **removed**; `UniversalTestId.unvalidatedWireValue` replaces
130
+ it, carrying the same verbatim component 1 value under a name that vouches for nothing.
131
+ - The `inline-loinc-candidate` token is **removed** from `UniversalTestIdProvenance`. A populated
132
+ component 1 **with** a vendor local code is now `local-code`, since the local code is the
133
+ identifier; **without** one it is the new token `unvalidated-wire-value-only`, which reports
134
+ that the value was seen without claiming it is a code, so such a record stays distinguishable
135
+ from one whose Universal Test ID was empty.
136
+ - The `LivdMapping` variant `{ status: "inline-loinc", loinc, source: "wire" }` is **removed**:
137
+ no disposition reports a wire value as a LOINC. Its positional replacement is the new
138
+ `{ status: "no-vendor-code" }`, a populated component 1 with no vendor local code to look up.
139
+
140
+ **The four retained dispositions keep their documented meanings exactly.** `mapped` still means
141
+ the consumer's catalog vouched for this LOINC and nothing else, never that the library adjudicated
142
+ the wire against it; `ambiguous` still means more than one distinct candidate with none chosen;
143
+ `unmapped` is still a miss; `no-code` is still nothing to map. Only which inputs reach them
144
+ changed. No pre-existing assertion covering those four outcomes for a record with an **empty**
145
+ first component was edited.
146
+
147
+ **`primaryCode()` keeps its name and changes its behavior**, which is a break with no compile
148
+ error behind it: audit every call site. It now returns the vendor local code and nothing else, so
149
+ where it used to answer with a first-component value it returns `undefined`, the same absence it
150
+ already returned for a record carrying nothing usable and which its callers already had to handle.
151
+ It reports no key rather than a key this library cannot vouch for.
152
+
153
+ New on `LivdAnnotation`, alongside the disposition rather than inside it, so both are inspectable
154
+ without reading prose:
155
+ - `unvalidatedWireValue`, the verbatim first-component value, carried on **every** disposition
156
+ (a miss, an ambiguity and a disagreement all keep it) and absent only when component 1 is empty.
157
+ - `wireValueDisagreesWithCatalog`, `true` if and only if the catalog vouched for exactly one LOINC,
158
+ component 1 is populated, and the two are not byte-identical. **`false` in every other case and
159
+ never absent**, including on the ordinary `R|1|^^^687|...` record, so no standing false
160
+ disagreement ships. It is deliberately not computed against an ambiguous candidate list, which
161
+ would corroborate exactly one candidate. It reports the difference and nothing else: both values
162
+ stay surfaced, neither is marked correct, neither is rewritten, and no field claims the
163
+ difference was settled.
164
+ - `reportedCode` is now always the code the catalog was consulted **with**, verbatim, and is
165
+ absent when nothing was looked up.
166
+
167
+ Two fail-safe corners are now stated and tested rather than left to be found. A consumer-supplied
168
+ catalog whose `lookup` throws propagates to the caller unchanged: a consumer's own crash must not
169
+ be indistinguishable from "your code is not in the catalog", so it is never reported as a miss and
170
+ no partially annotated result is returned. And a hit whose LOINC is a zero-length string is
171
+ reported as a miss, exactly as if the catalog held no entry, never as a vouched-for empty LOINC.
172
+
173
+ A record with no vendor local code emits **no** warning of its own, and emits neither
174
+ `ASTM_LIVD_UNMAPPED_CODE` nor `ASTM_LIVD_AMBIGUOUS_MAPPING`: no lookup happened, and a warning
175
+ asserting one did would claim more than happened. Neither warning's code, message or payload
176
+ changed.
177
+
12
178
  ### Added
13
179
 
180
+ - **A LIVD catalog says which publication it came from and which LOINC version the mapping was made
181
+ against, and that version rides on every annotation.** A consumer could record the vendor rows and
182
+ nothing about the publication behind them, so a mapping this library annotated was not
183
+ reproducible and the attribution statement the LOINC license requires had nowhere to live beside
184
+ the mappings it applies to.
185
+ - `LivdPublication`, an **optional second argument** to `defineLivdCatalog` and readable back as
186
+ `LivdCatalog.publication`: `publisher`, `publicationVersion`, `loincVersion` and
187
+ `loincCopyright`, all optional. Each declared value is preserved **verbatim**: nothing trimmed,
188
+ case folded, reordered, reformatted or defaulted. Absent, empty and whitespace only are one
189
+ case, no value declared, stored as absent rather than as a blank.
190
+ - `LivdAnnotation.catalogLoincVersion` carries that LOINC version on **every** annotation the
191
+ catalog produced, whatever the lookup answered (`mapped`, `unmapped`, `ambiguous`,
192
+ `no-vendor-code`, `no-code`), so a mapping records what it was made against and can be
193
+ reproduced. It is provenance about the CATALOG: no field says a LOINC was checked against it,
194
+ because none was.
195
+ - `ASTM_LIVD_CATALOG_NO_LOINC_VERSION` joins `LIVD_WARNING_CODES`, with `AstmLivdCatalogWarning`,
196
+ `LivdCatalogIdentity`, `LIVD_CATALOG_IDENTITY_UNDECLARED` and the builder
197
+ `livdCatalogMissingLoincVersion`. A catalog defined with no LOINC version surfaces one on
198
+ `LivdCatalog.warnings` and is **still built**: it is indexed and answers every lookup exactly as
199
+ it would have. The warning is raised once, where the catalog is defined, never per record, so
200
+ the per-record stream `applyLivd` returns keeps the same codes, in the same order, in the same
201
+ number.
202
+ - The warning's message is a **constant** and carries no consumer-supplied text: the metadata is
203
+ text this library cannot vouch for, and interpolating it would put whatever a consumer stored
204
+ into every log that prints the message. `AstmLivdCatalogWarning.catalog` carries the publisher
205
+ and publication version the catalog declared instead, and a catalog that declared neither is
206
+ reported as having declared no identity, positively, with no name, ordinal or borrowed row value
207
+ invented to stand in for one.
208
+ - **None of it is validated and none of it is a claim by this package.** No version format check,
209
+ no copyright-text check, no publisher check: this package performs **no LOINC validation of any
210
+ kind**, so each value is surfaced exactly as supplied and `catalogLoincVersion` never means a
211
+ LOINC was checked, found or conformed against that version. **Carrying an attribution statement
212
+ is not discharging it**: the license obligation stays the consumer's.
213
+ - Additive and source compatible. The second argument is optional, so the existing
214
+ single-argument call is unchanged; `publication` and `warnings` are optional members, so a
215
+ catalog a consumer implemented by hand still satisfies the interface and still annotates without
216
+ raising; and `catalogLoincVersion` is conditional, so an annotation from a catalog declaring no
217
+ LOINC version keeps the exact key set it had. The one visible widening is `LivdWarningCode`,
218
+ which gains the new registry member, so an exhaustive `switch` over it needs one more arm.
219
+ - **`toObject`, `toISO` and `toDate`: the shared `@cosyte` date conversion surface, with the
220
+ timezone honesty ASTM forces.** A parsed `AstmDate` could be rendered (`astmDateToLocalISO`) but
221
+ not read as components and not converted to an instant, and the name it was rendered under was
222
+ this package's alone, so a consumer holding dates from two `@cosyte` parsers wrote two different
223
+ conversions. The three names, their return shapes and their timezone rule are now identical across
224
+ the sibling parsers.
225
+ - `toObject(value)` returns a **frozen** object carrying only the components the value stated
226
+ (`{ year, month, day }` for `"20240315"`), with a spec-native 1-to-12 month and singular
227
+ `hour`/`minute`/`second` keys. Nothing is zero-filled, no key holds `undefined`, and the parse
228
+ bookkeeping (`raw`, `precision`, `truncated`) stays on `AstmDate` rather than crossing over: a
229
+ digit run that cut a component in half reports only its complete components, and the dangling
230
+ digit reaches no field. `Object.keys()` is therefore the value's precision. Deleting
231
+ `offsetMinutes`, which an ASTM value never carries, leaves an object
232
+ `Temporal.PlainDateTime.from` and luxon's `DateTime.fromObject` accept unchanged; neither
233
+ library is a dependency, the shape is the interoperability.
234
+ - `toISO(value)` renders ISO-8601 truncated to the stated precision and appends no `Z` and no
235
+ offset, because ASTM states none. It returns the same string `astmDateToLocalISO` returns for
236
+ every value `parseAstmDate` produces whose components are a real calendar date; that function is
237
+ unchanged, still exported, and still the repo-native name. On a value whose components are not,
238
+ it still renders the digits it was handed (`"1988-13-01"`) and `toISO` answers `undefined`
239
+ instead, which is the only place the two differ.
240
+ - `toDate(value, options?)` returns a `Date` **only** when the caller supplies
241
+ `options.assumeOffsetMinutes`, signed minutes east of UTC, with an explicit `0` meaning "read
242
+ this value as UTC". An ASTM timestamp is local to the instrument that wrote it, so without that
243
+ assumption there is no instant to give and the answer is `undefined`: the host machine's zone is
244
+ never read and UTC is never assumed, both of which would silently shift a date by a day in every
245
+ negative-offset zone. Components below the stated precision fill to their lowest legal value for
246
+ the instant only, leaving the value's own precision untouched, and a four-digit year below 100
247
+ stays that year (`"00500101"` is the year 50, never 1950). An offset that names no usable zone
248
+ is refused rather than applied, with `undefined` and never an `Invalid Date`: one that is not a
249
+ finite number, and one large enough that applying it lands outside the range a JS `Date`
250
+ represents. An `Invalid Date` satisfies the declared return type and defeats it, since it is
251
+ indistinguishable from a real instant without testing `getTime()` for `NaN`.
252
+ - The `DateParts` and `ToDateOptions` types, and a README section covering all three together with
253
+ the aliasing pattern the shared names force on a consumer importing two `@cosyte` parsers in one
254
+ file.
255
+ - A component that is not on the calendar is refused by all three rather than normalised.
256
+ `parseAstmDate` is lenient by design and reads `"19881301"` into month 13 rather than dropping
257
+ the field, and that is unchanged, so the conversions are where it is answered: a month outside 1
258
+ to 12, a day past the end of its own month (29 February in a common year included), an hour past
259
+ 23 and a minute or second past 59 each yield `undefined`. A JS `Date` would instead roll month 13
260
+ into January of the following year and return a date of birth a year out with nothing to say so.
261
+ The refusal is of the whole value, never of the offending component alone.
262
+ - None of the three ever throws: `undefined`, `null`, a value this parser refused, a value stating
263
+ no component, and a value whose components are not a real calendar date all answer `undefined`.
264
+ Nothing else moved. No existing export was removed, renamed or changed in behaviour, no
265
+ dependency of any kind was added, and the engine floor is unchanged.
266
+
267
+ - **A LIVD catalog can now say what tells two candidate LOINCs apart, and the reported units choose
268
+ between them.** One vendor analyte code mapping to several LOINCs is the ordinary case, not an
269
+ edge: the governing mapping guide gives the commonest chemistry analytes as its own worked
270
+ examples (a serum glucose reported as a mass concentration versus a substance concentration; a
271
+ urine analyte reported as a spot concentration versus a 24 hour excretion rate) and its remedy is
272
+ a mapping per unit. Until now `LivdEntry` had nowhere to record any of that, so a catalog covering
273
+ more than one reporting unit returned `ambiguous` for a large share of ordinary analytes.
274
+ - `LivdEntry` gains three **optional** attributes, each carried **verbatim** with no trimming,
275
+ case folding or normalization: `vendorSpecimenDescription`, `vendorResultDescription` and
276
+ `representativeUnit`.
277
+ - `LivdCatalog.lookup` gains an **optional** second parameter, the units the record reported.
278
+ `applyLivd` and `lookupLivdForRecord` pass field 5 of an `R` record, verbatim; an `O` record has
279
+ no units field, so nothing is passed for one. A hand-written catalog whose `lookup` declares
280
+ only the vendor code still satisfies the interface and answers exactly as before.
281
+ - Where a vendor code carries several distinct candidate LOINCs, the candidate whose
282
+ `representativeUnit` is **exactly equal** to the reported units is answered `mapped`. **The
283
+ comparison is verbatim and case sensitive and it is not UCUM**: nothing is normalized, case
284
+ folded, scaled or converted on either side, so `MG/DL` does not match `mg/dL` and `mg/L` never
285
+ matches `ug/dL`. UCUM requires a program declaring full conformance to compare unit expressions
286
+ by their semantics, so every unit-selected answer carries the new `unitComparison`
287
+ (`comparison: "verbatim-case-sensitive"`, `ucumSemantic: false`, both units verbatim, and a
288
+ note) rather than letting a matched unit read as a conformance claim this package does not make.
289
+ - Refusing still beats guessing, and nothing about `ambiguous` was narrowed. The units matching no
290
+ candidate, matching more than one, or not being readable at all (absent, empty, whitespace only,
291
+ or lost to a truncated or malformed record) are all `ambiguous` with every candidate surfaced
292
+ and no LOINC chosen. An unreadable units field reads as **no units reported**: a typed
293
+ annotation is still produced, nothing raises and no record is omitted. An ambiguous answer now
294
+ also carries `candidateDetails` (each candidate row's LOINC and LIVD attributes, one per catalog
295
+ row, including rows that are not unit qualified) and a `reason` naming which refusal it was.
296
+ - A candidate whose `representativeUnit` is absent, empty or whitespace only is **not unit
297
+ qualified**: never selected by a unit comparison, and still surfaced among the candidates. A
298
+ vendor code carrying exactly **one** candidate LOINC is answered whether or not the units agree,
299
+ with no `unitComparison`, because no unit chose anything there. An answer that read `mapped`
300
+ before this change can never become `ambiguous`.
301
+ - A `mapped` answer's `representativeUnit` is provenance about the **catalog row**, not a
302
+ restatement of what the record reported. Where a vendor code carries a single candidate LOINC
303
+ across several rows spelling the unit differently, the answer takes the first row's attributes
304
+ (exactly as it has always taken the first row's `loincLongName`), so that field can name a unit
305
+ the record did not report. Only `unitComparison` asserts the two were equal, and it is present
306
+ only where a unit actually chose between candidates: branch on that field, not on this one.
307
+ - `vendorSpecimenDescription` and `vendorResultDescription` are **never matched on**. Both are
308
+ free text, and the guide states directly that this information is not intended to be parsed by
309
+ software that automates the mapping, so they are stored and surfaced for a human and nothing
310
+ else. Only the representative unit ever selects.
311
+ - **Additive throughout.** All three attributes are optional and every new answer field is
312
+ conditional, so a catalog carrying none of them answers byte-identically to how it answered
313
+ before, for `mapped`, `unmapped` and `ambiguous` alike. No LOINC is validated or shape-tested,
314
+ no unit is converted, no data is migrated and no consumer catalog is rewritten. New exported
315
+ types: `LivdCandidate`, `LivdUnitComparison`, `LivdAmbiguityReason`.
316
+
317
+ - **Every interpreted abnormal flag and result status reports the vocabulary it was graded against,
318
+ and `HU`/`LU` are recognized.** The `R` record's flag (field 7) and status (field 9) letters come
319
+ from vocabularies maintained on other bodies' schedules, while the record grammar around them is
320
+ archived and frozen, so this package's exposure to a moving vocabulary was real and invisible from
321
+ the outside: a consumer holding `{ recognized: false, meaning: "undefined" }` could not tell a code
322
+ **no published vocabulary defines** from one **this library had not caught up to**. That
323
+ distinction is now carried on the value itself.
324
+ - `AbnormalFlag.vocabulary` names the code system by `system` and `version`, present whether or not
325
+ the letter was recognized. It is the HL7 v3 ObservationInterpretation code system, which carries
326
+ the concepts kept aligned with the HL7 v2 Table 0078 interpretation codes previously named in
327
+ prose only. **The version records what this library compared against**, not what the sender
328
+ meant, and it is not a conformance claim about any instrument.
329
+ - `ResultStatus.vocabulary` is always the explicit **no citable published source**
330
+ (`attributed: false`, with fixed prose in `reason`). The normative text that would bind the
331
+ status letter set is purchase-gated and was not read; a neighbouring HL7 table whose letters
332
+ partly agree is deliberately **not** adopted in its place, because asserting that binding is not
333
+ something the public record supports. Saying the set is unattributed is the honest answer and is
334
+ distinguishable from an attribution that is simply absent; it never carries an identifier.
335
+ - `HU` (`"significantly-high"`) and `LU` (`"significantly-low"`) are recognized. Both are active
336
+ concepts in the graded vocabulary and were absent from the sixteen letters this package
337
+ recognized, so a feed sending either got a warning and `"undefined"`. They are distinct from
338
+ `H`/`L`, from `HH`/`LL` and from the directional `U`/`D`, all six of which keep their readings
339
+ unchanged. The recognized set is eighteen: `L`, `H`, `LL`, `HH`, `<`, `>`, `N`, `A`, `AA`, `U`,
340
+ `D`, `B`, `W`, `S`, `R`, `I`, `HU`, `LU`.
341
+ - New public values: `ABNORMAL_FLAG_CODES` (the recognized set as a list),
342
+ `ABNORMAL_FLAG_VOCABULARY`, `RESULT_STATUS_VOCABULARY`, `describeVocabulary`, and the
343
+ `NamedVocabulary` / `UnattributedVocabulary` / `VocabularyAttribution` types.
344
+ - **No tolerance was widened.** Recognition stays exact match after the existing trim: no case
345
+ folding, so `hu` and `f` are reported unrecognized with their raw text intact. No other concept
346
+ from that code system is adopted, including the deprecated `H>` and `L<` whose replacements
347
+ `HU`/`LU` are; those stay unrecognized, which is the fail-safe direction. An unrecognized flag is
348
+ still never `normal` and an absent status is still never `final`.
349
+ - **Nothing reaches the wire.** The attribution is interpretation output; a parsed and
350
+ re-serialized record emits byte-identical output, pinned by literal expected strings in
351
+ `test/records/golden-round-trip.test.ts`. No new dependency.
352
+
14
353
  - **The PHI sweep now reads the bytes git carries, as a union with the working-tree walk.** All mode
15
354
  reconciled the paths it walked against the paths git tracks, and a comparison of path _sets_ is
16
355
  satisfied by a working tree that carries different content at those paths. Four states were
@@ -51,7 +390,7 @@ this file is maintained by hand (Changesets handles the version bump and publish
51
390
  adding a workflow that would have to be made a required context separately. It asserts that every
52
391
  pointer resolves, that no section a pointer reaches is empty, that the record stays reachable from
53
392
  the always-read file, and that the always-read file still links the record by path. It asserts
54
- **no** ecosystem-wide contract: several cosyte repos carry no such record at all, and a gate
393
+ **no** ecosystem-wide contract: several Cosyte repos carry no such record at all, and a gate
55
394
  phrased as though every repo did would be an overclaim.
56
395
  - **The matcher was re-derived against this tree rather than ported, and that was the whole job.**
57
396
  Both sibling spellings score **zero** here: the path-qualified form used by `ccda` and `mllp`,
@@ -1094,7 +1433,7 @@ this file is maintained by hand (Changesets handles the version bump and publish
1094
1433
  **The refusal removes no capability.** `composeAstmFrames` already accepted `Uint8Array`, so a
1095
1434
  consumer who knows their instrument's code page encodes with their own encoder and passes bytes,
1096
1435
  which are written through untouched. A stream whose every character is Latin-1 is byte-identical
1097
- to before, the full `0x00`–`0xFF` range included. The **record** layer is deliberately unchanged:
1436
+ to before, the full `0x00` to `0xFF` range included. The **record** layer is deliberately unchanged:
1098
1437
  `serializeAstmRecords` returns a `string`, which is not yet bytes, so the caller keeps the choice.
1099
1438
 
1100
1439
  **Stated no wider than it was measured.** This closes the string-to-bytes step only. It is not a
@@ -1571,7 +1910,7 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
1571
1910
  acceptance under a tolerating profile) is _measured_, on the tree before and after the change.
1572
1911
 
1573
1912
  - **The em dash (U+2014) is gone from every tracked file, and a CI gate keeps it out**
1574
- (`EMDASH-CONFORMANCE`). The brand rule bans the character outright across every cosyte surface
1913
+ (`EMDASH-CONFORMANCE`). The brand rule bans the character outright across every Cosyte surface
1575
1914
  and names commit messages explicitly; this package was the last one it had not reached, and the
1576
1915
  only one where it did not arrive on a clean tree. **1,129 occurrences across 108 of the 142
1577
1916
  tracked files** were rewritten with a comma, a colon, or a period. The sweep and the gate are one
@@ -2035,7 +2374,7 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
2035
2374
 
2036
2375
  ## [0.0.1] - 2026-07-22
2037
2376
 
2038
- The first pre-alpha release. The package begins its public history at `0.0.x`, per the cosyte version
2377
+ The first pre-alpha release. The package begins its public history at `0.0.x`, per the Cosyte version
2039
2378
  ladder (`0.0.x` until first alpha).
2040
2379
 
2041
2380
  ### Added
@@ -2163,7 +2502,7 @@ warnings: readonly AstmFrameWarning[] }`. A frame is `<STX> FN text <ETB|ETX> CS
2163
2502
  - **Modulo-256 checksum** over the bytes after `STX` up to and **including** the `ETB`/`ETX`
2164
2503
  terminator, two hex chars: **verified on decode, emitted uppercase, accepted lowercase** (a real
2165
2504
  vendor quirk). `computeChecksum` / `toChecksumHex` / `parseChecksumHex` exported.
2166
- - **Frame-number `0`–`7` sequencing** (rolls over `7 → 0 → 1`, starts at `1`) and **multi-frame record
2505
+ - **Frame-number `0` to `7` sequencing** (rolls over `7 → 0 → 1`, starts at `1`) and **multi-frame record
2167
2506
  reassembly**: text is capped at **240 bytes** (the seven control bytes are **not** counted), `ETB`
2168
2507
  is intermediate / `ETX` final. `parseFramedAstm(bytes, opts?)` composes the framing and record layers
2169
2508
  at the edge (decode → reassemble trusted records → `parseAstmRecords`).
@@ -2199,7 +2538,7 @@ defaulted: boolean; warnings }`. A leading `STX`/`ENQ` ⇒ **framed** (serial, a
2199
2538
  by `ltpInitialState()`. Events are the four LTP control signals (`enq`/`ack`/`nak`/`eot`) plus a
2200
2539
  codec-decoded `frame`; actions are `sendAck` / `sendNak` / `sendEot` / `deliverRecord`. It models
2201
2540
  the LIS01-A2 establishment → transfer → termination phases as `neutral ⇄ transfer`, reassembling
2202
- `ETB…ETX` runs into delivered records and tracking the `0`–`7` frame sequence.
2541
+ `ETB…ETX` runs into delivered records and tracking the `0` to `7` frame sequence.
2203
2542
  - **ACK-failsafe (safety-critical, borrowed from `mllp`).** A frame the codec did not vouch for (a
2204
2543
  **bad checksum**, an **unterminated** frame, or one **out of sequence**) is answered with `NAK`,
2205
2544
  **never** a fabricated positive `ACK`, and its bytes are **never** appended to a record or
@@ -2236,7 +2575,7 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
2236
2575
  counters, the `L` terminator) is **computed, not guessed** (a sequence number may be overridden).
2237
2576
  - **Frame encoder.** `composeAstmFrames(records, opts?)` is the exact inverse of `decodeAstmFrames`:
2238
2577
  it wraps reassembled record bytes into `<STX> FN text <ETB|ETX> CS <CR><LF>` frames with the
2239
- modulo-256 **checksum** and the `0`–`7` **frame number** **computed** (never accepted-as-given or
2578
+ modulo-256 **checksum** and the `0` to `7` **frame number** **computed** (never accepted-as-given or
2240
2579
  faked; emitted uppercase), numbered continuously across the stream (start `1`, roll over `7 → 0`),
2241
2580
  and every record over **240** text bytes **split** `ETB…ETX` (the seven control bytes never counted).
2242
2581
  `serializeFramedAstm(msg | records)` composes both emit layers at the edge: the mirror of