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.
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 monotonicagreement_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.6and the standard-librarydecimalmodule. Every monetary field usesdecimal.Decimal; neverfloat. - Access role: write access to the
routing_decision_logtable and read access to the contract registry (typically thereconciliation_etlservice 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.
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.
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:
- Contract baseline inheritance — inherit the default rate from the parent or category-level agreement.
- Historical baseline projection — for missing thresholds, project a rolling average from prior closed periods, adjusted for seasonality.
- Proxy mapping via product hierarchy — traverse the GS1-compliant category tree to the nearest valid promotion ancestor for an unmapped SKU.
- Zero-accrual hold — when nothing resolves, route to manual review under a hard accrual cap.
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.
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.
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
- Floating-point drift in accrual math. Computing
rate * unitswithfloatlets 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 indecimal.Decimaland quantize explicitly withDecimal("0.01"). - Non-deterministic ladder order. Iterating
exc.missingas 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 thedecision_hashis reproducible. - 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. - Silent rejection instead of routing. Dropping a claim that fails schema validation makes the liability invisible rather than managed. Always emit a
ClaimExceptionwith the raw payload preserved (Step 1) so the gap is auditable. - 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 reversingdecision_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.
Related
- Parent cluster: Fallback Routing Logic
- Agreement Schema Design — the versioned registry that supplies inheritance paths and default rates
- Eligibility Rule Framework — the relaxed predicates fallback routing applies when strict terms fail
- Payout Structure Modeling — how confirmed terms recompute the accrual once a gap is resolved