Quarantining Invalid CAM Expense Records

A record that fails validation during a CAM close is not garbage to discard — it is a vendor invoice with real dollars attached that must be held, explained, fixed, and re-run without ever silently vanishing or double-posting. This page designs the quarantine queue that holds those rejects: the typed error taxonomy that says why each record failed, the context envelope that captures enough to fix it without re-reading the source PDF, and the remediate-and-replay loop that returns a corrected record to the allocatable pool exactly once. It is the reject-path implementation behind schema validation for parsed expense data, the validation stage of the broader automated invoice parsing and data ingestion pipeline. Where the validator decides accept-or-reject, this queue owns everything that happens to a record after the reject — and getting that lifecycle wrong is how a legitimate $8,400.00 repair invoice quietly disappears three weeks before year-end tie-out.

Quarantine and replay loop: validate, branch to accepted or quarantine, remediate, replay A parsed record enters a validation gate. Passing records flow to the Accepted lane and the allocatable pool. Failing records drop into a quarantine store that captures full context, then to a remediation step whose corrected output is replayed back through the same gate, closing the loop so a fixed invoice re-enters exactly once. Parsed record (dict, source_hash) Validate Pydantic gate Accepted certified record Allocatable pool Quarantined context captured Remediate apply patch pass fail replay
Every rejected record is held with its context, remediated, then replayed through the same gate — closing the loop exactly once.

Context & When to Use This Approach

A quarantine queue earns its keep the moment your feed stops being small enough to fix by hand. If a nightly ingest of a dozen trusted invoices throws a validation error, an accountant can open the PDF and re-key it. That does not survive a month-end close where three properties dump several thousand invoices from vendor portals, an accounts-payable inbox, and an SFTP drop within the same hour. At that volume the reject path needs to be a first-class system, not an exception log, and the signals that you have crossed the line are concrete:

  • A single malformed record must never abort the surrounding batch — the other invoices in the same run have to keep flowing while the bad one is set aside.
  • Accountants need to work rejects from the reject itself, with the exact failing rule, field, source coordinates, and document hash in front of them, instead of re-hunting the originating PDF for every failure.
  • A rejected record is frequently fixable and legitimate — a transposed total, a credit memo parsed with the wrong sign, an OCR-mangled amount — so the queue must support correcting it and re-running it, not just deleting it.
  • The same corrected invoice must re-enter the pipeline exactly once, even if a replay is retried, so a fixed $8,400.00 repair never posts twice against a tenant’s pro-rata share.

This is the sibling concern to building a CAM data validation layer: that page builds the gate that decides accept-or-quarantine and streams a batch through it; this page owns what the queue does with the rejects afterward. The two share a philosophy with retrying failed invoice batches with idempotency — a poison record is held rather than allowed to sink a run, and every re-run is keyed so it cannot double-count. If you are only validating a handful of trusted invoices interactively, skip all of this and let the model raise. The lifecycle below is for the case where a reject has to be captured, explained, fixed, and replayed under audit.

Assume the field-level models already exist. The ParsedCAMInvoice Pydantic model and the to_cents money coercion helper are defined in the parent schema validation for parsed expense data; this page imports them and builds the quarantine lifecycle around them. Every monetary value stays in Python’s decimal module — never float — so that summing thousands of quarantined-then-remediated amounts never drifts a reconciliation past its one-cent tolerance.

Step-by-Step Implementation

The lifecycle is five stages: validate and classify the failure by type, wrap the reject in a context envelope, route it to a keyed quarantine store, remediate and replay it exactly once, and instrument the queue so a spike is visible before it becomes a tie-out surprise.

Step 1 — Validate with Pydantic and classify every failure by type

Raw exceptions are hostile to a queue: “1 validation error” tells an accountant nothing sortable. Convert every failure into a typed taxonomy so the queue can group rejects by cause, route them to the right person, and drive metrics. Map Pydantic’s structured error output onto a small closed set of categories that mean something in CAM terms.

from __future__ import annotations

import enum
from dataclasses import dataclass
from decimal import Decimal
from typing import Optional

from pydantic import ValidationError as PydanticValidationError

# Field-level model + money helper live in the parent stage's implementation.
from cam.schema import ParsedCAMInvoice


