Skip to main content
Version: v0.1.0

Spec notes: message-type & structure awareness

@cosyte/hl7 ships a misroute / truncation safety net: for the message types it covers it knows which segments HL7's own published message-structure definitions give a minimum of one, and tells you when a message does not carry one of them. msg.structure is the read-side view; the parser also emits a single additive Tier-2 MISSING_EXPECTED_GROUP warning per absent segment, naming that segment.

This is not a conformance validator. It checks segment presence, not ordering, cardinality or content, and it covers twelve message codes rather than the whole standard. It never throws and never rewrites the message; strict mode may promote the warning per the usual Postel's-Law model.

If you want the ordering and cardinality question answered, ask for it: validateMessageStructure is an opt-in check over the same publication, described in its own section further down this page. It changes nothing about a parse that does not call it.

Where the expectations come from​

Every expectation is derived, not transcribed. The package vendors a byte-for-byte snapshot of HL7's machine-readable message structures and derives the registry from it offline; there is no leniency list, no suppression list and no network call at build time or at run time.

The provenance ships with the code, so a warning can be audited without leaving the package:

import { STRUCTURE_REGISTRY_PROVENANCE, findMessageStructureDefinition } from "@cosyte/hl7";

STRUCTURE_REGISTRY_PROVENANCE.publication.repository; // => "HL7/v2ig"

// Which published structure is behind an ADT^A01 expectation, and which
// variants of it were read?
const def = findMessageStructureDefinition("ADT", "A01");
def?.structureId; // => "ADT_A01"
def?.structureIds.length; // => 4
def?.requiredSegments.join(","); // => "EVN,MSH,PID,PV1"

STRUCTURE_REGISTRY_PROVENANCE carries the publication and the exact commit it was read at, the byte count, sha256 and upstream URL of all 83 vendored files, the variant family behind each referenced structure, and the structure id behind each recognized (message code, trigger event) pair.

The three rules the derivation follows​

  1. Effective minimum from the root. A segment is expected only when its minimum is at least one along the whole path from the structure root. A segment the publication marks required inside a group it marks optional is therefore not expected, which is why a conformant ORU^R01 with no OBX and no top-level PID stays warning-free.
  2. A variant family must agree. The publication splits some structures into single-uppercase-letter variants (ADT_A01-A through ADT_A01-D) while its message list references the unsuffixed name (ADT_A01). The family of a referenced id is that id plus any id formed by appending a hyphen and exactly one uppercase letter, and a segment is expected only when every member of the family gives it a minimum of one. Where variants disagree the publication has not settled the question, and a warning heuristic takes the silent side.
  3. Unrecognized is silent. A (message code, trigger event) pair with no registry entry yields recognized: false and emits nothing.

What ships​

msg.structure.requiredSegments lists the expectations for the matched type and msg.structure.missingSegments lists the ones the message does not carry. missingGroups is the same list under its older name.

