Exclusion Mapping for Tenant-Specific CAM

Two tenants in the same building can be billed different amounts for the identical general-ledger expense, and both numbers can be correct. That is the problem exclusion mapping solves. Within the Expense Allocation Logic & Rule Engines pipeline, exclusion mapping is the stage that reads each tenant’s lease and decides, line item by line item, which general-ledger (GL) charges are recoverable for that tenant and which are carved out — capital replacements, landlord-reserved overhead, categories a tenant negotiated away, and costs incurred outside the lease term. Get it wrong and the failure is silent: an excluded capital roof replacement leaks into the operating pool, inflates the recovery denominator, and produces a year-end statement that ties out to a number no auditor will accept. This page specifies the exclusion engine’s data contracts, its deterministic precedence rules, a runnable typed implementation, and the edge cases that separate a mapping that runs unattended from one that quietly overbills.

Exclusion mapping splits one shared GL pool into recoverable and excluded sets A coded, validated general-ledger pool and a tenant-scoped exclusion rule set both enter the exclusion engine. The engine partitions the pool into a recoverable set, which feeds pro-rata allocation, and an excluded set, which feeds the audit trail — so the same ledger produces a different recoverable pool per tenant. Coded GL pool validated line items Tenant rule set (property_id, tenant_id) Exclusion cascade Recoverable set → pro-rata allocation Excluded set → audit trail, one reason each
The same ledger yields a different recoverable pool per tenant: the engine partitions each line item into a recoverable or an excluded set, and every exclusion carries a reason.

Prerequisites & Data Contracts

Exclusion mapping neither parses documents nor computes shares — it sits between the two and can only be as correct as the contracts on either side. Three inputs must exist and be stable before the engine evaluates a single line item.

A coded, validated GL line item. The engine consumes the output of ingestion, never a raw export. Each record has already passed through GL code mapping for CAM expenses, so it arrives with a canonical gl_code, a resolved category, a Decimal amount, a posting_date, and a property_id. Records that failed schema validation for parsed expense data never reach the exclusion stage — they belong in the quarantine queue, because a null category or a float amount will corrupt every downstream pool.

A tenant-scoped exclusion rule set. The carve-outs are lease facts, not chart facts, so they are keyed by (property_id, tenant_id) and read from the lease abstraction database. Each rule set names the categories a tenant excludes, the description patterns that flag a charge (for example roof, structural, capital), the GL codes treated as capital, the lease commencement and expiration dates, and the lease_version that produced them. Every rule set is versioned so that a reconciliation run can be replayed against the exact lease language in force during the period under review.

A canonical category enumeration. The categories a rule set references must be the same closed set defined in defining CAM expense categories in commercial leases. An exclusion rule that names a category outside that enumeration is a bug, not a new carve-out, and the engine should reject the rule set at load time rather than silently match nothing. Mapping the raw lease language into that enumeration is itself a task — see how to map NNN lease clauses to CAM categories.

Rule Design & Exclusion Precedence

Lease language is unstructured; exclusion mapping requires structured, machine-readable predicates. The engine evaluates each GL line item against an ordered cascade of predicates and stops at the first one that fires, so that every excluded amount carries exactly one, unambiguous reason. Precedence matters because a single charge can satisfy several carve-outs at once — a $180,000 roof membrane replacement posted after a tenant’s lease expired is both capital and temporal — and the reason recorded on the exclusion drives how it is reported and disputed.

The cascade runs in this order:

  1. Temporal carve-out. The posting_date falls outside the tenant’s occupancy window (before commencement or after expiration). These amounts are excluded before any category logic runs, because a charge the tenant was not in the building for is never recoverable regardless of type.
  2. Capital expenditure. The gl_code is in the tenant’s capital set (HVAC replacement, roof resurfacing, structural upgrade). Capital items are excluded from the in-year operating pool; whether they are amortized back in over a useful life is a downstream decision, not an exclusion decision.
  3. Landlord-reserved category. The category is one the lease reserves to the landlord — leasing commissions, corporate overhead, marketing — or one this specific tenant negotiated out of its shared pool.
  4. Pattern match. The description matches a tenant-specific regular expression. This is the escape hatch for charges that are correctly coded and in-term but still excluded by bespoke lease language a category alone cannot express.

A line item that survives all four predicates is recoverable. Formally, the exclusion decision for line item tt under a tenant’s rule set is the disjunction of the four predicates,

E(t)=ρtime(t)ρcap(t)ρcat(t)ρpat(t),E(t) = \rho_{\text{time}}(t) \,\lor\, \rho_{\text{cap}}(t) \,\lor\, \rho_{\text{cat}}(t) \,\lor\, \rho_{\text{pat}}(t),

