Volver a los artículos

Dejé de traducir reglas de negocio a código

Dejé de traducir reglas de negocio a código

Antes de empezar: qué es JSONata

Este post gira alrededor de JSONata, un lenguaje de consulta y transformación para JSON. Si conoces XPath, la idea es la misma: navegar y transformar sin escribir el recorrido a mano. Una expresión recibe un JSON y devuelve un valor, un objeto nuevo o una decisión.
No hace falta que lo conozcas. Cada vez que en el post aparece una expresión, aparece con un laboratorio como este, que corre el motor de referencia de JSONata en tu navegador: cambia el input o la expresión y el resultado se actualiza solo. Estos cinco ejemplos cubren casi todo lo que una regla de negocio necesita.
…
Corre jsonata-js 2.2.2 en tu navegador, el motor de referencia. Corta a 500 niveles de profundidad y 1000 ms.

Cómo nacía una regla

Durante un buen tiempo, mi trabajo con las reglas de negocio fue de traductor.
Alguien de operaciones me decía que un pedido que lleva más de una hora sin movimiento hay que escalarlo, o que si el cliente ya reclamó dos veces esta semana el mensaje va por otro lado. Yo lo escribía en código, salía un PR, pasaba por revisión, esperaba la aprobación y se publicaba en el siguiente despliegue. Entre la idea y la regla andando podían pasar días.
Y a veces, después de todo eso, lo que salía no era lo que la persona esperaba. Para ellos, sin movimiento era un pedido que llevaba una hora sin cambiar de estado. Yo lo había programado como un repartidor que llevaba una hora sin moverse en el mapa. Las dos cosas se llaman igual y escalan pedidos distintos. Ahí el ciclo empezaba de nuevo, con el agravante de que ahora había código escrito que había que cambiar.
Lo caro nunca fue la regla. La regla eran cuatro líneas. Lo caro era el trayecto, y sobre todo la parte del trayecto que era yo: alguien que no conoce la operación traduciendo lo que dijo alguien que sí la conoce.

Ceder el control

La idea de sacarme del medio no salió de una reunión de producto. Salió de mi calendario. Me habían puesto en otros proyectos, y cada regla nueva que pedía operaciones competía por el mismo tiempo. Yo seguía siendo el traductor, pero ahora un traductor que tardaba en atender.
Ahí me hice la pregunta que debí haberme hecho antes: ¿qué estaba aportando yo en ese camino? Criterio de negocio, no. La regla venía completa desde operaciones. Lo que yo aportaba era sintaxis. Y la sintaxis es lo único de todo el proceso que se puede delegar en una herramienta.
Así que propuse lo obvio: que la regla la escriba quien la entiende, y que nosotros pongamos el lugar donde escribirla.
Lo que no dije en esa conversación, porque todavía no lo sabía, es que ceder el control tiene dos condiciones. La primera es un lenguaje que se pueda exponer sin miedo, porque la regla la va a escribir alguien de afuera del equipo. La segunda, que terminó ocupando la mitad de este post, es un lugar donde esa persona vea el resultado de su regla antes de publicarla. Sin eso, ceder el control es ceder el problema.

El lenguaje

Un lenguaje ya teníamos. El motor evaluaba condiciones con JsonLogic, que es exactamente lo que su nombre dice: lógica. Sirve para responder si un pedido cumple una condición. No sirve para construir el payload de la llamada que sigue, ni para sumar los ítems, ni para armar el objeto que el paso siguiente espera. Y las reglas que pedía operaciones casi nunca eran solo una condición: eran una condición y una transformación, y la transformación era justamente la parte que yo terminaba escribiendo a mano.
JSONata es la misma idea, pero para transformar. Lo que convence a la gente es la expresión de mapear y sumar: order.items.(price * qty) no necesita un map. El punto aplica la expresión de la derecha a cada elemento de la izquierda, y si la izquierda es un arreglo el resultado es un arreglo. Un mapeo que en código son seis líneas acá es una. Cambia una cantidad en el input y mira cómo cambia el total.
…
Corre jsonata-js 2.2.2 en tu navegador, el motor de referencia. Corta a 500 niveles de profundidad y 1000 ms.
Y cumple la primera condición: no tiene acceso al sistema de archivos, no hace llamadas de red, no tiene bucles infinitos fáciles de escribir por accidente. Para dárselo a alguien de afuera del equipo, eso importa tanto como la sintaxis.