MessageTrigger eventsExpected segmentsPublished structure
ACK(message code alone)MSA, MSHACK
ADTA01, A04, A08, A13EVN, MSH, PID, PV1ADT_A01
ADTA02EVN, MSH, PID, PV1ADT_A02
ADTA03EVN, MSH, PID, PV1ADT_A03
ADTA05, A14, A28, A31EVN, MSH, PID, PV1ADT_A05
ADTA06, A07EVN, MSH, PID, PV1ADT_A06
ADTA09, A10, A11EVN, MSH, PID, PV1ADT_A09
ADTA12EVN, MSH, PID, PV1ADT_A12
ADTA15EVN, MSH, PID, PRT, PV1ADT_A15
ADTA16EVN, MSH, PID, PV1ADT_A16
ADTA17EVN, MSH, PID, PV1ADT_A17
ADTA20EVN, MSH, NPUADT_A20
ADTA21, A22, A23, A25, A26, A27, A29, A32, A33EVN, MSH, PID, PV1ADT_A21
ADTA24EVN, MSH, PIDADT_A24
ADTA37EVN, MSH, PIDADT_A37
ADTA38EVN, MSH, PID, PV1ADT_A38
ADTA40, A41, A42EVN, MRG, MSH, PIDADT_A39
ADTA43, A49EVN, MRG, MSH, PIDADT_A43
ADTA44, A47EVN, MRG, MSH, PIDADT_A44
ADTA45EVN, MRG, MSH, PID, PV1ADT_A45
ADTA50, A51EVN, MRG, MSH, PID, PV1ADT_A50
ADTA52, A53EVN, MSH, PID, PV1ADT_A52
ADTA54, A55EVN, MSH, PID, PV1ADT_A54
ADTA60EVN, MSH, PIDADT_A60
ADTA61, A62EVN, MSH, PID, PV1ADT_A61
DFTP03EVN, FT1, MSH, PIDDFT_P03
DFTP11EVN, FT1, MSH, PIDDFT_P11
MDMT01, T03, T05, T07, T09, T11EVN, MSH, PID, PV1, TXAMDM_T01
MDMT02, T04, T06, T08, T10EVN, MSH, OBX, PID, PV1, TXAMDM_T02
OMGO19MSH, OBR, ORCOMG_O19
OMIO23IPC, MSH, OBR, ORCOMI_O23
OMLO21MSH, ORCOML_O21
OMLO33MSH, ORC, SPMOML_O33
OMLO35MSH, ORC, SAC, SPMOML_O35
OMLO39MSH, ORCOML_O39
OMLO59_AMSH, ORCOML_O59
OMPO09MSH, ORC, RXO, RXROMP_O09
ORMO01ORC(retained transcription)
ORUR01, R40, R42, R43MSH, OBRORU_R01
ORUR30, R31, R32MSH, OBR, OBX, ORC, PIDORU_R30
SIUS12 to S24, S26, S27MSH, RGS, SCHSIU_S12
VXUV04MSH, PIDVXU_V04

MSH appears throughout because the publication requires it. It can never produce this warning in practice: a message with no MSH fails earlier, with the Tier-3 NO_MSH_SEGMENT error.

The opt-in published-structure validator​

validateMessageStructure(msg) answers the question the presence net does not: does this message follow the shape the publication gives its trigger event, in the published order, with each segment appearing as many times as the publication allows? You ask for it explicitly; nothing calls it for you.

It reports three things, each under its own stable code:

CodeWhat it means
STRUCTURE_SEGMENT_OUT_OF_ORDERThe message's segment sequence stops being a beginning the published order allows, reading that order with every published bound honoured.
STRUCTURE_SEGMENT_CARDINALITYThe segment occurs fewer times than the published minimum along the path from the structure root, or more times than the published maximum.
STRUCTURE_SEGMENT_UNEXPECTEDThe published structure does not name this segment anywhere, a Z segment included. It carries severity: "warning" rather than "error", because HL7 reserves Z segments for exactly this.

A finding carries the code, a severity, a PHI-free locus (segment name, 0-indexed occurrence, published structure id) and a human-readable message. No field, component or subcomponent value is ever read into one.

How the order check reads the publication. It reads it as written: every node may occur as few and as many times as the publication says at that locus. A group it lets occur more than once may repeat, which is what lets a conformant ORU^R01 carry OBR OBX OBR OBX; a group bounded at one occurrence may not be re-entered, so a VXU^V04 carrying PV2 before PV1 inside a PATIENT_VISIT group the publication bounds at [0..1] is reported; and no occurrence of a group may begin without the child the publication requires first, so an ADT^A01 carrying IN2 before its IN1 is reported too, though INSURANCE repeats and both counts are inside their bounds.

One bound is dropped, and only where the count check has already reported it. The three questions are kept apart so that one defect is reported as one thing. Reading the order with every bound honoured would make an absent required segment stop the sequence dead, and everything after it would read as misplaced on top of the missing-segment finding. So for a segment name the cardinality check reports, that bound alone is dropped from the order reading: too few occurrences drops its minimum, too many drops its maximum. Every other bound still stands, including every bound on every other segment, so a transposition further down the same message is still reported.

Which occurrence the finding names: the one that arrived late. Two segments in an order the publication does not allow give two candidates, the one that came early and the one that came late, and the finding names the late one: the segment the publication puts first and the message delivered second. So a PV2 before its PV1 is reported against PV1, and a GSP before its PD1 against PD1.

