@cosyte/astm 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,8 +9,444 @@ this file is maintained by hand (Changesets handles the version bump and publish
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ### Added
13
+
14
+ - **`messages()`: read a stream as the sequence of messages it actually is**
15
+ (`ASTM-PATIENT-RESULT-MISATTRIBUTION`). A parsed model has always been a whole record
16
+ **stream**, and a stream may carry several messages back to back: a message runs from an `H`
17
+ header to its `L` terminator. `messages(msg)` splits the stream into them and returns a
18
+ readonly array of `AstmStreamMessage`, each carrying only its own records:
19
+
20
+ ```ts
21
+ for (const m of messages(parseAstmRecords(raw))) {
22
+ m.patient; // the P for THIS message
23
+ m.results; // the Rs for THIS message
24
+ }
25
+ ```
26
+
27
+ Each entry carries `index`, `header`, `delimiters`, `records`, `patient`, `patients`,
28
+ `results`, `orders`, `comments`, and `queries`. It never throws, and a single-message stream
29
+ yields exactly one entry whose `records` are the whole stream, so it is safe to reach for
30
+ unconditionally.
31
+
32
+ **A message opens at every `H`** and runs to the record before the next one. That is
33
+ deliberately the _same_ boundary the parser already uses to scope delimiters, so message
34
+ boundaries and delimiter scope cannot disagree; `m.delimiters` is the set that message's
35
+ records were actually read with. The `L` terminator closes a message but opens no scope, so
36
+ records between an `L` and the next `H` stay with the message their header opened rather than
37
+ being dropped. Grouping is **total**: every record lands in exactly one message, none
38
+ duplicated and none lost, which is asserted directly.
39
+
40
+ **Evidence, labelled.** The message unit is `H` … `L`, _verified primary_: CLSI LIS02-A2 §2 is
41
+ definitional about the unit itself, bounding a message by the `H` record at one end and the `L`
42
+ record at the other. Read directly in CLSI's own free sample, and stated here in our own words
43
+ on purpose: we may read and cite the standard but never reproduce its prose, and this file ships
44
+ inside the npm tarball. That clause is the whole of what is claimed from the standard here, and
45
+ it does **not** settle which record _opens_ a new scope partway through a stream. So treating a
46
+ second `H` as the start of the next message is recorded as a **reasoned choice, not a citation**:
47
+ no clause number is claimed for it, and we do not assert the standard is silent either. It is
48
+ chosen to agree with the delimiter scoping this parser already enforces, and because it drops no
49
+ record. **The OSS corpus was checked rather than dismissed**, and unlike for delimiter questions
50
+ it is informative here, but only negatively: neither commonly-cited reference implementation
51
+ models the message unit at all. One dispatches records to per-type callbacks one at a time with
52
+ no carried state, leaving the association to the integrator; the other merges every message of a
53
+ transport session into a single envelope of flat per-record-type buckets, from which the
54
+ association cannot be recovered at all. A flat model is the incumbent shape, and this entry is
55
+ what it costs.
56
+
57
+ **`patient` is answered only when the message determines one.** It is the single `P` when the
58
+ message carries exactly one, and `undefined` when it carries none **or** several;
59
+ `patients.length` distinguishes those two, and there is no third meaning. A message carrying
60
+ several patients is not answered with the first one, because that is the guess this release
61
+ exists to remove. Within-message record hierarchy (which `P` a given `R` files against when a
62
+ message carries several) is **not** modeled: the clauses that would ground it (the
63
+ message-level structure diagram, and the `P` sequence-number rule) are withheld from the free
64
+ sample and paywalled, so the layer declines rather than inventing a rule. Multi-patient
65
+ messages are real, at least one openly-published vendor interface grammar makes the patient
66
+ group repeatable on the download direction, so this is a deferral with a known shape, not a
67
+ claim that the case does not arise, and not a claim that the standard is silent about it.
68
+
69
+ - **`pnpm check:no-internal-refs` + a `no-internal-refs` CI job** (`PUBLIC-SURFACE-HYGIENE`,
70
+ founder directive 2026-07-27). Internal only; no change to the published package surface. `astm` was
71
+ the last parser with no gate on this rule at all, which is why the class had regrown. The script is
72
+ ported from `ncpdp`'s copy (which carries three fixes `hl7`'s reference lacks: the `src/`
73
+ string-literal pass, the plural `phases?` stem, and `/` in the ADR separator class) plus `cli`'s
74
+ seventh rule, the prose roadmap citation, which neither `hl7` nor `ncpdp` has and which found the
75
+ second-largest class here. Four surfaces are scanned against seven rules: public markdown +
76
+ the npm `description`/`keywords`, `src/` doc comments, and `src/` string literals. The two prose
77
+ surfaces are scanned line by line **and** paragraph-reflowed, so a violation that straddles a wrap
78
+ is still caught; the npm metadata and the string-literal pass are line-scanned only. A multi-line
79
+ template literal is not scanned at all, split or not, because the extractor needs both delimiters
80
+ on one line. `//` comments, `CHANGELOG.md` and `.changeset/` are deliberately out of scope:
81
+ the convention names them as where identifiers belong.
82
+
83
+ Re-derived for this repo rather than copied: rule 2 now excludes E1381/CLSI-LIS01's own
84
+ **protocol phases** (`protocol`, `three-`, `establishment`, `transfer`, `termination`, `neutral`,
85
+ `idle`), because "phase" is the standard's word for the state of the line and `LtpState.phase` is an
86
+ exported field. The cost is disclosed rather than papered over: because a lookbehind exempts the
87
+ whole match, one of our own phases sitting behind one of those words is not caught (`the transfer
88
+ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was written, measured and
89
+ removed, because the justification did not survive the corpus: the digit is usually the next
90
+ clause's first token, so it red on `In the transfer phase 240 bytes is the maximum frame text` and
91
+ on `a three-phase 400 V supply`, which are reference prose. Where a rule cannot be guarded, it is
92
+ cut rather than hardened. And the `SPEC-7` /
93
+ `ACC-42` synthetic example ids in every runnable sample are
94
+ `WORD-N` exactly, so a shape-keyed rule would rewrite the sample a consumer copy-pastes. Both are
95
+ asserted in negative self-tests, alongside `ASTM-E1394`, `CLSI-LIS01`, `LIS02-A2`, `POCT1-A`,
96
+ `E1394-97` and `ICD-10-CM`. A further negative self-test pins **bare `§` section citations as
97
+ deliberately unruled**, so closing that hole has to be a decision rather than a widening that lands
98
+ by accident.
99
+
100
+ The script also **refuses to run under a `grep` that cannot see its subject.** A scanner-visibility
101
+ probe seeds a violation into a file containing a NUL byte and requires it to come back as either a
102
+ hit or a stderr diagnostic; silence at exit 0 is a hard refusal, because a tool that skips a file
103
+ produces output identical to a tool that read it and found it clean. This is not theoretical: the
104
+ development container interposes a `grep` with `-I` forced, which drops such input without a word.
105
+ An interposed shell function is also `unset` rather than assumed absent. Both directions are
106
+ proven: an `-I`-forcing `grep` ahead of it on `PATH` makes the gate refuse, and an exported `grep`
107
+ function is neutralised. The companion `-G` (basic-regex) hazard needs no separate guard: every
108
+ positive self-test uses alternation, so a BRE-forced `grep` fails them and refuses.
109
+
110
+ Known gaps, stated rather than discovered later: a bare `P\d+` label (`(P7)`) is not caught, because
111
+ a general `P\d+` rule has corrupted ICD-10-CM codes in a sibling; `phase` at the end of a clause is
112
+ not caught; bare `§` section citations are deliberately unruled; and `dist/` is build output this
113
+ script cannot read, so it gates dist's source, not dist. Fourteen `P\d+` lines and the falsehoods
114
+ below were found by hand, not by a rule.
115
+
116
+ ### Changed
117
+
118
+ - **The em dash (U+2014) is gone from every tracked file, and a CI gate keeps it out**
119
+ (`EMDASH-CONFORMANCE`). The brand rule bans the character outright across every cosyte surface
120
+ and names commit messages explicitly; this package was the last one it had not reached, and the
121
+ only one where it did not arrive on a clean tree. **1,129 occurrences across 108 of the 142
122
+ tracked files** were rewritten with a comma, a colon, or a period. The sweep and the gate are one
123
+ change on purpose: a sweep with no gate grows back, and a gate with no sweep reds CI on arrival.
124
+
125
+ **What a consumer can observe.** The npm `description` no longer carries the character. The pages
126
+ published to docs.cosyte.com are rewritten, including the title of the limitations page, now
127
+ `What it does, and does not do`. Warning and error `message` text is repunctuated, and a
128
+ consumer's log prints that text. **No warning or fatal code changed**: codes are the stable
129
+ contract, and the snapshot tests that pin them are untouched. No runtime behaviour changed; the
130
+ parser, the frame codec, the LTP reducer, and the emit path are not part of this change.
131
+
132
+ **Two findings worth carrying, because neither is visible from a diff.** First, an em dash can be
133
+ a semantic **value**: `docs-content/architecture.md`'s layer table used a bare dash in the
134
+ **Standard** column to mean _this layer is governed by no standard_, and a bulk rewrite turned it
135
+ into a stray colon, so the cell read "unstated" rather than "none" on the page whose job is honest
136
+ disclosure. Nothing in CI could have caught that; it is now the explicit word `None`. Second, rewriting the
137
+ separator in the warning registries as a colon turned `test/records/multi-header-delimiters.test.ts`
138
+ red: that fixture declares `:` as its **component** delimiter, and the test asserts that no warning
139
+ message contains one of its delimiter characters. All 22 registry messages separate with a comma
140
+ now. The invariant that assertion really pins is that a warning message is a **constant carrying no
141
+ field data**, which is what makes it value-free; a comma is not safer than a colon in principle,
142
+ because ASTM delimiters self-declare and any character can be one. The existing test caught this,
143
+ not a reviewer.
144
+
145
+ **Where the rewrite needed a rewrite, not a substitution.** Where the character had bracketed an
146
+ aside that itself contains commas, replacing both ends with commas turns the aside into an
147
+ indistinguishable list item, and several sentences went false that way. Those now use
148
+ **parentheses**, which is the fourth option the rule always allowed and the one a mechanical
149
+ substitution never reaches for.
150
+
151
+ - **BREAKING: `patient()`, `results()`, `orders()`, `comments()`, and `query()` now throw on a
152
+ stream they cannot answer for, instead of answering across patients**
153
+ (`ASTM-PATIENT-RESULT-MISATTRIBUTION`; shipped `0.0.1` through `0.0.3`).
154
+
155
+ **The defect.** These accessors read the whole stream. On a stream carrying more than one
156
+ message, `patient()` answered with the **first** `P` in the stream while `results()` answered
157
+ with **every** `R` in it, so pairing them, which is exactly what this package's one-line north
158
+ star does, attributed one patient's results to another. It needed no delimiter redeclaration
159
+ and no unusual delimiters: an ordinary two-message stream in one canonical set reproduced it,
160
+ with **zero** warnings, and `strict` mode raised no objection either. On an analyzer-to-LIS
161
+ path that is a result filed against the wrong patient. Nothing was mis-_read_, every `P` and
162
+ every `R` was correct in `records`, but there was no way to ask which belonged together.
163
+
164
+ **The change.** Those five accessors now throw `AstmAmbiguousStreamError` with code
165
+ `ASTM_AMBIGUOUS_MULTI_MESSAGE` when the stream carries more than one message. `patient()` also
166
+ throws `ASTM_AMBIGUOUS_MULTI_PATIENT` when the single message it is reading carries more than
167
+ one `P`, which is the same harm one level down: "the first `P`" is a guess about whose result
168
+ it is. The error carries a stable code, a value-free position, and the two counts; never an
169
+ identifier and never a value.
170
+
171
+ **Why the break is the fix, not a cost of it.** The callers this breaks are exactly the
172
+ population that is being silently corrupted today. A caller who now gets an exception is
173
+ strictly better off than one who quietly received another patient's results: the failure is
174
+ loud, immediate, positioned, and correctable, where before it was invisible at every layer.
175
+ There is no version of this where the old behaviour is safe to keep, and no warning strong
176
+ enough to substitute for refusing to answer, because the wrong answer was already being used.
177
+
178
+ **Which callers are affected, stated exactly.** A stream that is one message carrying at most one
179
+ `P` is **unchanged**, and so is a result-only message with no `P` at all: `patient()` still answers
180
+ `undefined` there, which is an ordinary shape and not an error. But `ASTM_AMBIGUOUS_MULTI_PATIENT`
181
+ **does reach single-message callers**, a lone message carrying several patients used to answer
182
+ with the first of them and now refuses, so "single-message streams are unaffected" would be false
183
+ and is not claimed. That second break is the same wrong-patient guess as the first, one level down,
184
+ and it is disclosed on the README and in the docs for the same reason the first one is.
185
+ `commentsFor()` is unchanged and works on any stream, single- or multi-message, because the parent
186
+ record it is handed already names the message.
187
+
188
+ **Migrating** is mechanical: replace `results(msg)` and `patient(msg)` with a walk over
189
+ `messages(msg)`, reading each message's own `patient` and `results`. Where a caller genuinely
190
+ wants every result in a stream regardless of who they belong to, that is
191
+ `messages(msg).flatMap((m) => m.results)`: written out, so it is a choice rather than a
192
+ default.
193
+
12
194
  ### Fixed
13
195
 
196
+ - **Emit no longer writes a stream it cannot read back** (`ASTM-EMIT-RESIDUALS`; both gaps shipped
197
+ `0.0.1` through `0.0.3`, `PRE-EXISTING`, neither a regression). Two residual emit-side holes, both
198
+ recorded and deliberately deferred by `ASTM-MIXED-DELIMITER-EMIT` (#21) and again by
199
+ `ASTM-SECOND-HEADER-COLLAPSE` (#22) because each turned on a question those slices did not answer.
200
+ Both were reproduced by **executing** the shipped code before anything was changed, and re-verified
201
+ the same way after; neither was half-fixed by the two earlier slices.
202
+
203
+ **1. A delimiter declaration longer than three characters lost its extra bytes.** Three characters
204
+ of a header's declaration carry a role (repeat, component, escape, by position) and the reader
205
+ ignores any beyond them rather than refusing the stream. Emit regenerated the declaration from the
206
+ three roles alone, so a header that arrived as `H|\^&#` went back out as `H|\^&`, with no warning
207
+ and no way for a caller to notice. Measured before the fix: `serializeAstmRecords(msg, msg.delimiters)`
208
+ on `H|\^&#|||SENDER^SYS|||||||P|1` returned a header short of its `#` and a round-trip that was not
209
+ byte-exact.
210
+
211
+ The surplus is now carried through from the modeled declaration field. Of the three available
212
+ dispositions (preserve, refuse, report), **preserve** was chosen. Refusing would reject a stream
213
+ the parser reads without complaint, on a published package, over bytes it has already decided are
214
+ inert. Reporting is not available at all: emit returns a bare string and has no warning channel, so
215
+ a warning could only be ignored while the truncated stream still shipped, which is the same reason
216
+ #21 chose re-encoding. Preserving costs nothing and is the only one of the three that makes the
217
+ round-trip byte-exact. What the surplus _means_ remains unresolved (the clauses that would settle
218
+ it are not in the freely published material and were not read, so no clause is cited here in either
219
+ direction) but carrying bytes through unread is a strictly smaller claim than deleting them, and it
220
+ stays coherent with a reader that now scopes delimiters forward from every header: the re-read
221
+ declaration resolves to the same four roles either way. This lands on the **default** canonical
222
+ path too, not only when a set is passed explicitly (normalizing a message replaces the four
223
+ delimiter roles, and the surplus holds none of them) so a caller who was relying on emit to strip
224
+ those bytes will find it no longer does.
225
+
226
+ The surplus is dropped in exactly the two cases where it could not be read back as surplus: the
227
+ header is being transcoded into a different delimiter set, so the surplus belonged to the
228
+ declaration being replaced; or the surplus is not inert on the wire, meaning it contains the field
229
+ separator or **any control character**. The control rule is deliberately wider than the record
230
+ layer alone needs. A `CR`/`LF` would end the record and shift every data field along, but this text
231
+ also reaches the **frame** layer through `serializeFramedAstm`, where `STX`, `ETX` and `ETB` are
232
+ structural: a surplus carrying one of those truncated the frame body, and re-reading the framed
233
+ stream then dropped the **entire header record** (its sender, its receiver, its control ID)
234
+ behind nothing but an `ASTM_FRAME_BAD_CHECKSUM`. Rather than enumerate the bytes each layer happens
235
+ to reserve, and re-derive that list whenever a layer is added, no control character is carried at
236
+ all. Two things that rule does **not** do, said here rather than left to be found: it is keyed on
237
+ the character while the frame layer's structure is keyed on the low byte (`charCodeAt(i) & 0xff`),
238
+ so a non-control character truncating onto `STX`/`ETX`/`ETB` still breaks framing, loudly, with a
239
+ typed error or an unknown-record-type warning, never silently; and refusing a control character in
240
+ a _surplus_ does not mean one cannot be a _delimiter role_, since only `CR`/`LF` are refused
241
+ there. Those are
242
+ structural losses; dropping inert bytes is not.
243
+
244
+ **2. A caller-supplied delimiter set was never validated.** `serializeAstmRecords(msg, d)` and its
245
+ three siblings took `d` on trust. Measured before the fix, across six malformed sets: a
246
+ multi-character field separator, an empty escape character, a `field`/`escape` collision and a `CR`
247
+ separator each emitted a stream `parseAstmRecords` then **threw** on; a `repeat`/`component`
248
+ collision and a `component`/`escape` collision each emitted one it **re-read with a different field
249
+ tree and zero warnings**. An empty escape additionally garbled the values themselves: `28.6` went
250
+ out as `2E8E.E6`. On the analyzer-to-LIS path a field tree that changes under a re-read is a lost
251
+ result or a lost specimen identifier, and the caller had no signal either way.
252
+
253
+ `d` is now checked before any bytes are written, on all four public emit entry points
254
+ (`serializeAstmRecords`, `serializeAstmRecord`, `serializeField`, `encodeComponent`): each separator
255
+ exactly one character, none a `CR`/`LF`, no two the same character. A failing set is an
256
+ `AstmSerializeError` with the new code `ASTM_EMIT_INVALID_DELIMITERS`: including a set that omits a
257
+ member or holds a non-string, which the types forbid but a JavaScript caller can still pass, and
258
+ which previously surfaced as a raw `TypeError` from inside the serializer.
259
+
260
+ **The three rules are necessary, not sufficient, and the docs say so rather than implying a
261
+ guarantee.** A set can satisfy all three and still emit a stream that reads back wrong: a separator
262
+ equal to a record's type letter (`field` of `R`) makes the type letter itself get escaped away, and
263
+ the record re-reads as unsupported with its result lost. That corruption is unchanged by this slice,
264
+ it reproduces byte-identically before it, and is recorded as a known defect rather than fixed
265
+ here, because the rule that would catch it has to be derived rather than guessed. **A typed error rather than a
266
+ warning, and the house rule from #21 is why**: with no warning channel on a `string` return, refusing
267
+ at the call is the only disposition that reaches the caller at all. The counter-argument was weighed
268
+ and is real: `@cosyte/astm` is published, and refusing rejects input previously accepted. It is
269
+ accepted deliberately, because the input turned away is exactly the input that was being corrupted,
270
+ and because the three rules are not stylistic: each names a case where the emitted bytes provably do
271
+ not read back as the records that produced them. The narrowing has one consequence worth stating
272
+ plainly: the **reader** tolerates a header declaring, say, the same character for `repeat` and
273
+ `component`, so `serializeAstmRecords(msg, msg.delimiters)` on such a message now throws where it
274
+ previously returned bytes that read back wrong. The canonical default is untouched, and so is every
275
+ well-formed set. The error names the role at fault and never echoes the offending characters, since
276
+ a value failing the one-character rule can be arbitrary caller text and the message reaches logs.
277
+
278
+ The invariant both halves are held against is the one #21 and #22 established: **a round-trip never
279
+ silently loses a field, in either direction.** The property is asserted by round-tripping through the
280
+ real parser, not by inspecting the serializer.
281
+
282
+ `AstmSerializeError.code` widens from a single string literal to a two-member union, exported as the
283
+ new `AstmSerializeErrorCode` type. `ASTM_EMIT_UNENCODABLE_VALUE` is unchanged.
284
+
285
+ - **`parseAstmRecords` now reads the delimiters from every `H` record, not only the first, so a
286
+ stream carrying more than one message no longer silently loses fields** (`ASTM-SECOND-HEADER-COLLAPSE`;
287
+ shipped `0.0.1` through `0.0.3`, `PRE-EXISTING`, not a regression). This is the same silent field
288
+ collapse as `ASTM-MIXED-DELIMITER-EMIT` one layer over, on the **parse** side, which is why #21 did
289
+ not narrow it: #21 fixed what we write, and a stream arriving with two headers is mis-read on the
290
+ way **in**, before any of that runs.
291
+
292
+ A message runs `H` … `L`, so one stream may carry several back to back and each header declares the
293
+ delimiters for the records that follow it. Delimiters were read only from `records[0]` and applied
294
+ to the whole stream, so every record after a redeclaring header **collapsed into a single field,
295
+ with zero warnings**, and `{ strict: true }` accepted it. Measured by execution on `dc09a3a` before
296
+ the fix: in a two-message stream whose second header declared `*~:#`, the second result, value
297
+ `99.9`, units `mmol/L`, abnormal flag `H`, status `F`, came back with `value`, `units`, `flag` and
298
+ `resultStatus` **all absent** and `status` reading `unspecified`. On the analyzer↔LIS path that is a
299
+ lost result with no signal to the caller.
300
+
301
+ **The decision, and its grounding.** Delimiters are now scoped **forward from each header**: a later
302
+ `H` governs itself and the records after it, until the next `H`. The asymmetry with the emit bug is
303
+ real: on emit the library was corrupting its own output, so re-encoding was unambiguously safe,
304
+ whereas on parse the bytes are the sender's and a second `H` is legal. What settles it is that
305
+ forward scoping is the **only** reading that never reinterprets bytes already consumed: records
306
+ before a redeclaration keep the set they were read with, so the two sets never disagree about the
307
+ same bytes. "First header wins forever" is the collapse itself; "last header wins retroactively"
308
+ would require re-reading records already delivered.
309
+
310
+ **Evidence, labelled.** The message unit is `H` … `L`, _verified primary_: LIS02-A2 §2 makes the
311
+ message a **bounded unit**, one record type opening it and another closing it, which is why a second
312
+ `H` begins a new message rather than corrupting the current one. Read directly in CLSI's own free
313
+ sample of the document. (Stated in our own words on purpose. We may read and cite the standard but
314
+ never reproduce its prose, and `CHANGELOG.md` ships inside the npm tarball: an earlier draft of
315
+ this entry quoted the clause verbatim and was caught in review.) That a header may follow a
316
+ terminator to start another message: _verified secondary_, two independent restatements of the
317
+ terminator clause (Roche cobas b 121 ASTM interface description; Genaux, _Introduction to ASTM
318
+ Message Formats_, 2024).
319
+ That a header's delimiters govern "the message": _verified secondary_ (Genaux; Stratford Software
320
+ interface spec), which leans per-message but **does not address a redeclaration that changes the
321
+ set**. **We could not reach the normative text on that specific question**: LIS02-A2 §5.4
322
+ (Delimiters) and §6.2 (Delimiter Definition) are precisely the clauses withheld from the free
323
+ sample, and E1394-97 and LIS01-A2 stayed paywalled. So the forward-scoping rule is recorded as a
324
+ **reasoned choice, not a citation**: no clause number is claimed for it, and we do not assert the
325
+ standard is silent either, because that would need the same evidence.
326
+
327
+ The OSS reference corpus cannot ground this one and is reported as a negative result rather than
328
+ dressed up: `kxepal/python-astm` and `senaite.astm` both hardcode `|\^&` as module constants, never
329
+ read the header's declaration at all, and neither tracks `H` … `L` boundaries, so differential
330
+ testing against them is uninformative here.
331
+
332
+ **Behaviour.** A redeclaration that **changes** the set is honored and raises
333
+ `ASTM_RECORD_DELIMITERS_REDECLARED`. A header that merely **restates** the set in force is a no-op
334
+ and raises nothing: several messages in one delimiter set is an ordinary shape, and warning on it
335
+ would be noise. A later header whose declaration is **unusable** (too short, or a field separator
336
+ that also names another role) keeps the set already in force and raises
337
+ `ASTM_RECORD_UNREADABLE_REDECLARATION`; a set is never guessed and no record is dropped. The same
338
+ condition on the _first_ header remains the `ASTM_RECORD_UNDECLARED_DELIMITERS` fatal: there is no
339
+ earlier set to fall back to, and that is pinned by a test so it cannot be softened by accident.
340
+
341
+ **Surface.** Two new stable warning codes (`ASTM_RECORD_DELIMITERS_REDECLARED`,
342
+ `ASTM_RECORD_UNREADABLE_REDECLARATION`) with `delimitersRedeclared` / `unreadableRedeclaration`
343
+ factories, both **safety-critical** by the profile gate's default-deny rule, so no profile can quiet
344
+ them (asserted). `HeaderRecord.delimiters` now reports the set **that** header put into force rather
345
+ than always the first header's; `AstmMessage.delimiters` is unchanged and stays the first header's.
346
+ The sites that _stated_ the old single-header rule were swept, the `parseAstmRecords` JSDoc, the
347
+ module header, `HeaderRecord`/`AstmMessage` type docs, `readDelimiters` (whose doc comment said the
348
+ caller escalates an unusable declaration to the fatal, true only of the first header now), and
349
+ `docs-content/quickstart.md`, because on #21 the refuter's first pass was refused for exactly the
350
+ opposite failure: correct code shipped behind documentation that steered consumers the wrong way.
351
+ The `readDelimiters` comment also said it returns `{ ok: false }` when it has always returned
352
+ `undefined`; that was already wrong before this slice and is corrected in the same paragraph.
353
+
354
+ **Deliberately left for their own slices** (both recorded with this item, both **emit**-side, and
355
+ both turning on questions this one does not answer): a delimiter declaration **longer than three
356
+ characters** still loses its extra bytes on emit, what a fourth declaration byte even means is
357
+ unresolved by the same withheld clauses, and `serializeAstmRecords(msg, d)` still does **not
358
+ validate a caller-supplied `d`**, so a malformed set emits a stream this library's own parser then
359
+ rejects or mis-reads, with no typed error. Neither is touched here; folding an emit-side change into
360
+ a parse-side fix would have widened the diff without answering either question.
361
+
362
+ **Strict-mode boundary, disclosed as a sample and not a census.** Measured base-build vs head-build
363
+ over 216,699 synthetic streams (the 11 repo fixtures, 464 single-header streams, and multi-header
364
+ pairs across a sampled sweep of delimiter sets): **394 moved accepted→rejected, 0 the other way.**
365
+ All 11 fixtures and all 464 single-header streams were **unchanged**, in strict mode and in their
366
+ lenient warning lists, which matches the code path, since the new warnings can only be raised at an
367
+ `H` record that is not `records[0]`.
368
+
369
+ - **`serializeAstmRecords` no longer emits a mixed-delimiter stream that silently loses fields on the
370
+ next read** (`ASTM-MIXED-DELIMITER-EMIT`; shipped since `0.0.1`, `PRE-EXISTING`, not a regression).
371
+ Emit normalized the header to the canonical `H|\^&` set but re-emitted `M` (manufacturer) and `S`
372
+ (scientific) records byte-for-byte from `rawLine`. For a message that arrived under a vendor
373
+ delimiter set the output was therefore **non-conformant**: a canonical header above `M`/`S` rows
374
+ still written in the original delimiters. Re-parsing that output **collapsed every field of those
375
+ rows into one, with zero warnings**: on the analyzer↔LIS path, a lost result or a lost
376
+ patient/specimen identifier with no signal to the caller. Verified by execution before and after,
377
+ not by inspection: a five-field `M` row round-tripped to one field.
378
+
379
+ **The semantics chosen, and why.** The two candidates were to re-encode every record to the
380
+ delimiters the header declares, or to refuse/warn on a message whose records disagree.
381
+ **Re-encoding was chosen.** It is what the serializer already promises, one spec-clean stream in
382
+ the declared set, and a mixed-delimiter stream is not spec-clean. Emit returns a `string` and has
383
+ no warning channel, so a warning could only have been ignored while the corrupt stream still
384
+ shipped, and a refusal would have rejected messages this library successfully parsed, a harder
385
+ break on a published package. The invariant that had to hold either way is that **a round-trip
386
+ never silently loses a field, in either direction**; it is now pinned by a property over arbitrary
387
+ declared delimiter sets, which fails against the old code. `M`/`S` are reproduced byte-for-byte
388
+ **only when a reader using the emit delimiters would recover exactly the fields the record models**,
389
+ so bytes change for exactly the streams that were being corrupted and for no others. The reasoning
390
+ is recorded at the site in `src/records/serialize.ts`.
391
+
392
+ - **Editing a modeled `H` field and re-serializing no longer silently keeps the original value**
393
+ (`PRE-EXISTING`, found in the same pass). The header was emitted by re-tokenizing its preserved
394
+ `rawLine`, so the model was bypassed on emit. It is now emitted from `HeaderRecord.fields` like
395
+ every other record type. The delimiter declaration itself is still never taken from the model: it
396
+ always states the delimiters actually being emitted with, because a declaration that disagrees with
397
+ the records around it is the very corruption above.
398
+
399
+ - **`HeaderRecord.fields` now holds the header's real fields.** The generic escape-aware tokenizer
400
+ reads the escape character sitting _literally_ inside the delimiter declaration as an unterminated
401
+ escape and swallows the remainder of the record, so a canonical header modeled as **two** fields
402
+ (`H` and everything else merged) instead of one per field. A header-aware `tokenizeHeader` takes the
403
+ declaration verbatim as one opaque field and tokenizes the data portion normally; it is exported
404
+ alongside `tokenizeRecord`. This is what made emitting the header from its model possible.
405
+
406
+ **A `strict` consumer will observe one side effect, stated rather than left to be discovered.**
407
+ The old tokenizer read the declaration's literal escape char as an unterminated escape and raised a
408
+ spurious `ASTM_UNKNOWN_ESCAPE_SEQUENCE` on some headers, which `{ strict: true }` rejected. Those
409
+ warnings are gone, so a few messages that previously threw now parse. Measured over ~40,000
410
+ differential messages: 1,176 moved from rejected to accepted, **0** moved the other way, and every
411
+ warning delta is confined to `recordIndex 0`, no warning on any other record is added or dropped.
412
+
413
+ Three `PRE-EXISTING` neighbours were found while grading this and are deliberately **not** fixed
414
+ here, to keep the slice the size of its item: `parseAstmRecords` reads delimiters only from the
415
+ first header, so a **second `H` mid-stream that redeclares them** still yields the same silent
416
+ field collapse (parse-side, not emit-side, the emitter faithfully reproduces what parse modeled),
417
+ **since fixed, see the parse-side entry above**;
418
+ a delimiter declaration longer than three characters silently loses its extra bytes on emit; and
419
+ `serializeAstmRecords(msg, d)` does not validate a caller-supplied `d`, so a malformed set (a
420
+ multi-char delimiter, an empty escape, a `field`/`escape` collision) emits a stream this library's
421
+ own parser then rejects or mis-reads, with no typed error.
422
+
423
+ - **The type documentation shipped in `dist/index.d.ts` no longer understates the library.** The
424
+ entry-point module documentation ended by calling serialize and build deferred, on a tree that exports
425
+ `serializeAstmRecords`, `buildAstmMessage`, `composeAstmFrames` and `serializeFramedAstm`, and the
426
+ record-types module documentation described only `H`/`P`/`O`/`R`/`L` as modeled, naming
427
+ result-flag/status semantics, comment, query, `M`/`S`, framing and serialization as still to come,
428
+ when all of them are modeled. Both
429
+ blocks are carried verbatim in `dist/index.d.ts` and `dist/index.d.cts`, which is what an installer
430
+ receives. (They are module-level blocks that bind to no exported symbol, so they sit in the
431
+ declaration text rather than in any symbol's editor tooltip.) Every affected block now describes
432
+ what the code does. 18 source files changed; no runtime behaviour, export, type or warning code is
433
+ affected.
434
+ - **The fatal-error taxonomy is now accurate about what `err.code` can hold.** `FATAL_CODES`
435
+ documented itself as later growing the frame codec's own `ASTM_FRAME_*` fatals. `FatalCode` is a
436
+ closed three-value union and the frame codec does not widen it: it reuses `EMPTY_INPUT` for an
437
+ empty stream, and its own thrown errors are separate types with their own discriminants
438
+ (`AstmFrameEncodeError` carries `ASTM_FRAME_EMPTY_RECORD`; `AstmFrameStrictError` carries the
439
+ rejected warnings rather than a `code`). Narrowing an `AstmParseError` on `code` will only ever
440
+ see one of the three.
441
+ - **The serializer's round-trip note now names the cases that do not round-trip.** It claimed
442
+ serialization emits from the decoded component tree. Three record types do not: `H`, `M` and `S`
443
+ are emitted from their preserved `rawLine`, so an edit to their modeled `fields` is silently not
444
+ reflected on emit -- editing a header field and re-serializing keeps the original value, with no
445
+ warning. `M`/`S` additionally keep whatever delimiters they arrived with, so a non-canonical
446
+ `M`/`S` row does not come back as separate fields. Normalization to `H|\^&` is also skipped when a
447
+ delimiter set is passed explicitly. All of it is now stated next to the round-trip claim it
448
+ bounds, and `serializeAstmRecords` gained the `@param d` its siblings already had.
449
+
14
450
  - **The published documentation sidebar now follows the shared section order.** The shipped
15
451
  `docs-content/sidebars.json` carried a category named "About" holding the "what it does and does not
16
452
  do" page. That label is not one of the sections the documentation site recognises (Overview,
@@ -79,12 +515,12 @@ ladder (`0.0.x` until first alpha).
79
515
  record stream and pull result value + units + flag in one line.
80
516
  - `parseAstmRecords(raw, opts?)` → an immutable, deeply-frozen `AstmMessage`; `results(msg)` /
81
517
  `patient(msg)` typed extractors.
82
- - **Delimiter self-declaration** — the four delimiters (field / repeat / component / escape) are
518
+ - **Delimiter self-declaration**: the four delimiters (field / repeat / component / escape) are
83
519
  read from each `H` record, never hardcoded, with ASTM's `\`=repeat and `&`=escape mapping.
84
- - **Escape codec** — `&F&`/`&S&`/`&R&`/`&E&` are decoded via escape-aware split-then-decode, so a
520
+ - **Escape codec**: `&F&`/`&S&`/`&R&`/`&E&` are decoded via escape-aware split-then-decode, so a
85
521
  value containing an escaped component delimiter reads as **one** component (the documented
86
522
  silent-misread class the OSS references exhibit). Re-escaping is deferred to the emit phase (P7).
87
- - Modeled records: `H` (delimiter provenance), `P` (identity — practice-assigned ID and
523
+ - Modeled records: `H` (delimiter provenance), `P` (identity, practice-assigned ID and
88
524
  laboratory-assigned ID kept **distinct**), `O` (accession + Universal Test ID), `R` (all 14
89
525
  fields; value / units / flags / status surfaced **raw**), `L`. Unknown record types surface as
90
526
  `unsupported` records with a warning, never dropped.
@@ -93,10 +529,10 @@ ladder (`0.0.x` until first alpha).
93
529
  recognition (`[OSS-derived]` field order), the deep-freeze base, and the warning/fatal registry.
94
530
  - Fatal codes: `EMPTY_INPUT` (shared), `ASTM_RECORD_NO_HEADER`, `ASTM_RECORD_UNDECLARED_DELIMITERS`.
95
531
  Warning codes: `ASTM_RECORD_UNKNOWN_TYPE`, `ASTM_NONSTANDARD_DELIMITERS`,
96
- `ASTM_UNKNOWN_ESCAPE_SEQUENCE`, `ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT` — all carry stable code +
532
+ `ASTM_UNKNOWN_ESCAPE_SEQUENCE`, `ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT`, all carry stable code +
97
533
  value-free positional context.
98
534
  - **Fail-safe on an unescaped component delimiter in a result value:** the full raw value and the
99
- component split are both surfaced and an `ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT` warning fires — the
535
+ component split are both surfaced and an `ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT` warning fires, the
100
536
  primary `value` is never silently truncated to the first component.
101
537
  - `scripts/phi-scan.ts` extended toward the P-record loci (name + DOB, delimiter-aware); synthetic
102
538
  fixtures declared in `scripts/phi-allow-list.txt`.
@@ -104,109 +540,109 @@ ladder (`0.0.x` until first alpha).
104
540
  `AstmParseError`, `AstmStrictError`, the record/value model types, and the `WARNING_CODES` /
105
541
  `FATAL_CODES` registries.
106
542
  - **Safety-critical result semantics (ASTM-2, roadmap Phase 2).** The raw `R`-record letters that
107
- Phase 1 surfaced are now modeled into fail-safe semantics, under one rule — _never a confident wrong
543
+ Phase 1 surfaced are now modeled into fail-safe semantics, under one rule: _never a confident wrong
108
544
  value_. The raw strings (`abnormalFlags`, `resultStatus`, `referenceRange`, `units`) still coexist
109
545
  with the modeled views; nothing is collapsed or reconciled.
110
546
  - **Abnormal flags (field 7) → HL7 Table 0078.** `interpretAbnormalFlag()` and the `flag` field on
111
547
  `ResultRecord` model the full value set: `L`/`H`, panic `LL`/`HH`, off-scale `<`/`>`, `N`, `A`/`AA`,
112
- the **directional** significant-change `U` (up) / `D` (down) — _not_ units/delta — `B`/`W`, and
548
+ the **directional** significant-change `U` (up) / `D` (down), _not_ units/delta, `B`/`W`, and
113
549
  microbiology `S`/`R`/`I`. An **unrecognized** flag is surfaced as `meaning: "undefined"` with an
114
- `ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG` warning — **never dropped, never coerced to `normal`**.
550
+ `ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG` warning, **never dropped, never coerced to `normal`**.
115
551
  - **Result status (field 9).** `interpretResultStatus()` and the always-present `status` field model
116
552
  `F`/`C`/`P`/`R`/`S`/`I`/`X`, with **`C` correction** (`supersedes: true`) and **`X` cancel**
117
- (`cancelled: true`) so a superseded/cancelled result can **never** read as current — `isActiveFinal`
553
+ (`cancelled: true`) so a superseded/cancelled result can **never** read as current, `isActiveFinal`
118
554
  is `true` only for a plain `F`. An **absent** status is typed `unspecified` (never assumed `final`);
119
555
  an unrecognized one is `undefined` + `ASTM_RECORD_UNDEFINED_RESULT_STATUS`.
120
556
  - **Reference range (field 6).** `parseReferenceRange()` and the `range` field parse `low-high`
121
557
  (closed), `<high` (open-low), and `>low` (open-high); bounds are surfaced as **verbatim numeric
122
558
  text** (never coerced to floats). The range is read from the **full field text**, so a
123
- component-delimited value (`low^high`) is preserved verbatim and read as `unparsed` — never
559
+ component-delimited value (`low^high`) is preserved verbatim and read as `unparsed`: never
124
560
  truncated to a single bound. An unparseable range is `kind: "unparsed"` +
125
- `ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE` — **no bound is fabricated**. The exact delimiter is
561
+ `ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE`, **no bound is fabricated**. The exact delimiter is
126
562
  `[OSS-derived]` pending the purchased CLSI LIS02-A2 (roadmap §10 Q1).
127
563
  - **Units discipline (field 5).** A _numeric_ result value with no units raises
128
564
  `ASTM_RECORD_UNITS_ABSENT`; units are vendor free text (not UCUM) and are **never defaulted,
129
565
  guessed, or converted**.
130
566
  - New warning codes (registry extended, snapshot locked): `ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG`,
131
567
  `ASTM_RECORD_UNDEFINED_RESULT_STATUS`, `ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE`,
132
- `ASTM_RECORD_UNITS_ABSENT` — all value-free (code + record/field index only).
568
+ `ASTM_RECORD_UNITS_ABSENT`, all value-free (code + record/field index only).
133
569
  - **Patient/order identity depth, comments, and partial-timestamp hardening (ASTM-3, roadmap Phase 3).**
134
570
  The misfiling-prevention slice: model the identity that a result files against, and the context that
135
571
  qualifies it.
136
572
  - **Full patient (`P`) identity.** The **practice-assigned ID (field 3)**, the **laboratory-assigned
137
573
  ID (field 4)**, and a **third patient ID (field 5)** are modeled as **distinct** fields that never
138
- collapse into one — conflating them is the primary result-misfiling path. Adds mother's maiden name
574
+ collapse into one: conflating them is the primary result-misfiling path. Adds mother's maiden name
139
575
  (field 7) alongside the existing name components (field 6), birthdate (field 8), and sex (field 9).
140
576
  - **Full order (`O`).** `priority` (field 6), `actionCode` (field ~12), and `reportType` (field ~26)
141
577
  are surfaced **verbatim** on top of the existing specimen/accession + Universal Test ID. The `~`
142
- field indices and the code sets are `[OSS-derived]` (paywalled) — never mapped to a guessed meaning.
143
- - **The `C` (comment) record.** Modeled as `source` (field 3), `text` (field 4, component-capable —
578
+ field indices and the code sets are `[OSS-derived]` (paywalled): never mapped to a guessed meaning.
579
+ - **The `C` (comment) record.** Modeled as `source` (field 3), `text` (field 4, component-capable,
144
580
  the full text is surfaced plus the component split, never truncated), and `commentType` (field 5).
145
581
  Each comment is **attached by position** to the immediately-preceding `H`/`P`/`O`/`R` parent
146
582
  (`parentIndex`); consecutive comments share that parent. **Fail-safe:** an **orphan** comment with no
147
583
  valid parent is attached to the message root (`attachedToRoot: true`) with an
148
- `ASTM_RECORD_ORPHAN_COMMENT` warning — **never silently dropped**. New extractors `comments(msg)` /
584
+ `ASTM_RECORD_ORPHAN_COMMENT` warning, **never silently dropped**. New extractors `comments(msg)` /
149
585
  `commentsFor(msg, record)` / `orders(msg)`, and the pure `attachComments()` attachment pass.
150
586
  - **Comment-type codes are `[OSS-derived]`.** `I` (instrument) is the only value seen in the
151
587
  permissively-licensed real transcripts; `G`/`T`/`P` are defined only in the paywalled CLSI LIS02-A2
152
- and are **not** interpreted — `commentType` is surfaced raw, never mapped to a guessed meaning.
588
+ and are **not** interpreted, `commentType` is surfaced raw, never mapped to a guessed meaning.
153
589
  - **Partial-timestamp hardening.** A `YYYYMMDDHHMMSS` value with an odd digit run that truncates a
154
590
  two-digit component (lengths 5/7/9/11/13) sets `AstmDate.truncated`, is preserved verbatim in `raw`,
155
- and stops at the last **complete** component — the dangling digit is **never zero-filled into a
591
+ and stops at the last **complete** component: the dangling digit is **never zero-filled into a
156
592
  fabricated time**. A caller surfaces this as a value-free `ASTM_RECORD_PARTIAL_TIMESTAMP` warning
157
- (P field 8, R fields 12/13). No timezone is modeled — times stay instrument-local, never assumed UTC.
593
+ (P field 8, R fields 12/13). No timezone is modeled: times stay instrument-local, never assumed UTC.
158
594
  - New warning codes (registry extended, snapshot locked): `ASTM_RECORD_ORPHAN_COMMENT`,
159
- `ASTM_RECORD_PARTIAL_TIMESTAMP` — value-free (code + record/field index only).
595
+ `ASTM_RECORD_PARTIAL_TIMESTAMP`, value-free (code + record/field index only).
160
596
  - `scripts/phi-scan.ts` extended toward the mother's-maiden locus (P field 7), on top of the existing
161
597
  name (field 6) + DOB (field 8) detection; synthetic fixtures declared in `scripts/phi-allow-list.txt`.
162
598
  - **Query (`Q`) + host-query flow + `M`/`S` surfaced verbatim (ASTM-4, roadmap Phase 4).** Completes the
163
- record grammar — **the record-content layer is now feature-complete.**
599
+ record grammar: **the record-content layer is now feature-complete.**
164
600
  - **The `Q` (Request Information) record.** Modeled at the public ASTM E1394 field positions:
165
601
  `startingRangeId` (field 3) and `endingRangeId` (field 4) surfaced as the **full verbatim field**
166
602
  (never truncated to a component), the Universal Test ID (field 5, same caret structure as `O`/`R`),
167
603
  and `requestInformationStatus` (field 13) surfaced **verbatim**. The range component structure, the
168
604
  `ALL` universal-query keyword (`queriesAllTests`), and the request-information status code set are
169
- all **`[OSS-derived / paywalled]`** (roadmap §10 Q3) — surfaced, flagged, and **never interpreted or
605
+ all **`[OSS-derived / paywalled]`** (roadmap §10 Q3): surfaced, flagged, and **never interpreted or
170
606
  guessed**. New `query(msg)` extractor.
171
607
  - **The host-query flow.** Every message is classified up front (`msg.classification`): an `H/P/Q/L`
172
608
  **request** is `host-query`, an `R`-bearing message is `results`, an `O`-only message is `orders`,
173
- else `indeterminate`. **Fail-safe:** the `Q` **dominates** — a `Q`-bearing message is a request and
609
+ else `indeterminate`. **Fail-safe:** the `Q` **dominates**, a `Q`-bearing message is a request and
174
610
  is **never** read as a result set, even when a result record is also present (a contradiction flagged
175
611
  with `ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND`). Gate on `classification.isHostQueryRequest`. Pure
176
612
  `classifyMessage(records)` exported.
177
613
  - **`M` (manufacturer) + `S` (scientific) records surfaced verbatim.** Vendor-defined free-form
178
614
  QC / calibration / maintenance data, preserved byte-for-byte on `record.rawLine` and **never**
179
- interpreted into typed clinical fields — a QC value can never be read as a patient result. Round-trip
615
+ interpreted into typed clinical fields: a QC value can never be read as a patient result. Round-trip
180
616
  byte-identical.
181
617
  - New warning codes (registry extended, snapshot locked): `ASTM_RECORD_UNINTERPRETED_QUERY_STATUS`
182
618
  (a Q request-information status surfaced verbatim; the code set is paywalled, so it is passed through
183
- uninterpreted) and `ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND` — both value-free (code + position only).
619
+ uninterpreted) and `ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND`, both value-free (code + position only).
184
620
  - `AstmMessage` gains a `classification` field; `AstmRecord` gains `QueryRecord` / `ManufacturerRecord`
185
621
  / `ScientificRecord` members (an unknown type letter is still an `UnsupportedRecord`, never dropped).
186
- - **E1381/CLSI-LIS01 frame codec (ASTM-5, roadmap Phase 5).** The **low-level framing layer** begins —
622
+ - **E1381/CLSI-LIS01 frame codec (ASTM-5, roadmap Phase 5).** The **low-level framing layer** begins:
187
623
  a separate, independent layer from the record layer, sharing only the payload boundary. `src/frames/`
188
624
  decodes a framed byte stream into frames + reassembled record bytes; `src/common/` and `src/records/`
189
625
  are untouched.
190
626
  - `decodeAstmFrames(bytes, opts?)` → `{ records: readonly Uint8Array[]; frames: readonly AstmFrame[];
191
627
  warnings: readonly AstmFrameWarning[] }`. A frame is `<STX> FN text <ETB|ETX> CS <CR><LF>`.
192
628
  - **Modulo-256 checksum** over the bytes after `STX` up to and **including** the `ETB`/`ETX`
193
- terminator, two hex chars — **verified on decode, emitted uppercase, accepted lowercase** (a real
629
+ terminator, two hex chars: **verified on decode, emitted uppercase, accepted lowercase** (a real
194
630
  vendor quirk). `computeChecksum` / `toChecksumHex` / `parseChecksumHex` exported.
195
631
  - **Frame-number `0`–`7` sequencing** (rolls over `7 → 0 → 1`, starts at `1`) and **multi-frame record
196
- reassembly** — text is capped at **240 bytes** (the seven control bytes are **not** counted), `ETB`
632
+ reassembly**: text is capped at **240 bytes** (the seven control bytes are **not** counted), `ETB`
197
633
  is intermediate / `ETX` final. `parseFramedAstm(bytes, opts?)` composes the framing and record layers
198
634
  at the edge (decode → reassemble trusted records → `parseAstmRecords`).
199
635
  - **Fail-safe (byte-level, safety-critical):** a **checksum mismatch** surfaces the frame flagged
200
- `trusted: false` and **never merges** it into a record (default warn in lenient / thrown in strict —
636
+ `trusted: false` and **never merges** it into a record (default warn in lenient / thrown in strict,
201
637
  the "checksums are routinely not validated" claim was _refuted_: we validate); a **frame-number gap**
202
638
  warns and is **never silently bridged**; an **unterminated** frame surfaces the partial bytes
203
639
  untrusted and **invents no partial record**; an **oversize** (>240) frame is flagged, never dropped.
204
640
  - New `ASTM_FRAME_*` warning registry (a **second** registry alongside `ASTM_RECORD_*`, sharing only
205
641
  the `EMPTY_INPUT` fatal; snapshot locked): `ASTM_FRAME_BAD_CHECKSUM`, `ASTM_FRAME_SEQUENCE_GAP`,
206
- `ASTM_FRAME_UNTERMINATED`, `ASTM_FRAME_OVERSIZE` — every warning **value-free**, carrying a **frame
642
+ `ASTM_FRAME_UNTERMINATED`, `ASTM_FRAME_OVERSIZE`, every warning **value-free**, carrying a **frame
207
643
  number + byte offset** only, never the record bytes a frame holds. `{ strict: true }` throws
208
644
  `AstmFrameStrictError`.
209
- - **Fuzz gate (required, part of `verify`):** a `fast-check` target over the codec — arbitrary /
645
+ - **Fuzz gate (required, part of `verify`):** a `fast-check` target over the codec, arbitrary /
210
646
  truncated / mixed / control-char-laden bytes never crash, hang, or OOM; they degrade to a typed
211
647
  error or a value-free warning. Plus property tests: N-frame reassembly equals the single-frame form,
212
648
  and every trusted frame's recomputed checksum matches its declared value.
@@ -214,13 +650,13 @@ warnings: readonly AstmFrameWarning[] }`. A frame is `<STX> FN text <ETB|ETX> CS
214
650
  `DecodeAstmFramesResult`, `FramedAstmResult`, `AstmFramePosition`, `AstmFrameWarning`,
215
651
  `FrameWarningCode`, `FRAME_WARNING_CODES`.
216
652
  - **Transport variants + pure LTP protocol reducer (ASTM-6, roadmap Phase 6).** The **LTP protocol
217
- layer** — `src/ltp/` — sits above the frame codec: transport auto-detection plus a deterministic,
653
+ layer**, `src/ltp/`, sits above the frame codec: transport auto-detection plus a deterministic,
218
654
  socket-free session state machine. No live I/O: the consumer owns the wire and clock; this layer
219
655
  decides.
220
656
  - **Transport auto-detection.** `detectFraming(bytes, opts?)` → `{ framing: "framed" | "raw";
221
657
  defaulted: boolean; warnings }`. A leading `STX`/`ENQ` ⇒ **framed** (serial, and the cobas 4800 /
222
658
  iNTERFACEWARE Iguana framed-over-TCP reality); a leading bare record letter (`H`/`P`/`O`/`R`/`C`/
223
- `Q`/`M`/`S`/`L`) ⇒ **raw** (the cobas b121 raw-TCP reality — framing dropped, records streamed
659
+ `Q`/`M`/`S`/`L`) ⇒ **raw** (the cobas b121 raw-TCP reality, framing dropped, records streamed
224
660
  directly). An unrecognizable lead **defaults to framed and warns**
225
661
  (`ASTM_LTP_AMBIGUOUS_TRANSPORT`), never guessing silently into data loss; an `override` forces the
226
662
  mode (the Phase-8 profile hook).
@@ -229,8 +665,8 @@ defaulted: boolean; warnings }`. A leading `STX`/`ENQ` ⇒ **framed** (serial, a
229
665
  codec-decoded `frame`; actions are `sendAck` / `sendNak` / `sendEot` / `deliverRecord`. It models
230
666
  the LIS01-A2 establishment → transfer → termination phases as `neutral ⇄ transfer`, reassembling
231
667
  `ETB…ETX` runs into delivered records and tracking the `0`–`7` frame sequence.
232
- - **ACK-failsafe (safety-critical, borrowed from `mllp`).** A frame the codec did not vouch for — a
233
- **bad checksum**, an **unterminated** frame, or one **out of sequence** — is answered with `NAK`,
668
+ - **ACK-failsafe (safety-critical, borrowed from `mllp`).** A frame the codec did not vouch for (a
669
+ **bad checksum**, an **unterminated** frame, or one **out of sequence**) is answered with `NAK`,
234
670
  **never** a fabricated positive `ACK`, and its bytes are **never** appended to a record or
235
671
  delivered. A `NAK` drives **retransmit, not acceptance** (`ASTM_LTP_FRAME_REJECTED`). A duplicate
236
672
  of the last-accepted frame is idempotently re-`ACK`ed without re-appending; a partial record open
@@ -245,33 +681,33 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
245
681
  `ltpInitialState`, `ltpReduce`, `LtpPhase`, `LtpState`, `LtpEvent`, `LtpAction`, `LtpTransition`,
246
682
  `AstmLtpWarning`, `LtpWarningCode`, `LTP_WARNING_CODES`, `ltpAmbiguousTransport`,
247
683
  `ltpUnexpectedEvent`, `ltpFrameRejected`.
248
- - **Spec-clean serializers + builders — both layers (ASTM-7, roadmap Phase 7).** The **emit** side: the
684
+ - **Spec-clean serializers + builders, both layers (ASTM-7, roadmap Phase 7).** The **emit** side: the
249
685
  conservative inverse of the parser and the frame codec, so **round-trip fidelity holds by
250
- construction**. Postel's Law's second half — liberal on parse, strict on emit.
686
+ construction**. Postel's Law's second half: liberal on parse, strict on emit.
251
687
  - **Record serializer.** `serializeAstmRecords(msg | records)` and `serializeAstmRecord(record)` emit a
252
688
  `CR`-terminated stream with the **canonical** `H|\^&` delimiters and every embedded delimiter
253
- re-escaped. `encodeComponent()` is the exact inverse of the Phase-1 escape codec — the escape char is
254
- encoded **first** (`&` → `&E&`), then the field / component / repeat delimiters (`&F&`/`&S&`/`&R&`) —
689
+ re-escaped. `encodeComponent()` is the exact inverse of the Phase-1 escape codec. The escape char is
690
+ encoded **first** (`&` → `&E&`), then the field / component / repeat delimiters (`&F&`/`&S&`/`&R&`),
255
691
  so a value containing a delimiter (a titre `1^40` → `1&S&40`) can never break framing and reads back
256
692
  as **one** component. A source parsed with **non-canonical** delimiters is **normalized** to the
257
693
  canonical set on emit (vendor-delimiter round-tripping is a Phase-8 profile concern). The header's
258
694
  delimiter declaration is emitted **literally** (never escaped); `M`/`S` records are re-emitted
259
695
  **byte-identically** from `rawLine`.
260
696
  - **Message builder.** `buildAstmMessage(input)` constructs a spec-clean stream from typed input under
261
- the **never-fabricate** discipline: it emits **only** the values the caller supplied — an omitted
697
+ the **never-fabricate** discipline: it emits **only** the values the caller supplied, an omitted
262
698
  field is left empty, **never a defaulted clinical value** (an unset result status reads back as
263
699
  `unspecified`, never `final`; units / abnormal flags / patient IDs are never defaulted). The
264
- **structure** — record type letters, the canonical delimiter declaration, per-record-type sequence
265
- counters, the `L` terminator — is **computed, not guessed** (a sequence number may be overridden).
700
+ **structure** (record type letters, the canonical delimiter declaration, per-record-type sequence
701
+ counters, the `L` terminator) is **computed, not guessed** (a sequence number may be overridden).
266
702
  - **Frame encoder.** `composeAstmFrames(records, opts?)` is the exact inverse of `decodeAstmFrames`:
267
703
  it wraps reassembled record bytes into `<STX> FN text <ETB|ETX> CS <CR><LF>` frames with the
268
704
  modulo-256 **checksum** and the `0`–`7` **frame number** **computed** (never accepted-as-given or
269
705
  faked; emitted uppercase), numbered continuously across the stream (start `1`, roll over `7 → 0`),
270
706
  and every record over **240** text bytes **split** `ETB…ETX` (the seven control bytes never counted).
271
- `serializeFramedAstm(msg | records)` composes both emit layers at the edge — the mirror of
707
+ `serializeFramedAstm(msg | records)` composes both emit layers at the edge: the mirror of
272
708
  `parseFramedAstm`.
273
- - **Framing-integrity guards (typed errors, conservative emit).** A value carrying a `CR`/`LF` — which
274
- no ASTM escape can encode — is refused with an `AstmSerializeError` (`ASTM_EMIT_UNENCODABLE_VALUE`)
709
+ - **Framing-integrity guards (typed errors, conservative emit).** A value carrying a `CR`/`LF`, which
710
+ no ASTM escape can encode, is refused with an `AstmSerializeError` (`ASTM_EMIT_UNENCODABLE_VALUE`)
275
711
  rather than emitted into a corrupted wire; an empty record or empty record list is an
276
712
  `AstmFrameEncodeError` (`ASTM_FRAME_EMPTY_RECORD`), never an empty frame.
277
713
  - **Round-trip proven.** The shared archetype `roundTripProperty` is now **live** (serialize is the
@@ -285,23 +721,23 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
285
721
  `MessageInput`, `HeaderInput`, `PatientInput`, `PatientNameInput`, `OrderInput`, `ResultInput`,
286
722
  `CommentInput`, `QueryInput`, `VerbatimInput`), `composeAstmFrames`, `AstmFrameEncodeError`,
287
723
  `ComposeFramesOptions`, `serializeFramedAstm`.
288
- - **Vendor profile system — engine + registry + quirk tolerance + definition-time safety gate
724
+ - **Vendor profile system, engine + registry + quirk tolerance + definition-time safety gate
289
725
  (ASTM-8, roadmap Phase 8).** `src/profiles/` mirrors the sibling `@cosyte/hl7` `defineProfile` /
290
726
  `@cosyte/ccda` `defineCcdaProfile` shape: `name` / `lineage` / `describe()` / `extends`-merge, a
291
727
  provenance-backed built-in registry, a runtime tolerance transform, and a definition-time safety gate.
292
728
  - `defineAstmProfile(opts)` builds a frozen, immutable profile declaring the **non-safety-critical**
293
729
  warning codes a class of streams is expected to trip (each with a grounded `rationale`), plus an
294
- optional `transport` override (`"framed"`/`"raw"`) — the raw-vs-framed-TCP knob a consumer feeds to
730
+ optional `transport` override (`"framed"`/`"raw"`): the raw-vs-framed-TCP knob a consumer feeds to
295
731
  `detectFraming(bytes, { override })` for a stream whose leading byte would auto-detect the wrong way.
296
732
  - **A profile never alters an extracted value.** The transform (`applyAstmProfileToWarnings`, run last
297
733
  in `parseAstmRecords`) only ever re-badges a warning it _expects_ to the new `PROFILE_QUIRK_APPLIED`
298
- code (flagged `expected: true`, carrying the original `toleratedCode` and position) — Postel's Law
734
+ code (flagged `expected: true`, carrying the original `toleratedCode` and position), Postel's Law
299
735
  with a receipt: nothing is dropped, and a spec-clean message parses byte-identically with or without
300
736
  a profile.
301
737
  - **The safety gate is default-deny and total.** Only four benign, value-preserving record codes are
302
738
  tolerable (`ASTM_RECORD_UNKNOWN_TYPE`, `ASTM_NONSTANDARD_DELIMITERS`, `ASTM_UNKNOWN_ESCAPE_SEQUENCE`,
303
- `ASTM_RECORD_UNINTERPRETED_QUERY_STATUS`); **every other code across all three registries — record,
304
- frame (`ASTM_FRAME_*`), and LTP (`ASTM_LTP_*`) — is safety-critical and refused at definition time**
739
+ `ASTM_RECORD_UNINTERPRETED_QUERY_STATUS`); **every other code across all three registries (record,
740
+ frame `ASTM_FRAME_*`, and LTP `ASTM_LTP_*`) is safety-critical and refused at definition time**
305
741
  with an `AstmProfileDefinitionError`. A profile therefore can never make a bad checksum "ok," a
306
742
  cancelled result read "final," or quiet a wrong value / flag / status / range / units / patient or
307
743
  comment context / message-kind ambiguity. Any warning code added in a future phase is
@@ -309,7 +745,7 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
309
745
  - `parseAstmRecords(raw, { profile })` accepts an explicit profile; `{ profile: null }` opts out of
310
746
  the process-scoped default (`setDefaultAstmProfile`); an `expected` quirk does **not** escalate in
311
747
  `strict` mode. `AstmMessage` gains an additive `profile?: { name, lineage }` attribution.
312
- - **Built-ins:** `astmProfiles.default` (tolerates nothing) + `astmProfiles.referenceCorpus` — a
748
+ - **Built-ins:** `astmProfiles.default` (tolerates nothing) + `astmProfiles.referenceCorpus`, a
313
749
  **non-vendor**, evidence-backed profile grounded firsthand in the redistributable OSS reference
314
750
  corpus (`kxepal/python-astm` `codec.py` (BSD) + `senaite.astm`, which split on raw delimiters and
315
751
  never un-escape `&F&`/`&S&`/`&R&`/`&E&`), tolerating only the resulting non-standard-escape
@@ -322,49 +758,49 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
322
758
  `resolveProfileTransport`, `profileQuirkApplied`, `SAFETY_CRITICAL_CODES`, `TOLERABLE_CODES`,
323
759
  `ALL_ASTM_WARNING_CODES`, `isSafetyCriticalCode`, and the `AstmProfile`, `DefineAstmProfileOptions`,
324
760
  `AstmQuirkTolerance`, `AstmQuirkMatch`, `AstmProfileProvenance`, `AnyAstmWarningCode` types.
325
- - **LIVD-aware LOINC recognition — bring-your-own catalog, zero bundled terminology data (ASTM-9,
761
+ - **LIVD-aware LOINC recognition: bring-your-own catalog, zero bundled terminology data (ASTM-9,
326
762
  roadmap Phase 9).** The `src/terminology/` layer maps an analyzer's local test code (the Universal
327
763
  Test ID's vendor/local code on `R`/`O` records) to a standard LOINC via a **consumer-supplied** IICC
328
- LIVD ("LOINC to Vendor IVD") catalog — **additive, advisory, and never a guessed LOINC** (a wrong
764
+ LIVD ("LOINC to Vendor IVD") catalog: **additive, advisory, and never a guessed LOINC** (a wrong
329
765
  LOINC mis-identifies a test).
330
766
  - `defineLivdCatalog(entries)` builds an immutable, frozen catalog indexed by the **Vendor Analyte
331
767
  Code** (the vendor transmission code the instrument sends), grounded firsthand on the IICC LIVD
332
768
  digital format / HL7 LIVD IG; `catalog.lookup(code)` returns `mapped` (one LOINC), `unmapped` (a
333
- miss), or `ambiguous` (a code matching more than one distinct LOINC — surfaced, **never resolved**).
769
+ miss), or `ambiguous` (a code matching more than one distinct LOINC, surfaced, **never resolved**).
334
770
  - `applyLivd(msg, catalog)` produces a **separate** layer of per-`R`/`O` `LivdAnnotation`s and never
335
771
  mutates, alters, or drops the raw reported code/value; a catalog hit is labeled `derived: true`
336
772
  (`source: "livd"`), an inline LOINC already on the wire is surfaced `source: "wire"` (never
337
773
  overwritten by the catalog), and a miss/conflict is `unmapped`/`ambiguous` with a **value-free**
338
- warning — a LOINC is **never** fabricated. `lookupLivdForRecord(record, catalog)` annotates one
774
+ warning, a LOINC is **never** fabricated. `lookupLivdForRecord(record, catalog)` annotates one
339
775
  record.
340
- - **No LOINC / SNOMED / LIVD data is bundled** (roadmap §5). Firsthand: LOINC is © Regenstrief —
776
+ - **No LOINC / SNOMED / LIVD data is bundled** (roadmap §5). Firsthand: LOINC is © Regenstrief,
341
777
  redistributable only _with its attribution notice_, not public-domain; and the public CDC LIVD file
342
778
  is a **SARS-CoV-2-specific** publication that also carries separately-licensed SNOMED CT, not a
343
779
  general-analyte, public-domain catalog. The package stays a structural recognizer, not a dictionary:
344
780
  the consumer supplies the LIVD data (and owns its license obligations).
345
- - New `ASTM_LIVD_*` warning registry (`ASTM_LIVD_UNMAPPED_CODE`, `ASTM_LIVD_AMBIGUOUS_MAPPING`) — a
781
+ - New `ASTM_LIVD_*` warning registry (`ASTM_LIVD_UNMAPPED_CODE`, `ASTM_LIVD_AMBIGUOUS_MAPPING`): a
346
782
  fourth, self-contained registry, deliberately outside the profile safety gate's universe (a LIVD
347
783
  non-mapping is a post-parse advisory, not a parse-time deviation a profile could tolerate). New
348
784
  exports: `defineLivdCatalog`, `applyLivd`, `lookupLivdForRecord`, `LIVD_WARNING_CODES`,
349
785
  `livdUnmappedCode`, `livdAmbiguousMapping`, and the `LivdCatalog`, `LivdEntry`, `LivdLookup`,
350
786
  `LivdAnnotation`, `LivdMapping`, `LivdResult`, `AstmLivdWarning`, `LivdWarningCode` types.
351
- - **Release hardening (ASTM-10, roadmap Phase 10 — the final phase).** Publish-readiness for the now
787
+ - **Release hardening (ASTM-10, roadmap Phase 10, the final phase).** Publish-readiness for the now
352
788
  feature-complete parser: coverage, fuzz, firsthand differential testing, the full docs spine, and a
353
789
  proven release shape. No new runtime API.
354
790
  - **Differential conformance vs [python-astm][pa]** (BSD-3-Clause reference codec, commit
355
791
  `4170ce0c`), grounded **firsthand** in `test/differential/`: outputs captured once from the
356
792
  reference (`generate-reference-vectors.py` → `reference-vectors.json`; **no reference code
357
- vendored**), then asserted against `@cosyte/astm` on three shared paths — the **modulo-256
793
+ vendored**), then asserted against `@cosyte/astm` on three shared paths, the **modulo-256
358
794
  checksum**, the **record field/component split** (escape-free, non-header), and a
359
795
  **cross-implementation frame decode** (python encodes + splits → our decoder verifies every
360
796
  checksum and reassembles the exact record bytes). The **deliberate divergences** are asserted on
361
797
  purpose: we un-escape `&F&`/`&S&`/`&R&`/`&E&` (python leaves them literal), we validate the frame
362
798
  checksum (python does not verify on decode), and we classify the `Q` host-query (python has no
363
- model). CI needs no Python — only the captured JSON.
364
- - **Per-directory ≥ 90 coverage gating extended to the whole `src/` surface** — `frames`, `ltp`,
799
+ model). CI needs no Python: only the captured JSON.
800
+ - **Per-directory ≥ 90 coverage gating extended to the whole `src/` surface**: `frames`, `ltp`,
365
801
  and `terminology` now gate per-dir alongside `common`/`records`/`profiles` (on top of the global
366
802
  gate), so the release bar holds directory by directory, not just in aggregate.
367
- - **Record-tokenizer fuzz** (`test/property/records-fuzz.property.test.ts`) — the companion to the
803
+ - **Record-tokenizer fuzz** (`test/property/records-fuzz.property.test.ts`), the companion to the
368
804
  frame-codec fuzz: arbitrary / truncated / delimiter- and escape-laden input into
369
805
  `parseAstmRecords` never crashes, hangs, or OOMs; lenient mode only ever throws a sanctioned
370
806
  Tier-3 fatal, strict only `AstmStrictError`, and every warning carries a registered code. Both
@@ -373,9 +809,9 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
373
809
  - **Publish dry-run proven release-shaped:** `attw` all-green (per-condition ESM/CJS types), a new
374
810
  `smoke` gate (`scripts/smoke.mjs`) that imports the **built** ESM and requires the **built** CJS
375
811
  entry and parses a result through each (now wired into `verify.sh`), and an `npm publish
376
- --dry-run` pack inspection (10 files — `dist/` + `README`/`LICENSE`/`CHANGELOG`/`package.json`,
812
+ --dry-run` pack inspection (10 files, `dist/` + `README`/`LICENSE`/`CHANGELOG`/`package.json`,
377
813
  no `src` or tests). Zero runtime dependencies; MIT.
378
- - **Full Diátaxis docs spine + honesty docs.** New `docs-content/limitations.md` (**What it does —
814
+ - **Full Diátaxis docs spine + honesty docs.** New `docs-content/limitations.md` (**What it does,
379
815
  and does not do**: no live I/O, units are verbatim free text not UCUM, no bundled terminology
380
816
  dictionary, `M`/`S` verbatim, the archived-standard status, and the MIT-vs-CLSI license posture)
381
817
  and `docs-content/architecture.md` (the two independent layers and their payload boundary); the
@@ -393,12 +829,12 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
393
829
 
394
830
  ### Deferred (later phases)
395
831
 
396
- - **Named per-vendor profiles** (cobas / Sysmex / ADVIA / Mindray / Snibe) stay `REAL-CORPUS`-gated —
832
+ - **Named per-vendor profiles** (cobas / Sysmex / ADVIA / Mindray / Snibe) stay `REAL-CORPUS`-gated:
397
833
  the Phase-8 engine supports them (tolerate + transport override), but no public vendor-attributed
398
834
  quirk document grounds a named one. **No bundled terminology
399
- dictionary** — LIVD-aware LOINC recognition is bring-your-own by design (Phase 9); the package ships
835
+ dictionary**: LIVD-aware LOINC recognition is bring-your-own by design (Phase 9); the package ships
400
836
  no LOINC / SNOMED / LIVD data and mapping quality is the consumer's catalog. The LTP reducer remains
401
- a pure state machine — no live I/O: wiring it to a real `SerialPort`/`net.Socket` (and the
837
+ a pure state machine, no live I/O: wiring it to a real `SerialPort`/`net.Socket` (and the
402
838
  interactive contention/timeout/retransmit **timing**) is a thin consumer adapter, and the standard's
403
839
  exact numeric timeouts / retry counts are deferred (we model transitions, not timers).
404
840