Repository navigation
Version Packages - #83
Merged
Merged
Conversation
NSchatz
force-pushed
the
changeset-release/main
branch
4 times, most recently
from
September 25, 2026 21:18
b08d687 to
e2c4b07
Compare
NSchatz
force-pushed
the
changeset-release/main
branch
from
September 25, 2026 22:46
e2c4b07 to
efdf91e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and publish to npm yourself or setup this action to publish automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
Releases
@cosyte/mllp@0.1.0
Minor Changes
fe9b510:
close()now drains: it awaits the acknowledgements of sends already written to the transport, bounded bydrainTimeoutMs, 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 oneMllpConnectionError({ phase: 'close' })before it delegated to the connection's close, anddrainTimeoutMsbounded nothing on the client: theDRAININGstate and thebeforeCloseseam 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, sodrainTimeoutMs(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 tobeforeCloseis composed with rather than replaced.The report, split in three.
MllpNeverDeliveredErroris 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.MllpUnknownFateErroris 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 carriesflushedAt, the moment those bytes were written, which is what a replay decision reasons about, pluselapsedMsandbyteCount.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 withinstanceof; 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
stackplus 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 underpipeline: 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, onclose()and ondestroy()alike, and an abortedclose()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-10plusMSH-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;ackTimeoutMskeeps 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.mdis that missing artifact, published as a navigable page. It declares, in the terms a review is recorded in:FrameReaderand forMllpServerseparately. 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'sallowDelimiterBytesInPayloadpasses a payload's raw0x0B/0x1Cthrough with a warning each, producing a block a conformant peer will mis-split, and it appeared nowhere in the published documentation set whiledocs-content/framing.mdread "There is no option to emit a malformed frame". It is off on every path this package takes by itself, including theack-from-hl7builder that exposes it as a passthrough. No behaviour changed; the statement of it did.autoAckunset the server sends nothing of its own and the message handler owns the response, and onlycreateStarterServersupplies a default.buildRawAck, behindautoAck, downgrades a requested positiveAA/CAto a non-positiveAE/CEfor anFHS/BHSbatch envelope and for two messages concatenated into one frame.buildMllpAck, on the@cosyte/mllp/ack-from-hl7subpath, refuses the batch envelope the same way but does not detect the concatenated frame: it answers a positiveAAcorrelated to the first message, with no warning. That is recorded as a named limitation of this release with the remedy beside it, the exportedrawAckUncorrelatable, which returnstruefor both shapes and is what the server's own route applies. No behaviour changed here either; both routes do exactly what they did before.Unverifiedis distinct from bothSupportedandNot 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 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.tsreads the page's tables and compares them with the shipped tree: the tolerance set againstFrameReaderOptionsandEncoderOptions, every named warning code against theWarningCodeunion, each tolerance-to-code pairing against the real codec driven over a synthetic byte stream in both directions, the per-role defaults againstSERVER_DEFAULT_FRAMING, every named package option against the properties declared undersrc/, and the declared release againstpackage.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 thatstrict: trueoverrides 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.mjsnow rewrites the declared release alongside theVERSIONexport, 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.dhParametersonServerTlsOptions, 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 wastls.atnaTransportSecurity: true, which picks one for you, so a server that instead restrictedtls.ciphersto 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
atnaTransportSecurityselects, 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 unlikeatnaTransportSecurityandciphers, which both declare the offered list and therefore still refuse each other.New stable code
MLLP_TLS_DH_PARAMETERS_REJECTED, onMllpTlsConfigurationErrorbesideMLLP_TLS_CIPHER_LIST_REJECTEDandMLLP_TLS_CIPHER_OPTION_CONFLICT. Parameters that are not PEM, are not aDH PARAMETERSblock, 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 rejectlisten()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 andlisten()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/mllpset no cipher list at all:cipherswas 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-listorNODE_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 isMLLP_TLS_CIPHER_LIST_REJECTED, and there is no fallback to the runtime default list;atnaTransportSecurityandciphersboth set isMLLP_TLS_CIPHER_OPTION_CONFLICT, because each declares the offered list and honouring one would silently discard the other. Either rejects the originatingconnect()orlisten()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 byinstanceofpluscode, 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/maxVersiontypes, 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:
runDifferentialnow ships in the published package, sends a canonical corpus of synthetic messages at a peer you name, and returns a per-exchange frame-parity andMSA-2correlation 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
filespublishesdistand four documents, so nothing undertest/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.
resultis one ofparity-observed,deviations-observed,no-observationorskipped, and none of them says a peer is conformant. A run in which nothing was answered isno-observationand 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, noBuffer, noDate, 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_FSorMLLP_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 underMLLP_ACK_UNMATCHED_CONTROL_IDand never as a framing-parity failure, and an acknowledgement carrying no readable control ID at all isabsentrather 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
VTis 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 ahost:portthrows the newMllpDifferentialConfigurationError, carrying the new stable codeMLLP_DIFF_PEER_UNPARSEABLEand 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), andundecodable-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.DifferentialRunOptionstakesdeadlineMs,maxFrameSizeBytes, your ownexchanges, anAbortSignal, and aconnectfactory 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.0is the first release of@cosyte/mllpwhose 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,createServerand their starter forms), strict framing throughencodeFrameandFrameReader, 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 answeredAE. Auto-reconnect with backoff, backpressure, aclose()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-freeInMemoryTransportat@cosyte/mllp/testing, acknowledgements built from a parsed message at@cosyte/mllp/ack-from-hl7(with the optional@cosyte/hl7peer), andrunDifferential, 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.0range takes the patches and stops before 0.2.0.What is not covered yet. Batch acknowledgement: an
FHS/BHSenvelope is answered with a warnedAE, 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 =NEand 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. ACAreports the commit disposition through a new per-sendonCommitAckhook and leaves the send pending; the laterAA,AEorARsettles 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). ACEorCRrejects the send at once withMllpCommitRejectedError, because a peer that refused custody of the bytes is not going to report on them later. MSH-15ALwith MSH-16NEis a legal pair and settles on its singleCA. 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 newapplicationAckTimeoutMs(global or per send, defaulting to the acknowledgement timeout in force for that send), and ends withMllpApplicationAckError, a distinct type carrying the commit disposition already received and areasonof'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:
ALasks always,NEnever,ERon an error or reject only,SUon a success only. Where the answer is yes it emits the accept-mode counterpart,CAwhere it would answerAA,CEwhereAE,CRwhereAR, 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:MllpApplicationAckErrorandMllpCommitRejectedError. New surfaces: the client's'warning'event also carriesAckModeWarning, the server gains an'ackModeWarning'event, andNackEvent.ackCodenow reports the code that actually went on the wire, so it can beCEorCR.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-scannow refuses a run (exit 2) that withdrew a target it had enumerated, instead of reporting on the targets that were left.--allow-fixture <path>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 printedOK, no hitsand 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-fixturematched no enumerated target, is unchanged: the tolerated mid-sweep vanish, the per-walk-root observed-nothing refusal,--stagedand the index corpus all behave exactly as before. To keep a synthetic fixture passing, declare its identifiers inscripts/phi-allow-list.txt, which leaves the file scanned.Also in this release: the advisory override for
js-yamlwidens to>=4.0.0 <4.3.0and resolves at4.3.0, matching the extended advisory range, and apnpm-workspace.yamldeclares a 24 hour publication cooldown (minimumReleaseAge: 1440) and ano-downgradetrust 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-yamlis reached only through development tooling, and nothing here alters what is published indist.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
letinside theonFramecallback and then called.equalson it. It now collects payloads into aBuffer[]and claims that exactly one came out. The suite compiles both first examples with the settingstsc --initwrites 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.
Statusnow 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 safetyandContributing.Why this existsstates the problem and the nearest alternative rather than restating the feature list.Installgives the Node floor, the package manager and the dual ESM plus CJS format in one copy-pasteable block.Contributingsays 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 safetyis 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: theMSHheader an acknowledgement is built from, and theMSAof 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: theack-from-hl7subpath 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-2echoing the control id that was sent, and a new framing example shows the bytesencodeFrameemits and a chunkedFrameReaderdelivering 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/testingis one of three published subpaths andrunDifferentialis 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 coversInMemoryTransport.pair(), the four conditions a pair can simulate (chunked reads throughsplit(n), backpressure throughpause()/resume(), a one-sided reset throughdestroy(reason), a graceful close throughclose()), driving a wholeConnectionover 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'sformatandformat:checkscripts 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./<id>.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.
allmode walkedtest/andsrc/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 hitsand 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
6eb1615and 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
*.mdnow 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 onCHANGELOG.mdmakespnpm phi-scanexit 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.hl7or.binname, so the same bytes exit 1 atexamples/data/capture.hl7and 0 atexamples/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/hl7dev/test peer now installs from the npm registry instead of avendored packed tarball, and
vendor/is deleted.The tarball held
@cosyte/hl7at0.0.0and never moved while that packageshipped 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-10ofID\Xechoed back asID\E\X, which is adifferent 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/hl7remains an optionalpeer dependency referenced only from the
@cosyte/mllp/ack-from-hl7subpath,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.0rather than0.0.12. This one adds public API and a publishedconformance declaration, and a patch number would have told you nothing was added. Nothing exported
by
0.0.11is removed or renamed in it, on any of the three published subpaths.Contributor guidance for picking a bump type now describes the
0.1.xladder instead of the retiredpre-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.