Discrepancy Resolution & Dispute Routing

A freight audit that stops at the variance flag has done half a job. The validation tier can prove an invoice overbilled by $312.40 on a mis-rated lane, but that number is worthless until it becomes a claim a carrier accepts and a credit that lands in the ledger. Between the flag and the recovered dollar sits a whole pipeline stage that most programs never build deterministically — and without it, flagged variance quietly evaporates. Disputes rot in a shared spreadsheet nobody owns, short-pays get deducted but never tracked against a remittance, and by the time an analyst circles back, the carrier has auto-rejected the claim because it arrived outside the contractual filing window. The money was found; it was never collected.

This page defines the discrepancy layer: the deterministic path a flagged line takes from variance verdict to settled recovery. It reads compliance verdicts from Rule-Based Rate Validation & Accessorial Auditing and the point-in-time tariff context from Freight Contract Architecture & Rate Mapping, then classifies each variance, decides whether it is worth disputing, routes it to the right carrier channel, submits the claim idempotently, records both the claim and its outcome in a double-entry ledger, and settles the result as a recovery or a tracked short-pay. Every hop is auditable and every dollar is accounted for on both sides of the book.

The audience is the same freight-audit and Python ETL engineers who own the upstream tiers, now responsible for the recovery mechanics: claim state machines, carrier API contracts, deduction codes, and the reconciliation math that ties a disputed invoice to the credit memo that eventually clears it. Get this layer right and found variance turns into collected cash with a defensible trail; get it wrong and the audit becomes a report of money you noticed leaving rather than money you brought back.

Pipeline architecture overview

The discrepancy layer is a directed flow, not a queue of tickets. A variance verdict enters, is classified into a specific dispute-worthy type, routed to the carrier channel that matches that type, submitted as a claim, and then tracked through reconciliation until the ledger balances. Failures at any hop — a rejected claim, a carrier API timeout, a variance too small to pursue — branch to explicit terminal states rather than silently stalling.

Discrepancy resolution flow from variance verdict to settlement A left-to-right pipeline carries a variance verdict through five stages. Stage one classifies the verdict into a specific variance type with a confidence score. Stage two routes dispute-worthy variances to a carrier channel; below-threshold variances branch down to a write-off terminal. Stage three submits the claim to the carrier dispute API idempotently; transient API failures branch down to a dead-letter queue for retry, and carrier auto-rejections branch to a rejected terminal. Stage four records the claim and its outcome in a double-entry reconciliation ledger. Stage five settles the outcome, splitting into a recovered-credit terminal and a tracked short-pay terminal. The ledger feeds both settlement terminals so the book balances on each side. CLASSIFY ROUTE SUBMIT RECONCILE SETTLE Variance classification type + confidence Dispute routing channel per type Carrier dispute API idempotent submit Reconciliation ledger double-entry Settlement recover / short-pay below threshold Write-off not worth pursuing API error auto-reject Dead-letter queue retry with backoff Rejected stale / denied claim posts to both sides Recovered credit credit memo cleared Tracked short-pay deduction vs remittance verdict in from validation
The discrepancy layer moves a variance verdict through classification, routing, idempotent carrier submission, ledger reconciliation, and settlement. Below-threshold variances write off, transient API failures dead-letter for retry, carrier auto-rejections terminate as rejected, and the double-entry ledger posts to both the recovered-credit and tracked-short-pay settlement paths.

The flow is event-driven. Each variance verdict is published as a discrete event and consumed by a dispatch worker that owns one claim through its whole lifecycle. Because a claim can sit open for weeks while a carrier investigates, state lives in a durable store keyed by a stable dispute id, not in worker memory — a worker restart resumes claims exactly where they were. The canonical data flow below maps each stage to the record it reads and the record it writes.

