Una autorización de tarjeta está por salir. Puede ir a cualquiera de cuatro proveedores, cada uno con comisión distinta y un historial propio de aprobación para este tipo de transacción. Si se rechaza, hay que decidir si se reintenta, cuándo y con quién. No es solo infraestructura: entre probabilidad de aprobación y comisiones hay una concesión de producto que debe expresarse en unidades discutibles por una persona. Construí este motor para tomar esa decisión y demostrar un principio: evidencia en la decisión principal, IA en los bordes. Partió de un ejercicio técnico que generalicé como proyecto personal; los datos son sintéticos y su generador viene en el repositorio.
El motor decide de forma determinista
decide(txn, config) -> Decision es una función pura sobre tablas preparadas de antemano. Cinco decisiones definen el diseño.
Separación entre proceso previo y decisión en línea. El motor nunca lee eventos de pago sin procesar. Un lote reconstruye una fila por intento de autorización y la agrega en tablas de aprobación. La ruta en línea carga esas tablas una vez y decide en microsegundos, sin consultar una base de datos durante la solicitud. Así, la decisión puede probarse sin infraestructura y auditarse después: es la frontera que se trazaría en producción entre conciliación y routing.
Jerarquía de segmentos con fallback. El segmento más específico combina canal × tipo de fondos × emisor × banda de monto (L0); el más general solo conserva el canal (L3). El soporte se resuelve por proveedor: se elimina una dimensión a la vez —banda de monto, emisor y tipo de fondos— hasta que la celda alcanza min_support (200 por defecto), y la decisión informa el nivel utilizado. Nunca se elimina el canal: una compra con usuario presente y un cobro sin sesión son situaciones distintas; promediarlas lleva a una tabla de routing engañosa.
Límite inferior de Wilson, no tasa cruda. Un proveedor con 9 aprobaciones de 10 intentos en una celda no es necesariamente un proveedor de 90%. El límite cae hacia cero cuando la muestra es pequeña, por lo que 870 de 1000 con evidencia sólida supera a 9 de 10 afortunados sin añadir una regla de confianza: «el mejor respaldado por evidencia», no solo «el mejor observado».
cost_bias como control en unidades reales. La concesión se expresa en puntos porcentuales de aprobación que la persona operadora acepta perder para usar un proveedor más barato: tolerance = cost_bias × 10 pp. Gana el proveedor de menor comisión que quede dentro de esa tolerancia respecto al mejor en aprobación. Un puntaje combinado permitiría que una diferencia diminuta de comisión superara silenciosamente una brecha grande de aprobación; un filtro por tolerancia lo impide.
Reintentos por clase de error, no por contador ciego. insufficient_funds es un problema de la cuenta, no del proveedor: sin sesión, se reintenta con el mismo proveedor en la siguiente ventana de cobro. bank_auth_required sin sesión no puede resolverse sin el cliente; se detiene la cadena y se solicita reprogramar con el usuario presente. fraud_risk e invalid_card_info detienen definitivamente los reintentos: recuperación marginal baja y costo real por intento. generic_decline cambia al proveedor siguiente según el puntaje, y una clase desconocida usa esa política e informa que lo hizo. Los límites difieren por canal: ocho intentos con usuario presente y veinte sin sesión.
La evidencia: un replay fuera de muestra sobre 84,011 transacciones de prueba —días 22–31, tablas entrenadas con los días 1–21— estima 72.02% de aprobación frente a 66.13% observado con cost_bias=0: +5.89 puntos porcentuales. El costo del control también se expone: 71.56% / +5.43 puntos en 0.5 y 69.57% / +3.44 puntos en 1.0. Son cifras direccionales, no una prueba A/B: el routing histórico no fue aleatorizado, no se modeló la capacidad y la «aprobación esperada» aplica al volumen de prueba un límite de Wilson del período de entrenamiento. Estas advertencias se publican junto al titular en el archivo de resumen.
Dónde pertenece la IA y dónde no
No hay LLM dentro de decide(), ni está previsto añadirlo. Una decisión financiera no debe depender de un token muestreado: latencia, auditabilidad e investigación posterior apuntan en la misma dirección. El modelo queda limitado a dos bordes donde el lenguaje sí es el problema. También allí se diseñó para la posibilidad de alucinación: el esquema restringe la respuesta al enum, una compuerta explícita rechaza salidas fuera de él, la confianza baja recurre a un valor seguro que la máquina de reintentos ya entiende y la evaluación cuenta cada violación; debe haber cero para pasar.
Entrada: normalizador de rechazos. Cada proveedor habla un dialecto distinto: números ISO 8583, un decline_code similar al de Stripe, un refusalReason similar al de Adyen o texto bancario sin código. La máquina de estados usa un enum de seis clases, por lo que esos dialectos deben converger antes de llegar al motor. Una tabla determinista procesa los códigos frecuentes —confianza 1.0, sin latencia ni costo—. Solo las faltas de esa tabla llegan a la cadena de modelos (Gemini primero, Mistral después), que responde bajo un esquema limitado al enum. Todo resultado fuera de él o con confianza inferior a 0.6 se descarta en favor de generic_decline, la alternativa segura de la política de reintentos. El repositorio funciona y pasa pruebas incluso sin claves de API.
Salida: servidor MCP. Seis herramientas permiten a una persona analista operar el motor en inglés mientras la decisión sigue siendo determinista. Un incidente típico: «psp-b acaba de fallar; ¿a dónde va un checkout de débito de 250 unidades y cuánto cuesta?» equivale a simulate más explain_decision con psps_down=["psp-b"]. «¿El routing recurrente hacia psp-c responde a una señal real o a tres transacciones afortunadas?» lleva a segment_evidence, que devuelve n y aprobaciones por proveedor: un conteo, no una opinión. El agente puede interrogar cada decisión, pero no cambiarla.
La misma disciplina rige el servidor MCP remoto y el harness de evaluaciones de nutri.: asignar al modelo una tarea delimitada, dejar la corrección en una capa que no controla y medir la parte susceptible de fallar de maneras que las pruebas convencionales no detectan.
Las evaluaciones como compuerta
El normalizador es precisamente esa parte y por ello se mide. El conjunto de referencia incluye 48 rechazos: 28 casos resueltos por tabla (58%) y 20 fuera de ella (42%) —un error ortográfico procedente del historial de cambios, texto bancario extenso, códigos inusuales—, incluidos tres casos deliberadamente ambiguos donde generic_decline es la respuesta correcta. El runner informa exactitud por ruta y una tabla de confusión por clase, exige cero alucinaciones —si una clase fuera del enum llegara como cadena desconocida a la máquina de estados, la evaluación comprueba que la compuerta aún lo evita— y falla si la exactitud cae más de dos puntos porcentuales frente a la línea base versionada.
Hay dos líneas base, cada una comparada consigo misma y nunca con la otra. Solo tabla, sin claves: 32/48 = 66.67% total, 28/28 en la ruta de tabla y generic_decline seguro en los 20 casos que pasan de largo. Es el piso con el modelo apagado, no una medición del modelo. Con LLM y claves, ejecutado contra la API desplegada para que las claves no salgan del servidor: 48/48 = 100%; 28 por tabla, 19 por gemini-3.6-flash y uno por fallback de baja confianza en un caso cuya respuesta esperada era generic_decline. Cuarenta y ocho casos en una sola ejecución constituyen una compuerta de regresión, no una afirmación sobre todos los casos futuros.
Qué ofrece la demo a quien la revisa
Para un reclutador con dos minutos: ocho transacciones, cada una cerca de un límite de decisión —fallback en la jerarquía, control de costo que cambia la ruta, reintento sin sesión que vuelve al proveedor que acaba de rechazar, parada por fraude, proveedor deshabilitado durante un incidente, BIN desconocido y monto justo en el borde de una banda—. Cada una se ejecuta con un clic y devuelve la decisión con líneas que explican el motivo.
Para quien gestiona pagos, los controles están activos. Cambiar cost_bias muestra la curva de tolerancia de comisiones; marcar un proveedor como caído recalcula las opciones; el panel de evidencia muestra nivel, n, aprobaciones, tasa cruda y límite de Wilson por proveedor. Las mismas funciones sustentan la API HTTP —cinco endpoints bajo /api/— y la configuración MCP. Son dos envolturas del mismo módulo, no implementaciones separadas que puedan divergir.
Cómo se construyó
El proyecto se realizó de punta a punta dentro de Claude Code. Una sesión coordinadora planificó el rediseño y delegó a subagentes el runtime del motor y los bordes de IA, la interfaz web, la publicación del repositorio y despliegue, y este texto. Antes de aceptar cada resultado, lo contrastó con pruebas, evaluaciones y endpoints en vivo. Es el mismo sistema de trabajo que uso en nutri.: archivos de convenciones que el agente debe cargar en cada sesión, controles estructurales y una suite de pruebas aprobada como condición de terminado. La tesis del proyecto —núcleo determinista, modelo en los bordes, evaluaciones como compuerta— también guio su construcción.
Los controles demostraron su utilidad aquí. La verificación tras el despliegue encontró que una regla de reescritura de Vercel eliminaba todas las rutas de la API: cada endpoint devolvía 404; se corrigió antes de compartir la URL. Una comprobación de la interfaz contra la resolución de segmentos del motor encontró cinco de ocho emisores de demo mal rotulados como «desconocidos». Un subagente recibió una regla de cantidad de páginas que no aplicaba a estos PDF, se negó a recortar contenido y señaló la premisa equivocada. Otro recibió la indicación de que el DNS estaba en Vercel, comprobó que estaba en Cloudflare y se detuvo sin tocar la configuración de la cuenta. No bastó con pedir cautela al modelo: existían verificaciones independientes de su cautela.
Registrar la decisión
Una decisión de routing solo sirve si el resto del sistema se entera. En ese traspaso, los sistemas de pago pueden perder dinero en silencio: se guarda el intento, el proceso muere antes de publicar el evento y los sistemas posteriores no reciben la noticia. La conciliación puede descubrirlo después. Después es la parte costosa.
La outbox se confirma junto con el hecho. record_decision() escribe la fila del intento y la de outbox en una sola transacción: se guardan ambas o ninguna. Así no hay un intervalo en que exista la decisión sin su evento. Un drain() separado publica pendientes desde el más antiguo y los marca; la disponibilidad del publicador no afecta la ruta de decisión.
Entrega al menos una vez, de forma explícita. drain() puede publicar y morir antes de registrar que lo hizo; la siguiente pasada vuelve a publicar. Esa es la garantía real de una outbox y obliga al consumidor a deduplicar. Las pruebas muestran el comportamiento: al forzar un fallo del insert de outbox, comprueban que tampoco queda el intento; al repetir la misma clave, verifican una fila y un evento; en una prueba de caída entre publicación y marcado, vuelven a procesar y confirman que el consumidor vio dos veces el evento pero entregó una sola vez. El almacenamiento usa SQLite de la biblioteca estándar, sin añadir dependencias.
Límites
Los datos son sintéticos y buscan hacer interesante el problema de routing, no reproducir una cartera real. No hay conectores a proveedores activos: el motor decide y registra, pero no envía autorizaciones. Tampoco implementa orquestación 3DS, tokens de red ni reglas de reintento de los esquemas de tarjetas. El backtest es direccional por las razones anteriores. Hay además una asimetría explícita: las tablas agregan todos los intentos, mientras el backtest entrena y reproduce únicamente los primeros. Crear tablas de producción limitadas a primeros intentos es la siguiente corrección.
Pruébalo
Demo: orchestrator.vryahn.com · Repositorio: github.com/vryahn/payment_orchestrator · Configuración MCP: MCP.md
En local, los ocho casos límite se ejecutan con una línea: python cli.py --txn-file demo_transactions.json.
La lección de producto: para responder «¿dónde debe intervenir la IA?» primero hay que identificar qué decisión debe seguir siendo determinista y por qué. Aquí la respuesta se plasma en la arquitectura: motor auditable en la ruta principal, modelo en los dos bordes donde la entrada es lenguaje y evaluación entre el modelo y la máquina de estados.