El incidente
MigrateFelix tiene un guardián delante de los prompts que van al modelo. Valida la entrada, y su trabajo es rechazar lo que no debería llegar a la inferencia.
Durante un periodo, ese guardián corrió con su flag activado y sin API key configurada. Al no poder llamar al servicio del que depende, tomó la rama de fallo abierto: escribió un warning en el log y dejó pasar el prompt. Todos los prompts. Durante todo ese tiempo.
El sistema estaba, según su propia configuración, protegido. El flag decía que sí. El log decía que el guardián se había ejecutado. Un panel que contara ejecuciones del guardián habría mostrado actividad normal.
No lo encontré leyendo el código. El código, leído, parece correcto: hay un guardián, se invoca, tiene manejo de errores. Lo encontré leyendo el entorno del proceso vivo — qué variables tenía realmente cargadas la cosa que estaba corriendo, no qué variables decían los defaults del repositorio que debería tener.
Por qué es peor que no tener nada
Esta es la parte que quiero argumentar bien, porque suena a exageración retórica y no lo es.
Un sistema sin guardrail tiene una propiedad valiosa: todo el mundo sabe que no tiene guardrail. Las decisiones que se toman encima son conscientes de eso. Nadie relaja una revisión manual porque "ya lo filtra el guardián". Nadie amplía qué entradas se aceptan apoyándose en una validación que no existe. El riesgo está a la vista y se compensa en otro sitio.
Un sistema con un guardrail que falla abierto tiene el mismo riesgo real y ninguna de esas compensaciones. Peor: tiene evidencia activa en contra de descubrirlo. Hay logs. Hay un flag en true. Si alguien pregunta "¿validamos las entradas?", la respuesta documentada, verificable y completamente equivocada es que sí.
La seguridad de un sistema no es la suma de sus controles. Es la suma de sus controles menos lo que la gente asume de más por creer que están ahí. Un control que falla abierto en silencio puntúa negativo en esa cuenta.
La misma forma, tres veces, en el mismo producto
Lo que me hizo escribir esto no fue el guardián. Fue darme cuenta de que era la tercera vez.
Uno. El guardián de prompts, arriba: activado, sin credencial, fallando abierto y registrándolo como funcionamiento normal.
Dos. El webhook de pagos. Durante meses, ningún evento de Stripe llegó a procesarse: todos morían en la verificación de firma, y el manejador se tragaba el fallo y devolvía 200. Stripe, que reintenta cuando recibe un 5xx, no reintentó nunca — porque le estábamos diciendo que todo había ido bien. El problema solo se hizo visible cuando cambié el manejador para devolver 5xx ante un fallo. El fallo llevaba meses ocurriendo; lo que faltaba no era detección, era dejar de mentir en la respuesta.
Tres. Treinta trabajos en el historial con estado terminal de éxito y sin ningún artefacto asociado. completed sin módulo entregado. Invisible porque nada lo comprobaba.
Tres subsistemas distintos, escritos en momentos distintos, con la misma estructura: ante un fallo, reportar éxito y seguir. Ninguno era un descuido puntual. Los tres eran la elección por defecto que toma un programador cuando quiere que el sistema sea robusto y confunde "robusto" con "que no se caiga".
La elección que nadie recuerda haber tomado
Cuando escribes esto:
try:
resultado = guardian.validar(prompt)
except Exception:
logger.warning("El guardián no está disponible; continuando")
resultado = PERMITIR
…no sientes que estés tomando una decisión de seguridad. Sientes que estás siendo prudente: no quieres que un servicio auxiliar caído tumbe el producto entero. Es un instinto correcto en el sitio equivocado.
Para un componente auxiliar — una métrica, un envío de correo de cortesía, un caché — fallar abierto es lo correcto. Para un control, fallar abierto convierte la caída del control en una desactivación silenciosa del control. Y los controles se caen precisamente cuando el entorno está raro, que es cuando más los necesitas.
La regla que aplico ahora: si el componente existe para decir que no, no puede tener un camino que diga que sí por accidente. Si de verdad no puedo permitirme que su caída bloquee el producto, entonces esa es una decisión de negocio que se toma explícitamente, se escribe, y sobre todo se hace visible en el momento en que ocurre — no se esconde en un warning que nadie lee.
Qué cambié
Arrancar en ruidoso, no en permisivo. Un control cuya configuración está incompleta no debe arrancar en modo permisivo escribiendo un warning. O falla cerrado, o se niega a arrancar. Las dos opciones son molestas y las dos son honestas; el warning no es ninguna de las dos, porque un warning es un mensaje para alguien que ya está mirando, y nadie está mirando.
Comprobar el proceso vivo, no los defaults del repositorio. Este es el hábito concreto que sacó el incidente a la luz y el que más recomiendo. Los defaults del código te dicen qué debería haber. El entorno del proceso te dice qué hay. Entre esas dos cosas caben meses.
Que el estado sea una consecuencia, no una afirmación. Los treinta trabajos fantasma no se arreglaron pidiéndole al servicio que comprobara mejor: ahora una restricción de base de datos impide que exista un trabajo con estado terminal de éxito sin artefacto. La regla dejó de ser algo que el código recuerda cumplir y pasó a ser algo que el código no puede incumplir.
Evaluar contra casos reales, incluyendo el caso "el control está roto". Una batería de evaluación de prompts que solo prueba entradas buenas y entradas maliciosas no habría detectado nada de esto, porque el guardián respondía correctamente cuando se le podía preguntar. El caso que faltaba era el de la dependencia caída. Ese es el caso que hay que probar, y es el que casi nunca está en la suite.
Lo que me llevo
Publicar el fallo de seguridad de tu propio producto es incómodo, y lo hago porque el patrón es demasiado común para dejarlo sin nombre. Tres veces en un solo producto, escritas por la misma persona, sin darse cuenta ninguna de las tres.
Si te llevas una sola frase: un control que no puede fallar de forma visible no es un control, es una creencia. Y merece la pena ir a mirar hoy mismo qué variables de entorno tiene realmente cargado tu proceso en producción, porque el código del repositorio no te lo va a decir.