ACE and ABI Rejection Handling

ACE and ABI rejection handling is the deterministic layer that reads CBP’s response to an electronic filing, decodes why a transmission was refused, and decides whether the fix is an automatic resubmission or a human correction. When a broker sends an ABI entry into CBP’s Automated Commercial Environment, the acknowledgment that comes back is not a simple yes or no — it is an X12 824 Application Advice or an ACE response cycle carrying reject and error condition codes that must be parsed, classified, and acted on before a shipment misses its release window. Part of the Compliance Reporting & ACE Transmission reference architecture, this component sits at the far end of the filing pipeline: it consumes the raw EDI response, matches it back to the original entry, and routes each rejection either into an idempotent resubmission path or into a dead-letter queue for review. For a compliance officer, the reject must be traceable to a named CBP condition code and a defensible remediation; for the Python ETL teams operating the pipeline, that same reject must be an event with a stable identity, so replaying the response never double-files an entry that CBP already accepted.

Problem Framing: The Silent Reject Backlog

The failure mode this component exists to prevent is the silent reject backlog — filings that CBP refused, whose 824 advice was received but never fully parsed, so the entry sits unresubmitted until liquidated damages or a missed release window surfaces it. Three defects recur in hand-built reject handling. First, envelope blindness: the code reads the functional payload but ignores the ISA/GS/ST control envelope, so it cannot correlate a reject in a batch back to the specific ST transaction set that failed, and one bad entry poisons the acknowledgment for the whole group. Second, flat classification: every reject is treated the same, so a soft, retryable advisory (a duplicate acknowledgment, a transient queue condition) triggers a manual ticket while a hard, structural reject (an invalid HTS number, a missing bond) gets blindly resubmitted unchanged and rejected again in an endless loop. Third, non-idempotent resubmission: a retry re-sends the entry with a fresh control number and no dedupe key, so a response that was merely delayed produces two entries for one shipment, an over-declaration that itself becomes a compliance finding.

Each defect converts a routine refusal into an exposure — a late filing, a duplicated declaration, or an entry that never clears. The architecture below closes those gaps by parsing the full EDI envelope, binding every rejection to a typed error taxonomy that separates hard from soft, and giving each reject event a deterministic identity so resubmission is safe to replay.

ACE and ABI rejection lifecycle from 824 advice to reconciled ledger A top-down flow diagram tracing one CBP response through the reject handler. It enters as an ACE AE or AX 824 Application Advice. The first stage parses the EDI envelope — the ISA interchange, GS functional group, and ST transaction set control segments — to correlate the response to the original entry. The second stage extracts the error condition codes from the 824 TED and error segments. The third stage is a classification decision: is this a hard reject or a soft reject? A hard reject, such as an invalid HTS number or a missing bond, branches left into a dead-letter queue and then to a human correction path that files a post-summary correction or a manual fix. A soft reject, such as a duplicate acknowledgment or a transient queue condition, branches right into an idempotent resubmission with exponential backoff. Both branches converge into a reconciliation stage that matches the eventual ACE accept against the reject ledger and closes the event. Hard-reject boxes are shaded red, soft-reject boxes are shaded teal, and the classification decision is shaded gold. hard soft ACE AE / AX · 824 Application Advice Parse envelope · ISA / GS / ST Extract error codes · TED segments Classify reject hard vs soft · error taxonomy Hard reject invalid HTS · missing bond not retryable as-is Soft reject duplicate ack · queue busy retryable unchanged Dead-letter queue human correction PSC / re-file Idempotent resubmit dedupe key + backoff safe to replay Reconcile ledger match AX accept · close event
The reject lifecycle: parse the EDI envelope, extract and classify condition codes, then route hard rejects to a dead-letter queue and soft rejects to idempotent resubmission, reconciling both against CBP's eventual accept.

Schema / Data Contract

