Inspiration

Es jueves, cuatro de la tarde, en un taller metalmecánico de 28 empleados en Apodaca, Nuevo León. Lupita es la única persona de administración, arriba de ella no hay tesorería, y tiene 92 transferencias a proveedores que mandar antes del corte del banco. Le llega un WhatsApp del proveedor de aceros: "cambiamos de cuenta por temas administrativos, aquí va la nueva CLABE". Pega la CLABE, aprieta enviar, y salen 537,960.97 pesos. Son las 4:02. El mensaje no era del proveedor: le secuestraron el WhatsApp.

En México ese dinero no regresa. Una orden de transferencia aceptada es firme, irrevocable, exigible y oponible frente a terceros (artículo 11 de la Ley de Sistemas de Pagos). Un SPEI no es un pago con tarjeta: no hay contracargo, y de los pesos reclamados por fraude en el primer trimestre de 2026 los bancos devolvieron el 24.3 por ciento.

Y hay una segunda pérdida que no necesita ladrón. Si el SAT publica a tu proveedor en la lista del artículo 69-B, las operaciones amparadas por sus facturas "no producen ni produjeron efecto fiscal alguno". El tiempo pasado es todo el problema: las deducciones y el IVA que ya tomaste se anulan hacia atrás. De cada 100,000 pesos de subtotal ya deducido se revierten 46,000 entre ISR e IVA, y la exposición la crea una publicación que ocurre después de que el dinero ya salió. La lista se movió en 33 fechas en los doce meses al 31 de julio de 2026, una cada once días, con 973 contribuyentes que pasaron a definitivo. Desde el 1 de enero de 2026 el artículo 49 Bis arranca además un reloj de treinta días contra el comprador, y el sello digital que se restringe al final es el del comprador.

Las dos pérdidas se podían ver antes de dar clic. Nadie las ve porque en el minuto entre aprobar un pago y enviarlo no hay nadie: el contador revisa la lista del SAT una vez al mes, el banco ejecuta la transferencia sin verificar quién recibe el dinero, y Lupita tiene un PDF, un WhatsApp y el token del banco. La consulta al SAT es pública, gratuita y toma un minuto. Lo difícil nunca es la consulta: es el momento. Ahí vive SentryOne.

What it does

App en producción: sentryone-one.vercel.app (modo sin conexión: ?data=mock). Código: github.com/garzario/CapitalOneHackMTY, release v1.0.0.

SentryOne es el último control antes de que un pago a proveedor sea irrevocable. Toma las facturas (CFDI) que la empresa ya tiene y, en el minuto antes de enviar, cruza cada pago con tres fuentes que hoy nadie lee juntas:

1. La lista del SAT. Las dos listas, 69-B y 49 Bis, con todas sus versiones fechadas. Si el proveedor está publicado en definitivo, la línea se cancela sola con evento en la bitácora y solo el dueño de la empresa puede reabrirla, con su nombre y una razón. Cuando el SAT publica una versión nueva, SentryOne reproduce la bitácora de la propia empresa y pone en pesos la pérdida retroactiva: qué facturas ya se pagaron, la base deducida y el ISR y el IVA en riesgo. En la corrida del demo: 878,592.59 pesos de base y 404,152.59 de exposición, y una línea pendiente que se cancela sola mientras nadie la está viendo.

2. El historial de la cuenta. A qué CLABE le ha pagado siempre la empresa a ese proveedor, cuántas veces, y en qué plaza se abrió: la CLABE codifica la ciudad de apertura y SentryOne la traduce con el catálogo de plazas de Banxico. Una cuenta nueva que difiere en dos dígitos de la que se pagó 52 veces y que se abrió en la Ciudad de México cuando el proveedor siempre ha cobrado en Apodaca queda en rojo, con las posiciones que difieren y las dos plazas escritas en la evidencia. Encima, una red de beneficiarios entre empresas dice si alguien más ha pagado ese mismo par proveedor-cuenta y desde cuándo.

