Skip to content

Fallback Routing for Missing Agreement Terms

When a rebate claim, vendor invoice, or POS line arrives without fully populated agreement terms — no payout rate, no tier thresholds, an unresolved SKU, or a missing effective window — the reconciliation engine cannot simply reject it without leaking accrual accuracy, and it must not silently guess a number it cannot defend in an audit. This page documents the exact procedure for handling that gap: classify the missing term, resolve it through a deterministic precedence ladder, cap the provisional accrual so unverified liability cannot accumulate, and write an immutable decision record. It is the implementation-level companion to the fallback routing logic cluster, which governs the broader cascading-decision architecture inside Core Architecture & Promotion Mapping.

Fallback resolution precedence ladder A claim exception carrying at least one missing agreement term enters a classifier that enumerates the absent fields. Each gap descends a fixed precedence ladder of four rungs evaluated top to bottom: rung one inherits a contract baseline default rate (high confidence); rung two projects a historical baseline rolling average for missing thresholds (medium confidence); rung three proxy-maps an unmapped SKU to its nearest valid promotion ancestor in the GS1 hierarchy (low confidence); and rung four, reached only when nothing resolves, places a zero-accrual hold routed to manual review under a hard cap (held). A rung that matches exits to the right with its confidence tag; an unmatched rung descends to the next. Every resolution feeds a single finalize stage that clamps the provisional accrual to the historical mean times 1.15 and writes one immutable routing_decision_log row with a stable decision_hash. Claim exception ≥1 missing term · raw payload kept Classify the gap exhaustive: which required fields absent per gap, in fixed order 1 Contract baseline inheritance parent / category default rate match no match 2 Historical baseline projection rolling avg of prior closed periods match no match 3 Proxy mapping via hierarchy nearest valid promotion ancestor (GS1) match nothing resolved 4 Zero-accrual hold → manual review, hard accrual cap held contract_baseline confidence: high historical_baseline confidence: medium proxy_mapping confidence: low zero_accrual_hold confidence: held · route: manual_review every resolution → finalize Cap the provisional accrual & log the decision capped = min( rate × units , historical_mean × 1.15 ) · Decimal, quantized to 0.01 writes one immutable routing_decision_log row · stable decision_hash · carries the confidence tag

Prerequisites

Before wiring fallback resolution into the pipeline, confirm the following are in place:

  • A versioned agreement registry. Default rates and parent-contract inheritance paths originate in agreement schema design and must carry an agreement_id, a monotonic agreement_version, and explicit inheritance and override permissions. Fallback resolves against this registry; it never fabricates terms outside it.
  • Normalized, deduplicated input. Claims arrive from the data ingestion normalization pipelines already reduced to a single unit-of-measure with external identifiers resolved, so a “missing SKU mapping” is a genuine gap rather than an un-normalized code.
  • Eligibility predicates available. Relaxed fallback criteria (grace windows, category-level minimums) are read from the eligibility rule framework rather than hard-coded into the router.
  • Python packages: pydantic>=2.6 and the standard-library decimal module. Every monetary field uses decimal.Decimal; never float.
  • Access role: write access to the routing_decision_log table and read access to the contract registry (typically the reconciliation_etl service role). Financial override functions are out of scope for this role.

Step-by-step implementation

Step 1 — Model the missing-term exception

Encode the gap as a typed Pydantic v2 record so that an unclassifiable exception fails at load time rather than slipping into the accrual run untracked. Preserving the original payload is what keeps the claim traceable instead of rejected.

python
from decimal import Decimal
from enum import Enum
from typing import Optional
from pydantic import BaseModel, field_validator


class MissingTerm(str, Enum):
    PAYOUT_RATE = "payout_rate"
    TIER_THRESHOLD = "tier_threshold"
    SKU_MAPPING = "sku_mapping"
    EFFECTIVE_WINDOW = "effective_window"


class ClaimException(BaseModel):
    vendor_id: str
    promotion_id: Optional[str]
    claim_period: str            # ISO 'YYYY-MM' fiscal period
    sku: Optional[str]
    units: int
    missing: list[MissingTerm]
    raw_payload: dict            # original submission, kept verbatim

    @field_validator("missing")
    @classmethod
    def at_least_one_gap(cls, v: list[MissingTerm]) -> list[MissingTerm]:
        if not v:
            raise ValueError("ClaimException requires at least one missing term")
        return v