and the recoverable pool that feeds allocation is the sum of every non-excluded amount:

Prec=tTat1 ⁣[¬E(t)].P_{\text{rec}} = \sum_{t \in T} a_t \cdot \mathbf{1}\!\left[\neg E(t)\right].

This PrecP_{\text{rec}} is the denominator every tenant’s pro rata share calculation under BOMA standards divides into. An excluded dollar that leaks past E(t)E(t) does not just misprice one line — it inflates the denominator for every tenant in the pool, which is why the engine records a reason for each exclusion and refuses to guess.

The four-predicate exclusion cascade evaluated first-match-wins A GL line item descends through four ordered predicates — temporal carve-out, capital expenditure, landlord-reserved category, and pattern match. The first predicate that fires short-circuits evaluation and stamps the line with exactly one reason: OUTSIDE_TERM, CAPITAL, LANDLORD_RESERVED, or PATTERN. A line that survives all four predicates is recoverable and joins the pro-rata pool. GL line item precedence — first match wins 1 · Temporal carve-out posting_date outside term 2 · Capital expenditure gl_code in capital set 3 · Landlord-reserved category reserved / negotiated out 4 · Pattern match description regex Recoverable → pro-rata pool denominator OUTSIDE_TERM CAPITAL LANDLORD_RESERVED PATTERN match match match match no match ↓ no match ↓ no match ↓ no match ↓
Predicates are evaluated top to bottom; the first to fire short-circuits the cascade and stamps the line with exactly one reason, so a charge that is both capital and out-of-term is always recorded as OUTSIDE_TERM.

Python Implementation

The engine below is deterministic and typed, and every monetary value is a Decimal — binary float cannot represent cent amounts exactly, and summed drift across thousands of line items produces pools that fail to tie out. Rule sets and line items are pydantic models so that a malformed lease export is rejected at the boundary rather than deep inside the cascade. Pattern matching uses re from the standard library (docs.python.org/3/library/re.html).

from __future__ import annotations

from datetime import date
from decimal import Decimal
from enum import Enum
import re
from typing import Iterable

from pydantic import BaseModel, Field


class ExclusionReason(str, Enum):
    """Exactly one reason is recorded per excluded line item."""
    OUTSIDE_TERM = "outside_lease_term"
    CAPITAL = "capital_expenditure"
    LANDLORD_RESERVED = "landlord_reserved_category"
    PATTERN = "pattern_exclusion"
    RECOVERABLE = "recoverable"


class GLLineItem(BaseModel):
    """A coded, validated ledger line ready for exclusion evaluation."""
    gl_id: str
    property_id: str
    tenant_id: str
    gl_code: str
    category: str
    description: str
    amount: Decimal
    posting_date: date


class ExclusionRuleSet(BaseModel):
    """Tenant-specific carve-outs, read from the lease abstraction database."""
    property_id: str
    tenant_id: str
    lease_version: str
    lease_start: date
    lease_end: date
    capital_gl_codes: frozenset[str] = Field(default_factory=frozenset)
    excluded_categories: frozenset[str] = Field(default_factory=frozenset)
    excluded_patterns: tuple[str, ...] = ()


class ExclusionResult(BaseModel):
    """The engine's verdict for one line item, carrying an audit reason."""
    gl_id: str
    tenant_id: str
    amount: Decimal
    is_excluded: bool
    reason: ExclusionReason
    lease_version: str


def evaluate_line_item(item: GLLineItem, rules: ExclusionRuleSet) -> ExclusionResult:
    """Apply the exclusion cascade in strict precedence order.

    First matching predicate wins so that every exclusion carries exactly
    one reason. A line that survives all four predicates is recoverable.
    """
    if item.posting_date < rules.lease_start or item.posting_date > rules.lease_end:
        reason = ExclusionReason.OUTSIDE_TERM
    elif item.gl_code in rules.capital_gl_codes:
        reason = ExclusionReason.CAPITAL
    elif item.category in rules.excluded_categories:
        reason = ExclusionReason.LANDLORD_RESERVED
    elif any(re.search(p, item.description, re.IGNORECASE) for p in rules.excluded_patterns):
        reason = ExclusionReason.PATTERN
    else:
        reason = ExclusionReason.RECOVERABLE

    return ExclusionResult(
        gl_id=item.gl_id,
        tenant_id=item.tenant_id,
        amount=item.amount,
        is_excluded=reason is not ExclusionReason.RECOVERABLE,
        reason=reason,
        lease_version=rules.lease_version,
    )