Where the reading stops is deliberately not what gets named. The order reading stops at the first segment it cannot place, and where the publication is free to skip past the segment the message delayed, that is somewhere else entirely: an ADT^A01 carrying GSP before PD1 reads cleanly as far as the PD1, because skipping the optional PD1 is how the GSP was read, so it stops on the PD1 and every segment after the stop is in its published place. Naming the stop would name a healthy segment about as often as the defect. So the question is asked directly instead: is there an adjacent pair the message delivered in one order and the publication takes in the other. Where an exchange leaves a sequence the publication derives, that pair is the defect and its second member is the finding's locus. Where more than one pair would do, the earliest is taken, because a message is read from the front.

Every adjacent pair is asked, rather than a shortlist near the point the reading stopped, and in a message that repeats a group the two are often nowhere near each other. An OML^O21 carrying three orders, with the first order's note delivered after the second order's ORC, reads on for six more segments before the publication runs out of places to put what arrives: the second order absorbs the note, and it is the third order's controller that finally has nowhere to go. Asking every pair is affordable because the answer for all of them comes out of one pass backwards over the message and one forwards, rather than one pass per pair, so the cost is a fixed handful of readings however long the message is.

Where no exchange derives, the defect is something other than two segments in the wrong order and there is no second member to name. The finding then names the segment that belonged where the reading stopped, if the message carries one later, and failing that the segment that could not be placed.

The one silence left. The order check asks whether the message so far is a beginning the publication allows. It does not ask whether the message stops in the middle of one. A message that ends inside a group occurrence, with a required child of that occurrence that never arrives, is left to the cardinality check, and that check reads minimums from the structure root, where a segment inside an optional group is not required. So a message that ends part way through an optional group's occurrence draws no finding. That is a missing segment rather than a misplaced one, and it is the end of the message alone: the same gap with any segment after it is reported, because that next segment cannot be placed.

A line carrying no segment name at all is not validated. A message that ends with a segment terminator parses as a trailing segment whose type is the empty string; the publication names segments rather than blank lines, so that line draws no structural finding. The parse still reports it, under UNKNOWN_SEGMENT.

import { parseHL7, validateMessageStructure } from "@cosyte/hl7";

// A synthetic ADT^A01 whose PV1 arrives before its PID.
const raw = [
"MSH|^~\\&|SEND|FAC|RECV|FAC|20260419101500||ADT^A01|EX00001|P|2.5.1",
"EVN|A01|20260419101500",
"PV1|1|I",
"PID|1||",
].join("\r");

const result = validateMessageStructure(parseHL7(raw));

result.validated; // => true
result.structureId; // => "ADT_A01-A"
result.findings.length; // => 1
result.findings[0]?.code; // => "STRUCTURE_SEGMENT_OUT_OF_ORDER"
result.findings[0]?.locus.segment; // => "PID"
result.findings[0]?.locus.occurrence; // => 0

Zero findings is not an attestation​

An empty findings list means this message did not break the published structure in the three ways above. It is not a statement that the message is conformant, and it is not a certificate anybody issued:

  • Field content, components, datatypes, lengths, value sets and HL7 tables are all unchecked here, and exhaustive coded-value validation is permanently out of scope for this package.
  • Coverage is twelve message codes, not the standard.
  • The publication behind it is vendored at a fixed commit, and its publisher states that the guide is in development and not yet production ready.
  • Where the publication splits a structure into variants, conforming to one variant is conforming to the family, so findings come back only when every variant is violated; structureId names the one variant the returned findings belong to and structureIds lists the family that was considered.

Read validated before you read findings​

validated: false means the publication cannot answer for this message, which is a different answer from "no findings". reason says which case it is, and reasonMessage says it in prose:

reasonWhen
NO_MESSAGE_TYPEMSH-9.1 is absent or blank.
UNRECOGNIZED_MESSAGE_TYPEThe registry models no published structure for this message type.
RETAINED_TRANSCRIPTIONThe type is recognized only through the one retained transcription (ORM^O01), which has no published order or maximum behind it.
EMPTY_EXPECTATIONThe published structure resolves to no ordered expectation at all. A structure that says nothing is not a structure that forbids everything.
import { parseHL7, validateMessageStructure } from "@cosyte/hl7";

const raw = "MSH|^~\\&|SEND|FAC|RECV|FAC|20260419101500||QRY^A19|EX00002|P|2.5.1\rQRD|20260419101500";
const result = validateMessageStructure(parseHL7(raw));

result.validated; // => false
result.reason; // => "UNRECOGNIZED_MESSAGE_TYPE"
result.findings.length; // => 0
result.structureId; // => ""

