← Carlos Paparoni  ·  AssemblerCoding
Tres fallas que no dejaron ningún mensaje de error

Los peores errores no lanzan nada. Simplemente dejan de funcionar en silencio.

Tres patrones de cuatro años dentro de una plataforma privada de administración de fondos — unos 11.000 commits, un equipo de siete personas, quince años de historia acumulada. Un permiso que se desactivó solo, consultas que se multiplicaron sin que nadie lo notara, y una compuerta de cobertura que había dejado de medir cualquier cosa. Los detalles del cliente se omiten; lo que se transfiere es el patrón.

01
Un renombre desarmó un permiso
02
N+1, encontrados con método
03
Una compuerta que aprobaba todo

01 — Control de acceso

Una anotación de queryset desactivó un permiso de campo

La plataforma enmascaraba campos sensibles por configuración y no por código: una estructura JSON nombraba los modelos y campos sensibles, y un mixin la consultaba al renderizar, verificando si el usuario actual tenía el permiso correspondiente a ese campo.

Esa indirección es buen diseño. También significa que el control de seguridad calza contra una cadena de texto — y una cadena es algo que una refactorización ajena puede cambiar.

Más tarde una columna se renombró a una anotación calculada, para que el valor ordenable incluyera un sufijo. Cambio razonable, revisado, desplegado. Pero la capa de enmascaramiento buscaba la expresión de valor de la columna dentro de la lista de campos sensibles, y el nombre de la anotación no estaba ahí. Sin excepción, sin advertencia, sin prueba en rojo. El campo simplemente se mostró en claro a usuarios que nunca tuvieron permiso para verlo.

# El control depende de un nombre...
SENSIBLES = {"Cliente": {"razon_social": {"ui": "total"}}}

# ...y esta refactorización lo saca del alcance.
# Antes — calza con SENSIBLES["Cliente"]["razon_social"]
columnas = {"Razón social": "razon_social"}

# Después — ordena bien, no enmascara nada, no lanza nada
qs = qs.annotate(razon_social_display=Concat("razon_social", V(" "), "sufijo"))
columnas = {"Razón social": "razon_social_display"}

Por qué es una clase de error y no un incidente

Cada superficie enmascarada del sistema estaba a un renombre de correr la misma suerte, y nada en el código lo habría advertido. El framework no tiene forma de saber que razon_social_display deriva de un campo protegido: esa relación existe solo en la cabeza de quien programa.

La corrección que importa no es parchar esa columna. Es hacer que la falla sea ruidosa:

  • Afirmar sobre el conjunto, no sobre los miembros. Una prueba que verifica "esta columna está enmascarada" sigue pasando para siempre mientras se filtran diez columnas nuevas. Una prueba que afirma el conjunto completo de columnas enmascaradas para un usuario sin privilegios falla en cuanto ese conjunto cambia — incluso cuando cambia por accidente.
  • Tratar las columnas derivadas como contaminadas. Si una anotación lee un campo protegido, la anotación está protegida. Declarar esa relación de forma explícita para que la verificación pueda seguirla.
  • Probar el caso negativo. Casi todas las pruebas de permisos verifican que el usuario autorizado ve el dato. La afirmación interesante es la contraria.
Regla transferible

Un control de seguridad que calza contra un identificador es tan fuerte como la estabilidad de ese identificador — y los identificadores los refactoriza gente que no tiene idea de que un permiso depende de ellos. Si un renombre puede desarmar la verificación, la verificación necesita una prueba que vigile el límite, no el caso puntual.

02 — Rendimiento

Buscar consultas N+1 a propósito en lugar de por casualidad

A lo largo de aproximadamente un año cerré unos veinte tickets de N+1 y consultas lentas en búsquedas de inversionistas, creación de flujos de capital, exportaciones CSV, una ruta de ingesta desde Salesforce y una traza de auditoría. Tratarlos como veinte errores sin relación habría significado veinte investigaciones. Tratarlos como un solo problema produjo un procedimiento repetible.

