Hash-Chaining Audit Logs for Actuarial Filings

Hash-chaining an actuarial audit log means every record carries the SHA-256 digest of its own payload combined with the digest of the record before it, so that altering any single entry silently breaks every hash that follows and a linear scan can pinpoint exactly where a filing history was tampered with. This page builds the append-only chain as a concrete data structure and a verify() routine, and it sits inside the broader Actuarial Audit Trail Architecture discipline alongside the durable storage side of the problem covered in WORM Storage Patterns for Regulatory Retention.

The Problem

An examiner reviewing a principle-based reserve filing rarely asks whether your log server is honest today; they ask whether an entry recorded eighteen months ago could have been quietly edited since. A plain append-only table cannot answer that, because a database row can be updated in place and the write timestamp rewritten with it. Hash-chaining converts the question from “do we trust the store” into “does the arithmetic still hold”, by making each record’s integrity depend on the record before it. If a valuation figure, an assumption-set version, or a sign-off timestamp is changed after the fact, the digest recomputed over that record no longer matches the prev_hash embedded in the next record, and the break propagates all the way to the head of the chain. That property — detection without needing to trust the storage layer — is what SR 11-7’s demand for an auditable model-change record and VM-31’s reproducibility expectation actually require in practice.

A Minimal Working Example

The pattern is three moving parts: a canonical serializer so the same content always hashes to the same digest, an append that folds each new payload into the previous record’s hash, and a verify that walks the chain and reports the first index where the arithmetic fails. It has no third-party dependencies and runs as-is:

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, asdict
from datetime import datetime, timezone

GENESIS_PREV_HASH = "0" * 64  # 32 zero bytes, hex-encoded


@dataclass(frozen=True)
class AuditRecord:
    """One immutable link in the filing audit chain."""

    index: int
    recorded_at_utc: str
    actor: str
    event: str            # e.g. "reserve_signed_off"
    payload: dict         # the actuarial content being attested
    prev_hash: str
    record_hash: str


def _canonical(payload: dict) -> bytes:
    # Sorted keys + compact separators => one payload, one byte-string, one digest.
    return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()


def _compute_hash(index: int, recorded_at_utc: str, actor: str,
                  event: str, payload: dict, prev_hash: str) -> str:
    h = hashlib.sha256()
    h.update(str(index).encode())
    h.update(recorded_at_utc.encode())
    h.update(actor.encode())
    h.update(event.encode())
    h.update(_canonical(payload))
    h.update(prev_hash.encode())        # bind this record to its predecessor
    return h.hexdigest()


def append(chain: list[AuditRecord], actor: str, event: str,
           payload: dict) -> AuditRecord:
    prev_hash = chain[-1].record_hash if chain else GENESIS_PREV_HASH
    index = len(chain)
    recorded_at_utc = datetime.now(timezone.utc).isoformat()
    record_hash = _compute_hash(index, recorded_at_utc, actor,
                                event, payload, prev_hash)
    record = AuditRecord(index, recorded_at_utc, actor, event,
                         payload, prev_hash, record_hash)
    chain.append(record)
    return record


def verify(chain: list[AuditRecord], start: int = 0) -> int | None:
    """Return the index of the first broken link, or None if intact."""
    for i in range(start, len(chain)):
        rec = chain[i]
        expected_prev = (GENESIS_PREV_HASH if i == 0
                         else chain[i - 1].record_hash)
        if rec.prev_hash != expected_prev:
            return i
        recomputed = _compute_hash(rec.index, rec.recorded_at_utc, rec.actor,
                                   rec.event, rec.payload, rec.prev_hash)
        if recomputed != rec.record_hash:
            return i
    return None