Stage Owner Reads Writes
Classification Variance classifier Validation verdict + variance amount Typed VarianceCase with confidence
Routing Dispute router Carrier channel registry + filing rules DisputeIntent bound to a channel
Submission Carrier dispute client DisputeIntent + idempotency key External claim id + submission receipt
Reconciliation Ledger Claim + carrier remittance/credit Double-entry postings (debit/credit)
Settlement Settlement worker Balanced ledger position Recovery record or tracked short-pay

Variance-type coverage

Not every flagged line is disputed the same way, and treating them uniformly is the fastest route to a low win rate. A duplicate-billing claim needs proof the same movement was invoiced twice; a fuel-surcharge overcharge needs the recomputed index value; a detention charge needs the gate-in/gate-out timestamps. The classifier assigns each verdict a variance type, and the type determines the evidence package, the carrier channel, and the filing deadline. The taxonomy below is the coverage contract for the discrepancy layer; the classification engine that produces it lives in Charge Variance Classification.

Variance type Trigger signal Evidence required Typical channel
Linehaul overcharge Billed rate exceeds resolved contract rate Rate context + contract version Carrier dispute API
Duplicate billing Same PRO + charge fingerprint seen twice Prior settlement id + fingerprint Short-pay deduction
Accessorial mismatch Charge absent from contracted schedule Accessorial schedule snapshot Carrier dispute API
Fuel-surcharge drift Aggregate FSC above DOE-indexed curve Recomputed FSC per line + index peg Carrier dispute API
Weight / zone error Billed weight or zone fails cross-check Actual weight log + zone map Carrier dispute API
Reweigh / reclass Carrier reclassed to a higher freight class Original BOL class + inspection Manual + API

Weight and zone errors arrive pre-flagged from Weight & Zone Cross-Validation, and accessorial mismatches carry a penalty weight from Accessorial Charge Scoring; the discrepancy layer does not re-derive those signals, it acts on them. A variance whose type cannot be determined with sufficient confidence is not force-fit into a bucket — it routes to manual review, because a mis-typed claim submitted to the wrong channel is worse than one a human triages.

Dispute contract and configuration

A dispute is a long-lived financial object, so its schema has to encode everything a carrier and an auditor will ask for months later: which invoice, which line, which contract version, how much is claimed, on what basis, and to which channel it was sent. The configuration that governs routing — filing windows, minimum claim amounts, channel endpoints — is versioned alongside the carrier registry so that a claim is always evaluated against the rules in force when the shipment was billed, exactly as tariffs are resolved point-in-time upstream.

Config object Keyed by Governs Consumed by
Channel registry carrier_scac API endpoint, auth, format Routing + submission
Filing window carrier_scac, variance type Days from invoice date to file Routing (deadline gate)
Minimum claim amount carrier_scac Below this, write off Routing (threshold gate)
Deduction code map carrier_scac, variance type Short-pay reason code Settlement
Retry policy channel Backoff, max attempts, DLQ Submission

The DisputeCase schema below is the object that flows through every stage. It carries the classified type and confidence, the claimed amount as an exact Decimal, the resolved contract_version_id so the claim references the same tariff the audit used, and a dispute_key derived deterministically from the shipment identity — the key that makes submission idempotent later.

# models/dispute.py
from pydantic import BaseModel, Field, field_validator
from datetime import date
from decimal import Decimal
from enum import Enum
import hashlib


class VarianceType(str, Enum):
    LINEHAUL_OVERCHARGE = "LINEHAUL_OVERCHARGE"
    DUPLICATE_BILLING = "DUPLICATE_BILLING"
    ACCESSORIAL_MISMATCH = "ACCESSORIAL_MISMATCH"
    FUEL_SURCHARGE_DRIFT = "FUEL_SURCHARGE_DRIFT"
    WEIGHT_ZONE_ERROR = "WEIGHT_ZONE_ERROR"
    REWEIGH_RECLASS = "REWEIGH_RECLASS"