Reject handling is only as reliable as the event it records. Before any classification fires, each rejection extracted from the 824 must become a structurally validated event carrying enough provenance to correlate it to the original filing and to make a resubmission decision idempotent. The Pydantic contract below formalizes that reject event, and the PostgreSQL DDL that follows persists it in an append-only reject ledger. The key discipline is that the interchange, group, and transaction-set control numbers from the ISA/GS/ST envelope are captured as first-class fields, so a reject is never a free-floating error string — it is bound to the exact entry and transmission that produced it.

from __future__ import annotations
import hashlib
import logging
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, field_validator

logging.basicConfig(format="%(asctime)s | %(levelname)s | %(name)s | %(message)s")
logger = logging.getLogger("ace.reject")


class RejectSeverity(str, Enum):
    HARD = "HARD"          # structural — cannot resubmit unchanged
    SOFT = "SOFT"          # advisory / transient — retryable as-is
    INFORMATIONAL = "INFO" # accepted with notes — no action required


class RejectDisposition(str, Enum):
    PENDING = "PENDING"
    DEAD_LETTERED = "DEAD_LETTERED"
    RESUBMIT_QUEUED = "RESUBMIT_QUEUED"
    RESOLVED = "RESOLVED"


class RejectEvent(BaseModel):
    entry_number: str = Field(..., pattern=r"^[A-Z0-9]{9,12}$")
    interchange_control: str = Field(..., min_length=1)   # ISA13
    group_control: str = Field(..., min_length=1)         # GS06
    transaction_control: str = Field(..., min_length=1)   # ST02
    condition_code: str = Field(..., min_length=1)        # CBP/824 error code
    severity: RejectSeverity
    segment_ref: Optional[str] = None                     # e.g. "TED*007"
    element_ref: Optional[str] = None
    free_text: Optional[str] = None
    received_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
    disposition: RejectDisposition = RejectDisposition.PENDING

    @field_validator("condition_code")
    @classmethod
    def _upper(cls, v: str) -> str:
        return v.strip().upper()

    def dedupe_key(self) -> str:
        """Stable identity so the same reject is never actioned twice."""
        raw = f"{self.entry_number}|{self.transaction_control}|{self.condition_code}|{self.segment_ref or ''}"
        return hashlib.sha256(raw.encode("utf-8")).hexdigest()

The dedupe_key is the linchpin of idempotency: it hashes the entry, the ST control number, and the condition code so that reprocessing the same 824 — a common occurrence when a mailbox is polled more than once — resolves to an event the ledger has already seen rather than a new action. The ledger table below enforces that at the storage layer with a unique constraint, and records the disposition transitions so a compliance officer can read the full remediation history of any refused entry.

CREATE TABLE ace_reject_ledger (
    reject_id       BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    dedupe_key      TEXT        NOT NULL UNIQUE,
    entry_number    TEXT        NOT NULL,
    interchange_ctl TEXT        NOT NULL,
    group_ctl       TEXT        NOT NULL,
    transaction_ctl TEXT        NOT NULL,
    condition_code  TEXT        NOT NULL,
    severity        TEXT        NOT NULL CHECK (severity IN ('HARD','SOFT','INFO')),
    segment_ref     TEXT,
    element_ref     TEXT,
    free_text       TEXT,
    disposition     TEXT        NOT NULL DEFAULT 'PENDING',
    resubmit_count  INTEGER     NOT NULL DEFAULT 0,
    received_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    resolved_at     TIMESTAMPTZ,
    payload_hash    TEXT        NOT NULL
);

CREATE INDEX ix_reject_entry      ON ace_reject_ledger (entry_number, received_at DESC);
CREATE INDEX ix_reject_open       ON ace_reject_ledger (disposition) WHERE disposition <> 'RESOLVED';
CREATE INDEX ix_reject_condition  ON ace_reject_ledger (condition_code);

The dedupe_key UNIQUE constraint means an INSERT ... ON CONFLICT (dedupe_key) DO NOTHING is the natural write path, and resubmit_count bounds automatic retries so a soft reject that never clears eventually escalates rather than looping forever. The partial index on open dispositions keeps the operational query — “what still needs action?” — cheap even when the ledger holds years of resolved history.

