Immutability and Hash Chains

Prerequisite: The Ledger, Auditability

An auditor pulls entry #4,821,003 from your ledger. They ask: “Was this entry always here, or was it inserted after the fact?” If your ledger allows UPDATE and DELETE on the entries table, you cannot answer that question. You can show them the current state of the row. You cannot prove the row has not been modified since it was written. The audit finding writes itself.

Immutability is not a design preference. It is the property that makes a ledger auditable. An append-only ledger with cryptographic chaining gives you a provable answer: this entry was written at this position in the sequence, and no entry before it has been altered since.

Append-Only Table Design

The simplest enforcement: remove the ability to modify or delete rows at the database level. Postgres row-level security policies can enforce append-only semantics without relying on application-layer discipline.

CREATE TABLE entries (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    account_id  BIGINT NOT NULL,
    amount      BIGINT NOT NULL,
    currency    TEXT NOT NULL,
    booking_time TIMESTAMPTZ NOT NULL DEFAULT now(),
    prev_hash   BYTEA NOT NULL,
    entry_hash  BYTEA NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE entries ENABLE ROW LEVEL SECURITY;

CREATE POLICY entries_insert_only ON entries
    FOR ALL
    USING (false)
    WITH CHECK (true);

REVOKE UPDATE, DELETE ON entries FROM app_user;

The USING (false) clause on the RLS policy prevents any existing row from being selected for update or deletion through the policy path. The WITH CHECK (true) clause allows inserts. The REVOKE removes UPDATE and DELETE grants as a second layer — belt and suspenders, because the cost of a missed mutation is an audit failure, not a bug.

RLS policies only apply to non-superuser roles. Your migration user and DBA connections bypass them. Restrict superuser access to break-glass procedures with audit logging, or the append-only guarantee is only as strong as your access control.

For Postgres-specific hardening, set default_transaction_read_only = on for the application role and grant explicit write access only to the entries table via a dedicated writer role. This prevents accidental mutations through ORM misconfiguration or raw SQL in application code.

Storage overhead for append-only tables is predictable. A ledger entry row with hash fields occupies roughly 200–250 bytes. At 10 million entries per day, that is 2–2.5 GB of raw table growth daily, before indexes. With a covering index on (account_id, amount) and a hash chain index on (id, entry_hash), total daily growth lands around 4–5 GB. Postgres handles this without partitioning up to roughly 500 million rows before query planner costs become noticeable. Beyond that, partition by booking month.

Compensating Entries

Immutability means you cannot fix a wrong entry by editing it. You fix it by writing a new entry that reverses the effect of the original.

A customer was charged $50.00 twice. The ledger has two entries. You do not delete one. You post a compensating entry — a credit of $50.00 that references the original entry it reverses.

INSERT INTO entries (account_id, amount, currency, prev_hash, entry_hash)
VALUES (
    $1,
    5000,  -- positive amount: credit back to customer
    'USD',
    $prev_hash,
    $new_hash
);

The compensating entry carries a reference_id pointing to the original entry (omitted from the schema above for clarity, but present in production). The balance derivation sums all three entries — the two debits and the compensating credit — and arrives at the correct balance. The audit trail shows what happened: two charges, then a reversal. No data was destroyed.

This is the same principle as accounting journal corrections. An incorrect journal entry is never erased. A reversing entry is posted, and both the original and the reversal appear in the books. The ledger tells the full story — including the mistakes.

See The Ledger. Double-entry bookkeeping has one rule: money is moved, never created or destroyed. Compensating entries move money back. Deleting an entry destroys the record that money moved at all.

Hash Chains

Append-only tables prevent modification through access control. Hash chains detect modification that bypasses access control — a compromised DBA account, a restore from a tampered backup, a rogue migration script.

Each entry stores a SHA-256 hash of its own contents and the hash of the previous entry. The chain is a linked list of cryptographic commitments.

import hashlib
import struct

def compute_entry_hash(entry_id, account_id, amount, currency, booking_time, prev_hash):
    payload = (
        struct.pack('>q', entry_id)
        + struct.pack('>q', account_id)
        + struct.pack('>q', amount)
        + currency.encode('utf-8')
        + booking_time.isoformat().encode('utf-8')
        + prev_hash
    )
    return hashlib.sha256(payload).digest()

The prev_hash field in each entry points to the entry_hash of the immediately preceding entry. The first entry in the ledger uses a well-known genesis hash — typically 32 zero bytes or a hash of the ledger’s creation metadata.

Field Size Purpose
prev_hash 32 bytes SHA-256 hash of the previous entry — backward link
entry_hash 32 bytes SHA-256 hash of this entry’s contents + prev_hash — integrity proof

The chain property: if any entry in the sequence is modified, its entry_hash changes, which breaks the prev_hash reference of the next entry, which breaks every subsequent hash in the chain. A single tampered entry invalidates every entry after it.

Verification

Chain verification is a sequential scan. Read every entry in order, recompute the hash from the entry’s fields and the previous entry’s hash, and compare to the stored entry_hash.

def verify_chain(entries):
    """Walk the chain and return the first broken link, or None if intact."""
    prev_hash = b'\x00' * 32  # genesis
    for entry in entries:
        expected = compute_entry_hash(
            entry.id, entry.account_id, entry.amount,
            entry.currency, entry.booking_time, prev_hash
        )
        if expected != entry.entry_hash:
            return entry.id  # chain breaks here
        if entry.prev_hash != prev_hash:
            return entry.id  # backward link mismatch
        prev_hash = entry.entry_hash
    return None

A full verification of 10 million entries takes 8–15 minutes on commodity hardware (limited by SHA-256 throughput at ~400 MB/s and row fetch from Postgres). You do not run full verification on every write. You run it on a schedule — nightly or weekly — and on demand after any incident that could have affected data integrity: a failed migration, a backup restore, a storage-layer error.

Incremental verification is faster. If the last verified entry was #9,500,000, verify only entries #9,500,001 through the current head. This takes seconds, not minutes, and can run hourly or on every batch of entries.

Verification mode Scope Duration (10M entries) Frequency
Full chain All entries from genesis 8–15 minutes Weekly or post-incident
Incremental Since last verified entry Seconds to minutes Hourly or per-batch
Spot check Random sample of chain segments Seconds Continuous

When verification fails, the break point tells you exactly where tampering occurred. Entry #4,821,003 has a hash that does not match its contents — either the contents were modified after the hash was computed, or the hash was recomputed without updating the chain forward. Both indicate unauthorized modification. The entries before the break point are intact. The entries after must be re-verified against an independent source — your checkpoint anchors.

Checkpoint Anchoring with S3 Object Lock

Hash chains prove internal consistency — no entry has been modified relative to its neighbors. But a sufficiently motivated attacker could rewrite the entire chain with new, valid hashes. To anchor the chain to an external, immutable reference point, periodically export a checkpoint to write-once storage.

Amazon S3 Object Lock in Compliance mode provides WORM (Write Once Read Many) semantics. Once written, an object cannot be deleted or overwritten — not by the application, not by the account administrator, not by AWS support. The retention period is configurable, and Compliance mode prevents even the root account from shortening it.

import boto3
from datetime import datetime, timedelta

def write_checkpoint(s3_client, bucket, chain_head_hash, entry_count, as_of):
    checkpoint = {
        'chain_head_hash': chain_head_hash.hex(),
        'entry_count': entry_count,
        'as_of': as_of.isoformat(),
        'verified_at': datetime.utcnow().isoformat()
    }
    key = f"checkpoints/{as_of.strftime('%Y/%m/%d')}/checkpoint.json"
    s3_client.put_object(
        Bucket=bucket,
        Key=key,
        Body=json.dumps(checkpoint),
        ObjectLockMode='COMPLIANCE',
        ObjectLockRetainUntilDate=datetime.utcnow() + timedelta(days=2555)  # 7 years
    )

The checkpoint contains the hash of the chain head (the most recent entry at checkpoint time), the entry count, and a timestamp. Seven-year retention covers most financial record-keeping requirements — SEC Rule 17a-4 requires broker-dealers to retain records for six years, and SOX requires seven.

To verify against a checkpoint: walk the chain from genesis to the checkpoint’s entry count, confirm the computed chain head hash matches the checkpoint’s stored hash, and confirm the checkpoint object’s S3 metadata shows no modification since its creation date. If the chain hash matches and the S3 object is intact, every entry up to that checkpoint is provably unmodified.

S3 Object Lock Compliance mode is irreversible for the retention period. A misconfigured retention of 100 years means you are paying for that storage for 100 years. Test with Governance mode first — it allows privileged deletion — and switch to Compliance mode for production.

Why Postgres Beats Blockchain for Audit

The pitch for blockchain-based ledgers is tamper evidence — the same property hash chains provide. The difference is cost, complexity, and what auditors actually need.

Dimension Postgres + hash chains Private blockchain (Hyperledger) Public blockchain (Ethereum)
Write latency 1–5ms 200–500ms (consensus) 12s (block time)
Throughput 10,000+ TPS 1,000–3,000 TPS ~30 TPS (L1)
Query capability Full SQL Limited (key-value) Extremely limited
Storage cost (1B entries) ~250 GB ~500 GB+ (replication) Not feasible
Auditor tooling pgAdmin, SQL, standard reports Custom tooling required Etherscan, custom indexers
SOC 2 evidence Database logs, RLS policies, checkpoint exports Consensus logs, node configs Block explorer exports
Operational complexity Standard DBA Blockchain ops team Gas management, node infra

A Postgres ledger with append-only policies, hash chains, and S3 Object Lock checkpoints provides the same tamper-evidence guarantee as a blockchain: if any entry is modified, the chain breaks and the checkpoint anchors prove when the modification occurred. The difference is that Postgres gives you SQL for reporting, standard tooling for operations, and sub-5ms write latency instead of consensus delays.

SOC 2 auditors verify controls, not technology choices. They check: Can application code modify or delete ledger entries? (RLS policies, REVOKE statements.) Is there an integrity verification mechanism? (Hash chain verification logs.) Are records preserved in tamper-resistant storage? (S3 Object Lock compliance reports.) Can you demonstrate the chain was intact at a given point in time? (Checkpoint verification.) Postgres with hash chains answers every one of these questions. A blockchain answers them too — but adds operational complexity that creates new control requirements without strengthening the guarantee.

What SOC 2 Auditors Verify

SOC 2 Type II audits test the operating effectiveness of controls over a review period, typically 6–12 months. For ledger integrity, the relevant Trust Services Criteria fall under Processing Integrity (PI1.1–PI1.5) and Common Criteria around change management (CC8.1).

The controls an auditor expects to see:

Control Evidence
Ledger entries cannot be modified after posting RLS policy definitions, REVOKE grants, application role permissions
Integrity verification runs on schedule Cron job logs, verification result records, alert configurations
Verification failures trigger investigation Incident tickets tied to verification alerts, response time metrics
Records are retained for the required period S3 Object Lock configuration, retention policy documentation
Access to modify integrity controls is restricted IAM policies, change management logs for schema migrations

The auditor does not verify every hash in the chain. They verify that you have a process that does, that the process runs regularly, that failures are detected and investigated, and that the underlying data cannot be modified through normal application paths. Your verification logs — timestamped records showing “chain verified from entry X to entry Y, result: intact” — are the primary evidence.

Store verification results in a separate table or system from the ledger itself. If the verification logs live in the same database as the entries they verify, a compromise that modifies entries could also modify the verification records. Write verification results to the same S3 bucket with Object Lock, alongside the checkpoints.

Storage Growth and Archival

An append-only ledger grows without bound. Every entry ever posted remains in the table. For a system posting 10 million entries per day, the entries table reaches 3.6 billion rows per year — roughly 900 GB of raw data before indexes.

Postgres table partitioning by booking month keeps query performance stable. The query planner prunes partitions that fall outside the query’s time range, and vacuum operations run per-partition instead of on the entire table.

CREATE TABLE entries (
    id          BIGINT GENERATED ALWAYS AS IDENTITY,
    account_id  BIGINT NOT NULL,
    amount      BIGINT NOT NULL,
    currency    TEXT NOT NULL,
    booking_time TIMESTAMPTZ NOT NULL DEFAULT now(),
    prev_hash   BYTEA NOT NULL,
    entry_hash  BYTEA NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (booking_time);

CREATE TABLE entries_2025_01 PARTITION OF entries
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

Archival moves old partitions to cold storage. Detach the partition from the live table, export it to Parquet or compressed CSV, upload to S3 with Object Lock, and drop the partition from Postgres. The hash chain remains valid — verification can reconstruct the chain by reading archived partitions from S3 in sequence.

Data age Storage tier Access pattern Cost (approx.)
0–3 months Postgres (hot) Real-time queries ~$0.10/GB/month (EBS gp3)
3–12 months Postgres (warm) or S3 Standard Batch queries, reporting ~$0.023/GB/month
1–7 years S3 Glacier Deep Archive Audit requests, compliance ~$0.00099/GB/month

The 7-year archive of a system posting 10 million entries per day — roughly 6.3 TB of raw data — costs approximately $75/year on Glacier Deep Archive. The cost of immutability is not storage. It is the operational discipline to never take a shortcut that breaks the chain.

When a partition is archived and dropped, the live hash chain starts from the first entry of the oldest remaining partition. The prev_hash of that entry points to the last entry of the archived partition. Full-chain verification requires pulling the archived partitions back — which is why checkpoints matter. If the checkpoint at the boundary between archived and live data verifies, you only need to verify the live chain forward from that checkpoint.