Metadata Normalization Standards

Lease abstraction pipelines ingest fragmented metadata from legacy Yardi and RealPage exports, unstructured PDFs, broker spreadsheets, and IoT building-management feeds — each with its own field names, units, and date conventions. Without a strict normalization layer, automation fails at the ingestion boundary: rent rolls misalign, compliance audits break, and errors cascade into financial forecasting. This page sits inside Core Architecture & Lease Taxonomy, and it owns one specific job in that architecture — turning the heterogeneous output of extraction into a single canonical, typed payload that every downstream system can trust.

The scoped problem this page solves: how do you convert dozens of vendor-specific field shapes into one deterministic schema, before records reach the canonical store, without silently dropping or corrupting data? The answer used in production lease platforms is a version-controlled canonical schema, a coercion layer that handles real-world mess, and a validation gate that admits clean records and quarantines everything else. Normalization is the contract that makes the lease data models layer behave predictably, and it is where field-level discipline is enforced once so that no other stage has to guess.

Normalization seam: heterogeneous sources converging into a validation gate Four divergent metadata sources feed one key-resolution step that maps vendor field names onto canonical keys, then a type-coercion step that produces Decimal, int, and ISO 8601 date values. A strict pydantic v2 gate validates each record: valid records become a typed, version-stamped canonical payload; records that raise a ValidationError are diverted to a dead-letter and review queue carrying the original input and a field-level errors array for replay. Yardi / RealPage vendor export Broker spreadsheet free-text cells OCR JSON parsed table spans BMS feed building metadata Key resolution vendor → canonical Type coercion Decimal · int · date validate pydantic v2 valid ValidationError Canonical payload typed · version-stamped Dead-letter / review input + field errors[]

Scope and where this fits

Normalization is the seam between two domains. Upstream, the parsing and extraction workflows domain produces candidate records — OCR token spans, parsed table cells, LLM-generated JSON — each carrying extraction variance and noise. Downstream, billing, compliance, and analytics engines expect a stable schema with known types. This page defines the rules that bridge them: field-name resolution, unit standardization, controlled vocabularies, and the validation contract. It deliberately does not cover how documents become candidate records (that is OCR and clause extraction) or how a stored financial row is later computed into rent (that is escalation logic). It covers the canonicalization step in the middle, where raw becomes trustworthy.

Prerequisites and environment setup

The implementation below targets Python 3.11+ and pydantic v2, which is the validation convention used throughout this architecture. Pin versions explicitly so coercion behavior does not drift between environments — pydantic v1 and v2 differ materially in validator semantics.

# requirements.txt
pydantic==2.7.*          # field_validator / model_validator API
python-dateutil==2.9.*   # tolerant date parsing for messy vendor strings
babel==2.15.*            # locale-aware number and currency parsing

Assumptions baked into the rules that follow:

  • Input arrives as plain dict payloads, one per lease, already keyed (however inconsistently) by the upstream extractor. Binary documents are out of scope here — they belong to the ingestion stage.
  • Monetary values use Decimal, never float. Floating-point cannot represent most decimal fractions exactly, and cent-level errors compound across thousands of leases.
  • All dates resolve to ISO 8601 (YYYY-MM-DD) with explicit, documented assumptions about ambiguous formats (US MM/DD vs European DD/MM).
  • The canonical schema is version-pinned. Every normalized record carries the schema version it was validated against, so a schema change never silently reinterprets old data.

Pipeline architecture

Normalization runs as a fixed sequence of stages. Treating it as an ordered pipeline — rather than one monolithic validator — keeps each transformation auditable and lets failures be attributed to a specific stage.

Stage Input Transformation Failure handling
1. Key resolution Raw vendor dict Map divergent keys (rent_psf, base_rent_psf, rent/sf) to canonical names Unknown key → log + drop with audit entry
2. Type coercion Canonically-keyed dict Strings → Decimal, int, date; strip currency symbols and thousands separators Uncoercible value → field-level error
3. Vocabulary mapping Coerced dict Collapse free-text enums (triple netNNN) via lookup table Unrecognized term → review queue or configured default
4. Schema validation Mapped dict pydantic model enforces ranges, invariants, cross-field rules ValidationError → dead-letter queue
5. Provenance stamping Valid model Attach source id, schema version, confidence
Five-stage normalization pipeline with a shared dead-letter lane Records flow left to right through five ordered stages: key resolution, type coercion, vocabulary mapping, schema validation, and provenance stamping. Each of the first four stages drops failures into a shared dead-letter and review lane beneath the pipeline — an unmapped key dropped with an audit entry, an uncoercible value, an unknown vocabulary term, and a pydantic ValidationError. Provenance stamping runs only on records that have already passed, so it has no failure branch. 1 Key resolution drop + audit 2 Type coercion uncoercible 3 Vocabulary mapping unknown term 4 Schema validation ValidationError 5 Provenance stamping Dead-letter / review queue field-level errors preserved, replayable