Le dimos el lenguaje y no lo usó nadie

Publicamos el editor con el preview al lado, le dimos una capacitación al equipo de operaciones y nos fuimos a mirar las métricas. Tenemos métricas de todo, y esta era fácil de leer: la cantidad de reglas tenía que subir.
No subió. Pasó el tiempo y la curva seguía plana.
Se lo pregunté a mis amigos de operaciones y me lo confirmaron sin rodeos: nadie estaba escribiendo reglas. La capacitación había sido demasiado compleja para gente que jamás había programado. Que una expresión sea corta no la vuelve legible para quien nunca vio un filtro entre corchetes. La persona de operaciones sabe perfectamente qué regla quiere. Lo que no sabe es cómo se escribe items[qty > 0], y no tiene por qué saberlo.
El error es tan simple que da un poco de vergüenza contarlo: le habíamos dado un lenguaje a gente que no habla ese lenguaje, y una capacitación diseñada por gente que sí lo habla. Habíamos movido el cuello de botella, no lo habíamos sacado. Antes la barrera era mi cola de PRs. Ahora era la sintaxis.

Sí lo usaron, pero por fuera

Lo siguiente también me lo contaron ellos, aunque la señal ya estaba en el sistema. Empezaron a aparecer reglas con variables que no existían: expresiones bien formadas, con la sintaxis impecable, que consultaban campos que ningún pedido tiene.
La explicación era la más simple. Abrían ChatGPT o Claude, describían la regla, copiaban la expresión que les devolvía y la pegaban en el editor.
A veces funcionaba. El asistente de afuera no conocía nuestros endpoints, no conocía los esquemas, no sabía qué campos trae un pedido ni cómo se llaman. Escribía JSONata correcto sobre una estructura de datos imaginada. Lo que llegaba al campo de texto era una expresión plausible, y plausible no es lo mismo que correcta.
Ese fue el momento en que entendimos el problema de verdad. La herramienta no había fracasado: se había ido a un lugar donde nosotros no la veíamos y donde no había nada que verificara el resultado.

El laboratorio

Si iban a usar un asistente de todas formas, la pregunta ya no era si, sino cuál y con qué contexto. La respuesta fue traer ese paso adentro. Armamos un laboratorio: un editor pequeño dentro del sistema, al lado del catálogo de reglas, con un agente propio.
La diferencia con el asistente de afuera no es el modelo. Es el contexto. El agente del laboratorio está conectado por MCP al catálogo de endpoints y a los esquemas del sistema, así que sabe qué campos existen, cómo se llaman y qué forma tiene la salida de cada paso. Cuando alguien de operaciones escribe la regla en español, el agente no adivina la estructura de los datos: la consulta.
una vuelta en el laboratorio
operaciones:  "si el pedido lleva más de una hora sin movimiento, escálalo"

agente:       inactiveMinutes > 60 ? "ESCALATE" : "NOTIFY"

              sobre el pedido #4471   ->  "ESCALATE"
              sobre el pedido #4472   ->  "NOTIFY"
…
Corre jsonata-js 2.2.2 en tu navegador, el motor de referencia. Corta a 500 niveles de profundidad y 1000 ms.
Eso es lo que cambia la adopción: la expresión llega ya corrida contra pedidos reales, con el resultado al lado. La persona no aprueba la expresión. Aprueba el resultado.

Si el agente escribe, ¿qué verifica el usuario?

Acá la historia se vuelve técnica, porque ceder el control tiene una consecuencia que desde producto no se ve.
La persona de operaciones no lee JSONata. Eso no lo arregló el laboratorio: lo que cambió es que ya no le hace falta. Aprueba lo que ve en el preview. Cuando construimos el preview lo pensamos como una comodidad para escribir más rápido. Con el agente en el medio es otra cosa: es la única superficie de verificación que queda en toda la cadena. No hay revisión de código después, porque no hay nadie que lea ese código.
Entonces hay una pregunta que había que responder antes de dejar publicar a nadie: ¿el preview dice la verdad?

