Amendment Versioning & Ledgers

A commercial lease is never signed once. Over its life it accretes amendments, estoppel certificates, subordination agreements, and assignments — each one changing the rent, the term, a notice window, or the party who owes the money. This page sits inside Core Architecture & Lease Taxonomy and takes up the append-only ledger that the parent architecture names but does not build: instead of editing the canonical record in place when an amendment lands, you append an immutable event and let a resolver fold the ordered history down to the state that was in force on any date you ask for. The specific problem solved here is reconstructing what the lease said on a given day — and proving which document said it — years after the fact.

Editing a lease record in place destroys the one thing an auditor, a portfolio accountant, and a retroactive rent roll all need: the ability to answer “what did we bill in Q3 2024, and under which instrument?” once a Q4 amendment has already overwritten the field. The pattern below treats the base lease plus its ordered amendment events as the source of truth, derives the active lease data models snapshot on demand, and carries a provenance pointer on every resolved value so the number on the rent roll always traces back to the page it came from. This is the temporal backbone the “latest effective amendment wins” rule depends on.

Append-only amendment ledger folded to an as-of canonical state A base lease and three ordered amendment events sit on a timeline: the base sets base rent and a twelve-month notice window, a first amendment lowers the notice window to six months effective 2023, a second amendment raises base rent effective 2024, and an assignment changes the tenant effective 2025. An as-of resolver reads the immutable events up to a query date and folds them, keeping the latest effective value per field. It emits an effective canonical state whose fields each carry a provenance arrow back to the specific document that supplied the winning value. Append-only event ledger (ordered by effective date) Base lease rent · notice 12mo eff. 2022-01-01 Amendment 1 notice → 6mo eff. 2023-06-01 Amendment 2 base rent ↑ eff. 2024-01-01 Assignment tenant → NewCo eff. 2025-03-01 As-of resolver fold events ≤ query date · latest effective wins Effective canonical state · as of 2024-07-01 notice_window = 6mo base_rent = raised value tenant = original ← Amendment 1 ← Amendment 2 ← Base lease each field carries a provenance pointer to its source document Provenance which document supplied each value survives an audit
The base lease and its ordered amendment events are immutable; a resolver folds every event up to the query date, keeping the latest effective value per field and tagging each with the document that supplied it.

Where an append-only ledger earns its keep

The core architecture establishes the canonical layer as a contract; this page is what keeps that contract honest over time. Three responsibilities belong here and nowhere else:

  • Upstream of it: a validated lease.canonicalized record arrives from the ingestion seam, and each subsequent amendment arrives the same way — as its own document with its own content hash, never as a mutation of the base. The metadata normalization standards that align field names apply to amendment events exactly as they do to the base lease.
  • This stage: persist every change as an ordered, immutable event; resolve the effective state as of any date; and attach provenance so every winning value points back to its source instrument.
  • Downstream of it: billing, compliance exports, and CRM read the resolved snapshot, not the raw event log, and low-confidence or conflicting amendments divert through fallback routing logic to human review before they are allowed to change what a tenant is billed.

A ledger that also tries to classify clauses or evaluate escalation formulas becomes impossible to audit. It answers exactly one question — “given this lease and its amendment history, what were the terms on date D, and which document set each of them?” — and delegates the rent arithmetic to escalation formula mapping and access control to security & access boundaries.

Prerequisites and environment setup

The reference implementation targets Python 3.11+ with a small, vendorable dependency set. Money is Decimal end to end, dates are ISO-8601, and events are validated by pydantic v2 before they are ever appended.

Dependency Version Role in the ledger
python 3.11+ datetime.date, pattern matching, immutable frozen models
pydantic 2.6+ Validating each AmendmentEvent at append time via field_validator / model_validator; frozen models enforce immutability
decimal (stdlib) Exact monetary values so retroactive rent rolls reconcile to the cent
relational store (Postgres or equivalent) A lease_versions / events table, append-only, with a unique constraint per (lease_id, sequence)

Install with pip install "pydantic>=2.6". Two storage assumptions matter. First, the events table is append-only by construction: grant the ingestion role INSERT but not UPDATE or DELETE on it, so an in-place edit is impossible at the database level rather than merely discouraged in code. Second, the table carries both an effective date (when the change takes force in the real world) and a recorded-at timestamp (when the system learned of it); the two differ whenever an amendment is signed late or backdated, and every retroactive query depends on keeping them distinct. A single versions table keyed on (lease_id, sequence) with a monotonic sequence per lease is enough; the resolver never mutates a row, it only reads.