3. El registro de Banxico. México no tiene confirmación de beneficiario. SentryOne la construye con lo que sí existe: manda un centavo a la cuenta nueva dentro de la misma corrida, la clave de rastreo regresa del riel y no de un teclado, y lee el Comprobante Electrónico de Pago que Banxico firma por cada SPEI, que trae el nombre del titular. Compara ese nombre con la razón social de la factura que se está pagando. Si el dueño es otra empresa, el pago de 537 mil se bloquea y el dinero se queda en casa.

Cada línea de la corrida sale con un nivel (confiable, precaución o alerta), un estado (retenido, cancelado o enviado) y la acción que el motor propone: retener, verificar o liberar. Nunca un porcentaje: el nivel llega siempre con los hallazgos que lo produjeron debajo, y una carta de una página por pago resume las señales para que finanzas decida con evidencia. Seis controles independientes por pago (lista del SAT, forense de CLABE con dígito verificador, banco y plaza, facturas duplicadas, cambio de comportamiento del proveedor, beneficiario contra CEP, conciliación contra el espejo bancario), y un control que no corrió dice qué le faltó, para que el silencio nunca se lea como un pago limpio.

Recorrido guiado, y la llamada te llega a ti. Un botón "Recorrido" en la barra de la app abre un paso a paso sobre las pantallas reales: la visión, la corrida, la captura al chat, la cuenta y su plaza, el centavo y el comprobante de Banxico, el SAT, la corrida que sale y quién decide. El último paso pide tu número: SentryOne te llama como si fueras el dueño de la empresa, te dice que hay una instrucción por 537,960.97 pesos a una cuenta nueva del proveedor de aceros, abierta en otra plaza, y te pregunta si la retiene hasta verificar o la libera bajo tu nombre. Lo que contestes con tu voz mueve la línea en la pantalla en ese momento, con tu decisión firmada como "Visitante", y diez minutos después la línea vuelve sola a su estado. Tu número no se guarda: solo un hash con sal, y la llamada se disclosa como línea automática desde la primera frase.

La instrucción entra como de verdad llega: Lupita arrastra la captura de WhatsApp al chat, el asistente transcribe la CLABE, corre los seis controles y contesta con la tarjeta de nivel y evidencia. Si pregunta por qué una línea está en rojo, responde con las lecturas que hizo a la vista y termina en una propuesta con el cuerpo exacto de la petición y un botón. Cuando hay que confirmar una cuenta nueva por teléfono, un agente de voz en español le llama al proveedor, confirma solo el cambio y los últimos cuatro dígitos, no pide ningún dato y nunca libera un pago. Los pagos liberados salen por el riel bancario con una persona que confirma con su nombre: 86 líneas con clave de rastreo, recibo y constancia PDF cada una, y las retenidas quedan fuera con su razón escrita. Y la app reporta su propia exactitud sobre casos etiquetados que quien escribió los detectores nunca leyó: precisión y recall por control y por nivel, con el número de casos junto a cada tasa.

How we built it

Nueve ADR en docs/adr, la API en docs/09-api.md, el mercado y el modelo con fuentes en docs/04 y docs/05.

Un monorepo de workspaces en bun y TypeScript, un solo runtime para la API, las pruebas, el generador de datos y las migraciones: doce paquetes, dos aplicaciones, nueve decisiones de arquitectura escritas como ADR, 15 migraciones, 28 endpoints documentados, diez pantallas y 2,594 pruebas en 138 archivos, ninguna de las cuales abre un socket. La regla que ordena todo: la inteligencia es un paquete puro sin red ni base de datos (packages/core), y cada proveedor externo entra por un paquete propio con un contrato que dice exactamente qué demuestra y qué no.

Capital One: Nessie es el banco de la empresa

