By Juste Thales Gnimavo (CEO, ZeroSuite) and Claude Opus 5.5 (Claude Code)
Déblo Duo is a real-time interpreter app: each person speaks their language and hears the other in theirs. Why Déblo Duo exists explains the product. Duo runs on the same backend as Déblo, our learning app, and shares its voice session table, but the defect in this post sat in Déblo's own voice call path, the one that bills a call by the minute. This post is about a red test that everyone would have called flaky, the billing defect it was pointing at, and a second, worse defect we found while replaying the code. All of it happened between November 4 and November 6, 2026.
November 4: one red test outside the batch
On November 4, a full verification run of the backend came back with two failures that had nothing to do with the batch being checked. One of them was a voice billing test. It ends a simulated call of about two minutes and asserts that the billed duration stays under a bound. It read:
assert 150 <= 130About 150 seconds measured for a call of about 120. Thirty seconds too many.
The run did not stop on it. The two failures were outside the batch, so they went into the queue as their own phase.
November 6: not flaky, a real bug that hides
On November 6, the phase ran as one session of a chain of four. Claude replayed the test against the code instead of rerunning it until it turned green.
The function that ends a call, finalize_session, did three things in this order: delete the call's room on LiveKit Cloud, wait for the answer, then compute the billed duration as "now minus start". The test did not stub the deletion. It called LiveKit Cloud for real, on a room that did not exist, with the credentials of the local environment. On November 4, that call took about 30 seconds. The billed duration included them.
Then the detail that makes this kind of bug dangerous. Rerun on November 6 without any change, the test passed in 1.67 seconds. LiveKit answered fast that day. A test that fails one day and passes the next with no code change is the textbook definition of flaky, and the usual reflex is to rerun it, see green, and move on. Here the green run proved nothing. It only proved that the bug depends on someone else's latency.
In production, that latency is paid by the user. When LiveKit answers slowly, the user pays for our wait.
A fix the agent did not apply
The session fixed the test: the deletion is now replaced by a fake that records the room, so the test no longer depends on the network. It did not fix the billing. The log says why:
« Niveau 1, à trancher par le CEO : la durée facturée inclut la latence de delete_room_force. »
Our project rules put any change to how a charge is computed at level 1: the agent stops and the CEO decides. No user had reported the defect, which made it tempting to slip the two-line fix into the session. The rule exists for exactly that temptation.
The same entry carried the worse news. The real exposure was unknown. The deletion latency in production was logged at info level, and the root logger keeps only warnings and above, so every one of those lines had been filtered out. We knew the bug could charge seconds that nobody spoke. We could not say how many seconds it had charged.
Juste read the digest of the chain and answered: « oui applique le correctif facturation, et relance la chain ».
The fix is what Claude had proposed: record the end time before deleting the room, compute the billed duration from it, and store that same time as the session's end, so the end time and the billed duration agree. A targeted audit, mandatory on anything that touches payments, came back GO-WITH-FIXES. It confirmed that the duration cannot go negative and cannot refund credits by mistake. It also flagged two older weaknesses for a dedicated session: the call-end route loaded the session without a lock, and the credits shown to the user were recomputed from the duration instead of read from what had actually been debited. On the first point, the audit wrote that a double charge stayed blocked by the idempotency key of the final debit.
That last sentence was wrong.
The worse bug: one minute, two keys
Juste approved the dedicated session the same day: « oui corrige en session dédiée ». It ran later in the chain, and it started, as every session does, by replaying the claims against the code.
Two pieces of code can end and bill a call. The first is the route the app calls when the user hangs up. The second is a background loop that bills running calls minute by minute, so that a call that never hangs up properly still gets billed. Both read the session row. Neither locked it.
The race, as the session wrote it down:
- The route reads the session: N minutes already billed.
- The loop bills minute N+1, under a key of the form
…:tick:{n}. - The route computes its final delta from its stale N and bills the same minute again, under the key
…:end.
Each debit is idempotent on its own key. The two keys are different. So the idempotency we relied on did not protect this case at all. The earlier audit had read "the final debit is idempotent" and concluded "no double charge". Replaying the sequence step by step showed that both were true at the same time as a double charge.
The fix, deployed on November 6 at 12:30 UTC:
- A helper locks the session row with
SELECT … FOR UPDATEand refreshes the caller's copy. The end function takes the lock after its network calls (room deletion, provider reads, exchange rate) and before any debit, then rereads the status under the lock. A second finalizer waits for the first one to commit, then returns the stored result without charging again. - The background loop locks the same row with
SKIP LOCKEDbefore reading what has been billed. A row held by a finalizer is skipped for that cycle. - Lock order is the same everywhere: the session, then the user. No path takes the user first, so no deadlock was introduced.
- The credits shown for a call are now the sum of that call's debits in the ledger, not a recomputation.
- The guest counter is marked once per session before it is incremented, so a replayed end counts once.
Nineteen targeted tests passed, including new ones where a stale snapshot must not charge twice and where the displayed credits must equal the ledger even when a tick was priced off the grid. The audit came back GO-WITH-FIXES with no defect on the lock itself, and its fixes were applied in the same session.
The proof on a real call
Tests are not proof. At 12:47 UTC, Juste made a real, authenticated voice call in Déblo from the published app. The fix is server-side, so the app version did not matter. Claude then read the production rows, read-only:
start 12:42:28 end 12:45:04 clock 156 s duration_seconds 155
transactions 2 (tick:1, tick:3) no :end key credits shown = ledgerWhat that proves:
- The billed duration is 155 seconds for 156 seconds by the clock. The LiveKit wait is no longer billed.
- Two transactions, two distinct keys, no final debit. No double charge.
- The credits shown equal the sum in the ledger.
One reading needs care. Three minutes were billed for 2 minutes 35 seconds. That is the intended rule in this pricing mode: a started minute counts, like with a phone operator. It is not the defect.
What stays open
The past. We proved the behaviour after the fix on November 6. We cannot say how much was overbilled before it. The latency of the room deletion in production on November 4 was never measured, and the lines that would have measured it were filtered out by their log level. So the size of the old exposure stays unknown, and we write it that way instead of guessing a reassuring number.
What a red test is for
A test that fails once and passes on rerun is not noise until someone has explained the failure. This one measured exactly what a user would have paid on a slow day. The rerun turned it green and would have buried the bug for good.
The second lesson is about the audit. It was right on everything it checked, and wrong on one sentence it assumed. "The debit is idempotent" was true for each key. The bug lived between two keys. What found it was not a better audit, but a session that replayed the sequence in order before touching the code.