Skip to content
Operate

Verification

Accounting replay, the versioned hash chain, checkpoints and external anchoring.

Full guide in the repository: docs/verification.md

LedgerForge has two independent, read-only checks. Accounting replay tests whether stored balances agree with persisted financial operations. Audit verification tests whether sealed transaction commitments, their links and checkpoint roots agree with the database. Run both. Neither proves that a real-world payment happened or that your application chose the right business transaction.

ledgerforgeCLI
$ ledgerforge verifyChecked 6 balances and 4 transactions: 0 issues$ ledgerforge audit verifyChain: 4 sealed, head ff10247395ae53c4580005ac9c6cfb6cc7f79cbf5aeed252850e619b82ea2821, 0 pending, 0 tampering findings$ ledgerforge audit export > checkpoints.json$ ledgerforge audit verify --anchor checkpoints.json --json{"verified":true,"last_sequence":4,"head_hash":"ff10247395ae53c4580005ac9c6cfb6cc7f79cbf5aeed252850e619b82ea2821","pending":0,"tampering":[]}

Accounting replay

ledgerforge verify reads balances and history in one PostgreSQL REPEATABLE READ snapshot, replays integer debits, credits, inflight reservations and releases, and compares the result with stored counters. It also checks amounts and scales, missing balances, parent and child relationships, reversals and terminal completions. It never repairs balances, and it exits non-zero when the report has issues.

Machine-readable report
$ ledgerforge verify --json{"verified":true,"balances":6,"transactions":4,"issues":[]}

Each issue has a kindand, where relevant, transaction or balance ID, field, expected and actual value. Preserve the report and a database snapshot when investigating; don't rewrite postings to make it pass. Replay can't detect a fully rewritten but consistent database, which is why the audit chain and external anchors exist.

Hash chain and checkpoints

Sealing is off by default. Turn it on in the config used by the API server, then restart it:

ledgerforge.json
{  "transaction": {    "hash_chain": { "enabled": true }  }}

The API server owns the sealing processor; workers alone don't seal. It polls every five seconds and waits thirty seconds behind the newest rows, so recent transactions stay unsealed for a short time. Canonical v3 hashes use SHA-256 over length-delimited fields including the previous hash, IDs, endpoints, amounts, precision, currency, status, reference, overdraft cap, queue action, execution controls and scheduling. Mutable customer metadata is excluded. Each sealed batch records a checkpoint with sequence, count, head_hash and created_at.

Hash chain (canonical v3, SHA-256)

  1. seq 1h1 = H(h0, txn 1)
  2. seq 2h2 = H(h1, txn 2)
  3. seq 3h3 = H(h2, txn 3)
  4. seq 4h4 = H(h3, txn 4)

Checkpoints

  • seq 1count 19f52ae4fe574506a5dbbc60fa563764579e97c3a818e6bb84f9ffc43ef53d081
  • seq 3count 33e859daaffd0aefeb952ad9d9f00f154d64f82f8e4d6ad0cbb3c80f5baca73ae
  • seq 4count 4ff10247395ae53c4580005ac9c6cfb6cc7f79cbf5aeed252850e619b82ea2821

External anchor

  1. 1audit export writes checkpoint roots as JSON
  2. 2Sign the exact bytes with a separate identity
  3. 3Store them outside the database administrators' reach
  4. 4audit verify --anchor compares them with the live chain
ledgerforge audit export
[  {    "sequence": 1,    "count": 1,    "head_hash": "9f52ae4fe574506a5dbbc60fa563764579e97c3a818e6bb84f9ffc43ef53d081",    "created_at": "2026-10-10T05:49:54.31577Z"  },  {    "sequence": 3,    "count": 3,    "head_hash": "3e859daaffd0aefeb952ad9d9f00f154d64f82f8e4d6ad0cbb3c80f5baca73ae",    "created_at": "2026-10-10T05:49:59.315556Z"  },  {    "sequence": 4,    "count": 4,    "head_hash": "ff10247395ae53c4580005ac9c6cfb6cc7f79cbf5aeed252850e619b82ea2821",    "created_at": "2026-10-10T05:50:09.315605Z"  }]

Pending coverage versus tampering

ReportMeaning
verified: true, pending: 0Commitments are consistent and every transaction row in the snapshot is sealed.
verified: true, pending > 0Links are intact; some rows aren't sealed yet. Check the server config, delay and backlog.
verified: false, tampering non-emptyConflicting or missing evidence. Preserve the database and report, then investigate.
Shortly after a new transactionexit 1
$ ledgerforge audit verifyChain: 4 sealed, head ff10247395ae53c4580005ac9c6cfb6cc7f79cbf5aeed252850e619b82ea2821, 1 pending, 0 tampering findingschain intact; coverage pending for 1 committed rows

The command exits non-zero for pending coverage too, with a distinct message. Don't treat that exit status alone as tampering; agree a cutoff and a bounded sealing lag instead.

External anchoring

Hashes stored in the same database can't protect against an administrator who can rewrite both postings and evidence. Keep checkpoint roots under a separate trust boundary:

  1. Run replay and audit verify, and explain any findings or pending coverage.
  2. Export checkpoints.json and record the ledger identity, release, UTC export time and last covered sequence.
  3. Sign the exact bytes with a separate identity, or submit their digest to an external timestamping service.
  4. Store export, signature and manifest in immutable storage that database administrators can't change. Keep earlier roots.
  5. Periodically authenticate an earlier export outside LedgerForge and compare it with the live database:
Terminal
# Example external signature check; use your organisation's signing system.$ gpg --verify checkpoints.json.asc checkpoints.json$ ledgerforge --config ./ledgerforge.json audit verify --anchor checkpoints.json --json
--anchorcompares retained sequences, counts and roots with the database. It doesn't check signatures, contact an anchoring service or decide whether the file is trustworthy; those are operator duties. An anchor protects the prefix it covers. See the full guide for legacy-data limits and the upgrade notes before migrating existing history.