Hash-Chaining Cost Ledger Entries for Tamper Evidence

An append-only store proves that a record was never overwritten, but on its own it cannot prove that a record was never inserted, removed, or reordered after the fact. Closing that gap is what hash-chaining does: each cost-ledger entry carries the fingerprint of the entry before it, so the ledger becomes a single linked sequence whose integrity is a global property. Edit any entry, drop one, or slip one in out of order, and the fingerprints downstream stop matching — the tampering is not merely discouraged but detectable, by anyone who recomputes the chain. This walkthrough builds that chain in Python: a genesis entry that anchors it, a prev_hash link that binds each entry to its predecessor, and a verifier that recomputes every link and reports the exact sequence where the chain breaks. It is the tamper-evidence layer of the shared spine specified in Audit Logging & Immutability.

Prerequisites and Context

This page targets Python 3.11+ and the same stack as the rest of the Audit Logging & Immutability section: Pydantic v2 (ConfigDict(frozen=True), field_validator) for the immutable entry model and the standard-library hashlib, json, decimal, and zoneinfo modules for the SHA-256 fingerprint, canonical serialization, Decimal money, and timezone-aware timestamps. Money is Decimal(str(x)), never float, because the fingerprint is only reproducible if the bytes going into it are. The write-once persistence this chain rides on — object-lock storage or an event-sourced table — is the subject of Designing Write-Once Audit Ledgers in Python; this page assumes entries are appended immutably and focuses on the linking and verification math.

The reason a cost ledger specifically needs this is the completion guarantor. A guarantor releasing reserves against a cost report needs to trust that the figures composing it were not quietly adjusted after the report was cut, and the reporting in Completion Bond Reporting & Guarantor Analytics cites chained entries precisely so that a reported variance can be pinned to a sequence range and independently re-verified. A chain turns “trust our process” into “recompute it yourself.”

Step-by-Step Implementation

A chain needs a starting point. The genesis entry is the anchor: its prev_hash is a fixed sentinel — sixty-four zeros — rather than a real predecessor, so the very first real entry links to something well-defined and every subsequent entry links to the one before it. Each entry’s own entry_hash is computed over its content including its prev_hash, which is the mechanism that propagates a break: change anything in an entry and its entry_hash changes, which was the next entry’s prev_hash, so the break cascades to the head.

from __future__ import annotations

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

from pydantic import BaseModel, ConfigDict, Field, field_validator

GENESIS_PREV = "0" * 64  # sentinel predecessor for the first entry


def canonical_bytes(payload: dict[str, Any]) -> bytes:
    """Stable bytes: sorted keys, no incidental whitespace, exact Decimal
    strings. Identical content always yields an identical fingerprint."""
    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 value: {type(value).__name__}")
    return json.dumps(
        payload, sort_keys=True, separators=(",", ":"), default=default
    ).encode("utf-8")


