Por Juste Thales Gnimavo (CEO, ZeroSuite) y Claude Opus 5.5 (Claude Code)
Déblo Duo es una app de interpretación en tiempo real: cada persona habla su idioma y oye a la otra en el suyo. Por qué existe Déblo Duo presenta el producto. Duo funciona sobre el mismo backend que Déblo, nuestra app de aprendizaje, y comparte su tabla de sesiones de voz, pero el defecto de este artículo estaba en la ruta de llamadas de voz propia de Déblo, la que factura una llamada por minuto. Este artículo cuenta un test rojo que cualquiera habría llamado inestable, el defecto de facturación que señalaba y un segundo defecto, más grave, que encontramos al repasar el código. Todo ocurrió entre el 4 y el 6 de noviembre de 2026.
4 de noviembre: un test rojo fuera del lote
El 4 de noviembre, una verificación completa del backend devolvió dos fallos que no tenían nada que ver con el lote revisado. Uno era un test de facturación de voz. Termina una llamada simulada de unos dos minutos y comprueba que la duración facturada se queda bajo un límite. Decía:
assert 150 <= 130Unos 150 segundos medidos para una llamada de unos 120. Treinta segundos de más.
La verificación no se detuvo ahí. Los dos fallos estaban fuera del lote, así que entraron en la cola como una fase aparte.
6 de noviembre: no era inestable, era un defecto real que se esconde
El 6 de noviembre, la fase corrió como una sesión de una cadena de cuatro. Claude repasó el test contra el código en lugar de relanzarlo hasta que saliera verde.
La función que termina una llamada, finalize_session, hacía tres cosas en este orden: borrar la sala de la llamada en LiveKit Cloud, esperar la respuesta y luego calcular la duración facturada como «ahora menos el inicio». El test no simulaba el borrado. Llamaba de verdad a LiveKit Cloud, sobre una sala que no existía, con las credenciales del entorno local. El 4 de noviembre esa llamada tardó unos 30 segundos. La duración facturada los incluía.
Luego, el detalle que hace peligroso este tipo de defecto. Relanzado el 6 de noviembre sin ningún cambio, el test pasó en 1,67 segundos. Ese día LiveKit respondía rápido. Un test que falla un día y pasa al siguiente sin cambios de código es la definición misma de un test inestable, y el reflejo habitual es relanzarlo, ver verde y seguir. Aquí el verde no probaba nada. Solo probaba que el defecto depende de la latencia de otro.
En producción, esa latencia la paga el usuario. Cuando LiveKit responde despacio, el usuario paga nuestra espera.
Una corrección que el agente no aplicó
La sesión corrigió el test: el borrado se sustituye ahora por un falso que registra la sala, y el test ya no depende de la red. No corrigió la facturación. El log dice por qué:
« Niveau 1, à trancher par le CEO : la durée facturée inclut la latence de delete_room_force. »
Nuestras reglas de proyecto ponen en el nivel 1 cualquier cambio en el cálculo de un cobro: el agente se detiene y el CEO decide. Ningún usuario había señalado el defecto, lo que hacía tentador meter la corrección de dos líneas en la sesión. La regla existe justo para esa tentación.
La misma entrada traía la mala noticia. La exposición real era desconocida. La latencia del borrado en producción se registraba en nivel info, y el logger raíz solo conserva advertencias o niveles superiores, así que todas esas líneas se habían filtrado. Sabíamos que el defecto podía cobrar segundos que nadie había hablado. No podíamos decir cuántos había cobrado.
Juste leyó el resumen de la cadena y respondió: « oui applique le correctif facturation, et relance la chain ».
La corrección es la que Claude había propuesto: registrar la hora de fin antes de borrar la sala, calcular la duración facturada a partir de ella y guardar esa misma hora como fin de la sesión, para que la hora de fin y la duración facturada coincidan. Una auditoría específica, obligatoria en todo lo que toca pagos, devolvió GO-WITH-FIXES. Confirmó que la duración no puede volverse negativa ni devolver créditos por error. También señaló dos debilidades más antiguas para una sesión dedicada: la ruta de fin de llamada cargaba la sesión sin bloqueo, y los créditos mostrados al usuario se recalculaban desde la duración en lugar de leerse de lo que realmente se había cobrado. Sobre el primer punto, la auditoría escribió que un doble cobro seguía bloqueado por la clave de idempotencia del cobro final.
Esa última frase era falsa.
El defecto peor: un minuto, dos claves
Juste aprobó la sesión dedicada el mismo día: « oui corrige en session dédiée ». Corrió más adelante en la cadena y empezó, como toda sesión, repasando las afirmaciones contra el código.
Dos piezas de código pueden terminar y facturar una llamada. La primera es la ruta que la app llama cuando el usuario cuelga. La segunda es un bucle en segundo plano que factura las llamadas en curso minuto a minuto, para que una llamada que nunca cuelga bien se facture igualmente. Las dos leían la fila de la sesión. Ninguna la bloqueaba.
La carrera, tal como la escribió la sesión:
- La ruta lee la sesión: N minutos ya facturados.
- El bucle factura el minuto N+1, bajo una clave de la forma
…:tick:{n}. - La ruta calcula su delta final a partir de su N desfasado y vuelve a facturar el mismo minuto, bajo la clave
…:end.
Cada cobro es idempotente sobre su propia clave. Las dos claves son distintas. Así que la idempotencia en la que confiábamos no protegía este caso en absoluto. La auditoría anterior había leído «el cobro final es idempotente» y había concluido «no hay doble cobro». Repasar la secuencia paso a paso mostró que ambas cosas podían ser ciertas a la vez que un doble cobro.
La corrección, desplegada el 6 de noviembre a las 12:30 UTC:
- Una función bloquea la fila de la sesión con
SELECT … FOR UPDATEy refresca la copia de quien la llama. La función de fin toma el bloqueo después de sus llamadas de red (borrado de la sala, lecturas del proveedor, tipo de cambio) y antes de cualquier cobro, y luego relee el estado bajo bloqueo. Un segundo finalizador espera el commit del primero y devuelve el resultado guardado sin volver a cobrar. - El bucle en segundo plano bloquea la misma fila con
SKIP LOCKEDantes de leer lo facturado. Una fila retenida por un finalizador se salta en ese ciclo. - El orden de los bloqueos es el mismo en todas partes: la sesión, luego el usuario. Ninguna ruta toma primero al usuario, así que no se introdujo ningún interbloqueo.
- Los créditos mostrados para una llamada son ahora la suma de sus cobros en el libro mayor, no un recálculo.
- El contador de invitados se marca una sola vez por sesión antes de incrementarse: un fin repetido cuenta una vez.
Diecinueve tests específicos pasaron, entre ellos tests nuevos en los que una instantánea desfasada no debe cobrar dos veces y en los que los créditos mostrados deben igualar el libro mayor aunque un tick tenga un precio fuera de tarifa. La auditoría devolvió GO-WITH-FIXES, sin defecto en el bloqueo en sí, y sus correcciones se aplicaron en la misma sesión.
La prueba en una llamada real
Los tests no son una prueba. A las 12:47 UTC, Juste hizo una llamada de voz real y autenticada en Déblo, desde la app publicada. La corrección está en el servidor, así que la versión de la app no cambiaba nada. Después, Claude leyó las filas de producción, en solo lectura:
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 = ledgerLo que eso prueba:
- La duración facturada es de 155 segundos para 156 segundos de reloj. La espera de LiveKit ya no se factura.
- Dos transacciones, dos claves distintas, ningún cobro final. No hay doble cobro.
- Los créditos mostrados igualan la suma del libro mayor.
Una lectura requiere cuidado. Se facturaron tres minutos por 2 minutos y 35 segundos. Es la regla prevista en este modo de tarificación: un minuto empezado cuenta, como con un operador telefónico. No es el defecto.
Lo que sigue abierto
El pasado. Probamos el comportamiento después de la corrección, el 6 de noviembre. No podemos decir cuánto se cobró de más antes. La latencia del borrado de salas en producción el 4 de noviembre nunca se midió, y las líneas que la habrían medido se filtraron por su nivel de log. El tamaño de la exposición anterior sigue siendo desconocido, y lo escribimos así en lugar de adivinar una cifra tranquilizadora.
Para qué sirve un test rojo
Un test que falla una vez y pasa al relanzarlo no es ruido mientras nadie haya explicado el fallo. Este medía exactamente lo que un usuario habría pagado un día lento. El relanzamiento lo puso en verde y habría enterrado el defecto para siempre.
La segunda lección es sobre la auditoría. Acertó en todo lo que comprobó y se equivocó en una frase que dio por supuesta. «El cobro es idempotente» era cierto para cada clave. El defecto vivía entre dos claves. Lo que lo encontró no fue una auditoría mejor, sino una sesión que repasó la secuencia en orden antes de tocar el código.