Nessie no es un adorno del reto: es el espejo bancario del taller. packages/nessie siembra la cuenta de la empresa con nuestra llave y la valida con una escritura, y packages/rail mueve dinero por ahí y nada más que lo que SentryOne ya tiene: el centavo de la prueba de beneficiario y el monto exacto de cada instrucción liberada, a la cuenta que esa instrucción nombra. La clave de rastreo regresa del riel y no de un teclado, la conciliación (control seis) lee las salidas del espejo y las cruza contra la corrida, y POST /run/:id/execute es idempotente: 86 líneas salen una sola vez aunque el botón se apriete dos. El producto está pensado como el control que un banco como Capital One embebe en su banca empresarial, y Nessie es la prueba de que cabe ahí.

Gemini API: un modelo que lee y propone, y no puede decidir

Gemini aparece en dos lugares. Primero, packages/extract, el único paquete autorizado a llamar a un modelo, hace OCR de la captura de WhatsApp con un esquema de respuesta de seis campos y ninguno donde quepa un veredicto, y una prueba lee el código fuente del paquete y falla si un módulo publicado siquiera menciona decide, score o recommend. Segundo, el asistente: Gemini con function calling sobre nuestra propia API, nueve herramientas, todas GET (get_run, get_instruction, get_supplier, get_verification, get_execution, get_receipt, sat_lookup, consortium_signal, get_metrics), donde readOnly es el literal true y no un booleano, así que una herramienta que escriba no se puede construir en el tipo. El turno se transmite por SSE token a token, termina como máximo en una propuesta con el cuerpo exacto del endpoint que se va a llamar, y la ejecuta una persona con un clic y con su nombre en la bitácora. Las CLABE viajan enmascaradas al modelo y cada mensaje registra su costo en el ledger.

ElevenLabs: dos agentes de voz que nunca liberan un pago

Dos agentes de Conversational AI en español mexicano, marcados por la integración de ElevenLabs con Twilio. El primero llama al proveedor cuando hay que confirmar una cuenta nueva: el guion se deriva de la instrucción y no se escribe a mano, dice solo los últimos cuatro dígitos separados uno por uno (el TTS leía "4611" como "cuatro mil seiscientos once"), nunca los dieciocho, no promete, no acusa, no pide datos y se disclosa como línea automática desde la primera frase: el proveedor rechazó la versión sin disclosure con call_initialization_error 3000, y esa fue una lección de seguridad, no un bug. El segundo agente es el del dueño, el que le marca al visitante desde el recorrido guiado: le explica el pago en riesgo y le pregunta si lo retiene o lo libera bajo su nombre. Las transcripciones las interpreta un analizador determinista y no un modelo: califica cláusulas solo de los turnos del interlocutor, distingue "no, es correcta" de "no es correcta" por la coma, y ordena negación sobre duda sobre confirmación, porque un "sí" que responde "¿hablo con el proveedor?" no es una confirmación sobre una cuenta bancaria. Ninguno de los resultados libera dinero por sí solo.

Tiger Data: la bitácora es una serie de tiempo

La columna vertebral es una bitácora append-only de eventos de pago en Postgres, protegida por un trigger que rechaza cualquier update o delete. Tiger Data la convierte en hypertable y el resumen diario que lee la línea del tiempo en un agregado continuo, aplicado en una migración condicional para que un Postgres 18 simple corra el mismo SQL contra la tabla base. Eso es lo que vuelve al barrido retroactivo del SAT una reproducción de la historia y no un recálculo: cuando el SAT publica, SentryOne reproduce los eventos de la propia empresa y pone en pesos qué facturas ya se pagaron, la base deducida y el impuesto en riesgo. Dos reglas nos costaron una migración cada una y quedaron escritas: un agregado continuo no se crea dentro de una transacción, y los índices únicos de una hypertable tienen que incluir la columna de particionado, lo cual decidió nuestras llaves primarias.

Snowflake API: una red entre empresas que no aprende de quién

