The File That Failed With Zero Errors: A Field Guide to Invisible EDI Defects
5 min read
API integrations fail loudly. You get a 400, a stack trace, a red dashboard, usually within milliseconds of doing the wrong thing.
File-based exchange fails politely. Your export job writes an 835 or an eligibility extract, your own parser reads it back without complaint, the file goes out over SFTP, and the verdict arrives days later: a reject report from the counterparty, a quiet hole in a reconciliation, or nothing at all. The feedback loop is measured in days, and it belongs to the other side.
After fourteen years of healthcare data exchange, the defects I respect most are the ones that produce zero errors on the sending side. This is a field guide to four of those classes. Every sample is synthetic, every linter output below is a real run, and you can reproduce all of it from the edilint repo.
TL;DR
- A file can satisfy the parser that wrote it and still fail the parser that grades it. That gap is where the expensive defects live.
- The four classes I check first: lookalike characters, declared counts that disagree with contents, mixed line terminators, and fixed-width layout drift.
- None of these are visible in a text editor. All of them are mechanically detectable in milliseconds.
- The fix is structural, not heroic: a pre-send lint gate in the send script or CI job, so the class gets caught every time instead of once.
Why interchange files fail differently
Healthcare still moves on files: X12 835s and 837s for remits and claims, HL7v2 batches, pipe-delimited eligibility extracts, fixed-width layouts that predate everyone currently maintaining them.
Three properties make file exchange uniquely good at hiding defects:
- Most parsers treat element content as opaque bytes. Structure gets checked; the bytes inside an address element do not.
- The authoritative validator is the counterparty's. Your parser accepting the file proves nothing about theirs.
- The feedback loop is asynchronous. By the time the reject report lands, the batch that produced the file has run three more times.
So the useful question is not "is this file valid?" but "what would make the other side's parser disagree with mine?" That question has recurring answers.
Class 1: The character that is not the character
Here are two segments from a synthetic 835. One of them will fail a payee address match at the receiving end:
N3*400 HARBOR WAY~
N3*400 HARBОR WAY~
They render identically in nearly every font. The second one contains U+041E, the Cyrillic capital О, where the Latin O should be. Byte-wise, that element can never equal 400 HARBOR WAY again.
These characters arrive through the side doors: address data pasted from a web form somewhere upstream, a legacy source encoded in Windows-1251, a PDF-to-text step that got creative. Your parser accepts the segment because it is structurally perfect. The receiver either bounces the file at a stricter character gate with an unhelpful message, or worse, accepts it and fails the string match silently.
The check is mechanical:
$ edilint homoglyph.x12
homoglyph.x12:9:12: error: [EL1005 charset.homoglyph] U+041E looks like ASCII "O" but is not; ASCII-expecting parsers will not match this value (record 9, segment N3)
1 file checked, 1 finding (1 error, 0 warning)
The same rule family catches zero-width and bidirectional formatting characters, which occupy bytes while rendering as nothing at all. Nobody finds those in an editor. A linter finds them every time.
Class 2: Counts that lie
Interchange formats are full of self-declared totals: SE01 says how many segments the transaction set has, IEA01 counts functional groups, a delimited file's trailer record declares how many detail rows preceded it. Receivers use these as their first integrity check.
Counts go wrong in unglamorous ways. Someone hand-edits a file to remove one claim before a resend and does not recount. A template hardcodes a total. A job dies mid-write and the trailer describes the file that should have existed.
$ edilint shortcount.x12
shortcount.x12:29: error: [EL3006 envelope.segment-count] SE01 declares 26 segment(s) from ST through SE inclusive but the file contains 27 (record 29, segment SE)
1 file checked, 1 finding (1 error, 0 warning)
The same idea extends to flat files, where the trailer's declared row count and each record type's field count are both worth recounting:
$ edilint --count-rule TRL:2:DTL eligibility.psv
eligibility.psv:3: error: [EL4101 fields.count-outlier] record type "DTL" has 8 field(s) here but 9 in 2 of 3 record(s) of this type; a shifted field count moves every value after the break (record 3, type DTL)
eligibility.psv:5: error: [EL4001 counts.mismatch] count rule TRL:2:DTL: field 2 declares 4 "DTL" record(s) but the file contains 3 (record 5, type TRL)
1 file checked, 2 findings (2 error, 0 warning)
That first finding is the sneaky one. A row with one missing field does not fail to parse; every value after the gap just quietly means something else.
Class 3: Terminator drift
A file assembled from pieces inherits each piece's line endings. A header template last touched on Windows, detail lines generated on Linux, one record "fixed" by an editor that normalized as it saved. The result parses fine on the platform that wrote it and splits differently on the platform that reads it.
$ edilint mixed-endings.psv
mixed-endings.psv:2: error: [EL2001 terminator.mixed] line ends with CRLF but the file predominantly uses LF (LF x4, CRLF x1); mixed line endings split records inconsistently across platforms (record 2, type DTL)
X12 has its own version of this class: segments not closed by the terminator the ISA header declared, or whitespace applied inconsistently between segments. Same theme, same detectability.
Class 4: Layout drift
Fixed-width formats fail the most quietly of all, because most fixed-width readers slice by offset and keep going. If the spec says a record is 49 characters and the code writes 48, nothing crashes. Every field after the missing character reads one position off: dates lose a digit, amounts shift, and the file loads.
Checked against a declared layout, the drift is obvious:
$ edilint --format fixed --layout remit-layout.json remit.txt
remit.txt:2: warning: [EL5002 layout.padding] field "last_name" (offset 15, width 16): value is right-aligned but the layout declares padding on the right (left-aligned) (record 2, type DTL)
remit.txt:3: warning: [EL5002 layout.padding] field "paid_amount" (offset 31, width 10): value is padded with spaces but the layout declares "0" (record 3, type DTL)
remit.txt:4: error: [EL5001 layout.length] record is 48 character(s) long but layout "remittance-detail" declares 49; field "paid_date" (offset 41, width 8) is truncated (record 4, type DTL)
1 file checked, 3 findings (1 error, 2 warning)
The padding warnings matter more than they look. A field padded on the wrong side is often the first symptom that two teams are reading the same spec PDF differently, and it tends to show up before the length break does.
The gate
None of these classes deserve a human catching them twice. The structural fix is a lint step in front of every send, in the same place you would put a test suite in front of a merge:
edilint outbound/*.x12 || exit 1
I built edilint to be exactly that gate: a single static Go binary with zero dependencies, 48 rules in the catalog across X12, HL7v2, EDIFACT, delimited, and fixed-width files, findings as compiler-style diagnostic lines or JSON, and exit codes that mean something (0 clean, 1 findings, 2 could not read). A --baseline flag lets you adopt it on a noisy legacy feed without boiling the ocean on day one.
The samples in this post ship in the repo's examples/ directory, synthetic payers and all, so every command above is reproducible.
Takeaways
- Trust structure checks less. The defects that cost real money are byte-level and semantic: characters, counts, terminators, offsets.
- Encode every caught defect as a rule. The first catch is detective work; the second should be a failing exit code.
- Put the gate where the file leaves, not where it is written. The last process to touch the bytes owns them.
- Prefer checks with recounts over checks with parses. "Does the trailer agree with the rows" catches a class that "does it parse" never will.
If you run interchange feeds and want to compare notes on defect classes I have not covered, my inbox is open.
Related Articles
Comments
Join the discussion. Be respectful.