Ledger repair after BAN-306 (BAN-348)¶
Between 2026-10-06 and 2026-10-08, 26,676 ledger flows on Lithic debit accounts that were
still wrong after the Galileo flow repair were corrected
in production. The 12,754 accounts the correction left owing money, or that Finance chose to
restore, were then admin credited through Hydra. This page records what was wrong, how it
was corrected, what it did to customer balances and what is still open. The routing that
caused the original contamination is documented in
Lithic → Galileo transaction routing.
One interactive view of the result is attached to this page:
- Ledger repair register: every one of the 26,679
planned flows with its tier, the batches that carried it, its balance change, Lithic
tokens, native flows and RUIDs; every one of the 18,556 accounts whose balance changed,
with the admin credit it received and its Hydra link, its balance at the last read and the
communication lists it was on; and the day-by-day, admin-credit, list and closing-check
totals.
The ticket is
BAN-348.
What was wrong¶
BAN-306 put every contaminated GA= flow back to its correct Galileo state. It did not
touch the Lithic side of those transactions, and a register built afterwards found that the
ledger still disagreed with Lithic on 26,679 flows:
| category | flows | what was wrong |
|---|---|---|
| GA flow: Lithic settlement never debited | 25,246 | the purchase shows as settled in the app, but the customer's balance never moved |
| Native flow mis-stated, 22 Apr to 31 May 2026 | 1,248 | settled purchases restated to zero, voided purchases ending on the wrong leg, sign-flipped expiries |
| GA flow: customer charged more than Lithic settled | 160 | the customer is owed the difference |
| Stranded native ASA hold from migration day | 21 | an authorisation hold that nothing will ever release |
| GA flow: reservation still open | 4 | a hold left on the GA flow |
The largest group, 24,061 flows, has one shape. The Galileo hold was still open when the card
migrated to Lithic, so the Lithic clearing landed on the GA= flow as a row with no state
change. The be-lithic settlement-report rerun of 2026-04-24 then set the flow to reserved 0
and settled 0, and the Galileo expiry closed it. The customer's history shows the purchase,
but the money was never taken.
How each flow was corrected¶
The mechanism is the one BAN-306 used. Each flow was corrected by one be-lithic
ReconcileTransactionFlowEvent message inserted into platform.MessageOutbox on
prod-lithic. be-lithic replays the token's latest Lithic event, and be-ledger overwrites the
native flow's reserved and settled totals with Lithic's transaction totals, so the native
flow ends exactly at Lithic's final amounts. Where the GA= flow had to change as well, the
message carried a GalileoFlowRestore block that sets the GA flow's reservation to 0 and its
settled amount to the target.
The targets were:
- GA flow: reserved 0, settled = what Galileo itself settled, plus anything Lithic events
of other tokens posted on the flow (a refund under its own token, for example); - native flow: Lithic's transaction total.
Before every batch a preflight read Lithic's latest event per token (dbo.LithicEventV2 and
dbo.LithicEvent), the flows' current TransactionFlowCore state, the account balance and
the outbox. A row that disagreed with the plan was excluded with a reason and stayed planned.
After the messages landed, a verify step read every flow again and compared it, and each
account's available balance, with the expected values. A flow counts as fixed only once
verify has passed it.
The flows were grouped into tiers by what the correction does to the customer:
| tier | flows | what happens to the customer |
|---|---|---|
| credit | 331 | charged more than Lithic settled; available goes up |
| debit-full | 9,770 | a settled purchase never debited; the balance covered it when the tier was built |
| debit-part | 13,700 | the same, but the balance covered only part of it |
| debit-none | 2,039 | the same, and the balance covered none of it |
| release | 818 | no balance change; the flows' reserved and settled split is corrected |
| asa-hold | 21 | the stranded hold is released |
The debit tiers ran with the account allowed to go below zero. That was decided on
2026-10-05: the admin credits afterwards bring the affected accounts back.
How it was run¶
| day (UTC) | batches | messages | returned to customers | taken from customers | net |
|---|---|---|---|---|---|
| 2026-10-06 | 10 | 409 | 11,473.18 | −1,533.90 | +9,939.28 |
| 2026-10-07 | 7 | 8,635 | 0.99 | −155,396.57 | −155,395.58 |
| 2026-10-08 | 13 | 17,660 | 217.96 | −564,074.33 | −563,856.37 |
| total | 30 | 26,704 | 11,692.13 | −721,004.80 | −709,312.67 |
Each tier started with one-message pilots and grew from there (1, 1, 10, 100, then up to
3,000 per batch). Messages were scheduled 3 s apart at first, then 1 s, then 0.5 s. The
be-platform outbox publishes every due message back to back, so the ledger receives them at
the scheduled pace, and 3,000 messages at 0.5 s arrive over about 25 minutes.
There are more messages (26,704) than flows (26,676 verified) because 28 flows were carried
by two batches: 27 follow-ups that needed a second message, and one flow debited 0.99 in one
batch and credited 0.99 in another.
What changed along the way:
- The GA target rule was corrected on 2026-10-07, after GA=258192772 showed a refund
posted under its own token on a GA flow. Every GA flow was audited against its
TransactionCorepostings; 20 flows carried such a refund, 19 were corrected in the plan
before they ran and one was fixed with an extra 0.99 message. - The plan was rebuilt each morning from live state (
TransactionFlowCoreand
AccountBalance) instead of the September register. 773 flows already held Lithic's totals
by then and became 0.00 releases, and the debit tiers were re-assigned by that day's
balances. - Two flows ran at Lithic's latest total (GA=258937663, GA=258893851), because a Lithic
event newer than the plan had changed the amount. - 8 of the 19 stranded holds had already been cleared on a GA flow by the old routing.
Settling only their native flow would have debited the customer twice, so each of those
messages also restored the GA flow to 0/0.
Outcome¶
26,676 of 26,679 flows verified, 0 failed. The fix returned 11,692.13 to customers and
took 721,004.80, a net of −709,312.67, and changed the balance of 18,556 accounts.
Three flows are held and were not sent:
- GA=259260782 and GA=259278006: the native flow already held Lithic's final amount at the
first preflight, because the settlement had been debited after the register was built. - One stranded-hold row whose Lithic transaction, 04722cfd-c0a2-4f9f-99ec-795087ab61f0, is
the same as that of GA=259232482, which was fixed by its own row.
Admin credits¶
The credit rule was set before the run:
- Restore: an Active account on Finance's write-off list was credited what the fix took
from it, net of what the fix returned to it. - To zero: every other account the fix left negative was credited up to zero, and only by
the part of the negative the fix caused. Blocked and Terminated accounts on the write-off
list were credited to zero in the same way, through a forced credit.
Credits were issued one at a time through Hydra, with a fresh AccountBalance read before
each chunk. An account credited to zero was skipped if it was no longer negative, and
no account was ever credited twice.
| day (UTC) | rule | accounts | amount |
|---|---|---|---|
| 2026-10-07 | restore | 1,319 | 17,857.28 |
| 2026-10-07 | to zero | 17 | 57.28 |
| 2026-10-08 | restore | 9,733 | 403,780.01 |
| 2026-10-08 | to zero | 1,422 | 92,776.40 |
| 2026-10-08 | to zero, Blocked or Terminated (forced) | 263 | 10,541.39 |
| issued | 12,754 | 525,012.36 | |
| 2026-10-07 | debited back (over-credit) | 38 | −1,017.19 |
| net to customers | 12,754 | 523,995.17 |
Finance's own total for the write-off list was 536,258.61, computed from an earlier balance
snapshot.
The over-credit. The first wave computed the restore from the fix's debits alone. 39
accounts on which the fix had also credited money received 1,019.52 too much; one account
whose fix was +300.00 and −300.00 was credited 300.00. 38 of them were debited back the same
afternoon through Hydra administrative debits, each only after a fresh balance read showed
the account still covered it. The 39th keeps 2.33, because its balance was −15.09 for
reasons unrelated to the fix. The rule has netted the fix's credits since, every planned
amount was checked against an independent recompute before the second wave, and no credit
has been debited back since.
Not credited:
- 13 Blocked accounts on the write-off list were refused by be-transactions
(AccountBlockedException) on 2026-10-07. None was negative. - 12 accounts planned for a credit to zero were no longer negative when the credit ran on
2026-10-08 (252.14).
After the credits (balances read 2026-10-08 15:00 UTC), 110 credited accounts are still
negative, −5,091.30 together: 97 restored, 12 credited to zero, and 1 credited and fully
debited back. A restore returns what the fix took, and a credit to zero covers only the part
of the negative the fix caused, so what remains was there before the fix or is the
customer's own activity.
The second wave ran as three parallel streams over disjoint account files, about two credits
per second together. minority-ledger-service, minority-hydra-api, minority-hydra-service and
minority-transactions-service showed no latency or error change.
Customer communication¶
Accounts whose balance changed were put on lists for the customer message, one list per
message:
| sent | list | accounts |
|---|---|---|
| 2026-10-06 | balance increased only | 209 |
| 2026-10-07 | list 1, restored to the balance before the fix | 1,286 |
| 2026-10-07 | list 2, negative, credited to zero | 16 |
| 2026-10-07 | list 3, no credit | 5,189 |
| 2026-10-07 | list 4, correction to the 2026-10-06 message | 42 |
| 2026-10-08 | list 1, restored to the balance before the fix | 9,733 |
| 2026-10-08 | list 2, negative, credited to zero | 1,683 |
| 2026-10-08 | list 3, no credit | 427 |
That is 18,543 distinct accounts; the 42 corrections had already been on the 2026-10-06 list.
An account already messaged was left off later lists unless the fix debited or credited it
again after its message. Two such accounts, messaged on 2026-10-07 as not negative and
credited to zero on 2026-10-08, form the 2026-10-08 list 4, which had not been sent when this
page was written. 13 accounts whose balance only went up, through the hold releases of
2026-10-08 (158.03 together), were on no list.
Closing checks¶
- GA flow audit, every
TransactionCoreposting of every GA flow read again after the
last batch: all 25,421 GA flows, the 2 held ones among them, are at reserved 0 and settled at
their target.TransactionFlowCoreagrees with the last posting on every flow, and no flow
has more than oneTransactionFlowCorerow. Native flows are not part of this audit; the
verify step checked each one against Lithic's latest event when its batch landed. - Over-credit recompute, after the second wave: every issued credit recomputed from the
applied batches. The outstanding over-credit is unchanged at one account, 2.33. - Lithic settlement file (
dbt_semantic.fact_lithic_debit_card_transaction): 1,477
distinct tokens were checked: the 1,462 not matched to a settlement file earlier and the 17
tokens of the 14 flows Galileo settled, two of them in both groups. Lithic's settled total
equals the plan's native target on 1,130 of the 1,226 plan rows the file covers, and no row
differs. The other 96 rows carry 198 tokens absent from the file, each with a Lithic final
of 0.00 (expired or declined, never settled), which the file does not carry. - Processor callbacks (
dbt_semantic.fact_processed_transaction): be-lithic's reconciled
callbacks after the replay equal the plan target on all 1,226 rows. - Galileo-settled flows: for the 14 flows Galileo settled, Lithic's settled total equals
the register's Lithic settled amount on all 14.
Things to know before the next repair¶
A compensation must net the fix's own credits. Summing only what the fix debited
over-credited 39 accounts. Compute the credit from everything the fix did to the account,
and recompute it independently from what was actually applied before issuing it.
A GA flow can be restored once per three months. The GalileoFlowRestore record's
idempotency key is derived only from GA-REPAIR:GA=<flow>, and be-ledger compares a key it
has seen in the last three months field by field. A second restore of the same flow with
different amounts is rejected with 400 "Transaction data does not match the existing
record." On 2026-10-08 an attempt to move 20 refunds off their GA flows failed this way on
every message, after four deliveries each, and nothing was written. Those 20 flows keep the
refund on the GA flow and the purchase on its native flow, which is net correct per customer.
Separating them needs a be-lithic change that puts the previous and target amounts into the
key. The dead-lettered messages must not be resent.
A flow can be carried by more than one batch. Anything that totals per flow (the
admin-credit plan, the lists, this page) must sum every applied batch for the flow. Keeping
only the last one planned 0.99 too much for one account; the independent recompute caught it
before the credit ran.
Long runs against Azure SQL need splitting. The access token is taken once per run and
lasts about an hour; a longer run fails with
Login failed for user '<token-identified principal>'. Splitting the accounts into disjoint files and running
them as parallel streams kept each run short. Disjoint files are what prevents a double
credit.
Lithic events live in two tables. Since 2026-09-29 new events are written to
dbo.LithicEventV2; older ones stay in dbo.LithicEvent, which BAN-392 plans to drop. Any
replay of an April token has to read both while both exist.
Still open¶
What is still wrong in the ledger, with what fixing each item takes, is tracked on
Ledger leftovers after BAN-348.
- The 20 refunds that stay on their GA flows, described above.
- Out of scope for this repair: 43 credit-program rows (a different card program), 1,888
stale holds (BAN-238) and 686 reserve mismatches from the BAN-348 register.