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.
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.
Related
- Audit Logging & Immutability — the shared spine this chaining and verification logic sits inside, and the engines that feed it.
- Designing Write-Once Audit Ledgers in Python — the append-only, write-once storage the chain depends on to stay unbroken.
- Production Schema Design — event-sourced and temporal storage patterns compatible with a persisted hash chain.
- Completion Bond Reporting & Guarantor Analytics — the reporting that pins reported variance to a chained sequence range a guarantor can re-verify.
- Guild Compliance & Rule Validation Automation — the compliance engines whose sealed figures become links in this chain.
Up one level: Audit Logging & Immutability, part of Core Production Architecture & Taxonomy.