@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 +329 -108
- package/README.md +23 -18
- package/TRADEMARKS.md +30 -0
- package/dist/ack-from-hl7/index.cjs +55 -15
- package/dist/ack-from-hl7/index.cjs.map +1 -1
- package/dist/ack-from-hl7/index.d.cts +112 -38
- package/dist/ack-from-hl7/index.d.ts +112 -38
- package/dist/ack-from-hl7/index.mjs +54 -15
- package/dist/ack-from-hl7/index.mjs.map +1 -1
- package/dist/{index-DjGQJtYE.d.cts → index-CD8V2Mtk.d.cts} +5 -5
- package/dist/{index-DjGQJtYE.d.ts → index-CD8V2Mtk.d.ts} +5 -5
- package/dist/index.cjs +157 -168
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +398 -420
- package/dist/index.d.ts +398 -420
- package/dist/index.mjs +157 -168
- package/dist/index.mjs.map +1 -1
- package/dist/testing/index.cjs +3 -3
- package/dist/testing/index.cjs.map +1 -1
- package/dist/testing/index.d.cts +8 -8
- package/dist/testing/index.d.ts +8 -8
- package/dist/testing/index.mjs +3 -3
- package/dist/testing/index.mjs.map +1 -1
- package/package.json +16 -7
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
34
|
-
it never read
|
|
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
|
|
37
|
-
pre-existing on `main`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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**
|
|
62
|
-
10 fields, which is malformed, but is precisely what a broken peer sends
|
|
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
|
|
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`
|
|
78
|
-
`MLLP-CORRELATOR-ASCII` had just fixed on the client
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
124
|
-
offset 3 of the MSH segment defines 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`)
|
|
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
|
|
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
|
|
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`
|
|
160
|
-
*different* id
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
176
|
-
1. **The decoder's own throw
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
217
|
-
a health check, a wrong-port misconfiguration
|
|
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
|
|
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
|
-
|
|
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
|
|
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*
|
|
246
|
-
message
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
287
|
-
emitted
|
|
288
|
-
mismatched ACK beats silence
|
|
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
|
|
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`
|
|
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
|
|
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)
|
|
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
|
|
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/`)
|
|
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/`)
|
|
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
|
|
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
|
|
350
|
-
detection and (d) fuzz chunk-boundary adversaries
|
|
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
|
|
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)
|
|
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
|
|
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'
|
|
368
|
-
`'TLSv1.0'/'TLSv1.1'` are not expressible through this API. No bundled cipher list
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
650
|
+
- **Explicit 6-state connection machine**: `CONNECTING | CONNECTED | DRAINING | RECONNECTING |
|
|
430
651
|
DISCONNECTED | CLOSED`, with `stateChange` events carrying `{ from, to, reason }`.
|
|
431
|
-
- **Framing
|
|
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`)
|
|
437
|
-
- **`ack-from-hl7` subpath
|
|
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
|
|
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
|
|
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
|
|
458
|
-
- **Package metadata
|
|
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
|
|
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()
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
518
|
-
re-settle a settled promise
|
|
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
|
|
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
|
|