@cosyte/hl7 0.0.1 → 0.0.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/CHANGELOG.md CHANGED
@@ -15,18 +15,294 @@ per the cosyte version ladder (`0.0.x` until first alpha).
15
15
 
16
16
  ### Added
17
17
 
18
+ - **Em-dash brand gate in CI (`scripts/check-no-emdash.sh`, `pnpm check:no-emdash`,
19
+ `.github/workflows/no-emdash.yml`; `EMDASH-CONFORMANCE` part 1).** The founder directive of
20
+ 2026-07-24 (`knowledgebase/06-brand/voice-and-tone.md`) bans `U+2014` outright across every cosyte
21
+ surface and names commit messages explicitly, and the meta-repo's `documentation/conventions.md`
22
+ has described the rule as CI-gated; it was in fact gated in only 3 repos of 10. This ports
23
+ `knowledgebase`'s scanner (the text-only variant, correct here because hl7 tracks no binaries:
24
+ all 414 tracked files read as us-ascii or utf-8 and none holds a NUL byte) and wires it to a
25
+ dedicated workflow that checks **both** the tracked files and the **PR title, body, and branch
26
+ commit messages**, the last of these on the non-default `edited` activity type so a description
27
+ retitled after the final push is re-checked before a squash merge turns it into the commit
28
+ message. It is a separate workflow rather than a job in `ci.yml` because `ci.yml`'s triggers drive
29
+ the Node 22 + 24 matrix plus the examples and starter-kit `extras` job, which should not re-run on
30
+ every PR-description edit. Within the bytes it is designed to match, the scanner refuses to report
31
+ green from a scan it could not complete: it pins `LC_ALL=C.UTF-8` (without it GNU grep 3.8 aborts
32
+ on `\x{2014}` and a naive port prints OK having matched nothing), self-tests against a known em
33
+ dash before believing a clean result, fails on any scanner stderr, and refuses an empty file list.
34
+ Two limits are documented in the script rather than engineered away: it excludes itself (it has to
35
+ name the encodings it bans), and it matches `U+2014` as UTF-8 plus five textual encodings, so an em
36
+ dash in a legacy-charset fixture (CP1252 `0x97`, UTF-16) scans clean and is a reviewer's job, not
37
+ the gate's. **No content changed**: hl7 was already
38
+ clean (0 of 53 markdown files), so this is regression prevention only. Dev-tooling only: no change
39
+ to the published package surface, parser behavior, or warning codes.
40
+ - **Performance / throughput bar: reproducible benchmark suite + a ratio-based perf-regression guard
41
+ (HL7-W, Phase W, sixth and final phase of the v2.4 capability arc; completes it).** `pnpm bench`
42
+ (`scripts/bench.ts`) measures parse **throughput** (msgs/sec + MB/sec across a small ADT^A01 and a
43
+ larger multi-OBX ORU^R01), **per-message retained memory**, and the **streaming peak memory** that
44
+ substantiates `parseStream`'s O(one-message) claim, over synthetic in-process inputs (no PHI, no
45
+ fixtures). Indicative numbers are published in `docs-content/benchmarks.md`. A CI-gating
46
+ perf-regression guard (`test/perf/perf-regression.test.ts`) fails on a regression while staying
47
+ non-flaky across runners: every assertion is **relative**, not an absolute floor: throughput
48
+ **linearity by message count** (`t(4N)/t(N) ≤ 10`, min-of-runs; catches super-linear-in-count),
49
+ **linearity by message size** (`t(4S)/t(S) ≤ 10`; catches an O(n²)-in-length tokenizer regression),
50
+ and streaming **read-ahead ≤ 2** (a pure count, the O(one-message) memory invariant as a perf gate,
51
+ which trips if `parseStream` ever buffers the file); one coarse absolute liveness floor (~100× below
52
+ real throughput) trips only on a catastrophic hang. Absolute memory numbers are published from the
53
+ benchmark (which forces GC via `--expose-gc`); the gate deliberately avoids un-GC'd `heapUsed` deltas.
54
+ **No behavior change**: measurement + a guard only; parse output is byte-identical, no `src/`
55
+ runtime code touched, no public API added. New: the `bench` package script and the `benchmarks` doc.
56
+ - **FHIR-bridge readiness: a versioned IR-stability contract + coverage proof (HL7-V, Phase V, fifth
57
+ of the v2.4 capability arc).** Documents and **versions** the mapping-source IR that
58
+ `@cosyte/transform` builds against, and proves, firsthand against the HL7 **v2-to-FHIR** IG
59
+ (v1.0.0, FHIR R4), that hl7 surfaces the source paths the IG references. **No v2→FHIR mapping is
60
+ added to hl7.** hl7 constructs no FHIR resource, ships no ConceptMap, translates no terminology;
61
+ that stays in `@cosyte/transform`. This is documentation + a coverage test over **existing** surface;
62
+ no new runtime API.
63
+ - **IR Contract v1.0.0** (`docs-content/spec-notes-fhir-bridge.md`) pins the stable mapping surface:
64
+ dot-path source addressing (`msg.get`/`resolvePath`/`parsePath`, `segment.field[.component]`
65
+ one-to-one with the IG), the twelve typed composites (`Field.asXpn`/`asCx`/`asCwe`/…, the
66
+ datatype-map inputs), code-system provenance, `parseDtm` datetime precision + timezone, the text
67
+ codec (`decodeText`/`renderText`/`Field.render`), and message metadata, with a **semver
68
+ deprecation posture** so `transform` can build once without forcing later hl7 churn.
69
+ - **Coverage proof** (`test/fhir-bridge-coverage.test.ts`) grounds against the IG's vendored raw
70
+ segment-map CSVs + its public sample corpus (provenance in
71
+ `test/fixtures/fhir-bridge/PROVENANCE.md`). Invariants: every IG-referenced field-level source path
72
+ is **addressable** (no hl7-side gap); every path the public sample **populates** resolves through
73
+ the IR (**63/63** exercised paths surfaced); the datatype-map shapes (XPN, CX with assigning
74
+ authority, CWE provenance, DTM precision+tz, rendered narrative) surface from the sample; and the
75
+ package exports carry **no** FHIR-typed symbol (scope-boundary guard).
76
+ - **Gaps recorded honestly.** The IG's public sample corpus is, verified firsthand, **one** v2
77
+ message (`MDM^T02`), not the multi-family `samples/` the roadmap assumed. **233 of 296**
78
+ IG-referenced field paths are un-exercised by that single sample: a **corpus-coverage** gap (the
79
+ thin sample set), **not** a hl7 reachability gap (hl7 addresses all 296 generically).
80
+ - **Conformance-profile tooling: `validateAgainstProfile` (HL7-U, Phase U, fourth of the v2.4
81
+ capability arc).** hl7 could parse and author messages and flag missing spec-Required segment
82
+ groups (`msg.structure`), but could not answer "does this feed meet _our_ interface spec?"
83
+ `validateAgainstProfile(message, profile)` runs a **consumer-authored declarative conformance
84
+ profile** and returns **typed findings**.
85
+ - **BYO profile + value sets: the deliberate scope boundary.** The profile schema is a bounded
86
+ subset of the HL7 v2 Message-Profile / NIST-IGAMT model: per-segment/field usage
87
+ (`R`/`RE`/`C`/`CE`/`O`/`X`), cardinality, length, and value-set binding against a
88
+ **consumer-supplied** code list. hl7 ships **no** vendor/IHE/regulatory profile, **no** bundled
89
+ code set (no LOINC/SNOMED/ICD/RxNorm lookup), and makes **no** network call, the sanctioned
90
+ functionality-plane replacement for the retired vendor-corpus arc.
91
+ - **Four invariants.** The engine **never throws**. A malformed profile yields `PROFILE_MALFORMED`
92
+ findings, not an exception (never a silent pass); the optional `defineConformanceProfile` is a
93
+ fail-fast authoring gate that _does_ throw `ProfileDefinitionError`. A **valid message yields zero
94
+ findings**. **No finding carries PHI.** Each names the structural locus (segment / field /
95
+ component / repetition / occurrence) and the rule, never the offending value. Validation is
96
+ **read-only**. `C`/`CE` presence is not evaluated (no conditional-predicate language, a
97
+ documented boundary), keeping valid⇒zero-findings airtight. Property-tested for never-throws,
98
+ read-only, and well-formed PHI-safe findings across message × profile pairs.
99
+ - **"No findings" is explicitly NOT a conformance attestation.** It means no _declared_ rule was
100
+ violated, nothing about undeclared parts of the message and no conformance certification.
101
+ - New public exports: `validateAgainstProfile`, `defineConformanceProfile`, the `conformance`
102
+ namespace, `FINDING_CODES`, `USAGE_CODES`, and the `ConformanceProfile` / `SegmentRule` /
103
+ `FieldRule` / `Cardinality` / `UsageCode` / `ConformanceFinding` / `FindingLocus` / `FindingCode`
104
+ / `FindingSeverity` / `ConformanceResult` types. Six additive finding codes:
105
+ `PROFILE_REQUIRED_ABSENT`, `PROFILE_NOT_PERMITTED`, `PROFILE_CARDINALITY`, `PROFILE_LENGTH`,
106
+ `PROFILE_VALUE_NOT_IN_SET`, `PROFILE_MALFORMED`. Additive: no change to existing output. Distinct
107
+ from the parse-profile system (`defineProfile`/`profiles`). See
108
+ `docs-content/spec-notes-conformance.md`.
109
+ - **Streaming / incremental parse: `parseStream` (HL7-S, Phase S, third of the v2.4 capability
110
+ arc).** hl7 could only parse a message (`parseHL7`) or a whole in-memory batch (`splitBatch`). A
111
+ large ELR/IIS/lab file or a live feed had to be fully buffered first. `parseStream(source, opts?)`
112
+ parses **incrementally**: it consumes a chunked source (a Node `Readable`, an async-iterable, or an
113
+ iterable of `string`/`Buffer` chunks) and **yields one message per `MSH`-delimited boundary** as it
114
+ completes, with **O(one-message)** memory, the whole stream is never retained.
115
+ - **Chunk-boundary invariant.** A message split across chunk boundaries (mid-segment, mid-field,
116
+ even mid-`MSH|^~\&`) is reassembled; feeding the same bytes in 1-byte chunks vs. one big chunk
117
+ yields **identical** messages. A `\r\n` terminator straddling a chunk boundary is not mistaken for
118
+ a bare `\r`; `\r` / `\r\n` / `\n` are all tolerated. Property-tested across randomized chunk
119
+ boundaries and 1-byte chunks.
120
+ - **No second grammar.** Each demarcated message is parsed by the shipped `parseHL7` (the second
121
+ argument (profile / `strict` / `charset` / `dateFormats`) is forwarded per message), so a
122
+ streamed message is byte-for-byte what `splitBatch` produces for the same bytes.
123
+ - **Isolation, never a dropped tail.** A malformed message mid-stream surfaces as an isolated typed
124
+ failure entry (the `Hl7ParseError`) and the stream **continues**. It never suppresses later
125
+ messages. Batch-envelope segments (`FHS`/`BHS`/`BTS`/`FTS`) act as boundaries and are never
126
+ yielded, so **yielded count == `MSH` count** (envelope count reconciliation stays `splitBatch`'s
127
+ job). A final message with no trailing terminator is still yielded in full, flagged via the new
128
+ stream-level `UNTERMINATED_STREAM_MESSAGE` warning on the entry's `streamWarnings` (never on
129
+ `Hl7Message.warnings`, never a throw).
130
+ - Consumes an **already-de-framed** stream. MLLP `<SB>…<EB><CR>` framing/timing/TLS remain
131
+ `@cosyte/mllp`'s. New public exports: `parseStream`, `unterminatedStreamMessage`, the
132
+ `Hl7StreamSource` / `StreamMessageEntry` types, and the `UNTERMINATED_STREAM_MESSAGE` warning code.
133
+ Additive: no change to `parseHL7` / `splitBatch` output. See `docs-content/spec-notes-stream.md`.
134
+ - **Typed emit symmetry: composite setters + `buildAdt` / `buildOru` (HL7-T, Phase T, second of the
135
+ v2.4 capability arc).** hl7 could read a message into typed objects but could only _write_ one
136
+ through stringly-typed field arrays (`buildMessage(...).addSegment("PID", ["", "", "MRN"])`, the
137
+ consumer hand-encoding `^`/`&`/`~`). Phase T makes hl7 an **authoring** library (the
138
+ conservative-emit mirror of the read helpers) built on Phase R's encode-safe codec:
139
+ - **`encodeComposite(kind, value)`** (and the per-type `encodeXpn`/`encodeXad`/`encodeCx`/`encodeCwe`/
140
+ `encodeCe`/`encodeXtn`/`encodePl`/`encodeHd`/`encodeTs`/`encodeNm`/`encodeXcn`, plus
141
+ `encodeCompositeReps` for repeating fields) turn a typed composite (an XPN name, a CX identifier
142
+ (nested-HD aware), a TS timestamp) into a spec-clean `RawField`. **`Hl7Message.setComposite(path,
143
+ kind, value)`** sets one at a field or `field[rep]` dot-path. Component values are stored
144
+ **decoded** and the serializer re-escapes on emit, so an embedded delimiter (`"Smith^Jr"`) is
145
+ **escaped, never injected**. It re-parses to the exact string, never forging a component boundary.
146
+ The **emit ∘ parse identity** holds on every modelled field (interior empties preserved, trailing
147
+ trimmed); property-tested across all composites over the delimiter-laden alphabet.
148
+ - **`buildAdt(event, init)`** and **`buildOru(init)`** assemble a full message (MSH + EVN + PID + PV1
149
+ for ADT; MSH + PID + OBR + OBX for ORU) from typed inputs, generating the control id / timestamp /
150
+ encoding characters correctly. The emitted message is **spec-clean, zero-warning, and
151
+ structurally complete**. It re-parses with `warnings: []` and `msg.structure.missingGroups` empty
152
+ (verified against the parser's own structure net across `A01`/`A02`/`A03`/`A04`/`A08`).
153
+ - **Never fabricate.** A builder emits only values the caller supplied; an omitted optional field is
154
+ an empty/absent field, never a defaulted clinical value. A required-but-absent structural input
155
+ (`patient`, an empty ADT `event`, or an ORU with no observation) is a typed `TypeError`, not a
156
+ guessed value. Complements the shipped `buildAck` (Phase C); the low-level `buildMessage` remains
157
+ for the long tail. Emit is spec-clean **base standard**, not a vendor dialect.
158
+
159
+ New public exports: `buildAdt`, `buildOru`, `encodeComposite`, `encodeCompositeReps`, the eleven
160
+ per-type `encodeXxx` functions, `Hl7Message.setComposite`, and the `CompositeKind` /
161
+ `CompositeValueByKind` / `BuildAdtInit` / `AdtPatient` / `AdtVisit` / `AdtEvent` / `BuildOruInit` /
162
+ `OruPatient` / `OruOrder` / `OruObservation` types. Ships two synthetic, PHI-scanned goldens
163
+ (`fixtures/build/adt-a01.golden.hl7`, `oru-r01.golden.hl7`) plus a property layer (emit ∘ parse
164
+ identity + no-injection). Additive: no change to parse output or existing emit. Depends on Phase R.
165
+
166
+ - **Formatted-text rendering + a first-class text codec (HL7-R, Phase R, first of the v2.4
167
+ capability arc).** Completes Phase A's explicit defer: the lenient parser preserves the
168
+ presentational escapes (`\H\`/`\N\` highlight, the `\.sp\`/`\.in\`/… formatting commands, charset
169
+ switches, vendor `\Z..\`) as un-rendered sentinels; a note surfaced to a human with those raw
170
+ sentinels in it is **misread**. Two additions close that, as an opt-in **layer** over the unchanged
171
+ raw extraction (Postel's Law, existing parse output is byte-for-byte identical):
172
+ - **`renderText(input, enc?, opts?)`** turns HL7 v2 §2.7 escape/formatting-bearing content into a
173
+ normalized **display model**: a plain-text `text` (formatting commands → whitespace/line breaks
174
+ per a conservative, documented normalization; highlight boundaries dropped) plus highlight-aware
175
+ `runs` (`{ text, highlighted }[]`). It **never fabricates**: a charset switch, a vendor `\Z..\`,
176
+ or a malformed/unterminated escape is preserved as its literal characters and flagged in
177
+ `unrenderedSequences`, never dropped or replaced with a guessed glyph. Grounded firsthand on the
178
+ HL7 v2 Ch. 2 §2.7 escape spec (delimiter §2.7.1, charset §2.7.2/3, hex §2.7.5/6, formatting
179
+ commands §2.7.6/7 [FT], local `\Z..\` §2.7.7/8, truncation §2.5.5.2, confirmed against the
180
+ v2.5.1 and v2.8.2 chapter text). **`Field.render(opts?)`** is the one-call form on a parsed field.
181
+ - a first-class **text codec**: `decodeText` / `encodeText`, also bundled as the `text` namespace
182
+ (`text.decode` / `text.encode` / `text.render`). The load-bearing invariant is **encode-safety**:
183
+ `encodeText` escapes every reserved character (the escape char first, then the
184
+ field/component/subcomponent/repetition separators, the truncation char, and framing-critical
185
+ CR/LF) so an arbitrary string round-trips (`decodeText(encodeText(s)) === s`) and re-parses to
186
+ exactly itself with **no delimiter injection**. A value can never break framing or forge a
187
+ component boundary. This is the safe-encode primitive Phase T (typed emit) will build on.
188
+
189
+ New public exports: `renderText`, `decodeText`, `encodeText`, the `text` namespace,
190
+ `Field.render()`, and the `RenderedText` / `TextRun` / `RenderTextOptions` types. Ships four
191
+ synthetic, PHI-scanned `fixtures/text/*.hl7` fixtures (formatting note, highlight, hex+delimiters,
192
+ inject-on-encode) plus a property layer (encode-safety over arbitrary strings; `renderText` is
193
+ total; a formatting sentinel never survives into the plain-text render). No new warning codes.
194
+
195
+ - **`profiles.va`: eighth built-in vendor profile (VA VistA Radiology/Nuclear Medicine imaging).**
196
+ Declares the `ZDS` Z-segment that carries the DICOM **Study Instance UID** (field 1, `RP` composite,
197
+ the UID sits in the first component, ZDS-1.1 "Pointer") so a VistA radiology feed parses without an
198
+ `UNKNOWN_SEGMENT` warning and `zds.get("studyInstanceUid")` resolves by name. `ZDS` is the **IHE
199
+ Radiology** RIS↔PACS bridge segment, the same cross-vendor extension `profiles.visage` and
200
+ `profiles.philips` declare, **not** a VA-proprietary quirk; the value this profile adds is a distinct
201
+ **named federal source** and coverage of the shape the VA spec documents that the imaging-vendor
202
+ specs do not: `ZDS` on **ORU** (result) messages (VistA sends `ZDS` in both ORM and ORU). Grounded in
203
+ the **publicly published** [Radiology/Nuclear Medicine 5.0 HL7 Interface Specification](https://www.va.gov/vdl/documents/clinical/radiology_nuclear_med/ra5_0hl7is.pdf)
204
+ (Version 3.6, Patch RA\*5.0\*203, June 2024, U.S. Department of Veterans Affairs), which documents
205
+ "ZDS Segment Fields in ORU and ORM", not an invented quirk (ADR 0018). The spec's messaging is
206
+ HL7-native v2.4, so the profile declares no custom date formats. Ships one synthetic, PHI-scanned
207
+ `vendor-shapes/va/oru-r01.hl7` (ORU^R01 + ZDS) fixture whose Study Instance UID uses the Medical
208
+ Connections free UID root, not the reserved DICOM arc. Additive: no change to existing profiles,
209
+ warning codes, or the parse/serialize surface.
210
+
211
+ - **`profiles.philips`: seventh built-in vendor profile (Philips Vue PACS "IS Link" imaging).**
212
+ Declares the six Vue PACS custom Z-segments, one per filler role: `ZDS` (DICOM **Study Instance
213
+ UID**, an RP composite whose first component is the UID), `ZLK` (linked studies / linked orders),
214
+ `ZAO` (order additional details: modality, body part, result-transfer + acquisition status,
215
+ technician and radiologist name/id, and the vendor's custom string/number/date slots), `ZEB`
216
+ (encrypted patient-info blob), `ZAP` (patient additional details), and `ZAV` (visit additional
217
+ details), so a Vue PACS ORM/ADT feed parses without `UNKNOWN_SEGMENT` warnings and each field
218
+ resolves by name (e.g. `zao.get("acquisitionStatus")`). Field positions are transcribed **verbatim**
219
+ from the spec, including two of its own gaps (`ZAO` has no field 7; `ZAP` has no field 2). Grounded
220
+ in the **publicly published** [Vue PACS 12.2.8 HL7 Interface Specifications](https://www.documents.philips.com/assets/Conformance%20Statements/20240409/8941f89d89aa4983aab7b14d00db578c.pdf)
221
+ (Philips, doc HA1669 Rev A, §§5.11–5.16), not an invented quirk (ADR 0018). Vendor `TS` timestamps
222
+ are HL7-native, so the profile declares no custom date formats. Naming the `ZEB` field is precisely
223
+ how a consumer learns that segment is PHI-bearing and must not be logged; the profile only _names_
224
+ fields. It never decodes, decrypts, or rewrites them. Ships two synthetic, PHI-scanned fixtures
225
+ (`vendor-shapes/philips/orm-o01.hl7` order-filler; `vendor-shapes/philips/adt-a08.hl7`
226
+ patient/visit-filler) with documented provenance. Additive: no change to existing profiles, warning
227
+ codes, or the parse/serialize surface.
228
+
229
+ - **`profiles.visage`: sixth built-in vendor profile (Visage 7 imaging/PACS).** Declares the `ZDS`
230
+ Z-segment that RIS/PACS order feeds use to carry the DICOM **Study Instance UID** (field 1, first
231
+ component), the IHE Radiology bridge that correlates an HL7 order to its DICOM study, so an
232
+ imaging ORM parses without an `UNKNOWN_SEGMENT` warning and `zds.get("studyInstanceUid")` resolves
233
+ by name. Grounded in the **publicly published** [Visage 7 HL7 Interface Specification](https://www.visageimaging.com/downloads/Visage7/Visage7_HL7InterfaceSpecification.pdf)
234
+ (V23.00, Jun 2026, Visage Imaging GmbH), its §4.4 ORM^O01 example and ZDS segment table, not an
235
+ invented quirk (ADR 0018: public specs unblock quirk-corpus work; a _specific undocumented_ vendor
236
+ deviation still waits on a private feed). Vendor dates in that spec are HL7-native, so the profile
237
+ declares no custom date formats. Ships with a synthetic `vendor-shapes/visage/orm-o01.hl7` fixture
238
+ (PHI-scanned) and a `test/fixtures/vendor-shapes/README.md` recording per-fixture provenance.
239
+ Additive: no change to existing profiles, warning codes, or the parse/serialize surface.
240
+
241
+ - **PHI commit-scanner (`scripts/phi-scan.ts`, `pnpm phi-scan`).** A zero-dependency, HL7 v2
242
+ shape-aware scanner that refuses fixtures (and a conservative text pass over `src/`) carrying
243
+ real-looking PHI, so a real patient message can never be committed by accident. It parses each
244
+ message's delimiters (`MSH-1` / `MSH-2`) and inspects only the fields that actually carry each
245
+ category: patient / person names (PID-5/-6/-9, NK1-2, GT1-3, IN1-16, MRG-7 as XPN; PV1-7/-8/-9/-17,
246
+ ORC-12, OBR-16, AIP-3, TXA-9, ROL-4 as XCN, reading the family/given from the correct components),
247
+ dates of birth (PID-7 / NK1-16), SSNs (PID-19, CX identifier-type `SS`, dashed patterns anywhere),
248
+ MRN / account numbers (PID-3 / -18), addresses (PID-11 / NK1-4 / GT1-5 / IN1-19), phones
249
+ (PID-13/-14 / NK1 / GT1 without the `555` fake-exchange convention), non-test emails, and a
250
+ site-defined `Z…`-segment name backstop, deliberately NOT a naive `Family^Given` text regex, which
251
+ would trip on coded values like `CBC^Complete Blood Count^LN` (false confidence). Synthetic
252
+ fixtures are positively declared in `scripts/phi-allow-list.txt` (HL7 v2 is byte-strict at the
253
+ front, so an inline `# synthetic: true` header is impossible, same model as `@cosyte/dicom` /
254
+ `@cosyte/x12`); a whole-file bypass requires `--allow-fixture` **and** an audit entry in
255
+ `phi-scan-overrides.md`. All `git` calls use `execFileSync` array-args (never shell-form). Runs at
256
+ pre-commit (`simple-git-hooks --staged`) and in CI (`run-phi-scan: true`); `scripts/verify.sh` now
257
+ reports `phi-scan ✓`. Dev-tooling only: no change to the published package surface or warning codes.
258
+
259
+ - **Trademark notice (`TRADEMARKS.md`).** This package names third-party systems to describe what it
260
+ interoperates with; the notice records that cosyte is not affiliated with, endorsed by, or
261
+ sponsored by any of them, that every reference is descriptive, and that the built-in profiles are
262
+ authored from public sources only. Added to `files` so it ships inside the published tarball, not
263
+ just on GitHub. Documentation only: no runtime or API change.
264
+
18
265
  - **Full documentation Diátaxis spine + doc/code-agreement gate (DOCS-CONTENT-P1).** `docs-content/`
19
266
  gains **Installation**, **Quickstart**, **Guides**, and **Troubleshooting** (with Known
20
267
  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
268
+ against synthetic messages, the template of record every other cosyte parser's docs copy. A new
22
269
  `test/docs-content.test.ts` runs `@cosyte/vitest-config@^0.0.2`'s `docSnippetSuite()` over the
23
270
  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.
271
+ inline `// =>` assertions checked: a documented example can never silently drift from the code.
25
272
  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.
273
+ only: no runtime/API change; the published artifact is unchanged.
27
274
 
28
275
  ### Changed
29
276
 
277
+ - **Corrected stale "not yet published to npm / unpublished" language in the docs site
278
+ (README-ORG-SWEEP).** `@cosyte/hl7` is now published on npm at `0.0.1` and public, so the
279
+ `docs-content/installation.md` Status blockquote ("not yet published… the shape it will take at
280
+ first publish; consume it from source or a workspace link") and the `docs-content/troubleshooting.md`
281
+ "Pre-alpha (`0.0.x`), unpublished" note were factually wrong. Both now state the package is published
282
+ on npm at `0.0.1` and public, still pre-alpha on the `0.0.x`-until-first-alpha ladder, with the
283
+ `npm install @cosyte/hl7` command described as live rather than aspirational. Docs-site content only
284
+ (not in the published tarball); no runtime or public-API change.
285
+
286
+ - **`profiles.meditech` re-grounded to a public MEDITECH spec (HL7-I, ADR 0018).** The profile's
287
+ Z-segment map previously declared a `ZVI` "visit info" segment (`visitReason`/`admitSource`) that
288
+ was a **community-sourced prior with no citable public source**. No public MEDITECH spec documents
289
+ a `ZVI` segment, so, per the "encode a quirk only when a real, publicly-documented spec grounds
290
+ it" mandate, `ZVI` was **removed** and replaced with the DFT charge Z-segments MEDITECH documents
291
+ verbatim in its **publicly downloadable** [Ancillary Charges (LAB/PHA/ITS/IDM) Outbound](https://ehr.meditech.com/sites/default/files/documents/20240613/ancillary-charges-outbound-21.pdf)
292
+ spec (Version 2.1, © 2021 Medical Information Technology, Inc.): `ZF1` ("PROVIDER ENCOUNTER COPAY
293
+ DATA": `providerEncounter`, `misServiceGroup`, `serviceGroupCopay`, `visitCopay`, `copayMinimum`,
294
+ `copayMaximum`) and `ZF2` ("ENCOUNTER PROCEDURE DATA": `setId`, `providerEncounter`,
295
+ `encounterDate`, `encounterProcedure`, `encounterProcedureQuantity`, `encounterProcedureCharge`,
296
+ `prvProcedureAmountPaid`, `prvProcedureAmountDue`). The profile's `YYYYMMDDHHMM` minute-precision
297
+ timestamp format is **kept and now spec-cited**, confirmed by that spec (MSH-7 length 12; EVN-2 /
298
+ PID-7 "Format is YYYYMMDDHHMM") and by the [Admissions and Registration Outbound](https://ehr.meditech.com/sites/default/files/documents/20240613/admissions-registration-outbound-24.pdf)
299
+ spec (Version 2.4, © 2021): "Date and Time in Admissions. Format is YYYYMMDDHHMM". The vendor-shape
300
+ fixture moved from `vendor-shapes/meditech/adt-a04.hl7` (ADT + `ZVI`) to
301
+ `vendor-shapes/meditech/dft-p03.hl7` (DFT^P03 + `ZF1`/`ZF2`), synthetic and PHI-scanned. Consumers
302
+ who relied on `ZVI` from `profiles.meditech` (an ungrounded mapping) must declare it themselves
303
+ via `defineProfile({ extends: profiles.meditech, customSegments: { ZVI: … } })`. `epic`, `cerner`,
304
+ `athena`, and `genericLab` remain community-sourced priors, tracked for the same treatment.
305
+
30
306
  - **Dropped the "Dogfooded in production" claim from the README feature list.** The bullet asserted
31
307
  the package was "used internally on healthcare-integration projects"; no engagement has run it, so
32
308
  the claim was not accurate and has been removed ahead of the first public release. `README.md`
@@ -34,27 +310,40 @@ per the cosyte version ladder (`0.0.x` until first alpha).
34
310
 
35
311
  ### Fixed
36
312
 
313
+ - **`scripts/sync-version.mjs` hardened against two latent defects, and gated in CI
314
+ (SYNC-VERSION-HARDENING).** Follow-up hardening on the VERSION-SYNC script; ported byte-identically
315
+ across `hl7`, `x12`, and `mllp`. (1) The version was spliced into `src/index.ts` via
316
+ `String.prototype.replace` with a _replacement string_, which interprets `$&`, `$1`, `` $` ``, etc.,
317
+ so a version like `1.2.3-$&x` would inject the matched text and corrupt the `VERSION` constant while
318
+ exiting 0. The replacement is now a replacer _function_, whose return value is inserted literally.
319
+ (2) The declaration regex was non-global, so `.replace` silently rewrote the _first_ match; a
320
+ column-0 decoy (e.g. inside a comment) ahead of the real declaration could be edited instead. The
321
+ script now matches globally, asserts exactly one declaration, and exits non-zero loudly otherwise.
322
+ Neither defect is reachable through Changesets today and both previously failed loud rather than
323
+ shipping a lying `VERSION`, so this is hardening, not a fix for an observed break. The
324
+ `format`/`format:check` globs now cover `scripts/**/*.mjs` so the script is prettier-gated in CI (it
325
+ was matched by no glob before). Build tooling only: no runtime or public-API change.
37
326
  - **The Release workflow can actually start.** `.github/workflows/release.yml` calls the shared
38
327
  `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
328
+ no `permissions:` of its own, so it inherited the repo default of `contents: read`. A called
40
329
  workflow may only downgrade the caller's `GITHUB_TOKEN`, never escalate it, so GitHub rejected the
41
330
  workflow at startup (~1s, no jobs, no logs). Every Release run from June 2026 until now failed this
42
331
  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.
332
+ the three scopes explicitly. CI-only: no runtime or API change.
44
333
 
45
334
  - **The `VERSION` export now tracks `package.json`, and the missing `version` script is restored
46
335
  (VERSION-SYNC).** Two latent release bugs, both of which would have bitten at the first publish.
47
336
  (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
337
+ `package.json`, so a published `0.0.1` would have shipped an export reading `"0.0.0"`. Every
49
338
  consumer asserting on or logging `VERSION` told the wrong version of the parser they were running.
50
339
  New `scripts/sync-version.mjs` rewrites the constant from `package.json` (idempotent; exits
51
340
  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
341
+ existed at all.** The shared `cosyte/.github` release workflow drives Changesets with
53
342
  `version: pnpm run version`, which would have failed with `ERR_PNPM_NO_SCRIPT`, so the "Version
54
343
  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
344
+ `package.json` rather than asserting its shape against a regex. The old test would have stayed
56
345
  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`.
346
+ form the siblings mirror. No version bumped, nothing published, still `0.0.0`.
58
347
 
59
348
  - **Value reads no longer double-decode (HL7-VALUE-REDECODE). Behavior change on a rare, spec-legal
60
349
  input (pre-alpha).** The tokenizer already unescapes every subcomponent once on parse, but
@@ -69,13 +358,13 @@ per the cosyte version ladder (`0.0.x` until first alpha).
69
358
 
70
359
  ### Changed
71
360
 
72
- - **Datetime precision + timezone fidelity — the TS/DTM composite (roadmap
361
+ - **Datetime precision + timezone fidelity: the TS/DTM composite (roadmap
73
362
  Phase N). Breaking (pre-alpha).** `field.asTs()` and every helper datetime now
74
363
  return the fidelity `TS` (`DtmParts`: `{ raw, valid, precision, year, month
75
364
  (1–12, spec-native), day, hour, minute, second, fractionalSeconds (verbatim),
76
365
  hasTimezone, offsetMinutes }`) instead of the old `{ raw, date }`. HL7 v2
77
366
  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
367
+ and a missing offset defaults to the **sender's** local zone, not UTC. The
79
368
  prior code zero-filled truncations (`|1970|` → `1970-01-01T00:00:00Z`) and
80
369
  coerced an offset-less value to UTC, which silently shifted a day-only birth
81
370
  date `|19880705|` to the previous calendar day in any negative-offset zone
@@ -87,7 +376,7 @@ hasTimezone, offsetMinutes }`) instead of the old `{ raw, date }`. HL7 v2
87
376
  `patient.dateOfBirth`, `visit.admit/dischargeDateTime`,
88
377
  `observations.observedDateTime` + `TS`/`DT` observation values,
89
378
  `allergies.onsetDate`, `diagnoses.dateTime`, `insurance.effective/expirationDate`,
90
- and `immunizations.administered/expirationDate` — each `Date → TS`. Fidelity
379
+ and `immunizations.administered/expirationDate`: each `Date → TS`. Fidelity
91
380
  only: no localization, timezone conversion, or arithmetic; `HHMM=0000` is never
92
381
  rolled to the previous day. New public exports: `parseDtm`, `formatDtm`,
93
382
  `dtmToDate`, and types `DtmParts` / `DtmPrecision` / `DtmToDateOptions`. **No
@@ -96,7 +385,7 @@ hasTimezone, offsetMinutes }`) instead of the old `{ raw, date }`. HL7 v2
96
385
 
97
386
  ### Removed
98
387
 
99
- - **`parseHl7Timestamp` (and its `ParseHl7TimestampOptions` type) — removed
388
+ - **`parseHl7Timestamp` (and its `ParseHl7TimestampOptions` type), removed
100
389
  (Phase N, breaking).** The Date-returning, UTC-assuming timestamp helper is
101
390
  replaced by the fidelity `parseDtm` + explicit `dtmToDate`, with
102
391
  `parseDtmCascade` covering the MSH-7 user-format fallback path.
@@ -109,7 +398,7 @@ hasTimezone, offsetMinutes }`) instead of the old `{ raw, date }`. HL7 v2
109
398
  `\Z..\`) and hex escapes (`\X41\`) with literal backslashes, so serialize
110
399
  **double-escaped** the former (`\H\` → `\E\H\E\`) and **decoded** the latter
111
400
  (`\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
401
+ was spec-clean and lossless-as-text but **not byte-verbatim**. A byte-level
113
402
  MSA-2/MSH-10 correlation (HL7 v2 §2.9.2.2) could fail. The tokenizer now
114
403
  records each affected subcomponent's **original wire bytes** in an internal
115
404
  `RawComponent.rawSubcomponents` overlay and the serializer emits from it
@@ -117,197 +406,197 @@ hasTimezone, offsetMinutes }`) instead of the old `{ raw, date }`. HL7 v2
117
406
  exact escape bytes. `buildAck`'s MSA-2 echo is byte-exact for a
118
407
  default-delimiter sender; a **custom-delimiter** sender is re-delimited
119
408
  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
409
+ inbound's, the sender's raw bytes would otherwise corrupt MSA-2's structure
410
+ under default delimiters, and the decoded id re-parses back correlation-correct
122
411
  per §2.9.2.2). **The `\X..\` policy is
123
412
  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
413
+ the wire bytes (value and casing) are preserved). Delimiter/newline escapes
125
414
  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** —
415
+ re-emits as `\X0D\` to protect wire framing. **No public-surface break.**
127
416
  `Field.value` and every composite/`toJSON` read the same decoded values as
128
417
  before; the overlay is `@internal` and absent from escape-free messages. See
129
418
  `docs-content/spec-notes-escapes.md`.
130
- - **PHI leak in the warning surface — `fieldWhitespaceTrimmed` and `unknownEscapeSequence` no
419
+ - **PHI leak in the warning surface: `fieldWhitespaceTrimmed` and `unknownEscapeSequence` no
131
420
  longer embed field-body content (HL7-TOKENIZE-PHI).** Both builders previously interpolated
132
421
  slices of the field VALUE directly into `Hl7ParseWarning.message`
133
422
  (`fieldWhitespaceTrimmed`'s before/after strings; `unknownEscapeSequence`'s raw escape body, and
134
423
  on an unterminated escape the **entire rest of the field**), so a clinical NTE-3/OBX-5 free-text
135
424
  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.
425
+ `Hl7Message.warnings`, a leak, since every other warning builder is structural-facts-only.
137
426
  `fieldWhitespaceTrimmed`'s signature changed to take leading/trailing **counts** instead of the
138
427
  original/trimmed strings; `unknownEscapeSequence` now reports only the escape body's **length**
139
428
  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
429
+ X C M Z H N` or a `.`-prefixed formatting escape, structural HL7 grammar, not PHI), that single
430
+ letter, never the body text. A new `unterminatedEscapeSequence` factory (still
142
431
  `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
432
+ tail-length. **No change to parse tolerance or round-trip fidelity.** The escape sequence and
144
433
  the whitespace are still preserved verbatim in the parsed/emitted output; only the WARNING
145
434
  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,
435
+ unchanged and stable (see `test/warning-codes.snapshot.test.ts`). This is a message-format fix,
147
436
  not a breaking change to the code registry.
148
- - **`buildAck` now echoes the full inbound MSH-10 field into MSA-2** — the raw
437
+ - **`buildAck` now echoes the full inbound MSH-10 field into MSA-2.** The raw
149
438
  field structure is carried over whole (delimiters and repetitions included)
150
439
  instead of the component-1-only `meta.controlId` scalar. A vendor-quirk
151
440
  control id carrying an unescaped delimiter (`ID^X`) was previously truncated
152
- to `ID` silently, under a confident positive ACK — a sender correlating on
441
+ to `ID` silently, under a confident positive ACK. A sender correlating on
153
442
  the raw MSH-10 bytes (as `@cosyte/mllp`'s client does) would never match the
154
443
  ACK and resend indefinitely (HL7 v2 §2.9.2.2: an MSA-2 mismatch is a
155
444
  correlation failure). The no-correlation fail-safe now keys on the raw field
156
445
  carrying any content, so a leading-delimiter id (`^X`) correlates instead of
157
446
  being spuriously downgraded. The echo is now **byte-verbatim** across the full
158
- escape alphabet (see the escape-fidelity entry below) — the earlier
447
+ escape alphabet (see the escape-fidelity entry below). The earlier
159
448
  canonicalization limits (hex escapes decoding to their byte, preserved escapes
160
449
  re-emitting as escaped literal text) are resolved. The only remaining
161
450
  transform is structural: the ACK emits with default encoding characters and
162
451
  trailing insignificant empties canonicalize (D-02).
163
452
  - **`reescape` now emits a literal CR in decoded content as its `\X0D\` hex
164
453
  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
454
+ inbound field decoded to a bare CR (the HL7 segment separator) and
166
455
  re-serializing it corrupted the emitted message's framing (a phantom
167
456
  segment split mid-field, silently). Reachable through `buildAck`'s MSA-2
168
457
  echo among every other emit path; now structurally safe and round-trip
169
458
  stable.
170
- - **`interpretAck` surfaces MSA-2 whole** (read-side symmetry) —
459
+ - **`interpretAck` surfaces MSA-2 whole** (read-side symmetry).
171
460
  `Acknowledgment.controlId` is now the field's canonical wire text
172
461
  (`Field.text`), not its first component.
173
462
 
174
463
  ### Added
175
464
 
176
- - **Scheduling / document / charge breadth helpers — `appointments()`,
465
+ - **Scheduling / document / charge breadth helpers: `appointments()`,
177
466
  `documents()`, `charges()` (roadmap Phase Q, P2).** Three new message-family
178
467
  helpers on `Hl7Message`, each mirroring the existing extractor pattern (typed,
179
468
  frozen, never-throws, not memoized):
180
- - **`appointments()`** over **SIU** (S12/S13/S14/S15/S26) — projects each
469
+ - **`appointments()`** over **SIU** (S12/S13/S14/S15/S26), projects each
181
470
  **SCH** into a typed `Appointment`: placer/filler appointment ids (SCH-1/2),
182
471
  **SCH-25 filler status** (Table 0278, verbatim), **SCH-11** start/end timing
183
472
  (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` /
473
+ follow it, surfaced as `AppointmentResource`s tagged `service` / `general` /
185
474
  `location` / `personnel` (the personnel/provider resource also carries a
186
475
  typed `XCN`).
187
- - **`documents()`** over **MDM** (T02/T04/T06) — projects each **TXA** into a
476
+ - **`documents()`** over **MDM** (T02/T04/T06), projects each **TXA** into a
188
477
  typed `ClinicalDocument` with the OBX narrative body grouped positionally
189
478
  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
479
+ (TXA-19, Table 0273) are surfaced as DISTINCT fields and NEVER conflated.** A
191
480
  document can be _available_ before it is _authenticated_, and reading a
192
481
  preliminary document as final is the clinical harm this split prevents. Both
193
482
  are verbatim / provenance-only.
194
- - **`charges()`** over **DFT** (P03) — projects each **FT1** into a typed
483
+ - **`charges()`** over **DFT** (P03), projects each **FT1** into a typed
195
484
  `Charge`: FT1-6 transaction type (Table 0017), FT1-7 transaction code (the
196
485
  institution charge code, CWE), FT1-11/12 extended/unit amounts, and the
197
486
  repeating FT1-19 diagnosis linkage. **No billing logic and no
198
- money-as-float** — amounts are surfaced as their **canonical CP wire text**
487
+ money-as-float.** Amounts are surfaced as their **canonical CP wire text**
199
488
  (e.g. `"150.00^USD"`), never parsed to a `number`.
200
489
 
201
490
  New public types: `Appointment`, `AppointmentResource`, `ClinicalDocument`,
202
- `Charge`. Breadth helpers only — not a scheduling-workflow state machine,
491
+ `Charge`. Breadth helpers only: not a scheduling-workflow state machine,
203
492
  signature verification, or a claims/pricing engine (roadmap §9
204
493
  known-limitations). Never throws on malformed input (HELPERS-07); missing
205
494
  fields → omitted keys.
206
495
 
207
- - **NTE narrative grouping — notes attached to their parent by position (roadmap
496
+ - **NTE narrative grouping: notes attached to their parent by position (roadmap
208
497
  Phase P, P1).** NTE (Notes and Comments) segments are now grouped to their
209
498
  parent **by position** and surfaced on the relevant helper output. An NTE
210
499
  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
500
+ Ch. 2, no link field), so attachment is positional: in an ORU
212
501
  (`{ [ORC] OBR [{NTE}] [{ [OBX] [{NTE}] }] }`, Ch. 7) a note after `OBX` lands on
213
502
  **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
503
+ (`order.notes`: ORC notes first, then OBR), and a note after `PID` on the
215
504
  **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 —
505
+ parent** (after `MSH`, or after an unsupported segment like `PV1`/`AL1`) is
506
+ surfaced at **message level** via the new **`msg.notes()`**. **Fail-safe,
218
507
  never mis-attached, never dropped:** the parent is the _nearest preceding
219
508
  non-NTE segment_ (consecutive NTEs chain; any intervening non-parent segment
220
- resets the target). Order-level notes mirror the `orders()` state machine —
509
+ resets the target). Order-level notes mirror the `orders()` state machine.
221
510
  notes after an `ORC` are buffered and flushed onto the OBR that opens the
