Ledger Drift Runbooks

Prerequisite: The Ledger, Incident Response

Your reconciliation job fires at 2:17 AM. The alert says the customer funds sub-ledger is $43,891.22 higher than the sum of its entries. You have two questions: is money missing, or is the ledger wrong? The answer determines whether this is a data incident or a financial incident — and you have about 30 minutes to figure out which before the next automated sweep moves money based on that incorrect balance.

Ledger drift is the gap between what your ledger says and what is true. “True” might be the bank’s actual balance, the on-chain token supply, or the mathematical sum of your own entries. Drift is not a bug category — it is a symptom. The runbook’s job is to diagnose the cause, contain the blast radius, and restore agreement between systems before the drift compounds.

Classifying the Drift

Not all drift is equal. The first step in any runbook is classification, because the response differs by orders of magnitude.

Class Definition Example Severity
Accounting drift Ledger disagrees with its own entries Running balance ≠ SUM(entries) Critical — data integrity violation
Internal drift Two internal systems disagree Sub-ledger total ≠ general ledger High — reconciliation break
External drift Internal ledger disagrees with external source Ledger balance ≠ bank statement High — potential money movement error
Cross-domain drift Fiat ledger disagrees with on-chain state Reserve balance ≠ token supply Critical — solvency implication

Accounting drift means your own data is inconsistent. This is always a bug — entries are the source of truth, and a stored balance that disagrees with SUM(entries) is wrong by definition. Every other class requires investigation to determine which side is correct.

Do not attempt to fix drift before classifying it. A premature compensating entry on an accounting drift masks the bug. A premature adjustment on an external drift creates a second discrepancy when the bank’s delayed transaction arrives.

Runbook 1: Accounting Drift

Trigger: running_balance ≠ SUM(entries) WHERE account_id = X

This is the most urgent class. Your balance derivation is broken, which means every balance-dependent operation — spending checks, settlement calculations, regulatory reporting — is operating on wrong data.

Step 1: Freeze the account. Prevent new entries from posting to the affected account. If your system supports account-level holds, apply one. If not, disable the flows that write to this account.

-- Verify the drift
SELECT
  a.balance AS stored_balance,
  SUM(e.amount) AS derived_balance,
  a.balance - SUM(e.amount) AS drift
FROM accounts a
JOIN entries e ON e.account_id = a.id
WHERE a.id = $1
GROUP BY a.id, a.balance;

Step 2: Find the divergence point. Walk backward through the entry sequence. The drift originated at the entry where the running balance first disagreed with the cumulative sum.

-- Find the first divergent entry
WITH running AS (
  SELECT
    id,
    amount,
    created_at,
    SUM(amount) OVER (ORDER BY sequence_id) AS cumulative,
    balance_after
  FROM entries
  WHERE account_id = $1
  ORDER BY sequence_id
)
SELECT * FROM running
WHERE cumulative != balance_after
ORDER BY created_at
LIMIT 1;

Step 3: Identify the cause. Common causes, in order of frequency:

Cause Signature Fix
Concurrent write without serialization Two entries with the same balance_after Enforce serializable isolation or row-level locking
Partial transaction Entry inserted but balance update failed Wrap both in the same database transaction
Manual adjustment Direct UPDATE on balance column Revoke write access, add audit trigger
Snapshot corruption Stale snapshot used for derivation Rebuild snapshot from entries

Step 4: Correct the balance. Set the stored balance to the derived value. Log the correction as an administrative event with the incident ID, the old value, and the new value.

Step 5: Verify downstream. Any system that cached or propagated the incorrect balance — settlement calculations, regulatory reports, customer-facing displays — must be recalculated from the corrected state.

Runbook 2: Internal Drift (Sub-Ledger vs General Ledger)

Trigger: SUM(sub_ledger_balances) ≠ general_ledger_balance for a given account class.

The general ledger is a derived view of the sub-ledgers. When they disagree, either a sub-ledger posted an entry that didn’t propagate to the GL, or the GL received an entry that doesn’t exist in any sub-ledger.

Step 1: Isolate the break. Determine which sub-ledger is off.

SELECT
  sub_ledger_id,
  SUM(balance) AS sub_total,
  gl.balance AS gl_balance,
  SUM(balance) - gl.balance AS break_amount
FROM sub_ledger_accounts sla
CROSS JOIN general_ledger gl
WHERE gl.account_class = $1
GROUP BY sub_ledger_id, gl.balance
HAVING SUM(balance) != gl.balance;

Step 2: Check the propagation pipeline. If the GL is updated asynchronously (CDC, event stream, batch job), the break may be a timing issue. Check the pipeline lag.

Pipeline type Expected lag Break threshold
Synchronous (same transaction) 0 Any nonzero break is a bug
CDC / streaming < 30 seconds Break persisting > 5 minutes
Batch (hourly/daily) Up to batch interval Break persisting past next batch

If the break is within the expected lag window and shrinking, it is a timing break — not a data integrity issue. Monitor it through the next pipeline cycle.

Step 3: Reconcile entries. If the break persists past the lag window, compare entry-level detail between the sub-ledger and GL. Look for:

  • Missing entries: Present in sub-ledger, absent in GL (or vice versa). The propagation mechanism dropped the event.
  • Duplicate entries: Same entry posted twice to the GL. Deduplication failed.
  • Amount mismatches: Entry exists in both but with different amounts. Transformation error in the pipeline.

