Building a CAM Dispute Resolution Workflow

A CAM dispute resolution workflow is the event-driven machinery that carries a tenant’s challenge from the moment it is filed against a reconciliation statement through triage, review, and a forward-dated resolution that never rewrites the closed period. It is the operational engine behind the dispute lifecycle sketched in tenant statement generation and dispute routing, and where that parent topic introduces the case state machine at a high level, this page builds the implementation: an intake contract that classifies a challenge on arrival, a transition table driven by explicit events rather than raw state hops, type-based triage that assigns each case to the right reviewer with the right evidence, an SLA clock that ages every open case against its lease-granted window, and a hash-chained audit log that makes the whole path tamper-evident. The goal is a workflow a landlord can run through an entire reconciliation season and still, months later, prove exactly who did what to which case and why.

The dispute resolution workflow stages Four rounded rectangles in a horizontal row, labeled Intake, Triage, Review, and Resolve, connected left to right by arrows carrying the secondary labels log, route, and decide. The final Resolve stage is highlighted to mark the forward-dated closing action. Intake Triage Review Resolve log route decide
The dispute case advances through guarded workflow stages from intake to a forward-dated resolution.

Context & When to Use This Approach

Reach for a formal dispute resolution workflow the moment tenant challenges stop fitting in a property manager’s inbox. A single-tenant building can survive on email and a spreadsheet; a portfolio issuing hundreds of reconciliation statements each season cannot, because every statement is a financial claim with a lease-defined clock attached, and an unanswered challenge is not merely a customer-service lapse but a potential waiver of the landlord’s recovery. The workflow described here belongs downstream of statement issuance and upstream of the next true-up cycle: it activates when a tenant pushes back on a number, and it deactivates only when the case reaches a closed, audited terminal state.

The design is worth the effort precisely because CAM disputes are adversarial and bounded by time. A tenant who contests its share is often exercising a contractual audit right, and that right expires — commonly ninety to one hundred eighty days after the statement is issued. A landlord who cannot show that each challenge was acknowledged, classified, reviewed, and answered inside that window is negotiating from weakness. Encoding the window as data on the case, rather than as a reminder someone might set, is the difference between a defensible position and a missed deadline. The workflow also has to respect an accounting constraint that is easy to get wrong under pressure: once a reconciliation period is closed and statements are out, the recognized-revenue snapshot for that period is fixed under FASB ASC 842 lease accounting guidelines, so a resolved dispute may only produce a forward-dated correction — never an in-place edit of the closed books. Every guard in the machine below exists to make that rule impossible to violate by accident.

Finally, this workflow assumes the security and integrity guarantees described in CAM reconciliation security and access controls: the actors who transition a case are authenticated and role-scoped, and the audit log the workflow writes is the same kind of append-only, tamper-evident record that access auditing relies on. If those controls are absent, the workflow’s audit trail is only as trustworthy as the least-privileged account that can quietly rewrite it.

It helps to be explicit about what this workflow is not. It is not a general help-desk ticketing system bolted onto CAM, and it is not a free-text negotiation log. The distinction matters because a CAM dispute has a fixed shape: it targets a specific pool on a specific statement for a specific period, it belongs to one of a small number of contestable grounds, and it must resolve into either a defended original figure or a forward-dated money movement. Modeling that shape directly — a typed intake, a closed set of states, and a small event vocabulary — is what makes the case machine analyzable, testable, and auditable, where a generic ticket queue would let cases drift into states no one can reconstruct. Treat the sections below as one continuous build: each step adds a layer that the next depends on, and none of them is optional if the resulting workflow is meant to survive a lease audit.

Step-by-Step Implementation

Step 1 — Model the dispute intake

Intake is where a vague complaint becomes a structured case. The intake model captures who is disputing, which statement and pool are contested, how much money is in play, and — critically — the classification that will drive every downstream routing decision. Classifying at the door, rather than deferring it to a reviewer, is what lets the workflow attach the right evidence and reviewer automatically. All amounts are decimal.Decimal; a claimed adjustment of $4,120.00 must never drift by a rounding artifact between intake and resolution.

from __future__ import annotations

from datetime import date, timedelta
from decimal import Decimal
from enum import Enum

from pydantic import BaseModel, Field