class ErrorCategory(str, enum.Enum):
    """Closed taxonomy of why a CAM record fails — sortable in the queue."""

    SCHEMA_TYPE = "schema_type"          # wrong type / uncoercible value
    MISSING_FIELD = "missing_field"      # a required field is absent
    RECONCILIATION = "reconciliation"    # line items do not sum to the total
    OUT_OF_RANGE = "out_of_range"        # amount, date, or share outside bounds
    SIGN_CONTRACT = "sign_contract"      # credit/reversal with unexpected sign
    PROVENANCE = "provenance"            # missing or mismatched source hash


@dataclass(frozen=True)
class TypedError:
    """One machine-readable, categorized reason a record failed."""

    category: ErrorCategory
    field: Optional[str]   # dotted path, e.g. "line_items.0.amount"
    message: str


def _classify(err: dict) -> ErrorCategory:
    """Map one Pydantic error dict onto the CAM taxonomy."""
    kind = err.get("type", "")
    if kind in {"missing", "model_attributes_type"}:
        return ErrorCategory.MISSING_FIELD
    if kind.endswith("_parsing") or kind.endswith("_type"):
        return ErrorCategory.SCHEMA_TYPE
    if "greater_than" in kind or "less_than" in kind:
        return ErrorCategory.OUT_OF_RANGE
    return ErrorCategory.SCHEMA_TYPE


def classify_failure(raw: dict) -> list[TypedError]:
    """Validate a raw record; return [] if clean, else typed errors."""
    try:
        ParsedCAMInvoice.model_validate(raw)
    except PydanticValidationError as exc:
        return [
            TypedError(
                category=_classify(err),
                field=".".join(str(p) for p in err["loc"]) or None,
                message=err["msg"],
            )
            for err in exc.errors()
        ]
    return []

The taxonomy is deliberately closed. A new failure mode should force a conscious decision to add a category, not silently land in a catch-all — because “unknown error” records are exactly the ones that rot in a queue nobody triages.

Step 2 — Capture full context into a quarantine record

A reject that carries only “reconciliation failed” makes an accountant reopen the PDF. Capture enough context that the fix can happen from the queue entry alone: the original raw payload (so it can be patched and replayed), the source document hash (so provenance survives), the typed errors, an attempt counter, and lifecycle timestamps. The source_hash is bound to the immutable stored document, never to the parse, so it is stable across every replay.

import hashlib
import json
from datetime import datetime, timezone


class QuarantineStatus(str, enum.Enum):
    NEW = "new"                # just quarantined, awaiting triage
    IN_REVIEW = "in_review"    # an accountant has claimed it
    REPLAYED = "replayed"      # a remediation was re-run
    RESOLVED = "resolved"      # replay passed; record is in the pool
    ABANDONED = "abandoned"    # duplicate or unfixable; closed with reason


def _now() -> datetime:
    return datetime.now(timezone.utc)


@dataclass
class QuarantineRecord:
    """A held reject with everything needed to fix and replay it."""

    quarantine_id: str
    invoice_id: str
    source_hash: str
    dominant_category: ErrorCategory
    errors: list[TypedError]
    raw_payload: dict          # the exact parsed input, for patch + replay
    status: QuarantineStatus
    attempts: int
    first_seen: datetime
    last_updated: datetime


def build_quarantine(raw: dict, source_hash: str, errors: list[TypedError]) -> QuarantineRecord:
    """Wrap a failed record and its typed errors into a queue entry."""
    invoice_id = str(raw.get("invoice_id", "UNKNOWN"))
    # A deterministic id keyed on source + payload so re-quarantining the
    # same reject collapses onto one entry rather than fanning out duplicates.
    fingerprint = hashlib.sha256(
        (source_hash + json.dumps(raw, sort_keys=True)).encode("utf-8")
    ).hexdigest()[:16]
    now = _now()
    return QuarantineRecord(
        quarantine_id=f"Q-{fingerprint}",
        invoice_id=invoice_id,
        source_hash=source_hash,
        dominant_category=errors[0].category,   # first error drives routing
        errors=errors,
        raw_payload=raw,
        status=QuarantineStatus.NEW,
        attempts=0,
        first_seen=now,
        last_updated=now,
    )

The quarantine_id is a content fingerprint, not a random UUID. That single decision is what makes the queue idempotent: re-ingesting the same broken invoice — a common outcome when an upstream retry replays a whole batch — updates the existing entry instead of spawning a second copy an accountant would have to reconcile.

Step 3 — Route failures to a quarantine queue keyed by content hash

