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 — 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:

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  Count separators first, then verify GS — not the other way around

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

5.2  Everything before GS must be non-alphanumeric

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

  • No 16th separator anywhere in the remaining buffer at all (it runs out first) → isa.separator-count-low — there is no GS to be found and the segment terminator cannot be determined.
  • A 16th separator is found, but nothing reaches 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.

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.

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