Standardizing CAM Taxonomies Across Portfolios
When every property in a portfolio classifies expenses its own way, consolidated CAM reconciliation quietly breaks: the same janitorial line means “recoverable” at one asset and “excluded” at another, cross-property benchmarking compares numbers that were never computed on the same vocabulary, and a portfolio-level true-up cannot be audited because no single definition of a category exists. This page is part of the CAM Architecture & Lease Clause Taxonomy reference architecture, and it solves one narrow problem inside it — turning a set of divergent, property-local expense taxonomies into one governed, versioned canonical vocabulary that every reconciliation across the portfolio resolves against. Property managers feel the pain as unexplained variance between sister properties, real estate accountants feel it as manual re-mapping every close, and the CRE developers and Python automation teams inherit an allocation engine that cannot generalize because the inputs never agreed on what a category is.
The registry is not a spreadsheet of category names — it is a semantically versioned data product that pins a stable code to every recoverable bucket, records how each property’s local codes crosswalk into those buckets, and refuses to release a change until the mapping is complete and approved. Every downstream number — a tenant’s proportionate share, a capped controllable, a consolidated recoverable total — becomes a lookup against one release of this registry rather than a per-property interpretation.
Prerequisites & Data Contracts
Standardization is a normalization layer, not a greenfield design, so three upstream contracts must be settled before the registry can hold a single property.
First, a canonical category tree has to exist as the target vocabulary. This is the hierarchical schema established when defining CAM expense categories in commercial leases — Level 1 pools (Property Operations, Taxes & Insurance, Capital Reserves), their Level 2 subcategories, and Level 3 leaf codes. The registry does not invent categories; it adopts that tree and assigns each node a code that is stable for the life of the portfolio. Because recoverability and cap treatment are contractual, not global, the tree also carries a controllable flag and a default recoverability that individual leases can override.
Second, each property must expose its active local codes. Every property management system emits its own GL numbers or category strings — 6120, HVAC-M, HVAC Repairs & Svc — and the crosswalk contract requires each asset to enumerate the codes that actually appear on its ledger for the period. A property cannot enter a consolidated reconciliation until 100% of its active codes resolve to a canonical node, so this enumeration is the gate, not an afterthought.
Third, the measurement basis must already be consistent. Standardizing categories is worthless if the pro-rata denominators disagree, so tenant and building rentable areas must be computed against a single BOMA measurement standard across the portfolio, and the versioned lease terms that hold those figures must live in the lease abstraction database. The registry stores the category foreign key that abstraction points into; keeping those keys identical across assets is precisely what makes consolidated reporting under FASB ASC 842 auditable.
The contract between a property and the registry is deliberately narrow:
| Field | Type | Contract |
|---|---|---|
property_id |
string | Stable key for the asset supplying local codes |
local_code |
string | Exactly the string the property’s PMS/GL emits |
canonical_code |
string | Target node in the shared category tree |
recoverability_override |
enum | null | Per-asset carve-out when the lease differs from the default |
version |
string | Semantic version of the registry release the mapping belongs to |
Algorithm & Rule Design
The core object is an immutable, semantically versioned registry release. A release bundles the canonical category tree with the full crosswalk for every property, and once published it never mutates — a definition change produces a new version, so a reconciliation for a past period resolves against exactly the vocabulary in force then. Semantic versioning encodes intent: a patch (2.4.1) corrects a mapping typo, a minor (2.5.0) adds a category or a property, and a major (3.0.0) merges, splits, or re-parents nodes in a way that breaks historical continuity and therefore requires an explicit migration.
Normalization is a total function. Given a property and one of its local codes, the resolver returns exactly one canonical category or raises — it never guesses. Formally, the crosswalk is a mapping from (property_id, local_code) pairs to canonical codes, and the release is only valid when is defined for every active code at every enrolled property. That completeness is expressed as a per-property coverage gate:
where is the set of active local codes at property . A property may only join a consolidated reconciliation when ; anything less means at least one dollar would resolve to no category.
Standardization pays off at rollup. Once every property speaks the canonical vocabulary, the recoverable total for a canonical category across the portfolio is a single, well-defined sum:
where is the spend on local code at property and is its effective recoverability (the property override when present, otherwise the canonical default). This is the figure that then feeds each tenant’s pro rata allocation and the stateful enforcement in managing expense caps and controllable limits — which only works because means the same thing at every asset.
Python Implementation
The registry uses pydantic for the typed contract and validation, and the decimal module for every monetary rollup — never float, because binary floating point accumulates sub-cent drift across thousands of lines and many properties. The models below are the runnable core: a canonical category, a crosswalk entry, and a registry release that validates its own integrity, normalizes a property-local code, and reports coverage.
from __future__ import annotations
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel, field_validator, model_validator
class Recoverability(str, Enum):
RECOVERABLE = "RECOVERABLE"
EXCLUDED = "EXCLUDED"
CONDITIONAL = "CONDITIONAL"
class CanonicalCategory(BaseModel):
"""A node in the portfolio-wide category tree. `code` is stable forever;
label and recoverability change only through a new registry version."""
code: str # e.g. "PROP_OPS.HVAC"
label: str
parent_code: str | None = None
controllable: bool
default_recoverability: Recoverability
@field_validator("code")
@classmethod
def _upper_dotted(cls, v: str) -> str:
if not v or v != v.upper():
raise ValueError("canonical code must be non-empty and upper-case")
return v
class CrosswalkEntry(BaseModel):
"""Maps one property-local GL/category code onto a canonical code."""
property_id: str
local_code: str # whatever the property's PMS emits
canonical_code: str
# Per-asset override when the lease carves the category out differently
# than the portfolio default.
recoverability_override: Recoverability | None = None
class TaxonomyRegistry(BaseModel):
"""A single immutable, semantically-versioned taxonomy release."""
version: str # e.g. "2.4.0"
categories: dict[str, CanonicalCategory]
crosswalk: list[CrosswalkEntry]
@model_validator(mode="after")
def _check_integrity(self) -> "TaxonomyRegistry":
# Every parent pointer and every crosswalk target must resolve.
for cat in self.categories.values():
if cat.parent_code and cat.parent_code not in self.categories:
raise ValueError(f"dangling parent: {cat.code} -> {cat.parent_code}")
for entry in self.crosswalk:
if entry.canonical_code not in self.categories:
raise ValueError(f"crosswalk targets unknown code: {entry.canonical_code}")
return self
def resolve(self, property_id: str, local_code: str) -> CanonicalCategory:
"""Normalize a property-local code into its canonical category.
Raises if the property has no mapping — never guesses silently."""
for entry in self.crosswalk:
if entry.property_id == property_id and entry.local_code == local_code:
return self.categories[entry.canonical_code]
raise KeyError(f"unmapped local code {local_code!r} at {property_id}")
def recoverability_for(self, property_id: str, local_code: str) -> Recoverability:
"""Effective recoverability = property override, else canonical default."""
for entry in self.crosswalk:
if entry.property_id == property_id and entry.local_code == local_code:
if entry.recoverability_override is not None:
return entry.recoverability_override
return self.categories[entry.canonical_code].default_recoverability
raise KeyError(f"unmapped local code {local_code!r} at {property_id}")
def coverage(self, property_id: str, active_local_codes: set[str]) -> Decimal:
"""Fraction of a property's active codes that resolve. Must equal 1
before the asset joins a consolidated reconciliation."""
if not active_local_codes:
return Decimal("1")
mapped = {e.local_code for e in self.crosswalk if e.property_id == property_id}
hit = len(active_local_codes & mapped)
return (Decimal(hit) / Decimal(len(active_local_codes))).quantize(Decimal("0.0001"))
With the registry in hand, rolling a whole portfolio up onto the canonical vocabulary is a single pass. Excluded lines drop out before they can inflate a recoverable pool, and every sum stays exact because the amounts are Decimal:
from collections import defaultdict
def recoverable_by_canonical(
registry: TaxonomyRegistry,
line_items: list[tuple[str, str, Decimal]], # (property_id, local_code, amount)
) -> dict[str, Decimal]:
"""Roll every property's recoverable spend up to canonical codes so the
portfolio reconciles on one vocabulary. Excluded lines are filtered out."""
totals: dict[str, Decimal] = defaultdict(lambda: Decimal("0.00"))
for property_id, local_code, amount in line_items:
if registry.recoverability_for(property_id, local_code) is Recoverability.EXCLUDED:
continue
canonical = registry.resolve(property_id, local_code)
totals[canonical.code] += amount
return {code: total for code, total in totals.items()}
Because a release is immutable, pinning a reconciliation to a version string is all it takes to reproduce a prior period exactly — the same discipline the lease abstraction database uses for temporal lease terms, and the same exact-arithmetic guarantee documented in the Python decimal module.
Validation Rules & Edge Cases
The registry is where taxonomy drift must be caught, because every mapping it lets through becomes a silent misallocation across an entire portfolio. The failure modes specific to standardization:
- Unmapped local code. A property emits a code the crosswalk has never seen (
coverage < 1). The resolver raises rather than defaulting, and the asset is blocked from the consolidated run until the mapping is added and re-approved — a missing dollar is never allowed to vanish into an implicit bucket. - Ambiguous many-to-one. Two local codes at one property both map to the same canonical node. This is legal and common (a PMS split of one concept), but the reverse — one local code claiming two canonical targets — is rejected by the crosswalk’s uniqueness constraint, because it would make
resolvenon-deterministic. - Recoverability conflict across assets. The same canonical category is
RECOVERABLEby default but must beEXCLUDEDat one property because that lease carves it out. This is resolved by the per-entryrecoverability_override, not by forking the category — forking would fragment the vocabulary the standardization exists to unify. The tenant-level version of this carve-out lives in exclusion mapping for tenant-specific CAM. - Category merge or split across versions. A major version that collapses two nodes into one, or splits one into two, orphans historical crosswalk entries. The migration must carry a
preceding_codemap so a re-run of a prior period still resolves through the old code, and consolidated trend lines are recomputed rather than silently re-bucketed. - Controllable-flag disagreement. A category flagged controllable at the portfolio level but treated as pass-through at one asset breaks cap enforcement. The flag is a property of the canonical node — assets do not override it; the correct fix is a distinct child node, so the controllable vs non-controllable distinction stays consistent across the portfolio.
Any code that fails validation is routed to a review queue with the offending property_id and local_code rather than being auto-assigned, so an accountant reconciles the gap deterministically instead of a heuristic guessing at it.
Integration Points
The registry is the shared vocabulary the rest of the pipeline reads and writes against, and each edge is a defined contract.
Upstream, parsed vendor invoices arriving through the automated invoice parsing pipeline carry raw local codes that are checked against the crosswalk during schema validation for parsed expense data; a line whose code has no canonical target fails validation before it can reach allocation. Clause-level dispositions produced when mapping NNN lease clauses to CAM categories supply the recoverability overrides the registry stores per property.
Downstream, the canonical codes become the join keys everything else depends on. The lease abstraction database stores foreign keys into the tree; GL code mapping for CAM expenses posts recoveries to the correct accounts using the canonical code rather than a property-local one; the expense allocation rule engines read one vocabulary of recoverable pools; and non-recoverable filtering follows the exclusion tracking rules the registry encodes. Because every release is versioned and every mutation is approval-gated, changes are locked down by CAM reconciliation security & access controls: accountants propose crosswalk edits, a taxonomy owner approves the release, and auditors read immutable version snapshots.
Testing & Verification
Standardization is only trustworthy if the registry is covered by tests that assert exact behavior — every code resolves, ambiguity is rejected, coverage gates hold, and rollups tie out to Decimal literals with no tolerance.
from decimal import Decimal
def _registry() -> TaxonomyRegistry:
return TaxonomyRegistry(
version="2.4.0",
categories={
"PROP_OPS": CanonicalCategory(
code="PROP_OPS", label="Property Operations",
controllable=True, default_recoverability=Recoverability.RECOVERABLE,
),
"PROP_OPS.HVAC": CanonicalCategory(
code="PROP_OPS.HVAC", label="HVAC Maintenance", parent_code="PROP_OPS",
controllable=True, default_recoverability=Recoverability.RECOVERABLE,
),
},
crosswalk=[
CrosswalkEntry(property_id="P-1", local_code="6120", canonical_code="PROP_OPS.HVAC"),
CrosswalkEntry(property_id="P-2", local_code="HVAC-M", canonical_code="PROP_OPS.HVAC"),
],
)
def test_divergent_local_codes_resolve_to_one_canonical() -> None:
reg = _registry()
assert reg.resolve("P-1", "6120").code == reg.resolve("P-2", "HVAC-M").code == "PROP_OPS.HVAC"
def test_unmapped_code_raises_rather_than_guessing() -> None:
import pytest
with pytest.raises(KeyError):
_registry().resolve("P-1", "9999")
def test_coverage_gate_blocks_incomplete_property() -> None:
reg = _registry()
assert reg.coverage("P-1", {"6120"}) == Decimal("1")
assert reg.coverage("P-1", {"6120", "6130"}) == Decimal("0.5000")
def test_portfolio_rollup_is_exact_and_drops_exclusions() -> None:
reg = _registry()
items = [
("P-1", "6120", Decimal("4500.00")),
("P-2", "HVAC-M", Decimal("2800.00")),
]
totals = recoverable_by_canonical(reg, items)
assert totals["PROP_OPS.HVAC"] == Decimal("7300.00")
Fixture strategy: build each portfolio scenario from a small factory that returns a fully typed TaxonomyRegistry, and assert on Decimal equality throughout. The moment a rollup test reaches for pytest.approx, treat it as a signal that a float crept into the money path and remove it. A regression suite should also pin a golden version and re-resolve a fixed set of historical line items on every change, so a new release can never silently re-bucket a prior period.
From Fragmented Codes to One Vocabulary
Standardizing CAM taxonomies across a portfolio is what converts a pile of property-local ledgers into a system that can reconcile and benchmark on one auditable vocabulary. By adopting the shared category tree, pinning each release with semantic versioning, gating every property on full coverage, storing per-asset recoverability as overrides rather than forks, and rolling spend up with exact Decimal arithmetic, the registry gives the reconciliation engine inputs that mean the same thing everywhere. From here the canonical codes flow into pro rata allocation, get enforced by expense caps and controllable limits, post through GL code mapping for CAM expenses, and are locked down by role-based access controls — the rest of the CAM Architecture & Lease Clause Taxonomy architecture this registry unifies.
Frequently Asked Questions
Why map property-local codes to a canonical tree instead of just renaming everything to match? Renaming forces every property management system, historical ledger, and vendor feed onto one code set, which is impossible to enforce and destroys the ability to re-run prior periods. A crosswalk lets each property keep emitting whatever its PMS produces while the registry normalizes those codes into one vocabulary at ingestion — reversible, versioned, and auditable.
What is the difference between a per-property recoverability override and forking a category? An override records that a single asset treats a shared category differently — usually because that lease carves it out — while still pointing at the same canonical node, so consolidated reporting stays coherent. Forking creates a second category that means almost the same thing, which fragments the vocabulary and reintroduces the exact drift standardization exists to remove.
How does semantic versioning keep a re-run reconciliation correct?
Each registry release is immutable and tagged (2.4.0). A reconciliation pins the version it ran against, so re-running a past period resolves every code through the crosswalk that was in force then, never a later merge or re-parenting. Reproducibility across versions is exactly what an auditor tests.
What happens when a property emits a code the registry has never seen?
The resolver raises and the property’s coverage drops below 1, which blocks it from the consolidated run rather than defaulting the dollar into an implicit bucket. The unmapped (property_id, local_code) pair is routed to a review queue for an accountant to map and re-approve, so no spend is ever silently absorbed.
Related
- Defining CAM expense categories in commercial leases — the canonical category tree this registry adopts as its target vocabulary.
- Building a lease abstraction database — stores the versioned lease terms and category foreign keys the crosswalk points into.
- Best practices for CAM expense exclusion tracking — how non-recoverable codes are filtered before a canonical rollup.
- CAM reconciliation security & access controls — approval gating and immutable snapshots for every taxonomy release.