The ordering matters. Key resolution must precede coercion (you cannot coerce a field you have not identified), and vocabulary mapping must precede validation (the schema’s enum constraint only accepts canonical values). Stage 5 records why a value is what it is — provenance is what makes a normalized record defensible in an audit.

Primary implementation

The canonical model defines required fields, acceptable ranges, and transformation rules in one version-controlled place. Property platforms store identical concepts under divergent keys (rent_sqft vs rent_per_sf vs base_rent_psf); a before validator resolves those variations and coerces messy values before pydantic enforces the schema. The following model is deterministic — identical input always yields identical output — which is the property that makes safe retries and idempotent ingestion possible.

import logging
import re
from datetime import datetime, date
from decimal import Decimal
from typing import Any
from pydantic import BaseModel, Field, field_validator, model_validator

logger = logging.getLogger("lease_metadata_normalizer")
logger.setLevel(logging.INFO)

SCHEMA_VERSION = "2026.06"

# Canonical key resolution: every known vendor alias maps to one schema key.
KEY_ALIASES: dict[str, str] = {
    "rent_sqft": "base_rent_psf", "rent_per_sf": "base_rent_psf",
    "rent/sf": "base_rent_psf", "base_rent_psf": "base_rent_psf",
    "lease_start": "commencement_date", "effective_date": "commencement_date",
    "rentable_area": "square_footage", "rsf": "square_footage",
}


def resolve_keys(raw: dict[str, Any]) -> dict[str, Any]:
    """Stage 1: collapse divergent vendor keys onto canonical names.

    Unknown keys are dropped with an audit log rather than silently passed
    through, so an unexpected column never pollutes the canonical record.
    """
    resolved: dict[str, Any] = {}
    for key, value in raw.items():
        canonical = KEY_ALIASES.get(key.strip().lower(), key.strip().lower())
        if canonical in resolved:
            logger.warning("Duplicate canonical key %s from alias %s", canonical, key)
        resolved[canonical] = value
    return resolved


class NormalizedLeaseMetadata(BaseModel):
    lease_id: str = Field(..., description="Unique lease identifier")
    property_type: str = Field(..., description="Standardized property classification")
    base_rent_psf: Decimal = Field(..., ge=0, description="Base rent per square foot")
    commencement_date: date = Field(..., description="Lease start date (ISO 8601)")
    expiration_date: date = Field(..., description="Lease end date (ISO 8601)")
    cam_structure: str = Field(..., description="CAM structure enum (NNN, GROSS, MODIFIED_GROSS)")
    square_footage: int = Field(..., gt=0, description="Rentable area in square feet")
    currency_code: str = Field(default="USD", pattern=r"^[A-Z]{3}$", description="ISO 4217 code")
    schema_version: str = Field(default=SCHEMA_VERSION, description="Schema the record was validated against")

    @field_validator("base_rent_psf", mode="before")
    @classmethod
    def coerce_rent_psf(cls, v: Any) -> Decimal:
        # Reject float at the boundary — casting preserves the rounding error.
        if isinstance(v, float):
            raise TypeError("Pass rent as str or Decimal, never float")
        if isinstance(v, (int, Decimal)):
            return Decimal(str(v))
        if isinstance(v, str):
            cleaned = re.sub(r"[^\d.]", "", v)  # strip $, commas, "/SF"
            if not cleaned:
                raise ValueError("Invalid rent value: no numeric characters found")
            return Decimal(cleaned)
        raise TypeError("base_rent_psf must be numeric or string")

    @field_validator("square_footage", mode="before")
    @classmethod
    def standardize_area(cls, v: Any) -> int:
        if isinstance(v, str):
            match = re.search(r"(\d+(?:[.,]\d+)?)", v.replace(",", ""))
            if match:
                return int(float(match.group(1)))
            raise ValueError("Invalid square footage format")
        return int(v)

    @field_validator("cam_structure", mode="before")
    @classmethod
    def normalize_cam_enum(cls, v: str) -> str:
        # Stage 3 vocabulary mapping — collapse free text to a canonical enum.
        mapping = {
            "triple net": "NNN", "nnn": "NNN", "net net net": "NNN", "3n": "NNN",
            "gross": "GROSS", "full service": "GROSS", "fs": "GROSS",
            "modified gross": "MODIFIED_GROSS", "mod gross": "MODIFIED_GROSS", "mg": "MODIFIED_GROSS",
        }
        normalized = v.strip().lower()
        return mapping.get(normalized, normalized.upper())

    @field_validator("commencement_date", "expiration_date", mode="before")
    @classmethod
    def parse_dates(cls, v: Any) -> date:
        if isinstance(v, date):
            return v
        if isinstance(v, str):
            for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%d-%b-%Y", "%Y%m%d"):
                try:
                    return datetime.strptime(v.strip(), fmt).date()
                except ValueError:
                    continue
            raise ValueError(f"Unrecognized date format: {v}")
        raise TypeError("Date must be string or date object")

    @model_validator(mode="after")
    def validate_date_sequence(self) -> "NormalizedLeaseMetadata":
        if self.expiration_date <= self.commencement_date:
            raise ValueError("Expiration date must be after commencement date")
        return self