222
511
  order, so **several `ORC`s before one `OBR` all contribute** in document order,
223
512
  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
513
+ projection** (a **later `PID`** in a multi-patient ORU (patient view is the
514
+ first PID), or a **trailing/dangling `ORC`** whose order never opens) is
226
515
  routed to `msg.notes()` rather than vanishing. Each non-empty **NTE-3 (Comment,
227
516
  FT, repeating)** repetition is one note line, with the **full** repetition text
228
517
  reassembled + HL7-unescaped, so a non-conformant raw `^`/`&` (which tokenizes
229
518
  NTE-3 into components) is preserved, not silently truncated. New optional
230
519
  `notes?: readonly string[]` on `Observation`, `Order`, and `Patient` (omitted
231
- when empty, frozen when present). **Additive only — no rename, no removal, no
520
+ when empty, frozen when present). **Additive only: no rename, no removal, no
232
521
  new warning code.** Deferred: NTE-2 (source of comment) / NTE-4 (comment type)
233
522
  interpretation, FT formatting-command rendering, and first-class
234
523
  `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,
524
+ - **Character-set / encoding decode: MSH-18 / Table 0211 (roadmap Phase O,
236
525
  P1).** `parseHL7` now resolves a `Buffer` input's declared character set from
237
526
  **MSH-18** and decodes the byte stream to text **before** tokenization, through
238
527
  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
528
+ exported). MSH-18 is honoured as a **repeating** field. The **first**
529
+ occurrence is the message default; a blank field is 7-bit **ASCII**. Decodes
241
530
  **`8859/1`** via Node's true `latin1` (byte-exact; Node's WHATWG
242
531
  `TextDecoder("iso-8859-1")` is windows-1252 and remaps the C1 range, so it is
243
532
  not used), ASCII / **UTF-8** (`UNICODE UTF-8` / `UNICODE`), and the faithfully
244
533
  decoded **ISO-8859** sets (`8859/2`–`8859/8`, `10`, `13`–`16`); `8859/9` and
245
534
  `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