El ciclo

  • Capturar. pg_stat_statements vía PGHero, ordenando por tiempo total y no por promedio — la consulta que más cuesta suele ser barata y ejecutarse todo el tiempo. Reiniciar estadísticas, ejercitar el flujo, volver a leer.
  • Reproducir. shell_plus --print-sql para una cadena del ORM; la barra de depuración cuando importa el contexto de la petición.
  • Diagnosticar. EXPLAIN (ANALYZE, BUFFERS), leyendo señales concretas: recorridos secuenciales sobre tablas grandes, estimaciones de filas muy lejos de las reales, ordenamientos que se derraman a disco y multiplicación de filas tras un join muchos-a-muchos.
  • Corregir y verificar que el plan cambió — no solo que la página se sintió más rápida.

Dos correcciones cubrieron casi todo

Una anotación muchos-a-muchos inflando la consulta externa. Agregar sobre un M2M en el nivel superior fuerza un join, el join multiplica filas y el ORM lo compensa con un DISTINCT sobre todo el resultado. Mover la agregación a una subconsulta correlacionada mantiene el join dentro de la subconsulta y la consulta externa limpia.

# Multiplicación de filas y luego un DISTINCT para deshacerla
qs = Cliente.objects.annotate(_etq=StringAgg("etiquetas__nombre", ", "))

# Subconsulta correlacionada — la consulta externa queda plana
etq = (Etiqueta.objects.filter(clientes=OuterRef("id"))
       .order_by().values("clientes")
       .annotate(v=StringAgg("nombre", ", ", distinct=True, default=""))
       .values("v"))
qs = Cliente.objects.annotate(_etq=Coalesce(Subquery(etq), Value("")))

La serialización recorriendo relaciones. Cualquier cosa que renderice una fila completa — model_to_dict, un constructor de columnas de exportación, una instantánea de auditoría — atraviesa todas las relaciones que encuentra. Una consulta por fila por relación, invisible hasta que la tabla crece.

Dejar la corrección asegurada

Una prueba de regresión que afirma un número exacto de consultas es frágil: falla ante cualquier cambio ajeno. La afirmación duradera es la invariancia: el número de consultas no debe crecer cuando crece el número de filas.

Dos cosas que me costaron tiempo, y vale decirlas sin rodeos:

  • Perfilar contra un volcado real anonimizado. Una base de datos de prueba pequeña esconde justo la latencia que uno busca; los planes cambian de forma a volúmenes de producción.
  • Que un error deje de aparecer no prueba que esté corregido. Reescribir una consulta la mueve a una huella nueva en el rastreador de errores, así que el incidente viejo deja de dispararse haya mejorado o no. Verificar con tiempos medidos de iteración, no viendo un tablero quedarse en silencio.

03 — Pruebas

La compuerta de cobertura que aprobaría un PR sin pruebas

Migré 272 archivos de prueba a árboles separados de unit/ e integration/, dividí CI en trabajos paralelos y agregué reporte de cobertura — un objetivo que el equipo arrastraba sin cumplir desde hacía un año. El frontend adoptó después la misma estructura. Esa parte salió bien, y no es la parte interesante.

Lo interesante es que la compuerta resultante no hacía lo que todos creían que hacía.

Falla uno: un total no dice nada sobre el diff

CI bloqueaba un pull request cuando la cobertura total caía por debajo de un umbral. En una base de código grande, código nuevo sin pruebas apenas mueve el total — así que un PR podía agregar cientos de líneas sin probar y pasar en verde. La compuerta medía la historia del repositorio, no el cambio bajo revisión.

La corrección es medir el diff: cruzar el reporte de cobertura contra git diff y fallar cuando las líneas modificadas quedan bajo el umbral. Además traslada la discusión de "nuestra cobertura es baja" a "este cambio no está probado", que es una conversación que un revisor sí puede tener.

Falla dos: activar cobertura de ramas mueve la compuerta en silencio

El comentario de CI reportaba cobertura de ramas como N/A%. Activarla parece un cambio de una línea de configuración. No lo es: la verificación del umbral compara contra un único porcentaje mezclado, así que agregar ramas al denominador cambia el número que la compuerta está comparando. Medido sobre las suites reales:

SuiteArchivosLíneasRamasMezcladoCompuerta
Unitarias49582,18%62,56%78,58%80% fallaría
Integración58377,84%50,07%72,94%76% fallaría