The before validators are doing the load-bearing work: they run on raw input before pydantic’s type machinery, which is exactly where messy vendor strings need to be tamed. The after model validator enforces cross-field invariants that no single field can check alone. Cross-property-type variation — industrial clear height and dock counts versus multifamily unit mix and concessions — is handled by applying type-specific transformation profiles ahead of this core schema, covered in depth in standardizing lease metadata normalization across property types.

Controlled vocabularies and enum mapping

Free text from brokers, attorneys, and property managers is the single largest source of entropy in a lease database. "triple net", "NNN", "net-net-net", and "3N" all describe the same expense structure and must collapse to one canonical value, because the structured enums produced here drive the downstream clause classification systems that perform risk scoring and compliance routing. A normalized enum is not cosmetic — a misclassified CAM structure silently changes which expenses a tenant owes.

Maintain the lookup table as a centralized, versioned registry rather than scattering string literals across the codebase. When an unrecognized value arrives, the engine has three defensible options, and the choice should be explicit per field: reject the record outright, route it to a manual review queue, or apply a configured default with an audit entry. Silent best-guess mapping is the one option to avoid — it is how legacy vendor codes quietly corrupt a canonical store. Records that fall through vocabulary mapping with low confidence are handed to the fallback routing logic layer rather than admitted as ground truth.

Validation and quality gates

Validation is where normalization earns its keep. The runner below executes the full pipeline and splits the stream: valid records flow forward, everything else is routed to a dead-letter queue with its original input and structured field-level errors preserved for replay. Keying the writer on a deterministic hash of lease_id plus source document version makes ingestion idempotent — replaying the same batch upserts the same rows instead of duplicating them.

import hashlib
import json
from pydantic import ValidationError


def ingestion_key(record: dict[str, Any]) -> str:
    """Deterministic idempotency key — replays upsert instead of duplicating."""
    basis = f"{record.get('lease_id')}|{record.get('source_document_version', '0')}"
    return hashlib.sha256(basis.encode()).hexdigest()


def normalize_batch(raw_records: list[dict[str, Any]]) -> tuple[list[NormalizedLeaseMetadata], list[dict]]:
    accepted: list[NormalizedLeaseMetadata] = []
    dead_letter: list[dict] = []
    for raw in raw_records:
        resolved = resolve_keys(raw)
        try:
            model = NormalizedLeaseMetadata(**resolved)
            accepted.append(model)
        except ValidationError as exc:
            dead_letter.append({
                "key": ingestion_key(raw),
                "input": raw,                       # preserved verbatim for replay
                "errors": json.loads(exc.json()),   # structured, per-field
                "schema_version": SCHEMA_VERSION,
            })
            logger.error("Rejected lease %s: %d field error(s)",
                         raw.get("lease_id", "<unknown>"), len(exc.errors()))
    return accepted, dead_letter

Beyond the runtime gate, enforce two standing quality gates in CI. First, contract tests assert that representative vendor exports still normalize cleanly, catching the day an upstream export silently changes its column names or date format. Second, idempotency tests run the same batch twice and assert byte-identical canonical output — the property that lets a pipeline retry safely after a partial failure. Validation that only runs in production is validation you find out about too late.