535
+ them to windows-1254/874 (which remap the C1 range, no faithful decoder). The
247
536
  multibyte / ISO-2022 East-Asian sets (`ISO IR14/87/159`, GB 18030, KS X 1001,
248
537
  CNS 11643, BIG-5) and UTF-16/32 are likewise **recognized and preserved
249
538
  verbatim** (full stateful decode is a documented non-goal). The §2.7.4 charset-switch escapes (`\Cxxyy\` / `\Mxxyyzz\`) are
250
539
  recognized by the escape layer and preserved. **Fail-safe (never guess, never
251
540
  silently corrupt):** decoding is **strict** (`fatal: true`) except byte-exact
252
541
  `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
542
+ silent `U+FFFD`. The bytes are read as a `latin1` 1:1 mapping and a warning
254
543
  fires: new **`UNSUPPORTED_CHARSET`** for a recognized set that was not decoded
255
544
  (never-decoded, or a strict-decode failure), existing `UNKNOWN_CHARSET` for a
256
545
  value not in Table 0211. Recoverability is exact for single-byte content;
257
546
  multibyte content is best-effort (a code-unit byte can coincide with a structural
258
- delimiter — documented). **Behaviour change (pre-alpha):** `UNKNOWN_CHARSET` now
547
+ delimiter, documented). **Behaviour change (pre-alpha):** `UNKNOWN_CHARSET` now
259
548
  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
