@cosyte/astm 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  <a href="https://cosyte.com">
2
2
  <picture>
3
3
  <source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
4
- <img alt="Cosyte: a plus mark set in two overlapping rounded squares, one solid and one outlined, beside the Cosyte wordmark" src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
4
+ <img alt="The Cosyte logo on its own white ground: the icon beside the word Cosyte." src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
5
5
  </picture>
6
6
  </a>
7
7
 
@@ -10,9 +10,9 @@
10
10
  > Read a real analyzer's ASTM traffic in one line, and never get a confident wrong value back.
11
11
 
12
12
  [![npm version](https://img.shields.io/npm/v/@cosyte/astm.svg)](https://www.npmjs.com/package/@cosyte/astm)
13
- [![CI](https://github.com/cosyte/astm/actions/workflows/ci.yml/badge.svg)](https://github.com/cosyte/astm/actions/workflows/ci.yml)
14
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cosyte/astm/blob/main/LICENSE)
15
- [![Node: >=22.0.0](https://img.shields.io/badge/node-%3E%3D22.0.0-brightgreen.svg)](https://nodejs.org)
13
+ [![CI](https://img.shields.io/github/actions/workflow/status/cosyte/astm/ci.yml?branch=main&label=CI)](https://github.com/cosyte/astm/actions/workflows/ci.yml)
14
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/cosyte/astm/blob/main/LICENSE)
15
+ [![Node](https://img.shields.io/badge/node-%3E%3D22.0.0-brightgreen.svg)](https://nodejs.org)
16
16
 
17
17
  ASTM parser, serializer, and builder for Node.js and TypeScript: lenient on parse, spec-clean on emit.
18
18
 
@@ -20,10 +20,11 @@ ASTM parser, serializer, and builder for Node.js and TypeScript: lenient on pars
20
20
  - [Status](#status)
21
21
  - [Install](#install)
22
22
  - [Usage](#usage)
23
- - [PHI and safety](#phi-and-safety)
24
- - [API](#api)
25
- - [Compatibility](#compatibility)
23
+ - [Documentation](#documentation)
24
+ - [Support](#support)
25
+ - [Security](#security)
26
26
  - [Contributing](#contributing)
27
+ - [Trademarks](#trademarks)
27
28
  - [License](#license)
28
29
 
29
30
  ## Why this exists
@@ -32,23 +33,22 @@ Lab integration teams receive ASTM off analyzers that each read the standard a l
32
33
 
33
34
  ## Status
34
35
 
35
- `0.1.0`. The public API is settled and is stable enough to depend on. The record, framing, transport, emit, profile and terminology layers are all shipped, and their exported names, options and return shapes are what a consumer builds against. Warning and error codes are part of that surface: consumers branch on `w.code`, so renaming one is a breaking change and is treated as one.
36
+ Published on npm. The public API is settled and is stable enough to depend on. The record, framing, transport, emit, profile, and terminology layers are all shipped, and their exported names, options, and return shapes are what a consumer builds against. Warning and error codes are part of that surface: consumers branch on `w.code`, so renaming one is a breaking change and is treated as one.
36
37
 
37
38
  Two public surfaces are still moving, named here rather than left to be found:
38
39
 
39
- - **The contents of the built-in profile registry.** `defineAstmProfile()`, the safety gate and the registry API are settled; what ships today is `default` plus the corpus-grounded `referenceCorpus`. Named per-vendor profiles are deliberately withheld until a public, vendor-attributed quirk document grounds them, so that set will grow.
40
+ - **The contents of the built-in profile registry.** `defineAstmProfile()`, the safety gate, and the registry API are settled; what ships today is `default` plus the corpus-grounded `referenceCorpus`. Named per-vendor profiles are deliberately withheld until a public, vendor-attributed quirk document grounds them, so that set will grow.
40
41
  - **The warning and error code sets.** A code is added when a deviation is measured that nothing reported before, and a new code is safety-critical by default, so a profile that tolerated the old reports does not tolerate the new one. Adding a code is additive, though it can change which streams a `{ strict: true }` parse refuses; renaming one is breaking and is treated as breaking.
41
42
 
42
43
  ## Install
43
44
 
44
45
  ```bash
45
- # Requires Node.js >=22.0.0. Ships dual ESM + CJS, with zero runtime dependencies.
46
46
  pnpm add @cosyte/astm
47
47
  # or
48
48
  npm install @cosyte/astm
49
49
  ```
50
50
 
51
- - **Node.js `>=22.0.0`.** That floor is `package.json` `engines.node`; nothing below it is supported.
51
+ - **Node.js 22 or later, tested on 22, 24, and 26.** That floor is `package.json` `engines.node` (`>=22.0.0`); nothing below it is supported.
52
52
  - **Dual ESM and CJS.** `import` resolves to `dist/index.mjs` and `require` to `dist/index.cjs`, each with its own type declarations, so the package loads from either module system without an interop shim.
53
53
  - **Zero runtime dependencies.** `dependencies` is empty and the package imports no Node built-in.
54
54
 
@@ -87,723 +87,46 @@ PRACTICE-0001 LAB-0002
87
87
  0
88
88
  ```
89
89
 
90
- Six things are worth reading off that output. The value and the units are surfaced raw, never converted and never defaulted. `status.isActiveFinal` is `true` only for a plain `F`, so a correction (`C`) or a cancellation (`X`) can never read as an active final result. An unrecognized abnormal flag reads `undefined`, never `normal`. The identifier a result is keyed on is the vendor local code in the Universal Test ID's fourth component. The practice- and laboratory-assigned patient IDs stay distinct, because collapsing them is the primary result-misfiling path. And `warnings` is empty here only because this stream is spec-clean: on vendor-quirky input it fills with stable, value-free codes instead of throwing, which is what the rest of this file is about.
90
+ Six things are worth reading off that output. The value and the units are surfaced raw, never converted, and never defaulted. `status.isActiveFinal` is `true` only for a plain `F`, so a correction (`C`) or a cancellation (`X`) can never read as an active final result. An unrecognized abnormal flag reads `undefined`, never `normal`. The identifier a result is keyed on is the vendor local code in the Universal Test ID's fourth component. The practice- and laboratory-assigned patient IDs stay distinct, because collapsing them is the primary result-misfiling path. And `warnings` is empty here only because this stream is spec-clean: on vendor-quirky input it fills with stable, value-free codes instead of throwing, and the pages under Documentation below say what each one means.
91
91
 
92
92
  That block is executed on every test run and its output is asserted against the text beside it (`test/readme-usage.test.ts`), so an example that drifts from the package fails the build rather than misleading a reader.
93
93
 
94
- ## PHI and safety
94
+ The rest of the API, layer by layer (several messages in one stream, framed bytes, the transport and its `LTP` reducer, LIVD mapping, and dates without an invented timezone), is in the guide [The API, layer by layer](https://github.com/cosyte/astm/blob/main/docs-content/guides-api-by-layer.md).
95
95
 
96
- ASTM instrument traffic carries PHI. This section says what the library does with it and, where a guarantee cannot be grounded in the code, states the limit instead.
97
-
98
- **What the library does not do.** It never logs: there is no logger, no `console` call and no log sink anywhere in `src/`. It never writes to disk and never opens a network connection; it imports no Node built-in and declares zero runtime dependencies. It retains nothing across calls: `parseAstmRecords` returns a deep-frozen model, and the only module-level state in the package is which vendor profile is selected as the default, which is configuration rather than data. It owns no socket: `ltpReduce` is a pure reducer that returns the actions to take, and you write the bytes.
99
-
100
- **What can reach a log through this library.** Every warning and every fatal error raised on stream content carries a stable code plus a position (the record's ordinal index, its type letter, and the 1-based field and component indices) and never a field value, so one can be logged verbatim without leaking a name, an identifier or a result value. Three messages in the package do quote a value back, and all three quote an argument you passed rather than anything read off the wire: an out-of-range `startFrameNumber`, and the options object and the profile name handed to `defineAstmProfile()`. That is the bound, stated rather than rounded up into a claim that no message ever quotes anything.
101
-
102
- **What you still own.** The parsed model holds the patient data you handed it, in your process, for as long as you keep it, so retention, encryption at rest, transport security, access control and audit are all yours. So is anything you print: a value-free warning does not make `JSON.stringify(msg)` safe, and the raw line the parser preserves verbatim on a deviation is the patient's data. The library also never redacts, masks or de-identifies. It surfaces what arrived.
103
-
104
- **One limit about this repository rather than about your data.** The committed PHI scan (`pnpm phi-scan`) is a starter. It enforces a cross-cutting SSN and non-test-email floor plus a check on the `P` record's name components, and structured field-level detection for address, phone and comment free text is not implemented here. It guards this repository's own fixtures, tests and examples; it makes no claim about a stream you feed the library. Every record in this file and in the test suite is synthetic.
105
-
106
- ## API
107
-
108
- `@cosyte/astm` is a zero-dependency TypeScript toolkit that follows the cosyte parser archetype: a lenient
109
- parser that turns real-world, vendor-quirky input into **warnings** rather than failures, paired with
110
- a serializer that always emits spec-clean output (Postel's Law). It mirrors the API shape of the
111
- reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
112
-
113
- Every export carries JSDoc that compiles into `dist/index.d.ts`, so the full signature reference is what your editor already shows on hover; the source is at [github.com/cosyte/astm/tree/main/src](https://github.com/cosyte/astm/tree/main/src). What follows is the shape of each layer and the caveats that bite.
114
-
115
- ### Parse records
116
-
117
- ```ts
118
- import { parseAstmRecords, results, patient } from "@cosyte/astm";
119
-
120
- // A de-framed ASTM record stream (CR-delimited records; the header declares the delimiters).
121
- const msg = parseAstmRecords(raw);
122
-
123
- results(msg)[0]?.value; // the measured value, surfaced raw
124
- results(msg)[0]?.units; // vendor free-text units (a missing unit is a warning, never a default)
125
- patient(msg)?.practiceAssignedId; // kept distinct from laboratoryAssignedId (the misfiling guard)
126
- msg.warnings; // stable, value-free positional tolerance warnings (never throws on quirks)
127
- ```
128
-
129
- The parser is **lenient by default** (vendor quirks become warnings, not failures) and refuses to
130
- produce a confident wrong value: an embedded escaped delimiter reads as one component, an unknown
131
- record type is surfaced (never dropped), and a missing unit is flagged (never defaulted). A
132
- `{ strict: true }` mode escalates every tolerated deviation to a thrown error.
133
-
134
- ### Several messages in one stream
135
-
136
- A message runs from its `H` header to its `L` terminator, so a stream can carry several. `messages()`
137
- splits a parsed stream into them, and each entry carries only its own records, so a patient is only
138
- ever paired with the results that message actually carried:
139
-
140
- ```ts
141
- import { parseAstmRecords, messages } from "@cosyte/astm";
142
-
143
- for (const m of messages(parseAstmRecords(raw))) {
144
- m.patient?.practiceAssignedId; // the P for THIS message
145
- m.results; // the Rs for THIS message
146
- m.delimiters; // the set THIS message's records were read with
147
- }
148
- ```
149
-
150
- The flat accessors above (`patient`, `results`, `orders`, `comments`, `query`) read the whole stream,
151
- so they **throw** `AstmAmbiguousStreamError` on a stream they cannot answer for rather than answering
152
- across patients:
153
-
154
- - `ASTM_AMBIGUOUS_MULTI_MESSAGE` from any of the five, when the stream carries more than one message.
155
- - `ASTM_AMBIGUOUS_MULTI_PATIENT` from `patient()` only, when a **single** message carries more than
156
- one `P` record. "The first `P`" is a guess about whose result it is, so it is refused there too.
157
-
158
- **Both are breaking**, and the second one reaches single-message callers: a lone message carrying
159
- several patients used to answer with the first of them. A stream that is one message with at most one
160
- patient is unchanged, and so is a result-only message with no `P` at all, which still answers
161
- `undefined`. `commentsFor()` is unchanged on every stream, because the parent record you hand it
162
- already names the message.
163
-
164
- Splitting reads each record's type letter, so check for an `ASTM_RECORD_UNKNOWN_TYPE` warning before
165
- you trust the split. A header the reader does not recognize as a header, one carrying a stray leading
166
- byte for instance, opens no message, and the messages either side of it merge back into one, so a
167
- patient can end up holding results that arrived under a different header. The
168
- parser warns on that record and a `{ strict: true }` parse refuses the stream. That warning is the
169
- only report the merge produces, so a profile is not allowed to tolerate it: the code is refused when
170
- a profile is defined, and a warning carrying it is not downgraded whatever profile is in force. Do
171
- not gate on the warning count, though, because the records that merged in can raise warnings of
172
- their own.
173
-
174
- Delimiters are re-read at each header too, so if the unrecognized one declared a different set, the
175
- records after it are read with the previous set and their fields can be lost rather than merely
176
- misfiled. `ASTM_RECORD_FIELDS_UNSEPARATED` reports a record that suffered the total form of that:
177
- the delimiters in force found no field separator in it at all, so the whole line read back as one
178
- field and none of its modeled fields survived. On a result record that is the value, the units and
179
- the status at once, so treat it as a lost result, not a formatting nit. The fields are never
180
- reconstructed, because the set the sender used is unknown and guessing at it would invent data. The
181
- code is safety-critical, and it does not need a mangled header to fire: a lone record written in
182
- another set trips it too.
183
-
184
- **Its absence does not certify that a record was read in its own set**, and this is the important
185
- half. The check tests one of the four delimiter roles, the **field** separator, and only in its
186
- total form, where no unescaped separator occurs in the line. Two classes of the same loss sit
187
- outside it:
188
-
189
- - A foreign set whose **field** separator happens to occur somewhere in the line still splits, on
190
- the wrong boundaries and in silence. A single stray `|` in an otherwise `*`-separated result loses
191
- the value, the units and the status with no warning at all, while the identical record without
192
- that one byte is reported. This also happens **inside** a run of these warnings, so even a run
193
- does not mean every record in it was checked.
194
- - A set differing in the **repeat, component or escape** role usually splits into fields normally,
195
- and the damage then varies. A mis-split component can cost a test identity while the value and
196
- units survive. The escape role's worst case has **narrowed, not gone**: a bare escape character no
197
- longer merges the rest of the record (it reads as a literal and raises
198
- `ASTM_UNPAIRED_ESCAPE_CHARACTER`), but an `&X&` sequence whose body is an unrecognized character
199
- that is itself a delimiter in force is an opaque
200
- atom, so that delimiter does not split and the value, the units and the status can still go
201
- together. That one raises `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`, which is not tolerable,
202
- alongside the tolerable `ASTM_UNKNOWN_ESCAPE_SEQUENCE`. The split itself is unchanged. Its mirror,
203
- where the leftmost alignment lets a delimiter split that a competing alignment would have held,
204
- gains a boundary instead of losing one and raises `ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT`, also
205
- not tolerable. Where that gained boundary is a **field** boundary and the reading taken resumes on
206
- an escape character heading no sequence it can interpret, every later field shifts and a
207
- result's units
208
- and status are read out of slots the other alignment does not put them in: that raises
209
- `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS`, not tolerable either. Where it is a **repeat** boundary
210
- nothing shifts, but the field is read out of its first repeat alone, so a gained first boundary
211
- truncates a value and costs a test identity or a patient name the components that sat after it:
212
- that raises `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`, not tolerable either. Where it is a
213
- **component** boundary nothing leaves the record and every component after it moves along the
214
- component list, so a coding scheme, a vendor local code or a given name is read out of a position the other
215
- alignment does not put it in: that raises `ASTM_RECORD_ALIGNMENT_SHIFTED_COMPONENTS`, not
216
- tolerable either.
217
-
218
- All are accepted limits, for two different reasons: widening the field-separator check would mean
219
- deciding which set a record ought to have had, which is the same guess the parser declines to make
220
- elsewhere, and narrowing the escape atom would break the guarantee it exists for. So they are
221
- written down rather than papered over. Read the warning as "this record definitely lost its
222
- fields", never as "no other record did". If
223
- delimiter drift is a real risk on your feed, parse with `{ strict: true }`, which refuses both an
224
- outright collapse and an unrecognized type letter, and treat `ASTM_RECORD_UNKNOWN_TYPE` as
225
- invalidating what follows it rather than expecting this warning to enumerate the damage.
226
-
227
- An unrecognized type letter also makes the message **kind** unknowable, because the letter that
228
- could not be read may have been the very `Q` that decides it. `classification.kind` is
229
- `indeterminate` in that case rather than `results` or `orders`, and `classification.hasUnrecognized`
230
- says why. A `Q` that was read still wins outright.
231
-
232
- ### Decode a framed byte stream
233
-
234
- ```ts
235
- import { decodeAstmFrames, parseFramedAstm, results } from "@cosyte/astm";
236
-
237
- // A raw ASTM byte stream off a serial line or socket.
238
- const { records, frames, warnings } = decodeAstmFrames(framedBytes);
239
- frames[0]?.checksum.valid; // the modulo-256 checksum verdict (emitted uppercase, accepted lowercase)
240
- warnings; // ASTM_FRAME_* deviations, each with a frame number + byte offset (never the record bytes)
241
-
242
- // Or compose both layers: decode frames → parse the trusted, reassembled records.
243
- const { message } = parseFramedAstm(framedBytes);
244
- results(message)[0]?.value; // only checksum-verified frames ever reach the record parser
245
- ```
246
-
247
- A checksum mismatch, a sequence gap, an unterminated frame, and an oversize (>240) frame are each a
248
- **warning** in the default lenient mode (surfaced, flagged, never silently trusted) and a thrown
249
- `AstmFrameStrictError` under `{ strict: true }`.
250
-
251
- ### Drive the transport (framed vs raw) + the LTP protocol
252
-
253
- ASTM transport is not uniform: **serial** always frames, but over **TCP it varies within a single
254
- vendor**, the cobas 4800 and Iguana keep the full `ENQ`/`ACK` + `STX`/checksum framing, while the
255
- cobas b121 drops it and streams de-framed record bytes directly. Detect which you have, then drive the
256
- pure protocol reducer with your own socket I/O.
257
-
258
- ```ts
259
- import {
260
- detectFraming,
261
- decodeAstmFrames,
262
- parseAstmRecords,
263
- ltpInitialState,
264
- ltpReduce,
265
- } from "@cosyte/astm";
266
-
267
- // 1. Route by the stream's leading byte (STX/ENQ ⇒ framed; a bare record letter ⇒ raw).
268
- const { framing } = detectFraming(leadingBytes); // "framed" | "raw" (override: { override: "raw" })
269
- if (framing === "raw") {
270
- // cobas b121 raw-TCP: no handshake, no frames, parse the record bytes directly.
271
- parseAstmRecords(rawBytes);
272
- }
273
-
274
- // 2. Framed transport: drive the pure receiver-side state machine. YOU own the socket + clock.
275
- let state = ltpInitialState();
276
- function onControlOrFrame(event) {
277
- const { state: next, actions, warnings } = ltpReduce(state, event);
278
- state = next;
279
- for (const a of actions) {
280
- if (a.type === "sendAck") socket.write(Uint8Array.of(0x06)); // ACK, only ever for a good frame
281
- if (a.type === "sendNak") socket.write(Uint8Array.of(0x15)); // NAK, bad checksum ⇒ retransmit
282
- if (a.type === "deliverRecord") parseAstmRecords(a.record); // a complete, trusted record
283
- }
284
- void warnings; // ASTM_LTP_*, value-free (a code + at most a frame number)
285
- }
286
- // Feed events as you read them: { type: "enq" }, { type: "frame", frame: decodeAstmFrames(b).frames[0] }, …
287
- ```
288
-
289
- The reducer is deterministic and fully testable without a socket. Its inviolable rule: a frame the
290
- codec did not vouch for (bad checksum, unterminated, or out of sequence) yields `sendNak`, **never** a
291
- fabricated `sendAck`, and is **never** appended to a record. A `NAK` drives retransmit, not acceptance.
292
- The interactive contention/timeout/retransmit **timing** is the consumer's: this layer models the
293
- state transitions, not the wall-clock timers.
294
-
295
- ### Map local codes to LOINC (LIVD, bring-your-own)
96
+ ## Documentation
296
97
 
297
- An analyzer sends a proprietary local test code in the Universal Test ID; a standard LOINC is mapped
298
- downstream. Supply your own IICC LIVD ("LOINC to Vendor IVD") catalog and annotate a message, the
299
- mapping is **additive and advisory**: it never touches the raw code or value, and an unmapped or
300
- ambiguous code is surfaced as such, **never a guessed LOINC**.
98
+ The full documentation lives at [docs.cosyte.com/astm](https://docs.cosyte.com/astm), built from this repository's [`docs-content/`](https://github.com/cosyte/astm/tree/main/docs-content). Start with these pages:
301
99
 
302
- ```ts
303
- import { parseAstmRecords, defineLivdCatalog, applyLivd } from "@cosyte/astm";
304
-
305
- // Your LIVD catalog: the vendor transmission code (Vendor Analyte Code) → LOINC.
306
- const catalog = defineLivdCatalog([{ vendorCode: "687", loinc: "1920-8", loincLongName: "AST" }]);
307
-
308
- const msg = parseAstmRecords("H|\\^&\rR|1|^^^687|28.6|U/L||N||F\rL|1\r");
309
- const { annotations, warnings } = applyLivd(msg, catalog);
310
-
311
- annotations[0]?.mapping; // { status: "mapped", loinc: "1920-8", loincLongName: "AST", source: "livd", derived: true }
312
- warnings; // ASTM_LIVD_UNMAPPED_CODE / ASTM_LIVD_AMBIGUOUS_MAPPING (value-free) for codes with no single LOINC
313
- ```
314
-
315
- **Your catalog answers the analyte-identity question, and the wire never does.** The Universal Test
316
- ID's first component is a LOINC slot, and the guide this catalog format comes from puts transmitting
317
- LOINC directly from IVD instruments explicitly out of scope: the analyte arrives as a vendor-defined
318
- code. So the lookup happens whenever a vendor local code is present, keyed on that code alone, and a
319
- populated first component neither answers for it nor selects among candidates. **This package
320
- performs no LOINC validation of any kind**: it never decides whether such a value "looks like" a
321
- LOINC, so `Glucose` and `2345-7` there are treated identically. The value is carried verbatim as
322
- `unvalidatedWireValue`, on every disposition, and is never reported as a LOINC.
323
-
324
- ```ts
325
- const msg = parseAstmRecords("H|\\^&\rR|1|Glucose^^^687|28.6|U/L||N||F\rL|1\r");
326
- const [a] = applyLivd(msg, catalog).annotations;
327
-
328
- a?.mapping.status; // "mapped": your catalog vouched for 1920-8 (the lookup used "687")
329
- a?.reportedCode; // "687": the code the catalog was consulted WITH, verbatim
330
- a?.unvalidatedWireValue; // "Glucose": carried verbatim, vouched for by nothing
331
- a?.wireValueDisagreesWithCatalog; // true: the two differ, and that is ALL this says
332
- ```
333
-
334
- `mapping.status` is a closed discriminant, so a `switch` over it is exhaustive:
335
-
336
- | `status` | what happened | warning |
337
- | ---------------- | ---------------------------------------------------------------------------------------- | ----------------------------- |
338
- | `mapped` | your catalog vouched for exactly one LOINC for the vendor local code | none |
339
- | `unmapped` | the vendor local code was looked up and your catalog held no entry | `ASTM_LIVD_UNMAPPED_CODE` |
340
- | `ambiguous` | several distinct LOINCs the reported units did not settle; all surfaced, **none chosen** | `ASTM_LIVD_AMBIGUOUS_MAPPING` |
341
- | `no-vendor-code` | component 1 is populated and there is no vendor local code: nothing was looked up | none |
342
- | `no-code` | the Universal Test ID carried no code at all | none |
343
-
344
- `unvalidatedWireValue` and `wireValueDisagreesWithCatalog` sit **beside** that discriminant and can
345
- accompany any of its cases. `wireValueDisagreesWithCatalog` is `true` only where your catalog
346
- vouched for exactly one LOINC, component 1 is populated, and the two are not byte-identical; it is
347
- `false` everywhere else and is never absent, so an ordinary `R|1|^^^687|...` record ships no
348
- standing false disagreement. **It reports the difference and nothing else**: both values stay
349
- surfaced, neither is marked correct, neither is rewritten, and nothing here says the difference was
350
- settled. Deciding which source to believe is a clinical judgement this library will not make for
351
- you.
352
-
353
- Two corners worth knowing before you write a catalog adapter: a `lookup` that **throws** propagates
354
- to your caller unchanged rather than reading as a catalog miss (your crash must not be
355
- indistinguishable from "this code is not in the catalog"), and a hit whose `loinc` is a zero-length
356
- string is reported as a miss rather than as a vouched-for empty LOINC.
357
-
358
- #### One vendor code, several LOINCs: the units decide
359
-
360
- One vendor analyte code mapping to several LOINCs is the **ordinary** case, not an edge: the guide
361
- this catalog format comes from gives the commonest chemistry analytes as its own worked examples, a
362
- serum glucose reported as a mass concentration versus a substance concentration, and a urine analyte
363
- reported as a spot concentration versus a 24 hour excretion rate. Its remedy is a mapping per unit,
364
- so a catalog row can carry a **representative unit of measure** beside the vendor code, and the
365
- `R` record already states its units on the wire.
366
-
367
- ```ts
368
- import { parseAstmRecords, defineLivdCatalog, applyLivd } from "@cosyte/astm";
369
-
370
- const glucose = defineLivdCatalog([
371
- { vendorCode: "GLU", loinc: "2345-7", representativeUnit: "mg/dL" },
372
- { vendorCode: "GLU", loinc: "14749-6", representativeUnit: "mmol/L" },
373
- ]);
374
-
375
- const msg = parseAstmRecords("H|\\^&\rR|1|^^^GLU|5.5|mmol/L||N||F\rL|1\r");
376
- applyLivd(msg, glucose).annotations[0]?.mapping;
377
- // { status: "mapped", loinc: "14749-6", representativeUnit: "mmol/L",
378
- // unitComparison: { comparison: "verbatim-case-sensitive", ucumSemantic: false, ... },
379
- // source: "livd", derived: true }
380
- ```
381
-
382
- **The comparison is verbatim and case sensitive, and it is not UCUM.** A catalog saying `mg/dL` does
383
- not match a feed saying `MG/DL`, and it never matches `g/L` however convertible the two are: nothing
384
- is normalized, case folded, scaled or converted on either side. UCUM defines a case-insensitive
385
- variant of every terminal symbol and requires a program claiming full conformance to compare unit
386
- expressions by their **semantics**, so this is a deliberately limited choice, the only comparison
387
- that cannot invent an equivalence. Because you could otherwise read a matched unit as a conformance
388
- claim this package does not make, every unit-selected answer carries `unitComparison` saying exactly
389
- what was compared.
390
-
391
- Refusing still beats guessing, so all four of these are `ambiguous` with **every candidate surfaced
392
- and no LOINC chosen**: the units match no candidate, they match more than one, the record reported no
393
- units (absent, empty, or whitespace only) and the candidates differ only by unit, or the units could
394
- not be read at all because the record was truncated or malformed. An unreadable units field reads as
395
- "no units reported": you still get a typed annotation, never an exception and never a dropped record.
396
- The answer's `reason` says which case it was, and `candidateDetails` carries each candidate row's
397
- LOINC and LIVD attributes so a human can choose what this package will not.
398
-
399
- A candidate whose `representativeUnit` is **absent, empty or whitespace only** is not unit qualified:
400
- it is never selected by a unit comparison, and it is still surfaced among the candidates. And a
401
- vendor code carrying exactly **one** candidate LOINC is answered whether or not the units agree, with
402
- no `unitComparison`, because no unit chose anything there.
403
-
404
- On a `mapped` answer, `representativeUnit` is provenance about the **catalog row** rather than a
405
- restatement of what the record reported: where a code carries a single candidate LOINC across several
406
- rows that spell the unit differently, the answer takes the first row's attributes, so that field can
407
- name a unit the record did not report. **Only `unitComparison` says the two were equal**, and it is
408
- present only where a unit actually chose between candidates. Branch on that field, not on this one.
409
-
410
- Two attributes ride along and are **never matched on**: `vendorSpecimenDescription` and
411
- `vendorResultDescription`. Both are free text, and the guide states directly that this information is
412
- not intended to be parsed by software that automates the mapping, so they are stored and surfaced
413
- verbatim for a human to read. Only the representative unit ever selects. All three attributes are
414
- **optional**: a catalog carrying none of them answers exactly as it did before they existed, for
415
- `mapped`, `unmapped` and `ambiguous` alike.
416
-
417
- **No LOINC / SNOMED / LIVD dictionary is bundled.** LOINC is © Regenstrief (redistributable only with
418
- its attribution notice) and the public CDC LIVD file is SARS-CoV-2-specific and carries
419
- separately-licensed SNOMED CT, so the package ships no terminology data and you bring the catalog (and
420
- its license obligations).
421
-
422
- > **Scope your catalog to the source device fleet.** The ASTM Universal Test ID carries no manufacturer
423
- > to disambiguate against, so the catalog keys on the vendor transmission code alone. Two different
424
- > instruments that reuse the same code for different analytes would both match: supply a catalog built
425
- > for the analyzers you actually receive from. (Conflicting entries _within_ one catalog are caught and
426
- > surfaced as `ambiguous`, never resolved to a guess.)
427
-
428
- ### Read a date without inventing a timezone
429
-
430
- **An ASTM timestamp carries no timezone, so it is not an instant.** The value is local to the
431
- instrument that wrote it, and nothing on the wire says which zone that was. `toDate` therefore
432
- returns `undefined` until **you** say what the offset was: it never reads the host machine's zone
433
- and never assumes UTC. Both guesses are silent, and both shift a collection date by a day in every
434
- negative-offset zone.
435
-
436
- `parseAstmDate` reads the digit run into an `AstmDate` that keeps whatever precision the instrument
437
- sent (`"20240315"` is a **day**, not a fabricated midnight). Three conversions read that value, and
438
- they carry the same names, the same shapes and the same timezone rule in every `@cosyte` parser:
439
-
440
- | function | returns | on a value it cannot convert |
441
- | ------------------------- | ------------------------ | --------------------------------------------- |
442
- | `toObject(value)` | `DateParts \| undefined` | `undefined` |
443
- | `toISO(value)` | `string \| undefined` | `undefined` |
444
- | `toDate(value, options?)` | `Date \| undefined` | `undefined`, including when no zone was given |
445
-
446
- ```ts
447
- import { parseAstmDate, toObject, toISO, toDate } from "@cosyte/astm";
448
-
449
- const collected = parseAstmDate("20240315"); // day precision, no time, no zone
100
+ - [Quickstart](https://github.com/cosyte/astm/blob/main/docs-content/quickstart.md): parse a result stream, read a result and its warnings, emit and frame records, and map a local code to LOINC.
101
+ - [The API, layer by layer](https://github.com/cosyte/astm/blob/main/docs-content/guides-api-by-layer.md): records, several messages in one stream, frames, the transport, LIVD mapping, and dates.
102
+ - [Coverage and compatibility](https://github.com/cosyte/astm/blob/main/docs-content/concepts-coverage.md): what the parser reads and emits, and the escape and delimiter edge cases each warning code reports.
103
+ - [Vendor profiles](https://github.com/cosyte/astm/blob/main/docs-content/guides-vendor-profiles.md): how a profile tolerates one documented deviation without touching a value.
104
+ - [What the library does with patient data](https://github.com/cosyte/astm/blob/main/docs-content/patient-data.md), and [what it does and does not do](https://github.com/cosyte/astm/blob/main/docs-content/limitations.md).
105
+ - [Troubleshooting](https://github.com/cosyte/astm/blob/main/docs-content/troubleshooting.md): common symptoms, and how to read what the parser is telling you.
450
106
 
451
- toObject(collected); // { year: 2024, month: 3, day: 15 } only what was stated
452
- toISO(collected); // "2024-03-15" no trailing Z, ever
453
- toDate(collected); // undefined the zone is unstated
107
+ ## Support
454
108
 
455
- // You know the analyzer runs on US Eastern standard time, so you say so:
456
- toDate(collected, { assumeOffsetMinutes: -300 }); // 2024-03-15T05:00:00.000Z
457
- toDate(collected, { assumeOffsetMinutes: 0 }); // 2024-03-15T00:00:00.000Z, "read it as UTC"
458
- ```
459
-
460
- `assumeOffsetMinutes` is signed minutes east of UTC, and an explicit `0` is a real answer ("treat
461
- this value as UTC"), not a default. Components below the stated precision fill to their lowest legal
462
- value for the instant only (month and day to 1, the time to 0); the value itself is untouched, so
463
- `toObject` and `toISO` keep reporting the precision that arrived. A four-digit year below 100 stays
464
- that year: `"00500101"` is the year 50, never 1950.
465
-
466
- An offset that names no usable zone is refused rather than applied, and **the answer is never an
467
- `Invalid Date`**: not a `NaN` or infinite `assumeOffsetMinutes`, and not one so large that applying
468
- it lands outside the range a JS `Date` represents. Both give you `undefined`, like every other case
469
- this surface cannot answer.
470
-
471
- ```ts
472
- toDate(collected, { assumeOffsetMinutes: Number.NaN }); // undefined
473
- toDate(collected, { assumeOffsetMinutes: 1e15 }); // undefined, past the representable range
474
- ```
475
-
476
- An `Invalid Date` would satisfy the `Date | undefined` return type and defeat its point: you cannot
477
- tell one from a real instant without testing `getTime()` for `NaN`, and calling `toISOString()` on it
478
- throws. Checking for `undefined` is the only test you need.
479
-
480
- `toObject` returns a **frozen** object carrying only the components the value stated, so
481
- `Object.keys()` is the precision. There is no `precision`, `raw` or `truncated` key on it (that is
482
- the parse record, and it stays on `AstmDate`), no zero-filling, and no key holding `undefined`. The
483
- month is spec-native **1 to 12**, and the keys are singular, so deleting `offsetMinutes` (which an
484
- ASTM value never has) leaves an object `Temporal.PlainDateTime.from` and luxon's
485
- `DateTime.fromObject` accept with no key rename and no value adjustment. Neither library is a
486
- dependency here: the shape is the interoperability, not an import.
487
-
488
- **A component that is not on the calendar is refused, not normalised.** The parser is lenient by
489
- design and will read `"19881301"` into month 13 rather than drop the field, so the conversions are
490
- where that has to be answered, and all three of them answer `undefined`:
109
+ Questions, bug reports, and analyzer quirks you have met on a real feed all start on the [issue tracker](https://github.com/cosyte/astm/issues); there is no separate forum. Describe a quirk by its shape, or reproduce it with synthetic values: never paste real patient data into an issue.
491
110
 
492
- ```ts
493
- parseAstmDate("19881301")?.month; // 13: the parser reports what arrived
494
- toObject(parseAstmDate("19881301")); // undefined
495
- toISO(parseAstmDate("19881301")); // undefined
496
- toDate(parseAstmDate("19881301"), { assumeOffsetMinutes: 0 }); // undefined
497
- ```
498
-
499
- A JS `Date` would have rolled that month 13 into January **1989** and handed back a date of birth a
500
- year out, with nothing to say so. A month outside 1 to 12, a day past the end of its own month (29
501
- February in a common year included), an hour past 23 and a minute or second past 59 are all refused
502
- the same way. The refusal is of the whole value, never of the offending component alone: `undefined`
503
- rather than a year-precision `{ year: 1988 }` the instrument never sent.
111
+ ## Security
504
112
 
505
- `astmDateToLocalISO` is unchanged and still exported. `toISO` returns the same string for every value
506
- this parser produces **whose components are a real calendar date**, under the name every `@cosyte`
507
- parser answers to. On a value whose components are not, the two differ on purpose and in one
508
- direction: `astmDateToLocalISO` still renders the digits it was handed (`"1988-13-01"`), which is its
509
- long-standing behaviour, and `toISO` gives you `undefined` instead of a string no ISO-8601 reader
510
- accepts.
113
+ Report a vulnerability privately, to security@cosyte.com or through GitHub's private vulnerability reporting, as the [security policy](https://github.com/cosyte/.github/blob/main/SECURITY.md) describes, and never in a public issue.
511
114
 
512
- #### Using two `@cosyte` parsers in one file
115
+ ### PHI and safety
513
116
 
514
- The three names are deliberately identical across the packages, so importing two of them collides.
515
- Alias them, or namespace-import:
117
+ ASTM instrument traffic carries PHI. This section says what the library does with it and, where a guarantee cannot be grounded in the code, states the limit instead.
516
118
 
517
- ```ts
518
- import { parseAstmDate, toISO as astmToISO, toDate as astmToDate } from "@cosyte/astm";
519
- import { toISO as hl7ToISO } from "@cosyte/hl7";
119
+ **What the library does not do.** It never logs: there is no logger, no `console` call and no log sink anywhere in `src/`. It never writes to disk and never opens a network connection; it imports no Node built-in and declares zero runtime dependencies. It retains nothing across calls: `parseAstmRecords` returns a deep-frozen model, and the only module-level state in the package is which vendor profile is selected as the default, which is configuration rather than data. It owns no socket: `ltpReduce` is a pure reducer that returns the actions to take, and you write the bytes.
520
120
 
521
- // Or namespace the whole package:
522
- import * as astm from "@cosyte/astm";
121
+ **What can reach a log through this library.** Every warning and every fatal error raised on stream content carries a stable code plus a position (the record's ordinal index, its type letter, and the 1-based field and component indices) and never a field value, so one can be logged verbatim without leaking a name, an identifier, or a result value. Three messages in the package do quote a value back, and all three quote an argument you passed rather than anything read off the wire: an out-of-range `startFrameNumber`, and the options object and the profile name handed to `defineAstmProfile()`. That is the bound, stated rather than rounded up into a claim that no message ever quotes anything.
523
122
 
524
- astmToISO(parseAstmDate("202403150930")); // "2024-03-15T09:30"
525
- astm.toDate(parseAstmDate("20240315"), { assumeOffsetMinutes: -300 });
526
- ```
123
+ **What you still own.** The parsed model holds the patient data you handed it, in your process, for as long as you keep it, so retention, encryption at rest, transport security, access control and audit are all yours. So is anything you print: a value-free warning does not make `JSON.stringify(msg)` safe, and the raw line the parser preserves verbatim on a deviation is the patient's data. The library also never redacts, masks, or de-identifies. It surfaces what arrived.
527
124
 
528
- An HL7 v2 `DTM` can state its own offset and an ASTM timestamp never can, so the two answer
529
- differently on purpose: `hl7ToISO` may end in `Z` or `+HH:MM`, `astmToISO` never does. That
530
- difference is the standards', not the API's.
531
-
532
- ### The cosyte parser archetype
533
-
534
- - **Postel's Law**: liberal parser (lenient default + warnings), conservative serializer (always
535
- spec-clean), so quirks don't propagate downstream on round-trip.
536
- - **Tiered tolerance**: Tier 0/1 silent, Tier 2 warning + recovery (escalates in strict mode),
537
- Tier 3 fatal always.
538
- - **Stable warning codes**: warnings carry stable string codes + positional context; consumers
539
- branch on `w.code`, so renaming a code is a breaking change.
540
- - **Zero runtime dependencies**: Node stdlib only (healthcare integrations vet every dependency).
541
- - **Dual ESM + CJS**: built with `tsup`, validated with `attw`.
542
- - **Immutability**: parsed models are immutable; mutation is via explicit methods.
543
- - **Profile system**: a `defineAstmProfile()` API for vendor quirks, with built-in profiles authored
544
- through the same public API. A profile only ever downgrades an _expected_, non-safety-critical warning
545
- to `PROFILE_QUIRK_APPLIED` (it never alters a value) and may force the raw-vs-framed transport; a
546
- default-deny safety gate refuses to tolerate any safety-critical deviation at definition time.
547
- - **Terminology recognizer, not a dictionary.** LIVD-aware LOINC recognition is bring-your-own
548
- (`applyLivd` over a consumer-supplied catalog): additive, advisory, and never a guessed LOINC. The
549
- catalog answers for the analyte identity, never the wire, and no LOINC validation of any kind is
550
- performed. No LOINC / SNOMED / LIVD data is bundled.
551
-
552
- ## Compatibility
553
-
554
- - **Record content: ASTM E1394 / CLSI LIS02-A2.** `H`/`P`/`O`/`R`/`C`/`Q`/`M`/`S`/`L` are read; an unrecognized type letter is surfaced, never dropped.
555
- - **Framing and transport: ASTM E1381 / CLSI LIS01-A2.** Modulo-256 checksum, frame-number sequencing, the 240-byte multi-frame split, and a pure `ENQ`/`ACK`/`NAK`/`EOT` receiver state machine.
556
- - **Delimiters come from the stream, never from an assumption.** They are read at every `H` and scoped forward, and records already read keep the set they were read with.
557
- - **Framed and raw TCP are both handled.** Serial always frames; over TCP it varies within one vendor, so `detectFraming` routes cobas 4800 and Iguana (framed) from cobas b121 (framing dropped).
558
- - **No named per-vendor profile ships.** The engine, the registry and the safety gate are public; the built-in set is `default` plus the corpus-grounded `referenceCorpus`. Named profiles for cobas, Sysmex, ADVIA, Mindray and Snibe stay gated behind a public, vendor-attributed quirk document, and firsthand inspection of the public corpus found the record layer spec-clean for them.
559
- - **Three behaviors are reasoned from this reader rather than cited to a clause.** LIS02-A2 sections 5.4 and 6.2 are withheld from CLSI's free sample and the paywalled editions were not read here, so the forward-scoping rule for redeclared delimiters, the Latin-1 wire encoding and the reserved-byte set are this package's reading, not a quotation.
560
- - **The result-status letter set is bound by no citable published source**, and every interpreted status reports that in its `vocabulary` rather than implying an attribution it does not have. Abnormal flags are graded against HL7 v3 ObservationInterpretation and report that vocabulary too, recognized or not.
561
- - **The wire is read as Latin-1**, one byte per character. A character above `U+00FF` handed to the frame encoder is a typed error rather than a silently truncated byte, and a raw `STX`, `ETB` or `ETX` byte in a record is refused for the same reason: framing has no escape sequence for them.
562
- - **No LOINC, SNOMED or LIVD dictionary is bundled**, and no LOINC is ever guessed. You bring the catalog and its license obligations.
563
-
564
- ### What it covers
565
-
566
- - **Records (E1394 / LIS02-A2).** `H`/`P`/`O`/`R`/`C`/`Q`/`M`/`S`/`L` are read with per-header
567
- delimiter self-declaration and escape decode, so an escaped delimiter inside a value reads as one
568
- component. Result semantics are modeled and fail-safe: abnormal flags graded against the HL7 v3
569
- ObservationInterpretation code system, result status (a correction `C` or cancel `X` never reads as
570
- active-final), reference ranges kept verbatim, and a missing unit flagged rather than defaulted.
571
- Every interpreted flag and status reports the vocabulary it was graded against, recognized or not,
572
- so an unknown code is distinguishable from one the library has not caught up to; the status letter
573
- set reports that **no citable published source** binds it. The practice-, laboratory-, and third patient IDs stay
574
- distinct; a `C` comment attaches to its parent by position and an orphan is surfaced, never dropped;
575
- a partial timestamp is preserved and flagged, never zero-filled. A `Q`-bearing message is classified
576
- as a host query and is never read as a result set, and `M`/`S` vendor QC and calibration records are
577
- surfaced verbatim rather than interpreted into clinical fields.
578
- - **Framing and transport (E1381 / LIS01-A2).** `decodeAstmFrames` turns a framed byte stream into
579
- frames plus reassembled record bytes: it verifies the modulo-256 checksum (a bad frame is surfaced
580
- untrusted and never merged), tracks frame-number sequencing (a gap is never silently bridged), and
581
- reassembles the 240-byte-limited multi-frame records. `detectFraming` routes framed streams (serial,
582
- cobas 4800, Iguana) from raw ones (cobas b121, framing dropped), and `ltpReduce` is a pure,
583
- socket-free `ENQ`/`ACK`/`NAK`/`EOT` receiver state machine that NAKs a frame the codec did not vouch
584
- for rather than fabricating an ACK. `parseFramedAstm` composes both layers at the edge.
585
- - **Emit.** `serializeAstmRecords` and `buildAstmMessage` emit canonical `H|\^&` records with embedded
586
- delimiters re-escaped and nothing clinical fabricated; `composeAstmFrames` and `serializeFramedAstm`
587
- frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
588
- construction, and a delimiter set that fails any of the three conditions readback requires (one
589
- character per separator, no `CR`/`LF`, no two the same) is a typed error rather than bytes written
590
- and lost. Those three read the set alone, so each record is additionally checked against the set it
591
- is written with: a separator equal to a record's own type letter escapes that letter away and the
592
- record re-reads as a different record, which is `ASTM_EMIT_TYPE_LETTER_COLLISION` rather than
593
- output. That second check is about **transcoding**, not about which set you asked for, so it can
594
- fire with no delimiter argument at all: a stream read under a vendor's own set may carry a record
595
- whose type letter the canonical set escapes away, and `serializeFramedAstm` refuses it for the same
596
- reason. A frame carries **bytes**, so a record handed to `composeAstmFrames` as a `string` is one
597
- byte per character and a character above `U+00FF` is a typed error too: the encoder will not pick a
598
- character encoding for you, and it never quietly writes a different character than the one you gave
599
- it. Encode such content yourself and pass the `Uint8Array`. A raw `STX`, `ETB` or `ETX` **byte** in a
600
- record is a typed error as well, in either form: those three are what the decoder reads as the shape
601
- of a frame, framing has no escape sequence for them, and writing one through truncated the frame at
602
- that byte, silently losing a whole record whenever the two bytes after it happened to be the short
603
- frame's checksum. The **record** layer still carries them, because a returned string is not yet on a
604
- wire and a raw-transport consumer round-trips such a value exactly. `startFrameNumber` lets you
605
- compose one transfer across several calls, and `composeAstmFrames` checks it before it reads a
606
- record: a frame's number is a single ASCII digit, so a value that is not a whole number from `0` to
607
- `7` is a typed error rather than whatever byte the arithmetic truncated to. The round trip above is
608
- the **default** start; a non-default one writes a continuation of a sequence already in progress,
609
- and a continuation read on its own opens on a frame-sequence gap, so its first record is warned
610
- about and not emitted.
611
- - **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
612
- downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
613
- ever altering a value, behind a safety gate that refuses to tolerate any result value, flag,
614
- status, range, or units warning, any patient or comment context, any message-kind ambiguity, any
615
- unrecognized record type, and any frame or transport integrity warning. The gate runs when a
616
- profile is defined **and** again when a warning would be downgraded, so a profile assembled as a
617
- plain object rather than through `defineAstmProfile()` gets the same answer. A profile can never
618
- make a bad checksum "ok", a cancelled result read "final", or quiet the warning that says a
619
- message boundary went unrecognized. Named per-vendor profiles await a public, vendor-attributed
620
- quirk document.
621
- - **Terminology, bring your own.** `applyLivd(msg, catalog)` maps an analyzer's local test code to a
622
- LOINC from a consumer-supplied IICC LIVD catalog as an additive, advisory annotation that never
623
- mutates the raw code or value and never guesses a LOINC. The catalog is consulted whenever a
624
- vendor local code is present and is keyed on that code alone; a value in the Universal Test ID's
625
- first component is carried verbatim as an **unvalidated wire value**, never validated, never
626
- reported as a LOINC, and never used as a lookup key. No LOINC, SNOMED, or LIVD dictionary is
627
- bundled: the package stays a structural recognizer and you bring the catalog.
628
-
629
- ### An unescaped ampersand does not cost you the rest of the record
630
-
631
- An escape sequence is the escape character, **one** body character, and the escape character again
632
- (`&F&` `&S&` `&R&` `&E&`). An escape character that heads no such sequence is not an escape: it is
633
- read as the literal character it is, it opens no atom, and `ASTM_UNPAIRED_ESCAPE_CHARACTER` reports
634
- it. So `R|1|^^^687|28.6&|U/L||N||F` reads a value of `28.6&` with units `U/L` and status `final`,
635
- and `O&Brien` in a surname keeps the patient's birth date and sex.
636
-
637
- The parser does not decide what the sender meant by the character: it keeps the byte that arrived
638
- and says so. The spec-clean way to send a literal escape character is `&E&`, which is what this
639
- package's serializer emits, so a stream it produced never trips the code. The code is **tolerable**,
640
- so a vendor profile can expect it on a feed that sends bare ampersands and still parse
641
- `{ strict: true }`.
642
-
643
- **One escape shape still costs a field boundary, and it has a code of its own.** A real
644
- three-character sequence is opaque by design, which is what keeps `&F&` one token under a set that
645
- names `F` as a delimiter. So where the body is an **unrecognized** character that is itself a
646
- delimiter in force (`&|&` under the canonical set)
647
- that delimiter does not split, and every field after it shifts: `R|1|^^^687|28.6&|&U/L||||F` reads a
648
- value of `28.6&|&U/L` with no units and status `unspecified`. That reading is unchanged, and
649
- narrowing the atom to change it would break the guarantee the atom exists for. What such a record now
650
- raises, alongside the tolerable `ASTM_UNKNOWN_ESCAPE_SEQUENCE`, is
651
- `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`, which **no profile may tolerate**, so a
652
- `{ strict: true }` parse refuses it even under the shipped `referenceCorpus`. The narrower code fires
653
- only where the unrecognized body is one of the three splitting roles in force. Two exclusions are
654
- deliberate: the escape role, because nothing splits on it, and every **recognized** mnemonic, because
655
- `&F&` under a set naming `F` as the repeat delimiter is the sender escaping the field separator on
656
- purpose, and reporting that would report the escape mechanism working as a defect.
657
-
658
- Read it as a report, not a repair. It also does not survive a re-emit: the serializer rewrites the
659
- preserved sequence into recognized mnemonics, and that stream says the same value unambiguously, so a
660
- second-generation read is silent and is right about its own bytes. The first read of the wire bytes
661
- is where the condition exists to be caught.
662
-
663
- **The mirror of it costs a boundary in the other direction, and it also has a code of its own.**
664
- Sequences are matched greedily and leftmost, so the escape character that closes one cannot also open
665
- the next. Where it could have, the same bytes carry two alignments that disagree by one boundary:
666
- `R|1|^^^687|28.6&Z&|&U/L||||F` reads a value of `28.6&Z&` and units of `&U/L` under the alignment
667
- taken, and reads as a single unsplit field carrying both under the other. Every byte is preserved and
668
- the leftmost reading is kept (picking the other one would be a different guess with no more evidence
669
- behind it), but the boundary it hands you is a choice, so it raises
670
- `ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT`, which **no profile may tolerate**. Both codes the condition
671
- raised before (`ASTM_UNKNOWN_ESCAPE_SEQUENCE` and `ASTM_UNPAIRED_ESCAPE_CHARACTER`) are tolerable, so
672
- a strict parse under a profile naming them used to accept it. Two exclusions again. A **recognized**
673
- mnemonic before the delimiter is silent, because the reading taken interprets a construct (`&F&` is
674
- the sender escaping a separator, which is what the mechanism is for) while the competitor's body is a
675
- delimiter character the codec usually cannot interpret, so its own vocabulary prefers the reading
676
- taken. That is **not** the same as the reading taken being conformant, and the exclusion is wider
677
- than that argument: `28.6&F&|&U/L` reads a value of `28.6|` and units of `&U/L` while raising only
678
- the tolerable `ASTM_UNPAIRED_ESCAPE_CHARACTER`, and a declared set naming a mnemonic letter as a
679
- splitting delimiter makes both alignments interpret one construct each with neither preferred. The
680
- first of those is covered by a second code, below; the second is measured and recorded as an open
681
- residue. The other exclusion is a delimiter with no escape character two positions past it, which is
682
- no competing alignment at all. What does fire is a subset of what already raises
683
- `ASTM_UNKNOWN_ESCAPE_SEQUENCE`. It does not survive a re-emit either, for the same reason as its
684
- mirror: catch it on the first read.
685
-
686
- **And where that gained boundary is a FIELD boundary, it moves a result's status, which is a
687
- different question and so a different code.** The two alignments resume one character apart, so they
688
- disagree about the bytes after the boundary, not only about it (they can resync later, and the
689
- class where they do is named below). Where the escape character the
690
- reading taken resumes on heads no sequence this reader can _interpret_ (none at all, or one whose
691
- body is not a recognized mnemonic and is therefore preserved verbatim rather than read), that
692
- reading bought the boundary with bytes it cannot read while the competing alignment is the one that
693
- can use them. On the field separator that
694
- matters clinically: `R|1|^^^687|28.6&F&|&U/L||||F` reads **nine** fields under the alignment taken
695
- and **eight** under the other, so the sender's trailing `F` lands in field 9, the result status,
696
- under the first and in no field at all under the second. The parse hands back units of `&U/L` and a
697
- status of **`final`**, and both are consequences of the alignment rather than values the sender put
698
- in those slots. That raises `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS`, which **no profile may
699
- tolerate**; before it existed the only warning on that stream was the tolerable
700
- `ASTM_UNPAIRED_ESCAPE_CHARACTER`, so a strict parse under a legal profile accepted it. The reading is
701
- unchanged: it reports the shift rather than repairing it. It is wired to the **field** separator
702
- only, because a gained repeat or component boundary divides one field and so moves no field-indexed
703
- slot. **That bound is a choice, not a consequence**: components are modeled inside a field, so a
704
- gained repeat or component boundary does reach a modeled slot, and each of those two has a code of
705
- its own below. It stays silent in exactly one case,
706
- where the trailing escape character heads a sequence this reader RECOGNIZES: that is the escape
707
- mechanism working, and refusing it would refuse well-formed traffic. That is the only tail on which
708
- a stream's escaping can be clean, and so the only exclusion, **wherever the escape role is a
709
- character distinct from the three splitting roles**. Where a header names the escape character in a
710
- splitting role too, these codes can fire with neither escape report beside them, and what refuses the
711
- stream is `ASTM_RECORD_DELIMITER_ROLE_COLLISION` instead. **That silence is a trade
712
- and not a claim that nothing was lost there**: the gained field boundary is exactly as real, and on
713
- that tail it is `warnings: []`, so `R|1|^^^687|28.6&F&|&F&U/L||||F` reads nine fields against the
714
- competing alignment's eight and hands back a status of `final` with nothing reported at all. Read
715
- the raw line when an escape character sits next to a delimiter, whether or not anything fired.
716
- **The shift has one measured exception, and it is named rather than left to be found**: where the
717
- sequence past the boundary carries the field separator itself as its body, the reading taken holds
718
- that character inside an opaque atom while the competing alignment splits on it, so the two readings
719
- read the **same number** of fields in **different places**. `R|1|^^^687|28.6&F&|&|&U/L||||F` reads
720
- nine fields under both, with the status `F` in field 9 under both, and what differs is the units.
721
- The code still fires there, which is over-reporting relative to the field indexes and never
722
- under-reporting, and that class costs no stream its disposition, because
723
- `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE` has already refused it. It does not survive a re-emit
724
- either: catch it on the first read.
725
-
726
- **And where that gained boundary is a REPEAT boundary, nothing shifts and the field can still be
727
- read short.** What the report says is that the field is read as more repeats than the competing
728
- alignment gives it. A field's modeled value and components are taken from its **first repeat
729
- alone**, so where the gained boundary is the **first** one everything past it stays on the wire,
730
- stays in `repeats`, and leaves every modeled slot. On the canonical set, `R|1|^^^687|28.6&S&\&U/L|U/L||||F` reads a value of `28.6^`
731
- and drops `&U/L`, and `R|1|&F&\&687|28.6|U/L||||F` reads a Universal Test ID of one component
732
- holding a decoded field separator, so the local code `687` is in no modeled slot and the record is
733
- left with no code to key on at all, only an unvalidated wire value nobody wrote. A patient name
734
- loses its given and middle names the same way. That raises `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`, which **no profile may tolerate**;
735
- before it existed the only warnings on those streams were tolerable ones, so a strict parse under a
736
- legal profile accepted a truncated value and an emptied test identity. The reading is unchanged: it
737
- reports the gained boundary rather than repairing it. **At a LATER boundary it fires and no modeled
738
- slot moves**, because the first repeat is then the same under both readings; that is over-reporting
739
- relative to the modeled slots and never under-reporting, and it is measured rather than assumed. Its
740
- tail bound is the shift report's, for the same reason, and so is its one exclusion:
741
- `28.6&R&\&R&U/L` is the repeat separator escaped, written, and escaped again, and refusing it would
742
- refuse a well-formed stream. **That silence is a trade, not a claim that nothing was lost**:
743
- `28.6&S&\&S&U/L` still reads a value of `28.6^` with `warnings: []`. **The truncation has the same
744
- one exception as the shift**: where the sequence past the boundary carries the repeat separator
745
- itself, the two readings read the same number of repeats in different places, so the field is not
746
- read as more repeats at all, and that class was already refused by
747
- `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`. It is wired to the **repeat** separator only; a gained
748
- **component** boundary reaches a modeled slot differently, and has its own code below. It does not
749
- survive a re-emit either: catch it on the first read.
750
-
751
- **And where that gained boundary is a COMPONENT boundary, nothing leaves the record and the slots
752
- MOVE.** Components are modeled inside a field, so every component after the gained boundary sits
753
- further right than the competing alignment puts it, by a displacement that is **not fixed**: one
754
- place where the field carries a single contested construct, one more for each additional one, and
755
- none at all on the tie class. Counting warnings does not give it. On the canonical set,
756
- `R|1|&F&^&GLU^L^687|28.6|U/L||||F` reads a Universal Test ID of four components, so `L` is the coding
757
- scheme and `687` the vendor's local code; the competing alignment reads three, and `687` is the
758
- **coding scheme**. A code-system selector and a vendor's local code are not the same thing.
759
- `P|1||MRN-0001||DOE&F&^&JANE^A||19700101|F` reads a given name of `&JANE` and a middle name of `A`,
760
- where the competing alignment makes `A` the **given** name with no middle name at all. That raises
761
- `ASTM_RECORD_ALIGNMENT_SHIFTED_COMPONENTS`, which **no profile may tolerate**; before it existed the
762
- only warning on either stream was the tolerable `ASTM_UNPAIRED_ESCAPE_CHARACTER`, so a strict parse
763
- under a legal profile accepted both. The reading is unchanged: it reports the moved slots rather than
764
- repairing them. **Every gained boundary at or before the last modeled component index moves them,
765
- not only the first**, because the shift propagates to the end of the component list, which is where
766
- this differs from the repeat role. **Two bounds run the other way and it fires inside both**,
767
- over-reporting and never under: past the last modeled index nothing named moves (a name models three
768
- components, a Universal Test ID four), and inside a later repeat nothing modeled moves at all. Its
769
- tail bound is the other two reports', for the same reason, and so is its one exclusion, **and that
770
- silence is a trade rather than a claim that nothing was lost**: `&F&^&F&GLU^L^687` still reads one
771
- component more than the competing alignment, with `warnings: []`. **A THIRD bound runs the other way
772
- here, and unlike the two above it is about the bytes past the boundary rather than where the
773
- boundary sits**: where the sequence past it carries the component separator itself, the two readings
774
- read the same number of components in different places, so `DOE&F&^&^&JANE^A` reads three components
775
- under both with `A` the middle name under both, and that class was already refused by
776
- `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`. What holds wherever any of the three codes fires is
777
- that the two readings disagree and that both consume every byte, so neither is forced. It does not
778
- survive a re-emit either: catch it on the first read. With this the three splitting roles are all
779
- wired, and there is no fourth: nothing splits on the escape role.
780
-
781
- ### A header that names one character in two delimiter roles
782
-
783
- ASTM messages are self-describing: the `H` record declares the four delimiters and a conformant
784
- reader follows them. A declaration whose **field** separator is also one of the other three is
785
- refused outright, because the four roles would be indistinguishable. A declaration where two of the
786
- _remaining_ three agree is still read, because the stream is readable and refusing it would drop
787
- records the sender did send, but the boundary between those two roles is no longer in the bytes.
788
-
789
- Under `H|^^&`, where the repeat and component roles are both `^`, a field a canonical sender would
790
- have written as two repeats of two components reads back as four repeats of one component each.
791
- Under `H|\&&`, where the component and escape roles are both `&`, the same character splits
792
- (`A&B` is two components) or opens an escape atom (`A&F&B` is the single component `A|B`) depending
793
- only on what follows it.
794
-
795
- Such a header now raises `ASTM_RECORD_DELIMITER_ROLE_COLLISION`, at the header that put the set into
796
- force, once rather than once per colliding pair. A later header restating the set already in force
797
- warns nothing, on the same rule that governs the other delimiter warnings. The code is **not** tolerable. That matters
798
- because every such set is by definition non-canonical, so the only warning it used to raise was
799
- `ASTM_NONSTANDARD_DELIMITERS`, which is tolerable: a profile expecting an ordinary vendor set left a
800
- `{ strict: true }` parse accepting a declaration whose own field tree cannot be recovered. Emit has
801
- always refused these sets (`ASTM_EMIT_INVALID_DELIMITERS`), which is how the gap first showed: a
802
- message that parsed clean threw when it was serialized back against its own declared delimiters.
125
+ **One limit about this repository rather than about your data.** The committed PHI scan (`pnpm phi-scan`) is a starter. It enforces a cross-cutting SSN and non-test-email floor plus a check on the `P` record's name components, and structured field-level detection for address, phone, and comment free text is not implemented here. It guards this repository's own fixtures, tests, and examples; it makes no claim about a stream you feed the library. Every record in this file and in the test suite is synthetic.
803
126
 
804
127
  ## Contributing
805
128
 
806
- Issues and pull requests are welcome at [github.com/cosyte/astm](https://github.com/cosyte/astm). Ask a question by opening an [issue](https://github.com/cosyte/astm/issues); there is no separate forum.
129
+ Issues and pull requests are welcome at [github.com/cosyte/astm](https://github.com/cosyte/astm). Ask a question by opening an [issue](https://github.com/cosyte/astm/issues); there is no separate forum. The organization's [contributing guide](https://github.com/cosyte/.github/blob/main/CONTRIBUTING.md) and [code of conduct](https://github.com/cosyte/.github/blob/main/CODE_OF_CONDUCT.md) apply here.
807
130
 
808
131
  A contribution has to clear the same gates CI runs, and every one of them runs locally:
809
132
 
@@ -819,10 +142,14 @@ pnpm run check:no-internal-refs
819
142
  pnpm run phi-scan
820
143
  ```
821
144
 
822
- Three house rules reject a change on sight. **No real patient data**, in a fixture, a test, an example or a commit message: every record in this repository is synthetic, and `pnpm phi-scan` runs on every commit. **No em dash anywhere**, including commit messages (`pnpm run check:no-emdash`); write a comma, a colon, a period or parentheses instead. **No internal project bookkeeping on a public surface** (`pnpm run check:no-internal-refs`): this file, the shipped docs and every JSDoc comment say what the software does, never how the change got made.
145
+ Three house rules reject a change on sight. **No real patient data**, in a fixture, a test, an example, or a commit message: every record in this repository is synthetic, and `pnpm phi-scan` runs on every commit. **No em or en dash anywhere**, including commit messages (`pnpm run check:no-emdash`); write a comma, a colon, a period, or parentheses instead. **No internal project bookkeeping on a public surface** (`pnpm run check:no-internal-refs`): this file, the shipped docs, and every JSDoc comment say what the software does, never how the change got made.
823
146
 
824
147
  A behavior change needs a test that fails without it. Renaming a published warning or error code is a breaking change; adding one is not, though it can change which streams a `{ strict: true }` parse refuses, so say so.
825
148
 
149
+ ## Trademarks
150
+
151
+ ASTM, CLSI, LOINC, SNOMED CT, and the analyzer names in this package's documentation are the property of their owners, and Cosyte is not affiliated with, endorsed by, or sponsored by any of them. The names identify the standards this package reads and the analyzers whose behavior it describes. See [TRADEMARKS.md](https://github.com/cosyte/astm/blob/main/TRADEMARKS.md).
152
+
826
153
  ## License
827
154
 
828
- MIT (SPDX identifier `MIT`). Copyright (c) 2026 Cosyte. Full text: [LICENSE](https://github.com/cosyte/astm/blob/main/LICENSE).
155
+ MIT (SPDX identifier `MIT`). Copyright (c) 2026 Cosyte LLC. Full text: [LICENSE](https://github.com/cosyte/astm/blob/main/LICENSE).