How it differs from the other two structural surfaces​

What it checksWho wrote the rulesWhen it runs
msg.structureWhether a segment the publication gives a minimum of one is present at allHL7's publicationEvery parse, and it is what emits MISSING_EXPECTED_GROUP
validateMessageStructureSegment order, occurrence counts and unnamed segments against the published structureHL7's publicationOnly when you call it
validateAgainstProfileUsage, cardinality, length, value sets and coding systems, down to the componentYou, in a profile you authorOnly when you call it

The two opt-in checks are complementary rather than alternatives: the published structure knows nothing about your interface spec, and your interface spec usually does not restate the publication's own message shape.

Nothing changes for a caller who does not ask​

No new warning code exists, msg.warnings is unchanged, msg.structure is unchanged, and validation is read-only: the message's segments, warnings and serialization are byte-identical afterwards. That separation is deliberate. The volume section below records what happened the last time structural expectations widened underneath a live channel, and folding these findings into the default parse would do it again to every channel that treats a warning as a rejection.

What changed, difference by difference​

The registry used to be a hand transcription of the v2.5.1 chapters covering twelve message codes, eight ADT trigger events, and a hand-picked "anchor" segment per group. Every difference between that table and the derived registry is listed below, with the segment, the published minimum along the path from the structure root, and an adjudication.

Nothing here is a bug fix to your messages. These are expectations that widened, so a feed that already emitted non-conformant traffic will see warnings it did not see before. Read the "before you upgrade" section at the end.

Expectations added to a pair that was already recognized​

PairSegmentPublished minimumAdjudication
ADT^A01, A02, A03, A04, A05, A08, A11, A13EVN1Intended correction. The previous table excluded EVN deliberately, reasoning that "real senders omit it freely". That is a statement about senders, not about the standard, and it is exactly what stopped the library telling you an ADT^A01 was missing a segment the standard marks required. All four published ADT_A01 variants give EVN a minimum of one.
every recognized pairMSH1Intended correction. Required in every published structure. Unreachable in practice: an MSH-less message is a Tier-3 parse error before this check runs.
ORU^R01OBR1Intended correction. OBR sits inside ORDER_OBSERVATION, which the publication gives a minimum of one inside PATIENT_RESULT, which it also gives a minimum of one. The previous table accepted OBR or OBX, so a report carrying only OBX was silent; it now warns.
OMG^O19OBR1Intended correction. The previous table excluded OBR from every order message on the reasoning that it is optional in some of them. It is optional in OML_O21, OML_O39 and OML_O59, and required in OMG_O19 and OMI_O23. Deriving per structure resolves what one hand-picked rule could not.
OMI^O23OBR, IPC1Intended correction. Same reasoning as OMG^O19; IPC (imaging procedure control) is required in the published imaging order.
OMP^O09RXO, RXR1Intended correction. The published pharmacy order requires the order and route segments.
SIU^S12 through S24, S26RGS1Intended correction. The published scheduling structure requires at least one resource group.
MDM^T02, T06EVN, PV1, OBX1Intended correction. All three sit at a minimum of one along the whole path from the root in all five published MDM_T02 variants, which is the structure both trigger events reference: EVN and PV1 directly at the top level, OBX inside a document content group that is itself required. EVN was absent from the old table for every message code, not just for ADT, on the same reasoning that "real senders omit it freely"; a document notification is no more exempt from the published minimum than an admit is. Note that this repo's own document fixtures carry EVN but no PV1, and so now warn on PV1: a true positive against the publication, and the clearest single illustration of the volume change.
DFT^P03EVN1Intended correction.
ACKMSH1Intended correction.

Expectations removed or narrowed​

PairSegmentPublished minimumAdjudication
ORU^R01OBX0Intended correction. Every OBX in the published ORU_R01 sits inside a group with a minimum of zero, so a conformant report can carry none. It used to be accepted as a substitute for OBR; it is now neither expected nor a substitute.
ORU^R01NTE0 (family disagreement)Intended correction. Variants ORU_R01-C and ORU_R01-D give the order-observation NTE a minimum of one; ORU_R01-A and ORU_R01-B give it zero. Under the family rule the segment is not expected.
ADT^A20PID, PV10Intended correction. A bed-status update carries no patient: the published ADT_A20 requires NPU and neither PID nor PV1. The previous table applied one ADT shape to all eight of its trigger events, which is the failure mode that motivated deriving per structure.
ADT^A24, A37, A60PV10Intended correction. These link, unlink and adverse-reaction events carry no visit in the publication.
SIU, VXU, ORU^R01PID0Unchanged, and re-derived rather than assumed. The previous table excluded PID from these on a documented judgement; the publication agrees.

