Skip to content

Agreement Schema Design

The agreement schema is the deterministic contract between commercial intent and financial execution. Inside the broader core architecture and promotion mapping discipline, this page owns one specific sub-problem: how a negotiated trade agreement — with its tiers, windows, channel scopes, and settlement cadence — becomes a normalized, queryable, version-controlled record that every downstream stage can trust. Get the schema wrong and the failure modes are systemic: claims validate against the wrong contract version, retroactive amendments silently re-rate closed periods, and a single agreement loaded twice doubles the accrual. Get it right and reconciliation becomes a function of data rather than of an analyst’s memory. The audience is concrete — Python ETL developers who model and ingest the contract, trade finance analysts who defend the accrual it produces, and vendor managers who onboard and amend it without breaking active pipelines.

This is the canonical source of truth for promotion-to-transaction binding. A single vendor contract may govern multiple program types, channel-specific accruals, and overlapping volume tiers across staggered effective dates. The schema abstracts that into a structure that survives ingestion, transformation, and settlement, and it establishes explicit parent-child lineage between master agreements, amendment addenda, and SKU-level execution rules — so every invoice line traces deterministically back to the governing clause that authorized it.

Positioning Within the Reconciliation Architecture

The schema sits upstream of every rule the system evaluates. The eligibility rule framework reads its predicates to decide which transactions qualify; payout structure modeling reads its tiers to turn qualifying volume into money; and the claim validation rule engine references a specific agreement version when it adjudicates a vendor’s claim. Because all three consume the same artifact, the schema’s contract is load-bearing: a rename here is a breaking change everywhere, and an ambiguous tier boundary here becomes a payout dispute three stages downstream.

Two design commitments make that safe. First, immutability with temporal precision — every record carries a stable primary key, a version hash, and an explicit half-open effective interval, so a contract amended mid-cycle never re-validates history under new terms. Second, deterministic precedence — overlapping validity windows resolve through declared rules, never ad-hoc overrides, which is what prevents double-counting at month-end close and gives finance a clean trail for accrual reversals.

Agreement schema entity topology An entity relationship diagram. A MasterAgreement holds the abstract contract identity (agreement_id, vendor_id, program_type) and owns one-to-many AgreementVersion records keyed by version_sequence. Each immutable AgreementVersion fans out to many ordered Tiers, many EligibilityPredicates, and exactly one SettlementTerms. An amendment addendum is itself a child AgreementVersion that references its parent through parent_agreement_id, forming a self-referential lineage. 1 : ∗ version_sequence 1 : ∗ ordered 1 : ∗ 1 : 1 parent_agreement_id MasterAgreement agreement_id (PK) · vendor_id program_type (enum) AgreementVersion version_sequence · status version_hash (SHA-256) effective [start, end) scope_hash · currency Amendment child AgreementVersion version_sequence + 1 re-rates forward only → parent_agreement_id Tier lower / upper_bound rate (Decimal) EligibilityPredicate field · op · value AND / OR / NOT tree SettlementTerms settlement_frequency fallback_policy

Entity Topology and Schema Specification

A resilient schema separates static contract metadata from dynamic execution parameters, and it separates the agreement (an abstract contract) from the agreement version (the immutable thing claims actually reference). Static metadata changes rarely and identifies the contract; execution parameters — tiers, predicates, rates — change on every amendment and are therefore versioned as a unit. Amendment tracking carries a parent_agreement_id and a monotonic version_sequence so lineage survives renegotiation, and a status flag (draft, active, suspended, expired) gates which versions are eligible for transaction matching.

The fields below are the minimum contract the downstream stages depend on. Treat them as a versioned interface: adding a nullable field is backward-compatible, while renaming or retyping one is a breaking change that bumps the schema version and forces a re-hash of affected records.

Field Type Entity Constraint
agreement_id str (ULID) Agreement immutable, primary key
vendor_id str Agreement FK to vendor master
program_type enum Agreement volume_rebate, flat_allowance, growth_incentive, scan_down
parent_agreement_id str (ULID) | null AgreementVersion self-reference for amendments
version_sequence int AgreementVersion monotonic, gap-free per agreement_id
version_hash str (SHA-256) AgreementVersion deterministic over canonicalized terms
effective_start / effective_end date AgreementVersion half-open [start, end), UTC-anchored
currency str (ISO 4217) AgreementVersion validated against allow-list
settlement_frequency enum SettlementTerms monthly, quarterly, annual
scope_hash str (SHA-256) AgreementVersion deterministic over channel+territory+hierarchy
tiers list[Tier] AgreementVersion ordered by lower_bound, non-overlapping
rate Decimal (str at rest) Tier 4 dp, parsed via decimal.Decimal, never float
status enum AgreementVersion draft, active, suspended, expired
fallback_policy enum SettlementTerms hold, default_pool, reject

