@cosyte/mllp 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
@@ -12,60 +12,214 @@ this file is maintained by hand (Changesets handles the version bump and publish
12
12
  The first pre-alpha release (`0.0.1`) will ship the v1 MLLP transport surface below. The package
13
13
  begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` until first alpha).
14
14
 
15
+ ### Added
16
+
17
+ - **Em-dash brand gate (`scripts/check-no-emdash.sh`, `pnpm check:no-emdash`, and
18
+ `.github/workflows/no-emdash.yml`).** Enforces the founder directive that bans `U+2014`
19
+ outright (`knowledgebase/06-brand/voice-and-tone.md`: "No em dashes. Ever."), which names
20
+ commit messages explicitly. It covers both halves of the rule: every tracked file, and the
21
+ PR title, body, and commit messages, the latter on the non-default `edited` trigger so
22
+ retitling a PR re-checks it. Its own CI workflow rather than a job in `ci.yml`, because
23
+ `edited` would otherwise re-run the whole Node 22 + 24 matrix on a typo fix.
24
+ **mllp was already clean, measured over all 152 tracked files byte by byte rather than over
25
+ markdown alone** (a markdown-only count is what wrongly cleared `dicom`, which held six live
26
+ em dashes in four non-markdown files), so this gate changed no content and exists purely to
27
+ stop a regression.
28
+ The script is **composed** from three sibling copies, not taken from one: `website`'s
29
+ NUL-exclusion shape as the base, `ncpdp`'s two route fixes (a tracked file named `-` was read
30
+ as standard input and never opened; `-d skip` silently passed a tracked symlink to a
31
+ directory), and `dicom`'s binary-match diagnostic branch. `website`'s shape is the right base
32
+ here because this repo tracks one binary, `vendor/cosyte-hl7-0.0.0.tgz`, and a compressed
33
+ stream can contain the em dash bytes by coincidence, which would red the text-only shape with
34
+ no remediation available. **The disclosed cost, stated rather than implied: a tracked *text*
35
+ file holding a NUL byte would be silently exempt, and seeding the tarball itself with a live
36
+ em dash leaves the gate green. That is a miss, not a pass.** mllp has no NUL-bearing text
37
+ file today, so the exclusion currently exempts exactly one file and that file is a genuine
38
+ binary. The at-risk fixture class is not hypothetical, though: git calls the three
39
+ `test/differential/fixtures/*.frame.bin` captures binary too (lone-CR HL7 v2 framing), they
40
+ hold no NUL so they stay in scope, and each was seeded with a live em dash to prove the gate
41
+ actually reads them. Every route by which the scan could print OK without reading its input
42
+ was checked red before this landed, with a seeded fixture per route; the full account is in
43
+ the script header.
44
+
45
+ ### Security
46
+
47
+ - **Repo-side PHI commit-scanner (`scripts/phi-scan.ts`), matching the `@cosyte/hl7` pilot.**
48
+ mllp transports HL7 v2 payloads (MLLP wraps HL7 in `VT … FS CR`), so its data fixtures
49
+ (`test/**` `.frame.bin` frames) carry the same PHI shapes hl7's do. The scanner is a direct
50
+ port of hl7's HL7 v2 segment/field-position-aware detector (names, DOB, SSN, MRN/account,
51
+ address, phone, email, and a site-defined `Z…`-segment name backstop) with one transport-layer
52
+ addition: it **unwraps the MLLP frame** (strips the `VT` start-block and trailing `FS CR`
53
+ end-block) before the HL7-aware scan, so a framed fixture's payload is scanned exactly as an
54
+ un-framed `.hl7` file and the framing bytes cannot defeat delimiter/segment detection. The
55
+ unwrap only ever removes framing bytes, so malformed frames (missing end-block, double-framing)
56
+ cannot bypass it; non-HL7 binary byte/buffer fixtures fall through to a conservative dashed-SSN
57
+ + email pass: no crash, no false positive. Anything not in the synthetic allow-list
58
+ (`scripts/phi-allow-list.txt`) is a hit. Wired like hl7: `pnpm phi-scan`, a `simple-git-hooks`
59
+ `pre-commit` running `phi-scan --staged`, and `run-phi-scan: true` on the CI caller; adds
60
+ `phi-scan-overrides.md` (the audited bypass log) and `test/scripts/phi-scan.test.ts`. Tooling /
61
+ safety only: no runtime or public-API change.
62
+
63
+ ### Documentation
64
+
65
+ - **`docs-content/` brought to the full canonical Diátaxis spine.** The sidebar was a flat list
66
+ (`intro`, `framing`, `acks`, `reliability`, `tls`, `limitations`); it is now the canonical spine
67
+ every `@cosyte/*` package shares: Overview → Installation → Quickstart → Core Concepts (`framing`,
68
+ `acks`) → Guides (`reliability`, `tls`) → API Reference (resolver-injected) → Troubleshooting
69
+ (`limitations`). So a developer moving between `@cosyte/hl7` and `@cosyte/mllp` gets one
70
+ navigation. Adds two new tutorials: **Installation** (prerequisites, the optional `@cosyte/hl7`
71
+ peer, a runnable smoke test) and **Quickstart** (the framing round-trip, opt-in tolerance with
72
+ stable warning codes, and the client/server surface). Every example honors the "transport, not
73
+ parsing" boundary. Runnable snippets are now gated by the shared doc/code-agreement harness
74
+ (`docSnippetSuite` from `@cosyte/vitest-config/snippets`, wired in `test/docs-content.test.ts`),
75
+ which extracts each ` ```ts runnable ` block, executes it against the built package, and asserts its
76
+ `// =>` results. A documented example can never silently drift from the shipped surface. Also
77
+ corrects two `intro.md` snippets that referenced non-existent API (`createInMemoryTransport` →
78
+ `InMemoryTransport.pair()`; the receive example's fictional `respond`/`buildAck` → the real
79
+ `createServer({ onMessage })`). Bumps the `@cosyte/vitest-config` devDependency to `^0.0.2` for the
80
+ `/snippets` export. Docs and tests only: no runtime or public-API change.
81
+ - **`docs-content/installation.md` publish-status note corrected (README-ORG-SWEEP).** The Status
82
+ callout said the package was "not yet published to npm" and that the install command was "the shape
83
+ it will take at first publish": stale now that `@cosyte/mllp` is published on npm at `0.0.1` and
84
+ public. Rewritten to state it is published and public, still pre-alpha on the cosyte
85
+ `0.0.x`-until-first-alpha ladder (no API-stability promise), and that `npm install @cosyte/mllp` is
86
+ live. Docs only: no runtime or public-API change.
87
+
15
88
  ### Fixed
16
89
 
90
+ - **`ack-from-hl7`: a non-text `encoding` override is now rejected on a `Buffer` inbound too, not just
91
+ the text path, and this fixes a flaky `verify` failure (MLLP-ACK-NONTEXT-CODEC-BUFFER).**
92
+ MLLP-ACK-NONTEXT-CODEC-FRAME (below) spared the `Buffer` path on the belief that a lossy `Buffer`
93
+ override was already caught loudly by the byte-level `MLLP_ACK_CONTROL_ID_NOT_VERBATIM` check. That
94
+ holds for a lossy **charset** codec (`ascii` masking a high bit) but not for a genuinely non-text one.
95
+ A non-text codec (`base64`/`base64url`/`hex`/`utf16le`/`ucs2`) garbles the **inbound** decode:
96
+ `buf.toString("base64")` never begins with `MSH`, so it always routes to the unparseable fallback,
97
+ whose MSA-2 is empty and whose `verifyVerbatimEcho` short-circuits before the NOT_VERBATIM proof can
98
+ run, and then serializes that fallback ACK with the same codec, decoding the ACK text to random
99
+ bytes that ~3–4 % of the time contain a `VT`/`FS` byte and make the strict `encodeFrame` throw a
100
+ nondeterministic `MllpFramingError`. So the path was neither the "loud AE" it was documented to be
101
+ nor caught by any falsifiable check. It was an unreadable frame that sometimes crashed. It surfaced
102
+ as CI flake: the `verify` test asserting a reliable `AE` tripped `encodeFrame` on **both** Node 22
103
+ and Node 24 (the base64 decode is byte-identical across the two: never a runtime divergence, only a
104
+ coin-flip draw of the fallback's generated MSH-10 that landed differently on the two matrix legs of
105
+ one run). `buildMllpAck` now throws a `TypeError` at the boundary for a non-text codec on **any**
106
+ input shape, deterministically. The legitimate byte-level escape hatch is preserved untouched:
107
+ `latin1` (byte-verbatim default for a `Buffer`), `ascii`, `utf8`, and `binary` are still accepted,
108
+ and a lossy charset override on a `Buffer` is still caught loudly by
109
+ `MLLP_ACK_CONTROL_ID_NOT_VERBATIM`. No warning code or other public type changes.
110
+ - **`ack-from-hl7`: a non-text `encoding` override on a text inbound is rejected at the boundary
111
+ instead of emitting a garbage frame (MLLP-ACK-NONTEXT-CODEC-FRAME).** On a `string` / `Hl7Message`
112
+ inbound the resolved codec is used only to serialize the ACK back to bytes. A **text** codec
113
+ (`utf8`/`ascii`/`latin1`) writes the ACK's characters as a byte stream a peer reads back as HL7; a
114
+ **non-text** one does not: `base64`/`base64url`/`hex` reinterpret the ACK *string* as encoded data
115
+ and decode it to unrelated bytes, and `utf16le`/`ucs2` NUL-pad every byte, so the emitted frame is
116
+ wholesale garbage the receiver cannot parse. This was never the silent-corruption class the
117
+ `ascii`-override bleed (above) was: a garbage frame has no readable MSA-2, so the receiver's
118
+ `extractMsaControlId` returns `null` and the ACK-FAILSAFE path already downgrades to a loud `AE`.
119
+ The gap was ergonomic: the unusable ACK was handed back to be written to a socket and found broken
120
+ a round trip later. `buildMllpAck` now throws a `TypeError` at the boundary for a non-text codec on
121
+ a text inbound (exactly as it already does for an unknown `code`), naming the remedy: use a text
122
+ codec, or pass the raw `Buffer`. Scoped to the text path only: on a `Buffer` inbound a codec
123
+ override remains the documented escape hatch, and a lossy one there is still caught loudly by the
124
+ byte-level `MLLP_ACK_CONTROL_ID_NOT_VERBATIM` check. Default `utf8`/`latin1` paths and all-ASCII
125
+ control IDs are unaffected; no warning code or other public type changes.
126
+ - **`scripts/sync-version.mjs` hardened against two latent defects, and gated in CI
127
+ (SYNC-VERSION-HARDENING).** Follow-up hardening on the VERSION-SYNC script; ported byte-identically
128
+ across `hl7`, `x12`, and `mllp`. (1) The version was spliced into `src/index.ts` via
129
+ `String.prototype.replace` with a _replacement string_, which interprets `$&`, `$1`, `` $` ``, etc.,
130
+ so a version like `1.2.3-$&x` would inject the matched text and corrupt the `VERSION` constant while
131
+ exiting 0. The replacement is now a replacer _function_, whose return value is inserted literally.
132
+ (2) The declaration regex was non-global, so `.replace` silently rewrote the _first_ match; a
133
+ column-0 decoy (e.g. inside a comment) ahead of the real declaration could be edited instead. The
134
+ script now matches globally, asserts exactly one declaration, and exits non-zero loudly otherwise.
135
+ Neither defect is reachable through Changesets today and both previously failed loud rather than
136
+ shipping a lying `VERSION`, so this is hardening, not a fix for an observed break. The
137
+ `format`/`format:check` globs now cover `scripts/**/*.mjs` so the script is prettier-gated in CI (it
138
+ was matched by no glob before); widening the gate also reformatted the pre-existing
139
+ `scripts/generate-test-certs.mjs` (cosmetic quote/wrap only). Build tooling only: no runtime or
140
+ public-API change.
141
+ - **`ack-from-hl7`: a lossy `{ encoding: "ascii" }` override on a text inbound can no longer corrupt a
142
+ control ID silently (MLLP-ACK-ASCII-OVERRIDE-BLEED).** The residual path the double-encode fix below
143
+ did not close. `MLLP_ACK_CONTROL_ID_UNVERIFIABLE` originally flagged a text inbound by inspecting the
144
+ **emitted** MSA-2 bytes for a non-ASCII value, a proxy with a blind spot on a lossy override. Node's
145
+ `ascii` codec truncates a code unit to its low 8 bits, so a control-ID code unit above `0xFF`, e.g.
146
+ `U+0153` (`œ`, what a windows-1252 decode yields for a `0x9C` wire byte), is masked *into* the ASCII
147
+ byte range (`0x53`, `'S'`). The emitted MSA-2 is then all-ASCII, the proxy stayed silent, and a
148
+ positive `AA` went out echoing a **different** control ID the sender cannot correlate (ACK timeout →
149
+ resend → **duplicate clinical message**). The check now reads the MSA-2's **pre-encoding code units**
150
+ instead of the emitted bytes, so a non-ASCII code unit is flagged whatever the codec did to the byte,
151
+ a strict superset of the old test (encoding ASCII code units can never produce a non-ASCII byte), so
152
+ the default `utf8` text path is unchanged and all-ASCII control IDs stay quiet. No public-surface
153
+ change; the warning still carries byte/code-unit lengths only (PHI discipline) and names the same
154
+ remedy: pass the raw `Buffer`.
155
+
156
+ - **`ack-from-hl7`: the `string`/`Hl7Message` overload no longer double-encodes a high-bit control ID
157
+ silently.** `buildMllpAck` re-encodes a decoded-text inbound with the JS-native `utf8` default, so
158
+ `buildAckAA(payload.toString("latin1"))` on a control ID of `A <0x8B> B` (legal under `MSH-18` =
159
+ `8859/1`) emitted MSA-2 as `A <0xC2 0x8B> B`, a *different* control ID. The sender keyed its
160
+ in-flight store on `0x8B`, could not match the ACK, timed out, and resent a **duplicate clinical
161
+ message**. The `MLLP_ACK_CONTROL_ID_NOT_VERBATIM` guard could not see it: on a text inbound it
162
+ re-derives the inbound bytes from the same text with the same codec, so the comparison is a
163
+ tautology. The encoding cannot be fixed from decoded text (a string does not remember its codec), so
164
+ this is an **API-shape** fix: the text path now emits a new, distinct warning code,
165
+ **`MLLP_ACK_CONTROL_ID_UNVERIFIABLE`** (exported from `@cosyte/mllp/ack-from-hl7`), whenever the
166
+ emitted MSA-2 holds a non-ASCII byte on a `string`/`Hl7Message` inbound, a *cannot-verify* signal,
167
+ deliberately separate from the `Buffer`-path *proof-of-mismatch*. An all-ASCII control ID stays
168
+ quiet; the warning carries byte lengths only (PHI discipline) and names the remedy: pass the raw
169
+ `Buffer`. Found by the 4th MLLP-ACK-UTF8 conformance-refuter.
170
+
17
171
  - **Removed ten orphan gitlinks from `.claude/worktrees/`.** A commit captured local agent worktree
18
172
  state as ten mode-`160000` gitlinks with no `.gitmodules` entry, pointing at objects that never
19
173
  existed in this repo. This is the ADR 0004 failure mode that `iac` and `pathways` each produced;
20
174
  it went unnoticed here. `.claude/worktrees/` is now gitignored so it cannot recur. Repo hygiene
21
- only — nothing in `src/`, `test/`, or the published tarball is affected.
175
+ only: nothing in `src/`, `test/`, or the published tarball is affected.
22
176
 
23
177
  - **The Release workflow can actually start.** `.github/workflows/release.yml` calls the shared
24
178
  `cosyte/.github` pipeline, which requests `contents`/`id-token`/`pull-requests: write`, but declared
25
- no `permissions:` of its own — so it inherited the repo default of `contents: read`. A called
179
+ no `permissions:` of its own, so it inherited the repo default of `contents: read`. A called
26
180
  workflow may only downgrade the caller's `GITHUB_TOKEN`, never escalate it, so GitHub rejected the
27
181
  workflow at startup (~1s, no jobs, no logs). Every Release run from June 2026 until now failed this
28
182
  way, unnoticed, because a `startup_failure` produces no logs to read. The caller job now declares
29
- the three scopes explicitly. CI-only — no runtime or API change.
183
+ the three scopes explicitly. CI-only: no runtime or API change.
30
184
 
31
- - **`buildRawAck` and the server's auto-ACK path said "AA — I've got it" for messages they could not
185
+ - **`buildRawAck` and the server's auto-ACK path said "AA, I've got it" for messages they could not
32
186
  correlate (MLLP-ACK-FAILSAFE).** A positive acknowledgement tells the sender it may forget the
33
- message; when the ACK names a control ID the sender cannot match — or names one of several messages
34
- it never read — the sender times out and resends, committing a **duplicate clinical message** (or
187
+ message; when the ACK names a control ID the sender cannot match (or names one of several messages
188
+ it never read), the sender times out and resends, committing a **duplicate clinical message** (or
35
189
  believes a destroyed message was delivered). `buildMllpAck` already downgraded and warned; the raw
36
- builder and the default `autoAck: 'AA'` path did not, and four peer-reachable inputs — all
37
- pre-existing on `main` — produced a positive `MSA|AA|`: (1) an inbound with an empty MSH-10, (2)
190
+ builder and the default `autoAck: 'AA'` path did not, and four peer-reachable inputs (all
191
+ pre-existing on `main`) produced a positive `MSA|AA|`: (1) an inbound with an empty MSH-10, (2)
38
192
  **two concatenated `MSH` messages** in one frame (an `AA` naming only the first, message 2 silently
39
193
  unacknowledged), (3) a **`BOM`/`SP`/`TAB` before `MSH`** (the junk shares the MSH's segment line, so
40
194
  `MSH` heads no segment → unreadable → `MSA|AA|` with an empty MSA-2, no warning), and (4) worst,
41
- verified over a real socket, a **raw `VT` inside a payload** — the decoder discards the accumulated
195
+ verified over a real socket, a **raw `VT` inside a payload**: the decoder discards the accumulated
42
196
  bytes (`MLLP_TRAILING_BYTES`) and delivers only the *fragment* after it, which the server auto-ACKed
43
197
  `MSA|AA|`: the clinical message destroyed and positively acknowledged. A requested positive code
44
198
  (`AA`/`CA`) is now **downgraded** to `AE`/`CE` whenever the payload cannot carry a correlatable
45
- positive ACK — no readable `MSH`, an empty MSH-10, a batch/concatenated-message shape
199
+ positive ACK: no readable `MSH`, an empty MSH-10, a batch/concatenated-message shape
46
200
  (`FHS`/`BHS`/`BTS`/`FTS` or a second `MSH`), or, on the server path, a frame the decoder flagged
47
201
  with discarded bytes. This is a **refusal**, not a widened reader: it never makes an unreadable
48
- message readable, re-bases on a located `MSH`, or parses a batch — batch ACK stays its own unbuilt
202
+ message readable, re-bases on a located `MSH`, or parses a batch. Batch ACK stays its own unbuilt
49
203
  feature (`MLLP-BATCH`), a loud non-positive answer. The wire downgrade in `buildRawAck` protects any
50
204
  direct caller (defense in depth); the server re-checks the same condition so the downgrade is
51
205
  **observable**, emitting a PHI-safe `'nack'` event with a new `reason`
52
206
  (`'handler-rejected' | 'uncorrelatable-inbound' | 'discarded-bytes'`), never the payload or control
53
207
  ID. New exports: `rawAckUncorrelatable(payload)` and the `NackReason` type. As part of this,
54
208
  `MLLP_TRAILING_BYTES` is now **reserved for the mid-payload `VT` discard** (a frame-scoped signal)
55
- and is no longer emitted — nor mis-attributed to the *next* frame — for an inter-frame stray byte
209
+ and is no longer emitted (nor mis-attributed to the *next* frame) for an inter-frame stray byte
56
210
  under `allowFsOnly`, which `MLLP_FS_WITHOUT_CR` already reports; without that, a good message
57
211
  pipelined after a stray-byte frame would have been wrongly downgraded to `AE` (caught by the
58
212
  conformance gate).
59
213
  - **The MSH-10 scan ran past the segment terminator and returned the patient's MRN as the
60
214
  correlation key (MLLP-ACK-UTF8; found by the conformance gate).** `extractMshControlId` counted
61
- field separators without ever stopping at `CR`/`LF`. On a **truncated MSH** — one with fewer than
62
- 10 fields, which is malformed, but is precisely what a broken peer sends — the count therefore ran
215
+ field separators without ever stopping at `CR`/`LF`. On a **truncated MSH** (one with fewer than
216
+ 10 fields, which is malformed, but is precisely what a broken peer sends), the count therefore ran
63
217
  *past the segment terminator* and kept counting inside the next segment. Given
64
218
  `MSH|^~\&|EPIC|HOSP|MIRTH|LAB` + `PID|1||MRN00042|…`, the "MSH-10" it returned was **`PID-3`: the
65
219
  patient's medical record number**. `MllpClient.send()` calls this on every outbound payload in
66
220
  controlId mode, so that value became the correlator's key, and was carried into
67
221
  `MllpTimeoutError.messageControlId` and the `MLLP_ACK_UNMATCHED_CONTROL_ID` /
68
- `MLLP_ACK_AFTER_TIMEOUT` warnings — **a patient identifier in a log line, and a mis-read one at
222
+ `MLLP_ACK_AFTER_TIMEOUT` warnings: **a patient identifier in a log line, and a mis-read one at
69
223
  that**, plus a correlation key that is not the control ID the peer will ACK. Present since Phase 5
70
224
  and untouched by MLLP-CORRELATOR-ASCII, which fixed the *decode* of this scan but not its
71
225
  *bounds*. Fixed: the scan is now `readMshSegment`, which bounds the MSH at its terminator before
@@ -74,37 +228,37 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
74
228
  - **`ack-from-hl7` could not echo a control ID verbatim, so a cosyte client could not correlate a
75
229
  cosyte server's ACK (MLLP-ACK-UTF8).** `buildMllpAck` decoded the inbound through the peer
76
230
  parser's charset machinery and re-encoded the ACK through a hardcoded **`utf8`**. The two are not
77
- inverses. A control-ID byte `0x8B` — legal under an `MSH-18` of `8859/1`, and the exact case
78
- `MLLP-CORRELATOR-ASCII` had just fixed on the client — came back out of MSA-2 as the **two** bytes
231
+ inverses. A control-ID byte `0x8B` (legal under an `MSH-18` of `8859/1`, and the exact case
232
+ `MLLP-CORRELATOR-ASCII` had just fixed on the client) came back out of MSA-2 as the **two** bytes
79
233
  `0xC2 0x8B`: a *different* control ID, so HL7 v2.5.1 §2.9.2.2's verbatim-echo requirement was
80
234
  violated. The client keys its in-flight store on the raw bytes it sent, so it could not match that
81
235
  ACK: the send never settled → ACK timeout → resend → **duplicate clinical message**. This was the
82
236
  third and last of the three call sites that each re-derived "read the control ID" independently and
83
237
  each got it wrong differently.
84
- - `Buffer` input is now decoded as **`latin1`** and the ACK re-encoded with the same codec — one
238
+ - `Buffer` input is now decoded as **`latin1`** and the ACK re-encoded with the same codec, one
85
239
  symmetric choice, so the round-trip is the exact identity for any inbound bytes. `latin1` is the
86
240
  only codec for which that holds: `ascii` masks the high bit, `utf8` folds invalid sequences onto
87
241
  `U+FFFD`, and a `TextDecoder`'s `iso-8859-1` label is aliased by the WHATWG Encoding Standard to
88
- **windows-1252** (`0x8B` → `U+2039`), which does not round-trip `0x80`–`0x9F` at all — so the
242
+ **windows-1252** (`0x8B` → `U+2039`), which does not round-trip `0x80`–`0x9F` at all, so the
89
243
  decode had to be taken away from the charset-aware parser and done on the bytes directly.
90
244
  `string` / `Hl7Message` input keeps its `utf8` default (the caller already chose the decode).
91
- - The MSH read is now **one** implementation — `readMshSegment` in `src/internal/control-id.ts` —
245
+ - The MSH read is now **one** implementation, `readMshSegment` in `src/internal/control-id.ts`,
92
246
  genuinely *called* by all three consumers: the client correlator, `buildRawAck`, and
93
247
  `buildMllpAck`. `buildRawAck` previously re-derived its own read
94
248
  (`payload.toString("latin1").split("\r")`, hunting for an `MSH` anywhere in the payload), and
95
249
  the two disagreed on real inputs: on a truncated MSH followed by a `PID` the correlator keyed on
96
- the PID's MRN while `buildRawAck` echoed an empty MSA-2; on a payload with a **leading `CR`** —
97
- which the MLLP decoder passes straight through — `buildRawAck` echoed MSH-10 correctly while the
250
+ the PID's MRN while `buildRawAck` echoed an empty MSA-2; on a payload with a **leading `CR`** (
251
+ which the MLLP decoder passes straight through) `buildRawAck` echoed MSH-10 correctly while the
98
252
  correlator, requiring `MSH` at byte 0, gave up. Every such disagreement is an ACK the sender
99
253
  cannot match.
100
254
  - They now agree at the **tolerant** fixed point, not a lossy one: `readMshSegment` **locates** the
101
255
  `MSH` (the first `CR`/`LF`-delimited segment starting with `MSH`) instead of demanding it at byte
102
256
  0, so a leading `CR`/`LF` or an `FHS`/`BHS` batch header (§2.10.3) cannot hide a control ID that
103
- is plainly present — and *then* bounds the field scan at that segment's terminator. Both rules
257
+ is plainly present, and *then* bounds the field scan at that segment's terminator. Both rules
104
258
  are needed and neither may be traded for the other. An interim version of this fix forced
105
259
  agreement by requiring `MSH` at byte 0 everywhere, which "resolved" the leading-`CR`
106
260
  disagreement by degrading the side that was **right**: `buildRawAck` began emitting a positive
107
- `AA` with an empty MSA-2, **silently**, for a message whose MSH-10 was there to read — a
261
+ `AA` with an empty MSA-2, **silently**, for a message whose MSH-10 was there to read, a
108
262
  tolerance regression that manufactured the very duplicate-message failure this item exists to
109
263
  close. A lenient reader may never drop data that is present (Postel's Law). `buildMllpAck` strips **leading
110
264
  segment terminators only** before parsing, for the same reason and no further: `parseHL7` requires
@@ -112,16 +266,16 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
112
266
  so dropping it can hide nothing.
113
267
  - **An HL7 batch (§2.10.3) is still refused, loudly.** `buildMllpAck` does not implement batch ACK,
114
268
  so an `FHS`/`BHS` envelope falls through to `parseHL7`'s `NO_MSH_SEGMENT` and out into the
115
- warned, non-positive `AE` fallback — exactly as before this item. An interim version of the fix
269
+ warned, non-positive `AE` fallback, exactly as before this item. An interim version of the fix
116
270
  above re-based the payload on the *located* `MSH`, which skipped the batch envelope: the builder
117
271
  then parsed only message 1, silently discarded every later `MSH` and the `BTS`/`FTS`, and returned
118
272
  a positive **`AA` correlated to message 1 with zero warnings** for a batch whose messages 2..N it
119
- had never read — telling the sender the whole batch was accepted. A positive ACK for a message
273
+ had never read, telling the sender the whole batch was accepted. A positive ACK for a message
120
274
  nobody looked at is what the commit contract exists to make structurally impossible. Batch ACK is
121
275
  its own feature; it is not something to arrive at by accident via a byte-offset helper.
122
276
  - **`buildRawAck` assumed `|` was the field separator instead of reading MSH-1
123
- (MLLP-ACK-UTF8, sibling).** MSH-1 *is* the field separator (HL7 v2.5.1 §2.5.4) — the byte at
124
- offset 3 of the MSH segment defines it — and the client-side scanners had always read it
277
+ (MLLP-ACK-UTF8, sibling).** MSH-1 *is* the field separator (HL7 v2.5.1 §2.5.4), the byte at
278
+ offset 3 of the MSH segment defines it, and the client-side scanners had always read it
125
279
  dynamically. `buildRawAck` split on a hardcoded `|`, so a `!`-delimited message yielded one field
126
280
  and **every** echoed field came back empty: the ACK went out as `MSA|AA|` with **no correlation id
127
281
  at all**, unmatchable by construction. It now reads MSH-1 and echoes the inbound's own MSH-1/MSH-2,
@@ -134,21 +288,21 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
134
288
 
135
289
  - **The client's ACK correlator masked the high bit out of the correlation key
136
290
  (MLLP-CORRELATOR-ASCII).** `extractMshControlId` / `extractMsaControlId` decoded MSH-10 / MSA-2
137
- with `ascii` (`byte & 0x7f`) — the same class of bug the Phase 10 entry below fixed in
291
+ with `ascii` (`byte & 0x7f`), the same class of bug the Phase 10 entry below fixed in
138
292
  `buildRawAck`, left behind on the client side since Phase 5, so the server's MSH-10 → MSA-2 echo
139
293
  and the client's read-back did not agree on what a control ID *is*. The extracted string **is**
140
294
  the correlator's key (live store, graveyard, ACK lookup), so a lossy decode is a lossy key: the
141
295
  two legal, distinct control IDs `MSGÉ1` and `MSGI1` (`0xC9 & 0x7F === 0x49`) collapsed onto one
142
296
  key, the second `enqueue()` overwrote the first in the `Map`, and the first send could never be
143
297
  settled by its own ACK. The masked ID was also what reached `MLLP_ACK_UNMATCHED_CONTROL_ID` /
144
- `MLLP_ACK_AFTER_TIMEOUT` observers and `MllpTimeoutError.messageControlId` — an ID that was never
298
+ `MLLP_ACK_AFTER_TIMEOUT` observers and `MllpTimeoutError.messageControlId`, an ID that was never
145
299
  on the wire, misdirecting the operator tracing a lost message. Reachable when MSH-18 declares a
146
300
  non-ASCII charset (e.g. `8859/1`). Fixed: both extractors decode `latin1` (1:1 byte↔code-unit, so
147
301
  distinct bytes stay distinct keys and no VT/FS can be synthesized). Six tests added under
148
302
  `test/client/correlator-controlid.test.ts`, each failing under the old decode, one of them a
149
303
  cross-path round-trip pinning `buildRawAck`'s echo and the client extractors to the same key.
150
304
  Pure-ASCII control IDs are unaffected. **Scope at the time:** the two paths agreed byte-for-byte
151
- only for the `|`-delimited messages `buildRawAck` supported — it still hardcoded `|` where the
305
+ only for the `|`-delimited messages `buildRawAck` supported: it still hardcoded `|` where the
152
306
  extractors read the separator from MSH-1, and the `ack-from-hl7` subpath still round-tripped
153
307
  control IDs through `utf8`. Both of those were left pre-existing then, and are **closed by
154
308
  MLLP-ACK-UTF8** (above); the scanners are now a single shared implementation.
@@ -156,36 +310,36 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
156
310
  - **A peer could crash the server with one high-bit byte, and corrupt the ACK control ID
157
311
  (Phase 10).** `buildRawAck` decoded the inbound message with `ascii`, which masks the high bit
158
312
  (`byte & 0x7f`). Two consequences, both serious. **Spec:** MSA-2 must echo the inbound MSH-10
159
- **verbatim** (HL7 v2.5.1 §2.9.2.2), but a control-ID byte `0x8B` silently became `0x0B` — a
160
- *different* id — breaking the sender's own ACK correlation for any non-ASCII charset. **Safety:**
313
+ **verbatim** (HL7 v2.5.1 §2.9.2.2), but a control-ID byte `0x8B` silently became `0x0B` (a
314
+ *different* id), breaking the sender's own ACK correlation for any non-ASCII charset. **Safety:**
161
315
  `0x8B → 0x0B` is a **VT** and `0x9C → 0x1C` is an **FS**, so `ascii` *synthesized framing
162
316
  delimiters* from ordinary payload bytes; a peer sending one high-bit byte in an echoed MSH field
163
- made the ACK payload contain a real VT/FS, which `encodeFrame` (strict) rejected — and that throw
317
+ made the ACK payload contain a real VT/FS, which `encodeFrame` (strict) rejected, and that throw
164
318
  escaped the `void`-ed `_sendCommitAck`, **crashing the whole server on peer-controlled input with
165
319
  no consumer bug at all**, and suppressing the fail-safe ACK. Fixed: `buildRawAck` uses `latin1`
166
320
  (byte-exact; a delivered payload cannot itself contain VT/FS, and `latin1` cannot synthesize
167
- one), and `_dispatchAck` is now **total** — a frame failure (still reachable via a caller's
321
+ one), and `_dispatchAck` is now **total**: a frame failure (still reachable via a caller's
168
322
  `autoAck: fn`) surfaces as a connection `'error'` and the message goes un-ACKed (fail-safe: the
169
323
  sender resends), never a process kill. New suite `test/server/ack-serialization-safety.test.ts`.
170
- - **Anything throwing on the receive path crashed the whole process — four routes, all closed
324
+ - **Anything throwing on the receive path crashed the whole process, four routes, all closed
171
325
  (Phase 10).** `FrameReader.push()` runs synchronously inside the transport's data callback, which
172
326
  on a real socket **is** the `'data'` listener, so any throw there is an **uncaught exception** that
173
- kills the process — every other connection and every in-flight durable commit with it. The
327
+ kills the process, every other connection and every in-flight durable commit with it. The
174
328
  conformance gate refuted the fix three times, each round surfacing a route the previous fix had
175
- missed — **four** in total:
176
- 1. **The decoder's own throw** — `Connection` fed `push(chunk)` with no `try`/`catch`. Reachable on
329
+ missed, **four** in total:
330
+ 1. **The decoder's own throw**: `Connection` fed `push(chunk)` with no `try`/`catch`. Reachable on
177
331
  a **default server from a single byte**: `SERVER_DEFAULT_FRAMING` leaves `allowMissingLeadingVt`
178
332
  off, so any non-whitespace byte where a `VT` was expected threw `MLLP_MISSING_LEADING_VT`
179
333
  (`MLLP_FRAME_TOO_LARGE` reached the same path). One stray keepalive character from a real
180
334
  interface engine was enough.
181
- 2. **`emit('error')` with no listener** — Node raises `ERR_UNHANDLED_ERROR`, and that throw happened
335
+ 2. **`emit('error')` with no listener**: Node raises `ERR_UNHANDLED_ERROR`, and that throw happened
182
336
  *inside the catch block added for (1)*, escaping by the identical route. `MllpServer`/
183
337
  `MllpClient` each attach an `'error'` listener, which masked it; `Connection` is a public export
184
338
  and need not.
185
- 3. **A throwing `'message'`/`'warning'` subscriber** — `onFrame` dispatches synchronously inside
339
+ 3. **A throwing `'message'`/`'warning'` subscriber**: `onFrame` dispatches synchronously inside
186
340
  `push()`, so an ordinary consumer bug (a metrics tap, a logger) unwound through the socket
187
341
  handler too.
188
- 4. **The five lifecycle emits** — `destroy()` → `_transition()` → `emit('stateChange'|'close'|…)`
342
+ 4. **The five lifecycle emits**: `destroy()` → `_transition()` → `emit('stateChange'|'close'|…)`
189
343
  runs *inside* the catch block added for (1), and a throw raised inside a `catch` is **not**
190
344
  caught by that block. A throwing `'close'` subscriber plus one stray byte still killed the
191
345
  process, four frames up.
@@ -196,11 +350,11 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
196
350
 
197
351
  Now: a fatal framing error surfaces as a frozen `'error'` event (`phase: 'receive'`,
198
352
  `connectionCause: 'framing-fatal'`, the `MllpFramingError` preserved as `cause` so the stable
199
- `code`/`byteOffset` survive) and **only that connection** is destroyed — a server drops the one bad
353
+ `code`/`byteOffset` survive) and **only that connection** is destroyed. A server drops the one bad
200
354
  peer and keeps serving. Every `'error'` emit is guarded by `listenerCount` and wrapped. Subscriber
201
- throws are contained per-subscriber at the dispatch site — what WARN-06 always promised but only
355
+ throws are contained per-subscriber at the dispatch site, what WARN-06 always promised but only
202
356
  half-implemented (the `onWarning` *option* was guarded; the event broadcast was not). A fatal
203
- framing error is also reported **exactly once** now — `destroy(err)` forwards the reason to
357
+ framing error is also reported **exactly once** now: `destroy(err)` forwards the reason to
204
358
  `transport.destroy(err)`, which made a real socket echo it back through `_onTransportError` and
205
359
  emit a second, causeless `'error'`, double-counting on an alerting dashboard. The
206
360
  connection is destroyed rather than resynchronized deliberately: after a throw the reader's position
@@ -208,27 +362,27 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
208
362
  message gets silently mis-split. The existing suites missed all of this because the in-memory
209
363
  transport wraps delivery in `try`/`finally`, re-routing the throw to the *writer*; only a real
210
364
  socket reproduces it. New suites: `test/server/framing-error-containment.test.ts` (real loopback
211
- sockets) and `test/connection/receive-containment.test.ts` (drives the data callback directly) —
365
+ sockets) and `test/connection/receive-containment.test.ts` (drives the data callback directly),
212
366
  both verified to fail without the fixes.
213
367
  - **A fatal framing error triggered an unbounded reconnect storm (Phase 10).**
214
368
  `isTransientConnectionError` switches on `err.code` and fell through to `default: return true`, so a
215
369
  `MllpFramingError` was classified **transient**. `createStarterClient` (where `autoReconnect`
216
- defaults **on**) therefore retried forever against a peer that was not speaking MLLP — an HTTP probe,
217
- a health check, a wrong-port misconfiguration — with the backoff hammering an interface engine that
370
+ defaults **on**) therefore retried forever against a peer that was not speaking MLLP (an HTTP probe,
371
+ a health check, a wrong-port misconfiguration) with the backoff hammering an interface engine that
218
372
  was already misconfigured. `MLLP_*` codes are now **permanent**, alongside the TLS classes and for
219
373
  the same reason: every reconnect meets the same bytes.
220
374
  - **A throwing `'message'` observer suppressed the ACK (Phase 10).** `MllpServer` emits `'message'` to
221
375
  observers *before* ACK dispatch (D-03), so an observer that threw aborted the handler before the ACK
222
- was sent — one broken logger silently turned every message into a no-ACK, and every sender resent
376
+ was sent. One broken logger silently turned every message into a no-ACK, and every sender resent
223
377
  forever with nothing to diagnose it by. The emit is now contained: the throw surfaces on `'error'`
224
378
  and the commit contract proceeds untouched. The ACK decision belongs to `ServerOptions.onMessage`
225
379
  (the durable-commit step), not to a metrics tap.
226
380
  - **Release pipeline could not have released (Phase 10).** The shared `cosyte/.github` release
227
- workflow drives Changesets with `version: pnpm run version`, but no `version` script existed —
228
- it failed with `ERR_PNPM_NO_SCRIPT`, so the "Version Packages" PR could never be opened. Added
381
+ workflow drives Changesets with `version: pnpm run version`, but no `version` script existed.
382
+ It failed with `ERR_PNPM_NO_SCRIPT`, so the "Version Packages" PR could never be opened. Added
229
383
  `version` (`changeset version` → `scripts/sync-version.mjs` → `prettier --write`).
230
384
  - **The `VERSION` export would have lied about the release (Phase 10).** `VERSION` was hardcoded
231
- `"0.0.0"` in `src/index.ts`, while `changeset version` bumps only `package.json` — the published
385
+ `"0.0.0"` in `src/index.ts`, while `changeset version` bumps only `package.json`. The published
232
386
  `0.0.1` would have exported `"0.0.0"`. `scripts/sync-version.mjs` now rewrites the constant from
233
387
  `package.json` inside the `version` script, and `test/sanity.test.ts` compares the export against
234
388
  `package.json` rather than asserting a hardcoded literal against a hardcoded literal (the old
@@ -238,14 +392,14 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
238
392
  equality check against any other version into a compile error. Now `VERSION: string`.
239
393
  - **Docs accuracy (Phase 10).** Found by the conformance gate, which refuted the first cut of the
240
394
  new guide:
241
- - `docs-content/intro.md` described the decoder as liberal outright. It is **strict by default** —
395
+ - `docs-content/intro.md` described the decoder as liberal outright. It is **strict by default**:
242
396
  tolerance is opt-in per flag, and it is `MllpServer` that ships tolerant defaults (`allowFsOnly`,
243
397
  `allowLfAfterFs`, `allowLeadingWhitespace`; `allowMissingLeadingVt` stays off even there).
244
398
  - `MLLP_TRAILING_BYTES` is **not** benign junk between frames. It fires on a `VT` appearing
245
- *mid-payload* — which **discards the accumulated partial payload**, i.e. a **truncated**
246
- message — and on a stray byte after `FS` under `allowFsOnly`. Now documented as something to
399
+ *mid-payload* (which **discards the accumulated partial payload**, i.e. a **truncated**
400
+ message) and on a stray byte after `FS` under `allowFsOnly`. Now documented as something to
247
401
  alert on rather than ignore.
248
- - **`close()` does not drain in-flight messages** — it *rejects* them with
402
+ - **`close()` does not drain in-flight messages**: it *rejects* them with
249
403
  `MllpConnectionError({ phase: 'close' })`. No drain hook is wired to the `DRAINING` state, so
250
404
  `drainTimeoutMs` does not currently bound an in-flight ACK wait on the client. A message in
251
405
  flight at shutdown is an **unknown**, not a failure: the receiver may have committed it. Now
@@ -259,15 +413,15 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
259
413
  inbound declared no usable MSH-2, the builder fell back to the HL7 default encoding characters
260
414
  `^~\&` **without checking them against MSH-1**. For an inbound declaring MSH-1 = `^` (or `~`, `\`,
261
415
  `&`), the fallback's first character *is* the field separator, so the emitted ACK read back with an
262
- **empty MSH-2** and every later MSH field shifted by one (§2.5.4, §2.16 — the delimiters must be
416
+ **empty MSH-2** and every later MSH field shifted by one (§2.5.4, §2.16: the delimiters must be
263
417
  distinct). Fixed: `buildRawAck` substitutes only the one colliding **encoding character** and keeps
264
418
  the inbound's **field separator** unchanged.
265
419
  - Keeping the field separator is the load-bearing part. The field separator is the only byte that
266
420
  can truncate MSA-2, and MSH-10 provably cannot contain it (MSH-10 is a product of splitting the
267
- inbound MSH *on* it). An interim fix instead switched the ACK's field separator to `|` — and
421
+ inbound MSH *on* it). An interim fix instead switched the ACK's field separator to `|`, and
268
422
  since a `|` inside an `^`-delimited message's MSH-10 is *ordinary data* (§2.5.4), an MSH-10 of
269
423
  `ID|X` went out as `MSA|AA|ID|X`, which a receiver reads back as **`ID`**: silently **truncated**.
270
- Truncated is worse than empty — `ID` is *plausible*, so it can match a **different** in-flight
424
+ Truncated is worse than empty: `ID` is *plausible*, so it can match a **different** in-flight
271
425
  send and falsely settle it, losing one clinical message (its `send()` resolves though it was
272
426
  never acknowledged) and duplicating another (the one actually acknowledged stays in flight and
273
427
  resends). Substituting the encoding character avoids all of this: the ACK stays under the
@@ -278,17 +432,24 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
278
432
 
279
433
  ### Added
280
434
 
281
- - **`MLLP_ACK_CONTROL_ID_NOT_VERBATIM` (MLLP-ACK-UTF8).** A new stable warning code —
435
+ - **Trademark notice (`TRADEMARKS.md`).** This package names third-party systems to describe what it
436
+ interoperates with; the notice records that cosyte is not affiliated with, endorsed by, or
437
+ sponsored by any of them, that every reference is descriptive, and that the built-in profiles are
438
+ authored from public sources only. Added to `files` so it ships inside the published tarball, not
439
+ just on GitHub. Documentation only: no runtime or API change.
440
+
441
+
442
+ - **`MLLP_ACK_CONTROL_ID_NOT_VERBATIM` (MLLP-ACK-UTF8).** A new stable warning code,
282
443
  `ack-from-hl7`-scoped, emitted in `MllpAck.warnings`, not through the framing registry (13 codes
283
444
  total now). `buildMllpAck` **verifies** every ACK it builds against the very byte-level scanners the
284
445
  `@cosyte/mllp` client uses to correlate, and warns when MSA-2 is not byte-identical to the inbound
285
446
  MSH-10 (HL7 v2.5.1 §2.9.2.2). The warning reports the two byte **lengths** and withholds the
286
- field values — MSH-10 is inbound payload content and a warning goes to a log. The ACK is still
287
- emitted — a
288
- mismatched ACK beats silence — but the mismatch can no longer pass unremarked, because a
447
+ field values. MSH-10 is inbound payload content and a warning goes to a log. The ACK is still
448
+ emitted. A
449
+ mismatched ACK beats silence. But the mismatch can no longer pass unremarked, because a
289
450
  non-verbatim MSA-2 *is* an ACK the sender cannot match. It fires for an `encoding` override that
290
451
  cannot round-trip the inbound bytes, and for an inbound declaring non-default delimiters or a
291
- whitespace-padded control ID — cases `@cosyte/hl7`'s builder structurally cannot represent (it
452
+ whitespace-padded control ID, cases `@cosyte/hl7`'s builder structurally cannot represent (it
292
453
  always emits `|^~\&`, and trims field whitespace). `buildRawAck` is parser-free and has neither
293
454
  limit, so it remains the answer for a non-default-delimiter peer.
294
455
 
@@ -296,7 +457,7 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
296
457
  the wire bytes are decoded before `buildMllpAck` ever sees them, so it re-encodes the caller's text
297
458
  with the same codec it decoded it with: the codec cancels on both sides and a codec-induced
298
459
  mismatch is **structurally invisible**. `buildAckAA(payload.toString("latin1"))` on a high-bit
299
- control ID emits `0xC2 0x8B` — a different control ID — and warns about nothing. That double-encode
460
+ control ID emits `0xC2 0x8B` (a different control ID) and warns about nothing. That double-encode
300
461
  is pre-existing (byte-identical on the previous release line) and is tracked separately; the guard
301
462
  cannot be grown to catch it, because by then the bytes are gone. What was *new* and is now fixed is
302
463
  the **claim** that it could not happen: `docs-content/acks.md`, `docs-content/limitations.md`, and
@@ -312,7 +473,7 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
312
473
  (clean → typecheck → lint → test → build → `attw`) is green; `changeset version` consumes the
313
474
  pending changesets to exactly **`0.0.1`**; the dry-run packs 24 files (`dist/` + README +
314
475
  LICENSE + CHANGELOG) with public access and no `src/`, `test/`, or `vendor/` leakage;
315
- `pack:docs` produces both docs artifacts. **Nothing was published** — the first real publish
476
+ `pack:docs` produces both docs artifacts. **Nothing was published**. The first real publish
316
477
  is a human gate.
317
478
  - **Full `docs-content/` transport guide.** New pages: **Framing & tolerance** (wire format, the
318
479
  opt-in tolerance flags with a `FrameReader`-vs-`MllpServer` default table, the stable
@@ -329,51 +490,51 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
329
490
  - **Real-world interop, differential conformance & PHI/observability audit (Phase 9).**
330
491
  - **PHI hardening (framing).** `MllpFramingError.snippet` no longer carries a run of payload content
331
492
  bytes on **either** framing path. (1) The decoder's `MLLP_FRAME_TOO_LARGE` used to copy the last 32
332
- accumulated payload bytes into `snippet` (the too-large frame is a full HL7 message) — now empty (the
493
+ accumulated payload bytes into `snippet` (the too-large frame is a full HL7 message), now empty (the
333
494
  anomaly is the frame's *size*, not a byte). (2) The encoder's `MLLP_PAYLOAD_CONTAINS_VT`/`_FS` (strict,
334
- reachable from `client.send()`) copied up to 64 payload bytes around the offending delimiter — now
495
+ reachable from `client.send()`) copied up to 64 payload bytes around the offending delimiter, now
335
496
  just the single offending delimiter byte (a VT/FS control byte the `code` already names). Every
336
497
  framing throw now carries at most the single framing-boundary byte that broke the structure, never a
337
498
  payload run; the `snippet` PHI contract is documented on the field. No public-API change.
338
- - **Differential harness** (`test/differential/`) — byte-parity with the Google Cloud Healthcare MLLP
499
+ - **Differential harness** (`test/differential/`): byte-parity with the Google Cloud Healthcare MLLP
339
500
  adapter and Mirth/NextGen Connect (both R1). Tier 1 (always on) asserts decode + `encodeFrame`
340
501
  byte-parity against canonical R1 golden frames and ACK correlation; Tier 2 (`MLLP_DIFF_ADAPTER`
341
502
  opt-in) checks a live adapter and skips cleanly when unset, so `verify` stays green.
342
- - **Quirk corpus** (`test/conformance/`) — a realistic multi-segment HL7 message driven through each
503
+ - **Quirk corpus** (`test/conformance/`): a realistic multi-segment HL7 message driven through each
343
504
  §3 real-world deviation, asserting the exact warning code/typed error and byte-identical payload
344
505
  recovery; the lenient decoder never throws except the sanctioned `MLLP_FRAME_TOO_LARGE`.
345
- - **PHI-safety property suite** — generative proof (mutation-checked) that no framing diagnostic ever
506
+ - **PHI-safety property suite**: generative proof (mutation-checked) that no framing diagnostic ever
346
507
  echoes payload content, including the oversized path.
347
508
  - **Test-infra:** the pre-existing `test/server/*` suites now use the shared
348
509
  `test/helpers/tracked-servers.ts` (`must()` + `makeServerTracker()`) instead of copy-pasted helpers.
349
- - **Scope note:** the remaining Phase 9 roadmap acceptance items — (c) keepalive / half-open
350
- detection and (d) fuzz chunk-boundary adversaries — were already delivered in earlier phases
510
+ - **Scope note:** the remaining Phase 9 roadmap acceptance items, (c) keepalive / half-open
511
+ detection and (d) fuzz chunk-boundary adversaries, were already delivered in earlier phases
351
512
  (`socket.setKeepAlive` in `src/server/server.ts`; the byte-at-a-time `randomChunks` /
352
513
  `split(1)` generators in `test/property/fuzz.property.test.ts`), so Phase 9 legitimately
353
514
  narrows to the PHI snippet audit + differential harness + quirk corpus.
354
515
  - **TLS / MLLPS hardening (Phase 8).** `TlsTransport` (wraps `tls.TLSSocket`, maps `onConnect` to
355
516
  `'secureConnect'`) joins `NetTransport` as a first-class `Transport`. Client: `ClientOptions.tls?:
356
- TlsOptions | true` — verification **on by default**; the only opt-out is the loud
517
+ TlsOptions | true`: verification **on by default**; the only opt-out is the loud
357
518
  `allowUnverified` flag, which emits a frozen `'securityWarning'` (`MLLP_TLS_VERIFY_DISABLED`) +
358
519
  `process.emitWarning` on every `secureConnect` (initial connect and every reconnect). Server:
359
520
  `ServerOptions.tls?: ServerTlsOptions` with `clientAuth: 'NONE' | 'WANT' | 'MUST'` (ATNA ITI-19
360
- mutual node authentication) — `'WANT'`/`'MUST'` surface a minimal, content-free `peerCertificate`
521
+ mutual node authentication): `'WANT'`/`'MUST'` surface a minimal, content-free `peerCertificate`
361
522
  (`{ subjectCN, issuerCN, validTo, authorized }`) on the `'connection'` event; `authorized`
362
523
  reports whether the chain was verified against `ca` (under `'WANT'` a certificate can be present
363
- yet unverified — never authorize on `subjectCN` alone); `'MUST'` additionally rejects
524
+ yet unverified, never authorize on `subjectCN` alone); `'MUST'` additionally rejects
364
525
  unauthorized/missing client certificates. Failed handshakes (incl. rejected mTLS client certs)
365
526
  never crash the server: a frozen `'tlsClientError'` event (`{ remoteAddress, remotePort, message,
366
527
  code, timestamp }`) is emitted and the server keeps accepting other connections. Both `minVersion`
367
- default to `'TLSv1.2'` — the IHE ATNA ITI-19 "TLS 1.2 Floor" (BCP195) floor (ITI TF-2 §3.19.6.2.3);
368
- `'TLSv1.0'/'TLSv1.1'` are not expressible through this API. No bundled cipher list — `ciphers`
528
+ default to `'TLSv1.2'`: the IHE ATNA ITI-19 "TLS 1.2 Floor" (BCP195) floor (ITI TF-2 §3.19.6.2.3);
529
+ `'TLSv1.0'/'TLSv1.1'` are not expressible through this API. No bundled cipher list: `ciphers`
369
530
  passes through to Node's OpenSSL defaults, which already include both ATNA-mandated ECDHE suites.
370
531
  New typed failure modes on `MllpConnectionError.connectionCause`: `'tls-verify'` (certificate
371
532
  verification failure) and `'tls-handshake'` (TLS-**protocol**-shaped pre-`secureConnect` failures
372
- only — `ERR_SSL_*`, `EPROTO`, OpenSSL alert-bearing errors; pure TCP failures like `ECONNREFUSED`
533
+ only: `ERR_SSL_*`, `EPROTO`, OpenSSL alert-bearing errors; pure TCP failures like `ECONNREFUSED`
373
534
  carry no `connectionCause`, same as plaintext). Both classes are **permanent** for the reconnect
374
- classifier — never auto-reconnect-looped into a misconfigured or MITM'd endpoint — while plain
535
+ classifier (never auto-reconnect-looped into a misconfigured or MITM'd endpoint) while plain
375
536
  network blips stay transient. TLS 1.3 honesty note (RFC 8446 §4.4.2): `connect()` resolving does
376
- NOT guarantee a `clientAuth: 'MUST'` server accepted the client certificate — a rejection
537
+ NOT guarantee a `clientAuth: 'MUST'` server accepted the client certificate. A rejection
377
538
  surfaces moments later as a typed post-connect error classified permanent; ACK correlation
378
539
  remains the delivery guarantee. New exported helpers `isTlsVerificationErrorCode(code)` and
379
540
  `isTlsProtocolError(err)`. New stats fields: `ClientStats.tls`, `ServerStats.tls`,
@@ -382,9 +543,9 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
382
543
  `MLLP_BIND_ALL_INTERFACES`, `isTlsVerificationErrorCode`, `isTlsProtocolError`. See
383
544
  `docs-content/tls.md` for the full guide (mTLS table, TLS-1.3 client-cert-rejection note,
384
545
  known limitations).
385
- - **`@cosyte/mllp/ack-from-hl7` — real helpers (Phase 7); stub removed.** A thin transport
546
+ - **`@cosyte/mllp/ack-from-hl7`: real helpers (Phase 7); stub removed.** A thin transport
386
547
  adapter over `@cosyte/hl7`'s `buildAck` (hl7 owns ACK content + the HL7 control tables;
387
- this package frames and correlates — O-1 boundary). New surface: `buildMllpAck(inbound,
548
+ this package frames and correlates, O-1 boundary). New surface: `buildMllpAck(inbound,
388
549
  { code, error?, encoding?, allowDelimiterBytesInPayload? })` returning a frozen `MllpAck`
389
550
  (`frame` ready-to-write MLLP bytes, unframed `payload`, the built `ack` message,
390
551
  `requestedCode` vs emitted `code`, verbatim `correlationId`, detected `mode`, content-free
@@ -392,25 +553,25 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
392
553
  (original-vs-enhanced from MSH-15/16); lazy peer loading with a typed
393
554
  `MllpPeerMissingError` (`MLLP_PEER_MISSING`) when `@cosyte/hl7` is absent; and the
394
555
  `loadHl7Peer` seam. Fail-safe by construction: a fatally-unparseable inbound never yields
395
- a positive ACK (`AA`→`AE`, `CA`→`CE` via the peer's `downgradePositiveAck` — no divergent
556
+ a positive ACK (`AA`→`AE`, `CA`→`CE` via the peer's `downgradePositiveAck`, no divergent
396
557
  copy of the pair), MSA-2 stays empty, and the result carries the new stable warning code
397
558
  **`MLLP_ACK_INBOUND_UNPARSEABLE`** (public API; 12 codes total). A parseable inbound with
398
559
  no MSH-10 rides the peer's own downgrade + `ACK_NO_CORRELATION_ID`. MSA-2 echoes the
399
- inbound MSH-10 whole — delimiter-bearing vendor-quirk ids (`ID^X`) byte-exact, matching
560
+ inbound MSH-10 whole: delimiter-bearing vendor-quirk ids (`ID^X`) byte-exact, matching
400
561
  this package's own raw-bytes client correlator (escape-bearing ids canonicalize; see the
401
562
  docs' known limitations).
402
563
  - **Dev/test consumption of the unpublished `@cosyte/hl7` peer** via a vendored packed
403
564
  tarball (`vendor/cosyte-hl7-0.0.0.tgz`, devDependency) so the accuracy suite runs against
404
- the real peer in CI — an interim mechanism until the cross-repo consumption decision
565
+ the real peer in CI, an interim mechanism until the cross-repo consumption decision
405
566
  lands; the runtime peer stays optional and is never bundled (`external` in tsup).
406
567
 
407
568
  ### Security
408
569
 
409
- - **Dev-dependency advisory remediation (no runtime impact — `@cosyte/mllp`
570
+ - **Dev-dependency advisory remediation (no runtime impact: `@cosyte/mllp`
410
571
  ships zero runtime dependencies, so the published artifact is unchanged).**
411
572
  Added scoped `pnpm.overrides` pinning two transitive **dev/build-time**
412
573
  packages to their patched releases: `esbuild` (`>=0.27.3 <0.28.1` →
413
- `0.28.1`; GHSA dev-server path-traversal — not reachable here: the library
574
+ `0.28.1`; GHSA dev-server path-traversal, not reachable here: the library
414
575
  builds via `tsup`/`vitest` and never runs `esbuild serve`) and the
415
576
  `@changesets/parse` copy of `js-yaml` (`>=4.0.0 <4.2.0` → `4.2.0`;
416
577
  GHSA-h67p-54hq-rp68 merge-key DoS). The `js-yaml@3.14.2` pulled by
@@ -423,18 +584,18 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
423
584
 
424
585
  ### Added
425
586
 
426
- - **MLLP client + server** — production-grade client and server with framing
587
+ - **MLLP client + server**: production-grade client and server with framing
427
588
  (`VT + payload + FS + CR`), ACK correlation, auto-reconnect with backoff, and backpressure.
428
589
  Buffer-first API on every public surface.
429
- - **Explicit 6-state connection machine** — `CONNECTING | CONNECTED | DRAINING | RECONNECTING |
590
+ - **Explicit 6-state connection machine**: `CONNECTING | CONNECTED | DRAINING | RECONNECTING |
430
591
  DISCONNECTED | CLOSED`, with `stateChange` events carrying `{ from, to, reason }`.
431
- - **Framing** — `FrameReader` with a bounded 16 MB default accumulator (`MLLP_FRAME_TOO_LARGE` on
592
+ - **Framing**: `FrameReader` with a bounded 16 MB default accumulator (`MLLP_FRAME_TOO_LARGE` on
432
593
  overflow); strict encoder, lenient decoder (Postel's Law).
433
594
  - **11 stable warning codes** with byte-offset context (`MLLP_MISSING_LEADING_VT`,
434
595
  `MLLP_FS_WITHOUT_CR`, `MLLP_FRAME_TOO_LARGE`, `MLLP_ACK_UNMATCHED_CONTROL_ID`, …).
435
596
  - **TLS** support; `AbortSignal` on every awaitable and `Symbol.asyncDispose` on every closeable.
436
- - **In-memory transport** (`@cosyte/mllp/testing`) — a deterministic, socket-free test double.
437
- - **`ack-from-hl7` subpath** — placeholder for building ACKs from parsed messages via the optional
597
+ - **In-memory transport** (`@cosyte/mllp/testing`): a deterministic, socket-free test double.
598
+ - **`ack-from-hl7` subpath**: placeholder for building ACKs from parsed messages via the optional
438
599
  `@cosyte/hl7` peer (helpers not yet implemented; Phase 6).
439
600
  - **Property + fuzz test layer** for the framing transport, built on the shared
440
601
  `@cosyte/test-utils` conformance kit and `fast-check` (both dev-only). Covers: codec round-trip
@@ -443,30 +604,30 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
443
604
  `lenientNeverThrowsProperty`; frozen-event-payload immutability via `immutabilityProperty`; a
444
605
  warning-code surface snapshot tripwire via `sortedCodeSet`; and a transport-robustness **fuzz**
445
606
  property feeding arbitrary random byte buffers and chunk-splits through `FrameReader` over the
446
- in-memory transport. Test-only — no public-surface change.
607
+ in-memory transport. Test-only: no public-surface change.
447
608
 
448
609
  - **Fail-safe ACK semantics & the commit contract (Phase 6, HL7 v2.5.1 §2.9.2).** A positive
449
610
  acknowledgement (`AA`) can never precede a successful durable commit: with `autoAck: 'AA'` + an
450
- `onMessage` handler the server **awaits the handler (the commit step) then ACKs** — `AA` on resolve,
611
+ `onMessage` handler the server **awaits the handler (the commit step) then ACKs**: `AA` on resolve,
451
612
  a **negative** code on throw/reject (`AE` by default; `AR` via `MllpAckError`), never `AA` before
452
613
  commit. `autoAck: 'AA'` without a handler is a documented **transport-accept** (received+framed, not
453
614
  application-processed). New public surface: `buildRawAck` (parser-free byte-level ACK builder echoing
454
615
  inbound `MSH-10` into `MSA-2`, never throwing on malformed input), the HL7 Table 0008 `AckCode` /
455
616
  `NegativeAckCode` unions, `MllpAckError`, `resolveNackCode`, and a PHI-safe `'nack'` event
456
617
  (`{ connectionId, ackCode }`) with its `NackEvent` type. No payload content or thrown error text ever
457
- reaches the wire, logs, or events — only routing/control metadata and the static ack code.
458
- - **Package metadata** — added `homepage` and `bugs` fields to `package.json` for npm completeness.
618
+ reaches the wire, logs, or events, only routing/control metadata and the static ack code.
619
+ - **Package metadata**: added `homepage` and `bugs` fields to `package.json` for npm completeness.
459
620
 
460
621
  ### Changed
461
622
 
462
- - **Server bind-safety hardening (Phase 8; BREAKING pre-publish — free before first release).**
623
+ - **Server bind-safety hardening (Phase 8; BREAKING pre-publish, free before first release).**
463
624
  `MllpServer.listen()` / `createStarterServer` default bind host changed `'0.0.0.0'` →
464
- `'127.0.0.1'`. Binding a wildcard host now requires `ServerOptions.allowWildcardBind: true` —
625
+ `'127.0.0.1'`. Binding a wildcard host now requires `ServerOptions.allowWildcardBind: true`,
465
626
  **enforced against the OS-normalized bound address**, not the requested spelling. Literal
466
627
  wildcard spellings (`'0.0.0.0'`, `'::'`, `''`, `'::0'`, `'0:0:0:0:0:0:0:0'`,
467
628
  `'::ffff:0.0.0.0'`) reject with a typed `MllpConnectionError` **before** binding;
468
629
  resolver-only shorthands (`'0'`, `'0.0'`, `'0x0.0.0.0'`, …) are caught by a post-bind check
469
- on `server.address()` — the just-bound server closes immediately and `listen()` rejects,
630
+ on `server.address()`. The just-bound server closes immediately and `listen()` rejects,
470
631
  leaving no listening state and emitting no `'listening'` event. `listen()` is **single-flight**:
471
632
  a call while the server is already listening, or while another `listen()` is in flight, rejects
472
633
  with a typed `MllpConnectionError` instead of racing the first call's post-bind checks (a lost
@@ -490,7 +651,7 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
490
651
 
491
652
  ### Removed
492
653
 
493
- - **`mitata` benchmark dependency and the `bench` script** — the script had no benchmark files.
654
+ - **`mitata` benchmark dependency and the `bench` script**: the script had no benchmark files.
494
655
 
495
656
  ### Deprecated
496
657
 
@@ -498,29 +659,29 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
498
659
 
499
660
  - **Bind errors no longer crash a server with no `'error'` listener (Phase 8 residuals, MLLP-8.1).**
500
661
  The constructor-time `net.Server`/`tls.Server` `'error'` forwarder ran before `listen()`'s own
501
- rejection handler and re-emitted unconditionally — on a server with no `'error'` listener, a plain
662
+ rejection handler and re-emitted unconditionally: on a server with no `'error'` listener, a plain
502
663
  bind error (`EADDRINUSE`, `EACCES`, …) crashed the process (unlistened `EventEmitter` `'error'`
503
664
  emissions throw) instead of rejecting the `listen()` promise. The forwarder is now guarded by
504
665
  **server state**, and the error contract is documented: with a listener attached, always
505
666
  forwarded; with none, a bind-window error rejects the `listen()` promise (the **primary** error
506
- surface — caveat: an `'error'` listener that synchronously calls `close()` during the bind window
667
+ surface, caveat: an `'error'` listener that synchronously calls `close()` during the bind window
507
668
  changes the rejection to the typed close-during-listen error), a stale error after `close()` is
508
669
  dropped, and a runtime error **while serving** (e.g. accept-loop `EMFILE`) deliberately keeps
509
- Node's fail-loud crash-on-unlistened-`'error'` convention — a silent accept outage is impossible.
670
+ Node's fail-loud crash-on-unlistened-`'error'` convention. A silent accept outage is impossible.
510
671
  A throwing `'listening'`/`'securityWarning'` subscriber can no longer strand the `listen()`
511
672
  promise and wedge the single-flight guard: each emit is contained separately (a throw in one
512
- subscriber cannot suppress a later emission — in particular the `MLLP_BIND_ALL_INTERFACES`
673
+ subscriber cannot suppress a later emission, in particular the `MLLP_BIND_ALL_INTERFACES`
513
674
  security warning always survives, with the operator-channel `process.emitWarning` fired first so
514
675
  no event listener can ever suppress it), the throw is surfaced via the guarded `'error'` tap
515
676
  (itself contained against a double throw), and `listen()` still resolves. Also consolidates `listen()`'s five hand-woven settle paths (abort,
516
677
  close-during-listen, no-address reject, post-bind wildcard reject, bind error) into one idempotent
517
- first-caller-wins settle helper — no path can leak a listener, strand the single-flight guard, or
518
- re-settle a settled promise — and documents three subtleties: the post-bind wildcard reject window
678
+ first-caller-wins settle helper (no path can leak a listener, strand the single-flight guard, or
679
+ re-settle a settled promise) and documents three subtleties: the post-bind wildcard reject window
519
680
  is accept-safe (the check runs synchronously on `'listening'`, before any connection can be
520
681
  delivered); `close({ signal })` with an **already-aborted** signal is a no-op `AbortError`
521
682
  rejection that does **not** settle an in-flight `listen()` (which continues and settles on its own
522
683
  bind outcome); and once the bind has succeeded, an abort of the listen signal fired from inside a
523
- `'listening'`/`'securityWarning'` handler is deliberately **too late** — the bind wins and
684
+ `'listening'`/`'securityWarning'` handler is deliberately **too late**: the bind wins and
524
685
  `listen()` resolves (use `close()` to shut down), so aborted-mid-emit can never strand
525
686
  `listening: true` on a closed socket.
526
687