Building a Hash-Chained Audit Ledger in Python

Within the broader Cryptographic Audit Chains specification — and the wider Audit Logging & Compliance Evidence framework it sits in — this page is the concrete build: an append-only ledger, in plain Python, that records every state transition of a data subject request (DSR) as a link in a tamper-evident chain. The engineers who need it are privacy and platform teams who have decided that a mutable log index is not defensible evidence and want a small, dependency-light structure they can drop onto the critical path of a DSR pipeline. The failure it prevents is concrete: an audit record that a regulator, or opposing counsel, could plausibly claim was edited after the fact. Once you can hand over a head digest and say “recompute it,” that claim evaporates. This page builds the append path; its sibling, Verifying Audit Chain Integrity After Tampering, builds the detection path that reads what you write here.

The ledger has three moving parts — a validated event model, an append operation that computes each link, and a durable JSONL persistence layer — wired together as shown below:

Append path of a hash-chained audit ledger, from event to persisted JSONL line An AuditEvent is validated and reduced to canonical bytes. The ledger's append operation reads the current head digest, concatenates it with the canonical bytes, and computes a SHA-256 link. That link becomes both the record's stored hash and the ledger's new head, closing a feedback loop so the next append chains from it. The completed record — previous hash, event, and new hash — is serialized as one line and appended to a durable JSONL file that is flushed and fsync'd before the pipeline transition it records is allowed to proceed. AuditEventcanonical bytes append():sha256(head+bytes) Recordprev · event · hash JSONLfsync append head digest new head chains next append
Each append reads the head digest, hashes it with the new event's canonical bytes, and writes the resulting link as both the record's hash and the ledger's new head before persisting the line.

Prerequisites

The ledger targets Python 3.11+ for tuple[...] / list[...] generics without from __future__ friction and for datetime UTC handling that avoids the deprecated utcnow(). It uses only one third-party package plus the standard library:

  • pydantic (v2) — strict validation of every event before it enters the chain. The code uses v2 idioms exclusively (model_config = ConfigDict(...), field_validator, model_validate); v1 patterns (class Config, @validator, .dict()) will not run.
  • hashlib (stdlib) — the SHA-256 implementation. SHA-256 is a NIST-approved function under NIST SP 800-107, which is why it is the default here rather than a faster non-approved alternative.
  • json, os, pathlib (stdlib) — canonical serialization and durable, flushed appends to a JSONL file.

Install the single dependency with pip install "pydantic>=2.6". No database, broker, or cloud client is required to build and test the ledger; durable object storage enters only at the WORM handoff, which is out of scope here. The event schema and canonicalization rules below are the same ones specified in the parent Cryptographic Audit Chains page.

Step-by-step implementation

Step 1 — Model the audit event with a canonical serialization

Every link is only as reproducible as the bytes it hashes, so the event model is where determinism is enforced. The model is frozen (immutable after construction), strict (no silent type coercion), and closed to extra fields (schema drift fails loudly). Its one job beyond validation is to emit canonical_bytes(): a single, stable byte string produced with sorted keys, no insignificant whitespace, and a UTC-normalized timestamp, so the same logical event hashes identically on any host and at any future date a regulator audits it.

from __future__ import annotations

import hashlib
import json
import os
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path

from pydantic import BaseModel, ConfigDict, Field, field_validator


class DSRStage(str, Enum):
    """The ordered state transitions a DSR ledger records."""

    INTAKE = "intake"
    VERIFIED = "verified"
    ROUTED = "routed"
    DISCOVERED = "discovered"
    REDACTED = "redacted"
    DELIVERED = "delivered"
    CLOSED = "closed"


class AuditEvent(BaseModel):
    """One immutable, deterministically serializable audit record.

    ``frozen=True`` blocks post-construction mutation; ``strict=True`` refuses
    coercions that would silently change the hashed bytes; ``extra="forbid"``
    turns schema drift into a validation error rather than an unhashed field.
    """

    model_config = ConfigDict(strict=True, frozen=True, extra="forbid")

    request_id: str = Field(pattern=r"^dsr_[0-9a-f]{32}$")
    stage: DSRStage
    legal_basis: str = Field(min_length=3, max_length=64)
    actor: str = Field(min_length=1, max_length=128)
    at: datetime

    @field_validator("at")
    @classmethod
    def must_be_timezone_aware(cls, value: datetime) -> datetime:
        """Reject naive datetimes so the canonical timestamp never drifts."""
        if value.tzinfo is None:
            raise ValueError("at must be timezone-aware (UTC)")
        return value

    def canonical_bytes(self) -> bytes:
        """Return the one true byte string used to hash this event."""
        payload = {
            "request_id": self.request_id,
            "stage": self.stage.value,
            "legal_basis": self.legal_basis,
            "actor": self.actor,
            "at": self.at.astimezone(timezone.utc).isoformat(),
        }
        return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()

