Mapping Commercial Lease Clauses to Standardized JSON Schemas

Once a clause has been labeled, exactly one engineering decision determines whether the rest of the platform can trust it: what shape does that clause serialize into, and how strictly is that shape enforced? This page resolves the narrow question of how to map a typed commercial lease clause — base rent, escalation, CAM pass-through, co-tenancy — into a standardized JSON schema that rejects ambiguous input at the boundary instead of letting it corrupt downstream billing. The bottleneck here is no longer optical character recognition; it is the deterministic translation of legal prose into a machine-readable contract that preserves legal nuance while enforcing validation.

Where this fits in the classification pipeline

This page sits one layer below the clause classification systems cluster inside Core Architecture & Lease Taxonomy. Classification answers what kind of clause is this and how confident am I; the schema described here is the output contract that every accepted label must serialize into. By the time a span reaches this stage it has already been segmented upstream, cleaned by OCR preprocessing where the source was scanned, and assigned a confidence score. The serialized record produced here is what feeds lease data models for storage and, for financial provisions, the escalation formula mapping engine that parses the actual percentages and indices. A schema that lets a tiered percentage-rent formula validate as a flat monthly charge poisons every calculation that reads it, so the discipline applied at this seam is what makes the whole abstraction auditable.

Clause-to-schema mapping data flow: clause_type dispatch, typed models, validation gate, and the dead-letter divert A classified clause record carrying a clause_type tag and confidence score enters a schema-mapping layer that dispatches on clause_type to one of four strict pydantic models: BaseRentClause coerces a Decimal monthly amount, EscalationClause captures typed formula inputs only, CAMPassThrough captures a pro-rata share, and CoTenancyClause splits a threshold from its consequence action. Every model feeds a single schema-validation gate enforcing extra=forbid, Decimal precision, and ISO 8601 dates. Records that pass serialize to storage and the lease data models layer; records that fail divert to a dead-letter review queue with their original payload preserved for replay. Classified clause clause_type + confidence Schema mapping dispatch by clause_type BaseRentClause Decimal amount · ISO date EscalationClause typed inputs, no math CAMPassThrough pro-rata share CoTenancyClause threshold → action Schema validation extra=forbid · Decimal · ISO Validated JSON → lease data models Dead-letter → review queue original payload preserved for replay pass fail · validation_failed

Choosing a schema-modeling approach

There are three common ways to enforce structure on extracted clauses, and they trade rigor against runtime ergonomics. The table below compares them against the requirements of a production lease pipeline.

Concern Flat dict / key-value Raw JSON Schema (if/then/else) Pydantic v2 discriminated union
Type coercion None — strings leak through Validation only, no coercion Coerces and validates in one pass
Financial precision Floats by default (lossy) Declares number, can’t enforce Decimal Native Decimal fields
Per-clause-type rules Hand-rolled if ladders Verbose conditional subschemas One model per clause_type, auto-dispatched
Error reporting Manual JSON Pointer paths Structured .errors() with locations
Reuse / composition Copy-paste $ref fragments Shared base model + inheritance
Runtime cost Lowest, no safety Schema-compile overhead Compiled validators, fast at scale

Raw JSON Schema is the right choice when the contract must be language-agnostic and published to external consumers. For an in-process Python abstraction pipeline, a pydantic discriminated union gives the same structural guarantees plus coercion and fixed-point precision, which is why it is the recommended approach below. Many teams do both: pydantic as the runtime guard, with model_json_schema() exporting a JSON Schema document as the published interface contract.

A validated mapping schema in Python

The reference implementation combines runtime type coercion with strict structural validation. Patterns are compiled once at import so high-throughput batches never pay redundant compile cost, financial fields use Decimal rather than float, and dates are forced to ISO 8601. A small router dispatches each payload to the model that matches its clause_type.

import re
import json
from decimal import Decimal
from datetime import date
from typing import Optional, Literal
from pydantic import BaseModel, Field, field_validator, ConfigDict, ValidationError

# Precompile patterns at module initialization to eliminate redundant overhead
PCT_PATTERN = re.compile(r"^(?P<value>\d+(?:\.\d+)?)\s*(?:percent|%|pct)$", re.IGNORECASE)
DATE_PATTERN = re.compile(r"^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})$")

