@cosyte/mllp 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,529 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ Versions and publishing are managed with [Changesets](https://github.com/changesets/changesets);
8
+ this file is maintained by hand (Changesets handles the version bump and publish only).
9
+
10
+ ## [Unreleased]
11
+
12
+ The first pre-alpha release (`0.0.1`) will ship the v1 MLLP transport surface below. The package
13
+ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` until first alpha).
14
+
15
+ ### Fixed
16
+
17
+ - **Removed ten orphan gitlinks from `.claude/worktrees/`.** A commit captured local agent worktree
18
+ state as ten mode-`160000` gitlinks with no `.gitmodules` entry, pointing at objects that never
19
+ existed in this repo. This is the ADR 0004 failure mode that `iac` and `pathways` each produced;
20
+ 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.
22
+
23
+ - **The Release workflow can actually start.** `.github/workflows/release.yml` calls the shared
24
+ `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
26
+ workflow may only downgrade the caller's `GITHUB_TOKEN`, never escalate it, so GitHub rejected the
27
+ workflow at startup (~1s, no jobs, no logs). Every Release run from June 2026 until now failed this
28
+ 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.
30
+
31
+ - **`buildRawAck` and the server's auto-ACK path said "AA — I've got it" for messages they could not
32
+ 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
35
+ 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)
38
+ **two concatenated `MSH` messages** in one frame (an `AA` naming only the first, message 2 silently
39
+ unacknowledged), (3) a **`BOM`/`SP`/`TAB` before `MSH`** (the junk shares the MSH's segment line, so
40
+ `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
42
+ bytes (`MLLP_TRAILING_BYTES`) and delivers only the *fragment* after it, which the server auto-ACKed
43
+ `MSA|AA|`: the clinical message destroyed and positively acknowledged. A requested positive code
44
+ (`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
46
+ (`FHS`/`BHS`/`BTS`/`FTS` or a second `MSH`), or, on the server path, a frame the decoder flagged
47
+ 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
49
+ feature (`MLLP-BATCH`), a loud non-positive answer. The wire downgrade in `buildRawAck` protects any
50
+ direct caller (defense in depth); the server re-checks the same condition so the downgrade is
51
+ **observable**, emitting a PHI-safe `'nack'` event with a new `reason`
52
+ (`'handler-rejected' | 'uncorrelatable-inbound' | 'discarded-bytes'`), never the payload or control
53
+ ID. New exports: `rawAckUncorrelatable(payload)` and the `NackReason` type. As part of this,
54
+ `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
56
+ under `allowFsOnly`, which `MLLP_FS_WITHOUT_CR` already reports; without that, a good message
57
+ pipelined after a stray-byte frame would have been wrongly downgraded to `AE` (caught by the
58
+ conformance gate).
59
+ - **The MSH-10 scan ran past the segment terminator and returned the patient's MRN as the
60
+ 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
63
+ *past the segment terminator* and kept counting inside the next segment. Given
64
+ `MSH|^~\&|EPIC|HOSP|MIRTH|LAB` + `PID|1||MRN00042|…`, the "MSH-10" it returned was **`PID-3`: the
65
+ patient's medical record number**. `MllpClient.send()` calls this on every outbound payload in
66
+ controlId mode, so that value became the correlator's key, and was carried into
67
+ `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
69
+ that**, plus a correlation key that is not the control ID the peer will ACK. Present since Phase 5
70
+ and untouched by MLLP-CORRELATOR-ASCII, which fixed the *decode* of this scan but not its
71
+ *bounds*. Fixed: the scan is now `readMshSegment`, which bounds the MSH at its terminator before
72
+ reading any field out of it. A field that does not exist reads as **absent**, never as the next
73
+ segment's contents.
74
+ - **`ack-from-hl7` could not echo a control ID verbatim, so a cosyte client could not correlate a
75
+ cosyte server's ACK (MLLP-ACK-UTF8).** `buildMllpAck` decoded the inbound through the peer
76
+ 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
79
+ `0xC2 0x8B`: a *different* control ID, so HL7 v2.5.1 §2.9.2.2's verbatim-echo requirement was
80
+ violated. The client keys its in-flight store on the raw bytes it sent, so it could not match that
81
+ ACK: the send never settled → ACK timeout → resend → **duplicate clinical message**. This was the
82
+ third and last of the three call sites that each re-derived "read the control ID" independently and
83
+ each got it wrong differently.
84
+ - `Buffer` input is now decoded as **`latin1`** and the ACK re-encoded with the same codec — one
85
+ symmetric choice, so the round-trip is the exact identity for any inbound bytes. `latin1` is the
86
+ only codec for which that holds: `ascii` masks the high bit, `utf8` folds invalid sequences onto
87
+ `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
89
+ decode had to be taken away from the charset-aware parser and done on the bytes directly.
90
+ `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` —
92
+ genuinely *called* by all three consumers: the client correlator, `buildRawAck`, and
93
+ `buildMllpAck`. `buildRawAck` previously re-derived its own read
94
+ (`payload.toString("latin1").split("\r")`, hunting for an `MSH` anywhere in the payload), and
95
+ 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
98
+ correlator, requiring `MSH` at byte 0, gave up. Every such disagreement is an ACK the sender
99
+ cannot match.
100
+ - They now agree at the **tolerant** fixed point, not a lossy one: `readMshSegment` **locates** the
101
+ `MSH` (the first `CR`/`LF`-delimited segment starting with `MSH`) instead of demanding it at byte
102
+ 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
104
+ are needed and neither may be traded for the other. An interim version of this fix forced
105
+ agreement by requiring `MSH` at byte 0 everywhere, which "resolved" the leading-`CR`
106
+ 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
108
+ tolerance regression that manufactured the very duplicate-message failure this item exists to
109
+ close. A lenient reader may never drop data that is present (Postel's Law). `buildMllpAck` strips **leading
110
+ segment terminators only** before parsing, for the same reason and no further: `parseHL7` requires
111
+ `MSH` to be the first segment, and a leading `CR`/`LF` is pure terminator noise carrying no data,
112
+ so dropping it can hide nothing.
113
+ - **An HL7 batch (§2.10.3) is still refused, loudly.** `buildMllpAck` does not implement batch ACK,
114
+ 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
116
+ above re-based the payload on the *located* `MSH`, which skipped the batch envelope: the builder
117
+ then parsed only message 1, silently discarded every later `MSH` and the `BTS`/`FTS`, and returned
118
+ 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
120
+ nobody looked at is what the commit contract exists to make structurally impossible. Batch ACK is
121
+ its own feature; it is not something to arrive at by accident via a byte-offset helper.
122
+ - **`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
125
+ dynamically. `buildRawAck` split on a hardcoded `|`, so a `!`-delimited message yielded one field
126
+ and **every** echoed field came back empty: the ACK went out as `MSA|AA|` with **no correlation id
127
+ at all**, unmatchable by construction. It now reads MSH-1 and echoes the inbound's own MSH-1/MSH-2,
128
+ which also keeps the echoed field *content* and the delimiters that define it together (re-emitting
129
+ `ID#X` under `^~\&` silently turns two components into one). Segment splitting now tolerates `LF`
130
+ and `CRLF` as well as `CR`, matching the scanners: an `LF`-terminated inbound previously left the
131
+ whole message as one "MSH" segment and emitted the ACK's MSH-12 as `2.5.1\nPID`, embedding a raw
132
+ `LF` and a stray segment id in the ACK. A framing byte (`VT`/`FS`) or segment terminator declared
133
+ as MSH-1 is refused and falls back to a minimal ACK, so the ACK can always be framed.
134
+
135
+ - **The client's ACK correlator masked the high bit out of the correlation key
136
+ (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
138
+ `buildRawAck`, left behind on the client side since Phase 5, so the server's MSH-10 → MSA-2 echo
139
+ and the client's read-back did not agree on what a control ID *is*. The extracted string **is**
140
+ the correlator's key (live store, graveyard, ACK lookup), so a lossy decode is a lossy key: the
141
+ two legal, distinct control IDs `MSGÉ1` and `MSGI1` (`0xC9 & 0x7F === 0x49`) collapsed onto one
142
+ key, the second `enqueue()` overwrote the first in the `Map`, and the first send could never be
143
+ 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
145
+ on the wire, misdirecting the operator tracing a lost message. Reachable when MSH-18 declares a
146
+ non-ASCII charset (e.g. `8859/1`). Fixed: both extractors decode `latin1` (1:1 byte↔code-unit, so
147
+ distinct bytes stay distinct keys and no VT/FS can be synthesized). Six tests added under
148
+ `test/client/correlator-controlid.test.ts`, each failing under the old decode, one of them a
149
+ cross-path round-trip pinning `buildRawAck`'s echo and the client extractors to the same key.
150
+ 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
152
+ extractors read the separator from MSH-1, and the `ack-from-hl7` subpath still round-tripped
153
+ control IDs through `utf8`. Both of those were left pre-existing then, and are **closed by
154
+ MLLP-ACK-UTF8** (above); the scanners are now a single shared implementation.
155
+
156
+ - **A peer could crash the server with one high-bit byte, and corrupt the ACK control ID
157
+ (Phase 10).** `buildRawAck` decoded the inbound message with `ascii`, which masks the high bit
158
+ (`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:**
161
+ `0x8B → 0x0B` is a **VT** and `0x9C → 0x1C` is an **FS**, so `ascii` *synthesized framing
162
+ 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
164
+ escaped the `void`-ed `_sendCommitAck`, **crashing the whole server on peer-controlled input with
165
+ no consumer bug at all**, and suppressing the fail-safe ACK. Fixed: `buildRawAck` uses `latin1`
166
+ (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
168
+ `autoAck: fn`) surfaces as a connection `'error'` and the message goes un-ACKed (fail-safe: the
169
+ 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
171
+ (Phase 10).** `FrameReader.push()` runs synchronously inside the transport's data callback, which
172
+ 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
174
+ 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
177
+ a **default server from a single byte**: `SERVER_DEFAULT_FRAMING` leaves `allowMissingLeadingVt`
178
+ off, so any non-whitespace byte where a `VT` was expected threw `MLLP_MISSING_LEADING_VT`
179
+ (`MLLP_FRAME_TOO_LARGE` reached the same path). One stray keepalive character from a real
180
+ interface engine was enough.
181
+ 2. **`emit('error')` with no listener** — Node raises `ERR_UNHANDLED_ERROR`, and that throw happened
182
+ *inside the catch block added for (1)*, escaping by the identical route. `MllpServer`/
183
+ `MllpClient` each attach an `'error'` listener, which masked it; `Connection` is a public export
184
+ and need not.
185
+ 3. **A throwing `'message'`/`'warning'` subscriber** — `onFrame` dispatches synchronously inside
186
+ `push()`, so an ordinary consumer bug (a metrics tap, a logger) unwound through the socket
187
+ handler too.
188
+ 4. **The five lifecycle emits** — `destroy()` → `_transition()` → `emit('stateChange'|'close'|…)`
189
+ runs *inside* the catch block added for (1), and a throw raised inside a `catch` is **not**
190
+ caught by that block. A throwing `'close'` subscriber plus one stray byte still killed the
191
+ process, four frames up.
192
+
193
+ Enumerating routes one at a time is what produced a fourth, so the rule is now **structural: no
194
+ `emit()` in `Connection` may reach a transport callback.** All eight events dispatch through
195
+ containment, pinned by a test that attaches a throwing subscriber to every one of them at once.
196
+
197
+ Now: a fatal framing error surfaces as a frozen `'error'` event (`phase: 'receive'`,
198
+ `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
200
+ 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
202
+ 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
204
+ `transport.destroy(err)`, which made a real socket echo it back through `_onTransportError` and
205
+ emit a second, causeless `'error'`, double-counting on an alerting dashboard. The
206
+ connection is destroyed rather than resynchronized deliberately: after a throw the reader's position
207
+ in the byte stream is untrustworthy, and guessing where the next frame begins is how a clinical
208
+ message gets silently mis-split. The existing suites missed all of this because the in-memory
209
+ transport wraps delivery in `try`/`finally`, re-routing the throw to the *writer*; only a real
210
+ 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) —
212
+ both verified to fail without the fixes.
213
+ - **A fatal framing error triggered an unbounded reconnect storm (Phase 10).**
214
+ `isTransientConnectionError` switches on `err.code` and fell through to `default: return true`, so a
215
+ `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
218
+ was already misconfigured. `MLLP_*` codes are now **permanent**, alongside the TLS classes and for
219
+ the same reason: every reconnect meets the same bytes.
220
+ - **A throwing `'message'` observer suppressed the ACK (Phase 10).** `MllpServer` emits `'message'` to
221
+ 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
223
+ forever with nothing to diagnose it by. The emit is now contained: the throw surfaces on `'error'`
224
+ and the commit contract proceeds untouched. The ACK decision belongs to `ServerOptions.onMessage`
225
+ (the durable-commit step), not to a metrics tap.
226
+ - **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
229
+ `version` (`changeset version` → `scripts/sync-version.mjs` → `prettier --write`).
230
+ - **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
232
+ `0.0.1` would have exported `"0.0.0"`. `scripts/sync-version.mjs` now rewrites the constant from
233
+ `package.json` inside the `version` script, and `test/sanity.test.ts` compares the export against
234
+ `package.json` rather than asserting a hardcoded literal against a hardcoded literal (the old
235
+ test would have stayed green through precisely this drift).
236
+ - **`VERSION` had a literal type in the published `.d.ts` (Phase 10).** It declared
237
+ `const VERSION = "0.0.0"`, leaking the current release into consumers' types and turning an
238
+ equality check against any other version into a compile error. Now `VERSION: string`.
239
+ - **Docs accuracy (Phase 10).** Found by the conformance gate, which refuted the first cut of the
240
+ new guide:
241
+ - `docs-content/intro.md` described the decoder as liberal outright. It is **strict by default** —
242
+ tolerance is opt-in per flag, and it is `MllpServer` that ships tolerant defaults (`allowFsOnly`,
243
+ `allowLfAfterFs`, `allowLeadingWhitespace`; `allowMissingLeadingVt` stays off even there).
244
+ - `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
247
+ alert on rather than ignore.
248
+ - **`close()` does not drain in-flight messages** — it *rejects* them with
249
+ `MllpConnectionError({ phase: 'close' })`. No drain hook is wired to the `DRAINING` state, so
250
+ `drainTimeoutMs` does not currently bound an in-flight ACK wait on the client. A message in
251
+ flight at shutdown is an **unknown**, not a failure: the receiver may have committed it. Now
252
+ stated honestly in the reliability guide and the limitations page, with the "await your sends,
253
+ then close" pattern.
254
+ - The absolute PHI claim ("never echoes message content") is now precise: diagnostics never echo a
255
+ *run* of content, but the single-byte `snippet` on `MLLP_MISSING_LEADING_VT` is by definition the
256
+ first byte of unframed content.
257
+
258
+ - **`buildRawAck` could emit an ACK whose MSH-2 collided with its MSH-1 (MLLP-ACK-UTF8).** When an
259
+ inbound declared no usable MSH-2, the builder fell back to the HL7 default encoding characters
260
+ `^~\&` **without checking them against MSH-1**. For an inbound declaring MSH-1 = `^` (or `~`, `\`,
261
+ `&`), 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
263
+ distinct). Fixed: `buildRawAck` substitutes only the one colliding **encoding character** and keeps
264
+ the inbound's **field separator** unchanged.
265
+ - Keeping the field separator is the load-bearing part. The field separator is the only byte that
266
+ 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
268
+ since a `|` inside an `^`-delimited message's MSH-10 is *ordinary data* (§2.5.4), an MSH-10 of
269
+ `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
271
+ send and falsely settle it, losing one clinical message (its `send()` resolves though it was
272
+ never acknowledged) and duplicating another (the one actually acknowledged stays in flight and
273
+ resends). Substituting the encoding character avoids all of this: the ACK stays under the
274
+ inbound's own delimiters, so the echo round-trips byte-for-byte whatever the control ID contains.
275
+ - It deliberately does **not** fall through to the minimal ACK, which would drop the MSA-2 echo: an
276
+ ACK that is well-formed but uncorrelatable is worse than one that correlates with an imperfect
277
+ header. The control-ID echo is the thing being protected.
278
+
279
+ ### Added
280
+
281
+ - **`MLLP_ACK_CONTROL_ID_NOT_VERBATIM` (MLLP-ACK-UTF8).** A new stable warning code —
282
+ `ack-from-hl7`-scoped, emitted in `MllpAck.warnings`, not through the framing registry (13 codes
283
+ total now). `buildMllpAck` **verifies** every ACK it builds against the very byte-level scanners the
284
+ `@cosyte/mllp` client uses to correlate, and warns when MSA-2 is not byte-identical to the inbound
285
+ 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
289
+ non-verbatim MSA-2 *is* an ACK the sender cannot match. It fires for an `encoding` override that
290
+ 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
292
+ always emits `|^~\&`, and trims field whitespace). `buildRawAck` is parser-free and has neither
293
+ limit, so it remains the answer for a non-default-delimiter peer.
294
+
295
+ **The check is a `Buffer` guarantee, and the docs now say so.** On a `string`/`Hl7Message` inbound
296
+ the wire bytes are decoded before `buildMllpAck` ever sees them, so it re-encodes the caller's text
297
+ with the same codec it decoded it with: the codec cancels on both sides and a codec-induced
298
+ 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
300
+ is pre-existing (byte-identical on the previous release line) and is tracked separately; the guard
301
+ cannot be grown to catch it, because by then the bytes are gone. What was *new* and is now fixed is
302
+ the **claim** that it could not happen: `docs-content/acks.md`, `docs-content/limitations.md`, and
303
+ the `BuildMllpAckOptions.encoding` JSDoc each asserted the echo was "never silently wrong". They now
304
+ scope the guarantee to `Buffer` inbound and name `Buffer` as the byte-safe path, and a test pins the
305
+ limitation so the claim cannot silently re-broaden.
306
+ - **`ConnectionErrorCause` gains `'framing-fatal'` (Phase 10).** Public union. Attached to the
307
+ `'error'` event when the decoder throws; classified **permanent** by `isTransientConnectionError`,
308
+ so a client never auto-reconnects into a peer that is not speaking MLLP.
309
+ - **Release readiness for `0.0.1` (Phase 10).**
310
+ - **Publish pipeline proven without burning a version.** New `publish:dry` script
311
+ (`pnpm publish --dry-run --no-git-checks`). Verified end to end: the `prepublishOnly` chain
312
+ (clean → typecheck → lint → test → build → `attw`) is green; `changeset version` consumes the
313
+ pending changesets to exactly **`0.0.1`**; the dry-run packs 24 files (`dist/` + README +
314
+ 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
316
+ is a human gate.
317
+ - **Full `docs-content/` transport guide.** New pages: **Framing & tolerance** (wire format, the
318
+ opt-in tolerance flags with a `FrameReader`-vs-`MllpServer` default table, the stable
319
+ warning-code registry, bounded accumulators, the PHI contract on diagnostics); **ACKs & the
320
+ commit contract** (fail-safe ACK semantics, the three `autoAck` modes, the transport-accept
321
+ caveat, FIFO-vs-control-id correlation, `ack-from-hl7`); **Connection, reconnect &
322
+ backpressure** (the 6-state machine, jittered exponential backoff, the transient-vs-permanent
323
+ classifier, keepalive vs dead-peer timeout, high-water marks, graceful drain); and **Known
324
+ limitations & non-goals** (at-least-once at best, no queue/replay, no MLLP R2, no Epic/Cerner
325
+ differential verification, no PKI, not byte-transparent, pre-stable API). Sidebar updated; all
326
+ examples synthetic.
327
+ - **README** now documents the commit contract and the non-goals, not just the feature list.
328
+
329
+ - **Real-world interop, differential conformance & PHI/observability audit (Phase 9).**
330
+ - **PHI hardening (framing).** `MllpFramingError.snippet` no longer carries a run of payload content
331
+ 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
333
+ 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
335
+ just the single offending delimiter byte (a VT/FS control byte the `code` already names). Every
336
+ framing throw now carries at most the single framing-boundary byte that broke the structure, never a
337
+ 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
339
+ adapter and Mirth/NextGen Connect (both R1). Tier 1 (always on) asserts decode + `encodeFrame`
340
+ byte-parity against canonical R1 golden frames and ACK correlation; Tier 2 (`MLLP_DIFF_ADAPTER`
341
+ 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
343
+ §3 real-world deviation, asserting the exact warning code/typed error and byte-identical payload
344
+ 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
346
+ echoes payload content, including the oversized path.
347
+ - **Test-infra:** the pre-existing `test/server/*` suites now use the shared
348
+ `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
351
+ (`socket.setKeepAlive` in `src/server/server.ts`; the byte-at-a-time `randomChunks` /
352
+ `split(1)` generators in `test/property/fuzz.property.test.ts`), so Phase 9 legitimately
353
+ narrows to the PHI snippet audit + differential harness + quirk corpus.
354
+ - **TLS / MLLPS hardening (Phase 8).** `TlsTransport` (wraps `tls.TLSSocket`, maps `onConnect` to
355
+ `'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
357
+ `allowUnverified` flag, which emits a frozen `'securityWarning'` (`MLLP_TLS_VERIFY_DISABLED`) +
358
+ `process.emitWarning` on every `secureConnect` (initial connect and every reconnect). Server:
359
+ `ServerOptions.tls?: ServerTlsOptions` with `clientAuth: 'NONE' | 'WANT' | 'MUST'` (ATNA ITI-19
360
+ mutual node authentication) — `'WANT'`/`'MUST'` surface a minimal, content-free `peerCertificate`
361
+ (`{ subjectCN, issuerCN, validTo, authorized }`) on the `'connection'` event; `authorized`
362
+ 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
364
+ unauthorized/missing client certificates. Failed handshakes (incl. rejected mTLS client certs)
365
+ never crash the server: a frozen `'tlsClientError'` event (`{ remoteAddress, remotePort, message,
366
+ 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`
369
+ passes through to Node's OpenSSL defaults, which already include both ATNA-mandated ECDHE suites.
370
+ New typed failure modes on `MllpConnectionError.connectionCause`: `'tls-verify'` (certificate
371
+ 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`
373
+ 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
375
+ 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
377
+ surfaces moments later as a typed post-connect error classified permanent; ACK correlation
378
+ remains the delivery guarantee. New exported helpers `isTlsVerificationErrorCode(code)` and
379
+ `isTlsProtocolError(err)`. New stats fields: `ClientStats.tls`, `ServerStats.tls`,
380
+ `ServerStats.tlsClientErrorsTotal`. New root exports: `TlsTransport`, `TlsOptions`,
381
+ `ServerTlsOptions`, `ClientAuth`, `SecurityWarning`, `MLLP_TLS_VERIFY_DISABLED`,
382
+ `MLLP_BIND_ALL_INTERFACES`, `isTlsVerificationErrorCode`, `isTlsProtocolError`. See
383
+ `docs-content/tls.md` for the full guide (mTLS table, TLS-1.3 client-cert-rejection note,
384
+ known limitations).
385
+ - **`@cosyte/mllp/ack-from-hl7` — real helpers (Phase 7); stub removed.** A thin transport
386
+ 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,
388
+ { code, error?, encoding?, allowDelimiterBytesInPayload? })` returning a frozen `MllpAck`
389
+ (`frame` ready-to-write MLLP bytes, unframed `payload`, the built `ack` message,
390
+ `requestedCode` vs emitted `code`, verbatim `correlationId`, detected `mode`, content-free
391
+ `warnings`); the six Table-0008 conveniences `buildAckAA/AE/AR/CA/CE/CR`; `detectMode`
392
+ (original-vs-enhanced from MSH-15/16); lazy peer loading with a typed
393
+ `MllpPeerMissingError` (`MLLP_PEER_MISSING`) when `@cosyte/hl7` is absent; and the
394
+ `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
396
+ copy of the pair), MSA-2 stays empty, and the result carries the new stable warning code
397
+ **`MLLP_ACK_INBOUND_UNPARSEABLE`** (public API; 12 codes total). A parseable inbound with
398
+ 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
400
+ this package's own raw-bytes client correlator (escape-bearing ids canonicalize; see the
401
+ docs' known limitations).
402
+ - **Dev/test consumption of the unpublished `@cosyte/hl7` peer** via a vendored packed
403
+ 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
405
+ lands; the runtime peer stays optional and is never bundled (`external` in tsup).
406
+
407
+ ### Security
408
+
409
+ - **Dev-dependency advisory remediation (no runtime impact — `@cosyte/mllp`
410
+ ships zero runtime dependencies, so the published artifact is unchanged).**
411
+ Added scoped `pnpm.overrides` pinning two transitive **dev/build-time**
412
+ 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
414
+ builds via `tsup`/`vitest` and never runs `esbuild serve`) and the
415
+ `@changesets/parse` copy of `js-yaml` (`>=4.0.0 <4.2.0` → `4.2.0`;
416
+ GHSA-h67p-54hq-rp68 merge-key DoS). The `js-yaml@3.14.2` pulled by
417
+ `read-yaml-file@1.1.0` (via `@manypkg/get-packages` → `@changesets/cli`) is
418
+ **intentionally left**: it calls `yaml.safeLoad`, removed/throwing in
419
+ js-yaml 4, so it cannot be force-upgraded without breaking the release
420
+ tooling, and it only parses trusted local repo YAML at release time. This is
421
+ the shared canonical override block, enforced suite-wide by the
422
+ `@cosyte/config` drift check.
423
+
424
+ ### Added
425
+
426
+ - **MLLP client + server** — production-grade client and server with framing
427
+ (`VT + payload + FS + CR`), ACK correlation, auto-reconnect with backoff, and backpressure.
428
+ Buffer-first API on every public surface.
429
+ - **Explicit 6-state connection machine** — `CONNECTING | CONNECTED | DRAINING | RECONNECTING |
430
+ DISCONNECTED | CLOSED`, with `stateChange` events carrying `{ from, to, reason }`.
431
+ - **Framing** — `FrameReader` with a bounded 16 MB default accumulator (`MLLP_FRAME_TOO_LARGE` on
432
+ overflow); strict encoder, lenient decoder (Postel's Law).
433
+ - **11 stable warning codes** with byte-offset context (`MLLP_MISSING_LEADING_VT`,
434
+ `MLLP_FS_WITHOUT_CR`, `MLLP_FRAME_TOO_LARGE`, `MLLP_ACK_UNMATCHED_CONTROL_ID`, …).
435
+ - **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
438
+ `@cosyte/hl7` peer (helpers not yet implemented; Phase 6).
439
+ - **Property + fuzz test layer** for the framing transport, built on the shared
440
+ `@cosyte/test-utils` conformance kit and `fast-check` (both dev-only). Covers: codec round-trip
441
+ byte fidelity (`encode → decode`) via `roundTripProperty`; lenient-decoder robustness
442
+ (malformed-but-recoverable frames recover into warnings, only `MLLP_FRAME_TOO_LARGE` throws) via
443
+ `lenientNeverThrowsProperty`; frozen-event-payload immutability via `immutabilityProperty`; a
444
+ warning-code surface snapshot tripwire via `sortedCodeSet`; and a transport-robustness **fuzz**
445
+ property feeding arbitrary random byte buffers and chunk-splits through `FrameReader` over the
446
+ in-memory transport. Test-only — no public-surface change.
447
+
448
+ - **Fail-safe ACK semantics & the commit contract (Phase 6, HL7 v2.5.1 §2.9.2).** A positive
449
+ 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,
451
+ a **negative** code on throw/reject (`AE` by default; `AR` via `MllpAckError`), never `AA` before
452
+ commit. `autoAck: 'AA'` without a handler is a documented **transport-accept** (received+framed, not
453
+ application-processed). New public surface: `buildRawAck` (parser-free byte-level ACK builder echoing
454
+ inbound `MSH-10` into `MSA-2`, never throwing on malformed input), the HL7 Table 0008 `AckCode` /
455
+ `NegativeAckCode` unions, `MllpAckError`, `resolveNackCode`, and a PHI-safe `'nack'` event
456
+ (`{ 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.
459
+
460
+ ### Changed
461
+
462
+ - **Server bind-safety hardening (Phase 8; BREAKING pre-publish — free before first release).**
463
+ `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` —
465
+ **enforced against the OS-normalized bound address**, not the requested spelling. Literal
466
+ wildcard spellings (`'0.0.0.0'`, `'::'`, `''`, `'::0'`, `'0:0:0:0:0:0:0:0'`,
467
+ `'::ffff:0.0.0.0'`) reject with a typed `MllpConnectionError` **before** binding;
468
+ 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,
470
+ leaving no listening state and emitting no `'listening'` event. `listen()` is **single-flight**:
471
+ a call while the server is already listening, or while another `listen()` is in flight, rejects
472
+ with a typed `MllpConnectionError` instead of racing the first call's post-bind checks (a lost
473
+ race could otherwise record listening state for a bind that no longer exists); `close()` before
474
+ re-listening. When a wildcard host IS bound with the flag set, the server emits a one-time
475
+ frozen `'securityWarning'` (`MLLP_BIND_ALL_INTERFACES`) + `process.emitWarning`, keyed off the
476
+ bound address.
477
+ - **Renamed the package `@cosyte/hl7-mllp` → `@cosyte/mllp`.** Not yet published, so no deprecation
478
+ path is needed; all imports, the `/testing` and `/ack-from-hl7` subpaths, and the optional
479
+ `@cosyte/hl7` peer dependency are unchanged.
480
+ - **Adopted the shared `@cosyte/*` engineering standard (Phase E).** Build via `@cosyte/tsup-config`
481
+ (`cosyteTsup`), tests via `@cosyte/vitest-config` (`cosyteVitest`), lint via `@cosyte/eslint-config`
482
+ (ESLint 10 + `typescript-eslint`) at `--max-warnings=0`, Prettier 3.8. Exact-pinned dev tooling,
483
+ canonical scripts (incl. `clean`, `attw --pack .`), per-condition `.d.cts` types on every `exports`
484
+ subpath, and thin-caller `ci.yml` / `release.yml` over `cosyte/.github`. Target bumped ES2022 →
485
+ ES2023.
486
+ - **Re-enabled the coverage gate** at per-directory >= 90 on
487
+ `framing|client|connection|server|transport` (was disabled).
488
+ - **Restored the JSDoc `error` gate** (the local `warn` downgrade was removed) and reformatted the
489
+ source to the shared Prettier config (double quotes); no behavior change.
490
+
491
+ ### Removed
492
+
493
+ - **`mitata` benchmark dependency and the `bench` script** — the script had no benchmark files.
494
+
495
+ ### Deprecated
496
+
497
+ ### Fixed
498
+
499
+ - **Bind errors no longer crash a server with no `'error'` listener (Phase 8 residuals, MLLP-8.1).**
500
+ 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
502
+ bind error (`EADDRINUSE`, `EACCES`, …) crashed the process (unlistened `EventEmitter` `'error'`
503
+ emissions throw) instead of rejecting the `listen()` promise. The forwarder is now guarded by
504
+ **server state**, and the error contract is documented: with a listener attached, always
505
+ 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
507
+ changes the rejection to the typed close-during-listen error), a stale error after `close()` is
508
+ 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.
510
+ A throwing `'listening'`/`'securityWarning'` subscriber can no longer strand the `listen()`
511
+ 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`
513
+ security warning always survives, with the operator-channel `process.emitWarning` fired first so
514
+ no event listener can ever suppress it), the throw is surfaced via the guarded `'error'` tap
515
+ (itself contained against a double throw), and `listen()` still resolves. Also consolidates `listen()`'s five hand-woven settle paths (abort,
516
+ 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
519
+ is accept-safe (the check runs synchronously on `'listening'`, before any connection can be
520
+ delivered); `close({ signal })` with an **already-aborted** signal is a no-op `AbortError`
521
+ rejection that does **not** settle an in-flight `listen()` (which continues and settles on its own
522
+ 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
524
+ `listen()` resolves (use `close()` to shut down), so aborted-mid-emit can never strand
525
+ `listening: true` on a closed socket.
526
+
527
+ ### Security
528
+
529
+ [Unreleased]: https://github.com/cosyte/mllp/commits/main
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cosyte
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.