549
+ charset code only (never a field value) so no PHI is exposed. New public warning
261
550
  code `UNSUPPORTED_CHARSET` (`WARNING_CODES` 18 → 19). See
262
551
  `docs-content/spec-notes-charset.md`.
263
552
 
264
- - **Order / medication timing — `order.timings` + `med.timings` (roadmap
553
+ - **Order / medication timing: `order.timings` + `med.timings` (roadmap
265
554
  Phase M).** `orders()` and `medications()` now surface the timing **structure**
266
555
  of an order/medication as a typed `OrderTiming[]`, read from the `TQ1` segment
267
556
  (HL7 v2.5+, Ch. 4 §4.5.4) or the **legacy embedded TQ** data type in `ORC-7` /
268
557
  `RXE-1` (pre-v2.5, Ch. 2A §2.A.81, detail withdrawn as of v2.7). Modelled:
269
558
  TQ1-2 quantity (CQ), **TQ1-3 repeat pattern (RPT, Table 0335)**, TQ1-4 explicit
270
559
  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**
560
+ `TS`), TQ1-9 priority (CWE), and **TQ1-14 total occurrences (NM), not TQ1-11**
272
561
  (the Text Instruction). The repeat pattern is surfaced **verbatim**
273
- (`repeatPattern.code`) — never normalized, resolved to clock times, or mapped
562
+ (`repeatPattern.code`), never normalized, resolved to clock times, or mapped
274
563
  to a different frequency (reading `Q6H` as "daily", or losing a `BID`, changes
275
564
  the administered dose count). A provenance-only `kind`
