The Four Numbers Before the Ledger: Control Totals and Break Aging in Settlement
Two questions that look alike
Completeness is not correctness. A file can arrive short, replayed, altered or internally balanced — and only one of those is a reconciliation problem. Control totals answer the pre-posting question (is this what the sender sent?); breaks answer the post-posting one (does my ledger agree with the other side?).
Reconciliation analytics owns what to compare, anomaly detection what to look for, real-time alerting the paging path, outage post-mortems what an incident leaves behind. This post is the gate in front of them.
The four numbers in a trailer record
Every mature clearing scheme carries its own arithmetic witness. An ACH batch ends with a Batch Control Record — Entry/Addenda Count, Entry Hash (a hash total of routing numbers, right-justified to ten digits) and Total Debit and Credit Entry Dollar Amounts — and the file ends with a File Control Record aggregating them (ACH format). Bacs returns a Submission Report with transaction count and total value, then an Input Report with the detail — the report a payroll team checks to confirm “the expected number of payments and total value match their payroll summary” (paygate, GoCardless).
| Control total | Question answered | Blind spot |
|---|---|---|
| Count | Did every record arrive? | Which one is missing |
| Hash total (sum of account numbers) | Did the set change? | An amount altered in-set |
| Debit total | Does one side equal the sender’s figure? | Extra records the sender counted too |
| Credit total | Is the file two-sided, DR == CR? | A valid payment sent twice |
Hash totals are the underrated number: summing account numbers yields a meaningless value, which is the point — it fingerprints the identity and count of records, not their value. Drop a record and count and hash move; alter an amount and the debit total moves; replay and the sequence number moves.
The five gates, and what each one catches
Each gate must be a refusal: a total an operator can override is documentation, not a control.
| Gate | Question | Failure caught | Action |
|---|---|---|---|
| G1 trailer present | Did every batch bring its trailer? | Truncated file, lost tail record | Refuse; request retransmission |
| G2 batch sequence | Is this the batch we expect next? | Replay, retransmission | Refuse; a replay generates duplicates |
| G3 batch recompute | Does the trailer describe what arrived? | In-flight alteration, dropped record | Refuse; the sender’s arithmetic is the witness |
| G4 file control record | Do the batches sum to the file header? | Partial file, mis-chunked upload | Refuse the file |
| G5 two-sided | Is the file balanced? | One-sided entries, corrupted direction codes | Refuse; an unbalanced file is not postable |
Kenya’s KEPSS rules show the item-level half of the same discipline: a bank receiving a multiple third-party payment with a problem in one payment must not return the whole message — only the problem payment, quoting main and related references, the rest applied as instructed (KEPSS §11.3(f)). Refuse the item, settle the batch, track the difference.
A control-total failure is not a break
| Control-total failure | Break | |
|---|---|---|
| When | Before posting | After posting |
| Unit | File or batch | An item, or an aggregate cell |
| Signal | Binary — refused | Graded — a value that should be zero |
| Action | Do not post; nothing moves | Post; carry the difference with an owner and an age |
| Clock | The sender’s retransmission window | The customer’s — RBI’s T+1 reversal, or ₹100/day |
| Cost | A delayed batch | An aged break, or someone else’s asset |
The incidents were not exotic
Citi, April 2024 — $81 trillion. An employee pasted an account number into the amount field, turning a $280 transfer into $81 trillion between two internal ledger accounts. A second employee checking it missed it too; a third caught it about 90 minutes after processing, and the entry was reversed hours later. No funds left the bank; it went to the Federal Reserve and the OCC as a “near miss” (CBS, NYT). Citi’s wording is the tell — “our detective controls promptly identified the inputting error”. Detection after posting is downstream of a total that would have caught it.
Citi, August 2020 — Revlon. As agent for a 2016 Revlon loan the bank intended a $7.8 million interest payment and wired just under $900 million as a payoff; lenders refused to return funds, producing a c. $500 million loss — a “massive, unforced error” in its CEO’s words (Reuters, Maryland Smith). A total checked against scheduled interest plus fees would have failed by two orders of magnitude.
Citi, 2 May 2022 — the fat finger. A trader meant to sell $58 million of equities, keyed 58m into quantity instead of notional, and built a 349-stock basket with a $444 billion notional. Systems blocked $255bn; the remaining $189bn reached the algorithm, which sold $1.4bn before the trader cancelled 15 minutes later — a $48 million loss. Regulators fined Citigroup Global Markets £27.77M (FCA) and £33.88M (PRA) — £61.6m — noting the trader could override a pop-up alert without scrolling through the alerts inside it (Guardian, Reuters). The limit existed; the enforcement did not.
Deutsche Bank, 16 March 2018 — €28 billion. In routine derivatives dealings the bank transferred €28bn (≈$34bn) to its own account at Eurex — more than its entire €24bn market capitalisation — an operation “meant to involve a far smaller sum, which the bank has not revealed”. It was corrected the same day, and the ECB asked for clarification (AFP, Reuters).
Four times, one shape: a plausible instruction, a pre-computed number nobody forced to agree, and a system happy to move the money.
The demo: five faults, five gates, one that walks through
Six two-sided batches, each with an honest trailer; five injected faults, distinguishable only by the control totals.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
import random
from collections import namedtuple
ENTRY = namedtuple("ENTRY", "account amount direction") # D = debit, C = credit
NETTING = 99_999_999 # internal netting account, keeps every batch two-sided
def make_day(seed=14, n_batches=6):
rng, batches, seq = random.Random(seed), [], 1
for _ in range(n_batches):
e = []
for _ in range(rng.randint(180, 260)):
e.append(ENTRY(rng.randint(10_000_000, 99_999_999),
rng.choice([rng.randint(500, 90_000),
rng.randint(100_000, 4_800_000),
rng.randint(5_000_000, 40_000_000)]),
"D" if rng.random() < 0.55 else "C"))
net = sum(x.amount if x.direction == "D" else -x.amount for x in e)
if net > 0:
e.append(ENTRY(NETTING, net, "C"))
elif net < 0:
e.append(ENTRY(NETTING, -net, "D"))
batches.append({"seq": seq, "entries": e})
seq += 1
return batches
def totals(entries):
return {"count": len(entries),
"hash": sum(x.account for x in entries) % 10 ** 10,
"dr": sum(x.amount for x in entries if x.direction == "D"),
"cr": sum(x.amount for x in entries if x.direction == "C")}
def trailer(batch):
return dict(totals(batch["entries"]), seq=batch["seq"])
def file_record(batches):
t = {"count": 0, "hash": 0, "dr": 0, "cr": 0, "batches": len(batches)}
for b in batches:
for k in ("count", "hash", "dr", "cr"):
t[k] += trailer(b)[k]
t["hash"] %= 10 ** 10
return t
def gates(after, declared_file, declared_trailers):
"""Five pre-posting questions. Each one is answerable from the file alone."""
fired = []
missing = [b["seq"] for b in after if b["seq"] not in declared_trailers]
if missing: # G1 every batch has a trailer
fired.append(("G1", f"batch {missing[0]} has no trailer"))
seqs = [b["seq"] for b in after]
if seqs != list(range(1, len(seqs) + 1)): # G2 batch sequence is 1..n
fired.append(("G2", f"sequence {seqs} is not 1..{len(seqs)}"))
for b in after: # G3 recompute vs trailer
d = declared_trailers.get(b["seq"])
if not d:
continue
r = trailer(b)
for k in ("count", "hash", "dr", "cr"):
if r[k] != d[k]:
fired.append(("G3", f"batch {b['seq']} {k}: recomputed {r[k]:,} "
f"!= trailer {d[k]:,} (delta {abs(r[k]-d[k]):,})"))
r = file_record(after)
if not missing: # G4 file control record
for k in ("count", "hash", "dr", "cr", "batches"):
if r[k] != declared_file[k]:
fired.append(("G4", f"file {k}: recomputed {r[k]:,} "
f"!= control record {declared_file[k]:,}"))
if r["dr"] != r["cr"]: # G5 the file is two-sided
fired.append(("G5", f"DR {r['dr']:,} vs CR {r['cr']:,}"))
return fired, r
def f_truncate(b):
b[-1]["entries"] = [b[-1]["entries"][0]] + b[-1]["entries"][2:]
return "last batch arrives with one detail record missing"
def f_duplicate(b):
d = dict(b[2]); d["seq"] = 9; b.append(d)
return "batch 3 retransmitted, renumbered 9"
def f_alter(b):
e = b[2]["entries"][7]
b[2]["entries"][7] = e._replace(amount=e.amount + 360_000)
return "one amount altered in transit (+360,000)"
def f_sequence(b):
b[4]["seq"] = 15
return "batch 5 renumbered 15 by a broken integration"
def f_inside(b):
e = b[5]["entries"][3]
b[5]["entries"].append(ENTRY(e.account, e.amount, "C"))
b[5]["entries"].append(ENTRY(e.account, e.amount, "D"))
return "a duplicate payment with both legs, netted and honestly counted"
FAULTS = [("truncate", f_truncate, False), ("duplicate", f_duplicate, False),
("alter", f_alter, False), ("sequence", f_sequence, False),
("inside", f_inside, True)] # True = the sending system's own totals include it
def run(fault, seed=14):
day = make_day(seed)
declared_file, declared_trailers = file_record(day), {b["seq"]: trailer(b) for b in day}
label = fault[1](day)
if fault[2]: # honest trailer: totals computed after the fault
declared_file = file_record(day)
declared_trailers = {b["seq"]: trailer(b) for b in day}
fired, r = gates(day, declared_file, declared_trailers)
verdict = "REFUSED PRE-POSTING" if fired else "POSTED"
first = f"{fired[0][0]} {fired[0][1]}" if fired else f"{r['count']:,} entries, DR == CR"
return label, verdict, first
clean = file_record(make_day(14))
print(f"clean day: {clean['count']:,} entries, DR {clean['dr']:,} = CR {clean['cr']:,}, "
f"hash {clean['hash']:,}\n")
print(f"{'fault':<10}{'verdict':<21}first gate to fire")
for name, fn, honest in FAULTS:
label, verdict, first = run((name, fn, honest))
print(f"{name:<10}{verdict:<21}{first}")
print(f"{'':<10}{label}")
BUCKETS = [("< 1 day", 41, 18_400_000, 0.25, 0.00), ("1-7 days", 27, 9_650_000, 0.75, 0.00),
("8-30 days", 14, 4_100_000, 3.00, 0.05), ("> 30 days", 6, 2_780_000, 8.00, 0.60)]
total = sum(b[2] for b in BUCKETS)
print(f"\n{'breaks by age':<14}{'n':>4}{'value (KES)':>15}{'age-share':>11}{'effort h':>10}")
for name, n, val, hours, _ in BUCKETS:
print(f"{name:<14}{n:>4}{val:>15,}{100*val/total:>10.1f}%{n*hours:>10.1f}")
aged = [b for b in BUCKETS if b[0] in ("8-30 days", "> 30 days")]
print(f"aged > 7 days: {sum(b[1] for b in aged)} of {sum(b[1] for b in BUCKETS)} breaks, "
f"KES {sum(b[2] for b in aged):,} ({100*sum(b[2] for b in aged)/total:.1f}% of value); "
f"write-off risk KES {sum(int(b[2]*b[4]) for b in BUCKETS):,}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
clean day: 1,170 entries, DR 5,759,092,508 = CR 5,759,092,508, hash 4,862,209,760
fault verdict first gate to fire
truncate REFUSED PRE-POSTING G3 batch 6 count: recomputed 190 != trailer 191 (delta 1)
last batch arrives with one detail record missing
duplicate REFUSED PRE-POSTING G1 batch 9 has no trailer
batch 3 retransmitted, renumbered 9
alter REFUSED PRE-POSTING G3 batch 3 cr: recomputed 875,767,475 != trailer 875,407,475 (delta 360,000)
one amount altered in transit (+360,000)
sequence REFUSED PRE-POSTING G1 batch 15 has no trailer
batch 5 renumbered 15 by a broken integration
inside POSTED 1,172 entries, DR == CR
a duplicate payment with both legs, netted and honestly counted
breaks by age n value (KES) age-share effort h
< 1 day 41 18,400,000 52.7% 10.2
1-7 days 27 9,650,000 27.6% 20.2
8-30 days 14 4,100,000 11.7% 42.0
> 30 days 6 2,780,000 8.0% 48.0
aged > 7 days: 20 of 88 breaks, KES 6,880,000 (19.7% of value); write-off risk KES 1,873,000
Four of the five faults never reach the ledger, each named by the gate that refused it. The fifth — a duplicate created inside the batch, both legs present, honestly counted, netting to zero — passes every gate, because each gate asks whether the file is faithful to itself, not whether the batch should have been sent. Control totals bound mechanical failure to zero; only the other side of the book — the beneficiary’s statement, the counterparty’s file — sees a well-formed duplicate.
Break aging: the cost that survives the gates
A break is not an anomaly with a resolution time; it is a liability with an owner and an age.
| Bucket | Meaning | Handling |
|---|---|---|
| < 1 day | Timing — the other side has not posted | Auto-retry next cycle |
| 1–7 days | Real difference — reference, fee, FX, wrong account | Named owner, statement pulled, cause coded |
| 8–30 days | Disputed, or the counterparty unreachable | Escalation, formal claim, provisional posting |
| > 30 days | Nobody owns it | Suspense or write-off |
The demo’s ladder is small — 88 breaks, KES 34.9M — and its shape is the durable part: the oldest bucket holds the smallest share of items and the largest share of the risk. Twenty breaks aged past seven days carry KES 6.88M — under a fifth of the value, but 90 hours of investigation and nearly all of the KES 1.87M expected write-off.
Unclosed items end somewhere. In Kenya an item with no active involvement for two years is declared unclaimed, and an estimated KES 241.1 billion of unclaimed financial assets sits across the economy — about 62% in financial services — with dormancy-driven items at a record KES 5.182 billion in 2025 while claimants fell 32.7% (Kenya News Agency, The Star). An aged break changes owner, and becomes a statutory problem.
How we can do better
| Control | Why it holds |
|---|---|
| Gate, don’t warn — a failed total refuses the file | Citi’s overridable pop-up cost £61.6m |
| Recompute, never trust — recalculate from the details received | A trailer is the sender’s claim, not evidence |
| Two-sided by construction — add an explicit netting entry | An unbalanced file should be unpostable |
| Hash totals on identity — sum account numbers, not only values | Catches an altered set that still balances |
Sequence discipline — batch numbers strictly 1..n | Turns a replay into a refusal, not a duplicate |
| Age on arrival — stamp the settlement date it should have had | A break found late must not look young |
| Owner and root cause per break | Feeds the histogram that fixes integrations |
Key takeaways
| Takeaway | Why it matters |
|---|---|
| Control totals are a pre-posting completeness proof | A count, a sum and a hash catch truncation, replay and alteration for free |
| Internal consistency is not intent | A well-formed duplicate passes every gate; only the counterparty sees it |
| Breaks age into liabilities | Two years of dormancy makes an unclosed item someone else’s asset |
| The incidents were ordinary input errors | Citi’s four, at $81T, $900M, $444bn and €28bn, were all stoppable before posting |
References
- ACH file format: control records and entry hash — timetrex.com
- Bacs payment reports — paygate.uk, GoCardless
- Central Bank of Kenya, Revised KEPSS Rules and Procedures, §11.3(f) — centralbank.go.ke
- CBS News, Citi mistakenly credits $81 trillion, 28 Feb 2025 — cbsnews.com
- Reuters, Citigroup cannot recoup Revlon payouts — reuters.com
- Maryland Smith, Lessons from Citi’s Revlon Error, 22 Mar 2021 — rhsmith.umd.edu
- Guardian, Citigroup fined over ‘fat finger’ error, 22 May 2024 — theguardian.com
- AFP, Deutsche Bank makes 28bn euro transfer in error, 20 Apr 2018 — phys.org
- Kenya News Agency, Sh241 billion unclaimed financial assets, UFAA releases — kenyanews.go.ke
- The Star, Unclaimed financial assets hit Sh5.18bn, 6 May 2026 — the-star.co.ke
Related posts
- Paging the Ledger: Real-Time Reconciliation Alerting That Actually Wakes Someone
- Anomaly Detection for Reconciliation at Scale
- Reconciliation Analytics: How Data Catches What Dashboards Miss
- The Money Stopped Moving: What Fintech Outage Post-Mortems Actually Measure
- Building an Aggregator/BaaS Security Playbook