Trigger events that became recognized​

Nothing lost recognition. The registry recognized 32 explicit (message code, trigger event) pairs plus ACK on the message code alone; it now recognizes 94 plus ACK on the message code alone.

MessageNewly recognized trigger events
ADTA06, A07, A09, A10, A12, A14, A15, A16, A17, A20, A21, A22, A23, A24, A25, A26, A27, A28, A29, A31, A32, A33, A37, A38, A40, A41, A42, A43, A44, A45, A47, A49, A50, A51, A52, A53, A54, A55, A60, A61, A62
ORUR30, R31, R32, R40, R42, R43
OMLO33, O35, O39, O59_A
MDMT01, T03, T04, T05, T07, T08, T09, T10, T11
DFTP11
SIUS27

One retained transcription​

ORM^O01 is the only entry that is not derived, and it is marked as such in the registry:

import { findMessageStructureDefinition } from "@cosyte/hl7";

const orm = findMessageStructureDefinition("ORM", "O01");
orm?.derivation; // => "retained-transcription"
orm?.structureId; // => ""
orm?.requiredSegments.join(","); // => "ORC"

The publication carries no ORM_O01 at all: it is absent from the 305-entry message-structure manifest and its raw path answers 404. ORM^O01 is the commonest legacy order message and the registry recognized it before, so dropping it would have been a silent loss of coverage. Its expectation is unchanged from the transcription (ORC), and retainedReason on the entry carries this paragraph's reason in one sentence.

Pairs the publication leaves unresolved​

Eight ADT merge and change-identifier messages appear in the publication's message list with no structure named: A18, A30, A34, A35, A36, A39, A46 and A48. They are recorded as unresolved by the generator, and the parser stays silent on them rather than guessing a shape. None of them was recognized before, so nothing regressed.

Before you upgrade: the volume change​

Three things changed at once, and all three point the same way:

  1. More trigger events are recognized, so messages that used to be recognized: false and silent are now checked.
  2. More segments are expected per pair, most visibly EVN across the whole ADT family and in MDM as well, plus PV1 in MDM. EVN was excluded from the old table for every message code, so the widening follows it everywhere the publication requires it, MDM and DFT^P03 included.
  3. The warning names a segment rather than a group label, so missingGroups now holds values like "EVN" where it used to hold "patient".

The net effect is that warning volume rises on non-conformant traffic. If your channel treats a MISSING_EXPECTED_GROUP warning as a rejection, sample your traffic before upgrading: an interface that quietly omits EVN has been accepted until now and will start being rejected. Nothing else changed. The warning is still Tier-2 and additive, lenient parse still never throws on it, the message is never rewritten, and an unrecognized type is still silent.

PHI​

None. The warning message carries only structural identifiers: the message type, the segment name and the published structure id, never a field value. The message type is shape-checked before it is interpolated and withheld wholesale if it does not match, rather than truncated.

Known limitations​

  • Coverage is twelve message codes. Widening it means adding structures to the vendored snapshot and re-running the generator.
  • The parse-time net is presence, not cardinality or order. A message carrying one malformed OBR satisfies an OBR expectation. Order and cardinality are the opt-in validator's question, and it too stops at the segment: neither surface reads a field.
  • The publication is a moving target. It is vendored at a fixed commit and its publisher states that the guide is in development and not yet production ready. The expectations here are that publication's, and where they differ from a given v2 release the difference is not adjudicated.
  • A family disagreement always resolves to silence. Where variants disagree the segment is not expected, so the net is quieter than the strictest variant would be.
  • OML^O59_A and other underscore-bearing trigger events are recognized, but the message type is withheld from the warning text because it does not match the identifier shape the warning layer permits. The segment name and the structure id are still named.

References​

  • HL7's published, machine-readable v2+ message structures, vendored under vendor/hl7-v2ig/ with per-file hashes and upstream URLs.
  • STRUCTURE_REGISTRY_PROVENANCE, the same record exported from the package.