Validation check: construct a ClaimException with an empty missing list and assert it raises ValidationError. A record with no declared gap must never enter the fallback path.

Step 2 — Classify the gap against the schema

Resolve the claim against the master agreement and enumerate exactly which required fields are absent. Strict schema validation produces a structured exception code instead of an outright rejection, attaching a reconciliation timestamp so liability stays traceable.

python
REQUIRED = ("payout_rate", "tier_threshold", "sku_mapping", "effective_window")

def classify(claim: dict, agreement: dict | None) -> list[MissingTerm]:
    if agreement is None:
        # no resolvable agreement at all — every term is missing
        return [MissingTerm(f) for f in REQUIRED]
    gaps = []
    if agreement.get("payout_rate") is None:
        gaps.append(MissingTerm.PAYOUT_RATE)
    if not agreement.get("tiers"):
        gaps.append(MissingTerm.TIER_THRESHOLD)
    if claim.get("sku") not in agreement.get("sku_map", {}):
        gaps.append(MissingTerm.SKU_MAPPING)
    if agreement.get("effective_window") is None:
        gaps.append(MissingTerm.EFFECTIVE_WINDOW)
    return gaps

Validation check: pass a fully populated agreement and assert classify(...) == []; pass agreement=None and assert all four terms are returned. The classifier must be exhaustive, not best-effort.

Step 3 — Resolve through the precedence ladder

Evaluate each gap against a strictly ordered hierarchy of resolution paths. The order is fixed so the same input always produces the same routing decision — auditability depends on this determinism. The four rungs descend from most to least trustworthy:

  1. Contract baseline inheritance — inherit the default rate from the parent or category-level agreement.
  2. Historical baseline projection — for missing thresholds, project a rolling average from prior closed periods, adjusted for seasonality.
  3. Proxy mapping via product hierarchy — traverse the GS1-compliant category tree to the nearest valid promotion ancestor for an unmapped SKU.
  4. Zero-accrual hold — when nothing resolves, route to manual review under a hard accrual cap.
python
def resolve(exc: ClaimException, registry) -> dict:
    for term in exc.missing:
        if term is MissingTerm.PAYOUT_RATE:
            rate = registry.parent_default_rate(exc.vendor_id)
            if rate is not None:
                return _routed("contract_baseline", Decimal(rate), exc)
        if term is MissingTerm.TIER_THRESHOLD:
            avg = registry.rolling_threshold(exc.vendor_id, periods=4)
            if avg is not None:
                return _routed("historical_baseline", Decimal(avg), exc)
        if term is MissingTerm.SKU_MAPPING:
            proxy = registry.nearest_promotion_ancestor(exc.sku)
            if proxy is not None:
                return _routed("proxy_mapping", Decimal(proxy.rate), exc)
    return _routed("zero_accrual_hold", Decimal("0"), exc, route="manual_review")

Validation check: stub the registry so only nearest_promotion_ancestor returns a value and assert the result’s rule == "proxy_mapping"; stub all paths to None and assert it falls through to zero_accrual_hold with route == "manual_review". The ladder must never skip a higher rung when one was available.

Step 4 — Cap the provisional accrual and log the decision

Resolution alone is not enough — an inherited or projected rate must be clamped against historical averages so a bad parent default cannot post unbounded liability, and every decision must be reconstructable point-in-time.

python
import hashlib, json
from datetime import datetime, timezone

CAP_FACTOR = Decimal("1.15")   # provisional payout ≤ 115% of historical mean

def _routed(rule, rate, exc, route="auto_accrue"):
    return {"rule": rule, "rate": rate, "route": route, "exc": exc}

