Function: buildRawAck()
buildRawAck(
payload,code):Buffer
Build a minimal original-mode HL7 v2 acknowledgement from raw inbound payload bytes,
without a parser (parser-driven ACKs are the @cosyte/mllp/ack-from-hl7 subpath).
Locates the MSH segment (the first CR/LF-delimited segment starting with MSH,
so a leading CR or an FHS/BHS batch header does not hide it), reads the field
separator from MSH-1 and the encoding characters from MSH-2, then splits that
segment on that separator to read fields. The ACK swaps sender/receiver per HL7 ACK
rules, echoes the inbound MSH-10 into MSA-2 (§2.9.2.2), sets MSA-1 to code, and, for
negative codes, adds a static, PHI-free MSA-3 reason. A fresh control ID fills the
ACK's own MSH-10.
Why the delimiters are read, not assumed
MSH-1 is the field separator (HL7 v2.5.1 §2.5.4): the byte at offset 3 of the MSH
segment defines it for the whole message. | is overwhelmingly common but it is a
convention, not the spec. Assuming it was wrong in two compounding ways:
- Reading. Splitting a
!-delimited message on|yields ONE field, so every echoed field, including MSH-10, came back empty. The ACK went out asMSA|AA|with no correlation id at all: the sender cannot match it, times out, and resends → a duplicate clinical message. That is the exact failure this package's correlator exists to prevent, manufactured by the ACK builder itself. - Writing. The echoed MSH-3..6 and MSH-10 field content is copied verbatim
from the inbound, still escaped against the inbound's encoding characters. Re-
emitting that content under a different delimiter set silently reinterprets it:
an inbound whose component separator is
#and whose MSH-10 isID#Xwould be re-emitted under^~\&as the literalID#X, which the sender then reads as a single component rather than two. Echoing MSH-1 and MSH-2 keeps the content and the delimiters that define it together.
It reads the MSH through the SHARED scanner
The MSH read is readMshSegment from src/internal/control-id.ts, the same call the
client's correlator makes to derive the key it will later match this ACK against. That
is deliberate and it is the whole point: this builder used to re-derive the read itself
(payload.toString("latin1").split("\r"), hunting for an MSH anywhere in the
payload), and the two disagreed on real inputs. On a truncated MSH followed by a
PID the correlator keyed on the PID's MRN while this builder echoed an empty MSA-2. On
a payload with a leading CR, which the MLLP decoder passes straight through, and
which real senders emit, this builder found the MSH and echoed MSH-10 correctly while
the correlator, requiring MSH at byte 0, gave up. Every such disagreement is an ACK the
sender cannot match → timeout → resend → duplicate clinical message.
The fix is one scan, but note which scan. The first attempt made them agree by
requiring MSH at byte 0 everywhere, which "resolved" the leading-CR disagreement by
degrading the side that had been right: buildRawAck began emitting a positive AA
with an empty MSA-2, silently, for a message whose MSH-10 was plainly present. Agreement
is not the goal; agreeing on the correct, tolerant answer is. A lenient reader may
never drop data that is there (Postel's Law, CLAUDE.md).
The fail-safe downgrade: never a positive AA/CA it cannot correlate
A positive acknowledgement is a promise the sender may forget the message. If it names a
control ID the sender cannot match, or names one message out of several it never read,
the sender times out and resends, committing a duplicate clinical message. So a
requested positive code is downgraded to its non-positive counterpart (AA→AE,
CA→CE) whenever the payload cannot carry a correlatable positive ACK: no readable
MSH, an empty MSH-10, or a batch/concatenated-message shape that a single MSA-2 cannot
acknowledge. See rawAckUncorrelatable for the exact conditions and why each is a
refusal rather than a widened reader. A requested negative code (AE/AR/CE/CR)
is never touched. This mirrors the parser-backed buildMllpAck, which downgrades and
warns on an unparseable inbound, the two builders' fail-safe semantics now agree.
Never throws and never copies payload content beyond the routing/control
metadata above, readMshSegment stops at the MSH's segment terminator, so no field of
any later segment (PID and friends) can be reached, let alone echoed. On a missing or
unreadable MSH it returns a minimal well-formed ACK carrying the (downgraded) code so
the caller can still respond.
Parameters
payload
Buffer
Raw decoded HL7 v2 payload bytes (MLLP framing already stripped).
code
Requested MSA-1 acknowledgement code. A positive AA/CA is downgraded to
AE/CE when the message cannot be correlated (see above).
Returns
Buffer
ACK payload bytes (no framing, the caller wraps with encodeFrame).
Example
import { buildRawAck } from '@cosyte/mllp';
const ack = buildRawAck(inboundPayload, 'AE'); // MSA|AE|<inbound MSH-10>|message could not be processed