def sha256_hex(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


class ChainedEntry(BaseModel):
    model_config = ConfigDict(frozen=True)

    sequence: int = Field(ge=0)
    cost_code: str
    amount: Decimal
    payload_sha256: str
    prev_hash: str
    entry_hash: str
    recorded_at: str

    @field_validator("payload_sha256", "prev_hash", "entry_hash")
    @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


def _entry_hash(sequence: int, cost_code: str, payload_digest: str,
                prev_hash: str, recorded_at: str) -> str:
    # prev_hash is part of the sealed material, so any upstream edit ripples.
    return sha256_hex(canonical_bytes({
        "sequence": sequence,
        "cost_code": cost_code,
        "payload_sha256": payload_digest,
        "prev_hash": prev_hash,
        "recorded_at": recorded_at,
    }))


class CostChain:
    def __init__(self) -> None:
        self._entries: list[ChainedEntry] = []

    @property
    def head_hash(self) -> str:
        return self._entries[-1].entry_hash if self._entries else GENESIS_PREV

    def append(self, cost_code: str, payload: dict[str, Any]) -> ChainedEntry:
        payload_digest = sha256_hex(canonical_bytes(payload))
        prev = self.head_hash            # genesis links to the zero sentinel
        sequence = len(self._entries)
        recorded_at = datetime.now(tz=ZoneInfo("UTC")).isoformat()
        entry = ChainedEntry(
            sequence=sequence,
            cost_code=cost_code,
            amount=Decimal(str(payload["amount"])),
            payload_sha256=payload_digest,
            prev_hash=prev,
            entry_hash=_entry_hash(
                sequence, cost_code, payload_digest, prev, recorded_at,
            ),
            recorded_at=recorded_at,
        )
        self._entries.append(entry)
        return entry


def verify(entries: list[ChainedEntry],
           payloads: dict[int, dict[str, Any]]) -> int | None:
    """Recompute the chain. Returns None if intact, or the first broken
    sequence — a pointer an auditor can act on, not just a yes/no."""
    expected_prev = GENESIS_PREV
    for entry in entries:
        seq = entry.sequence
        if sha256_hex(canonical_bytes(payloads[seq])) != entry.payload_sha256:
            return seq  # payload edited
        if entry.prev_hash != expected_prev:
            return seq  # entry inserted, removed, or reordered
        recomputed = _entry_hash(
            entry.sequence, entry.cost_code, entry.payload_sha256,
            entry.prev_hash, entry.recorded_at,
        )
        if recomputed != entry.entry_hash:
            return seq  # entry fields altered
        expected_prev = entry.entry_hash
    return None

The verifier checks three independent things per entry, and each catches a different attack. Re-hashing the payload catches a silent edit to the underlying figure. Comparing prev_hash against the running expected_prev catches insertion, deletion, or reordering — because a removed or reordered entry leaves the next entry pointing at a hash that no longer precedes it. Recomputing entry_hash catches tampering with the entry’s own sealed fields. Because the check returns the first offending sequence rather than a bare boolean, a completion guarantor’s analyst gets a coordinate to investigate instead of a verdict to argue with.

The diagram below shows the linked structure and why a single edit is not a local event: each entry’s prev_hash points back at the previous entry’s entry_hash, so altering any one entry invalidates every fingerprint to its right.

A hash chain of cost-ledger entries linked by prev_hash Four blocks run left to right: a genesis entry whose prev_hash is the zero sentinel, followed by entry one, entry two, and entry three. Each block shows its prev_hash and its entry_hash, and an arrow links each entry_hash to the next block's prev_hash. An accent-colored span under entries one through three, with a caption, shows that editing any single sealed entry changes its entry_hash so the next entry's prev_hash no longer matches, and verification fails at the first broken link. Each entry seals in the fingerprint of the one before it links links links Genesis prev: 000…000 hash: h0 Entry 1 prev: h0 hash: h1 Entry 2 prev: h1 hash: h2 Entry 3 prev: h2 hash: h3 Edit any one entry and its hash changes, so the next prev_hash no longer matches — verification fails at the first broken link.

Audit Trail Requirements

A chained cost ledger has to persist enough per entry that a verifier can rebuild every link from the stored record alone, without re-running the engine that produced it. Each entry records: the monotonic sequence that fixes its position; the cost_code it posts to; the amount as a Decimal; the payload_sha256 fingerprint of its full payload; the prev_hash linking it to its predecessor; its own entry_hash; and a timezone-aware recorded_at. The genesis entry is not a special case to be hidden — it is persisted like any other, with prev_hash set to the zero sentinel, so verification starts from a recorded anchor rather than an assumed one.

Two rules keep the chain auditable in practice. First, a correction is a new appended entry that references the sequence it supersedes, never an edit — the same append-only discipline established in Designing Write-Once Audit Ledgers in Python — so the chain stays unbroken and the amendment is itself part of the tamper-evident record. Second, verification is cheap and should be routine: recomputing a chain is a linear pass of SHA-256 operations, so it can run on every report generation and every bond-reporting export rather than only when something looks wrong.

Gotchas and Production Edge Cases

Canonical serialization is the whole game. A hash chain is only tamper-evident if identical content always produces identical bytes. The instant two runs can serialize the same payload differently — unsorted keys, incidental whitespace, a locale-dependent number format, a float repr — the fingerprint stops being reproducible and a legitimate re-verification looks like tampering. Pin serialization to sorted keys, tight separators, and exact Decimal strings, as canonical_bytes does, and reject anything it cannot render deterministically.

Reordering and gaps. The chain encodes order, so a reordered or missing entry is a detectable break, not a cosmetic issue — the next entry’s prev_hash will not match the running expectation. Assign sequence under a single writer from the current chain length rather than from an external counter two workers could race, so the recorded order is the true order. A gap in sequence is itself a signal a verifier should surface.

Forks. If two writers both append to the same head concurrently, they create a fork: two entries claiming the same prev_hash, only one of which can legitimately continue the chain. Prevent it by serializing appends through a single writer or a compare-and-set on the head hash, so a second concurrent append fails and retries against the new head rather than branching. A ledger that has forked is no longer a single verifiable sequence, and reconciling a fork after the fact is exactly the forensic mess the chain exists to avoid.

Float contamination. As everywhere in production finance, never build a Decimal from a float literal — Decimal(0.1) carries the binary rounding error straight into the fingerprint. Read amount through Decimal(str(...)) so the sealed value is exact and a re-hash months later reproduces it to the cent, the same discipline the penalty math in DGA Overtime & Turnaround Rules applies to its figures.

Trusting the anchor. A chain only proves internal consistency; it does not prove the genesis is the real one. For high-stakes bond closeouts, periodically publish the current head hash to an independent record — a countersigned report, a separate immutable store — so an examiner can confirm the whole chain descends from an anchor that existed at a known time, not one reconstructed after a dispute began.

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