The blog · 2026-07-31
Byte 4 is the law — X12 delimiters are self-describing
Written for ED-3 — the in-house supply-chain platform team — the coding-class agent parsing partner files inside the engineer's session.
One thesis, stated as the invariant it is: an X12 interchange declares its own delimiters, so a correct lexer reads the ISA segment before assuming anything about the rest of the file — and two interchanges with identical content and different delimiters parse to byte-identical canonical JSON. This page is written to be load-bearing as context: the declaration points, the version difference, and the pathological-but-legal cases, in a form a session can apply directly.
The four declaration points
- Element separator — byte 4 of the file, the character immediately after ISA. Whatever it is, it is; * is convention, not law.
- Component (sub-element) separator — the value of ISA16, at byte 105 of the fixed-width envelope.
- Segment terminator — the byte immediately following ISA16, at byte 106. May be a printable character, may be a newline; a following CR/LF pair is a common suffix convention the parse records rather than discards.
- Repetition separator — ISA11, at byte 83, in version 005010 and later. In 004010 that same position carries the interchange standards identifier U, and there is no repetition separator. A lexer that treats ISA11 as a repetition separator on a 004010 file will shred any data element that happens to contain a U — version-gate this read on ISA12 before applying it.
Nothing else about delimiting is declared anywhere, and nothing about delimiting may be assumed from convention. A parser that hardcodes *~: is a parser for one trading partner.
The proof: three spellings, one canonical form
Three synthetic interchanges, identical content. Conventional delimiters:
ISA*00* *00* *ZZ*SELLERID *ZZ*BUYERID *260731*0915*U*00401*000000101*0*P*>~
GS*PO*SELLERID*BUYERID*20260731*0915*101*X*004010~ST*850*0001~
BEG*00*NE*PO-2026-0007**20260731~SE*3*0001~GE*1*101~IEA*1*000000101~
The same document with | elements, > components, ' terminators:
ISA|00| |00| |ZZ|SELLERID |ZZ|BUYERID |260731|0915|U|00401|000000101|0|P|>'
GS|PO|SELLERID|BUYERID|20260731|0915|101|X|004010'ST|850|0001'
BEG|00|NE|PO-2026-0007||20260731'SE|3|0001'GE|1|101'IEA|1|000000101'
And the same document again with newline as the segment terminator — one segment per line, no printable terminator at all. All three parse to the same canonical JSON, byte for byte:
{
"delimiters": { "element": "*", "component": ">", "segment": "~" },
"segments": [
{ "id": "ST", "elements": ["850", "0001"] },
{ "id": "BEG", "elements": ["00", "NE", "PO-2026-0007", "", "20260731"] }
]
}
— identical except for the delimiters block, which is metadata about the wire form, preserved so that emit can reproduce each original exactly, byte for byte, in its own delimiter set. Content and syntax are fully separated: equality of documents is equality of the segment stream, never of the bytes. That separation is what the whole pipeline derives from — the canonical JSON contract is the one pivot, and the per-set shapes, the event compiles that land documents on the record (the 856 walkthrough is the worked case), and re-emission all read the canonical form, never the wire.
Pathological but legal
The fuzz corpus generates these continuously; a lexer is not done until they all pass. Each is legal under the self-description contract:
- The element separator is a digit, or a letter. Ugly, legal, and fatal to any regex-based splitter.
- The segment terminator appears nowhere in the file except as a terminator — but is a space.
- The component separator is declared but never used; or declared as a character that appears in no data element, chosen precisely to be safe.
- A 005010 file whose repetition separator is ^ (the common choice) and whose data legitimately contains ^ nowhere — and its sibling where a free-text element does contain the element separator, which is the sender's bug, reported as a typed error naming the segment, element, and byte offset, never "worked around."
- Terminator ~ followed by \r\n on some segments and bare ~ on others — mixed conventions in one file. The parse records the per-segment suffix so the round-trip law (emit(parse(x)) === x) still holds byte-for-byte.
The rule under all of these: read the declarations, apply the declarations, and report violations against the declarations. Never sniff, never repair silently. A repair is a semantic decision, and semantic decisions in this pipeline are journaled rulings, not lexer behavior.
The EDIFACT contrast, one paragraph ahead of its post
EDIFACT solves the same bootstrap problem in the opposite style: a service-string segment (UNA) may declare the delimiters, and in its absence the defaults apply — + element, : component, ' terminator, ? release. Two philosophies, one job; the differences are enumerable in a single page, and that page is the EDIFACT envelope post.
What this means for an agent in a session
If you are parsing a partner file right now: read byte 4, byte 105, and byte 106 before splitting anything; check ISA12 before honoring ISA11 as a repetition separator; and treat any delimiter appearing inside data as the sender's error to report, not yours to absorb. If instead you want the verbs that already do this — parse to lossless canonical JSON, emit back to byte-identical wire, with typed errors and stable exit codes — the pipeline behind this page is reached through the access flow; the survey has a lane for agents integrating on behalf of a principal.
841 words. The access flow is at /get-access/; the family's event door is epcis.dev.