Once the four delimiters are trusted, rebuilding the canonical 105‑byte ISA line is a total function: every non‑fatal input maps to exactly one canonical line, everything else to a named fatal. There is no third outcome.
This is the third act of the x12-tidy method. The first note located the run; the second recovered the four delimiters from it. If nothing fatal came back, the delimiters are now ground truth — and this note is the payoff: rebuilding the ISA line to the byte‑exact form the standard requires.
Reconstruction is a total function over the inputs the earlier steps did not reject. For any run whose delimiter parse raised no fatal:
Every non‑fatal input maps to exactly one canonical ISA line. Every other input maps to a fatal diagnostic that says why. There is no “reconstructed but maybe wrong.” No silent truncation, no partial line. That closed set is the point of this step, and §5 walks every branch of it.
An offset parser treats the 105‑byte length as structure: it reads
ISA13 at bytes 89–98 because that is where the
standard puts it. A sender who right‑trimmed a blank
ISA02 from ten spaces to nothing shifts every later field
left, and that parser is now reading ISA13 out of the middle of
ISA12 and ISA14.
x12-tidy never read a field at an offset. The
delimiter step took the line apart with a single split on
the element separator, anchored on the guaranteed separator count —
not on any byte position. So by the time reconstruction runs, the sixteen
element values are already in hand, whatever length they arrived
at.
from delimiters.py — the split happened once, there
# IsaDecomposition, from split_isa_line
decomposition.elements # (ISA01, ISA02, ..., ISA16) — raw, not width-checked
Pad what is short, and the line the sender made unreadable becomes readable again.
Each transformation is safe only because of what has already happened. Reorder any two and it breaks.
The delimiter parse split the run on the element separator to find
the delimiters, and it kept the pieces. Reconstruction consumes
decomposition.elements directly. One split, one source of truth
for where the element boundaries are.
Some senders hard‑wrap the ISA segment across lines, leaving a
\r or \n inside an element value. The ISA is
fixed‑width with no sub‑structure inside an element, so a
newline there is never data — it is wrap noise. Delete the
bytes, and the value is exactly what the sender wrote:
RECEIV\r\nER becomes RECEIVER, not
RECEIV ER. A space would be a character that was
never there, and it compounds with the width pass in §3.3.
But this is only safe here.
Earlier, a \r or \n could be the
segment terminator (\r\n, or a bare \r), or in a
pathological file a delimiter. Blank those out early and you destroy a real
delimiter. By this point the terminator has been identified and split off,
and the element separator has already done its work, so a
\r / \n still sitting inside
an element is provably neither.
Two elements are still excluded, by position, because their value is
a delimiter: ISA16 (always the component separator) and
ISA11 when it carries the repetition separator
(version 00403+). A sender is free to choose \r
for either.
from reconstruct.py
is_delimiter_element = index == 16 or (index == 11 and carries_repetition_separator)
if not is_delimiter_element and (b"\r" in value or b"\n" in value):
value = value.replace(b"\r", b"").replace(b"\n", b"") # isa.element-embedded-newline
The sixteen widths are fixed by the standard:
ISA01 2 ISA05 2 ISA09 6 ISA13 9
ISA02 10 ISA06 15 ISA10 4 ISA14 1
ISA03 2 ISA07 2 ISA11 1 ISA15 1
ISA04 10 ISA08 15 ISA12 5 ISA16 1
They sum to 86; with the ISA identifier (3) and the sixteen element
separators (16) that is the canonical 105 bytes. Per element:
isa.element-widthisa.element-widthisa.element-width is an error, not a warning
A padded value is unchanged in meaning — but until it is padded, the
ISA line is not 105 bytes, and conventional VAN services and every
fixed‑offset parser downstream cannot read the interchange at all.
That is not advisory.
ISA + the sixteen width‑correct elements, joined on
the sender's own element separator — unchanged, because any
valid non‑alphanumeric byte is conformant. The segment terminator is
likewise the sender's own byte: which character serves as a delimiter is
the sender's choice, X12 does not dictate it, so a \n
terminator stays \n and is never rewritten to ~.
It is not one of the 105 bytes — the reconstructed line carries none
— and is returned alongside for the eventual whole‑interchange
rejoin. When the sender left the terminator out entirely x12‑tidy
refuses rather than guess ~ — a wrong terminator would
break the split of every following segment. A final assertion checks the
length is 105; it cannot fail (sixteen fixed‑width elements plus the
separators sum to 105 by construction), so it is a tripwire for a future
change, not a reachable outcome.
An element longer than its fixed width with real,
non‑space data in the overflow — a 17‑character
sender ID in ISA06's 15‑byte field:
...*ACMEWIDGETSCORP01*ZZ*...
└ ISA06, 17 bytes, "01" past the width
The tool cannot know what the sender meant. Maybe an element separator
was dropped and ISA06 has swallowed part of ISA07.
Maybe the sender simply overran the field. Truncating to 15 bytes to
“fix” it would silently change an identifier; splitting it would
invent a boundary. So it does neither: isa.element-overflow,
fatal, with both possible causes named. This is
the method's refusal
rule — permissive parsing never invents.
| input | outcome |
|---|---|
| blank fixed-width fields right-trimmed | padded back — isa.element-width per field |
| element over-padded with spaces | trimmed — isa.element-width |
ISA segment hard-wrapped (\r / \n in a text element) |
line breaks deleted — isa.element-embedded-newline, then re-measured |
non-~ terminator (\n, bare \r) |
kept as‑is, no finding — which byte ends a segment is the sender's choice, not a deviation |
CR/LF after the terminator (~\r\n) |
kept in the tail, no finding — a newline suffix is lawful (X12.5 §4.3) |
other trailing bytes after the terminator (~ , a comment) |
the real terminator kept; the foreign bytes stripped — isa.trailing-junk |
| terminator omitted entirely (GS follows ISA16) | fatal isa.segment-terminator-stripped — refused rather than guess ~ |
| pipe / caret / any valid delimiters | kept as‑is; the delimiters are the sender's choice |
| conformant ISA line | returned unchanged — was_clean — the one row with nothing to repair |
| element over width with real data | fatal — isa.element-overflow |
| anything the delimiter step made fatal | propagated; no line |
No row produces a line that is not exactly 105 conformant bytes. That is what “total function” means here.
Reconstruction validates and repairs structure — widths,
delimiters, the terminator, the length. It does not judge element
values: whether ISA05 is a real interchange‑ID
qualifier, ISA09 a real date, ISA15 a legal usage
indicator, or whether ISA13 matches the IEA02 that
closes the interchange. That is the next phase — the GS/ST envelope
and control‑number checks — and it is a genuine backstop, not an
afterthought.
See the ladder: shape, then delimiters, then structure here, then values.
“Clean” is not a flag someone sets. It is a property you can test: feed a reconstructed line back through the whole pipeline and nothing this step repairs is still outstanding.
the round-trip acceptance test
for every non-terminal corpus input:
first = clean_isa_line(dirty)
reparsed = clean_isa_line(wrap(first.isa_line))
assert reparsed.isa_line == first.isa_line # a fixed point
assert reparsed.elements == first.elements
assert no isa.element-* / isa.leading-bytes / isa.trailing-*
in reparsed.diagnostics # nothing left to repair
The reconstructed line is a fixed point — cleaning
it again returns the identical bytes. Value‑level findings
(isa.version-unrecognized,
isa.isa11-not-standards-id) are out of scope for this step and
are allowed to survive a round trip; a structural one is not.
The corpus behind this is every case from the two earlier steps plus a “carriage return anywhere in the line” family, truncation, and byte‑mutation fuzz — the same partition the decompose sweep proves: clean refusal, or lossless account. Never a wrong answer.