Un preview que miente es peor que no tener preview

Sin preview, la persona que escribe la regla desconfía: la publica con cuidado, la mira funcionar en el primer pedido real y recién entonces se relaja. Con un preview que miente, confía. Y publica la regla convencida de que ya la vio funcionar.

Dos motores, un lenguaje

JSONata nació en JavaScript. La implementación de referencia, jsonata-js, es la que define qué hace el lenguaje: no hay una especificación formal aparte del comportamiento de ese código. En la JVM lo que existe son ports: hay uno de Dashjoin, que es una traducción directa del evaluador original, y hay uno de IBM, JSONata4Java, que es una implementación propia con su gramática en ANTLR.
Y acá está el detalle que decide todo. El preview corre en el navegador, así que corre jsonata-js. La regla, cuando se guarda y se ejecuta de verdad, corre en el backend, en la JVM, con uno de los ports. Son dos implementaciones distintas del mismo lenguaje evaluando la misma expresión.

Dos motores para la misma expresión

items[qty > 0][]navegador: el previewjsonata-jsbackend: la ejecuciónport de la JVM[ { "sku": "A" } ]{ "sku": "A" }el preview dijo arreglo y producción recibió un objeto
La pregunta no era si se puede evaluar JSONata en la JVM. Eso se sabía: hay librerías, están publicadas, se agregan al build y listo. La pregunta era si el motor de la JVM da exactamente el mismo resultado que el del navegador.

Las 21 expresiones que eligieron el motor

Para responderla armamos un banco de pruebas: un servicio pequeño que recibe una expresión, la evalúa en los tres motores a la vez y compara los resultados. Los tres son jsonata-js corriendo en Node, que hace de árbitro porque es la implementación normativa, y los dos ports de la JVM que estaban en carrera. Las herramientas de ese banco no son lo interesante de esta historia y no les voy a dedicar más espacio que este párrafo.
Lo que sí importa es de dónde salieron las 21 expresiones del corpus. No de la documentación de JSONata, sino de lo que el sistema ya hacía a mano con código propio: los mapeos, las agregaciones, los condicionales, las rutas hacia la salida de un paso anterior. La documentación de cualquier lenguaje muestra los casos en los que ese lenguaje se ve bien.
MotorDivergencias semánticas (18 casos)Latencia medianaDependencias transitivas
Dashjoin, versión 0.9.80menos de 1 msninguna: 166 KB con su propio parser de JSON
JSONata4Java, versión 2.5.53menos de 1 msjackson-databind, antlr4-runtime, spring-context, gson, woodstox, commons-text
Del conteo se excluyen tres de los 21 casos porque no son divergencias semánticas: uno usa $now(), que por diseño no es determinista, y los otros dos son expresiones que se van de las manos a propósito, donde fallar es el comportamiento correcto.
Nos fuimos por Dashjoin. Cero divergencias, y la columna de dependencias no es un detalle de gusto: ese backend se empaqueta como un jar único, y traer un contenedor de inyección de dependencias completo más dos librerías de JSON distintas, por una función de transformación de datos, no se justifica.
Dos cosas aparecieron solo al correr el corpus de verdad, y las dejo anotadas porque son el mismo tema de este post. La primera corrida marcó dos diferencias que no existían, porque el comparador normalizaba mal los objetos anidados dentro de un arreglo. Y durante un rato el motor de referencia pareció dos órdenes de magnitud más lento de lo que era, porque un temporizador mal cancelado mantenía vivo el event loop de Node. Ninguna de las dos se habría visto revisando el código, que es el argumento de siempre a favor de probar las cosas de verdad. Y las dos son el tema de este post: una herramienta que mide no puede mentir, igual que un preview no puede mentirle al usuario.

Las tres divergencias

