Skip to content

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.

Four-phase eligibility evaluation DAG with short-circuit exits A left-to-right spine evaluates a normalized transaction and its resolved agreement version through four ordered phases that short-circuit on the first failure. Phase 1, structural, checks that the keys required to evaluate the record are resolvable; a structural miss exits down to a Quarantine terminal as a data-quality fault. Phase 2, dimensional, checks channel, territory, hierarchy scope and the half-open UTC window; a scope or window miss drops into the routing phase. Phase 3, quantitative, checks that the volume clears the Decimal tier floor; falling below the floor drops into the routing phase. Phase 4, routing, applies the on_fail policy and disposes recoverable failures either to a Disqualify terminal for hard fails or to a Fallback queue for recoverable ones. A record that clears all three gates reaches the terminal Qualify decision, which carries the manifest hash and decision trace. EVALUATION DAG · SHORT-CIRCUITS ON THE FIRST FAILING PHASE Input normalized txn + agreement version 1 Structural keys resolvable? SKU · version · anchor date 2 Dimensional in scope & window? channel · UTC [start, end) 3 Quantitative clears threshold? Decimal tier floor Qualify decision + trace manifest_hash pinned data-quality fault scope / window miss below floor Quarantine holding table + ticket never dropped / accrued 4 Routing apply on_fail policy emit reason code + manifest_hash hard recoverable Disqualify Fallback queue

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:

  1. 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.
  2. 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.
  3. Quantitative — does the measured quantity clear the contracted threshold (minimum volume, product-mix ratio, incremental-over-baseline)? Threshold comparisons use Decimal, never float.
  4. 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.

python
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.

python
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.

python
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.

Up one level: Core Architecture & Promotion Mapping