Designing Write-Once Audit Ledgers in Python

The failure this page prevents is quiet and expensive: a posted production-finance figure gets edited in place — a rate corrected, a penalty softened, a contingency draw backdated — and because the storage layer allowed an update, the original is simply gone, and with it the evidence a completion guarantor or a union trust fund would have used to trust the report. A write-once audit ledger makes that class of failure structurally impossible: once a record is sealed it can be read forever but never overwritten, and a correction is a new record rather than an edit of the old one. This walkthrough builds that ledger in Python — a frozen Pydantic v2 entry model, an append-only store abstraction with a single mutating method, and a persistence layer backed by genuinely immutable storage — as the storage foundation beneath the shared spine described in Audit Logging & Immutability.

Prerequisites and Context

This page targets Python 3.11+ and the same narrow stack the rest of the Audit Logging & Immutability section uses: Pydantic v2 (model_validate, field_validator, ConfigDict(frozen=True)) for the immutable entry model, and the standard-library hashlib, json, decimal, and zoneinfo modules for fingerprinting, canonical serialization, currency-safe arithmetic, and timezone-aware timestamps. Money is Decimal built from strings as Decimal(str(x)), never float, so a fingerprint over a monetary payload is reproducible rather than dependent on binary float formatting. At production scale the store persists to append-only object storage or an event-sourced table via SQLAlchemy; the abstraction below is written so the backend is swappable without touching the sealing logic.

The scope here is deliberately the storage contract, not the hashing or chaining math. How entries are canonically serialized and linked into a tamper-evident chain is covered in Hash-Chaining Cost Ledger Entries for Tamper Evidence; this page assumes an entry already carries its fingerprint and focuses on guaranteeing it can never be mutated after it lands. That guarantee is what lets the residual, turnaround, and fringe engines across the platform treat this store as a trustworthy sink and what lets Completion Bond Reporting & Guarantor Analytics cite a stored figure without re-deriving it.

Step-by-Step Implementation

The design has two layers. The inner layer is a frozen entry model whose immutability is enforced at the object level, so nothing in the running process can reassign a field after validation. The outer layer is an append-only store whose public surface exposes exactly one mutating operation — append — and whose read operations return copies rather than mutable handles. The store also rejects a duplicate append idempotently, which is what makes a crash-and-retry safe rather than double-posting.

from __future__ import annotations

import hashlib
import json
from datetime import datetime
from decimal import Decimal
from typing import Any, Iterator
from zoneinfo import ZoneInfo

from pydantic import BaseModel, ConfigDict, Field, field_validator


def canonical_bytes(payload: dict[str, Any]) -> bytes:
    def default(value: Any) -> str:
        if isinstance(value, Decimal):
            return str(value)
        if isinstance(value, datetime):
            if value.tzinfo is None:
                raise ValueError("audit datetimes must be timezone-aware")
            return value.isoformat()
        raise TypeError(f"unserializable audit value: {type(value).__name__}")
    return json.dumps(
        payload, sort_keys=True, separators=(",", ":"), default=default
    ).encode("utf-8")


class LedgerEntry(BaseModel):
    """An immutable, self-describing audit record."""
    model_config = ConfigDict(frozen=True)

    sequence: int = Field(ge=0)
    source: str
    subject_id: str
    payload: dict[str, Any]
    payload_sha256: str
    operator_id: str = "SYS_AUTO"
    recorded_at: str

    @field_validator("payload_sha256")
    @classmethod
    def _is_sha256(cls, v: str) -> str:
        if len(v) != 64 or any(c not in "0123456789abcdef" for c in v):
            raise ValueError("expected a lowercase hex SHA-256 digest")
        return v

    @field_validator("payload")
    @classmethod
    def _serializable(cls, v: dict[str, Any]) -> dict[str, Any]:
        canonical_bytes(v)  # raises now if the payload can never be hashed
        return v


class WriteOnceError(RuntimeError):
    """Raised when a caller attempts to mutate an already-sealed entry."""


class AppendOnlyStore:
    """Storage contract: one mutating method, idempotent on re-append."""

    def __init__(self) -> None:
        self._entries: list[LedgerEntry] = []
        self._seen: set[str] = set()  # dedupe key -> idempotent replay guard

    def append(self, source: str, subject_id: str, payload: dict[str, Any],
               operator_id: str = "SYS_AUTO") -> LedgerEntry:
        digest = hashlib.sha256(canonical_bytes(payload)).hexdigest()
        # Idempotency key: same source, subject, and payload => same record.
        dedupe_key = f"{source}:{subject_id}:{digest}"
        if dedupe_key in self._seen:
            # A retried write is a no-op that returns the existing entry,
            # never a second row. This is what makes replay safe.
            return next(e for e in self._entries
                        if e.payload_sha256 == digest
                        and e.source == source
                        and e.subject_id == subject_id)
        entry = LedgerEntry(
            sequence=len(self._entries),
            source=source,
            subject_id=subject_id,
            payload=payload,
            payload_sha256=digest,
            operator_id=operator_id,
            recorded_at=datetime.now(tz=ZoneInfo("UTC")).isoformat(),
        )
        self._persist(entry)
        self._entries.append(entry)
        self._seen.add(dedupe_key)
        return entry

    def _persist(self, entry: LedgerEntry) -> None:
        """Write to genuinely immutable storage. Overridden per backend:
        an object-lock (WORM) put, or an INSERT into an append-only table
        with no UPDATE or DELETE grant. Never an in-place overwrite."""
        # Reference impl keeps the append in memory; production persists here.

    def correction(self, original: LedgerEntry, payload: dict[str, Any],
                   operator_id: str) -> LedgerEntry:
        """A fix is a NEW entry that references the original, never an edit.
        The original stays readable so any report built on it is recoverable."""
        amended = dict(payload)
        amended["corrects_sequence"] = original.sequence
        return self.append(original.source, original.subject_id,
                            amended, operator_id=operator_id)

    def get(self, sequence: int) -> LedgerEntry:
        return self._entries[sequence]  # frozen model; safe to hand out

    def __iter__(self) -> Iterator[LedgerEntry]:
        return iter(self._entries)