The event-sourced data model

Model change as a fold. The base lease is the seed; each AmendmentEvent is an immutable delta carrying only the fields it touches, an effective date, an ordering sequence, and a pointer to the document that authorized it. Resolving the state as of date D means replaying every event whose effective date is on or before D, in order, and keeping the last value written to each field — the “latest effective amendment wins” rule, made concrete.

Concept What it holds Immutability rule
Base lease The full canonical record at commencement Written once; never edited
Amendment event A sparse delta: only the fields this instrument changes Appended; never updated or deleted
Effective date The date the change takes force Fixed at append time
Sequence Monotonic tie-breaker within a lease Assigned at append; gap-free
Provenance document_id + hash of the source instrument Travels with every resolved field
As-of snapshot Derived, not stored — the fold result for a date Recomputed on demand; cacheable

The decision the resolver makes at each field is small but load-bearing: keep the current winner, or let the incoming event override it. The table below is the whole precedence policy.

Incoming event vs. current winner Effective date on/before query date? Field present in this event? Resolver action
Later effective date, same field Yes Yes Override — this becomes the winner, provenance updated
Earlier or equal effective date Yes Yes Keep existing winner unless sequence is strictly higher
Same effective date, higher sequence Yes Yes Override — sequence breaks the tie deterministically
Any event No (effective after query date) Skip — invisible to this as-of query
Later event Yes No (field untouched) Keep — a sparse delta never nulls a field it omits

Primary implementation

The resolver below folds an ordered event stream into an as-of snapshot and records, per field, which document supplied the winning value. Every event is a frozen pydantic model validated at construction, so a malformed amendment can never enter the ledger. Monetary deltas are Decimal; dates are date. The fold is deliberately pure — it takes the base plus events and a query date, and returns a snapshot with provenance, mutating nothing.

from __future__ import annotations

from datetime import date
from decimal import Decimal
from typing import Any, Optional

from pydantic import BaseModel, Field, field_validator, model_validator

# Fields an amendment is permitted to change. Anything outside this set is
# rejected at append time so the ledger never accretes untyped keys.
MUTABLE_FIELDS = {
    "base_rent_monthly",
    "notice_window_months",
    "expiration_date",
    "tenant_id",
    "escalation_type",
}


class AmendmentEvent(BaseModel):
    """One immutable, effective-dated change to a lease.

    `changes` is sparse: it holds only the fields this instrument alters.
    A field absent from `changes` is left untouched by the fold.
    """
    model_config = {"frozen": True}

    lease_id: str
    sequence: int = Field(ge=0)          # monotonic, gap-free within a lease
    kind: str                            # amendment | estoppel | subordination | assignment
    effective_date: date
    recorded_at: date
    document_id: str                     # provenance: the source instrument
    document_hash: str                   # content hash of that instrument
    changes: dict[str, Any] = Field(default_factory=dict)

    @field_validator("changes")
    @classmethod
    def only_known_fields(cls, v: dict[str, Any]) -> dict[str, Any]:
        unknown = set(v) - MUTABLE_FIELDS
        if unknown:
            raise ValueError(f"amendment touches unknown fields: {sorted(unknown)}")
        # Money is coerced to Decimal at the boundary, never left as float/str.
        if "base_rent_monthly" in v:
            v = {**v, "base_rent_monthly": Decimal(str(v["base_rent_monthly"]))}
        return v

    @model_validator(mode="after")
    def dates_are_coherent(self) -> "AmendmentEvent":
        if self.recorded_at < self.effective_date and self.kind != "amendment":
            # informational only for non-amendments; amendments may be backdated
            pass
        return self


class ResolvedField(BaseModel):
    """A single field of the as-of snapshot plus the document that set it."""
    value: Any
    source_document_id: str
    effective_date: date
    sequence: int


class AsOfSnapshot(BaseModel):
    lease_id: str
    as_of: date
    fields: dict[str, ResolvedField]

    def value(self, name: str) -> Any:
        return self.fields[name].value

    def provenance(self, name: str) -> str:
        return self.fields[name].source_document_id


