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, for four reasons — and the first one 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 on a byte numberThe 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 searches for the header by content.
find is not a validator, though. GS + the
separator can occur inside the ISA line — a sender ID (ISA06) or
receiver ID (ISA08) ending in GS, followed by the element
separator, is enough. When the fast‑path offset check has already
failed and find lands on one of those, the anchor is too early.
That is caught in 5.2, not here.
x12_tidy/envelope/isa/isa_line.py · _try_candidate, steps b–c
# b. the element separator is, by rule, the 4th byte of the ISA segment
element_separator = cleansed[3:4]
gs_identifier = GS_IDENTIFIER + element_separator
# 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:
gs_pos = hay.find(needle)
if gs_pos == -1:
if element_separator.isalnum():
# byte 3 is a letter/digit -- element data, not a delimiter,
# so `GS` + that byte was never a real search token
return _Attempt(None, Diagnostic(
Code.ISA_ELEMENT_SEPARATOR_INVALID, ...,
offset=isa_start + 3,
))
return _Attempt(None, Diagnostic(
Code.ISA_GS_NOT_FOUND,
f"no {gs_identifier!r} functional-group header after the ISA "
f"segment; cannot locate the end of the ISA line.",
offset=isa_start,
))
The isalnum check earns its place. A file with its element
separators stripped out — pasted from a PDF, mangled by a mail gateway —
leaves byte 3 holding the first digit of ISA01 instead of a
*. The search token becomes GS0, which is
nowhere, and the honest diagnosis is "byte 3 is not a delimiter", not
"there is no GS header" — the GS segment is usually sitting
right there in the file. This is the one delimiter judgement Step 1
makes, and only because without it the downstream stage that would raise
isa.element-separator-invalid never runs.
Once a candidate GS is found, the bytes before it are counted.
An early version accepted >= 16. That let three false anchors
through silently: a stray GS* deep in transaction data, leading
junk that ended in ISA*, and a GS* sitting inside
the ISA line's own ISA06/ISA08 data. Requiring the count to be exact
catches all three — and the diagnostic names the structural fault, not the
count:
GS* matched inside the ISA line (5.1) cuts the
run short → fewer than 16 separators → isa.separator-count-low;GS* matched past the real header (downstream, or a
decoy ISA* prefix) overshoots → more than 16 separators →
isa.separator-count-high.x12_tidy/envelope/isa/isa_line.py · _try_candidate, step d
# d. the run must hold exactly 16 element separators
isa_line = cleansed[:gs_pos]
separator_count = isa_line.count(element_separator)
if separator_count < ISA_ELEMENT_SEPARATORS:
return _Attempt(None, Diagnostic(Code.ISA_SEPARATOR_COUNT_LOW, ...))
if separator_count > ISA_ELEMENT_SEPARATORS:
return _Attempt(None, Diagnostic(Code.ISA_NO_FUNCTIONAL_GROUP, ...))
return _Attempt(isa_line, None) # a real ISA line
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.
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.separator-count-high |
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.