The version_hash is what makes ingestion idempotent and what lets the claim engine pin a claim to exactly the terms in force when the sale occurred. It is computed over a canonical serialization of the version’s terms — sorted keys, normalized decimal strings, UTC timestamps — so two structurally identical agreements hash identically and a single semantic edit produces a new hash.

Conditional Logic and Rule Integration

Promotional agreements are inherently conditional, so the schema must encode eligibility boundaries in a form an evaluation engine parses without ambiguity. Channel constraints, geographic territories, product hierarchies, and minimum purchase thresholds are modeled as an array of predicates — each a (field, operator, value) triple — combined under explicit logical operators (AND, OR, NOT) rather than implied by field adjacency. This keeps the encoded scope machine-readable and lets the eligibility framework resolve the applicable rule set by matching transactional attributes (store_id, sku_class, invoice_date) against the predicate tree.

json
{
  "scope": {
    "op": "AND",
    "predicates": [
      {"field": "channel", "op": "in", "value": ["GROCERY_RETAIL", "CLUB"]},
      {"field": "territory", "op": "eq", "value": "US"},
      {"field": "sku_hierarchy", "op": "descendant_of", "value": "BEV-CARBONATED"},
      {"field": "min_qty", "op": "gte", "value": "5000"}
    ]
  }
}

Encoding scope as data rather than as branching code is what lets vendor managers onboard a new promotion without a pipeline deploy, and it is what gives deterministic precedence its raw material: when two active versions could both match a transaction, the resolver ranks them by a declared key (specificity of scope, then version_sequence) so the outcome never depends on row order. The scope_hash over this predicate tree lets the engine cache eligibility decisions and detect when an amendment actually changed who qualifies versus merely restating terms.

Financial Settlement Layer

Once eligibility is confirmed, the schema drives accurate financial modeling. The settlement layer defines how qualifying volume becomes monetary value — accrual rates, caps, floors, and cadence — and it deliberately keeps calculation logic separate from storage format so finance can simulate accruals under varying volume scenarios before committing a vendor settlement. Tiered structures are stored as ordered arrays with explicit boundary conditions (lower_bound, upper_bound, rate), rates held as strings and parsed via decimal.Decimal at evaluation time to eliminate floating-point accumulation across a quarter-end run.

The distinction that drives the most disputes is how a crossed boundary re-rates volume. Incremental tiers rate each band at its own rate; retroactive tiers re-rate the entire cumulative volume at the highest earned rate the moment a breakpoint is crossed. The schema must store rate_type explicitly per agreement so the modeling layer never guesses.

python
from decimal import Decimal, ROUND_HALF_UP

def incremental_rebate(volume: Decimal, tiers: list["Tier"]) -> Decimal:
    """Each band earns its own rate; only volume within the band is rated at that band."""
    total = Decimal("0")
    for t in tiers:
        if volume <= t.lower_bound:
            break
        upper = min(volume, t.upper_bound or volume)
        total += (upper - t.lower_bound) * t.rate
    return total.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)

def retroactive_rebate(volume: Decimal, tiers: list["Tier"]) -> Decimal:
    """Crossing a breakpoint re-rates the ENTIRE cumulative volume at the highest earned rate."""
    earned = next(t.rate for t in reversed(tiers) if volume >= t.lower_bound)
    return (volume * earned).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)

For the full schema patterns behind progressive, flat, and retroactive tiers — including the JSON layout and ordering guarantees — see how to map vendor rebate tiers in JSON. Properly modeled tiers eliminate rounding discrepancies and ensure a retroactive tier jump applies consistently across historical transaction batches rather than only to records processed after the crossing.

ETL Implementation Patterns

Schema enforcement begins at ingestion. Model the contract with Pydantic v2 so type constraints, required fields, and custom validators for temporal windows and currency codes fail fast at the boundary instead of surfacing as a bad accrual weeks later. The validator below rejects an inverted interval and an unordered tier array — two of the most common causes of silent mis-rating.

python
from datetime import date
from decimal import Decimal
from pydantic import BaseModel, model_validator

class Tier(BaseModel):
    lower_bound: Decimal
    upper_bound: Decimal | None = None
    rate: Decimal

class AgreementVersion(BaseModel):
    agreement_id: str
    version_sequence: int
    effective_start: date
    effective_end: date
    currency: str
    tiers: list[Tier]

    @model_validator(mode="after")
    def _check(self) -> "AgreementVersion":
        if self.effective_end <= self.effective_start:
            raise ValueError("effective_end must be after effective_start (half-open interval)")
        bounds = [t.lower_bound for t in self.tiers]
        if bounds != sorted(bounds):
            raise ValueError("tiers must be ordered by ascending lower_bound")
        return self