class LeaseLedger:
    """Append-only ledger: a base lease plus an ordered list of events."""

    def __init__(self, lease_id: str, base: dict[str, Any], base_document_id: str,
                 commencement_date: date) -> None:
        self.lease_id = lease_id
        self._events: list[AmendmentEvent] = []
        # The base lease is modeled as sequence 0 so the fold has a single code path.
        self._base = AmendmentEvent(
            lease_id=lease_id,
            sequence=0,
            kind="base",
            effective_date=commencement_date,
            recorded_at=commencement_date,
            document_id=base_document_id,
            document_hash="base",
            changes={k: v for k, v in base.items() if k in MUTABLE_FIELDS},
        )

    def append(self, event: AmendmentEvent) -> None:
        if event.lease_id != self.lease_id:
            raise ValueError("event belongs to a different lease")
        if self._events and event.sequence <= self._events[-1].sequence:
            raise ValueError("sequence must be strictly increasing (append-only)")
        if event.sequence == 0:
            raise ValueError("sequence 0 is reserved for the base lease")
        self._events.append(event)  # never update or delete an existing event

    def resolve_as_of(self, query_date: date) -> AsOfSnapshot:
        """Fold every event effective on or before `query_date` into a snapshot.

        Ordering key is (effective_date, sequence): a later effective date wins,
        and sequence breaks ties for two amendments dated the same day.
        """
        visible = [e for e in [self._base, *self._events]
                   if e.effective_date <= query_date]
        visible.sort(key=lambda e: (e.effective_date, e.sequence))

        resolved: dict[str, ResolvedField] = {}
        for event in visible:
            for name, value in event.changes.items():
                resolved[name] = ResolvedField(
                    value=value,
                    source_document_id=event.document_id,
                    effective_date=event.effective_date,
                    sequence=event.sequence,
                )
        return AsOfSnapshot(lease_id=self.lease_id, as_of=query_date, fields=resolved)


# --- Usage: reconstruct the rent roll as it stood mid-2024 --------------------
ledger = LeaseLedger(
    lease_id="L-1001",
    base={"base_rent_monthly": Decimal("15000.00"), "notice_window_months": 12,
          "tenant_id": "T-ACME"},
    base_document_id="DOC-BASE-1001",
    commencement_date=date(2022, 1, 1),
)
ledger.append(AmendmentEvent(
    lease_id="L-1001", sequence=1, kind="amendment",
    effective_date=date(2023, 6, 1), recorded_at=date(2023, 5, 20),
    document_id="DOC-AMD-1", document_hash="a1f9...",
    changes={"notice_window_months": 6},
))
ledger.append(AmendmentEvent(
    lease_id="L-1001", sequence=2, kind="amendment",
    effective_date=date(2024, 1, 1), recorded_at=date(2023, 12, 10),
    document_id="DOC-AMD-2", document_hash="b7c2...",
    changes={"base_rent_monthly": Decimal("16250.00")},
))

snap = ledger.resolve_as_of(date(2024, 7, 1))
assert snap.value("base_rent_monthly") == Decimal("16250.00")
assert snap.provenance("base_rent_monthly") == "DOC-AMD-2"
assert snap.value("notice_window_months") == 6            # from Amendment 1
assert snap.value("tenant_id") == "T-ACME"                # never amended, from base

Notice what the fold does not do: it never overwrites a field an event omits, so a sparse amendment that only changes the notice window leaves the base rent — and its provenance — exactly as the prior winner set them. The resolved escalation type flows straight into the escalation formula mapping evaluator, which reads the as-of snapshot rather than the raw event log, so a retroactive CPI revision and a retroactive amendment are handled by the same replay.

Validation and quality gates

Correctness here is temporal, so the gates check invariants over the whole event history, not just one record:

  • Append-only invariant. The store grants INSERT only; the append method refuses any sequence that is not strictly greater than the last. There is no code path — and no database permission — that edits or deletes a recorded event. Any “correction” is itself a new event.
  • Monotonic, gap-free sequences. Each lease owns a (lease_id, sequence) unique constraint. A gap or a duplicate sequence means an event was lost or double-written, and the resolver’s tie-break stops being deterministic. Assert sequences == list(range(len(events) + 1)) in a nightly integrity check.
  • No in-place mutation. Snapshots are derived. If a downstream service ever needs to persist a snapshot (for caching), it stores it in a separate, clearly-labeled materialized table keyed on (lease_id, as_of) — never back into the events table.
  • Provenance completeness. Every field in a resolved snapshot must carry a non-null source_document_id. A field with no provenance means a value entered the ledger without a document behind it, which fails an ASC 842 / IFRS 16 audit on sight. Gate it: all(f.source_document_id for f in snap.fields.values()).

