@cosyte/astm 0.0.8 → 0.0.10

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,128 @@ this file is maintained by hand (Changesets handles the version bump and publish
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ### Fixed
13
+
14
+ - **The frame encoder no longer writes a different character than the one it was handed**
15
+ (`ASTM-FRAME-BYTE-RESIDUALS`, closing known defect 7). `composeAstmFrames` turned a record given
16
+ as a `string` into bytes with `charCodeAt(i) & 0xff`, so any character above `U+00FF` was replaced
17
+ by its low byte. That low byte is another perfectly ordinary character, which is why the
18
+ substitution was **silent**: it reached the wire, checksummed clean, and came back with an empty
19
+ warnings array from **both** layers.
20
+
21
+ Measured, on this package's own round trip, three shapes on the canonical set:
22
+ - `R|1|^^^687|28.6|μmol/L||N||F` read back `28.6` in `¼mol/L` (`U+03BC` low byte `0xBC`). A lab
23
+ number whose **units** silently changed.
24
+ - `P|1||LAB-0001||ŁUKASZ^JAN||19800101|M` read back the name spelled `AUKASZ` (`U+0141` low byte
25
+ `0x41`).
26
+ - `P|1||LAB-0001||GRAżYNA^ANNA||19800101|F` read back as **ten fields where the record models
27
+ nine** (`U+017C` low byte `0x7C`, the field separator). Every field after the split shifted one
28
+ along, so `patient().sex` answered `19800101` and the birth date was gone. That is **silent
29
+ re-read divergence**: a different field tree, `warnings: []` at both layers.
30
+
31
+ The known-defect entry said this class fails loudly in every case measured, and for the cases it
32
+ had measured that was true: they all truncated onto `STX`/`ETX`/`ETB`/`CR`. Nothing had measured a
33
+ code point whose low byte is an ordinary character, and that is the larger and quieter class.
34
+
35
+ **The disposition is a refusal, and it invents nothing.** `composeAstmFrames` now throws
36
+ `AstmFrameEncodeError` with the new `ASTM_FRAME_UNENCODABLE_CHARACTER` code, carrying the record
37
+ index and the character's index, never the character and never its code point. The alternative,
38
+ encoding as UTF-8, was **considered and rejected**: turning a character into bytes requires a
39
+ character encoding, this library reads no character-set declaration from any ASTM record, and
40
+ picking one would be a guess at bytes the caller never supplied, in a package that declines to
41
+ infer a delimiter set, a record type letter or a code system. Emit also has no warning channel, so
42
+ a warning could only have been ignored while the wrong bytes still shipped. That is the same
43
+ argument already written for `ASTM_EMIT_UNENCODABLE_VALUE` and `ASTM_EMIT_INVALID_DELIMITERS`.
44
+
45
+ **What grounds the Latin-1 boundary**, since no clause is claimed for it and none was read. Two
46
+ things, both firsthand. (1) This package's own byte-to-string boundary is already Latin-1, on the
47
+ read side, deliberately: `parseAstmRecords` decodes with one `String.fromCharCode` per byte "so
48
+ every byte survives 1:1". The encoder is documented as the exact inverse of the decoder, and the
49
+ decoder can never produce a code point above `U+00FF`, so a string carrying one is outside the
50
+ codec's domain by construction. (2) The redistributable OSS reference implementation
51
+ (`kxepal/python-astm`, BSD) parameterizes the codec on `encoding` throughout
52
+ (`encode(records, encoding=ENCODING, ...)` / `decode(data, encoding=ENCODING)`) and defines
53
+ `ENCODING = 'latin-1'` in `astm/constants.py`: the wire code page there is caller-supplied
54
+ out-of-band knowledge with a latin-1 default, not something a reader discovers from the stream.
55
+ **The normative text was not reachable and no clause is cited**: LIS02-A2 is withheld from CLSI's
56
+ free sample and E1394-97 / LIS01-A2 are paywalled, so this is a reasoned choice, exactly as the
57
+ delimiter-scoping and surplus-declaration choices before it were.
58
+
59
+ **The refusal removes no capability.** `composeAstmFrames` already accepted `Uint8Array`, so a
60
+ consumer who knows their instrument's code page encodes with their own encoder and passes bytes,
61
+ which are written through untouched. A stream whose every character is Latin-1 is byte-identical
62
+ to before, the full `0x00`–`0xFF` range included. The **record** layer is deliberately unchanged:
63
+ `serializeAstmRecords` returns a `string`, which is not yet bytes, so the caller keeps the choice.
64
+
65
+ **Stated no wider than it was measured.** This closes the string-to-bytes step only. It is not a
66
+ claim that every accepted record reads back: a record already carrying a raw `STX`/`ETX`/`ETB`
67
+ byte is framed as given and re-decodes wrong (known defect 6), which this change does not touch.
68
+ **Both** of that defect's branches are now pinned as out of scope in
69
+ `test/frames/unencodable-character.test.ts`, the warned one and the **silent** one: where the two
70
+ bytes following the embedded control character happen to be that truncated frame's checksum, the
71
+ short frame verifies and a whole result record is absorbed into the previous record's text with
72
+ `warnings: []` at both layers. That branch was found grading this change, reproduces byte-identically
73
+ on the base, and is why known defect 6's "it fails loudly" ranking was withdrawn rather than restated. A surrogate pair needs no
74
+ separate rule, each half being above `U+00FF` itself. New export `AstmFrameEncodeErrorCode`;
75
+ `AstmFrameEncodeError.code` is now that union and gains a `characterIndex`. The property "either
76
+ refuse the record string or reproduce it byte for byte" is asserted over 2,000 generated cases,
77
+ with its bound written into the test and a non-vacuity check that both sides of it are reached.
78
+
12
79
  ### Added
13
80
 
81
+ - **`ASTM_RECORD_FIELDS_UNSEPARATED`: a record that lost its fields no longer loses them in
82
+ silence** (`ASTM-TYPE-LETTER-SECOND-READER`, finding 2, the one that outranked the other).
83
+ A record carries its type letter and then its fields, separated by the field delimiter the header
84
+ declared, so a record that carries content beyond its type letter and still yields exactly **one**
85
+ field contains no field separator at all: the delimiters in force are not the set that record was
86
+ written with. Every modeled field of it is then absent. On an `R` that is the **value, the units
87
+ and the status at once**, and the result reads back as though it simply never carried them.
88
+
89
+ The reachable route is a header whose type letter the reader could not recognize. Delimiters are
90
+ re-read at every `H`, keyed on that same letter, so an unrecognized header does not re-scope them
91
+ and the whole following message is tokenized with the previous header's set. Measured: a
92
+ `99.9 mmol/L` final result read back with no value, no units and status `unspecified`, filed under
93
+ the previous message's patient, with the unrecognized-type warning as the **only** report on the
94
+ stream. That warning says a letter was unreadable; it never said a value had gone.
95
+
96
+ The detector is keyed on the **observed collapse**, not on that one cause of it, because
97
+ identifying the mangled header would itself require guessing which byte the sender meant. It
98
+ therefore also fires on the same silent collapse reached without any mangled header at all, which
99
+ reproduced on the previous release with **zero** warnings: a lone record written in another set
100
+ (`H|\^&` then `R*1*:::688*99.9*mmol/L**H**F`) parsed clean and answered `undefined` for the value.
101
+
102
+ **Reported, never repaired.** The fields are not re-split on a set no header declared, because
103
+ that would invent data; the raw line is surfaced intact and nothing is dropped. One warning per
104
+ affected record, each at its own position and carrying no field data. The code is **safety-critical
105
+ by construction** (the forbidden set is computed as every known code minus the tolerable
106
+ allow-list), so no profile can quiet it and `{ strict: true }` refuses. A header is exempt by
107
+ construction rather than by exception: it is always read with the set it declares itself. Content
108
+ after the type letter that is entirely whitespace is excluded, since no field is at stake there.
109
+
110
+ **Deliberately partial, and its absence certifies nothing.** This tests one of the four delimiter
111
+ roles, the **field** separator, and only in its total form, where that separator occurs nowhere in
112
+ the line. Two classes of the same loss stay silent and are documented as such on the code, in
113
+ `README.md` and in the quickstart rather than left to be inferred. A foreign set whose **field**
114
+ separator happens to occur anywhere in the line still splits, on the wrong boundaries: one stray
115
+ `|` inside an otherwise `*`-separated result loses the value, the units and the status with no
116
+ warning, and this can happen to one record **inside** a run of these warnings, so a run is not a
117
+ sweep of the records it spans. A set differing in the **repeat, component or escape** role usually
118
+ splits into fields normally, with the damage varying: a mis-split component can cost a test
119
+ identity while the value survives, but an **escape** character occurring literally in a record
120
+ merges every field after it and costs the value, the units and the status together (a lone `&`
121
+ under the canonical set leaves a nine-field `R` reading as four, in silence). All of them
122
+ reproduce identically on the previous release, and the last is recorded as a defect in its own
123
+ right. Widening the check would mean deciding which set a record ought to have had, which is the
124
+ same guess declined above, so the boundary is written down instead of chased.
125
+
126
+ The exported factory `fieldsUnseparated` joins the record registry, and `WARNING_CODES` goes from
127
+ 15 members to 16. **That a record's fields are separated by the declared field delimiter is read
128
+ off this package's own emit contract and off every fixture in this repository, not off a normative
129
+ sentence, and no clause is claimed for it**: the relevant CLSI text is withheld from the free
130
+ sample and the surrounding standards are paywalled, and the redistributable reference corpus
131
+ hardcodes the canonical delimiters and never reads the declaration, so it cannot ground a
132
+ delimiter question either.
133
+
14
134
  - **`messages()`: read a stream as the sequence of messages it actually is**
15
135
  (`ASTM-PATIENT-RESULT-MISATTRIBUTION`). A parsed model has always been a whole record
16
136
  **stream**, and a stream may carry several messages back to back: a message runs from an `H`
@@ -129,6 +249,39 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
129
249
 
130
250
  ### Changed
131
251
 
252
+ - **BREAKING: a record type letter the reader could not recognize no longer lets a host-query
253
+ request read as a result set** (`ASTM-TYPE-LETTER-SECOND-READER`, finding 1). `classifyMessage` counts `Q` / `R` /
254
+ `O` records **by letter**, and the `Q`-dominates guarantee (a message carrying a query is never
255
+ read as a result set) was stated on that count, so it only ever held while every letter was
256
+ legible. Measured: `H|\^&` plus a `Q` carrying one stray leading byte plus an `R` classified
257
+ `kind: "results"` with `isHostQueryRequest: false`, and the `ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND`
258
+ warning that flags a `Q` + `R` contradiction vanished along with the `Q` it was counting. A stray
259
+ byte turned the fail-safe off.
260
+
261
+ The intended letter is still **never inferred**: that is the guess this package declines to make,
262
+ and inferring it would split a stream a different way just as silently. What the classifier does
263
+ instead is decline the positive answer. An unsupported record with no `Q` read alongside it now
264
+ yields `kind: "indeterminate"` rather than `"results"` or `"orders"`, and the new
265
+ `AstmMessageClassification.hasUnrecognized` reports why. A `Q` that **was** read still dominates,
266
+ because an unreadable letter can only ever add a kind, never remove a query already on the wire.
267
+
268
+ `hasQuery` / `hasResults` / `hasOrders` stay truthful, so a caller wanting the raw tally still has
269
+ it, and `results()` and the rest are untouched. Breaking for a consumer branching on `kind`: on a
270
+ stream carrying an unrecognized record letter, a previously positive `kind` now reads
271
+ `indeterminate`. That is the fix, and it moves in the fail-safe direction. A conformant stream,
272
+ where every letter is legible, is unchanged. Kept on the `0.0.x` pre-alpha ladder as a patch, per
273
+ the repo's version policy.
274
+
275
+ Two limits, stated rather than implied. `msg.classification` is still folded over the **whole
276
+ stream** (a known, separately-recorded defect), so one unrecognized letter anywhere now withholds
277
+ `kind` for the entire stream **unless a `Q` was read**, in which case the `Q` still dominates as
278
+ before. That widens the existing over-trigger; the per-message answer,
279
+ `classifyMessage(m.records)` on a `messages()` entry, is unaffected and is the reading to prefer.
280
+ And the fix lands on `kind`, not on `isHostQueryRequest`, which `README.md` calls the safety
281
+ surface: a mangled `Q` still reports `false` there, because the parser genuinely did not read a
282
+ query. `false` therefore means "no query was read", never "this is a result set", and both the
283
+ type doc and the quickstart now say so and point at `kind`.
284
+
132
285
  - **BREAKING: a profile may no longer tolerate `ASTM_RECORD_UNKNOWN_TYPE`**
133
286
  (`ASTM-UNKNOWN-RECORD-REMERGE`). The code moves off the profile safety gate's tolerable
134
287
  allow-list and onto the safety-critical set, so `defineAstmProfile({ tolerate: [...] })` naming
@@ -292,6 +445,68 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
292
445
 
293
446
  ### Fixed
294
447
 
448
+ - **One unescaped `&` in a value no longer swallows every field after it**
449
+ (`ASTM-UNESCAPED-ESCAPE-SWALLOWS-TAIL`; `PRE-EXISTING`, shipped `0.0.1` through `0.0.8`, not a
450
+ regression). The escape codec read an escape character with no closing escape character as the
451
+ opening of a sequence and copied from it to the end of the record, so every field after it merged
452
+ into the field it sat in, **and there was no warning code for the condition at all**. Measured on
453
+ `064c078`: `R|1|^^^687|28.6&|U/L||N||F` read back `value = "28.6&|U/L||N||F"` with `units`,
454
+ `abnormalFlags` and `flag` all `undefined` and `status` `unspecified` rather than `final`, on a
455
+ stream declaring the canonical `H|\^&`, with `warnings: []`. On the patient side
456
+ `P|1||LAB-0001||O&BRIEN^JANE||19800101|F` read the surname as the whole rest of the record and
457
+ came back with **no birth date and no sex**, again silently. A result that reads `28.6` with its
458
+ units gone is the harm this package exists to prevent, and an ampersand in a surname is not exotic.
459
+
460
+ **The round trip made it worse rather than surfacing it.** Emit re-escaped the merged text into
461
+ `R|1|^^^687|28.6&E&&F&U/L&F&&F&N&F&&F&F`: a spec-clean-looking line that re-parsed to the same
462
+ wrong value, with zero warnings, byte-stable across further trips. Silent re-read divergence, with
463
+ no malformed delimiter set anywhere in it.
464
+
465
+ **The fix is a bound, and it infers nothing.** An escape sequence is now exactly three characters
466
+ (the escape character, **one** body character, the escape character), which is all the four
467
+ mnemonics `&F&` `&S&` `&R&` `&E&` ever need, and both `splitEscapeAware` and `decodeEscapes` share
468
+ that one definition. An escape character that heads no such sequence is read as the **literal
469
+ character it is**: the value keeps the byte that arrived, it opens no atom, and nothing is
470
+ invented to close a sequence the sender did not open. The same fixture now
471
+ reads `value = "28.6&"` with `units: "U/L"` and `status.meaning: "final"`, and the patient keeps
472
+ their birth date and sex. Emit writes the literal character as `&E&`, so the emitted line is
473
+ spec-clean **and** structurally faithful, and re-parses to the same tree.
474
+
475
+ The bound also removes a non-local behavior: under the unbounded scan, whether a field kept its
476
+ value depended on whether some **later** field in the record happened to carry an escape character
477
+ too, which paired across the field separator between them. Two bare ampersands in one record now
478
+ cost nothing.
479
+
480
+ New warning code `ASTM_UNPAIRED_ESCAPE_CHARACTER` and exported factory `unpairedEscapeCharacter`
481
+ report each such character, one per occurrence, positioned on the record and field, carrying no
482
+ field text (PHI discipline). The code is **tolerable**: a profile may expect it, because reading
483
+ the character as a literal is unconditional and the parsed value is byte-identical with the profile
484
+ and without it. Untolerated, `{ strict: true }` refuses.
485
+
486
+ **The scope is the bare character, and only that.** The atom rule is unchanged, so an `&X&`
487
+ sequence whose body **is** a delimiter still swallows that delimiter and still costs the value, the
488
+ units and the status together: `R|1|^^^687|28.6&|&U/L||||F` reads `28.6&|&U/L` with no units and
489
+ status `unspecified`. It is never silent, but its only report is `ASTM_UNKNOWN_ESCAPE_SEQUENCE`,
490
+ which is tolerable, so a profile expecting that code lets `{ strict: true }` accept it. That case
491
+ is recorded as a known defect and deliberately not closed here: narrowing the atom would break the
492
+ guarantee it exists for, which is that `&F&` stays one token under a set naming `F` as a delimiter.
493
+ Both halves are pinned, so neither can drift.
494
+
495
+ `ASTM_UNKNOWN_ESCAPE_SEQUENCE` is unchanged for a single unrecognized body (`&Z&`). A multi-
496
+ character body is no longer treated as one atom: its bytes are all preserved, but a **delimiter
497
+ inside** such a body now splits, which moves every field after it, and the report becomes one
498
+ unpaired-character warning per loose escape character instead of one unknown-sequence warning. Over
499
+ an exhaustive corpus of bodies up to four characters in a result-value slot, that is 1,085 records
500
+ read correctly where the previous release read them wrongly, against 27 read differently in the
501
+ other direction.
502
+
503
+ Measured red on base: 10 of the 18 new tests fail against `064c078` extracted into a clean tree.
504
+ Of the 8 that pass, 6 measure behavior this change does not touch and 2 are the boundary pins on
505
+ what it deliberately does **not** fix.
506
+ `test/records/unseparated-fields.test.ts` had pinned this loss as a documented limit of
507
+ `ASTM_RECORD_FIELDS_UNSEPARATED`; that pin is inverted rather than deleted, and the prose stating
508
+ the limit moved with it in `README.md`, the quickstart and the warning code's own docs.
509
+
295
510
  - **Emit no longer writes a stream it cannot read back** (`ASTM-EMIT-RESIDUALS`; both gaps shipped
296
511
  `0.0.1` through `0.0.3`, `PRE-EXISTING`, neither a regression). Two residual emit-side holes, both
297
512
  recorded and deliberately deferred by `ASTM-MIXED-DELIMITER-EMIT` (#21) and again by
@@ -333,11 +548,11 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
333
548
  behind nothing but an `ASTM_FRAME_BAD_CHECKSUM`. Rather than enumerate the bytes each layer happens
334
549
  to reserve, and re-derive that list whenever a layer is added, no control character is carried at
335
550
  all. Two things that rule does **not** do, said here rather than left to be found: it is keyed on
336
- the character while the frame layer's structure is keyed on the low byte (`charCodeAt(i) & 0xff`),
337
- so a non-control character truncating onto `STX`/`ETX`/`ETB` still breaks framing, loudly, with a
338
- typed error or an unknown-record-type warning, never silently; and refusing a control character in
339
- a _surplus_ does not mean one cannot be a _delimiter role_, since only `CR`/`LF` are refused
340
- there. Those are
551
+ the character, so a surplus character above `U+00FF` passes it (the frame layer is where that is
552
+ settled now, and refuses such a character rather than truncating it, see the entry above; when
553
+ this was written the frame encoder truncated to the low byte and the case broke framing instead);
554
+ and refusing a control character in a _surplus_ does not mean one cannot be a _delimiter role_,
555
+ since only `CR`/`LF` are refused there. Those are
341
556
  structural losses; dropping inert bytes is not.
342
557
 
343
558
  **2. A caller-supplied delimiter set was never validated.** `serializeAstmRecords(msg, d)` and its
package/README.md CHANGED
@@ -39,7 +39,10 @@ reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
39
39
  frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
40
40
  construction, and a delimiter set that fails any of the three conditions readback requires (one
41
41
  character per separator, no `CR`/`LF`, no two the same) is a typed error rather than bytes written
42
- and lost.
42
+ and lost. A frame carries **bytes**, so a record handed to `composeAstmFrames` as a `string` is one
43
+ byte per character and a character above `U+00FF` is a typed error too: the encoder will not pick a
44
+ character encoding for you, and it never quietly writes a different character than the one you gave
45
+ it. Encode such content yourself and pass the `Uint8Array`.
43
46
  - **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
44
47
  downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
45
48
  ever altering a value, behind a safety gate that refuses to tolerate any result value, flag,
@@ -143,6 +146,30 @@ produce a confident wrong value: an embedded escaped delimiter reads as one comp
143
146
  record type is surfaced (never dropped), and a missing unit is flagged (never defaulted). A
144
147
  `{ strict: true }` mode escalates every tolerated deviation to a thrown error.
145
148
 
149
+ ### An unescaped ampersand does not cost you the rest of the record
150
+
151
+ An escape sequence is the escape character, **one** body character, and the escape character again
152
+ (`&F&` `&S&` `&R&` `&E&`). An escape character that heads no such sequence is not an escape: it is
153
+ read as the literal character it is, it opens no atom, and `ASTM_UNPAIRED_ESCAPE_CHARACTER` reports
154
+ it. So `R|1|^^^687|28.6&|U/L||N||F` reads a value of `28.6&` with units `U/L` and status `final`,
155
+ and `O&Brien` in a surname keeps the patient's birth date and sex.
156
+
157
+ The parser does not decide what the sender meant by the character: it keeps the byte that arrived
158
+ and says so. The spec-clean way to send a literal escape character is `&E&`, which is what this
159
+ package's serializer emits, so a stream it produced never trips the code. The code is **tolerable**,
160
+ so a vendor profile can expect it on a feed that sends bare ampersands and still parse
161
+ `{ strict: true }`.
162
+
163
+ **One escape shape still costs a field boundary, and it is a different code.** A real three-character
164
+ sequence is opaque by design, which is what keeps `&F&` one token under a set that names `F` as a
165
+ delimiter. So where the body is itself a delimiter (`&|&` under the canonical set) that delimiter
166
+ does not split, and every field after it shifts: `R|1|^^^687|28.6&|&U/L||||F` reads a value of
167
+ `28.6&|&U/L` with no units and status `unspecified`. It is never silent, but the only report is
168
+ `ASTM_UNKNOWN_ESCAPE_SEQUENCE`, which is **tolerable**, so a profile expecting that code (including
169
+ the shipped `referenceCorpus`) will let a `{ strict: true }` parse accept it. Narrowing the atom to
170
+ close this would break the guarantee the atom exists for, so it is written down rather than papered
171
+ over.
172
+
146
173
  ### Several messages in one stream
147
174
 
148
175
  A message runs from its `H` header to its `L` terminator, so a stream can carry several. `messages()`
@@ -176,15 +203,55 @@ already names the message.
176
203
  Splitting reads each record's type letter, so check for an `ASTM_RECORD_UNKNOWN_TYPE` warning before
177
204
  you trust the split. A header the reader does not recognize as a header, one carrying a stray leading
178
205
  byte for instance, opens no message, and the messages either side of it merge back into one, so a
179
- patient can end up holding results that arrived under a different header. Delimiters are re-read at
180
- each header too, so if the unrecognized one declared a different set, the records after it are read
181
- with the previous set and their fields can be lost rather than merely misfiled. The
206
+ patient can end up holding results that arrived under a different header. The
182
207
  parser warns on that record and a `{ strict: true }` parse refuses the stream. That warning is the
183
208
  only report the merge produces, so a profile is not allowed to tolerate it: the code is refused when
184
209
  a profile is defined, and a warning carrying it is not downgraded whatever profile is in force. Do
185
210
  not gate on the warning count, though, because the records that merged in can raise warnings of
186
211
  their own.
187
212
 
213
+ Delimiters are re-read at each header too, so if the unrecognized one declared a different set, the
214
+ records after it are read with the previous set and their fields can be lost rather than merely
215
+ misfiled. `ASTM_RECORD_FIELDS_UNSEPARATED` reports a record that suffered the total form of that:
216
+ the delimiters in force found no field separator in it at all, so the whole line read back as one
217
+ field and none of its modeled fields survived. On a result record that is the value, the units and
218
+ the status at once, so treat it as a lost result, not a formatting nit. The fields are never
219
+ reconstructed, because the set the sender used is unknown and guessing at it would invent data. The
220
+ code is safety-critical, and it does not need a mangled header to fire: a lone record written in
221
+ another set trips it too.
222
+
223
+ **Its absence does not certify that a record was read in its own set**, and this is the important
224
+ half. The check tests one of the four delimiter roles, the **field** separator, and only in its
225
+ total form, where no unescaped separator occurs in the line. Two classes of the same loss sit
226
+ outside it:
227
+
228
+ - A foreign set whose **field** separator happens to occur somewhere in the line still splits, on
229
+ the wrong boundaries and in silence. A single stray `|` in an otherwise `*`-separated result loses
230
+ the value, the units and the status with no warning at all, while the identical record without
231
+ that one byte is reported. This also happens **inside** a run of these warnings, so even a run
232
+ does not mean every record in it was checked.
233
+ - A set differing in the **repeat, component or escape** role usually splits into fields normally,
234
+ and the damage then varies. A mis-split component can cost a test identity while the value and
235
+ units survive. The escape role's worst case has **narrowed, not gone**: a bare escape character no
236
+ longer merges the rest of the record (it reads as a literal and raises
237
+ `ASTM_UNPAIRED_ESCAPE_CHARACTER`), but an `&X&` sequence whose body is a delimiter is an opaque
238
+ atom, so that delimiter does not split and the value, the units and the status can still go
239
+ together. That one is reported only by the tolerable `ASTM_UNKNOWN_ESCAPE_SEQUENCE`.
240
+
241
+ All are accepted limits, for two different reasons: widening the field-separator check would mean
242
+ deciding which set a record ought to have had, which is the same guess the parser declines to make
243
+ elsewhere, and narrowing the escape atom would break the guarantee it exists for. So they are
244
+ written down rather than papered over. Read the warning as "this record definitely lost its
245
+ fields", never as "no other record did". If
246
+ delimiter drift is a real risk on your feed, parse with `{ strict: true }`, which refuses both an
247
+ outright collapse and an unrecognized type letter, and treat `ASTM_RECORD_UNKNOWN_TYPE` as
248
+ invalidating what follows it rather than expecting this warning to enumerate the damage.
249
+
250
+ An unrecognized type letter also makes the message **kind** unknowable, because the letter that
251
+ could not be read may have been the very `Q` that decides it. `classification.kind` is
252
+ `indeterminate` in that case rather than `results` or `orders`, and `classification.hasUnrecognized`
253
+ says why. A `Q` that was read still wins outright.
254
+
188
255
  ## Map local codes to LOINC (LIVD, bring-your-own)
189
256
 
190
257
  An analyzer sends a proprietary local test code in the Universal Test ID; a standard LOINC is mapped