El control del beneficiario es más débil exactamente donde más dinero cuesta: la primera factura de un proveedor, sin historia que comparar. La información que lo resolvería existe y está en otras empresas. SentryOne la pone en Snowflake y packages/consortium habla con ella por la SQL REST API y nada más: POST /api/v2/statements con un JWT de par de llaves firmado con node:crypto, GET /api/v2/statements/<handle> cuando la respuesta llega como 202, sin SDK ni dependencia nueva, así que el cliente es un archivo que se lee de principio a fin. Lo que sale de una empresa son siete columnas: hash con sal del tenant, del RFC y de la CLABE, el código de banco que esos dígitos ya dicen en público, una consecuencia de cuatro posibles, una fecha y la bandera de sintético. Lo que se lee es un agregado, nunca el renglón de otra empresa, y la ruta caliente lee una foto local: el motor recibe la señal de red como argumento, igual que el CEP, así que el almacén (un warehouse X-Small que solo despierta para traer la foto) nunca está en la ruta de una decisión.

Vultr: la API vive en un proceso que no se apaga

La API corre en un servidor propio de Vultr: 2 vCPU Intel Xeon (Skylake) a 2.0 GHz, 4 GB de RAM, 75 GB de disco, Ubuntu 24.04 LTS, Docker 29 y Caddy 2.11 con TLS automático de Let's Encrypt, provisionado con un script de cloud-init que clona el repo y levanta dos contenedores (oven/bun para la API y Caddy). Es un proceso vivo a propósito: los eventos en vivo (Server-Sent Events) que mantienen la corrida en pantalla no sobreviven en una función serverless, y eso contradijo nuestra decisión de despliegue original, que quedó enmendada por escrito en el ADR-0005. Caddy corre sin compresión y con flush_interval -1, porque un proxy con búfer entrega el stream en un solo bloque al cerrar. /health reporta cada dependencia por separado (Tiger, Nessie, riel, Snowflake, CEP, extracción, voz), cada escritura lleva X-Actor y un id de petición, y un token bucket limita las escrituras por cliente. Un comando, refresh.sh <rama>, despliega cualquier rama al servidor.

Vercel: la web estática que nunca habla con otra origen

apps/web es React con Vite, build estática en Vercel, con un sistema de diseño propio sobre el lenguaje visual de Capital One, modo claro y oscuro, responsive a 400 px, impresión de cartas y constancias, y un modo sin conexión con el mismo dataset que sirve la API. Vercel reescribe /api y /health al servidor de Vultr, así que el bundle no carga URL base y no existe CORS en ninguna parte. Producción sale de main; cada pull request tiene su preview, y la app vive en sentryone-one.vercel.app.

El motor y las piezas que nadie ve

  • packages/core: seis detectores deterministas, el motor de decisión por pérdida esperada (pesos en riesgo contra lo que cuesta retrasar ese pago un día, por proveedor), la retención con fecha límite y salida del dueño, y las dos funciones puras que derivan el nivel y el estado. Ni el nivel ni el estado se guardan en ninguna columna: un nivel almacenado puede contradecir a los hallazgos de los que salió, y uno derivado no.
  • packages/sat: las dos listas del SAT interpretadas por nombre de columna, cada situación fechada de cada contribuyente, y la lista real dentro del repositorio (14,234 registros, con procedencia documentada), porque un control que solo funciona mientras el portal del SAT responde no es un control.
  • packages/cep: el comprobante de Banxico leído por nombre de nodo, XML byte por byte, razones sociales normalizadas por tipo societario, y honestidad con el sello: Banxico no publica la especificación de firma, así que reportamos "sello no verificado", nunca "inválido".
  • packages/rail: tres rieles y cada uno dice qué demuestra: Nessie como banco de la empresa, STP documentado como participante real del SPEI, y el layout de dispersión CSV como camino sin API.
  • packages/constancia: los PDF (constancias, recibos y la carta de una página) escritos a mano, sin dependencia y sin navegador. packages/seed: una empresa sintética determinista desde una semilla fija, 92 instrucciones que liquidan 129 CFDI en un jueves, con los dos fraudes plantados. packages/engine, packages/nessie, packages/db cierran el conjunto.
  • El recorrido y la llamada al dueño reutilizan lo anterior: POST /api/v1/tour/call valida un celular mexicano, lo hashea, aplica su propio límite (una llamada por número cada diez minutos, veinte por hora) y usa el mismo cliente de llamadas salientes con el agente del dueño; un poller acotado lee la conversación al colgar, graba el evento antes de publicarlo por SSE, aplica la decisión por el mismo camino que usa la pantalla y programa la reversión a los diez minutos. La web solo escucha.
  • Calidad como parte del producto: CI con biome, typecheck, pruebas y build en cada PR; un scrub que revisa que el repositorio no filtre secretos; un release-check que corre todo antes de cortar versión; una evaluación a ciegas (bun run eval) que imprime la matriz de confusión por control y por nivel, y GET /api/v1/metrics sirve los mismos números, así que la pantalla de métricas es un reporte y no una afirmación. Release v1.0.0 etiquetada, con changelog, video de respaldo y playbooks de despliegue y demo.

