From efdf91ee6f267124b21661c537794b6125f3eb81 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:46:01 +0000 Subject: [PATCH] Version Packages --- .changeset/brave-moons-withdraw.md | 13 -- .changeset/chilled-donuts-repeat.md | 19 -- .changeset/first-use-examples-executed.md | 9 - .changeset/fresh-pugs-invite.md | 17 -- .changeset/lucky-otters-describe.md | 19 -- .changeset/olive-hills-declare.md | 26 --- .changeset/olive-hounds-reconcile.md | 21 -- .changeset/olive-mountains-install.md | 22 -- .changeset/plain-owls-supply.md | 15 -- .changeset/proud-moons-refuse.md | 21 -- .changeset/quickstart-page-matches-readme.md | 7 - .changeset/quiet-moons-observe.md | 25 --- .changeset/sweet-pandas-classify.md | 12 -- .changeset/tidy-hounds-explain.md | 5 - .changeset/what-0-1-means.md | 13 -- .changeset/wild-pears-repeat.md | 23 -- CHANGELOG.md | 210 +++++++++++++++++++ docs-content/conformance.md | 2 +- package.json | 2 +- src/index.ts | 2 +- 20 files changed, 213 insertions(+), 270 deletions(-) delete mode 100644 .changeset/brave-moons-withdraw.md delete mode 100644 .changeset/chilled-donuts-repeat.md delete mode 100644 .changeset/first-use-examples-executed.md delete mode 100644 .changeset/fresh-pugs-invite.md delete mode 100644 .changeset/lucky-otters-describe.md delete mode 100644 .changeset/olive-hills-declare.md delete mode 100644 .changeset/olive-hounds-reconcile.md delete mode 100644 .changeset/olive-mountains-install.md delete mode 100644 .changeset/plain-owls-supply.md delete mode 100644 .changeset/proud-moons-refuse.md delete mode 100644 .changeset/quickstart-page-matches-readme.md delete mode 100644 .changeset/quiet-moons-observe.md delete mode 100644 .changeset/sweet-pandas-classify.md delete mode 100644 .changeset/tidy-hounds-explain.md delete mode 100644 .changeset/what-0-1-means.md delete mode 100644 .changeset/wild-pears-repeat.md diff --git a/.changeset/brave-moons-withdraw.md b/.changeset/brave-moons-withdraw.md deleted file mode 100644 index 9bfef57..0000000 --- a/.changeset/brave-moons-withdraw.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@cosyte/mllp": patch ---- - -`pnpm phi-scan` now refuses a run (exit 2) that withdrew a target it had enumerated, instead of reporting on the targets that were left. - -`--allow-fixture ` used to subtract a path from the read set and let the scan carry on, so the same invocation over a corpus whose only violator had been withdrawn printed `OK, no hits` and exited 0. A scan that did not open a file has no clean verdict about it, which is the rule every other incompleteness in this scanner already follows: an unreadable tracked file, a non-regular entry, an unmerged path, an emptied walk root and an empty index all refuse. The bypass was the one route to a clean report that worked by not reading something, and it was the one a caller could take deliberately. - -The refusal is printed after any hits found in the targets the run did read, so a real finding is never swallowed by it, and it exits 2 rather than 1, because 1 means "hits found" and an incomplete sweep makes no such claim. A run that passes no `--allow-fixture`, or one whose `--allow-fixture` matched no enumerated target, is unchanged: the tolerated mid-sweep vanish, the per-walk-root observed-nothing refusal, `--staged` and the index corpus all behave exactly as before. To keep a synthetic fixture passing, declare its identifiers in `scripts/phi-allow-list.txt`, which leaves the file scanned. - -Also in this release: the advisory override for `js-yaml` widens to `>=4.0.0 <4.3.0` and resolves at `4.3.0`, matching the extended advisory range, and a `pnpm-workspace.yaml` declares a 24 hour publication cooldown (`minimumReleaseAge: 1440`) and a `no-downgrade` trust policy. Both settings postdate the pnpm this package pins, so they are declared now and take force when that pin is raised. - -No runtime behaviour of this package changes. The framing, ACK, warning-code and TLS surfaces are untouched, `js-yaml` is reached only through development tooling, and nothing here alters what is published in `dist`. diff --git a/.changeset/chilled-donuts-repeat.md b/.changeset/chilled-donuts-repeat.md deleted file mode 100644 index 36fafc0..0000000 --- a/.changeset/chilled-donuts-repeat.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@cosyte/mllp": minor ---- - -`close()` now drains: it awaits the acknowledgements of sends already written to the transport, bounded by `drainTimeoutMs`, and reports whatever is still unresolved by population, so a consumer's replay logic can tell a message that definitely never reached the wire from one whose fate is unknown. - -Before this, `close()` rejected every pending send with one `MllpConnectionError({ phase: 'close' })` before it delegated to the connection's close, and `drainTimeoutMs` bounded nothing on the client: the `DRAINING` state and the `beforeClose` seam both existed, and no client path used either. A message still sitting in the client and a message the peer may already have committed came back to the caller as the same error. A consumer that replayed on that signal duplicated clinical messages; one that did not, dropped them. - -**The wait.** `close()` installs the drain hook the connection already had a seam for, so `drainTimeoutMs` (default 30 s) is now the bound on a real acknowledgement wait. It ends the moment the last outstanding acknowledgement lands, so a peer that answers promptly costs milliseconds rather than the timeout, and it ends if the link fails mid-drain rather than sitting out a bound waiting for an answer that cannot arrive. A close with nothing pending, a close on a client with no connection, and a close on one that has already closed all return without touching the timeout. Whatever hook was already assigned to `beforeClose` is composed with rather than replaced. - -**The report, split in three.** `MllpNeverDeliveredError` is new and is the safe half: the message was still held inside the client, no bytes were written for it, and resending it cannot duplicate anything. `MllpUnknownFateError` is new and is the ambiguous half, reported as ambiguous: the bytes went out and no acknowledgement came back, so the peer may hold the message. It carries `flushedAt`, the moment those bytes were written, which is what a replay decision reasons about, plus `elapsedMs` and `byteCount`. `MllpApplicationAckError({ reason: 'connection-lost' })` is unchanged and keeps its precedence: a send whose commit disposition the peer already reported is never downgraded to an unknown fate, because the peer's custody of those bytes is a known fact. All three are told apart with `instanceof`; no caller needs to read an error message. - -**The unknown-fate report carries no payload content at all.** Every field on it is a byte count or a timestamp, the control ID appears only as a byte length, and its message is a constant that no builder takes a value for. An error is a diagnostic surface: it is logged, and its `stack` plus its own properties are what an error reporter ships off the box. - -**A send that was parked inside the client is now settled rather than abandoned.** One waiting for queue room under `onBackpressure: 'wait'`, or for the single in-flight slot under `pipeline: false`, had no representation on the shutdown path at all and its promise was simply left pending forever. Both now fail with the never-delivered report, on `close()` and on `destroy()` alike, and an aborted `close()` settles every still-pending send under the same rule rather than leaving any of them hanging. - -**A drain cannot make delivery certain, and this one does not pretend to.** An acknowledgement lost in flight stays indistinguishable from a message never received, which is exactly what the unknown-fate report says. Nothing is retried automatically: the accept acknowledgement is what releases a sender from resending, and the retransmission decision stays with the application and its peer's duplicate detection on `MSH-10` plus `MSH-7`. No write-ahead log, queue or replay is added, and nothing is stored. - -Deliberately unchanged: `destroy()` still settles everything at once, awaiting no acknowledgement and honouring no drain timeout; `ackTimeoutMs` keeps its meaning and its clock; the server's own drain is untouched; and correlation, framing and enhanced-mode acknowledgement behaviour are guarded here, not extended. diff --git a/.changeset/first-use-examples-executed.md b/.changeset/first-use-examples-executed.md deleted file mode 100644 index a7325d0..0000000 --- a/.changeset/first-use-examples-executed.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@cosyte/mllp": patch ---- - -Docs: the README's usage example is now one complete program the test suite runs, and the quickstart's first example is held to a committed synthetic fixture. - -The usage example used to be two fragments: a server on a fixed port whose handler called an undeclared `db`, and a client whose printed acknowledgement carried a timestamp and a control id that change on every run. It now starts the server on a port the operating system picks on loopback, sends one message, logs only the shape of the acknowledgement (its code, and whether MSA-2 echoed the control id sent), and closes both ends. The test suite reads it out of the README, runs it twice against the package, and compares its output with the block printed beside it. The message each first example puts on the wire is a byte-for-byte copy of a committed synthetic fixture, a changed value in either example fails the suite, and every install command the README and the installation page print is checked against the package's own name or a dependency it declares. - -The quickstart's first example did not compile in a strict TypeScript project: it assigned the de-framed payload to an untyped `let` inside the `onFrame` callback and then called `.equals` on it. It now collects payloads into a `Buffer[]` and claims that exactly one came out. The suite compiles both first examples with the settings `tsc --init` writes for a new project, so an example that does not compile fails it. diff --git a/.changeset/fresh-pugs-invite.md b/.changeset/fresh-pugs-invite.md deleted file mode 100644 index e85c3ef..0000000 --- a/.changeset/fresh-pugs-invite.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@cosyte/mllp": patch ---- - -The README is rewritten to the house package front-page standard, so the text npm freezes at publish is complete and correct rather than half-written. - -**`Status` now describes this release.** It names the version the page documents, says in plain terms that the public API is settled and safe to depend on, and names what is still moving rather than implying nothing is: batch acknowledgement is not built, verified engine coverage is a growing list rather than a complete one, and MLLP Release 2 is not spoken. The old line describing the package as unstable is gone. - -**Six sections a reader needs are now written:** `Why this exists`, `Status`, `Install`, `Usage`, `PHI and safety` and `Contributing`. `Why this exists` states the problem and the nearest alternative rather than restating the feature list. `Install` gives the Node floor, the package manager and the dual ESM plus CJS format in one copy-pasteable block. `Contributing` says where to ask, that pull requests are welcome, that no contributor guide exists yet, and exactly which checks a change has to clear. - -**`PHI and safety` is new, and it states bounds rather than absolutes.** What the transport does with a payload, which is hold it in memory only while it is in flight, and reuse its receive buffer for the next frame rather than scrub it. The reads it performs: the `MSH` header an acknowledgement is built from, and the `MSA` of an inbound acknowledgement, each bounded at that segment's own terminator, so no segment carrying clinical content is reached. The exception, named rather than glossed and opt-in either way: the `ack-from-hl7` subpath decodes a whole message and hands it to the optional parser peer, which resolves every field of every segment. What it does not do: no disk write, no queue, no replay store, no logger call. Exactly what a diagnostic can carry, including the two framing warning codes that render the hex of the single byte found where a framing byte was expected, and what to log instead if that is more than your threat model allows. And what you still own: encryption in transit, your own logging and retention, idempotency, and any decision to widen the loopback bind default. - -**Every example is verifiable.** The client example shows the acknowledgement it actually receives, with `MSA-2` echoing the control id that was sent, and a new framing example shows the bytes `encodeFrame` emits and a chunked `FrameReader` delivering one frame from two partial pushes, each with its real output printed beneath it. - -**Banner and head.** The banner alt text now matches the shared brand declaration for the tile it shows, character for character, instead of a hand-written description that had drifted from it. The one-line description under the badges is now the same string the package publishes, so the page and the registry no longer say two different things. The tagline is shortened to one readable line, and a table of contents was added. - -No runtime code, framing, acknowledgement, transport or TLS behaviour changes here. Documentation only. diff --git a/.changeset/lucky-otters-describe.md b/.changeset/lucky-otters-describe.md deleted file mode 100644 index 6504ab3..0000000 --- a/.changeset/lucky-otters-describe.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@cosyte/mllp": patch ---- - -The published documentation set gains a page contract, a page for the testing surface, and a test that validates the set as a set. - -**A new page: Testing & verification.** `@cosyte/mllp/testing` is one of three published subpaths and `runDifferential` is a root export, and neither had a page. The in-memory transport appeared as a seven-line aside on the entry page and one paragraph in the quickstart, with no sidebar entry of its own; the differential harness was documented only inside the troubleshooting page, so a shipped capability was reachable only from the page about what the package cannot do. Both now have one page covering them together, in the sidebar under Guides and linked from the entry page and the quickstart. It covers `InMemoryTransport.pair()`, the four conditions a pair can simulate (chunked reads through `split(n)`, backpressure through `pause()`/`resume()`, a one-sided reset through `destroy(reason)`, a graceful close through `close()`), driving a whole `Connection` over a pair with no network underneath, and the differential harness end to end: what a run sends, what the report carries, what it deliberately does not carry, and how to aim it safely. Two of its examples are executed against the built package on every test run. - -**Every page now declares a description.** None of them did, so every page on the docs site rendered with no meta description. Each one now carries a description written for a reader deciding whether the page answers their question. - -**The navigation order is fixed.** Three pages all declared position 1 and no page declared the last two, which left the rendered order of the entry page, the installation page and the quickstart to the theme's tie-break. Positions now ascend across the whole set in the order the sidebar lists it. - -**Two broken cross-references are repaired.** A link on the acknowledgements page pointed at a same-page fragment whose heading lives on the limitations page, so it went nowhere; it now points at the page that defines it. The installation page's link to the quickstart was the one sibling link in the set written without its file extension; it now matches the rest. - -**The release-status claim is stated once.** Where this package sits on its release ladder, and what that means for the stability of its public surface, was restated on two pages that could drift apart. It now lives on the limitations page alone, and the installation page links to it. No page other than the conformance statement carries a literal version, so a release has one line to move and no forgotten copy. - -**The set is formatted, and validated.** `docs-content/` is now covered by this package's `format` and `format:check` scripts like every other prose surface here, so tables and emphasis are consistent across the set. A new test validates the set against a stated contract: every page carries the four required frontmatter fields and an id matching its filename, the sidebar and the files are a bijection with positions ascending from 1, every relative link resolves and every fragment names a heading that exists on the page it targets, in-set links are written `./.md`, and no file carries an en dash or an em dash. Each check names the offending file or id when it fails. - -No source changed. No public surface, warning code, option name, per-role default or conformance verdict moved, and the conformance statement's verdicts, tables and declared version are untouched. diff --git a/.changeset/olive-hills-declare.md b/.changeset/olive-hills-declare.md deleted file mode 100644 index c0ab472..0000000 --- a/.changeset/olive-hills-declare.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@cosyte/mllp": minor ---- - -A conformance statement an integrator can hand to a reviewer, and a gate that keeps it true. Documentation and test-suite change only: no framing, acknowledgement, TLS or transport behaviour moves. - -The published documentation set described this package's behaviour across eight task-shaped guides: what to set, what throws, what a warning means. None of them was a declaration. An integrator preparing an IHE Integration Statement for a Product Registry entry, or preparing for a Connectathon, had to reconstruct the conformance surface by reading all eight and inferring which clauses were implemented. - -`docs-content/conformance.md` is that missing artifact, published as a navigable page. It declares, in the terms a review is recorded in: - -- **Every framing tolerance opt-in as a declared deviation from the strict block**, with the strict-block behaviour it deviates from, its stable warning code, and its default for a standalone `FrameReader` and for `MllpServer` separately. That last pair is what lets a reviewer tell a deviation this package ships on from one a deployment opted into. The encoder half is declared too, and writing it down corrected a claim the guides had been making: the encoder is strict **by default**, not structurally. `encodeFrame`'s `allowDelimiterBytesInPayload` passes a payload's raw `0x0B`/`0x1C` through with a warning each, producing a block a conformant peer will mis-split, and it appeared nowhere in the published documentation set while `docs-content/framing.md` read "There is no option to emit a malformed frame". It is off on every path this package takes by itself, including the `ack-from-hl7` builder that exposes it as a passthrough. No behaviour changed; the statement of it did. -- **Every acknowledgement mode with a separate client verdict and server verdict**: original mode, the enhanced-mode accept acknowledgement, the enhanced-mode application acknowledgement, the MLLP Release 2 commit acknowledgement and the batch acknowledgement. The application acknowledgement is recorded supported on the client and **not** supported on the server, because this package's server emits exactly one acknowledgement per inbound message and the second exchange is the consumer's to orchestrate. Release 2 and batch are recorded absent on both roles. The automatic server acknowledgement is recorded for what it is, an opt-in: with `autoAck` unset the server sends nothing of its own and the message handler owns the response, and only `createStarterServer` supplies a default. -- **What each acknowledgement-building route does with a multi-message frame, route by route.** The two routes do not agree and the page says so rather than averaging them. `buildRawAck`, behind `autoAck`, downgrades a requested positive `AA`/`CA` to a non-positive `AE`/`CE` for an `FHS`/`BHS` batch envelope **and** for two messages concatenated into one frame. `buildMllpAck`, on the `@cosyte/mllp/ack-from-hl7` subpath, refuses the batch envelope the same way but does **not** detect the concatenated frame: it answers a positive `AA` correlated to the first message, with no warning. That is recorded as a named limitation of this release with the remedy beside it, the exported `rawAckUncorrelatable`, which returns `true` for both shapes and is what the server's own route applies. No behaviour changed here either; both routes do exactly what they did before. -- **The IHE options in actor-and-option terms**: the actor, the transaction, and the option spelled as ITI TF-2 spells it, each split into what this package supplies and what remains the deploying actor's. Nothing is recorded as claimed. An option is claimed by an actor about itself, and a library can supply a mechanism and produce evidence, which is exactly as far as the page goes. -- **A closed three-word verdict vocabulary**, in which `Unverified` is distinct from both `Supported` and `Not supported`, so an inference from a specification is never handed to a reviewer as an observation. Two behaviours are recorded that way, each with its reason. -- **The release whose behaviour it declares**, so a reviewer holding the document knows which one it describes. - -**The declaration is checked against the code on every test run**, which is the half that keeps a claim someone else relies on from going quietly stale. `test/conformance/statement.test.ts` reads the page's tables and compares them with the shipped tree: the tolerance set against `FrameReaderOptions` and `EncoderOptions`, every named warning code against the `WarningCode` union, each tolerance-to-code pairing against the real codec driven over a synthetic byte stream in both directions, the per-role defaults against `SERVER_DEFAULT_FRAMING`, every named package option against the properties declared under `src/`, and the declared release against `package.json`. A mismatch fails and names what drifted. - -Both acknowledgement-building routes are also **run** against a batch envelope and against two concatenated messages, and the disposition each one actually returns must be the disposition the page declares for that cell, so a refusal claimed for a route that does not perform one cannot ship: a reviewer who reads "refused" does not add a guard above the library. The tolerance set is derived by subtracting a pinned list of non-tolerance members from the options interface rather than by matching an `allow*` prefix, so a future framing option not spelled that way cannot move what the decoder tolerates without reddening the gate, and the page's claim that `strict: true` overrides every opt-in is now exercised for each declared tolerance instead of asserted in prose. Thirty seeded drifts were each watched to fail before the gate was believed. - -`scripts/sync-version.mjs` now rewrites the declared release alongside the `VERSION` export, so a release bump does not red the gate while a hand-edit still does. It refuses, rather than silently no-opping, if either line is renamed or duplicated. - -The IHE ATNA transport-security option is now spelled as the published text spells it, `STX: TLS 1.2 floor using BCP195 Option`, on every page that names it, and the spelling is asserted case-exact across the whole documentation set: a Product Registry entry is recorded against the published wording, and one documentation set must not carry two spellings of one option name. - -The four existing guides that cover this ground gained a pointer to the statement rather than a copy of it. Both runnable examples on the new page carry synthetic bytes only, single ASCII letters chosen because they mean nothing, and both are executed against the built package by the existing doc-and-code agreement gate. diff --git a/.changeset/olive-hounds-reconcile.md b/.changeset/olive-hounds-reconcile.md deleted file mode 100644 index e1f0337..0000000 --- a/.changeset/olive-hounds-reconcile.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@cosyte/mllp": patch ---- - -Internal PHI-scan tooling: the all-mode sweep now reads the bytes git carries, not only the working tree. No runtime, API or published behaviour change. - -`all` mode walked `test/` and `src/` on disk and reported what it found there, and nothing reconciled that against what git actually carries. A directory of decoys at the tracked names satisfied it completely: every root still yielded, every path still matched, and the committed corpus went unopened while the gate printed `[phi-scan] OK, no hits` and exited 0. - -The sweep now also reads the bytes git carries at every path in the index, as a union with the walk. No walk root was narrowed and no clause was dropped: a file the walk reads is still read off disk with exactly the tiers it had. An index blob whose bytes the walk already read is skipped by byte comparison, never by stat, mtime or hash, which is what a decoy defeats. - -Eight states that printed a clean result at exit 0 are closed, each measured against the base commit `6eb1615` and each pinned by a case: a walk root swapped for a directory mirroring the tracked names; one fixture replaced by a clean decoy; a message under an undeclared top-level directory; a tracked file absent from the working tree; PHI behind a walk root that is itself a symlink; a tracked symbolic link outside every walk root; an unmerged index entry outside every walk root; and an empty index, whose every check passed vacuously. - -Five controls, each proved able to fail by mutating the route: a clean repo exits 0; a clean working-tree divergence exits 0; a violator in both the tree and the index is reported once; an emptied walk root still refuses though the index holds its files; and an absent walk root stays legitimate. The first is killed only by a composition of two mutations, because in a healthy repo the byte comparison leaves no index target for a single mutation to corrupt. - -It found real unscanned corpus. Thirty-two tracked non-markdown files sat outside both walk roots and had been read by neither route. Two carried a reported token, both already committed and neither PHI: the published author address in `package.json`, and the metavariable placeholders in this scanner's own docblock. Both are declared in the allow-list with their cost written beside them, including the fact that an email-domain entry is global and route-blind. - -Deliberately unchanged: the pre-commit route keeps its own scope, because what it enumerates decides what a commit is blocked on, which makes widening it a hook decision with its own argument; and the per-root observation rule stays a statement about the walk, so a root emptied on disk still refuses. Detective, not preventive, on every route. - -One behaviour change worth naming rather than burying: because the refusals deliberately run before the name filter, an unmerged `*.md` now refuses the all-mode sweep at exit 2 where it was previously skipped. It is fail-safe and consistent, but it has a local cost, since a conflict on `CHANGELOG.md` makes `pnpm phi-scan` exit 2 until it is resolved. The pre-commit route is unaffected and CI never has an unmerged index. - -Residual, measured. Reading an index entry is not the same act as tiering it: outside `test/` the detector gate still wants a `.hl7` or `.bin` name, so the same bytes exit 1 at `examples/data/capture.hl7` and 0 at `examples/data/capture.txt`. That is disclosed and pinned as a characterization case rather than closed, because handing a manifest or a lockfile to the segment parser would report identifiers and prose as person names. Working-tree bytes at a path outside every walk root are read by neither route, tracked or not, so a tracked file out there with unstaged edits is judged on its staged bytes. The tolerated-vanish window is one phase wider than at base, because the index is read before the walk; it is never silent, and the vanish re-check window is unchanged. Two conditions that do not fire here and will fire in a sibling are recorded in the scanner: end-of-line normalization makes every blob diverge and must not be answered by normalizing before comparing, and a gitlink refuses the sweep permanently. Exit codes are this repo's own and were derived rather than ported. diff --git a/.changeset/olive-mountains-install.md b/.changeset/olive-mountains-install.md deleted file mode 100644 index e83c9c6..0000000 --- a/.changeset/olive-mountains-install.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@cosyte/mllp": patch ---- - -The `@cosyte/hl7` dev/test peer now installs from the npm registry instead of a -vendored packed tarball, and `vendor/` is deleted. - -The tarball held `@cosyte/hl7` at `0.0.0` and never moved while that package -shipped ten releases. One of them made the MSA-2 control-id echo byte-verbatim -across the full escape alphabet. Because the pinned copy never picked it up, -this package's own test suite went on asserting the old behaviour as a -guarantee: an inbound `MSH-10` of `ID\X` echoed back as `ID\E\X`, which is a -different control id on the wire, so an ACK the sender cannot correlate, so a -resend, so a duplicate clinical message. That case is now pinned inverted, as a -round-trip through the parser path that must produce the same bytes as the -byte-copy path and must not warn. - -No runtime behaviour of this package changes: `@cosyte/hl7` remains an optional -peer dependency referenced only from the `@cosyte/mllp/ack-from-hl7` subpath, -and the emitted framing, ACK and warning-code surfaces are untouched. What -changes is which version of the peer the tests run against, and therefore what -they can prove. diff --git a/.changeset/plain-owls-supply.md b/.changeset/plain-owls-supply.md deleted file mode 100644 index 72865b9..0000000 --- a/.changeset/plain-owls-supply.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@cosyte/mllp": minor ---- - -A server can now supply the ephemeral Diffie-Hellman parameters its own site policy mandates, instead of taking whatever group the TLS library picks. - -**`tls.dhParameters` on `ServerTlsOptions`, PEM content, server side only.** Two of the four TLS 1.2 cipher suites the IHE ATNA ITI-19 transport-security option names are DHE, and a server with no Diffie-Hellman parameters cannot offer a DHE suite at all: it advertises the suite and then fails every handshake in it. Until now the only way to get a group was `tls.atnaTransportSecurity: true`, which picks one for you, so a server that instead restricted `tls.ciphers` to a DHE suite had no route to a working link, and a deployment whose policy names a specific group had no route to that group. - -The value is PEM content and never a filesystem path, matching every other credential on these types; this package performs no disk IO for any of them. There is deliberately no counterpart on the client type: the side that answers the key exchange supplies the parameters. - -**It takes precedence over the group `atnaTransportSecurity` selects, and setting both is not a conflict.** The two declare different things, so they compose: the option decides which suites are offered, and this decides which group answers the DHE half of them. That is unlike `atnaTransportSecurity` and `ciphers`, which both declare the offered list and therefore still refuse each other. - -**New stable code `MLLP_TLS_DH_PARAMETERS_REJECTED`**, on `MllpTlsConfigurationError` beside `MLLP_TLS_CIPHER_LIST_REJECTED` and `MLLP_TLS_CIPHER_OPTION_CONFLICT`. Parameters that are not PEM, are not a `DH PARAMETERS` block, do not carry their encapsulation boundaries the way the TLS library's line-oriented PEM reader wants them, do not decode to the parameter structure that library parses and nothing besides, or carry a group it refuses all reject `listen()` before anything is bound, and nothing falls back to a server running without them. That check is there because the TLS library's own behaviour for parameters it cannot read is to discard them in silence, leaving a listener that answers no DHE handshake and says nothing about why. The boundary clause is the one an operator meets by accident: a value that crosses an environment variable or a single-line JSON field loses its line breaks and stops being a parameter block, and it is now refused rather than accepted and thrown away. The structure clause is the one that arrives as the wrong file under the right label: a prime and a generator with any further field behind them beyond the one optional private-value length, read at the 32 bits the library reads it at, is not what it will parse. What the library tolerates is tolerated, including CRLF endings, a missing final newline, text either side of the block, and a private-value length that is genuinely present. The values are held to the same bounds the library's own parameter check applies, because a group outside them is loaded and then refused when the handshake asks for a key, which from outside is the same silent listener: the prime must be odd and the generator must be at least 2 and no greater than the prime minus 2. The prime is not tested for primality, because that costs seconds on a 3072-bit group and `listen()` is not the place to spend it, so a well-formed block with a composite modulus is accepted here exactly as the library accepts it. - -Nothing else moves. The four suites, the cipher list the option offers, the TLS 1.2 floor, the `'tlsNegotiated'` event and the existing configuration-error codes are unchanged, and a server that sets neither option still imposes no cipher list and no Diffie-Hellman parameters at all. diff --git a/.changeset/proud-moons-refuse.md b/.changeset/proud-moons-refuse.md deleted file mode 100644 index 9b7e95f..0000000 --- a/.changeset/proud-moons-refuse.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@cosyte/mllp": minor ---- - -A deployment claiming the IHE ATNA ITI-19 transport-security option can now turn it on as one setting, and can prove from its own logs which protocol version and cipher suite each TLS link actually negotiated. - -Before this, `@cosyte/mllp` set no cipher list at all: `ciphers` was an OpenSSL passthrough with no default, so what a link offered was whatever the runtime happened to compile in, which a Node distribution can configure at build time and an operator can replace wholesale from outside the process with `--tls-cipher-list` or `NODE_OPTIONS`. What a link offered was a property of the deployment, not of this package. Nothing anywhere reported what was actually negotiated, and the documentation told an operator to hand-write a cipher list. - -**`tls.atnaTransportSecurity`, on the client and on the server, off by default.** Selecting it offers exactly the four TLS 1.2 cipher suites ITI TF-2 §3.19.6.2.3 names and nothing else. It only ever narrows what is offered: a peer that supports none of the four fails the handshake rather than negotiating something else, which is why it is off by default and why turning it on can stop a link that worked before. Every cipher claim here is proven by a real handshake against a peer restricted to one suite, never by comparing a configured string against a document, because the runtime's own default list ends in a class token and what it effectively contains is not settled by reading it. - -**TLS 1.3 is not switched off as a side effect.** A TLS 1.3 suite is enabled only by its full name in the cipher list, so a list holding four TLS 1.2 suites alone would silently downgrade a link that reaches TLS 1.3 today. The three TLS 1.3 suites the runtime enables by default are offered alongside the four, and two peers that reach TLS 1.3 without the option still reach it with the option on. - -**The server also provides ephemeral Diffie-Hellman parameters when the option is selected.** Two of the four suites are DHE, and a server with no DH parameters cannot offer a DHE suite at all: it would advertise the list and then fail every DHE handshake in it. Nothing is provided when the option is off, so the not-selected path offers exactly what it offered before. - -**`'tlsNegotiated'`, a frozen per-connection record on both the client and the server.** It carries the negotiated protocol version and the cipher suite in its IANA spelling, the one the standard prints, so it compares against a conformance claim directly. It fires once per completed handshake, on the initial connect and on every reconnect, so a log records every session rather than only the first, and it is never emitted for a plaintext connection. It is a log line and carries no HL7 payload content and no certificate or key material: it is built at handshake-completion time, before any HL7 byte has crossed the link, from two names out of the TLS library's own suite registry plus the same host and port a security warning already carries. - -**`MllpTlsConfigurationError`, new, with two stable codes.** A cipher-suite list the runtime rejects is `MLLP_TLS_CIPHER_LIST_REJECTED`, and there is no fallback to the runtime default list; `atnaTransportSecurity` and `ciphers` both set is `MLLP_TLS_CIPHER_OPTION_CONFLICT`, because each declares the offered list and honouring one would silently discard the other. Either rejects the originating `connect()` or `listen()` before a socket exists, so nothing is left connected and nothing is left bound, and a server constructed with a refused configuration stays refused rather than becoming listenable on a retry. Both are identified by `instanceof` plus `code`, never by matching on a message: the message is a frozen registry entry whose factory takes only a code, and the suite list is validated on its own with no certificate, key or passphrase in scope, so the error cannot carry credential material. - -Deliberately unchanged: the TLS 1.2 floor, the `minVersion`/`maxVersion` types, verification on by default, `clientAuth`, bind safety, and every framing and acknowledgement behaviour. With the option off, this package still imposes no cipher list and a handshake against a peer restricted to a non-ATNA default suite still succeeds. - -Not in scope, and stated rather than implied: no certificate issuance, rotation, reload, CRL or OCSP; no ATNA audit-record emission, which belongs to the actor rather than to the transport; and no conformance statement. Claiming an IHE option stays the deployment's declaration to make. This package can support it and evidence it, and cannot make it on anyone's behalf. diff --git a/.changeset/quickstart-page-matches-readme.md b/.changeset/quickstart-page-matches-readme.md deleted file mode 100644 index babcfa2..0000000 --- a/.changeset/quickstart-page-matches-readme.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@cosyte/mllp": patch ---- - -The Quickstart page now opens with the same program as the README: a server and a client on loopback, one message sent and its acknowledgement read. - -Before this, the page opened with framing bytes by hand while the README opened with a server and a client, so a reader arriving from npm and one arriving from the documentation site met two different first programs. The framing, tolerance and encoder examples follow it unchanged. The test suite now fails if the page's first program or the output printed beside it differs from the README's, runs it against the built package, and compares what it prints with that output. Documentation and test change only. diff --git a/.changeset/quiet-moons-observe.md b/.changeset/quiet-moons-observe.md deleted file mode 100644 index ab74f6a..0000000 --- a/.changeset/quiet-moons-observe.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@cosyte/mllp": minor ---- - -Differential verification you can point at your own interface engine: `runDifferential` now ships in the published package, sends a canonical corpus of synthetic messages at a peer you name, and returns a per-exchange frame-parity and `MSA-2` correlation report. - -The differential harness existed before this, and it was unreachable. It was a test tier inside this repository, aimed at the two freely available open-source engines, and `files` publishes `dist` and four documents, so nothing under `test/` reached an installation. A consumer standing an interface up against an engine this project cannot obtain had no way to observe how that engine actually frames and acknowledges, which is why the limitations page said their behaviour was inferred from the spec rather than observed. It still is, for those engines. What is new is that you can go and observe your own. - -**What a run does.** One connection per exchange, opened, used and closed, so a peer that refuses or drops one connection costs one exchange rather than the whole run. Each message goes out through this package's own strict encoder, so what the peer receives is the canonical Release 1 block, `VT` + payload + `FS` + `CR`. The answer comes back through this package's own decoder with every tolerance enabled, so a peer that deviates is described rather than fatal. Byte parity means the MLLP **envelope**, compared against the canonical block this package emits for the same payload. It is not equality of message content, which can never hold: an acknowledgement carries the peer's own control ID, its own timestamp and its own sending application, and it is supposed to. - -**The report is an observation, never a verdict.** `result` is one of `parity-observed`, `deviations-observed`, `no-observation` or `skipped`, and none of them says a peer is conformant. A run in which nothing was answered is `no-observation` and is never presented as a success. Per exchange you get an identifier, an outcome, a byte-parity outcome, a correlation outcome, the warning codes observed with the byte offset each was detected at, the bytes sent and read as counts, the deadline waited and the time taken. It is plain JSON throughout, no `Buffer`, no `Date`, no class instance, so a pipeline reads it without scraping text. - -**A deviation is named by a stable warning code, not by an opaque error.** The block's own deviations arrive as `MLLP_MISSING_LEADING_VT`, `MLLP_FS_WITHOUT_CR`, `MLLP_LF_AFTER_FS`, `MLLP_LEADING_WHITESPACE`, `MLLP_TRAILING_BYTES`, `MLLP_EMPTY_PAYLOAD`, `MLLP_PAYLOAD_CONTAINS_FS` or `MLLP_FRAME_TOO_LARGE`, including the oversize case, which is a thrown decoder error everywhere else and is caught and named here. An acknowledgement that does not echo the control ID of the message it answered is reported as a **correlation** failure under `MLLP_ACK_UNMATCHED_CONTROL_ID` and never as a framing-parity failure, and an acknowledgement carrying no readable control ID at all is `absent` rather than a mismatch, because claiming a peer echoed the wrong identifier when it echoed nothing readable is a different and false finding. **No warning code was renamed, removed or repurposed**; every code published before this release is still there, spelled as it was. - -**The report carries no payload content, by construction.** A code, a byte offset and structural counts. Not a run of the peer's bytes, not the acknowledged control ID, not a truncation or a hex rendering of either. This is deliberate to the point of dropping the decoder's own warning MESSAGES, two of which render the hex of the byte found where a framing byte was expected, which on a block with no leading `VT` is the first byte of the peer's unframed content. The engine you aim this at may hold real patients and a report is a file that gets mailed to a vendor, so the report is safe to attach. - -**Aim it with care.** A run sends messages INTO whatever engine it is pointed at. They are synthetic patients, and an engine that accepts them stores synthetic patients: an admit and an observation result will land in whatever that endpoint feeds. Point it at a test or staging endpoint, never at a production interface carrying live traffic. The limitations page says so where the invocation is given. - -**Absent configuration and broken configuration are different things.** With no peer address the run skips cleanly and returns `result: 'skipped'`, so it is safe to leave in a suite that also runs where no engine exists. An address that is present and is not a `host:port` throws the new `MllpDifferentialConfigurationError`, carrying the new stable code `MLLP_DIFF_PEER_UNPARSEABLE` and the offending value, because a silent skip there reads as proof the harness ran. Addresses are split on the last colon, so an IPv6 literal resolves bracketed or bare. - -**Every failure a peer can present lands on its own named outcome and the run still returns a report**: `connection-refused`, `connection-failed`, `connection-dropped`, `unanswered` (which states the deadline it waited and reports neither parity nor correlation), and `undecodable-response`. - -New exports on the root subpath, all additive: `runDifferential`, `canonicalExchanges`, `canonicalAcknowledgement`, `resolveDifferentialPeer`, `MllpDifferentialConfigurationError`, `MLLP_DIFF_PEER_UNPARSEABLE`, `differentialConfigurationMessage`, and the report and option types. `DifferentialRunOptions` takes `deadlineMs`, `maxFrameSizeBytes`, your own `exchanges`, an `AbortSignal`, and a `connect` factory for a peer that is not a plain TCP socket, an MLLPS endpoint being the obvious case. No new runtime dependency: this package still has none. - -Nothing about the transport itself changed. No decoder tolerance was widened, no encoder output moved, no acknowledgement construction or commit-contract behaviour was touched. The harness observes; it does not relax anything. diff --git a/.changeset/sweet-pandas-classify.md b/.changeset/sweet-pandas-classify.md deleted file mode 100644 index a50bb34..0000000 --- a/.changeset/sweet-pandas-classify.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@cosyte/mllp": patch ---- - -The next release is numbered `0.1.0` rather than `0.0.12`. This one adds public API and a published -conformance declaration, and a patch number would have told you nothing was added. Nothing exported -by `0.0.11` is removed or renamed in it, on any of the three published subpaths. - -Contributor guidance for picking a bump type now describes the `0.1.x` ladder instead of the retired -pre-alpha one. - -No runtime, API or published behaviour changes: this is release bookkeeping. diff --git a/.changeset/tidy-hounds-explain.md b/.changeset/tidy-hounds-explain.md deleted file mode 100644 index 5778530..0000000 --- a/.changeset/tidy-hounds-explain.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@cosyte/mllp": patch ---- - -Rewrite the npm package description so it names the standard this transport carries and the capabilities it ships: `MLLP transport for HL7 v2: client and server, framing, ACK correlation, auto-reconnect, TLS`. The previous line led with a promotional adjective and never named HL7 v2, so npm search results and the repository summary both understated what the package is for. Metadata only: no API, no behaviour and no dependency change. diff --git a/.changeset/what-0-1-means.md b/.changeset/what-0-1-means.md deleted file mode 100644 index e5c0ae2..0000000 --- a/.changeset/what-0-1-means.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@cosyte/mllp": minor ---- - -`0.1.0` is the first release of `@cosyte/mllp` whose public API we ask you to build on. - -**What is covered, and what you can depend on.** Sending and receiving HL7 v2 over MLLP: the client and the server (`createClient`, `createServer` and their starter forms), strict framing through `encodeFrame` and `FrameReader`, acknowledgement correlation on MSA-2, and the commit contract, under which the server's positive acknowledgement never comes before your handler's commit and a handler that throws is answered `AE`. Auto-reconnect with backoff, backpressure, a `close()` that drains and then tells a message that was never written apart from one whose fate is unknown, TLS with certificate verification on by default, and a server that binds loopback unless you opt in to more. The socket-free `InMemoryTransport` at `@cosyte/mllp/testing`, acknowledgements built from a parsed message at `@cosyte/mllp/ack-from-hl7` (with the optional `@cosyte/hl7` peer), and `runDifferential`, which reports how the engine you point it at frames and acknowledges. The stable warning codes and security-warning codes are part of this API. Node.js 22 and 24, ESM and CommonJS, with type declarations for both. - -**What the version promises.** Until 1.0, a breaking change raises the minor version (0.1 to 0.2), and the changelog entry says what broke and what to change. A patch release (0.1.x) does not break you, so a `^0.1.0` range takes the patches and stops before 0.2.0. - -**What is not covered yet.** Batch acknowledgement: an `FHS`/`BHS` envelope is answered with a warned `AE`, never a positive one. MLLP Release 2. A queue or replay store for unacknowledged messages. Differential coverage of engines other than the freely available ones it runs against, so Epic and Cerner are not in it. The documentation at https://docs.cosyte.com/mllp lists the known limitations in full. - -The repository now carries runnable examples under `examples/`, run against the built package on every change. diff --git a/.changeset/wild-pears-repeat.md b/.changeset/wild-pears-repeat.md deleted file mode 100644 index 570b0ed..0000000 --- a/.changeset/wild-pears-repeat.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@cosyte/mllp": minor ---- - -Enhanced acknowledgement mode: a client send can now draw both its commit disposition and its later application disposition, and the server's auto-ACK answers in the half of HL7 Table 0008 the sender asked for. - -HL7 v2.5.1 §2.9 splits acknowledgement in two. The accept acknowledgement (`CA`/`CE`/`CR`) is the one that discharges the sender: the receiver has the bytes in safe storage and you may stop resending. The application acknowledgement (`AA`/`AE`/`AR`) is a later, separate exchange saying what the receiving application did with the message. A message enters that protocol by carrying a non-null MSH-15 or MSH-16, and §2.9 states the equivalence that ties the two protocols together: the original protocol is the enhanced protocol with MSH-15 = `NE` and MSH-16 = `AL`, so the single acknowledgement this package has always correlated is, in that framing, the application one. - -Before this, a client settled a send on the first acknowledgement carrying its control ID and deleted the pending entry, so the second one fell through to the unmatched path and was dropped. On an interface that runs enhanced mode that is a sender concluding success from a commit while the receiving application rejected the message. And the server dispatched a fixed application-mode code whatever the inbound asked for, answering a peer that requested a commit acknowledgement in the wrong half of the table. - -**Client, with `correlateByControlId: true`.** A `CA` reports the commit disposition through a new per-send `onCommitAck` hook and leaves the send pending; the later `AA`, `AE` or `AR` settles it, all three successfully, with the acknowledgement handed to the caller (which of them the receiving application chose is its clinical verdict and not this transport's to judge). A `CE` or `CR` rejects the send at once with `MllpCommitRejectedError`, because a peer that refused custody of the bytes is not going to report on them later. MSH-15 `AL` with MSH-16 `NE` is a legal pair and settles on its single `CA`. An acknowledgement whose MSA-1 is empty or outside Table 0008 is **not guessed**: the send stays pending until its wait expires and the acknowledgement is surfaced. - -**Two waits, both finite.** The first is the existing `ackTimeoutMs`, unchanged in duration, default, meaning and error. The second starts when the accept acknowledgement arrives, is bounded by a new `applicationAckTimeoutMs` (global or per send, defaulting to the acknowledgement timeout in force for that send), and ends with `MllpApplicationAckError`, a distinct type carrying the commit disposition already received and a `reason` of `'timeout'` or `'connection-lost'`. Receiving the accept acknowledgement stops the first timer, so it can no longer expire against a send the peer has already answered. A send waiting in that state when the link drops is failed rather than re-sent: the peer already holds the message. - -**Server.** The auto-ACK path reads MSH-15 against HL7 Table 0155 and evaluates it against the disposition the commit contract reached, which is a condition and not a switch: `AL` asks always, `NE` never, `ER` on an error or reject only, `SU` on a success only. Where the answer is yes it emits the accept-mode counterpart, `CA` where it would answer `AA`, `CE` where `AE`, `CR` where `AR`, the last being what a commit acknowledgement is required to carry for an unrecognised message type or trigger event. It still emits exactly one acknowledgement per inbound message, in every mode. Batch and concatenated frames keep their warned non-positive answer whatever MSH-15 says, and a header that cannot be scanned behaves exactly as before. - -**An unrecognised Table 0155 value is warned and defaulted, never fatal, in either direction.** This is a transport: no send is refused, delayed or altered over the value of a field, and downgrading an inbound message the handler committed would make a discharged sender resend a message already in storage. - -New stable codes, a family of their own rather than additions to the decoder's warning-code registry: `MLLP_ACK_ACCEPT_TYPE_UNRECOGNISED`, `MLLP_ACK_APPLICATION_TYPE_UNRECOGNISED`, `MLLP_ACK_TWO_PHASE_UNAVAILABLE`, `MLLP_ACK_COMMIT_ALREADY_REPORTED`, `MLLP_ACK_MSA1_ABSENT`, `MLLP_ACK_MSA1_UNCLASSIFIABLE`, `MLLP_ACK_SEND_ALREADY_DISPOSED`. Their text comes from a frozen registry whose lookup takes only a code, `ackModeDiagnosticMessage(code)`. New error identities: `MllpApplicationAckError` and `MllpCommitRejectedError`. New surfaces: the client's `'warning'` event also carries `AckModeWarning`, the server gains an `'ackModeWarning'` event, and `NackEvent.ackCode` now reports the code that actually went on the wire, so it can be `CE` or `CR`. - -Every one of these carries no payload content in any mode: stable codes, byte counts, byte offsets, elapsed times, and an MSA-1 value drawn from the closed six-code set. An MSA-1 nobody could classify is reported by its byte length alone. - -Deliberately unchanged, and pinned as such: an original-mode send, one with both fields empty or with a header this package cannot scan, behaves exactly as it did, one acknowledgement, settled by the first match, on the same timeout with the same error. Two-phase correlation requires control-ID correlation, since MSA-2 is the only thing that can attribute a second acknowledgement to a send; on the FIFO default an enhanced-mode send keeps the single-acknowledgement behaviour and warns rather than being refused or left pending. This package's own server still never sends the second acknowledgement, so pointing this client at this server with both fields asking for acknowledgement ends at the application-acknowledgement timeout. diff --git a/CHANGELOG.md b/CHANGELOG.md index f939207..3daa470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,215 @@ # Changelog +## 0.1.0 +### Minor Changes + +- fe9b510: `close()` now drains: it awaits the acknowledgements of sends already written to the transport, bounded by `drainTimeoutMs`, and reports whatever is still unresolved by population, so a consumer's replay logic can tell a message that definitely never reached the wire from one whose fate is unknown. + + Before this, `close()` rejected every pending send with one `MllpConnectionError({ phase: 'close' })` before it delegated to the connection's close, and `drainTimeoutMs` bounded nothing on the client: the `DRAINING` state and the `beforeClose` seam both existed, and no client path used either. A message still sitting in the client and a message the peer may already have committed came back to the caller as the same error. A consumer that replayed on that signal duplicated clinical messages; one that did not, dropped them. + + **The wait.** `close()` installs the drain hook the connection already had a seam for, so `drainTimeoutMs` (default 30 s) is now the bound on a real acknowledgement wait. It ends the moment the last outstanding acknowledgement lands, so a peer that answers promptly costs milliseconds rather than the timeout, and it ends if the link fails mid-drain rather than sitting out a bound waiting for an answer that cannot arrive. A close with nothing pending, a close on a client with no connection, and a close on one that has already closed all return without touching the timeout. Whatever hook was already assigned to `beforeClose` is composed with rather than replaced. + + **The report, split in three.** `MllpNeverDeliveredError` is new and is the safe half: the message was still held inside the client, no bytes were written for it, and resending it cannot duplicate anything. `MllpUnknownFateError` is new and is the ambiguous half, reported as ambiguous: the bytes went out and no acknowledgement came back, so the peer may hold the message. It carries `flushedAt`, the moment those bytes were written, which is what a replay decision reasons about, plus `elapsedMs` and `byteCount`. `MllpApplicationAckError({ reason: 'connection-lost' })` is unchanged and keeps its precedence: a send whose commit disposition the peer already reported is never downgraded to an unknown fate, because the peer's custody of those bytes is a known fact. All three are told apart with `instanceof`; no caller needs to read an error message. + + **The unknown-fate report carries no payload content at all.** Every field on it is a byte count or a timestamp, the control ID appears only as a byte length, and its message is a constant that no builder takes a value for. An error is a diagnostic surface: it is logged, and its `stack` plus its own properties are what an error reporter ships off the box. + + **A send that was parked inside the client is now settled rather than abandoned.** One waiting for queue room under `onBackpressure: 'wait'`, or for the single in-flight slot under `pipeline: false`, had no representation on the shutdown path at all and its promise was simply left pending forever. Both now fail with the never-delivered report, on `close()` and on `destroy()` alike, and an aborted `close()` settles every still-pending send under the same rule rather than leaving any of them hanging. + + **A drain cannot make delivery certain, and this one does not pretend to.** An acknowledgement lost in flight stays indistinguishable from a message never received, which is exactly what the unknown-fate report says. Nothing is retried automatically: the accept acknowledgement is what releases a sender from resending, and the retransmission decision stays with the application and its peer's duplicate detection on `MSH-10` plus `MSH-7`. No write-ahead log, queue or replay is added, and nothing is stored. + + Deliberately unchanged: `destroy()` still settles everything at once, awaiting no acknowledgement and honouring no drain timeout; `ackTimeoutMs` keeps its meaning and its clock; the server's own drain is untouched; and correlation, framing and enhanced-mode acknowledgement behaviour are guarded here, not extended. +- 7d9aa35: A conformance statement an integrator can hand to a reviewer, and a gate that keeps it true. Documentation and test-suite change only: no framing, acknowledgement, TLS or transport behaviour moves. + + The published documentation set described this package's behaviour across eight task-shaped guides: what to set, what throws, what a warning means. None of them was a declaration. An integrator preparing an IHE Integration Statement for a Product Registry entry, or preparing for a Connectathon, had to reconstruct the conformance surface by reading all eight and inferring which clauses were implemented. + + `docs-content/conformance.md` is that missing artifact, published as a navigable page. It declares, in the terms a review is recorded in: + + - **Every framing tolerance opt-in as a declared deviation from the strict block**, with the strict-block behaviour it deviates from, its stable warning code, and its default for a standalone `FrameReader` and for `MllpServer` separately. That last pair is what lets a reviewer tell a deviation this package ships on from one a deployment opted into. The encoder half is declared too, and writing it down corrected a claim the guides had been making: the encoder is strict **by default**, not structurally. `encodeFrame`'s `allowDelimiterBytesInPayload` passes a payload's raw `0x0B`/`0x1C` through with a warning each, producing a block a conformant peer will mis-split, and it appeared nowhere in the published documentation set while `docs-content/framing.md` read "There is no option to emit a malformed frame". It is off on every path this package takes by itself, including the `ack-from-hl7` builder that exposes it as a passthrough. No behaviour changed; the statement of it did. + - **Every acknowledgement mode with a separate client verdict and server verdict**: original mode, the enhanced-mode accept acknowledgement, the enhanced-mode application acknowledgement, the MLLP Release 2 commit acknowledgement and the batch acknowledgement. The application acknowledgement is recorded supported on the client and **not** supported on the server, because this package's server emits exactly one acknowledgement per inbound message and the second exchange is the consumer's to orchestrate. Release 2 and batch are recorded absent on both roles. The automatic server acknowledgement is recorded for what it is, an opt-in: with `autoAck` unset the server sends nothing of its own and the message handler owns the response, and only `createStarterServer` supplies a default. + - **What each acknowledgement-building route does with a multi-message frame, route by route.** The two routes do not agree and the page says so rather than averaging them. `buildRawAck`, behind `autoAck`, downgrades a requested positive `AA`/`CA` to a non-positive `AE`/`CE` for an `FHS`/`BHS` batch envelope **and** for two messages concatenated into one frame. `buildMllpAck`, on the `@cosyte/mllp/ack-from-hl7` subpath, refuses the batch envelope the same way but does **not** detect the concatenated frame: it answers a positive `AA` correlated to the first message, with no warning. That is recorded as a named limitation of this release with the remedy beside it, the exported `rawAckUncorrelatable`, which returns `true` for both shapes and is what the server's own route applies. No behaviour changed here either; both routes do exactly what they did before. + - **The IHE options in actor-and-option terms**: the actor, the transaction, and the option spelled as ITI TF-2 spells it, each split into what this package supplies and what remains the deploying actor's. Nothing is recorded as claimed. An option is claimed by an actor about itself, and a library can supply a mechanism and produce evidence, which is exactly as far as the page goes. + - **A closed three-word verdict vocabulary**, in which `Unverified` is distinct from both `Supported` and `Not supported`, so an inference from a specification is never handed to a reviewer as an observation. Two behaviours are recorded that way, each with its reason. + - **The release whose behaviour it declares**, so a reviewer holding the document knows which one it describes. + + **The declaration is checked against the code on every test run**, which is the half that keeps a claim someone else relies on from going quietly stale. `test/conformance/statement.test.ts` reads the page's tables and compares them with the shipped tree: the tolerance set against `FrameReaderOptions` and `EncoderOptions`, every named warning code against the `WarningCode` union, each tolerance-to-code pairing against the real codec driven over a synthetic byte stream in both directions, the per-role defaults against `SERVER_DEFAULT_FRAMING`, every named package option against the properties declared under `src/`, and the declared release against `package.json`. A mismatch fails and names what drifted. + + Both acknowledgement-building routes are also **run** against a batch envelope and against two concatenated messages, and the disposition each one actually returns must be the disposition the page declares for that cell, so a refusal claimed for a route that does not perform one cannot ship: a reviewer who reads "refused" does not add a guard above the library. The tolerance set is derived by subtracting a pinned list of non-tolerance members from the options interface rather than by matching an `allow*` prefix, so a future framing option not spelled that way cannot move what the decoder tolerates without reddening the gate, and the page's claim that `strict: true` overrides every opt-in is now exercised for each declared tolerance instead of asserted in prose. Thirty seeded drifts were each watched to fail before the gate was believed. + + `scripts/sync-version.mjs` now rewrites the declared release alongside the `VERSION` export, so a release bump does not red the gate while a hand-edit still does. It refuses, rather than silently no-opping, if either line is renamed or duplicated. + + The IHE ATNA transport-security option is now spelled as the published text spells it, `STX: TLS 1.2 floor using BCP195 Option`, on every page that names it, and the spelling is asserted case-exact across the whole documentation set: a Product Registry entry is recorded against the published wording, and one documentation set must not carry two spellings of one option name. + + The four existing guides that cover this ground gained a pointer to the statement rather than a copy of it. Both runnable examples on the new page carry synthetic bytes only, single ASCII letters chosen because they mean nothing, and both are executed against the built package by the existing doc-and-code agreement gate. +- 62375a9: A server can now supply the ephemeral Diffie-Hellman parameters its own site policy mandates, instead of taking whatever group the TLS library picks. + + **`tls.dhParameters` on `ServerTlsOptions`, PEM content, server side only.** Two of the four TLS 1.2 cipher suites the IHE ATNA ITI-19 transport-security option names are DHE, and a server with no Diffie-Hellman parameters cannot offer a DHE suite at all: it advertises the suite and then fails every handshake in it. Until now the only way to get a group was `tls.atnaTransportSecurity: true`, which picks one for you, so a server that instead restricted `tls.ciphers` to a DHE suite had no route to a working link, and a deployment whose policy names a specific group had no route to that group. + + The value is PEM content and never a filesystem path, matching every other credential on these types; this package performs no disk IO for any of them. There is deliberately no counterpart on the client type: the side that answers the key exchange supplies the parameters. + + **It takes precedence over the group `atnaTransportSecurity` selects, and setting both is not a conflict.** The two declare different things, so they compose: the option decides which suites are offered, and this decides which group answers the DHE half of them. That is unlike `atnaTransportSecurity` and `ciphers`, which both declare the offered list and therefore still refuse each other. + + **New stable code `MLLP_TLS_DH_PARAMETERS_REJECTED`**, on `MllpTlsConfigurationError` beside `MLLP_TLS_CIPHER_LIST_REJECTED` and `MLLP_TLS_CIPHER_OPTION_CONFLICT`. Parameters that are not PEM, are not a `DH PARAMETERS` block, do not carry their encapsulation boundaries the way the TLS library's line-oriented PEM reader wants them, do not decode to the parameter structure that library parses and nothing besides, or carry a group it refuses all reject `listen()` before anything is bound, and nothing falls back to a server running without them. That check is there because the TLS library's own behaviour for parameters it cannot read is to discard them in silence, leaving a listener that answers no DHE handshake and says nothing about why. The boundary clause is the one an operator meets by accident: a value that crosses an environment variable or a single-line JSON field loses its line breaks and stops being a parameter block, and it is now refused rather than accepted and thrown away. The structure clause is the one that arrives as the wrong file under the right label: a prime and a generator with any further field behind them beyond the one optional private-value length, read at the 32 bits the library reads it at, is not what it will parse. What the library tolerates is tolerated, including CRLF endings, a missing final newline, text either side of the block, and a private-value length that is genuinely present. The values are held to the same bounds the library's own parameter check applies, because a group outside them is loaded and then refused when the handshake asks for a key, which from outside is the same silent listener: the prime must be odd and the generator must be at least 2 and no greater than the prime minus 2. The prime is not tested for primality, because that costs seconds on a 3072-bit group and `listen()` is not the place to spend it, so a well-formed block with a composite modulus is accepted here exactly as the library accepts it. + + Nothing else moves. The four suites, the cipher list the option offers, the TLS 1.2 floor, the `'tlsNegotiated'` event and the existing configuration-error codes are unchanged, and a server that sets neither option still imposes no cipher list and no Diffie-Hellman parameters at all. +- 979af45: A deployment claiming the IHE ATNA ITI-19 transport-security option can now turn it on as one setting, and can prove from its own logs which protocol version and cipher suite each TLS link actually negotiated. + + Before this, `@cosyte/mllp` set no cipher list at all: `ciphers` was an OpenSSL passthrough with no default, so what a link offered was whatever the runtime happened to compile in, which a Node distribution can configure at build time and an operator can replace wholesale from outside the process with `--tls-cipher-list` or `NODE_OPTIONS`. What a link offered was a property of the deployment, not of this package. Nothing anywhere reported what was actually negotiated, and the documentation told an operator to hand-write a cipher list. + + **`tls.atnaTransportSecurity`, on the client and on the server, off by default.** Selecting it offers exactly the four TLS 1.2 cipher suites ITI TF-2 §3.19.6.2.3 names and nothing else. It only ever narrows what is offered: a peer that supports none of the four fails the handshake rather than negotiating something else, which is why it is off by default and why turning it on can stop a link that worked before. Every cipher claim here is proven by a real handshake against a peer restricted to one suite, never by comparing a configured string against a document, because the runtime's own default list ends in a class token and what it effectively contains is not settled by reading it. + + **TLS 1.3 is not switched off as a side effect.** A TLS 1.3 suite is enabled only by its full name in the cipher list, so a list holding four TLS 1.2 suites alone would silently downgrade a link that reaches TLS 1.3 today. The three TLS 1.3 suites the runtime enables by default are offered alongside the four, and two peers that reach TLS 1.3 without the option still reach it with the option on. + + **The server also provides ephemeral Diffie-Hellman parameters when the option is selected.** Two of the four suites are DHE, and a server with no DH parameters cannot offer a DHE suite at all: it would advertise the list and then fail every DHE handshake in it. Nothing is provided when the option is off, so the not-selected path offers exactly what it offered before. + + **`'tlsNegotiated'`, a frozen per-connection record on both the client and the server.** It carries the negotiated protocol version and the cipher suite in its IANA spelling, the one the standard prints, so it compares against a conformance claim directly. It fires once per completed handshake, on the initial connect and on every reconnect, so a log records every session rather than only the first, and it is never emitted for a plaintext connection. It is a log line and carries no HL7 payload content and no certificate or key material: it is built at handshake-completion time, before any HL7 byte has crossed the link, from two names out of the TLS library's own suite registry plus the same host and port a security warning already carries. + + **`MllpTlsConfigurationError`, new, with two stable codes.** A cipher-suite list the runtime rejects is `MLLP_TLS_CIPHER_LIST_REJECTED`, and there is no fallback to the runtime default list; `atnaTransportSecurity` and `ciphers` both set is `MLLP_TLS_CIPHER_OPTION_CONFLICT`, because each declares the offered list and honouring one would silently discard the other. Either rejects the originating `connect()` or `listen()` before a socket exists, so nothing is left connected and nothing is left bound, and a server constructed with a refused configuration stays refused rather than becoming listenable on a retry. Both are identified by `instanceof` plus `code`, never by matching on a message: the message is a frozen registry entry whose factory takes only a code, and the suite list is validated on its own with no certificate, key or passphrase in scope, so the error cannot carry credential material. + + Deliberately unchanged: the TLS 1.2 floor, the `minVersion`/`maxVersion` types, verification on by default, `clientAuth`, bind safety, and every framing and acknowledgement behaviour. With the option off, this package still imposes no cipher list and a handshake against a peer restricted to a non-ATNA default suite still succeeds. + + Not in scope, and stated rather than implied: no certificate issuance, rotation, reload, CRL or OCSP; no ATNA audit-record emission, which belongs to the actor rather than to the transport; and no conformance statement. Claiming an IHE option stays the deployment's declaration to make. This package can support it and evidence it, and cannot make it on anyone's behalf. +- 9639470: Differential verification you can point at your own interface engine: `runDifferential` now ships in the published package, sends a canonical corpus of synthetic messages at a peer you name, and returns a per-exchange frame-parity and `MSA-2` correlation report. + + The differential harness existed before this, and it was unreachable. It was a test tier inside this repository, aimed at the two freely available open-source engines, and `files` publishes `dist` and four documents, so nothing under `test/` reached an installation. A consumer standing an interface up against an engine this project cannot obtain had no way to observe how that engine actually frames and acknowledges, which is why the limitations page said their behaviour was inferred from the spec rather than observed. It still is, for those engines. What is new is that you can go and observe your own. + + **What a run does.** One connection per exchange, opened, used and closed, so a peer that refuses or drops one connection costs one exchange rather than the whole run. Each message goes out through this package's own strict encoder, so what the peer receives is the canonical Release 1 block, `VT` + payload + `FS` + `CR`. The answer comes back through this package's own decoder with every tolerance enabled, so a peer that deviates is described rather than fatal. Byte parity means the MLLP **envelope**, compared against the canonical block this package emits for the same payload. It is not equality of message content, which can never hold: an acknowledgement carries the peer's own control ID, its own timestamp and its own sending application, and it is supposed to. + + **The report is an observation, never a verdict.** `result` is one of `parity-observed`, `deviations-observed`, `no-observation` or `skipped`, and none of them says a peer is conformant. A run in which nothing was answered is `no-observation` and is never presented as a success. Per exchange you get an identifier, an outcome, a byte-parity outcome, a correlation outcome, the warning codes observed with the byte offset each was detected at, the bytes sent and read as counts, the deadline waited and the time taken. It is plain JSON throughout, no `Buffer`, no `Date`, no class instance, so a pipeline reads it without scraping text. + + **A deviation is named by a stable warning code, not by an opaque error.** The block's own deviations arrive as `MLLP_MISSING_LEADING_VT`, `MLLP_FS_WITHOUT_CR`, `MLLP_LF_AFTER_FS`, `MLLP_LEADING_WHITESPACE`, `MLLP_TRAILING_BYTES`, `MLLP_EMPTY_PAYLOAD`, `MLLP_PAYLOAD_CONTAINS_FS` or `MLLP_FRAME_TOO_LARGE`, including the oversize case, which is a thrown decoder error everywhere else and is caught and named here. An acknowledgement that does not echo the control ID of the message it answered is reported as a **correlation** failure under `MLLP_ACK_UNMATCHED_CONTROL_ID` and never as a framing-parity failure, and an acknowledgement carrying no readable control ID at all is `absent` rather than a mismatch, because claiming a peer echoed the wrong identifier when it echoed nothing readable is a different and false finding. **No warning code was renamed, removed or repurposed**; every code published before this release is still there, spelled as it was. + + **The report carries no payload content, by construction.** A code, a byte offset and structural counts. Not a run of the peer's bytes, not the acknowledged control ID, not a truncation or a hex rendering of either. This is deliberate to the point of dropping the decoder's own warning MESSAGES, two of which render the hex of the byte found where a framing byte was expected, which on a block with no leading `VT` is the first byte of the peer's unframed content. The engine you aim this at may hold real patients and a report is a file that gets mailed to a vendor, so the report is safe to attach. + + **Aim it with care.** A run sends messages INTO whatever engine it is pointed at. They are synthetic patients, and an engine that accepts them stores synthetic patients: an admit and an observation result will land in whatever that endpoint feeds. Point it at a test or staging endpoint, never at a production interface carrying live traffic. The limitations page says so where the invocation is given. + + **Absent configuration and broken configuration are different things.** With no peer address the run skips cleanly and returns `result: 'skipped'`, so it is safe to leave in a suite that also runs where no engine exists. An address that is present and is not a `host:port` throws the new `MllpDifferentialConfigurationError`, carrying the new stable code `MLLP_DIFF_PEER_UNPARSEABLE` and the offending value, because a silent skip there reads as proof the harness ran. Addresses are split on the last colon, so an IPv6 literal resolves bracketed or bare. + + **Every failure a peer can present lands on its own named outcome and the run still returns a report**: `connection-refused`, `connection-failed`, `connection-dropped`, `unanswered` (which states the deadline it waited and reports neither parity nor correlation), and `undecodable-response`. + + New exports on the root subpath, all additive: `runDifferential`, `canonicalExchanges`, `canonicalAcknowledgement`, `resolveDifferentialPeer`, `MllpDifferentialConfigurationError`, `MLLP_DIFF_PEER_UNPARSEABLE`, `differentialConfigurationMessage`, and the report and option types. `DifferentialRunOptions` takes `deadlineMs`, `maxFrameSizeBytes`, your own `exchanges`, an `AbortSignal`, and a `connect` factory for a peer that is not a plain TCP socket, an MLLPS endpoint being the obvious case. No new runtime dependency: this package still has none. + + Nothing about the transport itself changed. No decoder tolerance was widened, no encoder output moved, no acknowledgement construction or commit-contract behaviour was touched. The harness observes; it does not relax anything. +- a3a2eee: `0.1.0` is the first release of `@cosyte/mllp` whose public API we ask you to build on. + + **What is covered, and what you can depend on.** Sending and receiving HL7 v2 over MLLP: the client and the server (`createClient`, `createServer` and their starter forms), strict framing through `encodeFrame` and `FrameReader`, acknowledgement correlation on MSA-2, and the commit contract, under which the server's positive acknowledgement never comes before your handler's commit and a handler that throws is answered `AE`. Auto-reconnect with backoff, backpressure, a `close()` that drains and then tells a message that was never written apart from one whose fate is unknown, TLS with certificate verification on by default, and a server that binds loopback unless you opt in to more. The socket-free `InMemoryTransport` at `@cosyte/mllp/testing`, acknowledgements built from a parsed message at `@cosyte/mllp/ack-from-hl7` (with the optional `@cosyte/hl7` peer), and `runDifferential`, which reports how the engine you point it at frames and acknowledges. The stable warning codes and security-warning codes are part of this API. Node.js 22 and 24, ESM and CommonJS, with type declarations for both. + + **What the version promises.** Until 1.0, a breaking change raises the minor version (0.1 to 0.2), and the changelog entry says what broke and what to change. A patch release (0.1.x) does not break you, so a `^0.1.0` range takes the patches and stops before 0.2.0. + + **What is not covered yet.** Batch acknowledgement: an `FHS`/`BHS` envelope is answered with a warned `AE`, never a positive one. MLLP Release 2. A queue or replay store for unacknowledged messages. Differential coverage of engines other than the freely available ones it runs against, so Epic and Cerner are not in it. The documentation at https://docs.cosyte.com/mllp lists the known limitations in full. + + The repository now carries runnable examples under `examples/`, run against the built package on every change. +- da0d385: Enhanced acknowledgement mode: a client send can now draw both its commit disposition and its later application disposition, and the server's auto-ACK answers in the half of HL7 Table 0008 the sender asked for. + + HL7 v2.5.1 §2.9 splits acknowledgement in two. The accept acknowledgement (`CA`/`CE`/`CR`) is the one that discharges the sender: the receiver has the bytes in safe storage and you may stop resending. The application acknowledgement (`AA`/`AE`/`AR`) is a later, separate exchange saying what the receiving application did with the message. A message enters that protocol by carrying a non-null MSH-15 or MSH-16, and §2.9 states the equivalence that ties the two protocols together: the original protocol is the enhanced protocol with MSH-15 = `NE` and MSH-16 = `AL`, so the single acknowledgement this package has always correlated is, in that framing, the application one. + + Before this, a client settled a send on the first acknowledgement carrying its control ID and deleted the pending entry, so the second one fell through to the unmatched path and was dropped. On an interface that runs enhanced mode that is a sender concluding success from a commit while the receiving application rejected the message. And the server dispatched a fixed application-mode code whatever the inbound asked for, answering a peer that requested a commit acknowledgement in the wrong half of the table. + + **Client, with `correlateByControlId: true`.** A `CA` reports the commit disposition through a new per-send `onCommitAck` hook and leaves the send pending; the later `AA`, `AE` or `AR` settles it, all three successfully, with the acknowledgement handed to the caller (which of them the receiving application chose is its clinical verdict and not this transport's to judge). A `CE` or `CR` rejects the send at once with `MllpCommitRejectedError`, because a peer that refused custody of the bytes is not going to report on them later. MSH-15 `AL` with MSH-16 `NE` is a legal pair and settles on its single `CA`. An acknowledgement whose MSA-1 is empty or outside Table 0008 is **not guessed**: the send stays pending until its wait expires and the acknowledgement is surfaced. + + **Two waits, both finite.** The first is the existing `ackTimeoutMs`, unchanged in duration, default, meaning and error. The second starts when the accept acknowledgement arrives, is bounded by a new `applicationAckTimeoutMs` (global or per send, defaulting to the acknowledgement timeout in force for that send), and ends with `MllpApplicationAckError`, a distinct type carrying the commit disposition already received and a `reason` of `'timeout'` or `'connection-lost'`. Receiving the accept acknowledgement stops the first timer, so it can no longer expire against a send the peer has already answered. A send waiting in that state when the link drops is failed rather than re-sent: the peer already holds the message. + + **Server.** The auto-ACK path reads MSH-15 against HL7 Table 0155 and evaluates it against the disposition the commit contract reached, which is a condition and not a switch: `AL` asks always, `NE` never, `ER` on an error or reject only, `SU` on a success only. Where the answer is yes it emits the accept-mode counterpart, `CA` where it would answer `AA`, `CE` where `AE`, `CR` where `AR`, the last being what a commit acknowledgement is required to carry for an unrecognised message type or trigger event. It still emits exactly one acknowledgement per inbound message, in every mode. Batch and concatenated frames keep their warned non-positive answer whatever MSH-15 says, and a header that cannot be scanned behaves exactly as before. + + **An unrecognised Table 0155 value is warned and defaulted, never fatal, in either direction.** This is a transport: no send is refused, delayed or altered over the value of a field, and downgrading an inbound message the handler committed would make a discharged sender resend a message already in storage. + + New stable codes, a family of their own rather than additions to the decoder's warning-code registry: `MLLP_ACK_ACCEPT_TYPE_UNRECOGNISED`, `MLLP_ACK_APPLICATION_TYPE_UNRECOGNISED`, `MLLP_ACK_TWO_PHASE_UNAVAILABLE`, `MLLP_ACK_COMMIT_ALREADY_REPORTED`, `MLLP_ACK_MSA1_ABSENT`, `MLLP_ACK_MSA1_UNCLASSIFIABLE`, `MLLP_ACK_SEND_ALREADY_DISPOSED`. Their text comes from a frozen registry whose lookup takes only a code, `ackModeDiagnosticMessage(code)`. New error identities: `MllpApplicationAckError` and `MllpCommitRejectedError`. New surfaces: the client's `'warning'` event also carries `AckModeWarning`, the server gains an `'ackModeWarning'` event, and `NackEvent.ackCode` now reports the code that actually went on the wire, so it can be `CE` or `CR`. + + Every one of these carries no payload content in any mode: stable codes, byte counts, byte offsets, elapsed times, and an MSA-1 value drawn from the closed six-code set. An MSA-1 nobody could classify is reported by its byte length alone. + + Deliberately unchanged, and pinned as such: an original-mode send, one with both fields empty or with a header this package cannot scan, behaves exactly as it did, one acknowledgement, settled by the first match, on the same timeout with the same error. Two-phase correlation requires control-ID correlation, since MSA-2 is the only thing that can attribute a second acknowledgement to a send; on the FIFO default an enhanced-mode send keeps the single-acknowledgement behaviour and warns rather than being refused or left pending. This package's own server still never sends the second acknowledgement, so pointing this client at this server with both fields asking for acknowledgement ends at the application-acknowledgement timeout. + +### Patch Changes + +- 5dfc44b: `pnpm phi-scan` now refuses a run (exit 2) that withdrew a target it had enumerated, instead of reporting on the targets that were left. + + `--allow-fixture ` used to subtract a path from the read set and let the scan carry on, so the same invocation over a corpus whose only violator had been withdrawn printed `OK, no hits` and exited 0. A scan that did not open a file has no clean verdict about it, which is the rule every other incompleteness in this scanner already follows: an unreadable tracked file, a non-regular entry, an unmerged path, an emptied walk root and an empty index all refuse. The bypass was the one route to a clean report that worked by not reading something, and it was the one a caller could take deliberately. + + The refusal is printed after any hits found in the targets the run did read, so a real finding is never swallowed by it, and it exits 2 rather than 1, because 1 means "hits found" and an incomplete sweep makes no such claim. A run that passes no `--allow-fixture`, or one whose `--allow-fixture` matched no enumerated target, is unchanged: the tolerated mid-sweep vanish, the per-walk-root observed-nothing refusal, `--staged` and the index corpus all behave exactly as before. To keep a synthetic fixture passing, declare its identifiers in `scripts/phi-allow-list.txt`, which leaves the file scanned. + + Also in this release: the advisory override for `js-yaml` widens to `>=4.0.0 <4.3.0` and resolves at `4.3.0`, matching the extended advisory range, and a `pnpm-workspace.yaml` declares a 24 hour publication cooldown (`minimumReleaseAge: 1440`) and a `no-downgrade` trust policy. Both settings postdate the pnpm this package pins, so they are declared now and take force when that pin is raised. + + No runtime behaviour of this package changes. The framing, ACK, warning-code and TLS surfaces are untouched, `js-yaml` is reached only through development tooling, and nothing here alters what is published in `dist`. +- ff5b876: Docs: the README's usage example is now one complete program the test suite runs, and the quickstart's first example is held to a committed synthetic fixture. + + The usage example used to be two fragments: a server on a fixed port whose handler called an undeclared `db`, and a client whose printed acknowledgement carried a timestamp and a control id that change on every run. It now starts the server on a port the operating system picks on loopback, sends one message, logs only the shape of the acknowledgement (its code, and whether MSA-2 echoed the control id sent), and closes both ends. The test suite reads it out of the README, runs it twice against the package, and compares its output with the block printed beside it. The message each first example puts on the wire is a byte-for-byte copy of a committed synthetic fixture, a changed value in either example fails the suite, and every install command the README and the installation page print is checked against the package's own name or a dependency it declares. + + The quickstart's first example did not compile in a strict TypeScript project: it assigned the de-framed payload to an untyped `let` inside the `onFrame` callback and then called `.equals` on it. It now collects payloads into a `Buffer[]` and claims that exactly one came out. The suite compiles both first examples with the settings `tsc --init` writes for a new project, so an example that does not compile fails it. +- c44963b: The README is rewritten to the house package front-page standard, so the text npm freezes at publish is complete and correct rather than half-written. + + **`Status` now describes this release.** It names the version the page documents, says in plain terms that the public API is settled and safe to depend on, and names what is still moving rather than implying nothing is: batch acknowledgement is not built, verified engine coverage is a growing list rather than a complete one, and MLLP Release 2 is not spoken. The old line describing the package as unstable is gone. + + **Six sections a reader needs are now written:** `Why this exists`, `Status`, `Install`, `Usage`, `PHI and safety` and `Contributing`. `Why this exists` states the problem and the nearest alternative rather than restating the feature list. `Install` gives the Node floor, the package manager and the dual ESM plus CJS format in one copy-pasteable block. `Contributing` says where to ask, that pull requests are welcome, that no contributor guide exists yet, and exactly which checks a change has to clear. + + **`PHI and safety` is new, and it states bounds rather than absolutes.** What the transport does with a payload, which is hold it in memory only while it is in flight, and reuse its receive buffer for the next frame rather than scrub it. The reads it performs: the `MSH` header an acknowledgement is built from, and the `MSA` of an inbound acknowledgement, each bounded at that segment's own terminator, so no segment carrying clinical content is reached. The exception, named rather than glossed and opt-in either way: the `ack-from-hl7` subpath decodes a whole message and hands it to the optional parser peer, which resolves every field of every segment. What it does not do: no disk write, no queue, no replay store, no logger call. Exactly what a diagnostic can carry, including the two framing warning codes that render the hex of the single byte found where a framing byte was expected, and what to log instead if that is more than your threat model allows. And what you still own: encryption in transit, your own logging and retention, idempotency, and any decision to widen the loopback bind default. + + **Every example is verifiable.** The client example shows the acknowledgement it actually receives, with `MSA-2` echoing the control id that was sent, and a new framing example shows the bytes `encodeFrame` emits and a chunked `FrameReader` delivering one frame from two partial pushes, each with its real output printed beneath it. + + **Banner and head.** The banner alt text now matches the shared brand declaration for the tile it shows, character for character, instead of a hand-written description that had drifted from it. The one-line description under the badges is now the same string the package publishes, so the page and the registry no longer say two different things. The tagline is shortened to one readable line, and a table of contents was added. + + No runtime code, framing, acknowledgement, transport or TLS behaviour changes here. Documentation only. +- 37fb002: The published documentation set gains a page contract, a page for the testing surface, and a test that validates the set as a set. + + **A new page: Testing & verification.** `@cosyte/mllp/testing` is one of three published subpaths and `runDifferential` is a root export, and neither had a page. The in-memory transport appeared as a seven-line aside on the entry page and one paragraph in the quickstart, with no sidebar entry of its own; the differential harness was documented only inside the troubleshooting page, so a shipped capability was reachable only from the page about what the package cannot do. Both now have one page covering them together, in the sidebar under Guides and linked from the entry page and the quickstart. It covers `InMemoryTransport.pair()`, the four conditions a pair can simulate (chunked reads through `split(n)`, backpressure through `pause()`/`resume()`, a one-sided reset through `destroy(reason)`, a graceful close through `close()`), driving a whole `Connection` over a pair with no network underneath, and the differential harness end to end: what a run sends, what the report carries, what it deliberately does not carry, and how to aim it safely. Two of its examples are executed against the built package on every test run. + + **Every page now declares a description.** None of them did, so every page on the docs site rendered with no meta description. Each one now carries a description written for a reader deciding whether the page answers their question. + + **The navigation order is fixed.** Three pages all declared position 1 and no page declared the last two, which left the rendered order of the entry page, the installation page and the quickstart to the theme's tie-break. Positions now ascend across the whole set in the order the sidebar lists it. + + **Two broken cross-references are repaired.** A link on the acknowledgements page pointed at a same-page fragment whose heading lives on the limitations page, so it went nowhere; it now points at the page that defines it. The installation page's link to the quickstart was the one sibling link in the set written without its file extension; it now matches the rest. + + **The release-status claim is stated once.** Where this package sits on its release ladder, and what that means for the stability of its public surface, was restated on two pages that could drift apart. It now lives on the limitations page alone, and the installation page links to it. No page other than the conformance statement carries a literal version, so a release has one line to move and no forgotten copy. + + **The set is formatted, and validated.** `docs-content/` is now covered by this package's `format` and `format:check` scripts like every other prose surface here, so tables and emphasis are consistent across the set. A new test validates the set against a stated contract: every page carries the four required frontmatter fields and an id matching its filename, the sidebar and the files are a bijection with positions ascending from 1, every relative link resolves and every fragment names a heading that exists on the page it targets, in-set links are written `./.md`, and no file carries an en dash or an em dash. Each check names the offending file or id when it fails. + + No source changed. No public surface, warning code, option name, per-role default or conformance verdict moved, and the conformance statement's verdicts, tables and declared version are untouched. +- b3a69a7: Internal PHI-scan tooling: the all-mode sweep now reads the bytes git carries, not only the working tree. No runtime, API or published behaviour change. + + `all` mode walked `test/` and `src/` on disk and reported what it found there, and nothing reconciled that against what git actually carries. A directory of decoys at the tracked names satisfied it completely: every root still yielded, every path still matched, and the committed corpus went unopened while the gate printed `[phi-scan] OK, no hits` and exited 0. + + The sweep now also reads the bytes git carries at every path in the index, as a union with the walk. No walk root was narrowed and no clause was dropped: a file the walk reads is still read off disk with exactly the tiers it had. An index blob whose bytes the walk already read is skipped by byte comparison, never by stat, mtime or hash, which is what a decoy defeats. + + Eight states that printed a clean result at exit 0 are closed, each measured against the base commit `6eb1615` and each pinned by a case: a walk root swapped for a directory mirroring the tracked names; one fixture replaced by a clean decoy; a message under an undeclared top-level directory; a tracked file absent from the working tree; PHI behind a walk root that is itself a symlink; a tracked symbolic link outside every walk root; an unmerged index entry outside every walk root; and an empty index, whose every check passed vacuously. + + Five controls, each proved able to fail by mutating the route: a clean repo exits 0; a clean working-tree divergence exits 0; a violator in both the tree and the index is reported once; an emptied walk root still refuses though the index holds its files; and an absent walk root stays legitimate. The first is killed only by a composition of two mutations, because in a healthy repo the byte comparison leaves no index target for a single mutation to corrupt. + + It found real unscanned corpus. Thirty-two tracked non-markdown files sat outside both walk roots and had been read by neither route. Two carried a reported token, both already committed and neither PHI: the published author address in `package.json`, and the metavariable placeholders in this scanner's own docblock. Both are declared in the allow-list with their cost written beside them, including the fact that an email-domain entry is global and route-blind. + + Deliberately unchanged: the pre-commit route keeps its own scope, because what it enumerates decides what a commit is blocked on, which makes widening it a hook decision with its own argument; and the per-root observation rule stays a statement about the walk, so a root emptied on disk still refuses. Detective, not preventive, on every route. + + One behaviour change worth naming rather than burying: because the refusals deliberately run before the name filter, an unmerged `*.md` now refuses the all-mode sweep at exit 2 where it was previously skipped. It is fail-safe and consistent, but it has a local cost, since a conflict on `CHANGELOG.md` makes `pnpm phi-scan` exit 2 until it is resolved. The pre-commit route is unaffected and CI never has an unmerged index. + + Residual, measured. Reading an index entry is not the same act as tiering it: outside `test/` the detector gate still wants a `.hl7` or `.bin` name, so the same bytes exit 1 at `examples/data/capture.hl7` and 0 at `examples/data/capture.txt`. That is disclosed and pinned as a characterization case rather than closed, because handing a manifest or a lockfile to the segment parser would report identifiers and prose as person names. Working-tree bytes at a path outside every walk root are read by neither route, tracked or not, so a tracked file out there with unstaged edits is judged on its staged bytes. The tolerated-vanish window is one phase wider than at base, because the index is read before the walk; it is never silent, and the vanish re-check window is unchanged. Two conditions that do not fire here and will fire in a sibling are recorded in the scanner: end-of-line normalization makes every blob diverge and must not be answered by normalizing before comparing, and a gitlink refuses the sweep permanently. Exit codes are this repo's own and were derived rather than ported. +- fd04f57: The `@cosyte/hl7` dev/test peer now installs from the npm registry instead of a + vendored packed tarball, and `vendor/` is deleted. + + The tarball held `@cosyte/hl7` at `0.0.0` and never moved while that package + shipped ten releases. One of them made the MSA-2 control-id echo byte-verbatim + across the full escape alphabet. Because the pinned copy never picked it up, + this package's own test suite went on asserting the old behaviour as a + guarantee: an inbound `MSH-10` of `ID\X` echoed back as `ID\E\X`, which is a + different control id on the wire, so an ACK the sender cannot correlate, so a + resend, so a duplicate clinical message. That case is now pinned inverted, as a + round-trip through the parser path that must produce the same bytes as the + byte-copy path and must not warn. + + No runtime behaviour of this package changes: `@cosyte/hl7` remains an optional + peer dependency referenced only from the `@cosyte/mllp/ack-from-hl7` subpath, + and the emitted framing, ACK and warning-code surfaces are untouched. What + changes is which version of the peer the tests run against, and therefore what + they can prove. +- 9c339c1: The Quickstart page now opens with the same program as the README: a server and a client on loopback, one message sent and its acknowledgement read. + + Before this, the page opened with framing bytes by hand while the README opened with a server and a client, so a reader arriving from npm and one arriving from the documentation site met two different first programs. The framing, tolerance and encoder examples follow it unchanged. The test suite now fails if the page's first program or the output printed beside it differs from the README's, runs it against the built package, and compares what it prints with that output. Documentation and test change only. +- 1b65328: The next release is numbered `0.1.0` rather than `0.0.12`. This one adds public API and a published + conformance declaration, and a patch number would have told you nothing was added. Nothing exported + by `0.0.11` is removed or renamed in it, on any of the three published subpaths. + + Contributor guidance for picking a bump type now describes the `0.1.x` ladder instead of the retired + pre-alpha one. + + No runtime, API or published behaviour changes: this is release bookkeeping. +- 9959dc1: Rewrite the npm package description so it names the standard this transport carries and the capabilities it ships: `MLLP transport for HL7 v2: client and server, framing, ACK correlation, auto-reconnect, TLS`. The previous line led with a promotional adjective and never named HL7 v2, so npm search results and the repository summary both understated what the package is for. Metadata only: no API, no behaviour and no dependency change. + ## 0.0.11 ### Patch Changes diff --git a/docs-content/conformance.md b/docs-content/conformance.md index c31e2df..9763b3b 100644 --- a/docs-content/conformance.md +++ b/docs-content/conformance.md @@ -13,7 +13,7 @@ This page is the one document to hand a conformance reviewer. It declares, in th recorded in, which framing behaviours, acknowledgement modes and transport-security options `@cosyte/mllp` implements, and which it does not. -**Version declared:** `0.0.11` +**Version declared:** `0.1.0` Everything below describes that release. A conformance behaviour cannot move in this package without this page moving in the same change: the declared tolerance sets on both halves of the codec, the diff --git a/package.json b/package.json index 57347fa..95bf4de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cosyte/mllp", - "version": "0.0.11", + "version": "0.1.0", "description": "MLLP transport for HL7 v2 in Node.js and TypeScript: client and server, framing, ACK correlation, auto-reconnect, TLS, and an in-memory transport for tests.", "type": "module", "engines": { diff --git a/src/index.ts b/src/index.ts index 255e4b6..709324a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,7 +28,7 @@ * console.log(VERSION); * ``` */ -export const VERSION: string = "0.0.11"; +export const VERSION: string = "0.1.0"; // Phase 2: framing codec public surface export type { WarningCode, MllpWarning } from "./framing/index.js";