@cosyte/hl7 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,710 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ Versions and publishing are managed with [Changesets](https://github.com/changesets/changesets);
8
+ this file is maintained by hand (Changesets handles the version bump and publish only).
9
+
10
+ ## [Unreleased]
11
+
12
+ The first pre-alpha release (`0.0.1`) will ship the complete v1 API surface below. An earlier
13
+ `0.1.0` tag was prepared but never published, so the package begins its public history at `0.0.x`,
14
+ per the cosyte version ladder (`0.0.x` until first alpha).
15
+
16
+ ### Added
17
+
18
+ - **Full documentation Diátaxis spine + doc/code-agreement gate (DOCS-CONTENT-P1).** `docs-content/`
19
+ gains **Installation**, **Quickstart**, **Guides**, and **Troubleshooting** (with Known
20
+ Limitations) categories on top of the existing Overview + Core Concepts, authored from the real API
21
+ against synthetic messages — the template of record every other cosyte parser's docs copy. A new
22
+ `test/docs-content.test.ts` runs `@cosyte/vitest-config@^0.0.2`'s `docSnippetSuite()` over the
23
+ pages, so every ` ```ts runnable ` snippet is compiled, executed against the built package, and its
24
+ inline `// =>` assertions checked — a documented example can never silently drift from the code.
25
+ Bumps the `@cosyte/vitest-config` devDependency `^0.0.1 → ^0.0.2`. Documentation + test-tooling
26
+ only — no runtime/API change; the published artifact is unchanged.
27
+
28
+ ### Changed
29
+
30
+ - **Dropped the "Dogfooded in production" claim from the README feature list.** The bullet asserted
31
+ the package was "used internally on healthcare-integration projects"; no engagement has run it, so
32
+ the claim was not accurate and has been removed ahead of the first public release. `README.md`
33
+ ships inside the published tarball, so this is a user-visible docs change. No runtime/API change.
34
+
35
+ ### Fixed
36
+
37
+ - **The Release workflow can actually start.** `.github/workflows/release.yml` calls the shared
38
+ `cosyte/.github` pipeline, which requests `contents`/`id-token`/`pull-requests: write`, but declared
39
+ no `permissions:` of its own — so it inherited the repo default of `contents: read`. A called
40
+ workflow may only downgrade the caller's `GITHUB_TOKEN`, never escalate it, so GitHub rejected the
41
+ workflow at startup (~1s, no jobs, no logs). Every Release run from June 2026 until now failed this
42
+ way, unnoticed, because a `startup_failure` produces no logs to read. The caller job now declares
43
+ the three scopes explicitly. CI-only — no runtime or API change.
44
+
45
+ - **The `VERSION` export now tracks `package.json`, and the missing `version` script is restored
46
+ (VERSION-SYNC).** Two latent release bugs, both of which would have bitten at the first publish.
47
+ (1) `VERSION` was hardcoded `"0.0.0"` in `src/index.ts` while `changeset version` bumps only
48
+ `package.json`, so a published `0.0.1` would have shipped an export reading `"0.0.0"` — every
49
+ consumer asserting on or logging `VERSION` told the wrong version of the parser they were running.
50
+ New `scripts/sync-version.mjs` rewrites the constant from `package.json` (idempotent; exits
51
+ non-zero if the declaration is renamed rather than silently no-op'ing). (2) **No `version` script
52
+ existed at all** — the shared `cosyte/.github` release workflow drives Changesets with
53
+ `version: pnpm run version`, which would have failed with `ERR_PNPM_NO_SCRIPT`, so the "Version
54
+ Packages" PR could never have been opened. `test/sanity.test.ts` now compares `VERSION` against
55
+ `package.json` rather than asserting its shape against a regex — the old test would have stayed
56
+ green through exactly this drift. Ported from `@cosyte/mllp` (MLLP-10); `hl7` is the canonical
57
+ form the siblings mirror. No version bumped, nothing published — still `0.0.0`.
58
+
59
+ - **Value reads no longer double-decode (HL7-VALUE-REDECODE). Behavior change on a rare, spec-legal
60
+ input (pre-alpha).** The tokenizer already unescapes every subcomponent once on parse, but
61
+ `Field.value`, the dot-path `get()`/`resolvePath`, and the composite coercions (`asXpn`/`asCwe`/`asCx`/…
62
+ via the shared `readSubcomponent`, plus `asNm`/`asTs`) ran a **second** `unescape` over the
63
+ already-decoded value. A value whose own decoded bytes look like an escape was therefore decoded
64
+ twice: a wire `\E\F\E\` decodes once to the literal `\F\`, which the second pass wrongly turned into
65
+ the field separator `|`. Readers now return the once-decoded subcomponent verbatim
66
+ (`field(5).value` of `A\E\F\E\B` is `A\F\B`, not `A|B`); the common single-escape case is unchanged
67
+ (`\F\` → `|`), and emit stays byte-verbatim (the HL7-ESC raw overlay is a separate, untouched path).
68
+ Found by the HL7-ESC conformance-refuter; pinned by `test/value-redecode.test.ts`.
69
+
70
+ ### Changed
71
+
72
+ - **Datetime precision + timezone fidelity — the TS/DTM composite (roadmap
73
+ Phase N). Breaking (pre-alpha).** `field.asTs()` and every helper datetime now
74
+ return the fidelity `TS` (`DtmParts`: `{ raw, valid, precision, year, month
75
+ (1–12, spec-native), day, hour, minute, second, fractionalSeconds (verbatim),
76
+ hasTimezone, offsetMinutes }`) instead of the old `{ raw, date }`. HL7 v2
77
+ Ch. 2A DTM sets a value's **precision** by how many characters are populated,
78
+ and a missing offset defaults to the **sender's** local zone — not UTC. The
79
+ prior code zero-filled truncations (`|1970|` → `1970-01-01T00:00:00Z`) and
80
+ coerced an offset-less value to UTC, which silently shifted a day-only birth
81
+ date `|19880705|` to the previous calendar day in any negative-offset zone
82
+ [S-DTM-IMPL]. Now precision is preserved (no zero-fill), a missing offset is
83
+ **flagged** (`hasTimezone: false`) and never resolved, and no eager `Date` is
84
+ built. An absolute instant is materialized **only on explicit request** via
85
+ `dtmToDate(ts, { assumeOffsetMinutes? })`, which returns `undefined` for an
86
+ offset-less value rather than guessing UTC. Applies to `meta.timestamp`,
87
+ `patient.dateOfBirth`, `visit.admit/dischargeDateTime`,
88
+ `observations.observedDateTime` + `TS`/`DT` observation values,
89
+ `allergies.onsetDate`, `diagnoses.dateTime`, `insurance.effective/expirationDate`,
90
+ and `immunizations.administered/expirationDate` — each `Date → TS`. Fidelity
91
+ only: no localization, timezone conversion, or arithmetic; `HHMM=0000` is never
92
+ rolled to the previous day. New public exports: `parseDtm`, `formatDtm`,
93
+ `dtmToDate`, and types `DtmParts` / `DtmPrecision` / `DtmToDateOptions`. **No
94
+ warning-code change** (a missing timezone is the structural `hasTimezone: false`
95
+ flag, not a warning). See `docs-content/spec-notes-datetime-precision.md`.
96
+
97
+ ### Removed
98
+
99
+ - **`parseHl7Timestamp` (and its `ParseHl7TimestampOptions` type) — removed
100
+ (Phase N, breaking).** The Date-returning, UTC-assuming timestamp helper is
101
+ replaced by the fidelity `parseDtm` + explicit `dtmToDate`, with
102
+ `parseDtmCascade` covering the MSH-7 user-format fallback path.
103
+
104
+ ### Fixed
105
+
106
+ - **Escape-sequence emit is now byte-verbatim across the full alphabet
107
+ (HL7-ESC).** The decoded tree conflated recognize-and-preserve escapes (`\H\`,
108
+ `\N\`, `\.sp\`/formatting, `\Cxxyy\`/`\Mxxyyzz\` charset switches, vendor
109
+ `\Z..\`) and hex escapes (`\X41\`) with literal backslashes, so serialize
110
+ **double-escaped** the former (`\H\` → `\E\H\E\`) and **decoded** the latter
111
+ (`\X41\` → `A`, and non-canonical hex casing `\X0d\` → `\X0D\`). Emitted output
112
+ was spec-clean and lossless-as-text but **not byte-verbatim** — a byte-level
113
+ MSA-2/MSH-10 correlation (HL7 v2 §2.9.2.2) could fail. The tokenizer now
114
+ records each affected subcomponent's **original wire bytes** in an internal
115
+ `RawComponent.rawSubcomponents` overlay and the serializer emits from it
116
+ verbatim, so `parseHL7(x).toString()` and `Field.text` reproduce the sender's
117
+ exact escape bytes. `buildAck`'s MSA-2 echo is byte-exact for a
118
+ default-delimiter sender; a **custom-delimiter** sender is re-delimited
119
+ spec-cleanly (the overlay is bypassed when the ACK's encoding differs from the
120
+ inbound's — the sender's raw bytes would otherwise corrupt MSA-2's structure
121
+ under default delimiters — and the decoded id re-parses back correlation-correct
122
+ per §2.9.2.2). **The `\X..\` policy is
123
+ decode-on-read, re-encode-on-emit** (the decoded value surface is unchanged;
124
+ the wire bytes — value and casing — are preserved). Delimiter/newline escapes
125
+ are unaffected (they already round-trip through `reescape`); a decoded CR still
126
+ re-emits as `\X0D\` to protect wire framing. **No public-surface break** —
127
+ `Field.value` and every composite/`toJSON` read the same decoded values as
128
+ before; the overlay is `@internal` and absent from escape-free messages. See
129
+ `docs-content/spec-notes-escapes.md`.
130
+ - **PHI leak in the warning surface — `fieldWhitespaceTrimmed` and `unknownEscapeSequence` no
131
+ longer embed field-body content (HL7-TOKENIZE-PHI).** Both builders previously interpolated
132
+ slices of the field VALUE directly into `Hl7ParseWarning.message`
133
+ (`fieldWhitespaceTrimmed`'s before/after strings; `unknownEscapeSequence`'s raw escape body, and
134
+ on an unterminated escape the **entire rest of the field**), so a clinical NTE-3/OBX-5 free-text
135
+ value (PHI) carrying a stray `\` or surrounding whitespace could surface a payload fragment in
136
+ `Hl7Message.warnings` — a leak, since every other warning builder is structural-facts-only.
137
+ `fieldWhitespaceTrimmed`'s signature changed to take leading/trailing **counts** instead of the
138
+ original/trimmed strings; `unknownEscapeSequence` now reports only the escape body's **length**
139
+ plus, when the body's first character is a recognized HL7 escape-identifier letter (`F S T R E
140
+ X C M Z H N` or a `.`-prefixed formatting escape — structural HL7 grammar, not PHI), that single
141
+ letter — never the body text. A new `unterminatedEscapeSequence` factory (still
142
+ `UNKNOWN_ESCAPE_SEQUENCE`) covers the unterminated-escape case with no content and no
143
+ tail-length. **No change to parse tolerance or round-trip fidelity** — the escape sequence and
144
+ the whitespace are still preserved verbatim in the parsed/emitted output; only the WARNING
145
+ message lost the body. The `FIELD_WHITESPACE_TRIMMED` / `UNKNOWN_ESCAPE_SEQUENCE` codes are
146
+ unchanged and stable (see `test/warning-codes.snapshot.test.ts`) — this is a message-format fix,
147
+ not a breaking change to the code registry.
148
+ - **`buildAck` now echoes the full inbound MSH-10 field into MSA-2** — the raw
149
+ field structure is carried over whole (delimiters and repetitions included)
150
+ instead of the component-1-only `meta.controlId` scalar. A vendor-quirk
151
+ control id carrying an unescaped delimiter (`ID^X`) was previously truncated
152
+ to `ID` silently, under a confident positive ACK — a sender correlating on
153
+ the raw MSH-10 bytes (as `@cosyte/mllp`'s client does) would never match the
154
+ ACK and resend indefinitely (HL7 v2 §2.9.2.2: an MSA-2 mismatch is a
155
+ correlation failure). The no-correlation fail-safe now keys on the raw field
156
+ carrying any content, so a leading-delimiter id (`^X`) correlates instead of
157
+ being spuriously downgraded. The echo is now **byte-verbatim** across the full
158
+ escape alphabet (see the escape-fidelity entry below) — the earlier
159
+ canonicalization limits (hex escapes decoding to their byte, preserved escapes
160
+ re-emitting as escaped literal text) are resolved. The only remaining
161
+ transform is structural: the ACK emits with default encoding characters and
162
+ trailing insignificant empties canonicalize (D-02).
163
+ - **`reescape` now emits a literal CR in decoded content as its `\X0D\` hex
164
+ escape** instead of passing it through raw. A spec-legal `\X0D\` in any
165
+ inbound field decoded to a bare CR — the HL7 segment separator — and
166
+ re-serializing it corrupted the emitted message's framing (a phantom
167
+ segment split mid-field, silently). Reachable through `buildAck`'s MSA-2
168
+ echo among every other emit path; now structurally safe and round-trip
169
+ stable.
170
+ - **`interpretAck` surfaces MSA-2 whole** (read-side symmetry) —
171
+ `Acknowledgment.controlId` is now the field's canonical wire text
172
+ (`Field.text`), not its first component.
173
+
174
+ ### Added
175
+
176
+ - **Scheduling / document / charge breadth helpers — `appointments()`,
177
+ `documents()`, `charges()` (roadmap Phase Q, P2).** Three new message-family
178
+ helpers on `Hl7Message`, each mirroring the existing extractor pattern (typed,
179
+ frozen, never-throws, not memoized):
180
+ - **`appointments()`** over **SIU** (S12/S13/S14/S15/S26) — projects each
181
+ **SCH** into a typed `Appointment`: placer/filler appointment ids (SCH-1/2),
182
+ **SCH-25 filler status** (Table 0278, verbatim), **SCH-11** start/end timing
183
+ (TQ.4/TQ.5, fidelity `TS`), and the **AIS/AIG/AIL/AIP** resource groups that
184
+ follow it — surfaced as `AppointmentResource`s tagged `service` / `general` /
185
+ `location` / `personnel` (the personnel/provider resource also carries a
186
+ typed `XCN`).
187
+ - **`documents()`** over **MDM** (T02/T04/T06) — projects each **TXA** into a
188
+ typed `ClinicalDocument` with the OBX narrative body grouped positionally
189
+ under it. **Completion status (TXA-17, Table 0271) and availability status
190
+ (TXA-19, Table 0273) are surfaced as DISTINCT fields and NEVER conflated** — a
191
+ document can be _available_ before it is _authenticated_, and reading a
192
+ preliminary document as final is the clinical harm this split prevents. Both
193
+ are verbatim / provenance-only.
194
+ - **`charges()`** over **DFT** (P03) — projects each **FT1** into a typed
195
+ `Charge`: FT1-6 transaction type (Table 0017), FT1-7 transaction code (the
196
+ institution charge code, CWE), FT1-11/12 extended/unit amounts, and the
197
+ repeating FT1-19 diagnosis linkage. **No billing logic and no
198
+ money-as-float** — amounts are surfaced as their **canonical CP wire text**
199
+ (e.g. `"150.00^USD"`), never parsed to a `number`.
200
+
201
+ New public types: `Appointment`, `AppointmentResource`, `ClinicalDocument`,
202
+ `Charge`. Breadth helpers only — not a scheduling-workflow state machine,
203
+ signature verification, or a claims/pricing engine (roadmap §9
204
+ known-limitations). Never throws on malformed input (HELPERS-07); missing
205
+ fields → omitted keys.
206
+
207
+ - **NTE narrative grouping — notes attached to their parent by position (roadmap
208
+ Phase P, P1).** NTE (Notes and Comments) segments are now grouped to their
209
+ parent **by position** and surfaced on the relevant helper output. An NTE
210
+ inherits its meaning entirely from the segment it immediately follows (HL7 v2
211
+ Ch. 2 — no link field), so attachment is positional: in an ORU
212
+ (`{ [ORC] OBR [{NTE}] [{ [OBX] [{NTE}] }] }`, Ch. 7) a note after `OBX` lands on
213
+ **that result** (`observation.notes`), a note after `OBR`/`ORC` on the **order**
214
+ (`order.notes` — ORC notes first, then OBR), and a note after `PID` on the
215
+ **patient** (`msg.patient.notes`). A note with **no recognized preceding
216
+ parent** — after `MSH`, or after an unsupported segment like `PV1`/`AL1` — is
217
+ surfaced at **message level** via the new **`msg.notes()`**. **Fail-safe —
218
+ never mis-attached, never dropped:** the parent is the _nearest preceding
219
+ non-NTE segment_ (consecutive NTEs chain; any intervening non-parent segment
220
+ resets the target). Order-level notes mirror the `orders()` state machine —
221
+ notes after an `ORC` are buffered and flushed onto the OBR that opens the
222
+ order, so **several `ORC`s before one `OBR` all contribute** in document order,
223
+ not just the last. A note whose recognized parent has **no surfaced
224
+ projection** — a **later `PID`** in a multi-patient ORU (patient view is the
225
+ first PID), or a **trailing/dangling `ORC`** whose order never opens — is
226
+ routed to `msg.notes()` rather than vanishing. Each non-empty **NTE-3 (Comment,
227
+ FT, repeating)** repetition is one note line, with the **full** repetition text
228
+ reassembled + HL7-unescaped, so a non-conformant raw `^`/`&` (which tokenizes
229
+ NTE-3 into components) is preserved, not silently truncated. New optional
230
+ `notes?: readonly string[]` on `Observation`, `Order`, and `Patient` (omitted
231
+ when empty, frozen when present). **Additive only — no rename, no removal, no
232
+ new warning code.** Deferred: NTE-2 (source of comment) / NTE-4 (comment type)
233
+ interpretation, FT formatting-command rendering, and first-class
234
+ `patient.notes` on a 2nd+ `PID`'s group. See `docs-content/spec-notes-nte.md`.
235
+ - **Character-set / encoding decode — MSH-18 / Table 0211 (roadmap Phase O,
236
+ P1).** `parseHL7` now resolves a `Buffer` input's declared character set from
237
+ **MSH-18** and decodes the byte stream to text **before** tokenization, through
238
+ a frozen HL7 **Table-0211 registry** (`resolveCharset` / `canonicalCharset`,
239
+ exported). MSH-18 is honoured as a **repeating** field — the **first**
240
+ occurrence is the message default, a blank field is 7-bit **ASCII**. Decodes
241
+ **`8859/1`** via Node's true `latin1` (byte-exact; Node's WHATWG
242
+ `TextDecoder("iso-8859-1")` is windows-1252 and remaps the C1 range, so it is
243
+ not used), ASCII / **UTF-8** (`UNICODE UTF-8` / `UNICODE`), and the faithfully
244
+ decoded **ISO-8859** sets (`8859/2`–`8859/8`, `10`, `13`–`16`); `8859/9` and
245
+ `8859/11` are **recognized but preserved verbatim** because Node's ICU aliases
246
+ them to windows-1254/874 (which remap the C1 range — no faithful decoder). The
247
+ multibyte / ISO-2022 East-Asian sets (`ISO IR14/87/159`, GB 18030, KS X 1001,
248
+ CNS 11643, BIG-5) and UTF-16/32 are likewise **recognized and preserved
249
+ verbatim** (full stateful decode is a documented non-goal). The §2.7.4 charset-switch escapes (`\Cxxyy\` / `\Mxxyyzz\`) are
250
+ recognized by the escape layer and preserved. **Fail-safe (never guess, never
251
+ silently corrupt):** decoding is **strict** (`fatal: true`) except byte-exact
252
+ `8859/1`, so a byte invalid/undefined for the declared set does **not** become a
253
+ silent `U+FFFD` — the bytes are read as a `latin1` 1:1 mapping and a warning
254
+ fires: new **`UNSUPPORTED_CHARSET`** for a recognized set that was not decoded
255
+ (never-decoded, or a strict-decode failure), existing `UNKNOWN_CHARSET` for a
256
+ value not in Table 0211. Recoverability is exact for single-byte content;
257
+ multibyte content is best-effort (a code-unit byte can coincide with a structural
258
+ delimiter — documented). **Behaviour change (pre-alpha):** `UNKNOWN_CHARSET` now
259
+ reads bytes as `latin1` instead of falling back to UTF-8. Both warnings carry the
260
+ charset code only — never a field value — so no PHI is exposed. New public warning
261
+ code `UNSUPPORTED_CHARSET` (`WARNING_CODES` 18 → 19). See
262
+ `docs-content/spec-notes-charset.md`.
263
+
264
+ - **Order / medication timing — `order.timings` + `med.timings` (roadmap
265
+ Phase M).** `orders()` and `medications()` now surface the timing **structure**
266
+ of an order/medication as a typed `OrderTiming[]`, read from the `TQ1` segment
267
+ (HL7 v2.5+, Ch. 4 §4.5.4) or the **legacy embedded TQ** data type in `ORC-7` /
268
+ `RXE-1` (pre-v2.5, Ch. 2A §2.A.81, detail withdrawn as of v2.7). Modelled:
269
+ TQ1-2 quantity (CQ), **TQ1-3 repeat pattern (RPT, Table 0335)**, TQ1-4 explicit
270
+ time, TQ1-6 service duration, TQ1-7/-8 start/end (DTM → the Phase N fidelity
271
+ `TS`), TQ1-9 priority (CWE), and **TQ1-14 total occurrences (NM) — not TQ1-11**
272
+ (the Text Instruction). The repeat pattern is surfaced **verbatim**
273
+ (`repeatPattern.code`) — never normalized, resolved to clock times, or mapped
274
+ to a different frequency (reading `Q6H` as "daily", or losing a `BID`, changes
275
+ the administered dose count). A provenance-only `kind`
276
+ (`parametric`/`named`/`unknown`) never drives a schedule; a `parametric`
277
+ `Q<integer><unit>` template surfaces its **load-bearing integer** on
278
+ `repeatPattern.interval`, never dropped. `TQ1` vs the legacy embedded TQ is
279
+ chosen **by presence** (`source: "TQ1" | "legacy"`) — the legacy field is read
280
+ only when no `TQ1` accompanies the group, so the same timing is never
281
+ double-counted and a legacy-only timing is never dropped (the legacy source is
282
+ `ORC-7` for an order, `RXE-1` or the preceding `ORC`'s `ORC-7` for a
283
+ medication). Timings group positionally — a `TQ1` may sit either side of the
284
+ order detail, an intervening `ORC` re-scopes a following `TQ1` to the next
285
+ order, and multiple `TQ1` segments each surface (a tapering schedule). New
286
+ public types `OrderTiming`, `RepeatPattern`,
287
+ `RepeatPatternKind`, `TimingQuantity`; `Order` / `Medication` gain an
288
+ always-present `timings` array. **Additive only** — no rename, no removal, **no
289
+ new warning code**. hl7 surfaces the timing structure only; it does not compute
290
+ schedules, resolve "institution-specified times", or interpret sig. Non-goals:
291
+ `TQ2` beyond segment recognition, schedule computation, 2nd+ repetitions of
292
+ repeating timing fields. Never throws (HELPERS-07). See
293
+ `docs-content/spec-notes-timing.md`.
294
+
295
+ - **`splitBatch()` — batch / file envelope splitting (roadmap Phase L).**
296
+ Demarcates the individual `MSH`-led messages inside an HL7 v2 batch/file
297
+ stream (`[FHS] { [BHS] { MSH… } [BTS] } [FTS]`, HL7 v2 Ch. 2 §2.10.3) and
298
+ hands each one back parsed — either `{ ok: true, message }` or, for a message
299
+ that trips a Tier-3 fatal, `{ ok: false, error }`. A **malformed message
300
+ mid-batch is isolated**, never suppressing its siblings; a **bare single
301
+ message** (no envelope) passes straight through. Declared counts are
302
+ reconciled — **BTS-1** (batch message count) against the messages in the
303
+ batch, **FTS-1** (file batch count) against the batches in the file — and a
304
+ mismatch surfaces the new Tier-2 `BATCH_COUNT_MISMATCH` warning **without ever
305
+ dropping the tail**; an absent (`[0..1]`) or non-numeric count tolerantly
306
+ disables reconciliation. A `BHS`/`FHS` header with no matching `BTS`/`FTS`
307
+ raises the new `BATCH_MISSING_TRAILER` warning (`splitBatch` warns, never
308
+ rejects — enforcing a mandatory envelope is the caller's call). Batch-level
309
+ warnings live on the returned `BatchSplitResult.warnings` (never on
310
+ `Hl7Message.warnings`) and carry **counts / segment names / positions only —
311
+ no PHI**. A `Buffer` stream round-trips through `latin1` so each message's own
312
+ MSH-18 charset resolution runs on its original bytes. Kept a **separate
313
+ surface** from `parseHL7` (which rejects non-`MSH`-first input by design).
314
+ New public exports: `splitBatch`, the `batchCountMismatch` /
315
+ `batchMissingTrailer` factories, and types `Batch` / `BatchSplitResult` /
316
+ `BatchMessageEntry` / `BatchEnvelopeSegment` / `BatchEnvelopeName`. **Two
317
+ additive Tier-2 warning codes** (`BATCH_COUNT_MISMATCH`,
318
+ `BATCH_MISSING_TRAILER`) — the stable warning-code contract grows 16 → 18.
319
+ `batches` holds each message run delimited by a `BHS` and/or a `BTS` (a run
320
+ with neither is still yielded in `messages`, wrapped in no batch); FTS-1
321
+ reconciles **per-file** so concatenated files raise no false mismatch. Split-only: no
322
+ batch ACK generation, no envelope enforcement, BTS-3 totals surfaced but not
323
+ reconciled, no transport de-framing (that is `@cosyte/mllp`). Two structural
324
+ limits are documented: reserved envelope names are matched by name (a body
325
+ literally containing `FHS`/`BHS`/`BTS`/`FTS` is mis-split, its tail surfaced
326
+ as a `NO_MSH_SEGMENT` failure — never silently dropped), and one file envelope
327
+ per stream is the modelled case. See `docs-content/spec-notes-batch.md`.
328
+ - **`identityEvents()` — patient-identity / merge events (roadmap Phase K, P0
329
+ safety).** `msg.identityEvents()` recognizes the ADT identity-management
330
+ trigger family — merges (A18/A34/A35/A36/A39/A40/A41/A42), moves (A43/A44),
331
+ link/unlink (A24/A37), person add/update (A28/A31) — and surfaces every
332
+ party **labelled by role with segment provenance**: the `surviving` party is
333
+ only ever sourced from PID/PV1, the `prior` (non-surviving) party only ever
334
+ from MRG, and the merge direction is the spec constant `MRG_TO_PID` (HL7 v2
335
+ Ch. 3, A18: PID carries the surviving information, MRG the non-surviving) —
336
+ never inferred from content. Repeating PID+MRG groups yield one event each.
337
+ Fail-safe: an incomplete MRG→PID pair (no MRG, an orphaned MRG — which is
338
+ never dropped — or a PID with no surviving identifier) surfaces what is
339
+ present plus a new event-scoped Tier-2
340
+ **`MERGE_MISSING_PRIOR_OR_SURVIVOR`** warning carrying structural facts
341
+ only (never an identifier or name — no PHI). The MRG field map is
342
+ **version-scoped**: the backward-compat single-ID fields (MRG-4 / PID-2,
343
+ withdrawn as of v2.7) are not read when MSH-12 declares v2.7+. Applying the
344
+ merge (re-pointing data) stays the consumer's job. New public surface:
345
+ `Hl7Message.identityEvents()`, types `IdentityEvent` / `IdentityParty` /
346
+ `IdentityEventKind` / `IdentityRole`, the `mergeMissingPriorOrSurvivor`
347
+ factory, and the `MERGE_MISSING_PRIOR_OR_SURVIVOR` code. Additive only.
348
+ - **`MRG` is now a known segment** — parsing a merge message no longer emits
349
+ a spurious `UNKNOWN_SEGMENT` warning for MRG.
350
+ - **`Field.text`** — the field's canonical wire text (full repetitions/
351
+ components/subcomponents re-serialized with the active delimiters), the
352
+ whole-field counterpart to the component-1-only `Field.value`.
353
+ - **`downgradePositiveAck(code)`** — the single upstream source of truth for
354
+ the fail-safe downgrade pair (`AA`→`AE`, `CA`→`CE`; everything else passes
355
+ through), now exported so `@cosyte/mllp`'s `ack-from-hl7` adapter reuses it
356
+ instead of carrying a divergent copy. **`isPositiveAck`** is exported
357
+ alongside it.
358
+
359
+ ### Documentation
360
+
361
+ - **Adopted the documentation IA spine in `docs-content/`** (umbrella DOCS-D5; reference
362
+ exemplar for the suite). The three `spec-notes-*.md` files (coding-system provenance,
363
+ message-type & structure awareness, version-sensitivity matrix) are now grouped under a
364
+ canonical **Core Concepts** category on `docs.cosyte.com` — they previously existed in
365
+ `docs-content/` but were not referenced from `sidebars.json` and so were unreachable from
366
+ any rendered route. Each now carries minimal frontmatter (`id` / `title` /
367
+ `sidebar_label`) so the sidebar text stays concise; the prose is unchanged. Also
368
+ converted the two CommonMark autolinks in `spec-notes-coding-system.md`
369
+ (`<https://…>`) to Docusaurus-MDX-safe `[text](url)` form — autolinks parse as JSX in
370
+ MDX 3, which silently broke the link rendering. Doc-only — no runtime/API change.
371
+
372
+ - **Added a standard-reference primer (`spec-notes-primer.md`), first under Core Concepts.**
373
+ The other `spec-notes-*` pages are implementation notes (what the parser does about one
374
+ spec area); the primer is the spec-grounded companion they elaborate — the v2.x encoding
375
+ model (MSH-1/MSH-2 delimiter bootstrap, the fixed `CR` terminator, the
376
+ field→component→subcomponent hierarchy where only fields repeat), the three field
377
+ population states (populated / not-populated / **null** `|""|`), the abstract message
378
+ grammar (`[ ]` / `{ }` / `[{ }]`, base `OPT` vs. v2.7+ conformance `Usage` codes, and the
379
+ required-different-name-separator rule that makes a flat segment stream reconstructable),
380
+ the **three**-component `MSH-9`, the acknowledgement model (`MSH-15`/`MSH-16`; `CA/CR/CE`
381
+ vs. `AA/AE/AR`), and the `CX`/`XPN`/`XAD`/`HD` composite layouts with their version deltas.
382
+ Cross-links the phase notes for areas they already cover rather than duplicating them, and
383
+ records its provenance (primary Chapter 2 / 2.A text, adversarially fact-checked; the
384
+ official HL7 spec is the text of record). Doc-only — no runtime/API change.
385
+
386
+ ### Security
387
+
388
+ - **Dev-dependency advisory remediation (no runtime impact — `@cosyte/hl7`
389
+ ships zero runtime dependencies, so the published artifact is unchanged).**
390
+ Added scoped `pnpm.overrides` pinning two transitive **dev/build-time**
391
+ packages to their patched releases: `esbuild` (`>=0.27.3 <0.28.1` →
392
+ `0.28.1`; GHSA dev-server path-traversal — not reachable here: the library
393
+ builds via `tsup`/`vitest` and never runs `esbuild serve`) and the
394
+ `@changesets/parse` copy of `js-yaml` (`>=4.0.0 <4.2.0` → `4.2.0`;
395
+ GHSA-h67p-54hq-rp68 merge-key DoS). The `js-yaml@3.14.2` pulled by
396
+ `read-yaml-file@1.1.0` (via `@manypkg/get-packages` → `@changesets/cli`) is
397
+ **intentionally left**: it calls `yaml.safeLoad`, removed/throwing in
398
+ js-yaml 4, so it cannot be force-upgraded without breaking the release
399
+ tooling, and it only parses trusted local repo YAML at release time. This is
400
+ the shared canonical override block, enforced suite-wide by the
401
+ `@cosyte/config` drift check.
402
+
403
+ ### Changed
404
+
405
+ - Adopted the shared `@cosyte/*` toolchain standard: ES2023, ESLint 10 + type-checked
406
+ `typescript-eslint`, Vitest 4, `@types/node` 22, exact-pinned dev tools, the shared
407
+ `@cosyte/tsup-config` / `@cosyte/vitest-config`, and thin callers of the reusable `cosyte/.github`
408
+ CI/release workflows. No public API change.
409
+ - **Test bar** — added executable PHI-safety property tests
410
+ (`test/property/phi-safety.property.test.ts`). Locks two invariants: warning messages never echo
411
+ field VALUES (only positional context + bounded metadata), and `Hl7ParseError.snippet` length
412
+ stays ≤ 41 chars (40 + ellipsis) for adversarially-large inputs. Snippet **content** may carry
413
+ PHI by design (see `parser/errors.ts:70-72` — the documented consumer-redaction boundary); the
414
+ bound is what we lock in. Does **not** use `@cosyte/test-utils`' `assertNoSecretLeak` — that's
415
+ for `Secret<T>` wrappers (the pathways credentials pattern) and is the wrong shape for parser-
416
+ side PHI surfaces.
417
+ - **Coverage policy — `src/profiles/**`coverage hole closed, global`branches`floor restored to
418
+ 90 (roadmap Phase J, supersedes/absorbs the`H-PHI` coverage-policy entry above).** The D10
419
+ transient relaxation is now RESOLVED: targeted tests
420
+ (`test/profiles-merge-validate-coverage.test.ts`) closed the `mergeCustomSegments`/inheritance
421
+ branches in `profiles/merge.ts`and the validator branches in`profiles/validate.ts`+`profiles/describe.ts`that were unreachable via the public`defineProfile()`API alone.`src/profiles/\*\*`now carries its own`>= 90`per-directory coverage gate
422
+ (lines/branches/functions/statements) in`vitest.config.ts`, and the global `branches` floor is
423
+ back to the canonical 90 — no remaining relaxation. One line
424
+ (`validate.ts`'s `validateUniqueFieldNames`throw branch) stays intentionally uncovered and is
425
+ documented as provably unreachable by any legally-typed`Record<string, number>`(proven: no
426
+ real JS object — literal,`Map`, `Proxy`, or otherwise — can hold two same-named enumerable own
427
+ keys), matching the module's own defense-in-depth JSDoc.
428
+ - **Reference test bar — fuzz target, PHI-exec surfaces, and a differential harness (roadmap Phase
429
+ J, "finish the reference test bar").** Three additions, no behavior change:
430
+ - `test/property/fuzz.property.test.ts` — a dedicated high-run-count (1000 runs/property, fixed
431
+ seed) fuzz harness on top of the existing `lenient.property.test.ts` invariant
432
+ ("`parseHL7` either returns an `Hl7Message` or throws an `Hl7ParseError` with one of the 4
433
+ `FATAL_CODES`, never anything else"). Adds two generator families the existing suite didn't
434
+ cover: delimiter-mutation (inject/duplicate/drop the five HL7 delimiters + `\r`) of REAL
435
+ canonical-corpus messages, and truncations of those same messages at every cut point. Also
436
+ snapshots `FATAL_CODES`/`WARNING_CODES` registry membership and asserts a "survivor
437
+ round-trip" property (anything that parses can be `toString()`'d and re-parsed without
438
+ throwing).
439
+ - `test/property/phi-safety-surfaces.property.test.ts` — extends
440
+ `phi-safety.property.test.ts` (which covers the WARNING surface) to the FATAL error-message
441
+ surface: over PHI-shaped field values that trigger each reachable fatal code, asserts
442
+ `Hl7ParseError.message` carries only structural/positional facts, never an echoed field value.
443
+ Separately asserts the documented `errors.ts` snippet contract (bounded length, not
444
+ value-absence — snippets MAY carry PHI by design, redaction is a consumer responsibility) holds
445
+ for BOTH the direct-fatal snippet path and the strict-mode-escalation snippet path
446
+ (`index.ts::buildSnippet`, a distinct call site). Also documents, with a round-trip test, that
447
+ `toString()`/`toJSON()`/`prettyPrint()` legitimately contain field values (content surfaces,
448
+ not diagnostic/log surfaces) so they're never mistaken for a leak path.
449
+ - `test/differential/differential.test.ts` — an oracle-gated differential harness comparing
450
+ `@cosyte/hl7` against the external **python-hl7** parser (BSD license, PyPI package `hl7`) over
451
+ the full canonical corpus (27 fixtures): segment count + per-segment field-text parity. Skips
452
+ gracefully (`it.skip`) when no Python + `hl7` package is available, so `verify.sh`/CI stay green
453
+ without Python. One real, honest divergence was found and documented: `@cosyte/hl7`'s D-02
454
+ trailing-empty-component canonicalization (`Field.text` strips trailing empty
455
+ components/subcomponents; python-hl7's `str()` preserves them verbatim) — see
456
+ `docs-content/spec-notes-differential.md` for the full writeup, licensing bound, and other
457
+ plausible-but-unobserved divergence classes.
458
+
459
+ ### Fixed
460
+
461
+ - **Conformance — v2.7+ truncation char no longer rejects spec-conformant input** (roadmap Phase A,
462
+ P0 correctness). `readDelimiters` previously hard-coded MSH-2 length to 4 and threw the Tier-3
463
+ fatal `INVALID_ENCODING_CHARACTERS` on a 5-char MSH-2, so a spec-valid v2.7+ message carrying the
464
+ truncation character (`^~\&#` and friends) was rejected outright — a fail-unsafe rejection of
465
+ valid input. The parser now accepts both shapes: 4-char (v2.1–v2.6) and 5-char (v2.7+, spec
466
+ §2.5.5.2 — the 5th char is the truncation character, default `#`). The `EncodingCharacters` type
467
+ gains a new optional `truncation?: string` field that is set ONLY when MSH-2 actually declared
468
+ one, so messages that predate v2.7 round-trip with a 4-char MSH-2 unchanged. The serializer +
469
+ builder emit the 5th char back when present; pre-v2.7 messages are unaffected.
470
+ - **Conformance — standard escape sequences no longer warn as `UNKNOWN_ESCAPE_SEQUENCE`** (roadmap
471
+ Phase A). Six spec-defined escape families are now recognized:
472
+ - `\P\` — truncation character. **Decoded** to `enc.truncation ?? "#"` (spec §2.5.5.2). On
473
+ serialize, the truncation character is re-escaped back to `\P\` ONLY when MSH-2 declared one,
474
+ so pre-v2.7 messages round-trip the character literally.
475
+ - `\H\` / `\N\` — highlight on / off (spec §2.7.1). **Recognized but preserved verbatim** — the
476
+ parser does not pick a presentational policy; the markers stay in the decoded string for a
477
+ downstream renderer to consume.
478
+ - `\.sp\`, `\.in\`, `\.ti\`, `\.fi\`, `\.nf\`, `\.ce\` — formatting commands (spec §2.7.6).
479
+ Recognized and preserved verbatim, same rationale as highlight.
480
+ - `\Cxxyy\` (single-byte) and `\Mxxyyzz\` (multi-byte, 4 or 6 hex) — character-set switches
481
+ (spec §2.7.4). Recognized and preserved verbatim because byte-accurate decoding requires
482
+ charset state this module does not own.
483
+
484
+ No public surface is removed or renamed; the `WARNING_CODES` registry is unchanged
485
+ (snapshot test asserts additions-only). `\Z..\` (vendor-specific) and genuinely-malformed bodies
486
+ still warn + preserve as before. New fixtures in `test/fixtures/edge-cases/` (`truncation-char-msh2.hl7`,
487
+ `escape-highlight.hl7`, `escape-formatting.hl7`) lock the behavior, including byte-exact
488
+ round-trip through `toString()`.
489
+
490
+ - **Conformance — SN (Structured Numeric) results no longer silently drop the comparator/range**
491
+ (roadmap Phase B, P0 safety). An `OBX-2 = SN` value (e.g. `<^10`, `>^90`, `^100^-^200`, `^1^:^128`)
492
+ previously fell through the plain-string branch, where `<^10` collapsed to the bare string `"<"` —
493
+ a misread clinical result with a documented patient-harm path (a "less-than 10" result reading as
494
+ the operator alone). `msg.observations()` now dispatches `SN` to a typed `SN` value
495
+ (`comparator` / `num1` / `separatorOrSuffix` / `num2`). Fail-safe by construction: `num1`/`num2` are
496
+ strict-`Number()` parsed (`undefined`, **never `NaN`**), and the comparator is surfaced ONLY when
497
+ SN.1 is a recognized operator (`>` `<` `>=` `<=` `=` `<>`) — a non-operator in the comparator slot
498
+ is never passed off as a real relation. The comparator is preserved byte-for-byte across a
499
+ serialize → parse round-trip. New canonical fixture `test/fixtures/canonical/oru-r01-sn-results.hl7`
500
+ and property tests (`test/property/sn.property.test.ts`) lock the invariant over thousands of
501
+ generated values.
502
+
503
+ - **Types resolution from CommonJS** — the `exports` map now points the `require` condition's types at
504
+ `dist/index.d.cts` (was `index.d.ts`), fixing a "masquerading as ESM" (`attw` FalseESM) issue for
505
+ CJS consumers.
506
+
507
+ ### Added
508
+
509
+ - **Version-sensitivity hardening** (roadmap Phase H, P1) — the coded-element
510
+ composites are now robust to **append-only component growth** across HL7
511
+ v2.1–v2.8, with **no silent truncation**. `CWE` and `CE` gained an optional
512
+ `extraComponents: readonly string[]` that preserves any components beyond the
513
+ modeled set verbatim and in order: **CWE component 10+** (the v2.7
514
+ second-alternate triplet + coding-system / value-set OIDs) and **CE component
515
+ 7+**. An absent interior component is held as `""` so `extraComponents[i]`
516
+ maps back to its HL7 component number; trailing empties are stripped and the
517
+ key is omitted when there is nothing past the modeled set. This also unifies
518
+ **CE↔CWE** reading — because each accessor preserves the other's extra
519
+ components, reading a CWE-shaped value through `asCe()` is no longer lossy (CE
520
+ was deprecated at v2.5, withdrawn at v2.6 in favor of CWE). Parsing a coded
521
+ element with an arbitrary number of trailing components **never throws**
522
+ (forward-compatibility with future versions). The CWE coding-system version
523
+ ids (CWE.7/CWE.8) were already surfaced. Added a `version-growth` property
524
+ test (no-loss / no-throw / CE↔CWE uniformity) and the supported-version matrix
525
+ in `docs-content/spec-notes-version-matrix.md` (incl. the **TS→DTM**
526
+ supersession and the **MSH-21** Conformance Statement ID → Message Profile
527
+ Identifier rename at v2.5). Additive only — `extraComponents` is a new optional
528
+ field; no rename, no removal, no new warning code.
529
+ - **Message-type & structure awareness** (roadmap Phase G, P1) — a conservative
530
+ **misroute / truncation safety net**. `msg.structure` reports, for the common
531
+ message types, whether the core segment groups the HL7 v2.5.1 abstract syntax
532
+ marks **Required** for that trigger event are present; the parser also emits a
533
+ single additive Tier-2 `MISSING_EXPECTED_GROUP` warning per absent group (e.g.
534
+ an `ORU^R01` with no `OBR`/`OBX` result group). Keys on the **trigger event**,
535
+ not the message family, and models **Required anchors only** (EVN in ADT, PID
536
+ in ORU/SIU, OBR in OML/OMG/OMI, RXA in VXU are deliberately excluded) so a
537
+ conformant-but-sparse message never warns — every well-formed canonical
538
+ fixture emits zero structural warnings. Recognized types: ADT
539
+ (A01/A02/A03/A04/A05/A08/A11/A13), ORU^R01, ORM^O01, OML^O21, OMG^O19,
540
+ OMP^O09, OMI^O23, SIU (S12–S26), MDM (T02/T06), DFT^P03, VXU^V04, ACK; an
541
+ unrecognized type yields `recognized: false` and emits nothing. Warning-only
542
+ (Tier-2) — lenient parse never throws, `strict` may promote; the message
543
+ carries only structural facts (type, group, anchor names), never a field
544
+ value (no PHI). New public surface: `Hl7Message.structure`, the
545
+ `missingExpectedGroup` warning factory, the `MISSING_EXPECTED_GROUP` code, the
546
+ read-only `MESSAGE_STRUCTURE_DEFINITIONS` registry, `analyzeMessageStructure`,
547
+ and types `MessageStructure`, `StructureGroup`, `ExpectedSegmentGroup`,
548
+ `MessageStructureDefinition` (see `docs-content/spec-notes-structure.md`).
549
+ - **Coding-system provenance** (roadmap Phase F, P1) — `codingSystem(id)`,
550
+ `codingSystemOf(coded)`, and `alternateCodingSystemOf(coded)` answer "what
551
+ system does this code CLAIM?" off a `CWE` / `CE` (CWE.3 / CE.3 primary,
552
+ CWE.6 / CE.6 alternate). Alias-normalized + case-insensitive
553
+ (`LOINC` → `LN`, `SNOMED` → `SCT`, `RxNorm` → `RXN`) with the original
554
+ spelling preserved verbatim in `claimed`; an unregistered / local id is
555
+ surfaced verbatim with `known: false` (never dropped, never guessed); a
556
+ no-claim input returns `undefined`. The recognized subset is the frozen,
557
+ read-only `KNOWN_CODING_SYSTEMS`: `LN`, `SCT`, `I10`, `I10P`, `RXN`, `NDC`,
558
+ `CVX`, `MVX`, `UCUM`. **Provenance only** — no validation, lookup, network,
559
+ or bundled codeset. `I10` reports the registered Table 0396 claim `ICD-10`
560
+ (the WHO base), NOT a guessed `ICD-10-CM` (see
561
+ `docs-content/spec-notes-coding-system.md`). New public types
562
+ `KnownCodingSystem`, `CodingSystemInfo`, `CodedSystemFields`.
563
+ - **Parser** — `parseHL7(raw, optionsOrProfile?)` with a lenient default
564
+ parser that handles vendor-quirky HL7 v2.1–v2.8 input, and a
565
+ `{ strict: true }` mode that escalates every Tier-2 deviation to a thrown
566
+ `Hl7ParseError`. Accepts `string` or `Buffer` input; honours MSH-18
567
+ character set with a user `charset` override option.
568
+ - **Warning system** — 14 stable Tier-2 warning codes with positional
569
+ context (`segmentIndex`, `fieldIndex`, `repetitionIndex`, `componentIndex`,
570
+ `subcomponentIndex`): `MLLP_FRAMING_STRIPPED`, `FIELD_WHITESPACE_TRIMMED`,
571
+ `UNKNOWN_ESCAPE_SEQUENCE`, `TIMESTAMP_FALLBACK_FORMAT`, `SEGMENT_CASE`,
572
+ `EXTRA_FIELDS`, `UNKNOWN_SEGMENT`, `DUPLICATE_REQUIRED_SEGMENT`,
573
+ `ENCODING_MISMATCH`, `MISSING_REQUIRED_FIELD`, `OUT_OF_ORDER_SEGMENT`,
574
+ `VERSION_MISMATCH`, `UNKNOWN_CHARSET`, `ACK_NO_CORRELATION_ID`. Exposed via
575
+ `msg.warnings` and the `onWarning` callback.
576
+ - **Fatal errors** — 4 Tier-3 fatal codes always thrown as `Hl7ParseError`
577
+ (even in lenient mode): `NO_MSH_SEGMENT`, `MSH_TOO_SHORT`,
578
+ `INVALID_ENCODING_CHARACTERS`, `EMPTY_INPUT`. Each error carries
579
+ `message`, `position`, and `snippet`.
580
+ - **Structural model** — immutable `Hl7Message` with dot-path access
581
+ (`msg.get("PID.5.1")`, `msg.get("OBX[2].5")`), typed `Segment` and
582
+ `Field` wrappers, and `msg.segments("OBX")[0].field(3)` traversal.
583
+ Safe-access semantics (`undefined` / `[]` for missing paths, never
584
+ throws).
585
+ - **Composite types** — parsed instances and exported TypeScript
586
+ interfaces for XPN, XAD, CX, CWE, CE, XTN, PL, TS/DTM, NM, SN, HD, and XCN
587
+ (12 types). Also available under the `HL7` namespace:
588
+ `import { HL7 } from "@cosyte/hl7"; type T = HL7.XPN`.
589
+ - **`SN` composite + `Field.asSn()`** (roadmap Phase B) — `parseSn` /
590
+ `SN` / `HL7.SN` export the Structured Numeric datatype (comparator,
591
+ num1, separator/suffix, num2), and `field.asSn()` coerces an OBX-5 to it.
592
+ `msg.observations()` returns `{ valueType: "SN", value: SN | undefined }`
593
+ for `OBX-2 = SN`.
594
+ - **`observation.unitsAreUcum`** (roadmap Phase B) — a boolean claim-check
595
+ flag, `true` iff OBX-6's coding system (CWE.3) is exactly `UCUM`
596
+ (HL7 Table 0396). `false` when a unit is present but not declared UCUM
597
+ (surfaced as-is, never coerced); omitted entirely when OBX-6 is absent.
598
+ This is a claim check only — the library does not validate UCUM grammar
599
+ or convert units.
600
+ - **Named helpers** — one-line extraction for the most common HL7
601
+ fields: `msg.meta`, `msg.patient`, `msg.visit`, `msg.observations()`,
602
+ `msg.orders()`, `msg.nextOfKin()`, `msg.allergies()`, `msg.diagnoses()`,
603
+ `msg.insurance()`, `msg.medications()`, `msg.immunizations()`. All helpers
604
+ return `undefined` / empty arrays for missing optional data — they never throw.
605
+ - **`msg.medications()`** (roadmap Phase D, P0 safety) — projects every
606
+ RXO / RXE / RXD / RXA segment into a typed `Medication` across the four
607
+ pharmacy contexts (`order` / `encoded` / `dispense` / `administration`),
608
+ grouping the RXR (route, Table 0162 + site Table 0163) and RXC
609
+ (component) segments that follow each parent positionally — the same
610
+ state-machine `orders()` uses for OBR → OBX. The give code carries its
611
+ own coding-system provenance (`giveCode.nameOfCodingSystem`); the give
612
+ **amount** (how much) and the give **strength** (concentration, RXE-25/26)
613
+ are surfaced as **separate fields and are never reconciled** — a strength
614
+ a coded drug (e.g. an NDC) implies never validates or overwrites the
615
+ explicit RXE-25 strength, so a disagreement is preserved for the caller.
616
+ Numerics are strict-`asNm()` parsed (absent/blank → key omitted, never
617
+ `NaN`); output is frozen; not memoized. Never throws (HELPERS-07). New
618
+ public types: `Medication`, `MedicationContext`, `MedicationAmount`,
619
+ `MedicationStrength`, `MedicationRoute`, `MedicationComponent`.
620
+ - **`msg.immunizations()`** (roadmap Phase E, P0 safety) — projects every
621
+ RXA segment of a VXU^V04 into a typed `Immunization`, grouping the RXR
622
+ (route, Table 0162 + site Table 0163) and OBX (VFC eligibility / funding
623
+ source) segments that follow each RXA positionally, and carrying
624
+ `orderControl` from the preceding ORC of the `ORC`→`RXA`→`[RXR]`→`[{OBX}]`
625
+ order group — the same state machine `orders()` uses for OBR → OBX. The
626
+ action code (RXA-21, `A`/`D`/`U`) is surfaced **verbatim and never
627
+ defaulted** — a mis-key corrupts an IIS add/delete/update dedup; the
628
+ `recordOrigin` (administered vs historical) is derived **only** from the
629
+ well-known NIP001 RXA-9.1 codes (`00`; `01`-`08`) and omitted otherwise —
630
+ never guessed, with the raw RXA-9 claim always preserved on
631
+ `informationSource`. The vaccine code carries its own provenance
632
+ (`vaccineCode.nameOfCodingSystem`, CVX, + any CVX/NDC alternate);
633
+ `doseAmount` is strict-`asNm()` parsed (the IIS unknown-dose sentinel `999`
634
+ surfaced as the number `999`, never coerced). Output is frozen; not
635
+ memoized. Never throws (HELPERS-07). New public types: `Immunization`,
636
+ `ImmunizationRecordOrigin`.
637
+ - **Mutation** — `setField`, `addSegment`, `removeSegment` on
638
+ `Hl7Message`. Direct field mutation on unwrapped objects has no effect
639
+ (immutability by default).
640
+ - **Serialization** — `msg.toString()` emits spec-clean HL7 regardless
641
+ of input quirks (Postel's Law); `msg.toJSON()` returns a structured
642
+ JSON tree; `msg.prettyPrint()` returns a human-readable multi-line
643
+ string for logs. Escape sequences are re-encoded on serialize.
644
+ - **Message builder** — `buildMessage({...}).addSegment(...).toString()`
645
+ constructs valid outbound HL7 from scratch, with helpers for control
646
+ IDs and HL7 timestamps.
647
+ - **ACK generation + interpretation** (roadmap Phase C) — `buildAck(inbound,
648
+ { code, error?, mode? })` produces a spec-clean `MSH`+`MSA`[+`ERR`…]
649
+ acknowledgment: sender/receiver swapped (full multi-component HDs
650
+ preserved), MSH-9 = `ACK^<trigger>^ACK`, MSA-2 echoes the inbound MSH-10,
651
+ and each `ERR` carries an ERL location (ERR-2), a Table 0357 condition code
652
+ as a CWE (ERR-3), and a Table 0516 severity (ERR-4) — **codes and locations
653
+ only, never echoed PHI**. `buildAck` is mechanical (emits the disposition it
654
+ is told) with one safety override: an inbound with no MSH-10 cannot be
655
+ correlated, so a requested positive `AA`/`CA` is downgraded to `AE`/`CE`,
656
+ MSA-2 is left empty, and an `ACK_NO_CORRELATION_ID` warning rides on the
657
+ returned message — it never fabricates an unverifiable positive ACK.
658
+ `interpretAck(msg)` is the read-side: a typed `Acknowledgment` view whose
659
+ `accepted`/`error`/`rejected` flags are derived fail-safe from MSA-1 (all
660
+ three `false` on an absent or unrecognized code). `detectAckMode(inbound)`
661
+ exposes the spec-exact original-vs-enhanced detection (MSH-15/16). Control
662
+ vocabulary (Tables 0008/0357/0516/0155) ships as frozen read-only enums
663
+ (`ACK_CODES`, `ERR_CONDITION_CODES`, `ERR_SEVERITIES`, `ACK_CONDITIONS`).
664
+ - **Profile system** — `defineProfile()` API with `extends` composition
665
+ (single parent or array), merge semantics (scalars overwrite, arrays
666
+ concat+dedupe, `customSegments` deep-merge per key, `onWarning` chains),
667
+ `profile.describe()` introspection, `profile.lineage`, and
668
+ `ProfileDefinitionError` with actionable messages on invalid input.
669
+ - **Default profile management** — `setDefaultProfile(p)`,
670
+ `getDefaultProfile()`, `setDefaultProfile(null)`. Explicit arguments
671
+ override; `parseHL7(raw, { profile: null })` opts out for a single call.
672
+ - **Five built-in vendor profiles** — `profiles.epic`, `profiles.cerner`,
673
+ `profiles.meditech`, `profiles.athena`, `profiles.genericLab`. Each
674
+ authored through the public `defineProfile()` API with date-format
675
+ fallbacks and named Z-segments.
676
+ - **Segment.get(name)** — resolve custom-segment fields by declared
677
+ name (e.g., `msg.segments("ZDP")[0].get("departmentCode")`) when a
678
+ matching profile is applied.
679
+ - **Three runnable examples** — `examples/extract-patient-info.ts`,
680
+ `examples/read-lab-results.ts`, `examples/modify-and-resend.ts`, each
681
+ runnable via `pnpm tsx examples/<file>.ts` and smoke-tested via
682
+ `pnpm examples` in CI.
683
+ - **Profile starter kit** — `examples/profile-starter-kit/`, a
684
+ publishable template with its own CI + publish workflows,
685
+ `CUSTOMIZING.md` walkthrough, and placeholder tokens
686
+ (`{{YOUR_ORG}}` / `{{PROFILE_NAME}}`) that turn into a ready-to-publish
687
+ profile package in minutes.
688
+ - **Documentation** — comprehensive README (value prop, quickstart,
689
+ feature list, HL7-in-90-seconds primer, three access patterns, full
690
+ cookbook, Profiles section, Real-World Tolerance table, Error
691
+ Handling, Roadmap), `CHANGELOG.md`, `CONTRIBUTING.md`, `LICENSE`
692
+ (MIT).
693
+ - **Tooling** — strict TypeScript (`noUncheckedIndexedAccess`,
694
+ `exactOptionalPropertyTypes`), dual ESM + CJS build via tsup, Vitest
695
+ with ≥ 90% per-directory branch coverage on `src/parser/`, `src/model/`,
696
+ `src/helpers/`, `src/serialize/`, `src/builder/`. Lint, format, and
697
+ TypeScript settings come from the shared `@cosyte/*` config packages
698
+ (ESLint 9 + `typescript-eslint`). CI across Node 22 / 24.
699
+
700
+ ### Changed
701
+
702
+ ### Deprecated
703
+
704
+ ### Removed
705
+
706
+ ### Fixed
707
+
708
+ ### Security
709
+
710
+ [Unreleased]: https://github.com/cosyte/hl7/commits/main