class DisputeType(str, Enum):
    """The ground a tenant disputes, fixed at intake to drive triage."""

    MEASUREMENT = "measurement"   # contests the denominator / rentable area
    EXCLUSION = "exclusion"       # a negotiated-out cost appears on the statement
    CAP = "cap"                   # a controllable cap should have limited a pool
    MATH = "math"                 # the arithmetic itself is alleged wrong


class DisputeIntake(BaseModel):
    """The structured record created when a tenant files a challenge."""

    dispute_id: str
    tenant_id: str
    period: str                                   # e.g. "2025"
    statement_id: str
    contested_pool: str                           # pool the challenge targets
    dispute_type: DisputeType
    claimed_amount: Decimal = Field(..., ge=0)    # tenant's asserted correction
    statement_issued_on: date
    filed_on: date
    audit_window_days: int = Field(90, ge=1)      # from the tenant's lease

    def audit_deadline(self) -> date:
        """Last date the tenant's lease audit right remains exercisable."""
        return self.statement_issued_on + timedelta(days=self.audit_window_days)

    def is_in_window(self) -> bool:
        """A dispute filed after the audit right lapses is recorded but flagged."""
        return self.filed_on <= self.audit_deadline()

Step 2 — Define the state enum and event-driven transition guards

The case machine is driven by events, not by callers naming a target state directly. An event such as START_REVIEW is only legal from certain states, and the transition table keyed on (status, event) is the single source of truth for what may happen next. This event-driven shape is deliberately stricter than a bare state-to-state map: a caller cannot request an arbitrary jump, only fire a named action, and illegal actions are rejected before any side effect runs. The ADJUST event carries the accounting guard — it refuses to advance unless the resolution is accompanied by a forward-dated correction.

from __future__ import annotations

from dataclasses import dataclass, field


class DisputeStatus(str, Enum):
    INTAKE = "intake"
    TRIAGED = "triaged"
    UNDER_REVIEW = "under_review"
    ADJUSTED = "adjusted"
    UPHELD = "upheld"
    WITHDRAWN = "withdrawn"
    CLOSED = "closed"


class DisputeEvent(str, Enum):
    TRIAGE = "triage"
    START_REVIEW = "start_review"
    ADJUST = "adjust"
    UPHOLD = "uphold"
    WITHDRAW = "withdraw"
    CLOSE = "close"


# (current status, event) -> resulting status. Anything absent is illegal.
_TRANSITIONS: dict[tuple[DisputeStatus, DisputeEvent], DisputeStatus] = {
    (DisputeStatus.INTAKE, DisputeEvent.TRIAGE): DisputeStatus.TRIAGED,
    (DisputeStatus.INTAKE, DisputeEvent.WITHDRAW): DisputeStatus.WITHDRAWN,
    (DisputeStatus.TRIAGED, DisputeEvent.START_REVIEW): DisputeStatus.UNDER_REVIEW,
    (DisputeStatus.TRIAGED, DisputeEvent.WITHDRAW): DisputeStatus.WITHDRAWN,
    (DisputeStatus.UNDER_REVIEW, DisputeEvent.ADJUST): DisputeStatus.ADJUSTED,
    (DisputeStatus.UNDER_REVIEW, DisputeEvent.UPHOLD): DisputeStatus.UPHELD,
    (DisputeStatus.ADJUSTED, DisputeEvent.CLOSE): DisputeStatus.CLOSED,
    (DisputeStatus.UPHELD, DisputeEvent.CLOSE): DisputeStatus.CLOSED,
    (DisputeStatus.WITHDRAWN, DisputeEvent.CLOSE): DisputeStatus.CLOSED,
}


class IllegalDisputeEvent(Exception):
    """Raised when an event is fired from a state that forbids it."""


@dataclass
class DisputeCase:
    """A live dispute case: its intake, current status, and event history."""

    intake: DisputeIntake
    status: DisputeStatus = DisputeStatus.INTAKE
    history: list[tuple[DisputeStatus, DisputeEvent, DisputeStatus]] = field(
        default_factory=list
    )

    def fire(self, event: DisputeEvent, *, has_forward_correction: bool = False) -> None:
        """Advance the case by firing an event, or refuse the illegal move."""
        key = (self.status, event)
        if key not in _TRANSITIONS:
            raise IllegalDisputeEvent(f"{event.value} not allowed from {self.status.value}")
        # A closed period may only be corrected forward, never edited in place.
        if event is DisputeEvent.ADJUST and not has_forward_correction:
            raise IllegalDisputeEvent(
                "ADJUST requires a forward-dated correction, not an in-place edit"
            )
        nxt = _TRANSITIONS[key]
        self.history.append((self.status, event, nxt))
        self.status = nxt