Step 4: Resolve. For missing entries, replay the event or create a manual GL entry referencing the sub-ledger entry ID. For duplicates, reverse the duplicate. For amount mismatches, determine which side is correct (the sub-ledger entry is authoritative — it’s closer to the source transaction) and adjust the GL.

See Sub-Ledger Architecture. Reconciliation breaks between sub-ledgers and the GL are expected signals in a multi-book architecture. A break is an alarm — it tells you the propagation pipeline needs attention, not that the ledger is fundamentally broken.

Runbook 3: External Drift (Ledger vs Bank)

Trigger: internal_ledger_balance ≠ bank_statement_balance for a given bank account.

This is the most common drift class in production. Banks and your ledger process events at different speeds, apply different cutoff times, and report with different granularity.

Step 1: Establish the comparison window. Bank statements have cutoff times (often 5:00 PM or midnight local time). Your ledger is continuous. Compare balances at the bank’s cutoff, not at the current moment.

Step 2: Account for in-transit items.

Item type Direction Effect
Outgoing payments submitted, not yet debited Ledger has deducted, bank has not Ledger < bank
Incoming payments received by bank, not yet booked Bank has credited, ledger has not Ledger < bank
Returns in transit Bank has reversed, ledger has not Ledger > bank
Holds and pending authorizations Bank has reserved, ledger may not Varies
adjusted_ledger = ledger_balance
  + pending_outgoing       -- ledger deducted, bank hasn't
  - pending_incoming       -- bank credited, ledger hasn't
  + returns_in_transit     -- bank reversed, ledger hasn't

if adjusted_ledger == bank_balance:
    drift is timing-only, no action needed

Step 3: Investigate residual drift. If the adjusted balance still doesn’t match, the drift is real. Common causes:

  • Bank fees or interest not yet booked in your ledger. Banks post fees on their own schedule — monthly maintenance fees, wire fees, interest credits.
  • Rejected transactions the bank processed differently than expected. A wire that your system marked as sent but the bank rejected.
  • Manual bank operations — customer-initiated wires, regulatory holds, or fraud freezes applied directly at the bank.

Step 4: Book the adjustments. Each identified item becomes a ledger entry with a reference to the bank statement line. Do not create a single “adjustment” entry for the aggregate difference — each item must be individually traceable.

Runbook 4: Cross-Domain Drift (Fiat vs On-Chain)

Trigger: fiat_reserve_balance ≠ total_token_supply beyond configured tolerance.

This is the most operationally complex drift class. Two fundamentally different systems — a bank account and a blockchain — must agree on the same number.

Step 1: Establish current state on both sides.

on_chain_supply  = totalSupply() across all chains
fiat_reserves    = sum(bank_balances) + sum(money_market_positions)
in_transit_mints = mints requested, fiat received, tokens not yet minted
in_transit_burns = burns confirmed on-chain, fiat not yet disbursed

Step 2: Compute adjusted positions.

expected_supply = fiat_reserves - in_transit_burns + in_transit_mints
drift = on_chain_supply - expected_supply

A positive drift means more tokens exist than reserves back them — this is the critical direction. A negative drift means reserves exceed supply — capital is stranded but solvency is not at risk.

Drift direction Severity Immediate action
Supply > reserves (positive) Critical Pause minting, investigate
Supply < reserves (negative) Medium Investigate, no emergency action
Within tolerance (±0.01%) Normal Log, continue monitoring

Step 3: Trace the source. Check the mint/burn event log against the fiat movement log. Every mint should have a corresponding fiat deposit. Every burn should have a corresponding fiat disbursement.

-- Find unmatched mints (tokens exist without fiat backing)
SELECT m.tx_hash, m.amount, m.chain, m.timestamp
FROM mint_events m
LEFT JOIN fiat_deposits f
  ON m.reference_id = f.reference_id
WHERE f.id IS NULL
  AND m.timestamp > NOW() - INTERVAL '7 days';

Step 4: Remediate by cause.

  • Orphaned mint (fiat deposit failed after mint): Burn the unbacked tokens or expedite the fiat deposit.
  • Orphaned burn (fiat disbursement failed after burn): Process the fiat disbursement manually.
  • Chain reorg invalidated a burn: The tokens are back in circulation. Re-execute the burn after finality.
  • Stale oracle / delayed bank reporting: Wait for the next reporting cycle; log the timing break.

Cross-domain drift with supply exceeding reserves is a solvency event. Even a $1 discrepancy in the wrong direction means your attestation report cannot state that tokens are fully backed. Escalate immediately — do not wait for the next reconciliation cycle.

Post-Incident Verification

After resolving any drift, run the full verification checklist before declaring the incident resolved.

Check Query Pass criteria
Account balance integrity running_balance = SUM(entries) for affected accounts Zero drift
Sub-ledger roll-up SUM(sub_ledger) = GL for affected account classes Zero drift
External reconciliation Adjusted ledger = bank statement Within tolerance
Cross-domain reconciliation token_supply = adjusted_reserves Within tolerance
Hash chain integrity Verify chain from last known-good checkpoint No breaks
Downstream recalculation Recompute any reports or settlements that used incorrect data Results match corrected state

Log the incident with: root cause, drift amount, duration (when drift started to when it was resolved), blast radius (which accounts, which downstream systems), and the corrective entries created. This log is audit evidence — regulators and auditors will ask for it during examination.

The most dangerous drift is the one nobody notices. Continuous reconciliation — running these checks every 60 seconds instead of once a day — converts a 2 AM emergency into a 2 PM alert that gets resolved before end-of-day processing. The runbooks stay the same. The urgency drops by an order of magnitude.