@cosyte/astm 0.0.3 → 0.0.4

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,411 @@ 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
+ - **BREAKING: `patient()`, `results()`, `orders()`, `comments()`, and `query()` now throw on a
119
+ stream they cannot answer for, instead of answering across patients**
120
+ (`ASTM-PATIENT-RESULT-MISATTRIBUTION`; shipped `0.0.1` through `0.0.3`).
121
+
122
+ **The defect.** These accessors read the whole stream. On a stream carrying more than one
123
+ message, `patient()` answered with the **first** `P` in the stream while `results()` answered
124
+ with **every** `R` in it, so pairing them — which is exactly what this package's one-line north
125
+ star does — attributed one patient's results to another. It needed no delimiter redeclaration
126
+ and no unusual delimiters: an ordinary two-message stream in one canonical set reproduced it,
127
+ with **zero** warnings, and `strict` mode raised no objection either. On an analyzer-to-LIS
128
+ path that is a result filed against the wrong patient. Nothing was mis-_read_ — every `P` and
129
+ every `R` was correct in `records` — but there was no way to ask which belonged together.
130
+
131
+ **The change.** Those five accessors now throw `AstmAmbiguousStreamError` with code
132
+ `ASTM_AMBIGUOUS_MULTI_MESSAGE` when the stream carries more than one message. `patient()` also
133
+ throws `ASTM_AMBIGUOUS_MULTI_PATIENT` when the single message it is reading carries more than
134
+ one `P`, which is the same harm one level down: "the first `P`" is a guess about whose result
135
+ it is. The error carries a stable code, a value-free position, and the two counts; never an
136
+ identifier and never a value.
137
+
138
+ **Why the break is the fix, not a cost of it.** The callers this breaks are exactly the
139
+ population that is being silently corrupted today. A caller who now gets an exception is
140
+ strictly better off than one who quietly received another patient's results: the failure is
141
+ loud, immediate, positioned, and correctable, where before it was invisible at every layer.
142
+ There is no version of this where the old behaviour is safe to keep, and no warning strong
143
+ enough to substitute for refusing to answer, because the wrong answer was already being used.
144
+
145
+ **Which callers are affected, stated exactly.** A stream that is one message carrying at most one
146
+ `P` is **unchanged**, and so is a result-only message with no `P` at all: `patient()` still answers
147
+ `undefined` there, which is an ordinary shape and not an error. But `ASTM_AMBIGUOUS_MULTI_PATIENT`
148
+ **does reach single-message callers** — a lone message carrying several patients used to answer
149
+ with the first of them and now refuses — so "single-message streams are unaffected" would be false
150
+ and is not claimed. That second break is the same wrong-patient guess as the first, one level down,
151
+ and it is disclosed on the README and in the docs for the same reason the first one is.
152
+ `commentsFor()` is unchanged and works on any stream, single- or multi-message, because the parent
153
+ record it is handed already names the message.
154
+
155
+ **Migrating** is mechanical: replace `results(msg)` and `patient(msg)` with a walk over
156
+ `messages(msg)`, reading each message's own `patient` and `results`. Where a caller genuinely
157
+ wants every result in a stream regardless of who they belong to, that is
158
+ `messages(msg).flatMap((m) => m.results)` — written out, so it is a choice rather than a
159
+ default.
160
+
12
161
  ### Fixed
13
162
 
