A 1979 standard, fixed at 106 bytes, meets senders who strip it, prepend to it, and re‑encode it. The byte offsets don't survive that. Neither does the regex you'd reach for next. Here is what does — and how it names every deviation it steps over.
An ANSI X12 interchange is a stack of envelopes: ISA wraps one
or more GS functional groups, each wrapping ST
transaction sets, closed in reverse by SE, GE,
IEA. The very first segment, ISA, is the one every
parser has to read before it can read anything else: it is a fixed
105‑byte record whose byte positions declare the delimiters for
the entire rest of the file — element separator at byte 3, component
separator at byte 104, segment terminator at byte 105.
The standard is unusually strict about this. Every element in the ISA is a
fixed width. The segment is exactly 105 bytes plus its terminator. The
functional‑group header that follows, GS + the element
separator, therefore begins at byte 106 — always, by rule.
So the naive reader is one line: take data[106:109], check it
spells GS*, and slice the ISA at fixed offsets. It works on
conformant files. It fails on a large fraction of real ones, because the
audience for this tool is a developer holding a file their trading partner
sent that their parser just rejected, and they need to know
precisely what is wrong with it so they can push back or handle it.
That reframes the job: parse permissively — locate the envelope even
when it is malformed — then emit a diagnostic for every deviation rather
than failing on the first.
GS*
at byte 106. x12-tidy returns the run from ISA up to
(not including) that GS — terminator and any trailing bytes included;
splitting the run into elements comes later.The fixed‑offset reader from §1 fails on real files. The next instinct — and it is the right instinct to have — is a pattern: match the identifier, capture whatever byte is acting as the separator, hop over sixteen fields, then find the header.
the pattern you would reach for
rb"ISA(.)(?:.*?\1){15}.*?GS\1"
# ^ capture the separator — (.) then \1 matches any byte literally,
# metacharacters included, so it need not be hard-coded
It compiles. It even matches a clean file. It is still the wrong tool — and every reason below traces back to one thing: a regex can tell you whether something matched, never why it didn't. This tool's whole job is the why. Four consequences follow, and the first is fatal on its own:
\r\n, forty
newlines were appended, the file opened with a BOM, there were seventeen
separators because the sender's ID contains a *. A regex
returns Match or None — a non‑match gives
you no position and no cause. You would end up writing one pattern per
deviation and branching on which matched: a hand‑rolled parser, with
worse ergonomics than a real one.(?:(?!\1).)*\1 for "one
field, then the separator," sixteen times — and the capture handles any
separator byte without hard‑coding it. But that assumes every
separator is a field boundary, and in the case x12-tidy exists for, one
isn't: a sender whose ID holds the separator byte produces a segment that
is genuinely ambiguous — sixteen fields or seventeen, indistinguishable
from the bytes alone. A regex resolves that silently, matching some
boundary; the trailing GS\1 then anchors early, or the match
fails, with no signal either way. x12-tidy does the opposite: it counts
the separators, sees seventeen, and reports it
(isa.separator-count-high), naming both possible causes. And
GS\1 matches a GS + separator anywhere —
inside ISA06 or ISA08 data as readily as in a transaction set. A match
gives you a boundary; it never gives you "this boundary is suspect, and
here is why."ISA turn up in junk, the logic is: try this offset; if
the run is not a valid ISA line, anchor on the next ISA; if
none work, report the first candidate's failure. That is a
stateful search where every failure is inspected. A regex backtracks
inside the engine and hands you nothing to look at..*? or (?:(?!\1).)* — is the one that
explodes. And the O(1) shortcut — is GS already at
byte 106? — has no regex equivalent; the engine scans from the start
every time.The function that replaces the pattern is about forty lines. It never backtracks, it takes the O(1) shortcut when the file is clean, and every branch that tolerates a non‑conformance carries the sentence that explains it. The rest of this note is that function.
Every one of the following has been observed in production traffic. Each one breaks a fixed‑offset reader, and several break a delimiter‑first reader too.
ISA. A UTF‑8 byte‑order
mark (EF BB BF), SMTP or HTTP headers left in by a gateway, a
one‑line "generated by…" banner. Byte 0 is no longer
I.GS.CR LF after every
segment terminator — a pretty‑printer or an FTP transfer in text
mode. GS is pushed to byte 108.\r\n, or ~\r, so the "one byte at 105" model is
off‑by‑one for the whole file.isa, or Isa —
a system that lower‑cased the payload somewhere in transit. A
case‑sensitive find(b"ISA") reports an empty file.I 00 S 00 A 00. Nothing matches
ISA; the file is transcoded to single‑byte and
re‑parsed.* while using * as the separator. The
segment now has 17 separators and no single correct parse.GS + separator lookalike. A REF*GS*…
element deep in a transaction set when the real functional‑group
envelope is missing — or a sender ID ending in GS right
inside ISA06. Either matches b"GS" + sep before, or instead
of, the real header.ISA in junk. An email subject line reading
SUBJECT: ISA FILE, a path like /feeds/ISA/….
The first match is not the segment.GS header that follows the ISA line is.I\x00S\x00A for
little-endian, \x00I\x00S\x00A for big-endian — the
big-endian marker contains the little-endian one, so it is tested first).
And the payload is provably ASCII — X12's own character set — so decoding
UTF-16 and re-encoding to single-byte loses nothing. Contrast an ambiguous
separator count: there, two parses are genuinely possible and picking one
would be a guess, so it is reported. Here there is one decoding and it is
determined, so x12-tidy performs it, raises
isa.identifier-utf16 as a warning, and parses the
result. The one caveat — the file was rewritten before parsing, so every
offset in the report indexes the transcoded bytes, not the original — is
stated in the finding.
Locating the ISA line does exactly one thing: given the raw bytes, return
the run that starts with ISA and ends immediately before
GS + the element separator. It does not validate the
delimiters, the element widths, the terminator, or the element content —
that all comes later, and none of it can run until the run has been
located.
But a run has to clear a minimum bar to be an ISA line at all. Three checks, no more:
ISA (any case).GS + the element separator.ISA then
ISA01…ISA16.Everything past the bar — is ISA05 a valid
qualifier, are the fixed widths right, is the terminator a legal byte — is
decided downstream. Locating the line hands forward a run with the right
shape; whether it also has the right meaning is someone else's job.
GS — not the other way aroundThe end of the ISA line is wherever the GS functional‑group
header begins. Byte 106 is used only as a shortcut that produces the
identical answer when the file happens to be conformant; when it doesn't,
the code needs another way to find it.
An earlier version searched the buffer for GS + the element
separator first, then checked how many separators preceded the match. That
order has a real flaw: GS + the separator can occur
inside the ISA line itself (a sender ID ending in GS)
or inside a later segment's own data (REF*GS* deep in
a transaction set, or some other segment's own field happening to end in
GS right before its own next separator) — and a plain text
search cannot tell a genuine header from either coincidence. Worse: when the
ISA is itself short a couple of elements, the search can land past the true
boundary, inside the real GS segment's own fields, and
still count out to exactly 16 separators leading up to it —
accepting a wrong parse with no diagnostic at all. See the worked example
below.
So the order is reversed. Count forward to the 16th occurrence of
the element separator first — the position the standard fixes as
immediately before ISA16 — and only then look at what
follows:
x12_tidy/envelope/isa/isa_line.py · _try_candidate, step c
# c. find where the ISA line ends == where the GS segment starts
if hay[STANDARD_GS_OFFSET:STANDARD_GS_OFFSET + 3] == needle:
gs_pos = STANDARD_GS_OFFSET # fast path: GS at the standard offset
else:
sixteenth_sep = _nth_occurrence(hay, element_separator, ISA_ELEMENT_SEPARATORS)
if sixteenth_sep == -1:
return _Attempt(None, Diagnostic(Code.ISA_SEPARATOR_COUNT_LOW, ...))
gs_pos = _gs_after_boundary(hay, sixteenth_sep + 1, needle)
if gs_pos == -1:
if element_separator.isalnum():
return _Attempt(None, Diagnostic(Code.ISA_ELEMENT_SEPARATOR_INVALID, ...))
return _Attempt(None, Diagnostic(Code.ISA_GS_NOT_FOUND, ...))
Counting first means the content of any element — before or
after the 16th separator — is never even examined as a candidate for
GS; the search for GS only ever starts once 16
real separators have already gone by.
GS must be non-alphanumericCounting to the 16th separator only fixes where to start looking;
it doesn't yet confirm GS is really there. What follows must be
ISA16 (or, tolerated, no ISA16 at all — Step 2's
job to flag), the segment terminator, and only non-alphanumeric trailing
junk (appended \r\n, a stray space) before GS +
separator turns up. The moment an ordinary letter or digit is found that
isn't itself the start of GS + separator, the search stops and
refuses — that byte is proof whatever comes later is the tail of some other
field's value, not a real token:
x12_tidy/envelope/isa/isa_line.py · _gs_after_boundary
def _gs_after_boundary(haystack: bytes, start: int, needle: bytes) -> int:
pos = start
while pos < len(haystack):
if haystack[pos:pos + len(needle)] == needle:
return pos
if haystack[pos:pos + 1].isalnum():
return -1
pos += 1
return -1
Worked example — the case that motivated the reordering. An ISA
missing two elements (13 present instead of 15), immediately followed by a
real GS*PO*SENDERGS*RECEIVERID*... segment. Counting to "the
16th separator" overshoots into GS's own first two fields,
landing partway through SENDERGS. SENDERGS* does
contain the literal bytes GS* — a plain search from there
would find it, and the separator count leading up to that match would still
read exactly 16 (14 real ones, plus GS's own first two) —
silently passing a naive "exactly 16" check with no diagnostic at all. The
alphanumeric check is what actually catches this: the byte immediately
before the GS* match is R, an ordinary letter —
not ISA16, not a terminator, not tolerated junk — so
_gs_after_boundary stops there and returns -1.
The candidate is refused (isa.gs-not-found), not silently
accepted.
isa.separator-count-low — there is no
GS to be found and the segment terminator cannot be
determined.GS +
separator before either an alphanumeric byte or the end of the buffer
→ isa.gs-not-found. This now also covers what used to be
a separate isa.separator-count-high case (a stray
GS* with no real envelope at all, deep in the transaction
body) — there simply is no real GS header anywhere, which
isa.gs-not-found names directly.Both are fatal and terminal, same as before: a run that fails either does not go to recovery.
ISA, keep the first that parsesBecause ISA can appear in leading junk, the code collects
every occurrence (capped, against a file that is mostly the bytes
ISA) and tries each. The first candidate whose run clears the
bar wins; the bytes before it become an isa.leading-bytes
warning. If none clear it, the first candidate's failure is what gets
reported.
This is single-interchange behavior, not a general "pick the right
ISA" policy. A flat file can legitimately contain more
than one ISA...IEA interchange concatenated
back to back — each self-delimited, each free to declare its own
delimiters — and every one of them is real data, not junk to discard in
favor of "the" interchange. Today's "first that parses" retry exists to
skip genuine junk (a BOM, an email header, a stray ISA
inside a filename) ahead of the one real interchange this function is
asked to locate; it is not yet the right model for a file that holds
several real interchanges in a row — that requires calling this same
logic again on whatever bytes remain after each interchange concludes,
keeping every one that's found, not just the first. That loop
is tracked separately (see auditing-the-envelope.md §7) and
not yet built.
x12_tidy/envelope/isa/isa_line.py · _isa_offsets & the candidate loop
def _isa_offsets(haystack: bytes, identifier: bytes = ISA_IDENTIFIER) -> list[int]:
offsets: list[int] = []
at = haystack.find(identifier)
while at != -1 and len(offsets) < MAX_ISA_CANDIDATES:
offsets.append(at)
at = haystack.find(identifier, at + 1)
return offsets
# _try_all: first clean run wins; else the first failure is remembered
for isa_start in offsets:
attempt = _try_candidate(dirty, isa_start, case_insensitive=case_insensitive)
if attempt.isa_line is not None:
return IsaLineResult(attempt.isa_line, isa_start,
_context_diagnostics(dirty, isa_start, ...)), None
if first_failure is None:
first_failure = (isa_start, attempt.failure)
GS, so it fails and the search moves on. When every candidate
fails, the first failure — not a guess — is reported.Matching ISA case‑insensitively means lower‑casing the
whole buffer — an allocation the size of the file. The common case (an
uppercase identifier that parses) must not pay for it. So the structure is
two‑phase: try the exact‑uppercase candidates first, touching
nothing; only when all of them fail take one lower() copy and
retry. This also rescues a valid lowercase segment sitting behind
junk that contains the literal uppercase word ISA — the earlier
design missed that, because it only fell back when no ISA
existed at all.
x12_tidy/envelope/isa/isa_line.py · extract_isa_line, the two phases
def extract_isa_line(dirty: bytes) -> IsaLineResult:
# UTF-16? Transcode to single-byte and parse that, carrying a warning.
transcoded = decode_utf16(dirty)
if transcoded is not None:
inner = extract_isa_line(transcoded)
notice = Diagnostic(Code.ISA_IDENTIFIER_UTF16, ...) # offsets index the transcoded bytes
return IsaLineResult(inner.isa_line, inner.isa_start, [notice, *inner.diagnostics])
# Fast path: exact uppercase ISA identifiers. Never copies the buffer.
upper = _isa_offsets(dirty, ISA_IDENTIFIER)
result, upper_failure = _try_all(dirty, upper, case_insensitive=False)
if result is not None:
return result
# One full-buffer lower-case copy, only on this already-failed path.
lowered = dirty.lower()
if ISA_IDENTIFIER.lower() in lowered:
ci_offsets = _isa_offsets(lowered, ISA_IDENTIFIER.lower())
result, ci_failure = _try_all(dirty, ci_offsets, case_insensitive=True)
if result is not None:
return result
...
One guard keeps the fallback honest: the string isa occurs
inside ordinary words. A lowercase candidate is only reported as a identifier if it
sits where a segment could start.
x12_tidy/envelope/isa/isa_line.py · _looks_like_segment_start
def _looks_like_segment_start(dirty: bytes, offset: int) -> bool:
"""Start of file, or right after a non-alphanumeric byte. Distinguishes a
real lowercase `isa*` from `isa` buried in a word like "advisable"."""
return offset == 0 or not dirty[offset - 1 : offset].isalnum()
re.IGNORECASE is for"
A flag makes the matching case‑insensitive; it does nothing about
the rest. A case‑insensitive regex over a 2 MB buffer still visits
every byte — no cheaper than lower(), and with no way to skip
the work when the file is already uppercase. It matches isa
inside advisable just as eagerly, with no signal to separate that
from a real lowercase identifier. And it still produces a match, not the
isa.identifier-lowercase diagnostic that tells the developer their
partner lower‑cased the payload. The flag saves the one line that was
never the problem.
Nothing above is a silent repair. Each tolerance emits a stable diagnostic
code — isa.leading-bytes, isa.identifier-lowercase,
isa.identifier-utf16, isa.separator-count-high,
isa.gs-not-found, and the rest — so the developer holding the
bad file gets the exact list of what their partner did wrong, not a single
exception at the first surprise.
The step was validated against 103 adversarial inputs across two sweeps —
NUL bytes and 0xFF as separators, a 2 MB junk prefix,
UTF‑16 LE and BE, a PDF header, 2,000 appended newlines, a file that is
nothing but the bytes ISA. Zero crashes. Every returned run was
checked against a single invariant:
tests/test_isa_line.py · _assert_contract
def _assert_contract(dirty: bytes, r: IsaLineResult) -> None:
if r.isa_line is None:
return
assert r.isa_line[:3].upper() == b"ISA"
cleansed = dirty[r.isa_start:]
assert cleansed.startswith(r.isa_line)
assert cleansed[len(r.isa_line):][:2].upper() == b"GS"
# and, from the caller: r.isa_line.count(sep) == 16
| Input | Result | Diagnostics |
|---|---|---|
| UTF‑8 BOM, then a clean interchange | 106‑byte run | isa.leading-bytes |
SUBJECT: ISA FILE\n + lowercase interchange |
run returned | isa.identifier-lowercase, isa.leading-bytes |
| ISA02 & ISA04 stripped (86‑byte segment) | 86‑byte run | none — still 16 separators |
No GS envelope; a REF*GS* deep in the data |
fatal | isa.gs-not-found |
Two interchanges concatenated, first GS missing |
2nd interchange | isa.leading-bytes |
| UTF‑16 LE encoded file | run returned (transcoded) | isa.identifier-utf16 (warning) |
Element separator * occurs inside the sender's ID |
fatal | isa.separator-count-high |
2 MB of leading junk, then ISA |
106‑byte run | isa.leading-bytes |
Locating the ISA line returns a run with the shape of an ISA
line. It does not know whether the run has the meaning of one —
whether ISA01 is a real authorization qualifier, whether the
fixed widths line up, whether the delimiters are legal bytes. That comes
next, and it is a genuine backstop.
Those Pesky Delimiters picks up
here, with the four delimiters.
The one place the seam is visible: leading junk shaped exactly
like an ISA line — the bytes ISA, sixteen element separators,
then GS + that separator, across at least 109 bytes. It clears
all three checks, so the run is returned — correctly, by this stage's own
contract — and the element‑level validation downstream is what
rejects it, because the "elements" are the wrong widths and carry nonsense.
This is the layering doing its job: shape here, meaning next.
The weaker and far more common form — junk that merely ends in
ISA* — never reaches that seam. It carries no run of sixteen
separators, so the exactly‑16 gate rejects it and the retry moves to
the real segment.