Si se activa la bandera sin recalibrar, CI se pone en rojo sobre una base de código que no cambió. Si se activa en el otro sentido — una suite que omite vistas, admin y módulos de tareas mide menos archivos y puntúa más alto — la compuerta se vuelve más laxa en silencio mientras el número de la insignia sube.

Regla transferible

Una compuerta de cobertura mide exactamente lo que uno configuró que midiera, que rara vez es lo que el equipo cree que mide. Antes de confiar en un umbral, preguntar qué hay en el denominador, si cubre el cambio o el repositorio, y qué le pasa al número si alguien mueve una bandera. Una compuerta que nadie ha auditado es un rito, no un control.

El hilo común

Qué tienen en común estas tres

Ninguna lanzó un error. El permiso devolvió datos, las consultas devolvieron filas, el pipeline devolvió verde. Cada falla se veía exactamente igual que el éxito desde afuera, y cada una apareció porque alguien decidió ir a mirar.

Ese es el argumento a favor de las pasadas adversariales sobre el trabajo propio: auditar la capa de enmascaramiento, perfilar contra datos reales, leer la configuración de CI en vez de confiar en la insignia. Las fallas que se anuncian solas son las baratas.

← Carlos Paparoni  ·  AssemblerCoding
Three failures that left no error message

The worst bugs don't raise anything. They just quietly stop working.

Three patterns from four years inside a private fund-administration platform — roughly 11,000 commits, a seven-person team, fifteen years of accumulated history. A permission that switched itself off, queries that multiplied without anyone noticing, and a coverage gate that had stopped measuring anything. Client details are omitted throughout; what transfers is the pattern.

01
A rename disarmed a permission
02
N+1s, found systematically
03
A coverage gate that passed everything

01 — Access control

A queryset annotation switched off a field permission

The platform masked sensitive fields through configuration rather than code: a JSON structure named the models and fields that were sensitive, and a mixin consulted it at render time, checking whether the current user held the matching per-field permission.

That indirection is good design. It also means the security control matches on a string — and a string is something an unrelated refactor can change.

A column was later renamed to a computed annotation, so the sorted value could include a suffix. Reasonable change, reviewed, shipped. But the masking layer looked up the column's value expression against the sensitive-field list, and the annotation's name wasn't in it. No exception, no warning, no failing test. The field simply rendered in the clear for users who had never been granted permission to see it.

# The control is keyed on a name...
SENSITIVE = {"Customer": {"legal_name": {"ui": "total"}}}

# ...and this refactor renames it out of scope.
# Before — matches SENSITIVE["Customer"]["legal_name"]
columns = {"Legal Name": "legal_name"}

# After — sorts correctly, masks nothing, raises nothing
qs = qs.annotate(legal_name_display=Concat("legal_name", V(" "), "suffix"))
columns = {"Legal Name": "legal_name_display"}

What makes it a class, not an incident

Every masked surface in the system was one rename away from the same fate, and nothing in the codebase would have said so. The framework has no way to know that legal_name_display is derived from a protected field — the relationship exists only in the developer's head.

The fix that matters is not patching the one column. It is making the failure loud:

  • Assert on the set, not the members. A test that checks "this column is masked" passes forever while ten new columns leak. A test that asserts the complete set of masked columns for an unprivileged user fails the moment the set changes — including when it changes by accident.
  • Treat derived columns as tainted. If an annotation reads a protected field, the annotation is protected. Declare that relationship explicitly so the check can follow it.
  • Test the negative case. Most permission tests assert that the permitted user sees the data. The interesting assertion is the other one.
Transferable rule

A security control that matches on an identifier is only as strong as that identifier's stability — and identifiers are refactored by people who have no idea a permission depends on them. If a rename can disarm your check, the check needs a test that watches the boundary, not the instance.

02 — Performance

Finding N+1 queries on purpose instead of by accident

Over about a year I closed roughly twenty N+1 and slow-query tickets across investor lookups, capital-flow creation, CSV exports, a Salesforce ingest path and an audit trail. Treating them as twenty unrelated bugs would have taken twenty investigations. Treating them as one problem produced a repeatable procedure.

