On The Ledger logo

The source of truth for modern financial systems

About How It Works

Tamper detection in practice

What actually happens when a record is altered.

This is not a theoretical guarantee. Walk through a real tamper scenario — what gets changed, what the audit reveals, what the event log preserves, and how the correct state can be reconstructed. Every field name and hash format shown here is production-accurate.

Step 1 — The setup

A small ledger with three posted transactions.

Each transaction is linked to its predecessor by a SHA-256 hash chain. The chain is the audit surface. The event log is the canonical record of what was posted and when.

Transactions in ledger ledg-7f3a9c2d
id (uuid) effective_at description amount strong_content_hash previous_hash
tx-uuid-1 2026-01-15 09:00 UTC Opening balance transfer $10,000.00 b1e7…9a2f nil (genesis)
tx-uuid-2 2026-01-16 14:22 UTC Vendor payment — Acme Co. $850.00 c94d…3b81 b1e7…9a2f
tx-uuid-3 2026-01-17 11:05 UTC Payroll disbursement $850.00 a3f8…c204 c94d…3b81

The event log entry for transaction 2

Event log row — immutable once written; any attempted update or deletion is rejected by the application

{
  "id":              "evt-uuid-b2c3",
  "ledger_id":       "ledg-7f3a9c2d",
  "aggregate_type":  "Transaction",
  "aggregate_id":    "tx-uuid-2",
  "event_type":      "TransactionPosted",
  "sequence_number": 2,
  "occurred_at":     "2026-01-16T14:22:00.000Z",
  "recorded_at":     "2026-01-16T14:22:00.033Z",
  "payload": {
    "transaction_id":      "tx-uuid-2",
    "ledger_id":           "ledg-7f3a9c2d",
    "idempotency_key":     "vendor-payment-acme-2026-01-16",
    "effective_at":        "2026-01-16T14:22:00.000Z",
    "description":         "Vendor payment -- Acme Co.",
    "adjusting":           false,
    "reference_number":    "VND-2026-0116",
    "strong_content_hash": "c94d3b8112ef6a790d45f283a1c9e7b25d0f836e41a2c095d784b163e7803b81",
    "previous_hash":       "b1e7a429f5c32d8e1b0f7c6a4e2d9851f3b4e7c2a0d6f8b1e7a429f5c32d9a2f",
    "entries": [
      { "account_id": "acct-cash",   "direction": "credit", "amount_cents": 85000 },
      { "account_id": "acct-vendor", "direction": "debit",  "amount_cents": 85000 }
    ]
  },
  "metadata": {}
}

Step 2 — The tamper

Someone alters a historical transaction directly in the database.

An operator with direct database access changes the amount on the debit entry for transaction 2 — then updates the stored hash on the transaction record to try to cover their tracks. No error is raised. No warning is logged. From the application's perspective, everything looks consistent.

Two-step database mutation — bypassing all application-layer validations

-- Step 1: change the entry amount
UPDATE entries
SET amount_cents = 125000   -- was 85000 ($850.00); now $1,250.00
WHERE transaction_id = 'tx-uuid-2'
  AND direction = 'debit';

-- Step 2: update the stored hash to try to cover tracks
UPDATE transactions
SET strong_content_hash = 'd19c7fa3e2b581f046c9d3a7e15b28f4c032e8a96d7b410f253e9c84b17af7a1'
WHERE id = 'tx-uuid-2';

After the mutation

The entry amount has changed, and the actor updated the stored hash to match. But transaction 3's previous_hash still points to the original hash of transaction 2. The chain is broken — and the actor cannot fix it without altering the entire forward chain, and ultimately the event log, which is immutable.

Field Original value After mutation
entries.amount_cents (tx-uuid-2, debit) 85000 125000
transactions.strong_content_hash (tx-uuid-2) c94d…3b81 d19c…f7a1 (replaced to cover tracks)
transactions.previous_hash (tx-uuid-3) c94d…3b81 c94d…3b81 (unchanged — chain broken)

Step 3 — The audit

The audit walks the chain and finds the break.

The audit service walks each transaction in posting order, comparing each record's previous_hash to the stored hash of its predecessor. It stops at the first inconsistency. The tamper is detected not at the altered record, but at the next transaction — whose previous_hash no longer matches the hash the actor left behind.

Running the audit

result = Ledger::Public::Services::AuditManager
  .audit_chain(ledger_id: "ledg-7f3a9c2d")

Audit result

#<Ledger::Public::DTO::AuditResult
  status:        :error,
  ledger_id:     "ledg-7f3a9c2d",
  checked_count: 3,
  divergence_at: "tx-uuid-3",
  errors: [{
    code:           :hash_chain_divergence,
    message:        "Hash chain broken",
    transaction_id: "tx-uuid-3"
  }]
>

divergence_at identifies the first transaction where the chain is broken — transaction 3, whose previous_hash no longer matches the updated hash on transaction 2. The tampered record is transaction 2.

Step 4 — What we know

The event log is untouched. The original record is preserved.

The event log table is append-only. Every row is rejected by the application if any update or deletion is attempted. The event written at the time of posting still carries the original entry amounts, the original hash, and the sequence number that proves ordering. No operator, however privileged, can alter this without breaking the event chain itself.

Event log entry for transaction 2 — written at post time, immutable ever after

# Event log — sequence_number: 2, written at post time, immutable ever after
# Any update or deletion attempt is rejected by the application

payload = {
  transaction_id:      "tx-uuid-2",
  ledger_id:           "ledg-7f3a9c2d",
  idempotency_key:     "vendor-payment-acme-2026-01-16",
  effective_at:        "2026-01-16T14:22:00.000Z",
  description:         "Vendor payment -- Acme Co.",
  adjusting:           false,
  reference_number:    "VND-2026-0116",
  strong_content_hash: "c94d3b8112ef6a790d45f283a1c9e7b25d0f836e41a2c095d784b163e7803b81",
  previous_hash:       "b1e7a429f5c32d8e1b0f7c6a4e2d9851f3b4e7c2a0d6f8b1e7a429f5c32d9a2f",
  entries: [
    { account_id: "acct-cash",   direction: "credit", amount_cents: 85000 },
    { account_id: "acct-vendor", direction: "debit",  amount_cents: 85000 }
  ]
}

sequence_number is assigned per ledger at publish time: the publisher acquires a per-ledger advisory lock and assigns MAX(sequence_number)+1 within the same database transaction. The unique index on (ledger_id, sequence_number) enforces that no two events share a sequence number. Together, these guarantee monotonic, gapless ordering with no reordering possible.

Step 5 — Recovery

The event log rebuilds the correct state.

The event replay service walks the posted-transaction event stream for the ledger, rebuilding the transactions and entries projections from the ground up. The tampered record is replaced with the version derived from the immutable event payload. The hash chain is recomputed from scratch. A second audit run passes clean.

Before replay (transaction 2)

entries.amount_cents
125000
strong_content_hash
d19c…f7a1 (tampered)
amount (display)
$1,250.00 (tampered)
audit
status: error — divergence_at: tx-uuid-3

After replay (transaction 2)

entries.amount_cents
85000
strong_content_hash
c94d…3b81 (restored)
amount (display)
$850.00 (original)
audit
status: ok — checked_count: 3, divergence_at: nil

The event replay service is the production mechanism for this recovery path. Full wiring of the replay pipeline is tracked in the implementation backlog — the integrity guarantee described here is structural: as long as the event log is intact, the correct state is always recoverable.