class DisputeState(str, Enum):
    CLASSIFIED = "CLASSIFIED"
    ROUTED = "ROUTED"
    SUBMITTED = "SUBMITTED"
    ACKNOWLEDGED = "ACKNOWLEDGED"
    RECOVERED = "RECOVERED"
    REJECTED = "REJECTED"
    WRITTEN_OFF = "WRITTEN_OFF"


class DisputeCase(BaseModel):
    carrier_scac: str
    invoice_number: str
    pro_number: str
    contract_version_id: str
    variance_type: VarianceType
    confidence: float
    claimed_amount: Decimal
    ship_date: date
    state: DisputeState = DisputeState.CLASSIFIED
    dispute_key: str = ""

    @field_validator("claimed_amount")
    @classmethod
    def positive_claim(cls, v: Decimal) -> Decimal:
        # A zero or negative claim means the classifier fired on noise;
        # never let it reach a carrier as a real dispute.
        if v <= 0:
            raise ValueError("claimed_amount must be strictly positive")
        return v

    def with_key(self) -> "DisputeCase":
        # Deterministic identity: same movement + same variance type always
        # produces the same key, so a redelivered verdict cannot open a
        # second dispute for the same overcharge.
        raw = "|".join([
            self.carrier_scac.strip().upper(),
            self.invoice_number.strip(),
            self.pro_number.strip(),
            self.variance_type.value,
        ])
        self.dispute_key = hashlib.sha256(raw.encode()).hexdigest()
        return self

Core routing and dispatch implementation

The heart of the discrepancy layer is the dispatch loop: it takes a stream of classified DisputeCase objects and, for each, decides whether to pursue it, on which channel, and against which deadline — then advances its state. The loop is deliberately a pure decision function wrapped in an I/O shell, so the routing rules are unit-testable without a live carrier API. The most common production defect here is routing on the variance amount alone while ignoring the filing window: a $4,000 overcharge that missed the carrier’s 180-day claim deadline is uncollectable, and submitting it anyway just burns the carrier relationship and pollutes the win-rate metric.

# routing/dispatch.py
import logging
from datetime import date, timedelta
from decimal import Decimal

logger = logging.getLogger("discrepancy.router")


class DispositionRules:
    """Point-in-time routing rules resolved from the channel registry."""

    def __init__(self, registry: dict):
        self.registry = registry

    def route(self, case) -> dict:
        rules = self.registry[case.carrier_scac]

        # 1. Threshold gate: below the carrier's minimum, disputing costs
        #    more in handling than the claim is worth — write it off.
        if case.claimed_amount < Decimal(str(rules["min_claim_amount"])):
            return {"decision": "WRITE_OFF", "reason": "below_min_claim"}

        # 2. Deadline gate: a claim filed after the contractual window is
        #    auto-rejected by the carrier, so never spend a submission on it.
        window_days = rules["filing_window_days"][case.variance_type.value]
        deadline = case.ship_date + timedelta(days=window_days)
        if date.today() > deadline:
            return {"decision": "WRITE_OFF", "reason": "past_filing_window"}

        # 3. Confidence gate: low-confidence classifications go to a human
        #    rather than to the wrong channel with the wrong evidence.
        if case.confidence < rules.get("min_confidence", 0.80):
            return {"decision": "MANUAL_REVIEW", "reason": "low_confidence"}

        # 4. Channel selection is keyed by variance type — duplicates are
        #    recovered by short-pay, most rate errors by the dispute API.
        channel = rules["channel_by_type"][case.variance_type.value]
        return {"decision": "ROUTE", "channel": channel, "deadline": deadline.isoformat()}


def dispatch(cases, rules: DispositionRules, submitter, ledger):
    """Advance each classified case to its next durable state."""
    for case in cases:
        outcome = rules.route(case)
        if outcome["decision"] == "WRITE_OFF":
            ledger.record_writeoff(case, reason=outcome["reason"])
            logger.info("write_off", extra={"dispute_key": case.dispute_key,
                                            "reason": outcome["reason"]})
            continue
        if outcome["decision"] == "MANUAL_REVIEW":
            ledger.record_manual(case, reason=outcome["reason"])
            continue
        # ROUTE: hand off to the idempotent submitter for this channel.
        receipt = submitter.submit(case, channel=outcome["channel"])
        ledger.record_submission(case, receipt)