Step-by-Step Implementation

The handler runs in fixed stages, and each stage writes to the audit trail before the next begins, so a stalled entry can always be diagnosed from the ledger alone.

Stage 1 — Parse the EDI envelope

X12 interchanges nest three control layers around the functional payload: the ISA interchange header, the GS functional group, and one or more ST transaction sets. Correlating a reject back to the entry that caused it is impossible without reading those control numbers, so envelope parsing comes first. Real interchanges vary their element and segment terminators, which the ISA segment itself declares in fixed positions, so the parser reads them from the header rather than assuming defaults.

from dataclasses import dataclass


@dataclass(frozen=True)
class Envelope:
    element_sep: str
    segment_sep: str
    isa13: str   # interchange control number
    gs06: str    # group control number


def parse_envelope(raw: str) -> Envelope:
    if not raw.startswith("ISA"):
        raise ValueError("interchange does not begin with ISA header")
    element_sep = raw[3]                       # ISA declares its own separators
    isa_fields = raw[:106].split(element_sep)
    segment_sep = raw[105]
    isa13 = isa_fields[13]
    gs_seg = next(s for s in raw.split(segment_sep) if s.startswith("GS"))
    gs06 = gs_seg.split(element_sep)[6]
    return Envelope(element_sep=element_sep, segment_sep=segment_sep, isa13=isa13, gs06=gs06)

Reading the separators from the ISA header rather than hard-coding * and ~ is what lets the same parser handle interchanges from different trading-partner configurations without silently mis-splitting a segment.

Stage 2 — Extract error codes from the 824

The 824 Application Advice reports the fate of a received transaction set. Its OTI segment carries the acknowledgment code (accepted, accepted-with-error, or rejected), and each TED (Technical Error Description) segment carries a condition code, the referenced segment and element, and free-text detail. The extractor walks the transaction set and emits one RejectEvent per TED, tagging each with the envelope control numbers so the ledger can bind it to the original entry.

def extract_rejects(raw: str, entry_number: str) -> list[RejectEvent]:
    env = parse_envelope(raw)
    segments = [s for s in raw.split(env.segment_sep) if s]
    events: list[RejectEvent] = []
    st02 = ""
    for seg in segments:
        fields = seg.split(env.element_sep)
        tag = fields[0]
        if tag == "ST":
            st02 = fields[2]
        elif tag == "TED":
            condition = fields[1] if len(fields) > 1 else "UNSPECIFIED"
            segment_ref = fields[2] if len(fields) > 2 else None
            free_text = fields[5] if len(fields) > 5 else None
            events.append(RejectEvent(
                entry_number=entry_number,
                interchange_control=env.isa13,
                group_control=env.gs06,
                transaction_control=st02,
                condition_code=condition,
                severity=classify(condition),
                segment_ref=segment_ref,
                free_text=free_text,
            ))
    logger.info("extracted %d reject events for entry %s", len(events), entry_number)
    return events

Stage 3 — Classify against the error-code taxonomy

Classification is the decision that separates an automatic path from a human one, so it must be data-driven and defensible rather than a chain of ad-hoc if statements. A lookup table maps each CBP/824 condition code to a severity; anything not in the table defaults to HARD, because an unrecognized reject is safer to quarantine than to blindly retry. Hard rejects are structural — an invalid HTS number, a missing or insufficient bond, an entered value that fails a validity edit — and cannot be resubmitted unchanged. Soft rejects are advisory or transient — a duplicate acknowledgment, a busy-queue condition, a warning that accepts the entry with notes — and are safe to resubmit as-is or to leave alone.

# Condition-code taxonomy — codes are illustrative of CBP/824 categories.
_HARD_CODES = {
    "H01",  # invalid HTS number
    "H02",  # missing / insufficient bond
    "H03",  # entered value edit failure
    "H04",  # unknown importer of record
    "H05",  # manifest quantity mismatch
}
_SOFT_CODES = {
    "S01",  # duplicate transmission acknowledged
    "S02",  # queue temporarily unavailable
    "S03",  # accepted with advisory note
}