if __name__ == "__main__":
    log: list[AuditRecord] = []
    append(log, actor="appointed_actuary",
           event="assumptions_locked",
           payload={"assumption_set_version": "2025Q2.1",
                    "mortality_table": "2017 CSO"})
    append(log, actor="valuation_engine",
           event="deterministic_reserve_computed",
           payload={"deterministic_reserve": 1_284_500.00,
                    "scenario_seed": 20250630})
    append(log, actor="appointed_actuary",
           event="reserve_signed_off",
           payload={"minimum_reserve": 1_284_500.00})

    print("intact ->", verify(log))          # None
    tampered = asdict(log[1])
    tampered["payload"]["deterministic_reserve"] = 1_200_000.00
    log[1] = AuditRecord(**tampered)
    print("after edit ->", verify(log))       # 1
Hash-chained audit log: genesis record to a verify walk over linked records A genesis record carrying an all-zero previous hash feeds record n, which feeds record n plus one; each arrow carries the previous record's hash, and every record stores the SHA-256 of its payload combined with that previous hash. A verify routine reads the whole chain and flags the first link whose recomputed hash no longer matches. prev_hash prev_hash prev_hash Genesis record prev_hash = 0…0 index 0 Record n hash = H(payload ‖ prev_hash) Record n+1 hash = H(payload ‖ prev_hash) verify() walk first broken index or None
Each record binds to its predecessor's digest; editing one record breaks every hash downstream, and verify() reports the first failing index.

How It Works, Block by Block

_canonical removes serialization ambiguity before anything is hashed. A hash is only meaningful if identical content always produces identical bytes. Serializing the payload with sort_keys=True and compact separators means that a dictionary written as {"reserve": 1, "seed": 2} and one written as {"seed": 2, "reserve": 1} collapse to the same byte-string and therefore the same digest. Without this step, a payload that is semantically unchanged but re-serialized in a different key order would look like tampering — a false positive that erodes trust in the whole mechanism.

_compute_hash is where the chaining actually happens. The function feeds the index, timestamp, actor, event, canonical payload, and crucially the prev_hash into a single SHA-256 context. Folding prev_hash in last is what turns a list of independent digests into a chain: the digest of record n is a function of everything in record n and, transitively, of every record that came before it. Change one byte anywhere in the history and every subsequent record_hash is invalidated.

append derives prev_hash from the chain head, never from the caller. The predecessor hash is read from chain[-1] for an ordinary record and set to the fixed GENESIS_PREV_HASH sentinel for the very first one. Because the linkage is computed internally rather than passed in, a caller cannot forge a record that points at an arbitrary ancestor. The genesis case is handled explicitly so the first record is anchored to a known constant rather than to nothing.

verify performs two independent checks per link. First it confirms that the stored prev_hash equals the predecessor’s actual record_hash, catching a record that was re-pointed or reordered. Second it recomputes the record’s own hash from its fields and compares it to the stored record_hash, catching an in-place content edit. Returning the first failing index — rather than a bare boolean — tells an investigator precisely where the history diverges, which is what an examiner following the Actuarial Audit Trail Architecture will ask for. The identity signing described in Building Secure Audit Logs for Regulatory Submissions layers over this: the chain proves nothing was altered, a signature proves who attested to the head.

Edge Cases and Production Hardening

The genesis record needs its own anchor, or the chain has no root of trust. If the first record simply left prev_hash empty, an attacker could truncate the log to any point and re-hash from there, producing a shorter but internally consistent chain. Anchoring genesis to a published constant — and, better, notarizing that first digest externally — means a truncation is detectable because the surviving head no longer matches the externally recorded anchor. Persist the genesis digest into the immutable store described in WORM Storage Patterns for Regulatory Retention so the anchor itself cannot be rewritten.

Concurrent appends corrupt the chain if two writers read the same head. Because prev_hash is read from chain[-1], two processes that append simultaneously can both bind to the same predecessor, producing two records at the same index and a fork. Serialize appends behind a single writer or a database constraint that makes the index unique and monotonic:

import sqlite3