Troubleshooting

Rent parses to a value 100x too large or small. Almost always a locale collision: 1,200.50 read with European conventions becomes 1.20050, or a thousands separator is mistaken for a decimal point. Diagnose by logging the raw pre-coercion string alongside the parsed Decimal. Fix by tagging each source with its locale and parsing with babel.numbers.parse_decimal under that locale rather than a blanket regex strip.

Dates land in the wrong month. 03/04/2026 is March 4 under US convention and April 3 under European. The parse_dates validator will happily accept either with no error — a silent corruption. Diagnose by sampling records where day and month are both ≤ 12. Fix by carrying a per-source date-order flag and selecting the format list accordingly, never auto-guessing.

An unrecognized CAM term silently uppercases instead of mapping. The fallback normalized.upper() returns "NET LEASE" unchanged, which then passes the loose string field and pollutes the enum. Diagnose by querying distinct cam_structure values after a batch — anything not in the canonical set is a leak. Fix by constraining the field to a strict Enum or Literal so unknown terms raise instead of pass, then route them to review.

Zero-width or non-breaking spaces break numeric coercion. OCR and copy-paste from PDFs inject and   into otherwise-numeric strings, so re.sub(r"[^\d.]", "", v) strips them but a stricter parser may choke. Diagnose by printing repr() of the failing field. Fix by normalizing whitespace with unicodedata.normalize("NFKC", v) at the top of every string validator.

Duplicate canonical keys from competing aliases. A vendor that ships both effective_date and lease_start resolves both onto commencement_date, and the second silently overwrites the first. The resolve_keys warning surfaces this. Fix by defining an explicit precedence order among aliases rather than relying on dict iteration order.

Records vanish with no error. A field dropped at key resolution because its alias is unmapped never reaches validation, so no ValidationError fires — the record is simply incomplete. Diagnose by asserting that every required canonical key is present after stage 1. Fix by treating a missing required key as a stage-1 failure routed to the dead-letter queue, not a downstream surprise.

Performance and scale notes

For portfolio-scale batches, construct pydantic models from already-resolved dicts and avoid re-parsing source documents inside the normalizer — that work belongs to the OCR preprocessing and ingestion stages upstream. pydantic v2’s Rust core validates on the order of tens of thousands of records per second, so the bottleneck in a large run is almost always I/O (reading exports, writing the canonical store), not validation.

When normalizing tens of thousands of leases at once, stream rather than materialize: process the source as a generator and write accepted records in bounded batches so peak memory stays flat regardless of portfolio size. Run the normalizer as an idempotent task under an orchestrator such as Airflow, Prefect, or Dagster — because each pass is deterministic and keyed, a failed run can be retried wholesale without producing duplicates. Keep the dead-letter queue on a separate, durable path; its volume spikes precisely when an upstream format drifts, and that spike is your earliest signal that a vendor export changed. The same idempotency guarantees that make this layer safe also let it feed clean records into the escalation formula mapping engine without re-validation downstream.

Frequently asked questions

How do I handle lease amendments that override base metadata fields?

Normalize each amendment as its own record carrying a source_document_id and an effective date, then resolve the controlling value downstream in the data model with a point-in-time lookup. The normalization layer's job is to make every amendment payload conform to the same canonical schema; it does not flatten amendments into the base lease, which would destroy the audit trail.

What confidence threshold should trigger manual review instead of auto-normalization?

Carry the upstream extraction confidence into the pipeline and divert below roughly 0.75, tuning against a labeled holdout. Sub-threshold records go to a review queue through fallback routing rather than entering the canonical store. Version the threshold with the schema so a tuning change is traceable.

Why reject incoming floats for monetary fields instead of casting them?

Casting a float to Decimal preserves the original binary rounding error — Decimal(0.1) is not 0.1. Across thousands of leases those errors compound and break rent-roll reconciliation. Reject floats at the boundary and require str or Decimal input so the exact intended value is preserved.

How do I keep date parsing from silently guessing the wrong month?

Never auto-detect order for ambiguous MM/DD versus DD/MM strings. Tag each source with its date convention and select the format list per source. Ambiguity that cannot be resolved from the source metadata should fail to the review queue rather than be guessed.

← Back to Core Architecture & Lease Taxonomy

← Back to Core Architecture & Lease Taxonomy