The correction method is the single most important line of policy in the design. There is no update and no delete; the only way to change what the ledger says about a subject is to append a new entry that references the one it supersedes. Both remain readable, so a variance report cut against the original figure stays reconstructable even after the correction lands, and an examiner sees the amendment history rather than a rewritten present.

The write path below shows how a sealed entry fans out to genuinely immutable backends, and why the in-place update path is not merely discouraged but absent.

Write-once append path and the absent in-place update A sealed LedgerEntry passes to the single append operation of the append-only store. The append fans out to two interchangeable immutable backends: object-lock WORM object storage, and an event-sourced append-only table with no update or delete grant. A separate node representing an in-place update or delete is barred with an X and a dashed link, showing the store exposes no path to overwrite a sealed record; a correction instead becomes a new appended entry. Sealed LedgerEntry frozen · SHA-256 stamped append() only mutator Object-lock (WORM) retention-locked object storage Event-sourced table insert-only · no update/delete In-place UPDATE no such path exists corrections append, never overwrite

Both backends deliver the same guarantee through different mechanisms. Object-lock, or write-once-read-many (WORM), storage enforces immutability at the storage layer itself: a retention lock on the object refuses overwrite and delete for the configured period, so immutability holds even against a compromised application. An event-sourced table achieves it through permissions and discipline — the application role holds INSERT but neither UPDATE nor DELETE, and current state is a projection over the append log rather than a mutable row. The choice between them, and the hybrid patterns, are weighed in Production Schema Design; what matters here is that the _persist seam lets you swap one for the other without the sealing logic ever knowing.

Audit Trail Requirements

For the ledger to satisfy a Directors Guild of America (DGA) or Screen Actors Guild – American Federation of Television and Radio Artists (SAG-AFTRA) examiner as readily as a bond guarantor, every sealed entry must carry a fixed minimum set of fields, and every one of them must be persisted before any dependent transaction commits. At minimum an entry records: the monotonic sequence; the source engine that produced it; the subject_id it concerns; the full payload verbatim; the payload_sha256 fingerprint over that payload; the operator_id, set to SYS_AUTO for automated runs and a real user id for any manual override; and a timezone-aware recorded_at timestamp in UTC. A correction additionally carries the corrects_sequence of the entry it supersedes, so the amendment lineage is explicit rather than inferred.

Persisting the entry before the downstream ledger transaction commits is the ordering that makes a mid-run crash survivable: the store holds a replayable record of intent, so recovery replays forward from the last sealed entry rather than discovering a silent gap. Because the store is append-only and the append is idempotent on (source, subject_id, payload_sha256), that replay is safe to run repeatedly. This is the same audit-first discipline required across the sibling jurisdictions that write here, which is exactly why the storage contract lives in one shared module rather than being re-implemented per engine.

Gotchas and Production Edge Cases

In-place update is the cardinal sin. The most common way a well-intentioned team destroys an audit trail is an innocuous-looking UPDATE to “fix” a bad figure. Once the row is overwritten, the evidence that the figure was ever different is gone, and a guarantor who cannot see the prior state cannot trust the current one. Enforce immutability at the storage layer — a retention lock or a role without UPDATE/DELETE — not merely in application code, because application code is exactly what a well-meaning hotfix bypasses.

Replay and idempotency. Retries are not optional in a real pipeline: a network blip, a worker restart, or a re-queued batch will re-present the same record. Without a dedupe key, each retry appends a second entry and double-posts the underlying obligation. Deduplicating on the content-derived (source, subject_id, payload_sha256) tuple — as append does above — makes a re-append a no-op that returns the existing entry, so the ledger stays exactly-once even though delivery is at-least-once.

Float contamination in the payload. A single float that slips into a payload breaks reproducibility, because Decimal(0.1) and the platform’s float repr are not portable. Build every monetary value as Decimal(str(x)) and let canonical_bytes reject a non-serializable value outright, so a defective payload is caught at the boundary rather than sealed into a fingerprint no future run can reproduce.

Timestamps and clock drift. Stamp recorded_at from a timezone-aware zoneinfo UTC clock, never a naive datetime.now() and never a bare offset. An examiner reads recorded_at as the moment of record, and a naive timestamp that silently shifts under a server’s local zone is the kind of ambiguity a disputed audit turns on. The turnaround-window arithmetic in DGA Overtime & Turnaround Rules shows the same aware-datetime discipline applied to penalty math.

Sequence gaps and ordering. Because verification walks the chain by sequence, a gap or a reordered append is itself a signal. Assign sequence inside the store from the current length under a single writer, never from an external counter that two workers could race, so the order the ledger records is the order events actually occurred.

Up one level: Audit Logging & Immutability, part of Core Production Architecture & Taxonomy.