Las tres son de JSONata4Java, y no pesan lo mismo. Dos fallan con ruido y una en silencio.
CasoQué pasaGravedad
items[qty > 0][]Devuelve el objeto suelto donde la referencia devuelve un arreglo de un elemento. No lanza error.La peor clase: resultado incorrecto en silencio
$match(sku, /[A-Z]+-([0-9]+)/)No soporta literales de expresión regular: la firma de la función no acepta el argumento.Falla ruidosa
$ ~> |items|{...}|No parsea el operador de transformación, que existe desde JSONata 1.7.Falla ruidosa, al guardar
La primera necesita contexto, porque es una decisión de diseño de JSONata que sorprende a todo el mundo la primera vez: un filtro que devuelve un solo elemento devuelve el elemento, no un arreglo de uno. Se llama colapso del singleton, y existe para que las expresiones se lean bien. El precio es que el tipo del resultado depende de los datos. Por eso el lenguaje tiene una forma de decir siempre arreglo, que son los corchetes vacíos al final.
…
Corre jsonata-js 2.2.2 en tu navegador, el motor de referencia. Corta a 500 niveles de profundidad y 1000 ms.
Lo que ves ahí es lo que dice la referencia. JSONata4Java devuelve el objeto suelto también en el segundo caso, ignorando los corchetes, y no lanza error.

Por qué la silenciosa es la peor

Una falla ruidosa es un problema de compatibilidad: la persona escribe la regla, ve el error en el laboratorio y prueba otra cosa. Cuesta cinco minutos. La falla silenciosa rompe justamente lo que el preview prometía: el resultado se ve bien, se guarda bien, y revienta en producción el día que el paso siguiente recibe un objeto suelto donde esperaba un arreglo. Y cuando revienta, nadie va a buscar la causa en el motor.
Hay además un detalle que no se ve hasta que pones un agente en el medio. El agente aprendió JSONata de la documentación de referencia, y la documentación de referencia documenta el motor de JavaScript. Los literales de expresión regular y el operador de transformación son JSONata perfectamente válido y perfectamente idiomático: un agente los va a escribir sin dudar, porque son la forma natural de resolver esos dos problemas. Dos de las tres divergencias son exactamente esas. El agente no esquiva el problema de paridad: apunta derecho hacia él.

nothing no es null, y JSONata nunca avisa

Este no es un problema de paridad: los tres motores se comportan igual. Es peor, porque es el comportamiento correcto del lenguaje.
Si la expresión navega hacia una ruta que no existe, JSONata no devuelve null y no lanza un error. Devuelve nothing, que es la ausencia de resultado, en silencio. Un typo en el nombre de un campo se comporta exactamente igual que un campo que legítimamente no vino.
…
Corre jsonata-js 2.2.2 en tu navegador, el motor de referencia. Corta a 500 niveles de profundidad y 1000 ms.
JSONata distingue tres cosas que la mayoría de los lenguajes colapsa en una: el valor null, la clave presente con valor nulo, y la clave ausente. Si la capa que transporta el resultado hasta el laboratorio convierte nothing en null, la distinción se pierde justo donde importaba, y el usuario ve un resultado que parece un dato. Por eso el evaluador transporta nothing como un objeto sin clave de valor, nunca como un valor nulo:
la respuesta del evaluador
{ "kind": "nothing" }

{ "kind": "value", "value": null }
Y arriba hay que decidir qué significa nothing para el producto: ¿el paso se saltó?, ¿es una falla?, ¿la clave se omite del payload que sale? Es una decisión de producto, no de infraestructura, y si nadie la toma explícitamente el lenguaje la toma por omisión: devuelve nada y sigue.

La pila de la JVM no es la de V8

Esta es la divergencia más peligrosa de todas, y apareció fuera de la pregunta original.
…
Corre jsonata-js 2.2.2 en tu navegador, el motor de referencia. Corta a profundidad sin límite y 5000 ms.
El primer chip es el motor de referencia tal cual: devuelve 100000. El segundo le aplica a ese mismo motor el límite que tiene la pila de la JVM, para que veas lo que vería la persona que escribió la regla: la misma expresión, dos respuestas.
Lo importante es que no es un bug de los ports. Es la pila de llamadas de la JVM contra la de V8. Ningún port lo puede arreglar sin reescribir el evaluador entero sin recursión. La expresión que la persona vio funcionar en el laboratorio revienta en producción, y los dos comportamientos son correctos en su plataforma.
La mitigación no es técnica, es de contrato: el navegador tiene que aplicar el mismo límite de profundidad que el backend, y ese número deja de ser un detalle de configuración para volverse parte de lo que el producto promete. Si el backend corta a 400 niveles, el laboratorio corta a 400 niveles, y los dos fallan igual.
El otro hallazgo de esta zona es una inversión inesperada. Una expresión que quema CPU sin recursión, como sumar los cinco millones de elementos de un rango, se corta en ambos ports de la JVM al milisegundo siguiente del límite configurado, con un error claro de timeout. jsonata-js no expone un timebox público: la referencia es la que no tiene esa protección. Y ahora que quien escribe la expresión puede ser cualquiera de operaciones, el riesgo de que una tumbe el servicio dejó de ser teórico: ahí los ports están mejor que el original.