def append_atomic(conn: sqlite3.Connection, actor: str,
                  event: str, payload: dict) -> AuditRecord:
    with conn:  # a single transaction: read head + insert are atomic
        row = conn.execute(
            "SELECT record_hash, index_no FROM audit_chain "
            "ORDER BY index_no DESC LIMIT 1"
        ).fetchone()
        prev_hash = row[0] if row else GENESIS_PREV_HASH
        index = (row[1] + 1) if row else 0
        recorded_at_utc = datetime.now(timezone.utc).isoformat()
        record_hash = _compute_hash(index, recorded_at_utc, actor,
                                    event, payload, prev_hash)
        conn.execute(
            "INSERT INTO audit_chain(index_no, record_hash, prev_hash) "
            "VALUES (?, ?, ?)", (index, record_hash, prev_hash))
    return AuditRecord(index, recorded_at_utc, actor, event,
                       payload, prev_hash, record_hash)

A UNIQUE constraint on index_no turns a lost race into a rejected insert the caller can retry, rather than a silent fork that verify will later flag as a break with no way to reconstruct which branch was authoritative.

Verifying a slice must re-anchor, not assume. Re-running verify over a multi-million-record chain on every read is wasteful, so teams verify a recent window. But a slice starting at index k cannot trust chain[k-1] blindly — that predecessor might itself be forged. Pass the last externally notarized digest as the trusted boundary for the slice and confirm chain[k].prev_hash matches it before walking forward; only then does verifying the tail carry the same weight as verifying from genesis. Periodically notarize the current head digest to a WORM object so these trusted boundaries accumulate over time.

Compliance Note

Hash-chaining is the mechanical expression of the model-change and audit-trail expectations that run through the whole regulatory stack. SR 11-7 (the Federal Reserve and OCC supervisory guidance on model risk management) requires that model changes be documented and traceable over the model’s life; a chain where each link cryptographically depends on its predecessor makes that history tamper-evident rather than merely tabulated. VM-31, the NAIC PBR Actuarial Report requirement, obliges the appointed actuary to produce results that can be reproduced from documented assumptions and methods — and a chain that seals the assumption-set version and scenario seed alongside each reserve is exactly the reproducibility record it asks for. The chain proves integrity; pairing it with the durable, deletion-resistant medium of WORM Storage Patterns for Regulatory Retention is what makes that integrity survive the multi-year retention window an examiner may reach back across, within the wider Regulatory Architecture & Compliance Mapping discipline.

Frequently Asked Questions

How is a hash-chained log different from just storing rows in an append-only table?

An append-only table relies on the storage layer never permitting an update; a hash-chained log relies on arithmetic. Because each record embeds the digest of the one before it, an in-place edit anywhere in the history breaks every downstream hash, so tampering is detectable even if the underlying database row was silently rewritten and its timestamp forged.

What exactly does the verify function detect, and what does it not?

Verify detects any alteration to a record’s content, any re-pointing of a record to the wrong predecessor, and any reordering, and it returns the index of the first break. It does not by itself detect truncation of the chain’s tail unless the head digest was externally notarized, and it does not prove who wrote a record — that requires a separate signature over the head.

Do I have to re-verify the entire chain on every read?

No. You can verify a recent slice, provided the slice is re-anchored to a previously notarized digest rather than trusting an in-chain predecessor blindly. Periodically writing the current head digest to an immutable store gives you trusted boundaries so tail verification carries the same assurance as a full walk from genesis.

Why hash the payload with sorted keys before chaining?

Because a hash is only meaningful when identical content yields identical bytes. Serializing with sorted keys and compact separators guarantees that a semantically identical payload re-serialized in a different key order produces the same digest, preventing false tamper alarms that would otherwise fire on harmless re-serialization.

Up a level: Actuarial Audit Trail Architecture · Regulatory Architecture & Compliance Mapping


Regulatory references: Federal Reserve / OCC SR 11-7 Guidance on Model Risk Management; NAIC Valuation Manual VM-31 (PBR Actuarial Report).