Quarantining and Replaying Rejected Cost Rows

A validation gate that only rejects is half a system. The moment a cost row fails the strict schema built in Schema Validation & Error Handling, two obligations open at once: the failing row must be preserved exactly as it arrived so a production accountant can see what was wrong and fix it, and the corrected row must be able to re-enter the ledger later without any risk that a row already posted gets posted a second time. Get the first half wrong and a malformed line vanishes into a log file; get the second half wrong and a well-meaning re-run double-books a vendor invoice a completion guarantor is watching to the dollar. This walkthrough builds both halves as one deterministic loop — write the original row verbatim to a quarantine store alongside a SHA-256 fingerprint and a machine-readable reason code, then replay corrected rows with an idempotent dedup on the hash so a retried batch can never post the same cost twice.

Prerequisites and Context

This page picks up exactly where validation leaves off: a row has already been rejected by the boundary schema and its ValidationError reason is known. Quarantine and replay are the storage-and-recovery layer around that rejection, not a second validator. The implementation targets Python 3.11+ for standard-library zoneinfo, and uses the same restrained stack as the rest of Cost Ingestion & Data Parsing Workflows: hashlib for the deterministic row fingerprint, zoneinfo for timezone-aware audit stamps, and Pydantic v2 (model_validate, ConfigDict(frozen=True)) for the quarantine record itself. The append-only quarantine and ledger tables are typically fronted by SQLAlchemy 2.0, but nothing here depends on the backing store beyond its being append-only.

Two properties from upstream carry directly into this design. First, the row arrives as an untrusted dictionary and must be stored without mutation — the same forensic-preservation rule the malformed-input handling in Handling Malformed CSVs from Set Accountants enforces, because a quarantine entry an accountant edited in place is no longer evidence of what the vendor actually sent. Second, the quarantine store and the ledger both live under the write-once guarantees formalized in Audit Logging & Immutability: a resolved quarantine entry is superseded by a new record, never overwritten, so the original failure remains reconstructable for a guarantor’s examiner months after the close it delayed.

Step-by-Step Implementation

The mechanism rests on a single stable identity for a row: a SHA-256 over its canonical serialization. That fingerprint is computed the same way at three moments — when the row is quarantined, when a corrected version is prepared, and when it is posted — so the ledger can carry the set of hashes it has already committed and reject any replay whose hash is already present. Quarantine writes the raw row verbatim; replay recomputes the hash on the corrected row, checks it against the posted set, validates, and posts only if it is genuinely new.

from __future__ import annotations

import hashlib
import json
from datetime import datetime
from typing import Callable
from zoneinfo import ZoneInfo

from pydantic import BaseModel, ConfigDict

LEDGER_TZ = ZoneInfo("America/Los_Angeles")


def row_fingerprint(row: dict) -> str:
    """Stable SHA-256 over a canonical serialization of a raw cost row.

    The exact same serialization rule must be used everywhere a row is
    hashed, or the dedup key is not comparable across quarantine and replay.
    """
    canonical = json.dumps(row, sort_keys=True, separators=(",", ":"), default=str)
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


class QuarantineEntry(BaseModel):
    """A rejected cost row preserved verbatim for correction and replay."""

    model_config = ConfigDict(frozen=True)

    row_sha256: str
    raw_row: dict            # the original row, exactly as received — never mutated
    reason_code: str
    batch_id: str
    row_index: int
    quarantined_at: str
    resolved: bool = False


def quarantine_row(
    raw_row: dict, reason_code: str, batch_id: str, row_index: int
) -> QuarantineEntry:
    return QuarantineEntry(
        row_sha256=row_fingerprint(raw_row),
        raw_row=raw_row,
        reason_code=reason_code,
        batch_id=batch_id,
        row_index=row_index,
        quarantined_at=datetime.now(LEDGER_TZ).isoformat(),
    )


def replay_corrected(
    corrected_rows: list[dict],
    validate: Callable[[dict], None],   # raises on an invalid row
    post_to_ledger: Callable[[dict, str], None],
    posted_hashes: set[str],            # hashes already committed to the ledger
) -> dict:
    """Replay corrected rows idempotently. A row whose hash is already in the
    ledger is skipped, so a retried or overlapping batch never double-posts."""
    summary = {"posted": 0, "skipped_duplicate": 0, "still_invalid": 0}

    for row in corrected_rows:
        digest = row_fingerprint(row)

        # Idempotency guard: identical corrected bytes hash identically, so a
        # replay of an already-posted row is a no-op rather than a duplicate.
        if digest in posted_hashes:
            summary["skipped_duplicate"] += 1
            continue

        try:
            validate(row)
        except Exception:
            # Still not valid — leave it quarantined for another correction pass.
            summary["still_invalid"] += 1
            continue

        post_to_ledger(row, digest)
        posted_hashes.add(digest)
        summary["posted"] += 1

    return summary

