Al terminar esta lección
- Revisar un diff generado por IA en el orden autoridad, invariantes, transacción, errores, tests y estilo.
- Detectar una carrera de lectura y escritura, una autoridad de precio trasladada al cliente y un reintento que duplica efectos.
- Explicar por qué una suite verde no alcanza para integrar un cambio que toca una invariante.
- Pedir evidencia ejecutable (comando, exit code, mutación) en lugar de un “listo”, y verificarla contra el repositorio.
- Clasificar cada cambio del agente como dentro del alcance, fuera del alcance o pendiente de aprobación.
Nexo B2B · Tres diffs en una tarde
El viernes a las cuatro, el agente de Nexo entregó el checkpoint C del SLICE-C-RESERVA. El mensaje era corto: “Persistencia real y reserva atómica implementadas. Tests verdes. Lista para review.” El Responsable del Desarrollo no tomó ese mensaje como evidencia. Abrió el diff y lo recorrió con el mismo orden que usa con cualquier código que no escribió.
El diff tenía lo que el agente produce mejor: código limpio, nombres razonables, funciones cortas y tests que pasaban. Los tres problemas de esta lección no se ven en una primera lectura. Aparecen cuando preguntás qué pasa con dos requests simultáneos, quién decide el precio y qué ocurre cuando la red corta después del commit. Esa es la revisión semántica: leer el código como una afirmación sobre el negocio, no como texto que compila.
9.12 El orden de revisión
Un diff generado por IA llega con una forma engañosa: es grande, uniforme y coherente en el estilo. Esa uniformidad hace que el ojo recorra líneas en lugar de preguntas. La solución es empezar por el mapa del cambio y recién después bajar a las líneas. Primero mirá la lista de archivos tocados y cuántas líneas tiene cada uno. Un cambio que pretende tocar la reserva y además modifica una migración o un DTO compartido necesita una explicación antes de cualquier lectura.
Después mirá los contratos. Un cambio de firma, de tipo de retorno o de estado observable tiene efectos fuera del archivo donde ocurre, y conviene detectarlo antes de revisar la lógica interna. Recién entonces seguí el flujo de negocio de punta a punta: entrada, validación, caso de uso, persistencia, efectos externos y resultado. Cada tramo tiene una pregunta concreta. La entrada, ¿de quién viene cada dato? La validación, ¿qué rechaza y qué deja pasar? El caso de uso, ¿qué decisión toma y con qué fuente? La persistencia, ¿qué se confirma junto? Los efectos externos, ¿qué pasa si fallan después del commit?
Revisá en este orden. Cada paso puede detener la revisión, y el siguiente no tiene sentido si el anterior falló.
- Alcance. ¿Los archivos tocados están en el plan aprobado? Un archivo inesperado es una pregunta antes que una línea.
- Autoridad. ¿Alguna decisión de precio, crédito, estado o permiso sale del request del cliente?
- Invariantes. ¿La condición que protege la regla vive en un lugar que sobrevive a la concurrencia?
- Transacción y errores. ¿Qué se confirma junto y qué queda afuera? ¿Algún error de infraestructura se convierte en un estado de negocio que nadie decidió?
- Idempotencia y reintentos. ¿Qué pasa si el mismo intento llega dos veces, o si la respuesta se pierde después del commit?
- Tests. ¿Alguno falla si quitás deliberadamente la protección que dice cubrir?
- Estilo y nombres. Solo después.
El paso 6 es el más subestimado. Un test puede ejecutar el código correcto sin ejercitar el riesgo, y eso es exactamente lo que ocurrió con el primer diff.
Diff 1: la transacción que no protegía nada
El agente había recibido la instrucción de hacer la reserva atómica. Su respuesta fue envolver el adapter en una transacción y devolver un resultado tipado. El cambio, reducido a lo que importa, es este:
export class PostgresStockRepository implements StockRepository {
- async reserve(input: ReserveInput): Promise<ReserveResult> {
- const row = await this.pool.query(STOCK_SQL, [input.depotId, input.sku]);
- ...
+ async reserve(tx: Tx, input: ReserveInput): Promise<ReserveResult> {
+ const { rows } = await tx.query(
+ "select on_hand - reserved as available from stock_level where depot_id = $1 and sku = $2",
+ [input.depotId, input.sku]
+ );
+ if (rows[0].available < input.quantity) {
+ return { kind: "InsufficientStock", depotId: input.depotId, sku: input.sku, available: rows[0].available };
+ }
+ await tx.query(
+ "update stock_level set reserved = reserved + $1 where depot_id = $2 and sku = $3",
+ [input.quantity, input.depotId, input.sku]
+ );
+ return { kind: "Reserved", depotId: input.depotId, sku: input.sku, quantity: input.quantity };
}
}
Visto desde afuera, el cambio parece mejor que el original: hay transacción, hay una comprobación explícita y hay un resultado tipado. Pero la comprobación y la escritura son dos sentencias separadas. Entre ellas, otra transacción puede leer el mismo valor. Con READ COMMITTED, que es el nivel por defecto de PostgreSQL, dos transacciones pueden leer available = 1, pasar el if y después incrementar reserved cada una. Una transacción no protege nada por sí sola. Lo que protege es que la condición y la escritura se evalúen como una sola operación sobre la fila.
¿Dónde está la carrera?
Hacé clic en las líneas que permiten que dos reservas confirmen la última unidad.
Alternativas que el agente propuso y por qué no alcanzaban
Antes de escribir el adapter, el agente había propuesto tres caminos. Vale la pena verlos porque cada uno suena razonable, y dos de ellos tienen costos que no se ven en el diff.
La primera alternativa era subir el nivel de aislamiento a SERIALIZABLE y dejar que la base resolviera la carrera. Funciona como garantía, pero tiene un costo operativo: PostgreSQL devuelve un error de serialización cuando detecta el conflicto, y el código tiene que reintentar esa transacción. Ese reintento es una nueva fuente de efectos si no está bien diseñado, y el mecanismo es más difícil de razonar que una sentencia condicional. No es el cambio mínimo que preserva la invariante.
La segunda alternativa era un lock por SKU en memoria del proceso. Con una sola instancia podría parecer suficiente, pero el monolito modular del ADR-001 puede correr en más de una réplica del nodo de aplicación, y un Map de locks en un proceso no ve a los demás. Si la solución hubiera pasado por Redis, habría contradicho ADR-002 y agregado una dependencia sin ASR que la justifique.
La tercera alternativa era refactorizar el repositorio para separar consultas y comandos. Es una decisión de diseño válida en otro contexto, pero no cambia la garantía: la carrera seguiría ahí aunque el código quedara más prolijo. El agente la había incluido por reflejo de limpieza.
La alternativa elegida era la más chica: una sentencia condicional dentro de la transacción que ya existía. La decisión no se tomó por gusto. Se tomó porque es el único mecanismo de los cuatro que protege la invariante en la base, sin infraestructura nueva y sin cambiar el contrato, y porque el test concurrente puede demostrarlo.
El diagrama muestra el mecanismo que la lectura del código deja escondido. Dos transacciones que parecen independientes comparten una fila, y la decisión se toma sobre una foto vieja.
La prueba que corresponde no es una lectura del código sino un test que intenta reproducir la carrera. Lo escribís antes del cambio y lo ves fallar. Esta es la versión de la matriz de pruebas del módulo 10 para AC-06: con una unidad y dos reservas concurrentes, exactamente una confirma.
it("solo una reserva gana la última unidad", async () => {
await stock.setAvailable(depotId, sku, 1);
const start = barrier(2);
const reserve = (orderId: string) =>
db.transaction(async tx => {
await start.wait();
return repo(tx).reserve({ orderId, depotId, sku, quantity: 1 });
});
const [a, b] = await Promise.all([reserve("O-A"), reserve("O-B")]);
expect([a, b].filter(x => x.kind === "Reserved")).toHaveLength(1);
expect([a, b].filter(x => x.kind === "InsufficientStock")).toHaveLength(1);
expect(await stock.available(depotId, sku)).toBe(0);
});
Este test tiene un detalle que separa la evidencia de la apariencia. Corre con dos conexiones reales (el pool de tests tiene que ser de al menos dos). Con una sola conexión, las dos transacciones se ejecutan en serie, el test pasa aunque la carrera exista y el verde no dice nada sobre RN-05. Antes de confiar en él, hay que ver que falla con el diff del agente y que pasa con la condición correcta.
Diff 2: el precio que llegó en el request
El segundo cambio no tenía un diff bonito: era una simplificación. El agente quería reducir round-trips y notó que la UI ya mostraba el precio. Decidió usarlo cuando viene en el item del request. El problema está en el bloque que calcula líneas y total:
¿Quién decide el precio?
Hacé clic en las líneas donde el precio deja de venir del servidor o donde ese precio se usa para decidir.
El error compila, respeta el estilo del proyecto y deja verde la suite anterior. Viola la autoridad de precio, que es una garantía del negocio y no un detalle de implementación. La comparación que sigue es la que conviene tener al lado cuando revisás un request.
Precio informativo y precio autoritativo
const payload = {
requestId: "req-410",
clientId: "C-118",
items: [{ sku: "AX-442", quantity: 2, unitPrice: 1 }]
};
El servidor usa unitPrice si viene. Un request con precio 1 produce un pedido de dos unidades a un peso cada una.
const payload = {
requestId: "req-410",
clientId: "C-118",
items: [{ sku: "AX-442", quantity: 2 }]
};
El servidor consulta PricePolicy por cada SKU: el precio de lista vigente (12.450 por unidad para AX-442) o un acuerdo autorizado vigente. El total y la evaluación de crédito salen de esa fuente. Si la UI mostraba otro precio, el pedido lo registra como cambio, no como verdad.
La prueba que corresponde es el AC-04 escrito como test. Tiene que fallar con el código del agente: el total devuelto sería 2 en lugar de 2.490.000 centavos.
it("ignora el unitPrice que manda el cliente y usa el precio del servidor", async () => {
const env = await fixture({
stock: { "AX-442": 8 },
priceCents: { "AX-442": 1_245_000 }
});
// El payload trae un campo que el contrato no define
const payload = {
requestId: "req-410",
sellerId: env.sellerId,
clientId: env.clientId,
items: [{ sku: "AX-442", quantity: 2, unitPrice: 1 }]
};
const result = await env.createOrder(payload);
expect(result).toEqual({ kind: "confirmed", orderId: expect.any(String), totalCents: 2_490_000 });
});
El fixture es el de la matriz del módulo 10. Lo importante es la forma del test: el cliente manda un campo que no le corresponde, y el oráculo mira el resultado observable, no la implementación.
Diff 3: el reintento que duplicaba pedidos
El tercer cambio era de infraestructura. El agente agregó un wrapper “para tolerar cortes de red”, porque el vendedor en depósito pierde señal a veces. El código parece un patrón estándar:
¿Qué pasa si la respuesta se pierde después del commit?
Hacé clic en las líneas que rompen la idempotencia o reintentan lo que no debería reintentarse.
Vale la pena detenerse en la forma correcta del reintento, porque el agente no la tenía. Tres decisiones la definen. La primera es quién genera el requestId: debe nacer con la intención, en el momento en que el vendedor confirma el pedido, y viajar en el comando sin cambios. La segunda es qué se reintenta: solo las fallas transitorias de red o de timeout, nunca una respuesta de negocio como un rechazo de crédito o un conflicto. La tercera es qué devuelve el servidor cuando recibe la misma clave: el mismo pedido, sin una segunda reserva, y no un error. Esas tres decisiones no son de implementación. Están en RN-06, en ADR-005 y en AC-07, y el diff del agente las ignoró todas.
El reintento era plausible porque la intención de “reintentar al caer la red” es correcta. Lo que faltaba era la pregunta de la clave: ¿la misma intención llega dos veces con el mismo requestId o con uno nuevo? La respuesta define si el sistema es idempotente. Esa pregunta no aparece en los tests del agente, porque ningún test simula que la respuesta se pierde después de un commit exitoso. El test que la cubre es el de la matriz de pruebas: mismo requestId, mismo contenido, un solo pedido.
Esta clase de defecto es la que más conviene revisar con un segundo agente que solo recibe spec y diff. Un revisor distinto del que escribió el código tiende a buscar la pregunta del requestId antes que la del estilo, sobre todo si su prompt le fija prioridades: contradicciones con la spec, autoridad de datos, garantías transaccionales, idempotencia y alcance, en ese orden.
Ese segundo revisor no es evidencia. Puede encontrar un defecto que vos no viste, y también puede inventar uno plausible. Por eso su resultado se trata como una lista de hipótesis: cada observación se reproduce con un comando o un test, y las que no se reproducen se descartan y se anotan como tales. Lo que el segundo agente no puede hacer es decidir si el defecto es de código o de especificación. Esa clasificación vuelve al bundle.
Nexo B2B · Cuando el agente dice que está listo
El diff de la reserva llegó con un mensaje de cierre. Antes de integrar, el Responsable del Desarrollo tenía que decidir qué pedir. La conversación siguiente se parece a la que tienen muchos equipos a diario. Elegí la respuesta que sostiene la decisión de integrar.
El agente dice que el adapter está listo
Miércoles a la tarde. El agente terminó el adapter del checkpoint C y te escribe en la sesión. El diff está abierto en otra pestaña.
Agente
Terminé el adapter. Ahora la reserva usa un UPDATE condicional dentro de la transacción. Los cuatro tests de integración pasan. ¿Lo integramos?
¿Qué respondés?
El recorrido correcto no es el más rápido. Lo que lo hace correcto es que cada paso produce una evidencia distinta: el comando dice que corrió, la mutación dice que el test defiende la regla, y la explicación del mecanismo dice por qué la regla se cumple.
9.13 Qué evidencia pedir cuando el agente dice “pasa”
Pedí el comando exacto, el exit code y el resumen de la suite. Si el agente no pudo ejecutar el entorno real, no dejes que esa limitación se convierta en una afirmación. “No pude levantar PostgreSQL porque Docker no está disponible” es una salida válida. “Los integration tests deberían pasar” no lo es. El siguiente paso puede ser ejecutarlo vos, cambiar de entorno o dejar el gate bloqueado. La transparencia sobre lo que no corrió vale más que una sensación falsa de avance.
Evidencia débil y evidencia que sostiene la decisión
“Los tests pasan, la reserva es atómica y lo probé con varios casos.”
No dice qué corrió, cuántos casos eran concurrentes ni qué pasaría si la condición no existiera. No se puede revisar ni repetir.
npm run test:integration -- stock-reservation, exit code 0, 4 passed. Mutación: sin la condición del UPDATE, solo una reserva gana la última unidad falla con dos Reserved. Mecanismo: la condición y la escritura son la misma sentencia sobre la fila.
Cada afirmación tiene un comando, un resultado y una forma de comprobarla.
Archivos modificados.
Decisiones humanas utilizadas.
Supuestos que quedaron abiertos.
Comandos ejecutados y resultado (con exit code).
Tests que NO pudieron ejecutarse.
Cambios de contrato o de esquema.
Riesgos o seguimientos pendientes.
Diff fuera de alcance: ninguno, o lista explícita.
Nexo B2B · Expediente 09: el diff de 43 líneas
Después de los tres diffs, llegó un cambio de dominio más pequeño: validar que la cantidad de una línea sea múltiplo de la unidad de venta (RN-08). El agente propuso primero un plan largo que incluía refactorizar DTOs, renombrar servicios y separar un módulo de validación que nadie había pedido. El Responsable del Desarrollo recortó el plan hasta lo que el slice pedía: una invariante en dominio, un test unitario y un adapter que carga la política de múltiplos. El diff final ocupa 43 líneas.
export function validateQuantity(line: OrderLine, pack: PackPolicy): void {
if (line.quantity <= 0) throw new InvalidQuantity(line.sku, line.quantity);
+ if (line.quantity % pack.packSize !== 0) {
+ throw new InvalidQuantity(line.sku, line.quantity, pack.packSize);
+ }
}
Hay un detalle que no está en el diff. Si el adapter no encuentra packSize para un SKU, la tentación es devolver 1 por defecto. Ese default es una regla de negocio inventada: un SKU que se vende por caja cerrada aceptaría unidades sueltas. El cambio correcto lanza un error explícito y pregunta. Esa es la respuesta que esperás del agente y la que tenés que verificar en el diff.
Lo que cuenta de este cambio es la clasificación. Cada línea del diff, del plan y de las observaciones del agente pertenece a una de tres categorías, y esa clasificación es la que decide qué se integra.
¿Dónde cae cada cambio?
Elegí la categoría de cada cambio propuesto por el agente en el expediente 09.
Validar que la cantidad sea múltiplo de packSize en la invariante de la línea.
Renombrar OrderRepository a SalesOrderRepository en doce archivos “para claridad”.
Crear una tabla pack_policy con migración para guardar los múltiplos.
Agregar un test unitario para cantidad exacta del múltiplo y cantidad múltiplo menos uno.
Extraer los DTOs de pedido a un paquete compartido.
Comprobá tu criterio
El agente cambia una regla no aprobada y dice que todos los tests pasan. ¿Cómo revisás el cambio?
Tu agente responde: “Ahora todo corre en una transacción, así que no puede haber carrera por la última unidad”. ¿Qué respondés?
Nexo B2B · Tres lecciones de la misma tarde
La tarde no terminó con un diff perfecto. Terminó con tres rechazos, cada uno con una razón trazable. El primero ocurrió porque el agente confundió la transacción con la protección. El segundo, porque el agente confió en un dato que el cliente podía escribir. El tercero, porque el agente trató un intento de red como si fuera una intención nueva.
La revisión no buscaba un código más bonito. Buscaba preguntas que el código no respondía. En los tres casos, el agente había producido algo que compilaba y pasaba su propia suite, así que la pregunta correcta no era “¿funciona?” sino “¿qué decisión tomó sin que nadie se la diera?”.
Esa diferencia es la que la IA no reemplaza. Un agente puede sugerir la causa, escribir el test y proponer la corrección. La decisión de qué es correcto para Nexo, y de qué evidencia alcanza para integrarlo, sigue siendo humana.
Qué puede hacer el agente: revisar diffs contra la spec, buscar archivos y símbolos que contradigan el plan, escribir tests que reproduzcan un riesgo, proponer mutaciones y resumir la evidencia.
Qué no debe decidir: si una regla es correcta, si un valor de negocio existe, si una garantía se cumple sin ejecución, ni si el diff se integra.
Contexto mínimo útil: el bundle del slice, el diff completo, los archivos focales citados por el diff y la lista de gates. Nada de “todo el repo”.
Cómo verificar: cada hallazgo del revisor se reproduce con un comando o un test. Los que no se reproducen se descartan y se anotan como descartados.
Prompt de ejemplo (revisión semántica de un diff):
Rol: revisor de diffs, no implementás nada.
Te paso el bundle del slice y un diff. Revisalo en este orden:
1. ¿Hay archivos fuera del plan aprobado? Listalos.
2. ¿Alguna decisión de precio, crédito, estado o permiso depende de un valor que llega del request?
3. ¿La condición que protege una invariante se evalúa en la misma sentencia que la escritura, o con la fila bloqueada? Citá archivo y línea.
4. ¿Un reintento o una respuesta perdida puede producir un segundo efecto? ¿Qué requestId usa?
5. ¿Qué test falla si quitás la protección? Si ninguno, decilo.
No evalúes estilo. Para cada hallazgo, dame el comando o test que lo reproduce.
Cómo revisar su salida: ejecutá los comandos que propone, descartá los hallazgos que no se reproduzcan y llevá al bundle, no al chat, los que sean reales.
Errores frecuentes
- Síntoma: el agente dice “la reserva es atómica” y el test pasa. Causa: la transacción envuelve la lectura y la escritura, pero no hay una sentencia condicional ni un lock. El test corre con una sola conexión. Corrección: pedir la sentencia SQL, un test con dos conexiones que fuerce el orden y la mutación que lo hace fallar.
- Síntoma: el total de un pedido cambia según lo que mande la UI. Causa: el contrato acepta un precio en el item y el servidor lo usa si viene. Corrección: quitar el campo del contrato, resolver el precio con
PricePolicyy testear con un precio distinto al de lista. - Síntoma: un timeout genera dos pedidos. Causa: el reintento genera un
requestIdnuevo en cada intento. Corrección: elrequestIdnace con la intención, se reutiliza en cada reintento y el servidor devuelve el mismo pedido. - Síntoma: el diff cambia nombres en doce archivos y el reviewer no encuentra la regla nueva. Causa: el plan no fijó alcance y nadie separó el refactor del cambio funcional. Corrección: revisar el alcance antes de las líneas y devolver el refactor en un cambio aparte.
- Síntoma: un SKU sin
packSizese vende como si fuera unitario. Causa: el adapter devolvió 1 como default para no fallar. Corrección: error explícito y pregunta a la spec; el default es una regla de negocio inventada.
Para llevarte
- El orden de revisión es alcance, autoridad, invariantes, transacción, idempotencia, tests y estilo. Cambiarlo cambia lo que ves.
- Una transacción no es una garantía: la garantía está en la sentencia condicional o en el lock, y se demuestra con un test que fuerza la concurrencia.
- El precio, el crédito y el estado nunca los decide el request. Si aparece un campo de esos en el contrato, el diff se rechaza hasta que la autoridad vuelva al servidor.
- El
requestIdidentifica la intención. Un reintento de red reutiliza la clave; un intento nuevo de la persona, no. - La evidencia es comando, exit code, mutación y mecanismo. “Pasan” sin esas piezas no alcanza para integrar.
¿Cómo valoras el contenido técnico de esta lección?
Tu feedback nos ayuda a mejorar los laboratorios prácticos de Eje Z.