Challenges we ran into

  • El archivo del SAT no es el CSV que uno espera. Es ISO-8859-1 y no UTF-8; su número de filas y de líneas no coinciden porque hay saltos de línea dentro de nombres entrecomillados; 483 fechas del DOF son ilegibles y traen al lado una fecha de portal usable; 93 celdas cargan dos fechas en un campo; 91 RFC están tachados por orden judicial. Resolvemos columnas por nombre, reportamos cada fila ilegible con su número de línea y nos negamos a tirar una sola: una fila perdida en silencio de una lista negra fiscal es el peor error posible.
  • Un registro de detectores que no llamaba a ninguno. La primera versión descubría módulos por importación dinámica, no llamaba a nada, y las pruebas seguían en verde porque afirmaban sobre un arreglo vacío. Lo reemplazamos por una lista explícita y tipada; ahora cada control cae en "corrió" o "no corrió, y por esto". El silencio tiene que explicarse.
  • Una respuesta vacía de la red no es "no consultada". Snowflake devuelve todo como cadena y las fechas como días desde la época; la primera lectura real leyó 46 renglones y se saltó los 46, y escribió una foto vacía marcada como contestada. Ahora las fechas se formatean dentro del SQL y hay una prueba que falla sin el arreglo.
  • Un nivel calculado en cuatro lugares es el mismo error con mecha más larga. El motor sabía la gravedad, la API la acción, la web el color y el respaldo sin conexión una adivinanza. ADR-0009 dejó una sola función pura por valor, y una prueba recorre apps/web/src para que el siguiente componente que salga a la red sin consultar el modo falle en CI y no en el demo.
  • Usar un modelo sin dejarlo decidir nada, dos veces. Primero el extractor; después el asistente, donde la respuesta fue que las nueve herramientas son GET, readOnly es el literal true y no un booleano, y una propuesta se ejecuta con un clic de una persona y su nombre en la bitácora.
  • Un "sí" pelón no es una confirmación. En la llamada el agente pregunta primero si habla con el proveedor, así que ese "sí" jamás cuenta como acuerdo sobre una cuenta. "No, es correcta" y "no es correcta" son opuestos. El analizador califica cláusulas solo de los turnos del proveedor y ordena negación sobre duda sobre confirmación.
  • Server-Sent Events necesita un proceso vivo, lo que contradijo nuestra decisión de despliegue y obligó a una enmienda escrita: la web se quedó estática en Vercel y la API se mudó a Vultr detrás de Caddy.
  • Dos mesas de jueces de Capital One nos tiraron la narrativa el 12 de septiembre: competencia sin nombrar, problema en minutos y no en pesos, sin tasa de fraude con fuente, sin canal ni garantía. Reescribimos el producto y la historia en la noche: plaza de la CLABE, tres niveles sin porcentaje, cancelación automática por el SAT, carta de una página, asistente con captura, pagos desde la web, guion de tres minutos.