def partition_pool(
    items: Iterable[GLLineItem],
    rules_by_tenant: dict[tuple[str, str], ExclusionRuleSet],
) -> tuple[list[ExclusionResult], list[ExclusionResult]]:
    """Split a GL pool into recoverable and excluded results per tenant.

    A line item with no rule set for its (property_id, tenant_id) key is
    treated as fully recoverable only if a permissive default is supplied;
    here a missing rule set routes the item to the excluded set so that an
    unmapped tenant can never silently inflate a recovery denominator.
    """
    recoverable: list[ExclusionResult] = []
    excluded: list[ExclusionResult] = []

    for item in items:
        rules = rules_by_tenant.get((item.property_id, item.tenant_id))
        if rules is None:
            excluded.append(ExclusionResult(
                gl_id=item.gl_id, tenant_id=item.tenant_id, amount=item.amount,
                is_excluded=True, reason=ExclusionReason.LANDLORD_RESERVED,
                lease_version="MISSING_RULESET",
            ))
            continue
        result = evaluate_line_item(item, rules)
        (excluded if result.is_excluded else recoverable).append(result)

    return recoverable, excluded


def recoverable_total(results: Iterable[ExclusionResult]) -> Decimal:
    """Sum recoverable amounts with Decimal precision — the pro-rata denominator."""
    return sum((r.amount for r in results if not r.is_excluded), Decimal("0.00"))

The engine returns two typed lists: the clean, allocatable pool and the quarantined exclusions, each stamped with a reason and the lease_version that produced it. That separation is the whole point — the recoverable list is what the allocation stage divides among tenants, and the excluded list is the defensible, line-by-line record an accountant hands an auditor.

Validation Rules & Edge Cases

The cascade is simple; the ways real portfolios break it are not. Each of the following is a failure mode the engine must handle explicitly rather than absorb silently.

  • Conservation must hold. After partitioning, the sum of recoverable and excluded amounts must equal the original pool to the cent. Assert sum(recoverable) + sum(excluded) == total_gl using Decimal equality, not float tolerance; a mismatch means a line item was dropped or double-counted and the run must fail loudly.
  • Missing rule sets. A GL line whose (property_id, tenant_id) has no rule set must never default to recoverable — that is how an unmapped tenant silently inflates everyone’s denominator. Route it to the excluded set (as above) or to a review queue, but never assume recovery.
  • Overlapping predicates. A charge that is both capital and out-of-term is recorded once, under the higher-precedence reason (OUTSIDE_TERM). Because the cascade short-circuits, the reason is deterministic and reproducible across runs.
  • Mid-year lease boundaries. A tenant whose lease commences or expires mid-period will have in-term and out-of-term charges in the same reconciliation. The temporal predicate compares posting_date against the lease window per line, so partial-year occupancy is handled without a separate code path.
  • Regex greediness. An over-broad pattern like cap matches landscape. Anchor patterns and test them against a fixture of known-recoverable descriptions so an exclusion rule cannot quietly delete legitimate recoveries. Prefer the category and capital-code predicates over patterns wherever a category can express the carve-out.
  • Capital versus repair. Whether a charge is capital is a function of the lease’s capitalization threshold, not just the description; the split between an expensed repair and an amortized capital item is owned by the boundary discussed in handling controllable vs non-controllable CAM expenses.

Integration Points

Exclusion mapping is a gatekeeper, and its output shapes everything downstream of it.

  • Allocation denominator. The recoverable list is the exact pool implementing pro rata allocation algorithms distributes by tenant share. Because the engine already removed excluded dollars, the allocation stage never has to second-guess the base it is dividing.
  • Cap and ceiling logic. Exclusion must run before managing expense caps and controllable limits so that annual growth caps and controllable ceilings apply only to genuinely recoverable amounts. Capping a pool that still contains excluded capital would understate the tenant’s true controllable exposure.
  • Threshold calibration. The confidence and materiality thresholds that decide when a borderline exclusion routes to human review are tuned alongside threshold tuning for allocation accuracy, keeping the review queue small without letting material errors through.
  • Audit trail. Every ExclusionResult carries a reason and lease_version, so the excluded set is a complete, replayable record. Reconciling this record against the lease is exactly the discipline described in best practices for CAM expense exclusion tracking.

Testing & Verification

Exclusion bugs are expensive because they are invisible until an auditor or a tenant’s lease consultant finds them, so the engine is tested against small, hand-computed fixtures where the correct answer is known.

from datetime import date
from decimal import Decimal