The loop

  • Capture. pg_stat_statements through PGHero, sorted by total time rather than mean — the query that costs you the most is usually cheap and run constantly. Reset stats, exercise the flow, read again.
  • Reproduce. shell_plus --print-sql for an ORM chain, the debug toolbar when in-request context matters.
  • Diagnose. EXPLAIN (ANALYZE, BUFFERS), reading for a specific set of signals: sequential scans on large tables, row estimates far off actual, sorts spilling to disk, and row multiplication after a many-to-many join.
  • Fix, then verify the plan changed — not just that the page felt faster.

Two fixes covered most of it

A many-to-many annotation inflating the outer query. Aggregating across an M2M at the top level forces a join, the join multiplies rows, and the ORM compensates with DISTINCT over the whole result. Moving the aggregate into a correlated subquery keeps the join inside the subquery and the outer query clean.

# Row multiplication, then a DISTINCT to undo it
qs = Customer.objects.annotate(_tags=StringAgg("tags__name", ", "))

# Correlated subquery — outer query stays flat
tags = (Tag.objects.filter(customers=OuterRef("id"))
        .order_by().values("customers")
        .annotate(v=StringAgg("name", ", ", distinct=True, default=""))
        .values("v"))
qs = Customer.objects.annotate(_tags=Coalesce(Subquery(tags), Value("")))

Serialization walking relations. Anything that renders a whole row — model_to_dict, an export column builder, an audit snapshot — traverses every relation it finds. One query per row per relation, invisible until the table grows.

Locking the fix in

A regression test that asserts an exact query count is brittle: it fails on every unrelated change. The durable assertion is invariance — the query count must not grow when the row count grows.

Two things that cost me time, worth stating plainly:

  • Profile against a real anonymized dump. A small test database hides the latency you are hunting; plans change shape at production volumes.
  • An error going quiet is not proof of a fix. Reshaping a query moves it to a new fingerprint in the error tracker, so the old issue stops firing whether or not it got faster. Verify with measured iteration timing, not by watching a dashboard go silent.

03 — Testing

The coverage gate that would pass a PR with no tests

I migrated 272 test files into separate unit/ and integration/ trees, split CI into parallel jobs, and added coverage reporting — an objective the team had carried unmet for a year. The frontend adopted the same structure afterwards. That part went well, and it is not the interesting part.

The interesting part is that the gate it produced did not do what everyone believed it did.

Failure one: a total says nothing about the diff

CI blocked a pull request when overall coverage fell below a threshold. On a large codebase, new code with zero tests barely moves the total — so a PR could add hundreds of untested lines and pass a green check. The gate measured the codebase's history, not the change under review.

The fix is to measure the diff: cross-reference the coverage report against git diff and fail on changed lines below threshold. It also relocates the argument from "our coverage is too low" to "this change is untested," which is a conversation a reviewer can actually have.

Failure two: turning on branch coverage silently moves the gate

The CI comment reported branch coverage as N/A%. Enabling it looks like a one-line config change. It isn't: the threshold check compares against a single blended percentage, so adding branches to the denominator changes the number the gate is comparing. Measured on the real suites:

SuiteFilesLineBranchBlendedGate
Unit49582.18%62.56%78.58%80% would fail
Integration58377.84%50.07%72.94%76% would fail

Flip the flag without re-baselining and CI goes red on an unchanged codebase. Flip it in the other direction — a suite that omits views, admin and task modules measures fewer files and scores higher — and the gate quietly gets easier while the number on the badge goes up.

Transferable rule

A coverage gate measures exactly what you configured it to measure, which is rarely what the team thinks it measures. Before trusting a threshold, ask what is in the denominator, whether it covers the change or the codebase, and what happens to the number if someone toggles a flag. A gate nobody has audited is a ritual, not a control.

The through-line

What these three have in common

None of them raised an error. The permission returned data, the queries returned rows, the pipeline returned green. Each failure looked exactly like success from the outside, and each was found by someone deciding to go and look.

That is the argument for adversarial passes over your own work — auditing the masking layer, profiling against real data, reading the CI config instead of trusting the badge. The failures that announce themselves are the cheap ones.