276
565
  (`parametric`/`named`/`unknown`) never drives a schedule; a `parametric`
277
566
  `Q<integer><unit>` template surfaces its **load-bearing integer** on
278
567
  `repeatPattern.interval`, never dropped. `TQ1` vs the legacy embedded TQ is
279
- chosen **by presence** (`source: "TQ1" | "legacy"`) — the legacy field is read
568
+ chosen **by presence** (`source: "TQ1" | "legacy"`). The legacy field is read
280
569
  only when no `TQ1` accompanies the group, so the same timing is never
281
570
  double-counted and a legacy-only timing is never dropped (the legacy source is
282
571
  `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
572
+ medication). Timings group positionally. A `TQ1` may sit either side of the
284
573
  order detail, an intervening `ORC` re-scopes a following `TQ1` to the next
285
574
  order, and multiple `TQ1` segments each surface (a tapering schedule). New
286
575
  public types `OrderTiming`, `RepeatPattern`,
287
576
  `RepeatPatternKind`, `TimingQuantity`; `Order` / `Medication` gain an
288
- always-present `timings` array. **Additive only** — no rename, no removal, **no
577
+ always-present `timings` array. **Additive only:** no rename, no removal, **no
289
578
  new warning code**. hl7 surfaces the timing structure only; it does not compute
290
579
  schedules, resolve "institution-specified times", or interpret sig. Non-goals:
291
580
  `TQ2` beyond segment recognition, schedule computation, 2nd+ repetitions of
292
581
  repeating timing fields. Never throws (HELPERS-07). See
293
582
  `docs-content/spec-notes-timing.md`.
294
583
 
295
- - **`splitBatch()` — batch / file envelope splitting (roadmap Phase L).**
584
+ - **`splitBatch()`: batch / file envelope splitting (roadmap Phase L).**
296
585
  Demarcates the individual `MSH`-led messages inside an HL7 v2 batch/file
297
586
  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
587
+ hands each one back parsed: either `{ ok: true, message }` or, for a message
299
588
  that trips a Tier-3 fatal, `{ ok: false, error }`. A **malformed message
300
589
  mid-batch is isolated**, never suppressing its siblings; a **bare single
301
590
  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
591
+ reconciled: **BTS-1** (batch message count) against the messages in the
592
+ batch, **FTS-1** (file batch count) against the batches in the file, and a
304
593
  mismatch surfaces the new Tier-2 `BATCH_COUNT_MISMATCH` warning **without ever
305
594
  dropping the tail**; an absent (`[0..1]`) or non-numeric count tolerantly
306
595
  disables reconciliation. A `BHS`/`FHS` header with no matching `BTS`/`FTS`
307
596
  raises the new `BATCH_MISSING_TRAILER` warning (`splitBatch` warns, never
308
- rejects — enforcing a mandatory envelope is the caller's call). Batch-level
597
+ rejects: enforcing a mandatory envelope is the caller's call). Batch-level
309
598
  warnings live on the returned `BatchSplitResult.warnings` (never on
310
- `Hl7Message.warnings`) and carry **counts / segment names / positions only —
599
+ `Hl7Message.warnings`) and carry **counts / segment names / positions only,
311
600
  no PHI**. A `Buffer` stream round-trips through `latin1` so each message's own
312
601
  MSH-18 charset resolution runs on its original bytes. Kept a **separate
313
602
  surface** from `parseHL7` (which rejects non-`MSH`-first input by design).
@@ -315,7 +604,7 @@ X C M Z H N` or a `.`-prefixed formatting escape — structural HL7 grammar, not
315
604
  `batchMissingTrailer` factories, and types `Batch` / `BatchSplitResult` /
316
605
  `BatchMessageEntry` / `BatchEnvelopeSegment` / `BatchEnvelopeName`. **Two
317
606
  additive Tier-2 warning codes** (`BATCH_COUNT_MISMATCH`,
318
- `BATCH_MISSING_TRAILER`) — the stable warning-code contract grows 16 → 18.
607
+ `BATCH_MISSING_TRAILER`). The stable warning-code contract grows 16 → 18.
319
608
  `batches` holds each message run delimited by a `BHS` and/or a `BTS` (a run
320
609
  with neither is still yielded in `messages`, wrapped in no batch); FTS-1
321
610
  reconciles **per-file** so concatenated files raise no false mismatch. Split-only: no