The design turns replay from a risky bulk re-import into a safe, repeatable operation. posted_hashes is not an in-memory convenience — it is materialized from the hashes the ledger itself recorded on every committed row, so it survives a process restart and reflects the true posted state rather than one run’s memory. A corrected row that reproduces the same canonical bytes as an already-posted row produces the same digest and is skipped; a row still failing validation is counted and left in quarantine for a further correction pass rather than silently dropped. Nothing about the operation assumes it runs once: replaying the same corrected file twice, or replaying a batch that overlaps a previous one, converges to the same ledger state.

The path a rejected row travels from quarantine back to the ledger is a short decision flow with the dedup check as its pivot.

Quarantine and idempotent replay decision flow for a rejected cost row A cost row that fails validation is written verbatim to a quarantine store carrying its raw bytes, a SHA-256 fingerprint, and a reason code. An operator later corrects and resubmits the row, and its hash is recomputed with the same canonical rule. A single decision asks whether that hash is already present in the ledger's set of posted hashes. If it is, the replay is skipped as idempotent so no cost is double-posted. If it is not, the corrected row is validated and posted, and its hash is recorded in the ledger. Both branches converge on one terminal step that appends a replay audit entry. Row fails validation schema gate rejects Write to quarantine raw row · SHA-256 · reason Correct + resubmit operator fixes field Recompute row hash same canonical rule hash already posted? yes Skip · idempotent no double-post new Validate + post record hash in ledger Append replay entry + SHA-256 audit hash

Audit Trail Requirements

Both stores in this loop are append-only, and every transition through them is logged. A quarantine entry must carry, at minimum: the row_sha256 fingerprint of the original bytes; the raw_row verbatim; the machine-readable reason_code that names why validation failed; the batch_id and row_index that locate the row in its source file; and a timezone-aware quarantined_at stamp pinned to the studio’s booking day via zoneinfo rather than a bare offset. The quarantine record is written before the batch’s clean rows commit, so a crash between rejection and persistence leaves a replayable record of intent rather than a lost row.

Replay is logged with equal rigor. Each replay entry records the corrected row’s hash, the outcome (posted, skipped_duplicate, or still_invalid), a timezone-aware timestamp, and the identity of the operator who authorized the correction — SYS_AUTO is never valid on a replay, because a human decided what the fix was. Resolving a quarantine entry is itself an append: a new record marks the original resolved, and the original is never edited in place, so the failure, the correction, and the eventual post form an unbroken chain. That chain is what an examiner reads to confirm a re-run corrected a genuine error rather than quietly reposting a cost that was already in the ledger — the same immutable-ledger property specified in Audit Logging & Immutability.

Gotchas and Production Edge Cases

Replay idempotency. The single guarantee this whole design exists to provide is that replaying a corrected batch cannot double-post. That guarantee holds only if posted_hashes reflects the ledger’s committed state, not one run’s memory — materialize it from the hashes the ledger recorded, and add a hash to it in the same transaction that posts the row, so a crash between “post” and “remember” cannot readmit the row on the next replay. Deduping in memory alone means a process restart forgets what it posted and reposts it.

Hash stability. A dedup key is only useful if the same logical row always produces the same hash. Serialize canonically — sorted keys, fixed separators, a deterministic rule for non-string values — everywhere a row is fingerprinted, at quarantine and at replay alike. If one call path stringifies a number as "1250" and another as "1250.0", or one preserves key order and another does not, the same corrected row yields two different hashes and the idempotency guard silently fails. Pin the serialization in one shared function, as row_fingerprint does, and never hash a row any other way.

Partial-batch retries. When a replay of five hundred corrected rows fails at row three hundred, the safe recovery is to replay the whole file again — which is only safe because the first three hundred rows are already in posted_hashes and skip cleanly on the second pass. Never track “where the batch stopped” as a row offset and resume from it; an offset drifts the moment the corrected file is re-sorted or a row is added, whereas the hash-based skip is correct regardless of order or batch boundaries.

Correction changes the hash, on purpose. The quarantine entry stores the hash of the original row; a genuine correction changes the bytes and therefore the hash, which is exactly why a corrected row is not mistaken for its own failed predecessor. Do not try to preserve the original hash across a correction — the value of two distinct hashes is that the audit trail shows a real edit occurred rather than a duplicate slipping through.

Transport failures are not row failures. A truncated download or a 503 from a vendor API is an infrastructure outcome handled at the boundary in CSV & API Sync Pipelines; it must never land in this row-level quarantine, or an operator will waste a correction pass on a row that was never malformed, only never fully delivered.

Up one level: Schema Validation & Error Handling, part of Cost Ingestion & Data Parsing Workflows.