Validating at emission serves GDPR Art. 30 record-of-processing completeness directly: an event missing its legal_basis cannot enter the ledger as an unprovable gap, because construction fails before append() is ever reached.

Step 2 — Define the ledger record

A record is what actually gets persisted: the event, the digest of the predecessor it chains from, and its own computed link digest. Modeling it as its own frozen Pydantic type (rather than a loose dict) means a corrupted or hand-edited JSONL line fails model_validate on load instead of silently verifying. The stored prev_hash and entry_hash are what a verifier recomputes.

class LedgerRecord(BaseModel):
    """A persisted chain link: the event plus its chaining digests."""

    model_config = ConfigDict(strict=True, frozen=True, extra="forbid")

    seq: int = Field(ge=0)
    prev_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
    entry_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
    event: AuditEvent

Step 3 — Build the append-only ledger

The AppendOnlyLedger holds exactly one piece of mutable state — the head digest — and exposes a single mutating operation, append(). On construction the head is the fixed genesis anchor (sixty-four zeros), which commits the first real record to something an attacker cannot forge into a shorter valid chain. Each append() computes sha256(head + canonical_bytes), makes that digest the record’s entry_hash and the ledger’s new head, persists the record, and only then returns — so the head always reflects durable state, never a record that failed to write.

GENESIS: str = "0" * 64


class AppendOnlyLedger:
    """A durable, hash-chained, append-only DSR audit ledger.

    State is a single head digest. Records are persisted as JSON Lines,
    one record per line, flushed and fsync'd before ``append`` returns so
    the head never leads the durable file.
    """

    def __init__(self, path: str | Path) -> None:
        self._path = Path(path)
        self._head = GENESIS
        self._seq = 0
        if self._path.exists():
            self._restore()

    @property
    def head(self) -> str:
        """The digest fingerprinting the entire chain so far (32 bytes hex)."""
        return self._head

    @staticmethod
    def _link(prev_hash: str, event: AuditEvent) -> str:
        """Compute the tamper-evident digest for ``event`` after ``prev_hash``."""
        hasher = hashlib.sha256()
        hasher.update(prev_hash.encode("ascii"))
        hasher.update(event.canonical_bytes())
        return hasher.hexdigest()

    def append(self, event: AuditEvent) -> LedgerRecord:
        """Chain ``event`` onto the head and durably persist it."""
        entry_hash = self._link(self._head, event)
        record = LedgerRecord(
            seq=self._seq,
            prev_hash=self._head,
            entry_hash=entry_hash,
            event=event,
        )
        self._persist(record)
        # Advance in-memory state only after the write is durable.
        self._head = entry_hash
        self._seq += 1
        return record

    def _persist(self, record: LedgerRecord) -> None:
        """Append one record as a JSONL line, flushed and fsync'd to disk."""
        line = record.model_dump_json() + "\n"
        with self._path.open("a", encoding="utf-8") as handle:
            handle.write(line)
            handle.flush()
            os.fsync(handle.fileno())

    def _restore(self) -> None:
        """Rebuild head and sequence from an existing ledger file on startup."""
        with self._path.open("r", encoding="utf-8") as handle:
            for line in handle:
                line = line.strip()
                if not line:
                    continue
                record = LedgerRecord.model_validate_json(line)
                self._head = record.entry_hash
                self._seq = record.seq + 1

Because append() fsyncs before advancing the head, a crash mid-write leaves the durable file and the in-memory head consistent on the next _restore() — the partially written tail line simply fails model_validate_json and is the point from which you resume, never a silently accepted half-record.

Step 4 — Emit an event on the critical path

The ledger is only evidence if the write happens before the action it records is allowed to proceed. Wiring it into a pipeline transition therefore means: build the event, append it, and let the failure of the append block the transition rather than letting the work run unlogged.

def record_transition(
    ledger: AppendOnlyLedger,
    request_id: str,
    stage: DSRStage,
    legal_basis: str,
    actor: str,
) -> str:
    """Append a transition event and return the ledger's new head digest.

    Raises before the caller proceeds if the event is invalid or the append
    cannot be persisted, so no DSR action outruns its own evidence.
    """
    event = AuditEvent(
        request_id=request_id,
        stage=stage,
        legal_basis=legal_basis,
        actor=actor,
        at=datetime.now(timezone.utc),
    )
    record = ledger.append(event)
    return record.entry_hash

Configuration reference

Parameter Type Default Compliance note
path str | Path required JSONL ledger location; per-request paths keep each DSR independently sealable for closure.
GENESIS str "0" * 64 Fixed anchor committing the first record; prevents prefix truncation from producing a valid chain.
hash function callable hashlib.sha256 NIST SP 800-107 approved; the citable default for regulator-facing evidence.
strict bool True Refuses silent type coercion so hashed bytes reflect exactly what was submitted.
frozen bool True Blocks post-construction mutation of events and records, upholding append-only semantics.
extra str "forbid" Schema drift fails validation instead of entering the chain as an unhashed field (GDPR Art. 30).
fsync on append bool on Durability before the recorded action proceeds; the head never leads the durable file.