163
+ - **Emit no longer writes a stream it cannot read back** (`ASTM-EMIT-RESIDUALS`; both gaps shipped
164
+ `0.0.1` through `0.0.3`, `PRE-EXISTING`, neither a regression). Two residual emit-side holes, both
165
+ recorded and deliberately deferred by `ASTM-MIXED-DELIMITER-EMIT` (#21) and again by
166
+ `ASTM-SECOND-HEADER-COLLAPSE` (#22) because each turned on a question those slices did not answer.
167
+ Both were reproduced by **executing** the shipped code before anything was changed, and re-verified
168
+ the same way after; neither was half-fixed by the two earlier slices.
169
+
170
+ **1. A delimiter declaration longer than three characters lost its extra bytes.** Three characters
171
+ of a header's declaration carry a role — repeat, component, escape, by position — and the reader
172
+ ignores any beyond them rather than refusing the stream. Emit regenerated the declaration from the
173
+ three roles alone, so a header that arrived as `H|\^&#` went back out as `H|\^&`, with no warning
174
+ and no way for a caller to notice. Measured before the fix: `serializeAstmRecords(msg, msg.delimiters)`
175
+ on `H|\^&#|||SENDER^SYS|||||||P|1` returned a header short of its `#` and a round-trip that was not
176
+ byte-exact.
177
+
178
+ The surplus is now carried through from the modeled declaration field. Of the three available
179
+ dispositions — preserve, refuse, report — **preserve** was chosen. Refusing would reject a stream
180
+ the parser reads without complaint, on a published package, over bytes it has already decided are
181
+ inert. Reporting is not available at all: emit returns a bare string and has no warning channel, so
182
+ a warning could only be ignored while the truncated stream still shipped, which is the same reason
183
+ #21 chose re-encoding. Preserving costs nothing and is the only one of the three that makes the
184
+ round-trip byte-exact. What the surplus _means_ remains unresolved — the clauses that would settle
185
+ it are not in the freely published material and were not read, so no clause is cited here in either
186
+ direction — but carrying bytes through unread is a strictly smaller claim than deleting them, and it
187
+ stays coherent with a reader that now scopes delimiters forward from every header: the re-read
188
+ declaration resolves to the same four roles either way. This lands on the **default** canonical
189
+ path too, not only when a set is passed explicitly — normalizing a message replaces the four
190
+ delimiter roles, and the surplus holds none of them — so a caller who was relying on emit to strip
191
+ those bytes will find it no longer does.
192
+
193
+ The surplus is dropped in exactly the two cases where it could not be read back as surplus — the
194
+ header is being transcoded into a different delimiter set, so the surplus belonged to the
195
+ declaration being replaced; or the surplus is not inert on the wire, meaning it contains the field
196
+ separator or **any control character**. The control rule is deliberately wider than the record
197
+ layer alone needs. A `CR`/`LF` would end the record and shift every data field along, but this text
198
+ also reaches the **frame** layer through `serializeFramedAstm`, where `STX`, `ETX` and `ETB` are
199
+ structural: a surplus carrying one of those truncated the frame body, and re-reading the framed
200
+ stream then dropped the **entire header record** — its sender, its receiver, its control ID —
201
+ behind nothing but an `ASTM_FRAME_BAD_CHECKSUM`. Rather than enumerate the bytes each layer happens
202
+ to reserve, and re-derive that list whenever a layer is added, no control character is carried at
203
+ all. Two things that rule does **not** do, said here rather than left to be found: it is keyed on
204
+ the character while the frame layer's structure is keyed on the low byte (`charCodeAt(i) & 0xff`),
205
+ so a non-control character truncating onto `STX`/`ETX`/`ETB` still breaks framing — loudly, with a
206
+ typed error or an unknown-record-type warning, never silently; and refusing a control character in
207
+ a _surplus_ does not mean one cannot be a _delimiter role_, since only `CR`/`LF` are refused
208
+ there. Those are
209
+ structural losses; dropping inert bytes is not.
210
+
211
+ **2. A caller-supplied delimiter set was never validated.** `serializeAstmRecords(msg, d)` and its
212
+ three siblings took `d` on trust. Measured before the fix, across six malformed sets: a
213
+ multi-character field separator, an empty escape character, a `field`/`escape` collision and a `CR`
214
+ separator each emitted a stream `parseAstmRecords` then **threw** on; a `repeat`/`component`
215
+ collision and a `component`/`escape` collision each emitted one it **re-read with a different field
216
+ tree and zero warnings**. An empty escape additionally garbled the values themselves — `28.6` went
217
+ out as `2E8E.E6`. On the analyzer-to-LIS path a field tree that changes under a re-read is a lost
218
+ result or a lost specimen identifier, and the caller had no signal either way.
219
+
220
+ `d` is now checked before any bytes are written, on all four public emit entry points
221
+ (`serializeAstmRecords`, `serializeAstmRecord`, `serializeField`, `encodeComponent`): each separator
222
+ exactly one character, none a `CR`/`LF`, no two the same character. A failing set is an
223
+ `AstmSerializeError` with the new code `ASTM_EMIT_INVALID_DELIMITERS` — including a set that omits a
224
+ member or holds a non-string, which the types forbid but a JavaScript caller can still pass, and
225
+ which previously surfaced as a raw `TypeError` from inside the serializer.
226
+
227
+ **The three rules are necessary, not sufficient, and the docs say so rather than implying a
228
+ guarantee.** A set can satisfy all three and still emit a stream that reads back wrong: a separator
229
+ equal to a record's type letter (`field` of `R`) makes the type letter itself get escaped away, and
230
+ the record re-reads as unsupported with its result lost. That corruption is unchanged by this slice
231
+ — it reproduces byte-identically before it — and is recorded as a known defect rather than fixed
232
+ here, because the rule that would catch it has to be derived rather than guessed. **A typed error rather than a
233
+ warning, and the house rule from #21 is why**: with no warning channel on a `string` return, refusing
234
+ at the call is the only disposition that reaches the caller at all. The counter-argument was weighed
235
+ and is real — `@cosyte/astm` is published, and refusing rejects input previously accepted. It is
236
+ accepted deliberately, because the input turned away is exactly the input that was being corrupted,
237
+ and because the three rules are not stylistic: each names a case where the emitted bytes provably do
238
+ not read back as the records that produced them. The narrowing has one consequence worth stating
239
+ plainly: the **reader** tolerates a header declaring, say, the same character for `repeat` and
240
+ `component`, so `serializeAstmRecords(msg, msg.delimiters)` on such a message now throws where it
241
+ previously returned bytes that read back wrong. The canonical default is untouched, and so is every
242
+ well-formed set. The error names the role at fault and never echoes the offending characters, since
243
+ a value failing the one-character rule can be arbitrary caller text and the message reaches logs.
244
+
245
+ The invariant both halves are held against is the one #21 and #22 established: **a round-trip never
246
+ silently loses a field, in either direction.** The property is asserted by round-tripping through the
247
+ real parser, not by inspecting the serializer.
248
+
249
+ `AstmSerializeError.code` widens from a single string literal to a two-member union, exported as the
250
+ new `AstmSerializeErrorCode` type. `ASTM_EMIT_UNENCODABLE_VALUE` is unchanged.
251
+
252
+ - **`parseAstmRecords` now reads the delimiters from every `H` record, not only the first, so a
253
+ stream carrying more than one message no longer silently loses fields** (`ASTM-SECOND-HEADER-COLLAPSE`;
254
+ shipped `0.0.1` through `0.0.3`, `PRE-EXISTING`, not a regression). This is the same silent field
255
+ collapse as `ASTM-MIXED-DELIMITER-EMIT` one layer over, on the **parse** side, which is why #21 did
256
+ not narrow it: #21 fixed what we write, and a stream arriving with two headers is mis-read on the
257
+ way **in**, before any of that runs.
258
+
259
+ A message runs `H` … `L`, so one stream may carry several back to back and each header declares the
260
+ delimiters for the records that follow it. Delimiters were read only from `records[0]` and applied
261
+ to the whole stream, so every record after a redeclaring header **collapsed into a single field,
262
+ with zero warnings**, and `{ strict: true }` accepted it. Measured by execution on `dc09a3a` before
263
+ the fix: in a two-message stream whose second header declared `*~:#`, the second result — value
264
+ `99.9`, units `mmol/L`, abnormal flag `H`, status `F` — came back with `value`, `units`, `flag` and
265
+ `resultStatus` **all absent** and `status` reading `unspecified`. On the analyzer↔LIS path that is a
266
+ lost result with no signal to the caller.
267
+
268
+ **The decision, and its grounding.** Delimiters are now scoped **forward from each header**: a later
269
+ `H` governs itself and the records after it, until the next `H`. The asymmetry with the emit bug is
270
+ real — on emit the library was corrupting its own output, so re-encoding was unambiguously safe,
271
+ whereas on parse the bytes are the sender's and a second `H` is legal. What settles it is that
272
+ forward scoping is the **only** reading that never reinterprets bytes already consumed: records
273
+ before a redeclaration keep the set they were read with, so the two sets never disagree about the
274
+ same bytes. "First header wins forever" is the collapse itself; "last header wins retroactively"
275
+ would require re-reading records already delivered.
276
+
277
+ **Evidence, labelled.** The message unit is `H` … `L` — _verified primary_: LIS02-A2 §2 makes the
278
+ message a **bounded unit**, one record type opening it and another closing it, which is why a second
279
+ `H` begins a new message rather than corrupting the current one. Read directly in CLSI's own free
280
+ sample of the document. (Stated in our own words on purpose. We may read and cite the standard but
281
+ never reproduce its prose, and `CHANGELOG.md` ships inside the npm tarball — an earlier draft of
282
+ this entry quoted the clause verbatim and was caught in review.) That a header may follow a
283
+ terminator to start another message — _verified secondary_, two independent restatements of the
284
+ terminator clause (Roche cobas b 121 ASTM interface description; Genaux, _Introduction to ASTM
285
+ Message Formats_, 2024).
286
+ That a header's delimiters govern "the message" — _verified secondary_ (Genaux; Stratford Software
287
+ interface spec), which leans per-message but **does not address a redeclaration that changes the
288
+ set**. **We could not reach the normative text on that specific question**: LIS02-A2 §5.4
289
+ (Delimiters) and §6.2 (Delimiter Definition) are precisely the clauses withheld from the free
290
+ sample, and E1394-97 and LIS01-A2 stayed paywalled. So the forward-scoping rule is recorded as a
291
+ **reasoned choice, not a citation** — no clause number is claimed for it, and we do not assert the
292
+ standard is silent either, because that would need the same evidence.
293
+
294
+ The OSS reference corpus cannot ground this one and is reported as a negative result rather than
295
+ dressed up: `kxepal/python-astm` and `senaite.astm` both hardcode `|\^&` as module constants, never
296
+ read the header's declaration at all, and neither tracks `H` … `L` boundaries — so differential
297
+ testing against them is uninformative here.
298
+
299
+ **Behaviour.** A redeclaration that **changes** the set is honored and raises
300
+ `ASTM_RECORD_DELIMITERS_REDECLARED`. A header that merely **restates** the set in force is a no-op
301
+ and raises nothing — several messages in one delimiter set is an ordinary shape, and warning on it
302
+ would be noise. A later header whose declaration is **unusable** (too short, or a field separator
303
+ that also names another role) keeps the set already in force and raises
304
+ `ASTM_RECORD_UNREADABLE_REDECLARATION`; a set is never guessed and no record is dropped. The same
305
+ condition on the _first_ header remains the `ASTM_RECORD_UNDECLARED_DELIMITERS` fatal — there is no
306
+ earlier set to fall back to, and that is pinned by a test so it cannot be softened by accident.
307
+
308
+ **Surface.** Two new stable warning codes (`ASTM_RECORD_DELIMITERS_REDECLARED`,
309
+ `ASTM_RECORD_UNREADABLE_REDECLARATION`) with `delimitersRedeclared` / `unreadableRedeclaration`
310
+ factories, both **safety-critical** by the profile gate's default-deny rule, so no profile can quiet
311
+ them (asserted). `HeaderRecord.delimiters` now reports the set **that** header put into force rather
312
+ than always the first header's; `AstmMessage.delimiters` is unchanged and stays the first header's.
313
+ The sites that _stated_ the old single-header rule were swept — the `parseAstmRecords` JSDoc, the
314
+ module header, `HeaderRecord`/`AstmMessage` type docs, `readDelimiters` (whose doc comment said the
315
+ caller escalates an unusable declaration to the fatal, true only of the first header now), and
316
+ `docs-content/quickstart.md` — because on #21 the refuter's first pass was refused for exactly the
317
+ opposite failure: correct code shipped behind documentation that steered consumers the wrong way.
318
+ The `readDelimiters` comment also said it returns `{ ok: false }` when it has always returned
319
+ `undefined`; that was already wrong before this slice and is corrected in the same paragraph.
320
+
321
+ **Deliberately left for their own slices** (both recorded with this item, both **emit**-side, and
322
+ both turning on questions this one does not answer): a delimiter declaration **longer than three
323
+ characters** still loses its extra bytes on emit — what a fourth declaration byte even means is
324
+ unresolved by the same withheld clauses — and `serializeAstmRecords(msg, d)` still does **not
325
+ validate a caller-supplied `d`**, so a malformed set emits a stream this library's own parser then
326
+ rejects or mis-reads, with no typed error. Neither is touched here; folding an emit-side change into
327
+ a parse-side fix would have widened the diff without answering either question.
328
+
329
+ **Strict-mode boundary, disclosed as a sample and not a census.** Measured base-build vs head-build
330
+ over 216,699 synthetic streams (the 11 repo fixtures, 464 single-header streams, and multi-header
331
+ pairs across a sampled sweep of delimiter sets): **394 moved accepted→rejected, 0 the other way.**
332
+ All 11 fixtures and all 464 single-header streams were **unchanged**, in strict mode and in their
333
+ lenient warning lists — which matches the code path, since the new warnings can only be raised at an
334
+ `H` record that is not `records[0]`.
335
+
336
+ - **`serializeAstmRecords` no longer emits a mixed-delimiter stream that silently loses fields on the
337
+ next read** (`ASTM-MIXED-DELIMITER-EMIT`; shipped since `0.0.1`, `PRE-EXISTING`, not a regression).
338
+ Emit normalized the header to the canonical `H|\^&` set but re-emitted `M` (manufacturer) and `S`
339
+ (scientific) records byte-for-byte from `rawLine`. For a message that arrived under a vendor
340
+ delimiter set the output was therefore **non-conformant**: a canonical header above `M`/`S` rows
341
+ still written in the original delimiters. Re-parsing that output **collapsed every field of those
342
+ rows into one, with zero warnings** — on the analyzer↔LIS path, a lost result or a lost
343
+ patient/specimen identifier with no signal to the caller. Verified by execution before and after,
344
+ not by inspection: a five-field `M` row round-tripped to one field.
345
+
346
+ **The semantics chosen, and why.** The two candidates were to re-encode every record to the
347
+ delimiters the header declares, or to refuse/warn on a message whose records disagree.
348
+ **Re-encoding was chosen.** It is what the serializer already promises — one spec-clean stream in
349
+ the declared set — and a mixed-delimiter stream is not spec-clean. Emit returns a `string` and has
350
+ no warning channel, so a warning could only have been ignored while the corrupt stream still
351
+ shipped, and a refusal would have rejected messages this library successfully parsed, a harder
352
+ break on a published package. The invariant that had to hold either way is that **a round-trip
353
+ never silently loses a field, in either direction**; it is now pinned by a property over arbitrary
354
+ declared delimiter sets, which fails against the old code. `M`/`S` are reproduced byte-for-byte
355
+ **only when a reader using the emit delimiters would recover exactly the fields the record models**,
356
+ so bytes change for exactly the streams that were being corrupted and for no others. The reasoning
357
+ is recorded at the site in `src/records/serialize.ts`.
358
+
359
+ - **Editing a modeled `H` field and re-serializing no longer silently keeps the original value**
360
+ (`PRE-EXISTING`, found in the same pass). The header was emitted by re-tokenizing its preserved
361
+ `rawLine`, so the model was bypassed on emit. It is now emitted from `HeaderRecord.fields` like
362
+ every other record type. The delimiter declaration itself is still never taken from the model — it
363
+ always states the delimiters actually being emitted with, because a declaration that disagrees with
364
+ the records around it is the very corruption above.
365
+
366
+ - **`HeaderRecord.fields` now holds the header's real fields.** The generic escape-aware tokenizer
367
+ reads the escape character sitting _literally_ inside the delimiter declaration as an unterminated
368
+ escape and swallows the remainder of the record, so a canonical header modeled as **two** fields
369
+ (`H` and everything else merged) instead of one per field. A header-aware `tokenizeHeader` takes the
370
+ declaration verbatim as one opaque field and tokenizes the data portion normally; it is exported
371
+ alongside `tokenizeRecord`. This is what made emitting the header from its model possible.
372
+
373
+ **A `strict` consumer will observe one side effect, stated rather than left to be discovered.**
374
+ The old tokenizer read the declaration's literal escape char as an unterminated escape and raised a
375
+ spurious `ASTM_UNKNOWN_ESCAPE_SEQUENCE` on some headers, which `{ strict: true }` rejected. Those
376
+ warnings are gone, so a few messages that previously threw now parse. Measured over ~40,000
377
+ differential messages: 1,176 moved from rejected to accepted, **0** moved the other way, and every
378
+ warning delta is confined to `recordIndex 0` — no warning on any other record is added or dropped.
379
+
380
+ Three `PRE-EXISTING` neighbours were found while grading this and are deliberately **not** fixed
381
+ here, to keep the slice the size of its item: `parseAstmRecords` reads delimiters only from the
382
+ first header, so a **second `H` mid-stream that redeclares them** still yields the same silent
383
+ field collapse (parse-side, not emit-side — the emitter faithfully reproduces what parse modeled)
384
+ — **since fixed, see the parse-side entry above**;
385
+ a delimiter declaration longer than three characters silently loses its extra bytes on emit; and
386
+ `serializeAstmRecords(msg, d)` does not validate a caller-supplied `d`, so a malformed set (a
387
+ multi-char delimiter, an empty escape, a `field`/`escape` collision) emits a stream this library's
388
+ own parser then rejects or mis-reads, with no typed error.
389
+
390
+ - **The type documentation shipped in `dist/index.d.ts` no longer understates the library.** The
391
+ entry-point module documentation ended by calling serialize and build deferred, on a tree that exports
392
+ `serializeAstmRecords`, `buildAstmMessage`, `composeAstmFrames` and `serializeFramedAstm`, and the
393
+ record-types module documentation described only `H`/`P`/`O`/`R`/`L` as modeled, naming
394
+ result-flag/status semantics, comment, query, `M`/`S`, framing and serialization as still to come,
395
+ when all of them are modeled. Both
396
+ blocks are carried verbatim in `dist/index.d.ts` and `dist/index.d.cts`, which is what an installer
397
+ receives. (They are module-level blocks that bind to no exported symbol, so they sit in the
398
+ declaration text rather than in any symbol's editor tooltip.) Every affected block now describes
399
+ what the code does. 18 source files changed; no runtime behaviour, export, type or warning code is
400
+ affected.
401
+ - **The fatal-error taxonomy is now accurate about what `err.code` can hold.** `FATAL_CODES`
402
+ documented itself as later growing the frame codec's own `ASTM_FRAME_*` fatals. `FatalCode` is a
403
+ closed three-value union and the frame codec does not widen it: it reuses `EMPTY_INPUT` for an
404
+ empty stream, and its own thrown errors are separate types with their own discriminants
405
+ (`AstmFrameEncodeError` carries `ASTM_FRAME_EMPTY_RECORD`; `AstmFrameStrictError` carries the
406
+ rejected warnings rather than a `code`). Narrowing an `AstmParseError` on `code` will only ever
407
+ see one of the three.
408
+ - **The serializer's round-trip note now names the cases that do not round-trip.** It claimed
409
+ serialization emits from the decoded component tree. Three record types do not: `H`, `M` and `S`
410
+ are emitted from their preserved `rawLine`, so an edit to their modeled `fields` is silently not
411
+ reflected on emit -- editing a header field and re-serializing keeps the original value, with no
412
+ warning. `M`/`S` additionally keep whatever delimiters they arrived with, so a non-canonical
413
+ `M`/`S` row does not come back as separate fields. Normalization to `H|\^&` is also skipped when a
414
+ delimiter set is passed explicitly. All of it is now stated next to the round-trip claim it
415
+ bounds, and `serializeAstmRecords` gained the `@param d` its siblings already had.
416
+
14
417
  - **The published documentation sidebar now follows the shared section order.** The shipped
15
418
  `docs-content/sidebars.json` carried a category named "About" holding the "what it does and does not
16
419
  do" page. That label is not one of the sections the documentation site recognises (Overview,
package/README.md CHANGED
@@ -32,7 +32,9 @@ reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
32
32
  - **Emit.** `serializeAstmRecords` and `buildAstmMessage` emit canonical `H|\^&` records with embedded
33
33
  delimiters re-escaped and nothing clinical fabricated; `composeAstmFrames` and `serializeFramedAstm`
34
34
  frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
35
- construction.
35
+ construction, and a delimiter set that fails any of the three conditions readback requires — one
36
+ character per separator, no `CR`/`LF`, no two the same — is a typed error rather than bytes written
37
+ and lost.
36
38
  - **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
37
39
  downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
38
40
  ever altering a value, behind a definition-time safety gate that refuses to tolerate any result
@@ -133,6 +135,36 @@ produce a confident wrong value: an embedded escaped delimiter reads as one comp
133
135
  record type is surfaced (never dropped), and a missing unit is flagged (never defaulted). A
134
136
  `{ strict: true }` mode escalates every tolerated deviation to a thrown error.
135
137
 
138
+ ### Several messages in one stream
139
+
140
+ A message runs from its `H` header to its `L` terminator, so a stream can carry several. `messages()`
141
+ splits a parsed stream into them, and each entry carries only its own records — so a patient is only
142
+ ever paired with the results that message actually carried:
143
+
144
+ ```ts
145
+ import { parseAstmRecords, messages } from "@cosyte/astm";
146
+
147
+ for (const m of messages(parseAstmRecords(raw))) {
148
+ m.patient?.practiceAssignedId; // the P for THIS message
149
+ m.results; // the Rs for THIS message
150
+ m.delimiters; // the set THIS message's records were read with
151
+ }
152
+ ```
153
+
154
+ The flat accessors above (`patient`, `results`, `orders`, `comments`, `query`) read the whole stream,
155
+ so they **throw** `AstmAmbiguousStreamError` on a stream they cannot answer for rather than answering
156
+ across patients:
157
+
158
+ - `ASTM_AMBIGUOUS_MULTI_MESSAGE` from any of the five, when the stream carries more than one message.
159
+ - `ASTM_AMBIGUOUS_MULTI_PATIENT` from `patient()` only, when a **single** message carries more than
160
+ one `P` record. "The first `P`" is a guess about whose result it is, so it is refused there too.
161
+
162
+ **Both are breaking**, and the second one reaches single-message callers: a lone message carrying
163
+ several patients used to answer with the first of them. A stream that is one message with at most one
164
+ patient is unchanged, and so is a result-only message with no `P` at all, which still answers
165
+ `undefined`. `commentsFor()` is unchanged on every stream, because the parent record you hand it
166
+ already names the message.
167
+
136
168
  ## Map local codes to LOINC (LIVD, bring-your-own)
137
169
 
138
170
  An analyzer sends a proprietary local test code in the Universal Test ID; a standard LOINC is mapped