Step 3 — Triage each case by type, and Step 4 — generate the forward-dated adjustment

Triage is a deterministic lookup, not a judgement call: the DisputeType fixed at intake decides which queue owns the case and what evidence resolves it. A measurement challenge goes to whoever owns the lease abstraction and the BOMA-basis area; a cap challenge goes to the cap-schedule owner; and so on. Making this a table rather than a reviewer’s discretion has a second benefit beyond speed: it is auditable. A landlord can show that every challenge of a given ground was routed the same way and answered with the same class of evidence, which is a far stronger posture than a paper trail of individually reasoned assignments. When review concludes the tenant is right, the resolution never touches the closed period — it emits a ForwardAdjustment posted to the next open period, referencing the original statement and the dispute that produced it, so the closed books and their audit fingerprint stay intact under ASC 842. The sign convention matters here: a credit owed back to the tenant is a negative amount and an additional charge is positive, so the same adjustment type carries corrections in both directions without a special case.

from __future__ import annotations

from decimal import Decimal, ROUND_HALF_UP

CENTS = Decimal("0.01")


@dataclass(frozen=True)
class TriageAssignment:
    queue: str
    required_evidence: str


_TRIAGE_ROUTES: dict[DisputeType, TriageAssignment] = {
    DisputeType.MEASUREMENT: TriageAssignment("lease-abstraction", "rentable-area recertification"),
    DisputeType.EXCLUSION: TriageAssignment("exclusion-registry", "negotiated exclusion clause"),
    DisputeType.CAP: TriageAssignment("cap-schedule", "controllable cap version + prior base"),
    DisputeType.MATH: TriageAssignment("calculation-core", "audit-trail replay of the pool share"),
}


def triage(case: DisputeCase) -> TriageAssignment:
    """Route a triaged case to its owning queue with the evidence it needs."""
    if case.status is not DisputeStatus.TRIAGED:
        raise IllegalDisputeEvent("triage routing applies only to a triaged case")
    return _TRIAGE_ROUTES[case.intake.dispute_type]


@dataclass(frozen=True)
class ForwardAdjustment:
    """A correction posted to an OPEN period, never editing the closed one."""

    tenant_id: str
    original_statement_id: str
    dispute_id: str
    post_to_period: str            # the next open reconciliation period
    amount: Decimal                # signed: credit is negative, charge is positive

    def quantized(self) -> Decimal:
        return self.amount.quantize(CENTS, ROUND_HALF_UP)


def build_forward_adjustment(case: DisputeCase, corrected_amount: Decimal,
                             original_amount: Decimal, post_to_period: str) -> ForwardAdjustment:
    """Compute the signed correction and post it to the next open period."""
    delta = (corrected_amount - original_amount).quantize(CENTS, ROUND_HALF_UP)
    return ForwardAdjustment(
        tenant_id=case.intake.tenant_id,
        original_statement_id=case.intake.statement_id,
        dispute_id=case.intake.dispute_id,
        post_to_period=post_to_period,
        amount=delta,
    )

Step 5 — Age each open case against its SLA, and Step 6 — write a hash-chained audit log

An open case has two clocks: how long it has been open, and how close it is to the lease’s response deadline. The aging function surfaces cases approaching a breach before the window passes. Every transition, meanwhile, is committed to an append-only audit log whose entries are hash-chained with hashlib, so any later tampering with a resolved case’s history is detectable — each entry’s fingerprint depends on the one before it. Expressed plainly, days_open=nowfiled_ondays\_open = now - filed\_on, and a case is at risk when days_opendays\_open approaches the response window.

from __future__ import annotations

import hashlib
import json
from dataclasses import asdict


