# 997, 999, CONTRL — three acknowledgments, one question

> transactions.dev · by visibility.cloud — 2026-07-31
> Written for 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](/blog/the-envelope-must-agree-with-itself/).
- **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](/blog/reconcile-is-a-verdict/) — 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](/blog/the-envelope-must-agree-with-itself/)),
and `ack` maps that verdict to a 997. In original prose, since the
segment dictionary belongs to
[the licensed reference](https://www.stedi.com/edi/x12): 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](/blog/the-three-parser-differential-harness/) that grounds the
parse layer: the same corpus of deliberately broken documents is run
through
[pyx12](https://github.com/azoner/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](https://epcis.dev/conformance/). 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](/blog/una-is-a-courtesy/). 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](/blog/biztransaction-is-a-core-field/)),
not incidents.

Partner demands acks before go-live? That is a one-verb answer.
[The access flow](/get-access/) asks which sets and versions your
partners run precisely so the ack lane is ready when the first rejected
file needs answering.

---
Access: https://transactions.dev/get-access/ · All posts: https://transactions.dev/blog/ · Machine face: https://transactions.dev/llms.txt