def test_capital_and_temporal_precedence() -> None:
    rules = ExclusionRuleSet(
        property_id="P1", tenant_id="T1", lease_version="2026.1",
        lease_start=date(2026, 1, 1), lease_end=date(2026, 6, 30),
        capital_gl_codes=frozenset({"1700"}),
        excluded_categories=frozenset({"Marketing"}),
        excluded_patterns=(r"\broof\b",),
    )

    # In-term recoverable janitorial charge.
    recov = evaluate_line_item(GLLineItem(
        gl_id="L1", property_id="P1", tenant_id="T1", gl_code="6200",
        category="Janitorial", description="Common area sweep",
        amount=Decimal("1200.00"), posting_date=date(2026, 3, 1)), rules)
    assert recov.reason is ExclusionReason.RECOVERABLE
    assert recov.is_excluded is False

    # Capital roof charge posted after lease expiry: temporal wins on precedence.
    both = evaluate_line_item(GLLineItem(
        gl_id="L2", property_id="P1", tenant_id="T1", gl_code="1700",
        category="Repairs", description="Roof membrane replacement",
        amount=Decimal("180000.00"), posting_date=date(2026, 9, 1)), rules)
    assert both.reason is ExclusionReason.OUTSIDE_TERM

    # Conservation: recoverable + excluded equals the input, to the cent.
    items = [
        GLLineItem(gl_id="L1", property_id="P1", tenant_id="T1", gl_code="6200",
                   category="Janitorial", description="Sweep",
                   amount=Decimal("1200.00"), posting_date=date(2026, 3, 1)),
        GLLineItem(gl_id="L2", property_id="P1", tenant_id="T1", gl_code="1700",
                   category="Repairs", description="Roof",
                   amount=Decimal("180000.00"), posting_date=date(2026, 4, 1)),
    ]
    rec, exc = partition_pool(items, {("P1", "T1"): rules})
    total = sum((i.amount for i in items), Decimal("0.00"))
    assert recoverable_total(rec) + sum((r.amount for r in exc), Decimal("0.00")) == total
    assert recoverable_total(rec) == Decimal("1200.00")

The three assertions cover the properties that matter: a recoverable charge stays recoverable, an overlapping charge is excluded under the higher-precedence reason, and no dollar is created or destroyed by the partition. Because every amount is Decimal, the conservation check uses exact equality rather than a floating-point tolerance, so a one-cent rounding leak fails the test instead of hiding under isclose.

Frequently Asked Questions

Why does the engine record only one reason when a charge matches several carve-outs? An auditor asks why a specific dollar was excluded, and a single, deterministic reason is defensible while a list of maybe-reasons is not. The cascade short-circuits in a fixed precedence order — temporal, then capital, then landlord-reserved category, then pattern — so a charge that is both capital and out-of-term is always recorded under OUTSIDE_TERM, reproducibly, on every run.

What happens to a tenant that has no exclusion rule set? Its line items are routed to the excluded set with a MISSING_RULESET marker, never treated as recoverable. Defaulting an unmapped tenant to recoverable is the classic way excluded costs silently inflate the recovery denominator for everyone else in the pool, so the engine fails safe by excluding until a rule set is loaded.

Why must exclusion run before expense caps rather than after? Caps and controllable-expense ceilings are meant to limit the recoverable pool. If capping runs first, it operates on a pool still polluted with capital and landlord-reserved charges, understating the tenant’s true controllable exposure. Removing excluded amounts first means the cap applies to a base the lease actually allows to be recovered.

Can a regular-expression exclusion delete a legitimate recovery? Yes, which is why patterns are the lowest-precedence, last-resort predicate. An unanchored pattern like cap will match landscape and quietly remove a recoverable charge. Anchor every pattern, test it against a fixture of known-recoverable descriptions, and prefer the category or capital-code predicates whenever a structured field can express the same carve-out.

Why Decimal instead of float for the amounts? Binary floating point cannot represent most cent values exactly, so summing thousands of float amounts drifts by fractions of a cent and produces pools that fail conservation. Decimal, quantized to two places, keeps the recoverable-versus-excluded split penny-exact and lets the conservation check use exact equality instead of a tolerance that could mask a real error.

Where This Fits

Exclusion mapping is the control point where one shared GL pool becomes many tenant-specific recoverable pools, each defensible against the exact lease language behind it. By evaluating a deterministic precedence cascade, recording a single reason per exclusion, and preserving every amount as Decimal, the engine turns unstructured carve-outs into a base the rest of the pipeline can trust. It sits downstream of GL code mapping for CAM expenses, reads its carve-outs from the lease abstraction database, and feeds the recoverable pool straight into implementing pro rata allocation algorithms — the correctness of every tenant statement riding on which dollars this stage lets through.