The store is a keyed collection: upsert by quarantine_id, so the same reject seen twice never duplicates. In production this is a database table or a dead-letter queue; the interface below is what the rest of the lifecycle depends on. Keeping the key as the content fingerprint means an at-least-once ingest produces an exactly-once quarantine entry — the same guarantee that retrying failed invoice batches with idempotency applies to the happy path.

class QuarantineStore:
    """Idempotent, keyed store for held rejects (in-memory reference impl)."""

    def __init__(self) -> None:
        self._records: dict[str, QuarantineRecord] = {}

    def upsert(self, record: QuarantineRecord) -> QuarantineRecord:
        """Insert a new reject or refresh an existing one — never duplicate."""
        existing = self._records.get(record.quarantine_id)
        if existing is not None:
            # Same reject seen again: keep the original first_seen, refresh cause.
            existing.errors = record.errors
            existing.dominant_category = record.dominant_category
            existing.last_updated = _now()
            return existing
        self._records[record.quarantine_id] = record
        return record

    def open_records(self) -> list[QuarantineRecord]:
        """Everything still awaiting resolution, oldest first for aging."""
        open_states = {QuarantineStatus.NEW, QuarantineStatus.IN_REVIEW,
                       QuarantineStatus.REPLAYED}
        return sorted(
            (r for r in self._records.values() if r.status in open_states),
            key=lambda r: r.first_seen,
        )

    def get(self, quarantine_id: str) -> Optional[QuarantineRecord]:
        return self._records.get(quarantine_id)

Step 4 — Remediate and replay without double-counting

Remediation is a patch — a small mapping of field overrides an accountant supplies (or a rule proposes) — applied to the held raw_payload, after which the record is re-validated through the same gate from Step 1. If it now passes, it is promoted to RESOLVED and the certified invoice is returned for the allocatable pool; if it still fails, the attempt is recorded and it stays open with fresh errors. Because promotion is guarded by status, replaying an already-RESOLVED record is a no-op — the corrected invoice cannot post twice.

@dataclass(frozen=True)
class ReplayResult:
    quarantine_id: str
    resolved: bool
    invoice: Optional[ParsedCAMInvoice]   # present only when resolved


def remediate_and_replay(
    store: QuarantineStore,
    quarantine_id: str,
    patch: dict,
) -> ReplayResult:
    """Apply a field patch to a held reject and re-run validation once."""
    record = store.get(quarantine_id)
    if record is None:
        raise KeyError(f"no quarantined record {quarantine_id}")

    # Idempotency guard: an already-resolved record never re-posts.
    if record.status is QuarantineStatus.RESOLVED:
        return ReplayResult(quarantine_id, resolved=True, invoice=None)

    # Apply the correction to a copy — the original raw_payload is preserved
    # as the audit trail of what was actually parsed from the vendor PDF.
    patched = {**record.raw_payload, **patch}
    errors = classify_failure(patched)
    record.attempts += 1
    record.last_updated = _now()

    if errors:
        # Still broken: refresh the cause and keep it open for another pass.
        record.errors = errors
        record.dominant_category = errors[0].category
        record.status = QuarantineStatus.REPLAYED
        return ReplayResult(quarantine_id, resolved=False, invoice=None)

    # Clean now: promote and hand the certified invoice back to the pipeline.
    record.raw_payload = patched
    record.status = QuarantineStatus.RESOLVED
    invoice = ParsedCAMInvoice.model_validate(patched)
    return ReplayResult(quarantine_id, resolved=True, invoice=invoice)

Note that the patch is applied to a copy and the promotion is idempotent. Between those two properties, remediation is safe to retry, safe to run concurrently against distinct records, and always leaves an audit trail of the original parse next to the correction.

Step 5 — Instrument the queue: aging, category mix, and replay success

A quarantine queue that nobody watches is a silent backlog that surfaces as a blown year-end tie-out. Emit three numbers continuously: how the open rejects break down by category (which vendor template just changed), how old the oldest open reject is (are we falling behind the close deadline), and what share of remediations actually stick. The replay success rate is worth a formula, where resolvedresolved and abandonedabandoned are terminal counts:

replay_success=resolved/(resolved+abandoned)replay\_success = resolved / (resolved + abandoned)
from collections import Counter


@dataclass(frozen=True)
class QueueMetrics:
    open_count: int
    by_category: dict[str, int]
    oldest_age_seconds: Decimal
    replay_success: Decimal   # resolved / (resolved + abandoned)