Verification

The property to prove is that a freshly built chain verifies end to end and that its head is reproducible. This unit test appends a short DSR lifecycle, walks the chain from genesis recomputing every link, and asserts both integrity and a stable, non-genesis head.

from datetime import datetime, timezone


def _fixed_event(ledger_stage: DSRStage) -> AuditEvent:
    return AuditEvent(
        request_id="dsr_" + "a" * 32,
        stage=ledger_stage,
        legal_basis="art6-1-c",
        actor="svc:pipeline",
        at=datetime(2026, 7, 16, 12, 0, tzinfo=timezone.utc),
    )


def test_chain_verifies_end_to_end(tmp_path) -> None:
    ledger = AppendOnlyLedger(tmp_path / "ledger.jsonl")
    for stage in (DSRStage.INTAKE, DSRStage.VERIFIED, DSRStage.CLOSED):
        ledger.append(_fixed_event(stage))

    # Re-read the persisted records and recompute the chain from genesis.
    records = [
        LedgerRecord.model_validate_json(line)
        for line in (tmp_path / "ledger.jsonl").read_text().splitlines()
    ]
    prev = GENESIS
    for record in records:
        assert record.prev_hash == prev
        assert record.entry_hash == AppendOnlyLedger._link(prev, record.event)
        prev = record.entry_hash

    assert ledger.head == prev          # in-memory head matches durable chain
    assert ledger.head != GENESIS       # a non-empty chain moved off the anchor

Running under pytest should report the test passing. A second run against the same tmp_path exercises _restore(): reopen the ledger and assert head matches the value from the first run, confirming crash-recovery rebuilds identical state.

Troubleshooting

Non-deterministic head across machines — Root cause: serialization that is not canonical, usually unsorted keys, a locale-dependent float, or a naive timestamp. Fix: hash only canonical_bytes() (sorted keys, separators=(",", ":"), UTC ISO-8601) and keep the field_validator that rejects naive datetimes, so the same event always yields the same bytes.

ValidationError on load of an old ledger — Root cause: a schema change (a renamed or added field) makes historical JSONL lines fail model_validate_json. Fix: never mutate the event schema in place; version it and keep a reader for each version, since sealed records are immutable by design and must remain loadable exactly as written.

Head advances but the file is missing the last line — Root cause: the head was updated before the write was durable (for example fsync removed or exceptions swallowed). Fix: keep the ordering in append() — persist and fsync first, advance _head and _seq only after — so the in-memory head can never lead the durable file.

Forked chain: two records share a prev_hash — Root cause: concurrent append() calls read the same head before either persisted. Fix: serialize appends behind a single-writer lock (or an atomic compare-and-set on the head); a single ledger instance must have exactly one writer.

Duplicate events inflate the chain — Root cause: a retried pipeline transition appends the same logical event twice. Fix: make transitions idempotent upstream on request_id plus stage, so a retry re-reads the existing head rather than chaining a redundant link. The sibling Verifying Audit Chain Integrity After Tampering page shows how to walk this ledger and prove no such anomaly was introduced maliciously.

Frequently Asked Questions

Why persist to JSONL instead of a database table?

JSONL keeps the ledger append-only, portable, and trivially re-hashable by any verifier without a live database — a regulator can recompute the chain from the file alone. A database table is fine as a queryable index, but the authoritative evidence should be an append-only stream you can hand over and that a third party can verify offline. In production the JSONL stream is anchored into write-once storage, covered in WORM Storage for DSR Evidence.

Does the ledger need to be encrypted?

Chaining is an integrity primitive, not a confidentiality one, so it neither hides nor is a substitute for hiding contents. If the audit records contain personal data or sensitive metadata, encrypt them at rest through your storage layer; the hash chain still proves integrity over the ciphertext or over redacted records. Keep personal data out of the audit event where you can, recording identifiers and legal bases rather than raw subject data.

How do I correct a mistaken audit event without breaking the chain?

You never edit a sealed record. Append a compensating correction event that references the original by sequence and request ID, so the chain stays immutable and the trail shows both the error and its correction. Editing a record in place would change its bytes and break every downstream link, which is exactly the tamper signal the structure is designed to raise.

What stops someone from rebuilding the whole file with new hashes?

Nothing in the ledger alone — cryptographic chaining makes tampering detectable, not impossible. An attacker with write access to the file can re-hash a doctored sequence end to end and it will verify. Immutability comes from anchoring the ledger in write-once-read-many storage under an enforced retention lock, so the underlying bytes physically cannot be rewritten for the retention window.