Amendments whose extraction confidence falls below the review threshold never append silently. They divert through fallback routing logic to a human, because appending a wrong rent — even reversibly — still means a wrong number was billed for the window before the correction event lands.

Troubleshooting

Symptom Likely cause Diagnostic + fix
Two amendments with the same effective date resolve unpredictably Fold ordered only by effective_date, no tie-breaker Sort by (effective_date, sequence) so the higher sequence wins deterministically; never rely on insertion order alone.
An as-of query returns a value that never appears in any document Out-of-order effective dates folded in append order Always re-sort visible events by effective date before folding; append order and effective order diverge whenever an amendment is backdated.
A resolved field has no source document Provenance not written, or a base field omitted from the seed Enforce source_document_id on every ResolvedField; seed the base as sequence 0 so its fields carry DOC-BASE provenance.
A field the amendment did not mention comes back null Full-record overwrite instead of a sparse delta Store only changed fields in changes; the fold must skip absent keys, never null them.
A superseded value is still referenced by an old export Downstream cached a snapshot and did not re-resolve after a new event Invalidate the materialized snapshot on every append; key caches on the max sequence so a new event busts them.
A retroactive amendment doesn’t change last quarter’s rent roll Query used recorded_at instead of effective_date Reconstruct historical rolls by effective date; use recorded_at only for “what did we know on day X” audit questions.

Performance and scale notes

Folding an event stream is O(events) per query, which is trivial for a lease with a dozen amendments but wasteful if a hot dashboard resolves the same as-of date thousands of times a minute. Two levers keep it cheap. First, snapshot — materialize the resolved state at well-known dates (each fiscal quarter close, each rent-roll run) into a (lease_id, as_of) table and rebuild the entry only when a new event with an effective date at or before that boundary is appended. Second, fold from the nearest snapshot rather than from the base: to answer a query for date D, start from the latest materialized snapshot whose as_of <= D and replay only the handful of events after it, turning a full-history fold into a short one.

The append path itself is a single insert plus a constraint check, so write throughput scales with the store. The one number to watch is amendment fan-out: a portfolio-wide index revision (a CPI series correction) can append one event to tens of thousands of leases at once, so batch those appends and invalidate materialized snapshots in the same transaction. Because the ledger is append-only, none of this risks the audit trail — snapshots are a cache in front of an immutable log, and the log remains the sole source of truth for security & access boundaries to expose to time-bound auditor tokens.

Frequently asked questions

How do I reconstruct what a lease said on a specific past date? Fold every amendment event whose effective date is on or before that date, in (effective_date, sequence) order, keeping the latest value written to each field. Because events are immutable and the base lease is seeded as sequence 0, the result is a deterministic snapshot you can recompute identically years later — which is exactly what a retroactive rent roll or an ASC 842 restatement needs.

Two amendments carry the same effective date — which one wins? The one with the higher sequence number. Effective date is the primary sort key, but it is not unique, so the monotonic per-lease sequence breaks the tie deterministically. Never depend on insertion or arrival order; a backdated amendment recorded late must still resolve by its effective date, with sequence deciding same-day collisions.

How does provenance survive an audit? Every field in a resolved snapshot carries a source_document_id and the hash of the instrument that supplied it. When an auditor asks why the base rent was a given figure in Q3 2024, the snapshot points straight at the amendment document and its content hash, so the number traces back to a page rather than to an anonymous database row. A field with missing provenance fails the completeness gate before it ever reaches a report.

Why append a new event instead of editing the record in place? An in-place edit destroys history: once Q4’s amendment overwrites the rent field, you can no longer prove what you billed in Q3 or under which instrument. Appending keeps every prior state reconstructible, satisfies the append-only audit requirements of ASC 842 and IFRS 16, and lets a wrong entry be corrected by a further event without ever erasing the evidence of the mistake.

Where does a backdated or retroactive amendment fit? It appends like any other event but with an effective_date earlier than its recorded_at. Historical queries resolve by effective date, so the backdated change correctly rewrites the as-of state for the period it covers, while “what did we know on date X” audit queries resolve by recorded-at. Keeping the two timestamps distinct is what makes retroactive corrections defensible rather than confusing.

← Back to Core Architecture & Lease Taxonomy

← Back to Core Architecture & Lease Taxonomy