@@ -323,34 +612,34 @@ X C M Z H N` or a `.`-prefixed formatting escape — structural HL7 grammar, not
323
612
  reconciled, no transport de-framing (that is `@cosyte/mllp`). Two structural
324
613
  limits are documented: reserved envelope names are matched by name (a body
325
614
  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
615
+ as a `NO_MSH_SEGMENT` failure, never silently dropped), and one file envelope
327
616
  per stream is the modelled case. See `docs-content/spec-notes-batch.md`.
328
- - **`identityEvents()` — patient-identity / merge events (roadmap Phase K, P0
617
+ - **`identityEvents()`: patient-identity / merge events (roadmap Phase K, P0
329
618
  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
619
+ trigger family: merges (A18/A34/A35/A36/A39/A40/A41/A42), moves (A43/A44),
620
+ link/unlink (A24/A37), person add/update (A28/A31). It surfaces every
332
621
  party **labelled by role with segment provenance**: the `surviving` party is
333
622
  only ever sourced from PID/PV1, the `prior` (non-surviving) party only ever
334
623
  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) —
624
+ Ch. 3, A18: PID carries the surviving information, MRG the non-surviving),
336
625
  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
626
+ Fail-safe: an incomplete MRG→PID pair (no MRG, an orphaned MRG (which is
627
+ never dropped), or a PID with no surviving identifier) surfaces what is
339
628
  present plus a new event-scoped Tier-2
340
629
  **`MERGE_MISSING_PRIOR_OR_SURVIVOR`** warning carrying structural facts
341
- only (never an identifier or name — no PHI). The MRG field map is
630
+ only (never an identifier or name, no PHI). The MRG field map is
342
631
  **version-scoped**: the backward-compat single-ID fields (MRG-4 / PID-2,
343
632
  withdrawn as of v2.7) are not read when MSH-12 declares v2.7+. Applying the
344
633
  merge (re-pointing data) stays the consumer's job. New public surface:
345
634
  `Hl7Message.identityEvents()`, types `IdentityEvent` / `IdentityParty` /
346
635
  `IdentityEventKind` / `IdentityRole`, the `mergeMissingPriorOrSurvivor`
347
636
  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
637
+ - **`MRG` is now a known segment.** Parsing a merge message no longer emits
349
638
  a spurious `UNKNOWN_SEGMENT` warning for MRG.
350
- - **`Field.text`** — the field's canonical wire text (full repetitions/
639
+ - **`Field.text`**: the field's canonical wire text (full repetitions/
351
640
  components/subcomponents re-serialized with the active delimiters), the
352
641
  whole-field counterpart to the component-1-only `Field.value`.
353
- - **`downgradePositiveAck(code)`** — the single upstream source of truth for
642
+ - **`downgradePositiveAck(code)`**: the single upstream source of truth for
354
643
  the fail-safe downgrade pair (`AA`→`AE`, `CA`→`CE`; everything else passes
355
644
  through), now exported so `@cosyte/mllp`'s `ack-from-hl7` adapter reuses it
356
645
  instead of carrying a divergent copy. **`isPositiveAck`** is exported
@@ -361,17 +650,17 @@ X C M Z H N` or a `.`-prefixed formatting escape — structural HL7 grammar, not
361
650
  - **Adopted the documentation IA spine in `docs-content/`** (umbrella DOCS-D5; reference
362
651
  exemplar for the suite). The three `spec-notes-*.md` files (coding-system provenance,
363
652
  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
653
+ canonical **Core Concepts** category on `docs.cosyte.com`. They previously existed in
365
654
  `docs-content/` but were not referenced from `sidebars.json` and so were unreachable from
366
655
  any rendered route. Each now carries minimal frontmatter (`id` / `title` /
367
656
  `sidebar_label`) so the sidebar text stays concise; the prose is unchanged. Also
368
657
  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.
658
+ (`<https://…>`) to Docusaurus-MDX-safe `[text](url)` form. Autolinks parse as JSX in
659
+ MDX 3, which silently broke the link rendering. Doc-only: no runtime/API change.
371
660
 
372
661
  - **Added a standard-reference primer (`spec-notes-primer.md`), first under Core Concepts.**
373
662
  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
663
+ spec area); the primer is the spec-grounded companion they elaborate: the v2.x encoding
375
664
  model (MSH-1/MSH-2 delimiter bootstrap, the fixed `CR` terminator, the
376
665
  field→component→subcomponent hierarchy where only fields repeat), the three field
377
666
  population states (populated / not-populated / **null** `|""|`), the abstract message
@@ -381,15 +670,15 @@ X C M Z H N` or a `.`-prefixed formatting escape — structural HL7 grammar, not
381
670
  vs. `AA/AE/AR`), and the `CX`/`XPN`/`XAD`/`HD` composite layouts with their version deltas.
382
671
  Cross-links the phase notes for areas they already cover rather than duplicating them, and
383
672
  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.
673
+ official HL7 spec is the text of record). Doc-only: no runtime/API change.
385
674
 
386
675
  ### Security
387
676
 
388
- - **Dev-dependency advisory remediation (no runtime impact — `@cosyte/hl7`
677
+ - **Dev-dependency advisory remediation (no runtime impact, `@cosyte/hl7`
389
678
  ships zero runtime dependencies, so the published artifact is unchanged).**
390
679
  Added scoped `pnpm.overrides` pinning two transitive **dev/build-time**
391
680
  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
681
+ `0.28.1`; GHSA dev-server path-traversal, not reachable here: the library
393
682
  builds via `tsup`/`vitest` and never runs `esbuild serve`) and the
394
683
  `@changesets/parse` copy of `js-yaml` (`>=4.0.0 <4.2.0` → `4.2.0`;
395
684
  GHSA-h67p-54hq-rp68 merge-key DoS). The `js-yaml@3.14.2` pulled by
@@ -406,28 +695,28 @@ X C M Z H N` or a `.`-prefixed formatting escape — structural HL7 grammar, not
406
695
  `typescript-eslint`, Vitest 4, `@types/node` 22, exact-pinned dev tools, the shared
407
696
  `@cosyte/tsup-config` / `@cosyte/vitest-config`, and thin callers of the reusable `cosyte/.github`
408
697
  CI/release workflows. No public API change.
409
- - **Test bar** — added executable PHI-safety property tests
698
+ - **Test bar**: added executable PHI-safety property tests
410
699
  (`test/property/phi-safety.property.test.ts`). Locks two invariants: warning messages never echo
411
700
  field VALUES (only positional context + bounded metadata), and `Hl7ParseError.snippet` length
412
701
  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
702
+ PHI by design (see `parser/errors.ts:70-72`, the documented consumer-redaction boundary); the
703
+ bound is what we lock in. Does **not** use `@cosyte/test-utils`' `assertNoSecretLeak`. That's
415
704
  for `Secret<T>` wrappers (the pathways credentials pattern) and is the wrong shape for parser-
416
705
  side PHI surfaces.
417
- - **Coverage policy — `src/profiles/**`coverage hole closed, global`branches`floor restored to
706
+ - **Coverage policy: `src/profiles/**`coverage hole closed, global`branches`floor restored to
418
707
  90 (roadmap Phase J, supersedes/absorbs the`H-PHI` coverage-policy entry above).** The D10
419
708
  transient relaxation is now RESOLVED: targeted tests
420
709
  (`test/profiles-merge-validate-coverage.test.ts`) closed the `mergeCustomSegments`/inheritance
421
710
  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