def classify(condition_code: str) -> RejectSeverity:
    code = condition_code.strip().upper()
    if code in _SOFT_CODES:
        return RejectSeverity.SOFT
    if code in _HARD_CODES:
        return RejectSeverity.HARD
    logger.warning("unmapped condition code %s -> defaulting to HARD", code)
    return RejectSeverity.HARD  # fail closed: quarantine the unknown

Stage 4 — Route: dead-letter or resubmit

With every reject classified and persisted, the router applies the disposition. Hard rejects go to the dead-letter queue for human correction; soft rejects that remain under the retry ceiling are queued for idempotent resubmission; soft rejects that have exhausted their retries are escalated to the same dead-letter path so nothing loops silently.

MAX_SOFT_RETRIES = 4


async def route(event: RejectEvent, conn) -> RejectDisposition:
    row = await conn.fetchrow(
        """
        INSERT INTO ace_reject_ledger
            (dedupe_key, entry_number, interchange_ctl, group_ctl, transaction_ctl,
             condition_code, severity, segment_ref, free_text, payload_hash)
        VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
        ON CONFLICT (dedupe_key) DO UPDATE SET received_at = ace_reject_ledger.received_at
        RETURNING resubmit_count, disposition
        """,
        event.dedupe_key(), event.entry_number, event.interchange_control,
        event.group_control, event.transaction_control, event.condition_code,
        event.severity.value, event.segment_ref, event.free_text, event.dedupe_key(),
    )
    if event.severity is RejectSeverity.HARD:
        disposition = RejectDisposition.DEAD_LETTERED
    elif row["resubmit_count"] < MAX_SOFT_RETRIES:
        disposition = RejectDisposition.RESUBMIT_QUEUED
    else:
        logger.error("soft reject exhausted retries for %s -> dead-letter", event.entry_number)
        disposition = RejectDisposition.DEAD_LETTERED
    await conn.execute(
        "UPDATE ace_reject_ledger SET disposition=$1 WHERE dedupe_key=$2",
        disposition.value, event.dedupe_key(),
    )
    return disposition

The ON CONFLICT DO UPDATE ... RETURNING pattern makes re-reading the same 824 a no-op that still returns the current retry count, which is exactly what a safe replay requires. The precedence is explicit and worth stating on its own, because getting it wrong is the most common source of duplicated filings:

  1. Hard rejects always dead-letter — never auto-resubmit a structural defect.
  2. Soft rejects under the ceiling resubmit idempotently with backoff.
  3. Soft rejects over the ceiling escalate to dead-letter rather than loop.

The full worked decoding of every TED and OTI segment, including the acknowledgment-code matrix, lives in Decoding ACE X12 824 application advice errors; the mechanics of the resubmission path — control-number reuse, the dedupe key on the wire, and backoff scheduling — are covered in Resubmitting rejected ABI entries idempotently.

Validation & Determinism

Determinism is the property an audit actually tests: the same 824 payload, replayed against the same taxonomy version, must produce the same reject events and the same dispositions, with no new ledger rows on the second pass. Several cross-checks enforce that guarantee. The dedupe_key is a pure function of the entry, transaction-set control number, condition code, and segment reference, so identical inputs collide by construction and the unique constraint absorbs the duplicate. Classification is a table lookup with a fail-closed default, so an unrecognized code always resolves to HARD — the same way every time — rather than depending on evaluation order. Monetary fields that appear in reject free-text, such as an entered-value edit, are carried as Decimal and never coerced to float, so a value edit reported by CBP is compared at the same fixed precision the entry was filed with.

Inputs that cannot be resolved deterministically are quarantined rather than guessed. A 824 whose envelope cannot be parsed, or a TED whose condition code is empty, is written to the ledger with an UNSPECIFIED code and a HARD severity so it surfaces for review instead of being dropped. Binding each reject to a hash of the raw payload lets a verification reconstruct the exact response CBP sent and confirm that the disposition applied was the correct one for that message, even after the taxonomy table has since been extended.