def sla_status(case: DisputeCase, today: date, response_window_days: int = 45) -> str:
    """Classify an open case as on_track, at_risk, or breached."""
    if case.status in (DisputeStatus.CLOSED, DisputeStatus.WITHDRAWN):
        return "resolved"
    deadline = case.intake.filed_on + timedelta(days=response_window_days)
    days_left = (deadline - today).days
    if days_left < 0:
        return "breached"
    if days_left <= 7:
        return "at_risk"
    return "on_track"


@dataclass
class AuditEntry:
    dispute_id: str
    actor: str
    event: str
    from_status: str
    to_status: str
    at: str                       # ISO timestamp
    prev_hash: str
    entry_hash: str = ""

    def finalize(self) -> "AuditEntry":
        """Chain this entry to its predecessor with a tamper-evident hash."""
        payload = {k: v for k, v in asdict(self).items() if k != "entry_hash"}
        digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
        self.entry_hash = digest
        return self


def append_audit(log: list[AuditEntry], *, dispute_id: str, actor: str,
                 event: str, from_status: str, to_status: str, at: str) -> AuditEntry:
    """Append a hash-chained record of one transition to the audit log."""
    prev_hash = log[-1].entry_hash if log else "0" * 64
    entry = AuditEntry(dispute_id, actor, event, from_status, to_status, at, prev_hash)
    entry = entry.finalize()
    log.append(entry)
    return entry

Gotchas & Known Limitations

  • Never edit a closed period. The single most damaging mistake is resolving a dispute by rewriting the closed reconciliation. Under ASC 842 the closed period is a recognized-revenue snapshot; the only correct resolution is a forward-dated adjustment posted to an open period, which the ADJUST guard enforces. A workflow that lets a reviewer patch last year’s numbers can force a restatement.
  • Deduplicate on tenant, period, and pool. Tenants and their auditors resubmit. A second challenge against the same (tenant_id, period, contested_pool) while a case is open must attach to the existing case, not spawn a second machine that could resolve two different ways.
  • Model partial acceptance at line granularity. A tenant who accepts most of a statement and disputes one pool should open a case scoped to that pool only, so the undisputed balance is collectible and the SLA clock ages only the genuinely contested amount.
  • Out-of-window disputes are recorded, not silently dropped. is_in_window() flags a late filing, but the case is still logged; the audit trail must show that a lapsed audit right was the reason for declining, not an unexplained gap.
  • The SLA response window is a default, not a universal. response_window_days should be resolved per lease from the abstraction database; a single hard-coded window will misjudge the leases that negotiated their own terms.
  • Withdrawal is a real terminal path. Tenants abandon challenges. Without an explicit WITHDRAW event, those cases either linger open and pollute aging reports or get force-closed in a way the audit trail cannot explain.

Verification

Verify the machine the way a court would read it: exhaustively and against the table, not by spot-checking a few happy paths. Enumerate the full cross-product of DisputeStatus and DisputeEvent and assert that exactly the pairs present in _TRANSITIONS succeed while every other pair raises IllegalDisputeEvent; a future edit that accidentally lets a CLOSED case accept an event then fails immediately because the test derives its expectations from the same table the code enforces. Add a targeted assertion that firing ADJUST with has_forward_correction=False is rejected, which locks in the ASC 842 guard as a regression test rather than a comment. For triage, assert that each DisputeType routes to the queue and evidence a reviewer actually needs, so a mis-mapped route surfaces in the suite instead of in a reviewer’s confusion.

The money and time behaviors need their own checks. Build a ForwardAdjustment from a corrected obligation and assert the signed delta quantizes to the expected cents — a corrected share of $9,380.00 against an original $11,500.00 must yield a credit of exactly $-2,120.00, not a float approximation. Drive sla_status with fixtures set just inside and just past the response window and assert the at_risk and breached boundaries fire on the right day. Finally, exercise the audit log: append several transitions, recompute each entry’s SHA-256 over its payload plus its predecessor’s hash, and assert the chain validates; then mutate one entry’s fields and assert the recomputation no longer matches, proving the log is tamper-evident. Together these confirm the arithmetic a tenant will check, the deadlines a lease imposes, and the integrity an auditor will demand.

Once the state machine, triage routing, forward-dated corrections, aging, and audit chain all pass, the workflow is ready to sit behind issued statements — the same statements produced by generating CAM reconciliation statements as PDF — and to feed its resolved forward adjustments into the next reconciliation cycle, closing the loop opened in tenant statement generation and dispute routing.