X12 EDI  ·  Defensive parsing

Finding the Elusive ISA Line

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.

01 The problem

01  The ISA line is load‑bearing

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.

Fig. 1 — Anatomy of a conformant ISA line ISA * ISA01 * ISA02 * … * ISA15 * ISA16 fixed widths: 2·10·2·10·2·15·2·15·6·4·1·5·9·1·1·1 : ~ GS* 0 3 104 105 106 exactly 16 element separators between ISA and GS
The ISA segment is positional, not delimited-and-free: 16 fixed-width elements, a component separator, a one-byte terminator, and then 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.
02 The obvious alternative

02  Why a regex can't do it

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:

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.

03 Field conditions

03  What senders actually send

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.

Fig. 2 — Why fixed offsets break CONFORMANT ISA 105 bytes: ISA01 … ISA16 : ~ GS* byte 106 → GS ✓ AS RECEIVED BOM ISA ISA02 stripped → 96 bytes every downstream offset shifted … ISA16 : ~ GS* byte 106 → mid‑element ✗
Three prepended bytes and one omitted element are enough. The fixed‑offset reader looks at byte 106 and finds element data; it either rejects a valid interchange or, worse, mis‑reads the delimiters and corrupts everything downstream. The byte position is not an invariant. The GS header that follows the ISA line is.
Why UTF-16 is transcoded, not fatal The standard predates Unicode and is single-byte-per-character throughout — there is no legal X12 interchange that is anything else, so a UTF-16 file is not a malformed interchange, it is the right interchange in the wrong wrapper. Unwrapping it is not a guess: the byte order comes from the byte-order mark when present, otherwise from which of the two interleaved-NUL patterns is found (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.
04 Scope

04  One job: return the run

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:

Why the bar is here and not later A run that fails any of these is not an ISA line, and it is reported fatal and terminal — it does not go to recovery. More than 16 separators is unrecoverable by definition: a segment whose separator appears in its own data has no unambiguous parse. And you cannot parse delimiters out of a run that does not have 16 elements — there is nothing coherent to parse.

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.

05 The approach

05  Five techniques

5.1  Anchor on GS, not on a byte number

The 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.

5.2  Require exactly 16 separators — not "at least"

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:

  • a GS* matched inside the ISA line (5.1) cuts the run short → fewer than 16 separators → isa.separator-count-low;
  • a 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

5.3  Try every ISA, keep the first that parses

Because 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)
Fig. 3 — Multi-candidate anchoring dirty = b"SUBJECT: ISA FILE\n" + …ISA…GS… find every b"ISA" c1 · offset 9 ("ISA FILE") c2 · offset 19 (real segment) locate GS, count separators == 16 ? count = 2 no → next candidate == 16 ? count = 16 return the run + isa.leading-bytes (18 bytes skipped)
The exactly‑16 gate is what makes the retry safe: a candidate anchored in junk almost never has 16 separators followed by a GS, so it fails and the search moves on. When every candidate fails, the first failure — not a guess — is reported.

5.4  Case‑insensitive, but only after the fast path fails

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()
"But this is exactly what 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.

5.5  Name every deviation

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.

06 Validation

06  Proving it with hostile input

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
InputResultDiagnostics
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
07 The seam

07  Where content validation takes over

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.