Ingestion writes through idempotent upserts keyed on (agreement_id, version_hash). Because the hash is deterministic over canonicalized terms, re-loading an unchanged contract is a no-op and a genuine amendment lands as a new version row rather than overwriting history — which is exactly what the data ingestion and normalization pipelines stage needs to keep its loads replay-safe. Schema evolution follows semantic versioning: backward-compatible additions ship with a dual-read compatibility layer during the migration window so legacy contracts and modernized agreements coexist without a forced structural migration. Aligning the published contract with the JSON Schema specification lets vendor portals, BI tools, and the reconciliation engine consume one definition without per-consumer transformation.

Drift Detection and Validation

A schema is only trustworthy if the live data keeps matching it. Drift detection runs continuous validation of incoming transaction streams against active agreement versions, comparing expected accruals to actual payouts and flagging statistical deviations: expired versions still generating claims, rate overrides outside the contracted band, missing tier boundaries, or a sudden volume distribution that no active tier covers. Anything that fails validation is quarantined — written to a holding table with its mismatch reason intact and an exception ticket raised — rather than discarded, so no record is lost and none is silently accrued.

Drift signal Detection rule Action
Expired-version claim claim txn_date ≥ version effective_end quarantine, raise EXPIRED_VERSION ticket
Out-of-band rate applied rate ∉ contracted tier rates quarantine, route to finance review
Uncovered volume volume > top tier upper_bound and no cap flag CEILING_BREACH, hold accrual
Hash mismatch recomputed version_hash ≠ stored block load, force re-canonicalization

Catching these before month-end is what preserves margin integrity: a proactive EXPIRED_VERSION flag is a remediation task, while the same drift discovered after close is a restated accrual and an audit finding.

Fallback and Dispute Routing

When a transaction matches no active agreement version, the schema’s fallback_policy decides its fate deterministically — hold for manual review, default_pool to a declared default-rate accrual, or reject outright — and a dispute_routing field maps the case to the owning queue. Encoding the policy in the agreement rather than in pipeline code means unmatched transactions never stall settlement and never default to an analyst’s discretion. The escalation, adjudication, and audit-log mechanics of those queues are owned by the fallback routing logic cluster; the schema’s job is to name the correct policy and emit an audit entry that records which version was searched, why no match was found, and where the record was routed.

Security and Access Boundaries

Commercial terms carry sensitive pricing subject to internal compliance controls, so confidentiality is encoded at the schema level rather than bolted on at the endpoint. Field-level encryption protects rate, cap_amount, and vendor_contact at rest, and role-based access control (RBAC) tags travel with every entity so authorization is enforced per field: vendor-facing portals expose only that vendor’s contracted terms and settlement statements; ETL developers can deploy schema manifests but cannot mutate posted accruals; trade finance analysts can adjudicate exceptions and export audit trails but cannot edit a tier rate. Encryption keys and portal credentials rotate on a fixed schedule, and because the agreement version is immutable and signed, any retroactive edit to a sensitive field surfaces as a hash mismatch in reconciliation rather than passing unnoticed. Treating the agreement schema as a living, version-controlled, access-governed artifact — not a static database table — is what yields deterministic reconciliation, fewer manual exceptions, and a financial trail that survives audit.

Frequently Asked Questions

Why version the agreement instead of editing it in place? A claim references the exact terms in force when the sale occurred. If you edit an agreement in place, every historical claim silently re-validates under the new terms, which corrupts closed periods and destroys the audit trail. Versioning with an immutable version_hash pins each claim to a specific version_sequence, so an amendment creates a new row and history stays attributable to the rates that actually governed it.

What happens when a retroactive tier is crossed mid-cycle? With rate_type set to retroactive, retroactive_rebate re-rates the entire cumulative volume at the newly earned rate and the settlement layer posts the delta versus the prior accrual rather than a fresh full accrual. The original accrual record is preserved and a new timestamped record captures the re-rate, so the audit trail shows both the pre- and post-crossing state.

Why store rates as strings and parse with decimal.Decimal? Float arithmetic accumulates non-reproducible rounding drift that is immaterial per line but material across a quarter-end run, and it differs across platforms. Because IFRS 15 / ASC 606 treatment requires accruals reproducible to the cent and the version_hash must be identical on every environment, rates are held as strings at rest and parsed into Decimal with a fixed context and explicit ROUND_HALF_UP at evaluation.

How do two overlapping agreement versions get resolved deterministically? The resolver ranks matching versions by a declared precedence key — scope specificity first, then version_sequence — never by row order or insertion time. This guarantees the same transaction resolves to the same version on every run, which is what prevents double-counting and makes the eligibility decision reproducible during an audit.

Up one level: Core Architecture & Promotion Mapping