class LeaseClauseBase(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")

    clause_type: Literal["base_rent", "cam_pass_through", "co_tenancy", "escalation"]
    source_document_id: str
    extracted_text_snippet: Optional[str] = None

class BaseRentClause(LeaseClauseBase):
    clause_type: Literal["base_rent"] = "base_rent"
    monthly_amount: Decimal = Field(..., ge=0, description="Fixed monthly rent in base currency")
    currency_iso: str = Field(..., pattern="^[A-Z]{3}$")
    effective_date: date
    proration_method: Optional[Literal["actual_days", "30_360", "365_day"]] = None

    @field_validator("monthly_amount", mode="before")
    @classmethod
    def coerce_to_decimal(cls, v):
        if isinstance(v, str):
            return Decimal(v.replace(",", ""))
        return v

    @field_validator("effective_date", mode="before")
    @classmethod
    def validate_iso_date(cls, v):
        if isinstance(v, str):
            match = DATE_PATTERN.match(v.strip())
            if not match:
                raise ValueError("Date must conform to ISO 8601 (YYYY-MM-DD)")
            return date(int(match.group("year")), int(match.group("month")), int(match.group("day")))
        return v

class EscalationClause(LeaseClauseBase):
    clause_type: Literal["escalation"] = "escalation"
    escalation_type: Literal["fixed_step", "cpi_indexed", "percentage_of_sales"]
    trigger_value: Optional[Decimal] = Field(None, ge=0)
    frequency_months: int = Field(..., ge=1, le=120)

    @field_validator("trigger_value", mode="before")
    @classmethod
    def normalize_percentage(cls, v):
        if isinstance(v, str):
            match = PCT_PATTERN.match(v.strip())
            if match:
                return Decimal(match.group("value"))
            raise ValueError(f"Unrecognized percentage format: {v}")
        return v

def validate_lease_payload(raw_json: str) -> dict:
    """
    Validation router for lease abstraction pipelines.
    Routes payloads to the schema validator matching their clause_type.
    """
    try:
        payload = json.loads(raw_json)
        clause_type = payload.get("clause_type")

        if clause_type == "base_rent":
            validated = BaseRentClause.model_validate(payload)
        elif clause_type == "escalation":
            validated = EscalationClause.model_validate(payload)
        else:
            raise ValueError(f"Unsupported clause_type: {clause_type}")

        return validated.model_dump(mode="json")
    except ValidationError as e:
        # Structured error reporting for the reconciliation / dead-letter queue
        return {"status": "validation_failed", "errors": e.errors()}
    except json.JSONDecodeError as e:
        return {"status": "parse_failed", "error": str(e)}

if __name__ == "__main__":
    sample_payload = json.dumps({
        "clause_type": "escalation",
        "source_document_id": "LEASE-2024-88A",
        "extracted_text_snippet": "Rent shall increase by three point five percent every twelve months.",
        "escalation_type": "fixed_step",
        "trigger_value": "3.5%",
        "frequency_months": 12
    })
    print(json.dumps(validate_lease_payload(sample_payload), indent=2))

The field validators reject ambiguous string representations of percentages, forcing the pipeline to normalize inputs like 3.5% into a unified decimal field. Note that frequency_months is bounded at 120 (10 years) rather than 12: annual escalations are common, and a per-12-month cap would incorrectly reject valid multi-year step schedules. When the contract has to be shared with non-Python consumers, call EscalationClause.model_json_schema() to emit the equivalent JSON Schema document rather than maintaining two definitions by hand.

Modeling nested and conditional provisions

Commercial leases rarely conform to linear data structures. Co-tenancy clauses often contain nested conditions that depend on anchor-tenant occupancy thresholds, which themselves may trigger rent abatements or early-termination rights. Map these by isolating the primary condition from its secondary consequence: a co-tenancy object should carry an anchor_occupancy_threshold field alongside a consequence_action enumeration (rent_abatement, termination_option, marketing_contribution). That separation ensures a downstream billing engine only evaluates the relevant branch when the threshold condition is met, instead of guessing which half of a hybrid provision applies.

For deeply nested structures, define reusable fragments — monetary_value, date_range, tenant_entity — once and compose larger clause objects from them, the pydantic equivalent of a JSON Schema $ref. This keeps validation logic in one place and aligns serialized output with how lease data models store the same entities. Where a clause encodes a financial formula rather than a static value, keep the schema thin: capture the typed inputs (trigger_value, escalation_type, index reference) and hand the actual arithmetic to the escalation formula mapping layer rather than embedding calculation logic in the validator.

Edge cases specific to commercial leases

Real corpora break schema mapping in ways that look nothing like the happy path. The recurring offenders:

  • Amendment riders that override base terms. A Fifth Amendment can replace a base-rent figure or delete a renewal option while the original clause still exists in the document. The schema must record supersedes / effective_date provenance so the data layer can resolve precedence; it must never silently merge the two. Override resolution belongs in lease data models, not in this validator.
  • Tracked-changes and redline markup. Executed terms can be wrapped in <w:ins>/<w:del> runs that survive extraction. If deleted runs are not stripped before serialization, a struck percentage validates as live. Normalize redlines upstream and serialize only the executed text.
  • Non-breaking and zero-width spaces. Word and OCR inject \xa0 and zero-width joiners mid-token, so 3.5% arrives as 3.5\xa0% and normalize_percentage rejects it. Map \xa0 to a regular space and strip zero-width characters before validation — the same metadata normalization standards the rest of the pipeline depends on.
  • Multi-language and dual-currency provisions. Cross-border leases quote amounts in two currencies or state a clause in two languages. The currency_iso pattern enforces a single ISO code per monetary field, so model dual amounts as a list of typed monetary_value objects rather than coercing them into one ambiguous string.
  • Comma and locale-formatted numbers. 1,250.00 and 1.250,00 mean different things by locale; coerce_to_decimal only strips commas. Normalize the decimal separator at ingestion, not inside the schema, so the validator sees one canonical numeric form.

When to escalate to review or upstream fixes

Strict validation is deliberately loud: a rejected payload is a signal, not a failure to suppress. Rather than dropping records or raising fatal exceptions, the router returns a structured validation_failed result carrying e.errors() — the exact field and constraint that failed. Wire that result into fallback routing logic: payloads below the upstream confidence threshold, or that fail schema validation, divert to a human-in-the-loop reconciliation queue with their original payload preserved for replay. When the failure pattern is structural rather than semantic — garbled tokens, missing fields across a whole source — route those documents back through OCR preprocessing instead of loosening the schema to admit them. At portfolio scale, run validation inside the same worker fabric described in async batch processing, and let transient failures retry through the platform’s error handling and retry logic rather than swallowing them inside the validator. Version every schema with semantic versioning and keep extra="forbid" with explicit required fields so a new field addition never silently breaks a downstream consumer.

Frequently asked questions

How do I handle lease amendments that override base clauses?

The mapping schema does not resolve precedence — it records it. Serialize each clause with source_document_id, an effective date, and a supersession reference, then let the lease data models layer pick the live version using effective dates. Merging an amendment into the base clause inside the validator destroys the audit trail.

Should I use JSON Schema or pydantic for the contract?

Use pydantic as the runtime guard inside a Python pipeline — it coerces, enforces Decimal precision, and dispatches by clause_type in one pass. If the contract must be consumed by non-Python services, export it with model_json_schema() so the published JSON Schema and the runtime validator stay in sync from a single source.

Why use Decimal instead of float for rent and percentages?

Financial fields cannot tolerate binary floating-point drift; 0.1 + 0.2 is not 0.3 in float. Decimal preserves exact fixed-point values through reconciliation and escalation math, which is why the monthly_amount and trigger_value fields coerce strings straight into Decimal.

What should happen to a payload that fails validation?

It is never dropped. The router returns a structured validation_failed record with the exact failing field, and that record diverts through fallback routing to a review queue with its original payload preserved for replay after the schema or extraction is fixed.

← Back to Clause Classification Systems

← Back to Clause Classification Systems