The routing rules read the resolved rate context — the contracted amount, the contract version, the fuel index in force — from the same store the validation tier used, sourced through Freight Contract Architecture & Rate Mapping, so a claim never argues a number the audit itself did not stand behind. Channel selection then hands off to the carrier client, whose integration details (auth, payload shape, webhook callbacks) are owned by Carrier Dispute API Integration.

Idempotency for dispute submission

Submission is the hop where duplication does real financial damage. A dispatch worker can crash after a carrier accepts a claim but before it records the receipt; on restart it re-processes the same verdict and, without a guard, files the same claim twice. Now the carrier has two open disputes for one overcharge, the ledger double-counts the expected recovery, and reconciliation later shows a phantom shortfall when only one credit arrives. Idempotency is therefore enforced at the submission boundary, keyed by the deterministic dispute_key computed on the case.

# submission/idempotent.py
import logging

logger = logging.getLogger("discrepancy.submit")


class IdempotentSubmitter:
    def __init__(self, store, carrier_client):
        self.store = store            # durable claim-receipt store
        self.client = carrier_client

    def submit(self, case, channel: str) -> dict:
        # Claim the key atomically. If a receipt already exists, this is a
        # retry of an already-filed claim — return the stored receipt instead
        # of filing again. The carrier's own idempotency header is a second
        # line of defence, not a replacement for this one.
        existing = self.store.get_receipt(case.dispute_key)
        if existing is not None:
            logger.info("submit_skip_duplicate",
                        extra={"dispute_key": case.dispute_key})
            return existing

        receipt = self.client.file_claim(
            channel=channel,
            idempotency_key=case.dispute_key,   # carrier dedups on this too
            payload=case.model_dump(mode="json"),
        )
        # Persist the receipt BEFORE acknowledging success so a crash after
        # the carrier accepts can never lose the external claim id.
        self.store.put_receipt(case.dispute_key, receipt)
        return receipt

The guarantee is that filing the same variance any number of times yields exactly one carrier claim and one expected-recovery posting. The webhook side of this — carriers calling back with status changes, which can also arrive twice — is handled with the same key discipline in Handling Dispute Webhook Callbacks Idempotently. The duplicate-billing case, where the invoice itself is the duplicate rather than the submission, is a classification problem solved in Classifying Overcharge vs Duplicate Billing in Python.

Observability and the dispute state machine

A dispute that cannot say where it is in its lifecycle is an unrecoverable dollar in waiting. Every case moves through an explicit state machine, and every transition emits a structured log line carrying the dispute_key, the from/to states, the claimed amount, and the reason. Because claims are long-lived, the observability model is built around aging, not just throughput: the metric that matters is how many open claims are approaching their carrier response deadline, not how many were submitted today.

# observability/state_machine.py
import logging

logger = logging.getLogger("discrepancy.state")

# Only these transitions are legal; anything else is a bug, not a state.
ALLOWED = {
    "CLASSIFIED": {"ROUTED", "WRITTEN_OFF"},
    "ROUTED": {"SUBMITTED", "WRITTEN_OFF"},
    "SUBMITTED": {"ACKNOWLEDGED", "REJECTED"},
    "ACKNOWLEDGED": {"RECOVERED", "REJECTED"},
}


def transition(case, to_state: str) -> None:
    frm = case.state.value
    if to_state not in ALLOWED.get(frm, set()):
        # Illegal transitions route to a dead-letter for inspection rather
        # than corrupting the claim's audit trail with an impossible jump.
        raise ValueError(f"illegal transition {frm} -> {to_state}")
    logger.info("dispute_transition", extra={
        "dispute_key": case.dispute_key,
        "from": frm, "to": to_state,
        "claimed_amount": str(case.claimed_amount),
    })
    case.state = case.state.__class__(to_state)