Dos relojes, no uno

El timebox interno del motor solo se dispara en sus propios puntos de chequeo, así que una expresión que se cuelgue entre dos chequeos no lo activa nunca. Por eso hace falta un segundo reloj, de pared, por fuera del motor, y un campo en la respuesta que diga si el hilo se liberó después de cancelarlo. Si ese campo sale en falso alguna vez, ese motor se quedó con un worker de forma permanente, y eso es un problema distinto y peor que una expresión lenta.

Cuando la regla devuelve nada

El caso más común en el laboratorio no es la expresión que falla. Es la que devuelve nada. Y para quien no lee el lenguaje, esos dos casos se parecen demasiado: en los dos la pantalla no muestra un resultado.
El Exerciser oficial de JSONata es una buena herramienta y tiene un límite claro ahí: te dice que el resultado es nothing, no en qué paso se perdió. Para un dev eso es una cacería a mano, borrando pedazos de la derecha hasta que algo devuelve un valor. Para alguien de operaciones es un callejón sin salida.
Así que el laboratorio hace esa cacería solo. Descompone la expresión en prefijos acumulados, evalúa cada uno y los muestra en orden: el primer nothing de la lista es el eslabón que rompió.
…
Corre jsonata-js 2.2.2 en tu navegador, el motor de referencia. Corta a 500 niveles de profundidad y 1000 ms.
La descomposición solo se intenta sobre navegación pura. En cuanto la expresión tiene asignaciones, el operador de transformación, funciones anónimas o bloques, los prefijos dejan de ser sub-expresiones válidas y el resultado sería basura, así que devuelve vacío. Una herramienta que resuelve el caso común y avisa cuando no aplica sirve más que una que adivina.

Lo que cambió y lo que sigue abierto

El cuello de botella se fue, y esta vez las métricas sí se movieron. Cruzando las trazas de los agentes en Langfuse con las métricas del motor, la generación, el refinamiento y el ajuste de reglas subieron al menos un 40%. El dato que más me gusta no es el de la generación sino el del ajuste: las reglas se retocan más, porque retocar ya no cuesta un ticket.
Una regla nueva ya no es un ticket nuestro, un PR, una revisión y un despliegue: es alguien de operaciones escribiendo en español lo que quiere, viendo el resultado sobre pedidos reales y publicando. Y cuando lo que sale no es lo que esperaba, esa persona se entera en ese momento y no tres días después, que era la parte más cara de todo el proceso viejo. Yo dejé de ser el traductor, que era el eslabón que más se equivocaba.
Lo que sigue abierto es una pregunta de altitud. El sistema ya tenía cuatro mini-lenguajes propios: uno para las condiciones, uno para las plantillas, uno para las expresiones aritméticas y uno para las variables de cada nivel. Tres de los cuatro están implementados dos veces, en el backend y otra vez en el frontend, bajo comentarios que dicen KEEP IN SYNC. Ese comentario no es una nota: es una deuda con intereses. Si JSONata se queda como un quinto al lado de los otros cuatro, empeora exactamente el problema que vino a resolver. El valor aparece solo si reemplaza.
Si algo me llevo de todo esto es que ceder el control no fue el trabajo difícil. Darle a alguien un campo de texto se hace en una tarde. Lo difícil fue lo que vino después: descubrir que un lenguaje no se entrega solo, que la gente iba a resolver el hueco por su cuenta con las herramientas que tuviera a mano, y que en el momento en que la persona deja de leer el código lo único que le queda para confiar es lo que ve en la pantalla.
Ceder el control no fue darles un campo de texto. Fue conseguir que lo que ven ahí sea verdad.
Todos los artículos