711
  (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
712
+ back to the canonical 90, no remaining relaxation. One line
424
713
  (`validate.ts`'s `validateUniqueFieldNames`throw branch) stays intentionally uncovered and is
425
714
  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
715
+ real JS object (literal,`Map`, `Proxy`, or otherwise) can hold two same-named enumerable own
427
716
  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
717
+ - **Reference test bar: fuzz target, PHI-exec surfaces, and a differential harness (roadmap Phase
429
718
  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
719
+ - `test/property/fuzz.property.test.ts`: a dedicated high-run-count (1000 runs/property, fixed
431
720
  seed) fuzz harness on top of the existing `lenient.property.test.ts` invariant
432
721
  ("`parseHL7` either returns an `Hl7Message` or throws an `Hl7ParseError` with one of the 4
433
722
  `FATAL_CODES`, never anything else"). Adds two generator families the existing suite didn't
@@ -436,48 +725,48 @@ real JS object — literal,`Map`, `Proxy`, or otherwise — can hold two same-na
436
725
  snapshots `FATAL_CODES`/`WARNING_CODES` registry membership and asserts a "survivor
437
726
  round-trip" property (anything that parses can be `toString()`'d and re-parsed without
438
727
  throwing).
439
- - `test/property/phi-safety-surfaces.property.test.ts` — extends
728
+ - `test/property/phi-safety-surfaces.property.test.ts`: extends
440
729
  `phi-safety.property.test.ts` (which covers the WARNING surface) to the FATAL error-message
441
730
  surface: over PHI-shaped field values that trigger each reachable fatal code, asserts
442
731
  `Hl7ParseError.message` carries only structural/positional facts, never an echoed field value.
443
732
  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
733
+ value-absence, snippets MAY carry PHI by design, redaction is a consumer responsibility) holds
445
734
  for BOTH the direct-fatal snippet path and the strict-mode-escalation snippet path
446
735
  (`index.ts::buildSnippet`, a distinct call site). Also documents, with a round-trip test, that
447
736
  `toString()`/`toJSON()`/`prettyPrint()` legitimately contain field values (content surfaces,
448
737
  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
738
+ - `test/differential/differential.test.ts`: an oracle-gated differential harness comparing
450
739
  `@cosyte/hl7` against the external **python-hl7** parser (BSD license, PyPI package `hl7`) over
451
740
  the full canonical corpus (27 fixtures): segment count + per-segment field-text parity. Skips
452
741
  gracefully (`it.skip`) when no Python + `hl7` package is available, so `verify.sh`/CI stay green
453
742
  without Python. One real, honest divergence was found and documented: `@cosyte/hl7`'s D-02
454
743
  trailing-empty-component canonicalization (`Field.text` strips trailing empty
455
- components/subcomponents; python-hl7's `str()` preserves them verbatim) — see
744
+ components/subcomponents; python-hl7's `str()` preserves them verbatim). See
456
745
  `docs-content/spec-notes-differential.md` for the full writeup, licensing bound, and other
457
746
  plausible-but-unobserved divergence classes.
458
747
 
459
748
  ### Fixed
460
749
 
461
- - **Conformance — v2.7+ truncation char no longer rejects spec-conformant input** (roadmap Phase A,
750
+ - **Conformance: v2.7+ truncation char no longer rejects spec-conformant input** (roadmap Phase A,
462
751
  P0 correctness). `readDelimiters` previously hard-coded MSH-2 length to 4 and threw the Tier-3
463
752
  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
753
+ truncation character (`^~\&#` and friends) was rejected outright, a fail-unsafe rejection of
465
754
  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
755
+ §2.5.5.2, the 5th char is the truncation character, default `#`). The `EncodingCharacters` type
467
756
  gains a new optional `truncation?: string` field that is set ONLY when MSH-2 actually declared
468
757
  one, so messages that predate v2.7 round-trip with a 4-char MSH-2 unchanged. The serializer +
469
758
  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
759
+ - **Conformance: standard escape sequences no longer warn as `UNKNOWN_ESCAPE_SEQUENCE`** (roadmap
471
760
  Phase A). Six spec-defined escape families are now recognized:
472
- - `\P\` — truncation character. **Decoded** to `enc.truncation ?? "#"` (spec §2.5.5.2). On
761
+ - `\P\`: truncation character. **Decoded** to `enc.truncation ?? "#"` (spec §2.5.5.2). On
473
762
  serialize, the truncation character is re-escaped back to `\P\` ONLY when MSH-2 declared one,
474
763
  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
764
+ - `\H\` / `\N\`: highlight on / off (spec §2.7.1). **Recognized but preserved verbatim.** The
476
765
  parser does not pick a presentational policy; the markers stay in the decoded string for a
477
766
  downstream renderer to consume.
478
- - `\.sp\`, `\.in\`, `\.ti\`, `\.fi\`, `\.nf\`, `\.ce\` — formatting commands (spec §2.7.6).
767
+ - `\.sp\`, `\.in\`, `\.ti\`, `\.fi\`, `\.nf\`, `\.ce\`: formatting commands (spec §2.7.6).
479
768
  Recognized and preserved verbatim, same rationale as highlight.
480
- - `\Cxxyy\` (single-byte) and `\Mxxyyzz\` (multi-byte, 4 or 6 hex) — character-set switches
769
+ - `\Cxxyy\` (single-byte) and `\Mxxyyzz\` (multi-byte, 4 or 6 hex): character-set switches
481
770
  (spec §2.7.4). Recognized and preserved verbatim because byte-accurate decoding requires
482
771
  charset state this module does not own.
483
772
 
@@ -487,26 +776,26 @@ real JS object — literal,`Map`, `Proxy`, or otherwise — can hold two same-na
487
776
  `escape-highlight.hl7`, `escape-formatting.hl7`) lock the behavior, including byte-exact
488
777
  round-trip through `toString()`.
489
778
 
490
- - **Conformance — SN (Structured Numeric) results no longer silently drop the comparator/range**
779
+ - **Conformance: SN (Structured Numeric) results no longer silently drop the comparator/range**
491
780
  (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 `"<"` —
781
+ previously fell through the plain-string branch, where `<^10` collapsed to the bare string `"<"`,
493
782
  a misread clinical result with a documented patient-harm path (a "less-than 10" result reading as
494
783
  the operator alone). `msg.observations()` now dispatches `SN` to a typed `SN` value
495
784
  (`comparator` / `num1` / `separatorOrSuffix` / `num2`). Fail-safe by construction: `num1`/`num2` are
496
785
  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
786
+ SN.1 is a recognized operator (`>` `<` `>=` `<=` `=` `<>`). A non-operator in the comparator slot
498
787
  is never passed off as a real relation. The comparator is preserved byte-for-byte across a
499
788
  serialize → parse round-trip. New canonical fixture `test/fixtures/canonical/oru-r01-sn-results.hl7`
500
789
  and property tests (`test/property/sn.property.test.ts`) lock the invariant over thousands of
501
790
  generated values.
502
791
 
503
- - **Types resolution from CommonJS** — the `exports` map now points the `require` condition's types at
792
+ - **Types resolution from CommonJS**: the `exports` map now points the `require` condition's types at
504
793
  `dist/index.d.cts` (was `index.d.ts`), fixing a "masquerading as ESM" (`attw` FalseESM) issue for
505
794
  CJS consumers.
506
795
 
507
796
  ### Added
508
797
 
509
- - **Version-sensitivity hardening** (roadmap Phase H, P1) — the coded-element
798
+ - **Version-sensitivity hardening** (roadmap Phase H, P1). The coded-element
510
799
  composites are now robust to **append-only component growth** across HL7
511
800
  v2.1–v2.8, with **no silent truncation**. `CWE` and `CE` gained an optional
512
801
  `extraComponents: readonly string[]` that preserves any components beyond the
@@ -515,7 +804,7 @@ real JS object — literal,`Map`, `Proxy`, or otherwise — can hold two same-na
515
804
  7+**. An absent interior component is held as `""` so `extraComponents[i]`
516
805
  maps back to its HL7 component number; trailing empties are stripped and the
517
806
  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
807
+ **CE↔CWE** reading. Because each accessor preserves the other's extra
519
808
  components, reading a CWE-shaped value through `asCe()` is no longer lossy (CE
520
809
  was deprecated at v2.5, withdrawn at v2.6 in favor of CWE). Parsing a coded
521
810
  element with an arbitrary number of trailing components **never throws**
@@ -524,9 +813,9 @@ real JS object — literal,`Map`, `Proxy`, or otherwise — can hold two same-na
524
813
  test (no-loss / no-throw / CE↔CWE uniformity) and the supported-version matrix
525
814
  in `docs-content/spec-notes-version-matrix.md` (incl. the **TS→DTM**
526
815
  supersession and the **MSH-21** Conformance Statement ID → Message Profile
527
- Identifier rename at v2.5). Additive only — `extraComponents` is a new optional
816
+ Identifier rename at v2.5). Additive only: `extraComponents` is a new optional
528
817
  field; no rename, no removal, no new warning code.
529
- - **Message-type & structure awareness** (roadmap Phase G, P1) — a conservative
818
+ - **Message-type & structure awareness** (roadmap Phase G, P1). A conservative
530
819
  **misroute / truncation safety net**. `msg.structure` reports, for the common
531
820
  message types, whether the core segment groups the HL7 v2.5.1 abstract syntax
532
821
  marks **Required** for that trigger event are present; the parser also emits a
@@ -534,19 +823,19 @@ real JS object — literal,`Map`, `Proxy`, or otherwise — can hold two same-na
534
823
  an `ORU^R01` with no `OBR`/`OBX` result group). Keys on the **trigger event**,
535
824
  not the message family, and models **Required anchors only** (EVN in ADT, PID
536
825
  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
826
+ conformant-but-sparse message never warns. Every well-formed canonical
538
827
  fixture emits zero structural warnings. Recognized types: ADT
539
828
  (A01/A02/A03/A04/A05/A08/A11/A13), ORU^R01, ORM^O01, OML^O21, OMG^O19,
540
829
  OMP^O09, OMI^O23, SIU (S12–S26), MDM (T02/T06), DFT^P03, VXU^V04, ACK; an
541
830
  unrecognized type yields `recognized: false` and emits nothing. Warning-only
542
- (Tier-2) — lenient parse never throws, `strict` may promote; the message
831
+ (Tier-2). Lenient parse never throws, `strict` may promote; the message
543
832
  carries only structural facts (type, group, anchor names), never a field
544
833
  value (no PHI). New public surface: `Hl7Message.structure`, the
545
834
  `missingExpectedGroup` warning factory, the `MISSING_EXPECTED_GROUP` code, the
546
835
  read-only `MESSAGE_STRUCTURE_DEFINITIONS` registry, `analyzeMessageStructure`,
547
836
  and types `MessageStructure`, `StructureGroup`, `ExpectedSegmentGroup`,
548
837
  `MessageStructureDefinition` (see `docs-content/spec-notes-structure.md`).
549
- - **Coding-system provenance** (roadmap Phase F, P1) — `codingSystem(id)`,
838
+ - **Coding-system provenance** (roadmap Phase F, P1). `codingSystem(id)`,
550
839
  `codingSystemOf(coded)`, and `alternateCodingSystemOf(coded)` answer "what
551
840
  system does this code CLAIM?" off a `CWE` / `CE` (CWE.3 / CE.3 primary,
552
841
  CWE.6 / CE.6 alternate). Alias-normalized + case-insensitive
@@ -555,17 +844,17 @@ real JS object — literal,`Map`, `Proxy`, or otherwise — can hold two same-na
555
844
  surfaced verbatim with `known: false` (never dropped, never guessed); a
556
845
  no-claim input returns `undefined`. The recognized subset is the frozen,
557
846
  read-only `KNOWN_CODING_SYSTEMS`: `LN`, `SCT`, `I10`, `I10P`, `RXN`, `NDC`,
558
- `CVX`, `MVX`, `UCUM`. **Provenance only** — no validation, lookup, network,
847
+ `CVX`, `MVX`, `UCUM`. **Provenance only**: no validation, lookup, network,
559
848
  or bundled codeset. `I10` reports the registered Table 0396 claim `ICD-10`
560
849
  (the WHO base), NOT a guessed `ICD-10-CM` (see
561
850
  `docs-content/spec-notes-coding-system.md`). New public types
562
851
  `KnownCodingSystem`, `CodingSystemInfo`, `CodedSystemFields`.
563
- - **Parser** — `parseHL7(raw, optionsOrProfile?)` with a lenient default
852
+ - **Parser**: `parseHL7(raw, optionsOrProfile?)` with a lenient default
564
853
  parser that handles vendor-quirky HL7 v2.1–v2.8 input, and a
565
854
  `{ strict: true }` mode that escalates every Tier-2 deviation to a thrown
566
855
  `Hl7ParseError`. Accepts `string` or `Buffer` input; honours MSH-18
567
856
  character set with a user `charset` override option.
568
- - **Warning system** — 14 stable Tier-2 warning codes with positional
857
+ - **Warning system**: 14 stable Tier-2 warning codes with positional
569
858
  context (`segmentIndex`, `fieldIndex`, `repetitionIndex`, `componentIndex`,
570
859
  `subcomponentIndex`): `MLLP_FRAMING_STRIPPED`, `FIELD_WHITESPACE_TRIMMED`,
571
860
  `UNKNOWN_ESCAPE_SEQUENCE`, `TIMESTAMP_FALLBACK_FORMAT`, `SEGMENT_CASE`,
@@ -573,60 +862,60 @@ real JS object — literal,`Map`, `Proxy`, or otherwise — can hold two same-na
573
862
  `ENCODING_MISMATCH`, `MISSING_REQUIRED_FIELD`, `OUT_OF_ORDER_SEGMENT`,
574
863
  `VERSION_MISMATCH`, `UNKNOWN_CHARSET`, `ACK_NO_CORRELATION_ID`. Exposed via
575
864
  `msg.warnings` and the `onWarning` callback.
576
- - **Fatal errors** — 4 Tier-3 fatal codes always thrown as `Hl7ParseError`
865
+ - **Fatal errors**: 4 Tier-3 fatal codes always thrown as `Hl7ParseError`
577
866
  (even in lenient mode): `NO_MSH_SEGMENT`, `MSH_TOO_SHORT`,
578
867
  `INVALID_ENCODING_CHARACTERS`, `EMPTY_INPUT`. Each error carries
579
868
  `message`, `position`, and `snippet`.
580
- - **Structural model** — immutable `Hl7Message` with dot-path access
869
+ - **Structural model**: immutable `Hl7Message` with dot-path access
581
870
  (`msg.get("PID.5.1")`, `msg.get("OBX[2].5")`), typed `Segment` and
582
871
  `Field` wrappers, and `msg.segments("OBX")[0].field(3)` traversal.
583
872
  Safe-access semantics (`undefined` / `[]` for missing paths, never
584
873
  throws).
585
- - **Composite types** — parsed instances and exported TypeScript
874
+ - **Composite types**: parsed instances and exported TypeScript
586
875
  interfaces for XPN, XAD, CX, CWE, CE, XTN, PL, TS/DTM, NM, SN, HD, and XCN
587
876
  (12 types). Also available under the `HL7` namespace:
588
877
  `import { HL7 } from "@cosyte/hl7"; type T = HL7.XPN`.
589
- - **`SN` composite + `Field.asSn()`** (roadmap Phase B) — `parseSn` /
878
+ - **`SN` composite + `Field.asSn()`** (roadmap Phase B). `parseSn` /
590
879
  `SN` / `HL7.SN` export the Structured Numeric datatype (comparator,
591
880
  num1, separator/suffix, num2), and `field.asSn()` coerces an OBX-5 to it.
592
881
  `msg.observations()` returns `{ valueType: "SN", value: SN | undefined }`
593
882
  for `OBX-2 = SN`.
594
- - **`observation.unitsAreUcum`** (roadmap Phase B) — a boolean claim-check
883
+ - **`observation.unitsAreUcum`** (roadmap Phase B). A boolean claim-check
595
884
  flag, `true` iff OBX-6's coding system (CWE.3) is exactly `UCUM`
596
885
  (HL7 Table 0396). `false` when a unit is present but not declared UCUM
597
886
  (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
887
+ This is a claim check only. The library does not validate UCUM grammar
599
888
  or convert units.
600
- - **Named helpers** — one-line extraction for the most common HL7
889
+ - **Named helpers**. One-line extraction for the most common HL7
601
890
  fields: `msg.meta`, `msg.patient`, `msg.visit`, `msg.observations()`,
602
891
  `msg.orders()`, `msg.nextOfKin()`, `msg.allergies()`, `msg.diagnoses()`,
603
892
  `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
893
+ return `undefined` / empty arrays for missing optional data. They never throw.
894
+ - **`msg.medications()`** (roadmap Phase D, P0 safety). Projects every
606
895
  RXO / RXE / RXD / RXA segment into a typed `Medication` across the four
607
896
  pharmacy contexts (`order` / `encoded` / `dispense` / `administration`),
608
897
  grouping the RXR (route, Table 0162 + site Table 0163) and RXC
609
- (component) segments that follow each parent positionally — the same
898
+ (component) segments that follow each parent positionally, the same
610
899
  state-machine `orders()` uses for OBR → OBX. The give code carries its
611
900
  own coding-system provenance (`giveCode.nameOfCodingSystem`); the give
612
901
  **amount** (how much) and the give **strength** (concentration, RXE-25/26)
613
- are surfaced as **separate fields and are never reconciled** — a strength
902
+ are surfaced as **separate fields and are never reconciled**. A strength
614
903
  a coded drug (e.g. an NDC) implies never validates or overwrites the
615
904
  explicit RXE-25 strength, so a disagreement is preserved for the caller.
616
905
  Numerics are strict-`asNm()` parsed (absent/blank → key omitted, never
617
906
  `NaN`); output is frozen; not memoized. Never throws (HELPERS-07). New
618
907
  public types: `Medication`, `MedicationContext`, `MedicationAmount`,
619
908
  `MedicationStrength`, `MedicationRoute`, `MedicationComponent`.
620
- - **`msg.immunizations()`** (roadmap Phase E, P0 safety) — projects every
909
+ - **`msg.immunizations()`** (roadmap Phase E, P0 safety). Projects every
621
910
  RXA segment of a VXU^V04 into a typed `Immunization`, grouping the RXR
622
911
  (route, Table 0162 + site Table 0163) and OBX (VFC eligibility / funding
623
912
  source) segments that follow each RXA positionally, and carrying
624
913
  `orderControl` from the preceding ORC of the `ORC`→`RXA`→`[RXR]`→`[{OBX}]`
625
- order group — the same state machine `orders()` uses for OBR → OBX. The
914
+ order group, the same state machine `orders()` uses for OBR → OBX. The
626
915
  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
916
+ defaulted**. A mis-key corrupts an IIS add/delete/update dedup; the
628
917
  `recordOrigin` (administered vs historical) is derived **only** from the
629
- well-known NIP001 RXA-9.1 codes (`00`; `01`-`08`) and omitted otherwise —
918
+ well-known NIP001 RXA-9.1 codes (`00`; `01`-`08`) and omitted otherwise,
630
919
  never guessed, with the raw RXA-9 claim always preserved on
631
920
  `informationSource`. The vaccine code carries its own provenance
632
921
  (`vaccineCode.nameOfCodingSystem`, CVX, + any CVX/NDC alternate);
@@ -634,63 +923,63 @@ real JS object — literal,`Map`, `Proxy`, or otherwise — can hold two same-na
634
923
  surfaced as the number `999`, never coerced). Output is frozen; not
635
924
  memoized. Never throws (HELPERS-07). New public types: `Immunization`,
636
925
  `ImmunizationRecordOrigin`.
637
- - **Mutation** — `setField`, `addSegment`, `removeSegment` on
926
+ - **Mutation**: `setField`, `addSegment`, `removeSegment` on
638
927
  `Hl7Message`. Direct field mutation on unwrapped objects has no effect
639
928
  (immutability by default).
640
- - **Serialization** — `msg.toString()` emits spec-clean HL7 regardless
929
+ - **Serialization**: `msg.toString()` emits spec-clean HL7 regardless
641
930
  of input quirks (Postel's Law); `msg.toJSON()` returns a structured
642
931
  JSON tree; `msg.prettyPrint()` returns a human-readable multi-line
643
932
  string for logs. Escape sequences are re-encoded on serialize.
644
- - **Message builder** — `buildMessage({...}).addSegment(...).toString()`
933
+ - **Message builder**: `buildMessage({...}).addSegment(...).toString()`
645
934
  constructs valid outbound HL7 from scratch, with helpers for control
646
935
  IDs and HL7 timestamps.
647
- - **ACK generation + interpretation** (roadmap Phase C) — `buildAck(inbound,
936
+ - **ACK generation + interpretation** (roadmap Phase C). `buildAck(inbound,
648
937
  { code, error?, mode? })` produces a spec-clean `MSH`+`MSA`[+`ERR`…]
649
938
  acknowledgment: sender/receiver swapped (full multi-component HDs
650
939
  preserved), MSH-9 = `ACK^<trigger>^ACK`, MSA-2 echoes the inbound MSH-10,
651
940
  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
941
+ as a CWE (ERR-3), and a Table 0516 severity (ERR-4), **codes and locations
653
942
  only, never echoed PHI**. `buildAck` is mechanical (emits the disposition it
654
943
  is told) with one safety override: an inbound with no MSH-10 cannot be
655
944
  correlated, so a requested positive `AA`/`CA` is downgraded to `AE`/`CE`,
656
945
  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.
946
+ returned message. It never fabricates an unverifiable positive ACK.
658
947
  `interpretAck(msg)` is the read-side: a typed `Acknowledgment` view whose
659
948
  `accepted`/`error`/`rejected` flags are derived fail-safe from MSA-1 (all
660
949
  three `false` on an absent or unrecognized code). `detectAckMode(inbound)`
661
950
  exposes the spec-exact original-vs-enhanced detection (MSH-15/16). Control
662
951
  vocabulary (Tables 0008/0357/0516/0155) ships as frozen read-only enums
663
952
  (`ACK_CODES`, `ERR_CONDITION_CODES`, `ERR_SEVERITIES`, `ACK_CONDITIONS`).
664
- - **Profile system** — `defineProfile()` API with `extends` composition
953
+ - **Profile system**: `defineProfile()` API with `extends` composition
665
954
  (single parent or array), merge semantics (scalars overwrite, arrays
666
955
  concat+dedupe, `customSegments` deep-merge per key, `onWarning` chains),
667
956
  `profile.describe()` introspection, `profile.lineage`, and
668
957
  `ProfileDefinitionError` with actionable messages on invalid input.
669
- - **Default profile management** — `setDefaultProfile(p)`,
958
+ - **Default profile management**: `setDefaultProfile(p)`,
670
959
  `getDefaultProfile()`, `setDefaultProfile(null)`. Explicit arguments
671
960
  override; `parseHL7(raw, { profile: null })` opts out for a single call.
672
- - **Five built-in vendor profiles** — `profiles.epic`, `profiles.cerner`,
961
+ - **Five built-in vendor profiles**: `profiles.epic`, `profiles.cerner`,
673
962
  `profiles.meditech`, `profiles.athena`, `profiles.genericLab`. Each
674
963
  authored through the public `defineProfile()` API with date-format
675
964
  fallbacks and named Z-segments.
676
- - **Segment.get(name)** — resolve custom-segment fields by declared
965
+ - **Segment.get(name)**: resolve custom-segment fields by declared
677
966
  name (e.g., `msg.segments("ZDP")[0].get("departmentCode")`) when a
678
967
  matching profile is applied.
679
- - **Three runnable examples** — `examples/extract-patient-info.ts`,
968
+ - **Three runnable examples**: `examples/extract-patient-info.ts`,
680
969
  `examples/read-lab-results.ts`, `examples/modify-and-resend.ts`, each
681
970
  runnable via `pnpm tsx examples/<file>.ts` and smoke-tested via
682
971
  `pnpm examples` in CI.
683
- - **Profile starter kit** — `examples/profile-starter-kit/`, a
972
+ - **Profile starter kit**: `examples/profile-starter-kit/`, a
684
973
  publishable template with its own CI + publish workflows,
685
974
  `CUSTOMIZING.md` walkthrough, and placeholder tokens
686
975
  (`{{YOUR_ORG}}` / `{{PROFILE_NAME}}`) that turn into a ready-to-publish
687
976
  profile package in minutes.
688
- - **Documentation** — comprehensive README (value prop, quickstart,
977
+ - **Documentation**: comprehensive README (value prop, quickstart,
689
978
  feature list, HL7-in-90-seconds primer, three access patterns, full
690
979
  cookbook, Profiles section, Real-World Tolerance table, Error
691
980
  Handling, Roadmap), `CHANGELOG.md`, `CONTRIBUTING.md`, `LICENSE`
692
981
  (MIT).
693
- - **Tooling** — strict TypeScript (`noUncheckedIndexedAccess`,
982
+ - **Tooling**: strict TypeScript (`noUncheckedIndexedAccess`,
694
983
  `exactOptionalPropertyTypes`), dual ESM + CJS build via tsup, Vitest
695
984
  with ≥ 90% per-directory branch coverage on `src/parser/`, `src/model/`,
696
985
  `src/helpers/`, `src/serialize/`, `src/builder/`. Lint, format, and