Three terminal destinations cover every failure. A variance below threshold or past its window terminates as WRITTEN_OFF with the reason attached. A claim the carrier denies terminates as REJECTED, preserving the denial code so recurring rejection patterns surface a bad classification rule rather than being retried blindly. A transient carrier-API failure — a timeout, a 503 — routes to a dead-letter queue and retries with backoff, never abandoning a legitimate claim because the endpoint blipped. Aging alerts derive from the open-claim ledger position, and the alerting policy itself is tuned through Threshold Tuning & Alerting so a spike in rejections on one carrier pages the right owner.

Performance and scaling

Discrepancy volume tracks audit volume, which is bursty around month-end and carrier batch drops. The work splits into two very different profiles: submission is I/O-bound and rate-limited by carrier APIs, while classification and reconciliation are CPU-bound over Decimal arithmetic and set operations. The scaling model runs them as separate worker pools with independent concurrency caps, so a slow carrier endpoint cannot starve the classifier, and the large batch fan-out is delegated to Async Batch Processing Workflows rather than threaded inside the dispatch loop.

Knob Typical range Effect Watch for
Submission concurrency 4–16 per carrier Caps in-flight claims per API Carrier rate-limit 429s above the cap
Classification workers 1 per core CPU saturation for typing math GIL contention — prefer processes
Reconciliation batch size 500–2,000 Ledger transaction amortization Lock contention on hot ledger rows
Retry backoff base 2–5 s DLQ drain rate vs carrier load Thundering-herd retries after outage
Open-claim page size 1,000–5,000 Aging-scan memory footprint Full-table scans on the claim store

As a rough benchmark, a single classification worker types and confidence-scores several thousand verdicts per second against cached rate context; submission throughput is governed entirely by the carrier’s rate limit, which is why per-carrier concurrency caps and a dead-letter drain are load-bearing rather than optional. Reconciliation is cheapest when postings are batched into one transaction per carrier remittance rather than one per line.

Failure modes and troubleshooting

The recurring incidents in this layer have stable signatures. The table maps the symptom an operator sees to its root cause and resolution.

Symptom Root cause Resolution
Two open claims for one PRO Submission retried without an idempotency guard Enforce dispute_key claim; dedup on carrier header
High auto-reject rate on one carrier Claims filed past the filing window Add deadline gate to routing; write off stale variance
Expected recovery never clears Credit posted to the wrong invoice in the ledger Reconcile by remittance line, not by claim date
Duplicate paid, real overcharge disputed Classifier lacked stable shipment identity Fingerprint + dedup key before typing the variance
FSC claims all rejected as “within tolerance” Per-line check used; drift only shows in aggregate Aggregate FSC across the batch before claiming
Short-pay deducted but untracked Deduction never posted against remittance Post every deduction to the ledger with a reason code

When a claim stalls, the first diagnostic is always to replay its state-machine log: the last legal transition tells you whether it died in routing, submission, or reconciliation, which separates a carrier-side denial from an internal posting bug before anyone touches the carrier.

Discrepancy modules

The discrepancy layer is implemented across four focused areas. Each owns one slice of the flow above and hands its output to the next.

  • Charge Variance Classification — The taxonomy and engine that types each flagged line into a specific variance with a confidence score and decides dispute-worthiness before anything is routed.
  • Carrier Dispute API Integration — The client, auth, payload contracts, and webhook handling that submit claims to carrier dispute endpoints and ingest their status callbacks without duplication.
  • Reconciliation Ledger Schema — The double-entry Postgres model that records every claim, credit, and deduction so the book balances and a disputed invoice ties to the credit that clears it.
  • Short-Pay & Deduction Management — The deduction-code logic and remittance matching that turn a variance into a tracked short-pay recovered against the carrier’s next payment.

Up: Freight Bill Auditing & Carrier Rate Contract Automation — the freight audit pipeline home.