Accomplishments that we're proud of

  • Un flujo completo en producción, no en un video: captura de WhatsApp al chat, instrucción con nivel y evidencia, centavo por el riel, comprobante de Banxico leído, pago bloqueado, corrida enviada con recibo y constancia por línea.
  • La segunda pérdida, la fiscal, medida en pesos y en vivo. Nadie más la cruza con el pago en el momento de pagar.
  • Confirmación de beneficiario en un país que no la tiene, con lo que sí existe: el centavo, la clave de rastreo y el CEP. El centavo no es invento nuestro; está en las reglas del SPEI y se vende medido. Lo nuestro es la decisión que cuelga de su respuesta.
  • Niveles y estados derivados de la evidencia, nunca de un porcentaje, y una carta de una página por pago.
  • Una evaluación a ciegas de 35 casos que la persona que escribió los detectores nunca leyó, con precisión y recall por control publicados en la propia app.
  • Una red entre empresas que no aprende de quién, con hashes salados en Snowflake y sin un solo nombre fuera de la empresa.
  • Un asistente que no puede escribir, por tipo, y una llamada de voz que no puede liberar dinero.
  • La lista real del SAT, completa y con procedencia, dentro del repositorio.
  • Un recorrido guiado que termina en tu teléfono: el visitante recibe la llamada del dueño, decide con su voz, y ve la línea moverse en la pantalla. Es el único demo del reto donde el juez es parte del flujo y no espectador.
  • Un video de un minuto con la voz, la historia de Lupita y las pantallas reales, y un pitch de tres minutos con láminas construidas al ritmo del guion.
  • Trabajo de equipo real: 127 pull requests mergeados y todos los issues asignados por persona en un tablero, revisiones cruzadas, CI verde en cada merge, y una release etiquetada con changelog.

What we learned

Que el fraude en pagos no es un problema de modelos sino de momento: la consulta al SAT es gratuita y toma un minuto, lo difícil es que corra en el clic y no en el alta del proveedor. Que en México no hay contracargo y por eso el único lugar donde vale la pena revisar es antes de enviar. Que un nivel guardado puede contradecir a los hallazgos de los que salió y uno derivado no. Que la honestidad en la interfaz es una feature: "sello no verificado", "red no consultada" y "control que no corrió, y por esto" valen más que un verde que no revisó nada. Que un modelo de lenguaje es útil exactamente donde no decide. Y que dos mesas de jueces duras valen más que una semana de código: nos obligaron a explicar el problema en pesos, a nombrar a la competencia (ValidX, Portal de Proveedores, Clara, Xepelin, CONTPAQi; Trustpair, nsKnox y Eftsure fuera de México), y a decir quién compra, por qué canal y qué pasa cuando nos equivocamos.

What's next for SentryOne

  • El recorrido como onboarding real: el mismo paso a paso, con la empresa del cliente y sus facturas, es la primera sesión de cada despacho contable; la llamada de prueba al dueño es el momento en que entiende qué compró.
  • Diez corridas de pago reales en modo sombra. La tasa de falsos positivos con datos que no generamos nosotros es lo único que no sabemos, y es lo primero que vamos a medir.
  • El centavo real a través de un participante del SPEI (STP ya está documentado como riel), y la verificación de la firma del CEP cuando Banxico publique la especificación.
  • El barrido de la segunda lista del SAT (49 Bis) en cuanto exista como archivo y no como oficios del DOF.
  • Integración sin reemplazar el ERP: SentryOne entre el ERP y el banco, leyendo el layout de dispersión que el ERP ya exporta y devolviéndolo revisado; conectores con CONTPAQi, Aspel y SAP Business One.
  • Garantía por capas: expediente con nombre en cada pago, prueba sin costo, devolución de hasta doce meses de suscripción si un fraude pasa con todas las revisiones hechas, y una póliza con una aseguradora, que es quien puede venderla.
  • Mercado y canal. 246,000 empresas de 11 a 250 empleados en México, 6,476 en Nuevo León en manufactura, mayoreo y construcción; 899 pesos al mes por empresa y 3,900 por despacho contable con hasta veinte clientes; una sola factura detenida al año lo paga. Primero venta directa al dueño y a finanzas, luego los 143 despachos contables del estado como revendedores, y a escala el control embebido en la banca empresarial de un banco como Capital One.

Built With

Share this project:

Updates

Submission history