Eligibility Rule Framework
The eligibility rule framework is the deterministic evaluation layer that decides, transaction by transaction, whether a sale qualifies for a negotiated rebate before a single dollar is accrued. Inside the broader core architecture and promotion mapping discipline, this page owns one specific sub-problem: how the predicates a contract declares — channel scopes, product hierarchies, volume thresholds, and temporal windows — become executable, auditable, reproducible qualify/disqualify decisions. Get this layer wrong and the failures are immediate and expensive: payouts against sales dated outside the promotion window, duplicate qualification on midnight rollovers, off-cycle claims that pass because a timezone was parsed as a string, and an audit trail that cannot reconstruct why a given line qualified. The audience is concrete — Python ETL developers who own the matcher, trade finance analysts who defend the resulting accrual, and vendor managers who answer for a disputed qualification.
This is the binding point between contractual intent and financial execution. Upstream, the agreement schema design emits the predicates this framework reads; downstream, payout structure modeling turns a qualifying decision into money. The framework’s only job is to render that decision deterministically — the same transaction against the same agreement version must resolve identically on every run, on every node, during every audit replay.
Positioning Within the Reconciliation Architecture
The framework sits between the agreement and the accrual as a pure function: given a normalized transaction and a resolved agreement version, it returns a qualification decision plus a machine-readable trace. It does not load contracts (that is schema design), it does not compute money (that is payout modeling), and it does not adjudicate the records it rejects (that is fallback routing logic). Keeping that boundary sharp is what lets each layer scale, version, and test independently. The framework consumes clean, typed records delivered by the data ingestion normalization pipelines, and its decisions are later referenced — by agreement version and rule manifest hash — when the claim validation rule engine adjudicates a vendor’s claim against what the system independently determined was eligible.
Because every downstream stage trusts this decision, the contract is load-bearing. An ambiguous predicate here becomes a payout dispute two stages later, and a non-deterministic ordering here becomes an audit finding at quarter-end.
Entity Topology and Schema Specification
Eligibility rules are first-class data entities, not hardcoded scripts. A rule is serialized as a structured object whose every outcome-affecting field is typed, constrained, and folded into a content hash so the rule itself is version-addressable. Treating rules as data is what allows updates without a pipeline redeploy and replay of any historical evaluation under the exact rule version that governed it.
| Field | Type | Constraint | Purpose |
|---|---|---|---|
rule_id |
ULID (str) | immutable, unique | stable identity across versions |
rule_version |
int | monotonic ≥ 1 | pins each decision to a manifest |
manifest_hash |
str (hex) | SHA-256 of canonicalized rule set | reproducibility + replay key |
predicate |
object | field-operator-value tree | the eligibility condition |
scope |
object | channel / territory / hierarchy ids | dimensional applicability |
window |
object | [start, end) UTC, optional grace |
temporal boundary |
threshold |
object | Decimal bounds, unit |
quantitative gate |
priority |
int | explicit, no row-order fallback | precedence on overlap |
phase |
enum | structural | dimensional | quantitative | routing | DAG placement |
on_fail |
enum | disqualify | quarantine | route |
failure disposition |
Predicates are stored as field-operator-value triples combined under explicit AND / OR / NOT operators rather than as opaque expression strings, so they can be hashed, diffed, and audited. The entire active rule set is canonicalized (sorted keys, normalized number formats) and hashed into a single manifest_hash; that hash travels with every decision the framework emits, which is what makes “replay the exact rules that ran on this date” a lookup rather than an archaeology project.
Conditional Logic and Rule Integration
Eligibility is rarely a single boolean. A transaction must clear several orthogonal conditions, and the order in which they are evaluated is itself part of the contract. The framework arranges rules as a topologically sorted directed acyclic graph across four phases, short-circuiting as soon as any phase disqualifies:
- Structural — does the record have the keys required to be evaluated at all (resolved SKU, resolved agreement version, valid anchor date)? A structural miss is a data-quality fault, not a disqualification, and routes to quarantine.
- Dimensional — does the transaction’s channel, territory, and product hierarchy fall inside the agreement’s declared
scope? Scope is matched by specificity, so a SKU-level rule outranks a category-level rule deterministically. - Quantitative — does the measured quantity clear the contracted
threshold(minimum volume, product-mix ratio, incremental-over-baseline)? Threshold comparisons useDecimal, never float. - Routing — for records that fail an earlier phase recoverably, which queue or default policy applies?
Temporal evaluation lives inside the dimensional phase but deserves its own discipline. Windows are half-open [start, end) intervals anchored to a single authoritative date per agreement, evaluated on timezone-aware datetime objects normalized to UTC at ingestion — never on string comparison, which fails silently around daylight-saving transitions and leap days. The detailed mechanics of anchor selection, grace buffers, and fiscal-calendar offsets are owned by the configuring promotion eligibility windows guide; the framework’s job is to enforce the boundary the agreement declares and emit a precise reason code (WINDOW_PREMATURE, WINDOW_EXPIRED, OUTSIDE_GRACE) when it fails.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def in_window(txn_ts: datetime, start: datetime, end: datetime) -> bool:
"""Half-open [start, end) on timezone-aware UTC instants."""
if txn_ts.tzinfo is None:
raise ValueError("transaction timestamp must be timezone-aware")
t = txn_ts.astimezone(timezone.utc)
return start <= t < end # end is exclusive: no midnight double-count
Financial Settlement Gating
The framework does not compute money, but it produces the gate that money flows through. A qualifying decision carries the resolved tier context — the threshold band the transaction landed in and the rate type the agreement declares — so payout structure modeling can translate qualification into a flat, incremental, or retroactive rebate without re-deriving eligibility. Every quantity the framework compares for tier placement is parsed as decimal.Decimal under a fixed context, because float drift that is immaterial on one line becomes a material, non-reproducible delta across a quarter-end run and breaks the cent-level reproducibility that IFRS 15 / ASC 606 treatment requires.
from decimal import Decimal, getcontext, ROUND_HALF_UP
getcontext().prec = 28
def tier_for(volume: Decimal, bounds: list[Decimal]) -> int:
"""Return the highest tier index whose lower bound <= volume.
bounds is ascending; comparison is exact on Decimal."""
idx = 0
for i, lower in enumerate(bounds):
if volume >= lower:
idx = i
return idx
# example: a 12,480.5-unit shipment against ascending tier floors
volume = Decimal("12480.5")
floors = [Decimal("0"), Decimal("5000"), Decimal("10000"), Decimal("25000")]
assert tier_for(volume, floors) == 2 # qualifies into the third band
The framework records the band it placed the transaction into as part of the decision trace, so the eventual accrual and the eligibility decision can be reconciled against each other field by field.
ETL Implementation Patterns
The evaluation engine is stateless: given the same inputs it returns the same outputs regardless of processing order, which is what permits horizontal scaling across compute nodes while preserving deterministic results. In a Python pipeline, the rule object is validated with Pydantic v2 at the ingestion boundary so a malformed rule never reaches the matcher.
from decimal import Decimal
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, field_validator, model_validator
class Phase(str, Enum):
structural = "structural"
dimensional = "dimensional"
quantitative = "quantitative"
routing = "routing"
class Window(BaseModel):
start: datetime
end: datetime
@model_validator(mode="after")
def half_open(self):
if self.start.tzinfo is None or self.end.tzinfo is None:
raise ValueError("window bounds must be timezone-aware")
if self.start >= self.end:
raise ValueError("inverted window: start must precede end")
return self
class EligibilityRule(BaseModel):
rule_id: str
rule_version: int
phase: Phase
window: Window | None = None
threshold_floor: Decimal | None = None
priority: int
@field_validator("rule_version")
@classmethod
def positive(cls, v: int) -> int:
if v < 1:
raise ValueError("rule_version is monotonic and >= 1")
return v
For high-throughput batches, predicate matching is vectorized rather than row-iterated — columnar engines such as Polars or DuckDB apply boolean masks across millions of line items in-memory, with partition pruning on the anchor date so off-window records are eliminated before any per-row logic runs. The DuckDB documentation covers the analytical query patterns that suit this workload. Decisions are written with an idempotent upsert keyed on (transaction_hash, manifest_hash), so re-running a batch is a no-op rather than a source of duplicate accruals. Schema evolution is handled by versioning the manifest: a new rule set increments rule_version and produces a new manifest_hash, and old decisions remain attributable to the manifest that actually evaluated them.
Drift Detection and Validation
A rule set is only trustworthy while live data keeps matching its assumptions. Drift detection runs continuously, comparing current qualification rates and tier distributions against historical baselines and flagging statistical deviations before they reach settlement. Anything that fails validation is quarantined — written to a holding table with its reason code intact and an exception ticket raised — never silently dropped and never silently accrued.
| Drift signal | Detection rule | Action |
|---|---|---|
| Qualification-rate collapse | qualify rate deviates > N σ from trailing baseline | hold batch, raise QUALIFY_ANOMALY |
| Expired-window claims | qualifying txn_date ≥ window end |
quarantine, WINDOW_EXPIRED ticket |
| Uncovered volume | quantity above top tier floor with no cap | flag CEILING_BREACH, hold decision |
| Manifest mismatch | recomputed manifest_hash ≠ stored |
block run, force re-canonicalization |
| Unresolved anchor | missing or null timezone-aware anchor date | quarantine as structural fault |
Catching these before month-end is the difference between a remediation task and a restated accrual: a proactive WINDOW_EXPIRED flag is a ticket, while the same drift found after close is an audit finding.
Fallback and Dispute Routing
When a transaction fails an earlier phase recoverably — a missing reference dimension, an ambiguous scope match, a value just outside a grace buffer — the framework does not drop it. It routes the record according to the agreement’s declared policy and emits an audit entry naming which manifest was searched, which phase failed, and where the record went. The escalation, adjudication, and SLA mechanics of those queues are owned by the fallback routing logic cluster; the framework’s responsibility is to fail with a precise, machine-readable reason code and to keep the pipeline flowing — throughput is preserved because no record blocks settlement waiting on a human, and no record is lost. Every routed decision carries the same manifest_hash as a qualifying one, so a disputed record can be replayed against the exact rules that judged it.
Security and Access Boundaries
Rule definitions and threshold overrides encode commercial terms, so authorization is enforced at the field level rather than at the endpoint. Role-based access control (RBAC) tags travel with every rule entity: ETL developers can deploy a new rule manifest but cannot mutate a posted decision; trade finance analysts can adjudicate quarantined records and export the decision trace but cannot edit a threshold; vendor-facing surfaces expose only that vendor’s own qualification outcomes. Threshold overrides and manual exception approvals require an immutable, hash-chained approval log, so every qualification ultimately traces to an exact rule version, transaction hash, and approver identity. Because the manifest is signed and immutable, any retroactive edit to a predicate surfaces as a hash mismatch during reconciliation rather than passing unnoticed. Treating eligibility as a version-controlled, access-governed artifact — not a mutable config table — is what yields deterministic decisions, fewer manual exceptions, and a trail that survives audit.
Frequently Asked Questions
What happens when a retroactive tier is crossed mid-cycle?
The framework re-evaluates the transaction’s tier placement against cumulative volume and records the new band in the decision trace, but it does not compute the re-rate itself. It signals the crossing to the settlement layer, which posts the delta versus the prior accrual under a retroactive rate_type. The original decision is preserved and a new timestamped decision captures the post-crossing band, so the audit trail shows both states.
Why evaluate rules as a DAG instead of a flat list of checks? Order is part of the contract. A flat list leaves precedence to insertion order, which is non-deterministic across loads and impossible to audit. A topologically sorted DAG makes precedence explicit — structural before dimensional before quantitative — and lets the engine short-circuit on the first disqualifying phase, so the same transaction resolves identically on every run.
Why are eligibility windows half-open [start, end) rather than inclusive on both ends?
An inclusive end double-counts any transaction landing exactly on a midnight boundary, because that instant belongs to two adjacent windows. Half-open intervals assign each instant to exactly one window, which eliminates duplicate qualification at period rollovers without a special case.
How does the framework guarantee the same decision on every node and every replay?
Every outcome-affecting input — the normalized transaction, the resolved agreement version, and the active rule set — is folded into hashes (transaction_hash, manifest_hash), evaluation is a stateless pure function with no row-order dependence, and all temporal logic runs on UTC-normalized timezone-aware instants. Identical inputs therefore produce identical outputs regardless of node or processing order.
Related
- Agreement Schema Design — where the predicates this framework evaluates are defined and version-controlled.
- Payout Structure Modeling — turning a qualifying decision into tiered, capped, decimal-precise accruals.
- Fallback Routing Logic — the adjudication queues that act on this framework’s routing reason codes.
- Configuring Promotion Eligibility Windows — the temporal-boundary mechanics behind the dimensional phase.
- Claim Validation Rule Engine — how vendor claims are adjudicated against the decisions emitted here.
Up one level: Core Architecture & Promotion Mapping