The blog · 2026-07-31
997, 999, CONTRL — three acknowledgments, one question
Written for ED-1 — the supply-chain ISV / traceability-vendor engineering org — the integration engineer whose partner demands acks before go-live.
One thesis: a functional acknowledgment is a pure function of a validation result — nothing more — and once you see it that way, the 997, the 999, and CONTRL stop being three obligations and become one mechanism with three output grammars. The folklore around acks — which partner wants which, what counts as "accepted," why a rejection is not an incident — all resolves against that one sentence.
The layered question every ack answers
An acknowledgment encodes answers to a stack of questions, each committing the receiver to less than trading partners tend to assume:
- Did the envelope arrive and open? The ack names the functional group and transaction sets it examined — an existence claim keyed by the control numbers.
- Was each set syntactically usable? Accepted, accepted with errors noted, or rejected — per set, with typed syntax findings at the segment and element level.
- And that is all. An accepted 856 does not mean the shipment is right, the PO exists, or the goods will be received. Acceptance is a statement about syntax, and every dispute that begins "but you acknowledged it" ends at that line. Whether shipped matched seen is a different machine — reconcile — and whether the business content is honored is a different document (recadv, not an ack).
One bad file through the function
A synthetic 856 with a deliberate syntax fault — its second HL names a parent that does not exist:
ST*856*0001~
BSN*00*ASN-4471*20260731*0915~
HL*1**S~
HL*2*9*O~
PRF*PO-2026-0007~
CTT*2~
SE*7*0001~
validate produces a typed verdict (exit 1 — a negative domain verdict, not a tool failure), and ack maps that verdict to a 997. In original prose, since the segment dictionary belongs to the licensed reference: the generated 997 opens its own envelope, then states which functional group it is answering (an AK1 loop echoing the group's control number), then, per transaction set examined, which set (AK2, echoing set control 0001), what was found where (an error loop naming the segment position of the offending HL and the nature of the fault), the per-set verdict — rejected — and finally the group-level tally: one set included, one rejected, none accepted (AK9). Every value in it is drawn from the validation result and the envelope being answered. There is no judgment in the generator, and there is nothing else in the output.
That is what "pure function" buys operationally: acks are reproducible. Run ack on the same validation result twice and the outputs are identical; hand the validation result to a partner alongside the 997 and they can check one against the other mechanically.
Not our word: the differential cross-check
X12 publishes no conformance suite, so the pipeline's ack generation is held to agreement instead of authority — the same posture as the three-parser differential harness that grounds the parse layer: the same corpus of deliberately broken documents is run through pyx12 — the two-decade-old BSD-licensed Python implementation with its own independent 997 generator — and the two acknowledgments are compared finding-for-finding. Where independent implementations agree, that agreement is the evidence; where they disagree, the case goes to an adjudication queue and is settled by a journaled human ruling, the same governance the family applies to its pinned artifacts. The claim offered here is deliberately shaped to be checkable: not "our 997s are correct," but "our 997s agree with an implementation we do not control, on a corpus you can run yourself."
997 versus 999, and when partners demand which
The 997 is the 004010-era functional acknowledgment; the 999 arrived with 005010 and adds what the 997 could not say — implementation-level findings, i.e. "syntactically legal X12 that violates the implementation guide in force," alongside the base syntax findings. Healthcare mandated the 999 and it dominates there; retail and logistics remain overwhelmingly 997 country. The practical rule: send what the partner's guide demands, and treat the 999's extra layer as exactly the partner-rules layer it is — the same constraint stratum that guideline overlays address at validation time. Both generate from the same function; only the output grammar and the granularity of findings differ.
CONTRL, for the EDIFACT lane
EDIFACT's answer is one message, CONTRL, doing both jobs: interchange receipt and syntax verdict, keyed by the UNB/UNH references of that envelope model. Same function underneath: a validation result in, a per-message accepted/accepted-with-errors/rejected verdict out, findings pointing at segment positions. An integrator who has internalized the 997 learns CONTRL in an afternoon, and the pipeline emits it from the identical core — one validation engine, three ack grammars.
The design ruling worth stating plainly
Acknowledging a bad document is the system working. ack exits 0 on a rejected set, because the operation — examine, verdict, answer — has succeeded; the exit-1 lives on validate, where the negative verdict belongs. Pipelines that conflate "the document failed" with "the ack failed" end up paging humans for the exact situation the acknowledgment machinery was invented to handle automatically. Wire the distinction into your exit-code handling once, and rejected documents become what they should have always been: routine, typed, answered, and logged — context that ultimately rides the event record (the join this site exists for), not incidents.
Partner demands acks before go-live? That is a one-verb answer. The access flow asks which sets and versions your partners run precisely so the ack lane is ready when the first rejected file needs answering.
905 words. The access flow is at /get-access/; the family's event door is epcis.dev.