def finalize(routed: dict, historical_mean: Decimal, registry) -> dict:
    rate = routed["rate"]
    accrual = (rate * routed["exc"].units).quantize(Decimal("0.01"))
    cap = (historical_mean * CAP_FACTOR).quantize(Decimal("0.01"))
    capped = min(accrual, cap)
    record = {
        "vendor_id": routed["exc"].vendor_id,
        "promotion_id": routed["exc"].promotion_id,
        "claim_period": routed["exc"].claim_period,
        "sku": routed["exc"].sku,
        "missing": [m.value for m in routed["exc"].missing],
        "applied_rule": routed["rule"],
        "provisional_accrual": str(capped),
        "capped": capped < accrual,
        "route": routed["route"],
        "ts": datetime.now(timezone.utc).isoformat(),
    }
    blob = json.dumps(record, sort_keys=True)
    record["decision_hash"] = hashlib.sha256(blob.encode()).hexdigest()
    registry.write_routing_log(record)
    return record

Validation check: feed an accrual that exceeds the cap and assert record["capped"] is True and provisional_accrual == str(cap). Re-run finalize with identical inputs and assert the decision_hash is stable — a non-deterministic hash means an unsnapshotted read leaked in.

Step 5 — Arm the retroactive adjustment trigger

When the missing term is later populated, post a delta reconciliation to the original fiscal period so revenue recognition stays aligned with ASC 606. The provisional record’s decision_hash is the anchor the delta reverses against; rate-side recomputation of the confirmed payout itself belongs to payout structure modeling.

python
def reconcile_delta(prior: dict, confirmed_rate: Decimal, registry) -> dict:
    confirmed = (confirmed_rate * prior_units(prior)).quantize(Decimal("0.01"))
    delta = confirmed - Decimal(prior["provisional_accrual"])
    return registry.post_adjustment(
        period=prior["claim_period"],       # original period, not current
        reverses=prior["decision_hash"],
        amount=delta,
    )

Validation check: confirm a rate higher than the provisional one and assert the posted delta is positive and targets prior["claim_period"], not the current period. Drift between routed fallback values and confirmed terms should also feed agreement-drift alerts so persistent deviations prompt renegotiation.

Common failure modes and fixes

  1. Floating-point drift in accrual math. Computing rate * units with float lets rounding error compound across millions of lines and pushes claims past their cap by cents that aggregate into material variance. Keep every rate and amount in decimal.Decimal and quantize explicitly with Decimal("0.01").
  2. Non-deterministic ladder order. Iterating exc.missing as an unordered set, or reading registry parameters live during evaluation, lets two runs resolve the same claim differently. Fix the precedence order in code (Step 3) and snapshot the registry inputs so the decision_hash is reproducible.
  3. Uncapped parent default. Inheriting a stale or wrong parent rate without the cap clamp posts unbounded provisional liability that nobody approved. Always route the inherited rate through finalize (Step 4); past-cap amounts clamp and flag rather than pay.
  4. Silent rejection instead of routing. Dropping a claim that fails schema validation makes the liability invisible rather than managed. Always emit a ClaimException with the raw payload preserved (Step 1) so the gap is auditable.
  5. Delta posted to the wrong period. Posting a retroactive correction to the current period instead of the original one breaks period-over-period accrual tie-out. Always pass period=prior["claim_period"] and reference the reversing decision_hash (Step 5).

Operational checklist

Frequently asked questions

What happens when a missing term is populated after the period closes? Step 5 posts a delta reconciliation to the original claim_period and reverses against the provisional record’s decision_hash, so the correction is reconstructable rather than a fresh untraceable entry. The provisional accrual is never silently overwritten in place.

Why cap the accrual instead of paying the resolved rate in full? A fallback rate is an estimate, not a confirmed term. Clamping provisional payout to a configurable multiple of the historical mean bounds the unverified liability while the gap is open; the cap releases when the real term is confirmed and the delta is posted.

How do I keep two reconciliation runs reproducible? Fix the precedence order in code, snapshot the registry inputs the ladder reads, and hash the finalized decision. Identical inputs then yield an identical decision_hash and byte-identical accruals; any divergence points at a live read that was not snapshotted.

Who can override a zero-accrual hold? Only roles with explicit financial-override permission, and overrides require dual authorization that generates an immutable audit log entry. The reconciliation_etl service role that runs the pipeline configures routing rules but cannot release held liability.