Downstream Integration

Reject handling does not exist in isolation — it closes a loop that begins upstream in filing assembly. Every entry that lands here was first built by the Entry Summary Filing Schemas, which assemble the CBP 7501 line items and entered values that a hard reject may point back to; when the reject is an entered-value or classification edit, remediation means regenerating the affected line from that same schema layer, not hand-patching the EDI. When a hard reject requires a substantive change to an already-accepted or partially processed entry — a corrected value, a reclassified HTS number, a revised duty figure — the correction is not a raw resubmission but a formal amendment, which is why the dead-letter path hands those events to the Post-Entry Amendment Workflows that file post-summary corrections and reconcile any resulting duty adjustment.

The reconciliation stage is where the loop finally closes: when CBP returns an acceptance (an AX response) for a previously rejected entry, the reconciler matches it against the open reject event by entry and control number, sets the disposition to RESOLVED, and stamps resolved_at. Until that match arrives, the event stays open in the partial index, so the operational view of “entries CBP has refused and not yet accepted” is always a single indexed query rather than a reconstruction across logs.

Scaling & Resilience

A production broker processes thousands of entries per clearance window, and the 824 responses arrive in bursts as CBP drains its queue, so the handler must absorb spikes without either dropping responses or resubmitting into a backlog it is making worse. Four controls keep it inside budget:

  • Streaming segment parsing of each interchange rather than loading the whole mailbox into memory, so a large batched acknowledgment is processed one transaction set at a time.
  • Bounded resubmission concurrency through a semaphore-capped async queue, so a flood of soft rejects cannot overwhelm the ABI gateway or trip CBP’s own rate limits.
  • A dedupe-keyed ledger as the source of truth, so a mailbox polled twice, or a handler that restarts mid-batch, converges to the same state instead of double-filing.
  • Exponential backoff with jitter on the soft-reject path, so transient queue conditions are retried on a widening interval rather than hammered — the same backoff and retry discipline detailed in Error Handling & Retry Logic, applied here to EDI acknowledgments instead of document parsing jobs.

For resilience, an upstream ABI gateway failure trips a circuit breaker that pauses resubmission and lets soft rejects accumulate in the queue rather than failing them permanently; when the gateway recovers, the queue drains under the same concurrency cap. A resubmission is never emitted against a stale ledger state — the ON CONFLICT write and the resubmit_count ceiling together guarantee that a retry storm cannot escape the bound, so the worst case under sustained failure is a growing dead-letter queue that a human works down, never a stream of duplicate entries reaching CBP.

Compliance Obligations

Every reject and every resubmission is potential evidence in a CBP inquiry, so the ledger is designed as an immutable, append-only record. Each event carries the entry number, the full ISA/GS/ST control chain, the condition code and its classified severity, the raw payload hash, the disposition history, and the timestamps of receipt and resolution. Retention follows the CBP recordkeeping horizon — at least five years past the entry date under 19 CFR — held on an immutable tier so that a Focused Assessment or a broker-compliance review can be answered from primary records showing exactly which rejects were received, how each was classified, and when each was resolved.

The idempotency discipline is itself a compliance control, not merely an engineering nicety: a duplicated entry is an over-declaration that a customs authority can treat as a substantive error, so the dedupe key and the retry ceiling exist as much to protect the importer’s standing as to keep the pipeline tidy. Anything the handler cannot resolve deterministically — an unparseable envelope, an unmapped condition code, a soft reject that has exhausted its retries — is escalated to a licensed broker through the dead-letter queue rather than auto-actioned. That combination of an immutable reject ledger, a versioned and fail-closed taxonomy, deterministic dedupe identity, and mandatory human escalation is what turns CBP’s terse rejection codes into reject handling that survives audit instead of merely clearing the queue.

Up: Compliance Reporting & ACE Transmission