def queue_metrics(store: QuarantineStore) -> QueueMetrics:
    """Snapshot the queue health for a dashboard or alert threshold."""
    open_records = store.open_records()
    categories = Counter(r.dominant_category.value for r in open_records)

    if open_records:
        age = _now() - open_records[0].first_seen
        oldest = Decimal(str(age.total_seconds())).quantize(Decimal("1"))
    else:
        oldest = Decimal("0")

    all_records = list(store._records.values())
    resolved = sum(1 for r in all_records if r.status is QuarantineStatus.RESOLVED)
    abandoned = sum(1 for r in all_records if r.status is QuarantineStatus.ABANDONED)
    terminal = resolved + abandoned
    success = (
        (Decimal(resolved) / Decimal(terminal)).quantize(Decimal("0.01"))
        if terminal else Decimal("0")
    )
    return QueueMetrics(
        open_count=len(open_records),
        by_category=dict(categories),
        oldest_age_seconds=oldest,
        replay_success=success,
    )

A sudden concentration of reconciliation rejects against one vendor almost always means that vendor changed its statement template — a signal far cheaper to catch on this dashboard than in a tenant dispute months later.

Gotchas & Known Limitations

The failure modes that actually bite are in the lifecycle, not the field rules. Treat this as a pre-flight checklist before trusting the queue against a live feed:

Verification

Confirm the lifecycle by asserting on each stage independently: a clean record classifies to no errors, a defect classifies to the specific category that should fire, the store collapses duplicates, and a replay resolves a fixable reject exactly once while leaving an already-resolved record untouched. Assert on the taxonomy and the idempotency guard so a later refactor cannot quietly weaken either.

from decimal import Decimal


def _clean_raw() -> dict:
    return {
        "invoice_id": "INV-7781",
        "vendor_name": "Beacon Landscaping",
        "invoice_date": "2026-03-11",
        "property_id": "PLAZA-9",
        "total_amount": "2450.00",
        "line_items": [
            {"description": "Grounds", "gl_code": "5200-LAND", "amount": "2450.00"},
        ],
    }


def test_clean_record_has_no_typed_errors() -> None:
    assert classify_failure(_clean_raw()) == []


def test_missing_field_classifies_precisely() -> None:
    raw = _clean_raw()
    del raw["total_amount"]
    errors = classify_failure(raw)
    assert any(e.category is ErrorCategory.MISSING_FIELD for e in errors)


def test_store_collapses_duplicate_rejects() -> None:
    store = QuarantineStore()
    raw = _clean_raw()
    del raw["property_id"]
    errors = classify_failure(raw)
    first = store.upsert(build_quarantine(raw, "sha256:aa", errors))
    again = store.upsert(build_quarantine(raw, "sha256:aa", errors))
    assert first.quarantine_id == again.quarantine_id     # same content key
    assert len(store._records) == 1                       # no duplicate entry


def test_remediation_resolves_once_and_is_idempotent() -> None:
    store = QuarantineStore()
    raw = _clean_raw()
    raw["total_amount"] = "24500.00"   # a stray zero: fails reconciliation intent
    errors = classify_failure(raw) or [
        TypedError(ErrorCategory.RECONCILIATION, "total_amount", "does not tie")
    ]
    rec = store.upsert(build_quarantine(raw, "sha256:bb", errors))

    result = remediate_and_replay(store, rec.quarantine_id, {"total_amount": "2450.00"})
    assert result.resolved is True
    assert store.get(rec.quarantine_id).status is QuarantineStatus.RESOLVED
    assert store.get(rec.quarantine_id).attempts == 1

    # Replaying a resolved record must not re-post or re-increment.
    repeat = remediate_and_replay(store, rec.quarantine_id, {"total_amount": "2450.00"})
    assert repeat.resolved is True
    assert store.get(rec.quarantine_id).attempts == 1     # idempotent no-op

The last test is the one that matters most: it proves the corrected invoice resolves once and that a retried replay is a genuine no-op, so a fixed amount can never post twice against a tenant’s share. Extend it with property-based tests that generate reconciling and non-reconciling line-item sets in Decimal, assert the reconciling ones classify clean, and assert every non-reconciling one lands in quarantine with a category an accountant can route.

Once the queue is green, resolved records rejoin the certified stream that flows to GL code mapping for CAM expenses, and the whole design belongs to schema validation for parsed expense data; the gate that feeds this queue is built in building a CAM data validation layer.