@cosyte/mllp 0.0.1 → 0.0.3

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