Arquitecto de automatización con IA: Día 76
Índice de contenido
Resumen:
Una noche de jueves, una promoción de “Pay de queso 2x1” que Don Ernesto había rechazado con el botón rojo salió publicada en Instagram y Facebook y estuvo visible 42 minutos. En esta entrada, Valeria reconstruye la línea de tiempo segundo a segundo con los registros de ejecuciones, aprobaciones y publicaciones, reproduce la condición de carrera entre el reloj de caducidad y el webhook del botón, separa el disparador (la carrera) de la causa (un valor por defecto que publica) con cinco porqués y un árbol de fallos, y corrige los dos workflows con escrituras condicionales y el principio de falla cerrada. Al terminar, usted sabrá diagnosticar un fallo de aprobación humana, decidir qué flujos deben fallar cerrados, escribir las pruebas que lo habrían detectado y redactar un análisis posterior sin culpables.
¿Cómo citar el presente artículo?
Romero, J. (17 de abril de 2026). Arquitecto de automatización con IA: Día 76. python-data-engineer. https://www.python-data-engineer.com/post/aai/dia_76/.
Arquitecto de automatización con IA: Día 76 by Jeshua Romero Guadarrama, available under Attribution 4.0 International (CC BY 4.0) at https://www.python-data-engineer.com/post/aai/dia_76/.
Precios, límites y políticas revisados en octubre de 2026: planes, precios y límites de Make, n8n, Zapier, WhatsApp (Meta, Twilio, Wati) y de la API de Claude. Lo específico de hoy se revisó en octubre de 2026 en la documentación de n8n: la opción “Fallback Output” del nodo Switch (con los valores “None”, que descarta el elemento y es el comportamiento por defecto, “Extra Output” y “Output 0”, y que va dentro de options en el JSON), el nodo Stop and Error con su tipo de error y su mensaje, y la reanudación del nodo Wait por llamada a webhook con tiempo límite. El JSON del Bloque 7 tiene la estructura verificada contra la documentación, revisado en octubre de 2026, con identificadores de ejemplo; no se exportó desde una instancia de n8n. El texto del artículo 42 de la Ley Federal de Protección al Consumidor se contrastó con versiones publicadas de la ley; antes de citarlo en un documento formal, consulte el texto vigente en el Diario Oficial de la Federación.
Día 76. Anatomía de un fallo real IV: la publicación que salió sin aprobación
Introducción
Ayer, en el Día 75 (Cuadros de mando de las automatizaciones: métricas, costes y errores), Valeria definió indicadores y objetivos de servicio para cada flujo, unificó los registros de Make, n8n, Claude y la mensajería en una sola tabla de ejecuciones, calculó el presupuesto de errores y Trigo Lento ganó un panel que Don Ernesto puede leer en un minuto. Ese panel, en su primera mañana completa, encendió una luz roja que nadie esperaba.
Una aclaración antes de seguir, la misma que abrió el Día 29 (Anatomía de un fallo real I: el webhook que duplicó 37 pedidos), el Día 39 (Anatomía de un fallo real II: el agente que prometió un pastel imposible) y el Día 54 (Anatomía de un fallo real III: la plantilla pausada y los recordatorios que no salieron): el incidente de hoy es ficticio, construido para enseñar. Las clientas, los mensajes, las horas y las cifras son inventados, y los registros se reconstruyen con datos de juguete, sin teléfonos ni nombres de clientes. Lo que no es inventado es el mecanismo: dos procesos que escriben sobre el mismo registro casi al mismo tiempo y un flujo que, ante un estado que no reconoce, hace lo más peligroso que puede hacer.
Es viernes 17 de abril, a las 8:00. Valeria abre el cuadro de mando del Día 75 con el café todavía caliente y ve un indicador nuevo, uno que añadió el miércoles casi como trámite: “publicaciones con aprobación válida”. Debería decir $36$ de $36$. Dice $35$ de $36$, en rojo. No le hace falta buscar mucho, porque ya sabe cuál es: la noche anterior, el jueves 16 de abril a las 20:00, salió en Instagram y en Facebook una pieza con el texto “Pay de queso 2x1 este sábado”. Valeria la retiró a mano a las 20:42, después de que una clienta preguntara por mensaje directo si la promoción era cierta. Lo que la deja helada es otra cosa: en la tabla aprobaciones, la fila de esa pieza dice “rechazada”.
Don Ernesto llega al obrador a las 8:20, se quita el gorro y no se sienta. “Yo dije que no. Lo dije con el botón rojo. ¿Para qué me pregunta la máquina si luego hace lo que quiere?” No hay forma amable de contestar eso, porque tiene razón. El Día 74 (Humano en el bucle: aprobaciones con Slack y WhatsApp) le prometió que nada con el nombre de la panadería saldría sin que él lo viera, y la máquina le preguntó, él contestó y la máquina publicó de todos modos. Peor todavía: el registro dice que obedeció. El sábado, nueve clientas llegarán al mostrador con la captura de pantalla.
En la presente entrada, descubrirá cómo se analiza un fallo de aprobación humana con el mismo método de los tres incidentes anteriores. Reconstruirá la línea de tiempo segundo a segundo con los registros de ejecuciones, aprobaciones y publicaciones, y medirá cuánto tardó el sistema en darse cuenta por sí solo. Reproducirá en Python la condición de carrera entre el reloj de caducidad y el webhook del botón, con dos hilos que escriben sobre una copia de trigo_lento.sqlite, y comprobará que una actualización condicional la neutraliza. Aplicará cinco porqués y un árbol de fallos para distinguir el disparador de la causa, y verá por qué el principio de falla cerrada de Saltzer y Schroeder, escrito en 1975 para sistemas operativos, decide hoy qué hace un flujo de publicación ante un estado desconocido. Después corregirá TL Aprobaciones y TL Publicación multired, escribirá las pruebas que habrían detectado el fallo y generará el análisis posterior sin culpables con acciones, dueños y fechas.
Objetivos de aprendizaje
Al terminar esta entrada, usted podrá:
- Reconstruir la línea de tiempo de un incidente de aprobación humana a partir de los registros de
ejecuciones,aprobacionesy publicaciones, y medir el tiempo de detección. - Diagnosticar la causa raíz con cinco porqués y un árbol de fallos, distinguiendo el disparador (una condición de carrera) de la causa (un valor por defecto que publica).
- Corregir la máquina de estados del Día 74 con transiciones validadas en la base de datos y aplicar el principio de falla cerrada a toda acción con efecto reputacional o económico.
- Escribir las pruebas que habrían detectado el fallo y un análisis posterior sin culpables con acciones, dueños y fechas.
Un incidente ilustrativo
Antes de mirar registros, conviene fijar qué se sabe y cuánto costó. El jueves 16 de abril, a las 18:00, el agente de contenido del Día 71 preparó una pieza de última hora: el obrador había horneado de más para el fin de semana y Valeria propuso, en la base de Notion, una promoción de pay de queso para el sábado. El borrador entró al circuito de aprobación del Día 74 con hora de publicación a las 20:00, la franja de la noche en Facebook. Don Ernesto lo vio en su WhatsApp a las 19:00, lo pensó durante casi una hora (“un 2x1 en pay de queso es regalar trescientos ochenta pesos por cliente”) y, a las 19:59:58, pulsó “Rechazar”. A las 20:00:01, la pieza estaba en Instagram y en Facebook.
El impacto se mide en tres monedas. La primera es el tiempo: la pieza estuvo publicada $42$ minutos, el tiempo suficiente para que la vieran varios cientos de seguidores y para que algunos la guardaran. La segunda es el dinero. La publicidad, en México, obliga a quien la emite: el artículo 42 de la Ley Federal de Protección al Consumidor dice que el proveedor está obligado a entregar el bien o suministrar el servicio de acuerdo con los términos y condiciones ofrecidos o implícitos en la publicidad o información desplegados, salvo convenio en contrario o consentimiento escrito del consumidor. El Día 72 (Publicación multired: Instagram, Facebook y LinkedIn por API o con Make y n8n) ya había convertido esa ley en comprobaciones; hoy toca pagarla. El sábado, nueve clientas pidieron el 2x1 y Trigo Lento lo honró sin discutir: cada una se llevó un segundo pay de $380$ MXN sin pagarlo. La tercera moneda es la confianza, y esa no tiene tipo de cambio: la de Don Ernesto en la máquina, que es la que sostiene todo el módulo.
El coste directo del incidente se calcula así:
$$ C = k \cdot t, \qquad m = \frac{C / \text{tc}}{P} $$
donde:
- $k = 9$ es el número de clientas que pidieron la promoción y a las que se les cumplió.
- $t = 380$ MXN es el precio del pay de queso entero (P12), el que regaló cada 2x1.
- $\text{tc} = 18.50$ MXN por USD es el tipo de cambio didáctico de la serie.
- $P = 45$ USD es el presupuesto mensual de todo el ecosistema, y $m$ cuántos meses de ese presupuesto se fueron en una noche.
El resultado es $C = 9 \cdot 380 = 3,420$ MXN, unos $184.86$ USD, y $m = 184.86 / 45 = 4.11$: una sola publicación sin aprobación costó más que cuatro meses de todo el ecosistema de automatización ($4 \cdot 45 = 180$ USD). Las automatizaciones de Trigo Lento ahorran horas, pero una sola pieza fuera de control se come ese ahorro en un sábado. Con esa vara se juzgará todo lo que viene.
Atención aquí: Ante un error así, la tentación es borrar la publicación y no decir nada. No lo haga. La pieza estuvo visible, alguien la guardó y la ley obliga a cumplir lo publicado. Trigo Lento retiró las dos piezas, publicó a la mañana siguiente una aclaración breve y honesta (“Anoche se publicó por error una promoción que no estaba aprobada; a quien la haya visto, se la cumplimos este sábado”) y cumplió a cada clienta que la pidió. El error del sistema no se traslada al consumidor. Tampoco se escribe a las clientas una por una para “explicar”: la aclaración va por el mismo canal por el que salió el error y el análisis del incidente no guarda nombres ni teléfonos de nadie.
Línea de tiempo y detección
Un incidente se cuenta primero en orden y con evidencias, antes de cualquier hipótesis. Valeria tiene cuatro fuentes, y la primera ya es una ganancia del módulo: la tabla común ejecuciones del Día 75, que guarda una fila por ejecución de cada flujo con su versión, su inicio, su fin y su estado. Las otras tres son la tabla aprobaciones del Día 74, con el historial de cada escritura, el registro de publicaciones del Día 72, con la hora de cada pieza y su identificador en la red, y las personas: Don Ernesto con su teléfono, la clienta que escribió y el propio panel.
Recuerde cómo estaban conectadas las piezas. TL Publicación multired v1 (Día 72) tomaba cada pieza programada y, desde el Día 74, llamaba al sub-workflow TL Aprobaciones v1 y esperaba su respuesta. TL Aprobaciones v1 creaba la fila en estado pendiente, avisaba en #aprobaciones, escalaba a Valeria a los $30$ minutos y a Don Ernesto a los $60$, y esperaba en un nodo Wait con un límite de dos horas. Si alguien pulsaba un botón, el webhook de TL WhatsApp entrantes escribía la decisión en la tabla y reanudaba la espera; si se acababa el tiempo, el propio Wait continuaba, escribía caducada y devolvía ese estado a quien lo llamó. El primer bloque fija las importaciones, la semilla y los supuestos del día:
# Importaciones base, semilla de la serie, zona horaria, carpeta del día y supuestos en Python:
import warnings
warnings.filterwarnings('ignore')
import json
import sqlite3
import textwrap
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import numpy as np
import pandas as pd
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch, Polygon, RegularPolygon, Wedge, Rectangle
# Generador aleatorio con la semilla global de la serie:
rng = np.random.default_rng(2026)
pd.set_option('display.width', 140)
pd.set_option('display.max_colwidth', 70)
CDMX = ZoneInfo('America/Mexico_City')
carpeta = Path('trigo_lento') / 'dia_76'
carpeta.mkdir(parents = True, exist_ok = True)
# Supuestos del día (ficha) y datos fijos de la serie:
PUBLICACIONES_MES = 36 # piezas al mes en tres redes (Día 71)
P_CADUCIDAD = 0.04 # probabilidad supuesta de que una aprobación caduque
TICKET_PAY = 380 # pay de queso entero (P12), MXN
MXN_POR_USD = 18.50 # tipo de cambio didáctico de la serie
PRESUPUESTO_USD = 45 # presupuesto mensual del ecosistema
print('numpy', np.__version__, '| pandas', pd.__version__)
print('Desfase de la Ciudad de México el 16 de abril de 2026:', datetime(2026, 4, 16, 20, tzinfo = CDMX).strftime('%z'))
print(f'Supuestos: {PUBLICACIONES_MES} piezas al mes | caducidad {P_CADUCIDAD:.0%} | pay {TICKET_PAY} MXN')
numpy 2.4.6 | pandas 3.0.3
Desfase de la Ciudad de México el 16 de abril de 2026: -0600
Supuestos: 36 piezas al mes | caducidad 4% | pay 380 MXN
Los supuestos son los de la serie: $36$ piezas al mes en tres redes (el calendario del Día 71), una probabilidad de caducidad del $4%$ (una aprobación de cada veinticinco se queda sin respuesta en el plazo, una cifra supuesta que se discute más abajo) y el tipo de cambio didáctico. La ciudad de México no tiene horario de verano desde 2022, de modo que el desfase de la noche del incidente es $-06{:}00$, el mismo de todo el año: las horas de los registros se pueden comparar sin conversiones.
El segundo bloque reconstruye los tres registros con las filas que interesan, los guarda como CSV en la carpeta del día, los ordena en una sola línea de tiempo y dibuja la tarde completa y un acercamiento a los segundos decisivos:
# Registros reconstruidos del incidente (ejecuciones, aprobaciones y publicaciones) y línea de tiempo en Python:
dia = lambda h, m, s = 0, d = 16: datetime(2026, 4, d, h, m, s, tzinfo = CDMX)
ejecuciones = pd.DataFrame([
('TL Publicación multired', 'v1', dia(18, 0), dia(20, 0, 3), 'success', 'publicada en 2 redes'),
('TL Aprobaciones', 'v1', dia(18, 0), dia(20, 0, 0), 'success', 'devolvió estado "caducada"'),
('TL WhatsApp entrantes', 'v3', dia(20, 0, 2), dia(20, 0, 2), 'success', 'escribió estado "rechazada"'),
], columns = ['flujo', 'version', 'inicio', 'fin', 'estado', 'nota'])
aprobaciones = pd.DataFrame([ # historial de escrituras sobre la fila 'APR-0416-07'
(dia(18, 0, 0), 'Crear aprobación', None, 'pendiente', 'Slack #aprobaciones'),
(dia(18, 30, 0), 'Escalado', 'pendiente', 'pendiente', 'Valeria, por Slack'),
(dia(19, 0, 0), 'Escalado', 'pendiente', 'pendiente', 'Don Ernesto, por WhatsApp'),
(dia(20, 0, 0), 'Reloj de caducidad (Wait)', 'pendiente', 'caducada', 'escritor 1'),
(dia(20, 0, 2), 'Webhook del botón', 'caducada', 'rechazada', 'escritor 2'),
], columns = ['hora', 'escritor', 'antes', 'despues', 'canal'])
publicaciones = pd.DataFrame([
('TL-0416-PROMO-IG', 'instagram', dia(20, 0, 1), dia(20, 42, 10)),
('TL-0416-PROMO-FB', 'facebook', dia(20, 0, 1), dia(20, 42, 40)),
], columns = ['pieza', 'red', 'publicada', 'retirada'])
personas = pd.DataFrame([
(dia(19, 59, 58), 'WhatsApp de Don Ernesto', 'Pulsa "Rechazar"'),
(dia(20, 35, 0), 'Mensaje directo de una clienta', '"¿Es cierto lo del 2x1?"'),
(dia(8, 0, 0, d = 17), 'Cuadro de mando del Día 75', 'Aprobación válida: 35 de 36'),
], columns = ['hora', 'fuente', 'evento'])
for nombre, df in [('ejecuciones', ejecuciones), ('aprobaciones', aprobaciones), ('publicaciones', publicaciones)]:
df.to_csv(carpeta / f'{nombre}_incidente.csv', index = False)
linea = pd.concat([
aprobaciones.assign(fuente = 'tabla aprobaciones', evento = aprobaciones['escritor'] + ' (' + aprobaciones['canal'] + ')',
estado = aprobaciones['despues'])[['hora', 'fuente', 'evento', 'estado']],
publicaciones.assign(hora = publicaciones['publicada'], fuente = 'API de publicación',
evento = 'Publicada ' + publicaciones['pieza'], estado = 'caducada')[['hora', 'fuente', 'evento', 'estado']],
publicaciones.assign(hora = publicaciones['retirada'], fuente = 'API de publicación',
evento = 'Retirada a mano ' + publicaciones['pieza'], estado = 'rechazada')[['hora', 'fuente', 'evento', 'estado']],
personas.assign(estado = ['pendiente', 'rechazada', 'rechazada'])[['hora', 'fuente', 'evento', 'estado']],
]).sort_values('hora').reset_index(drop = True)
linea['hora_txt'] = linea['hora'].dt.strftime('%d/%m %H:%M:%S')
print(linea[['hora_txt', 'fuente', 'evento', 'estado']].to_string(index = False))
publicada, retirada = publicaciones['publicada'].min(), publicaciones['retirada'].max()
clienta, panel = personas['hora'].iloc[1], personas['hora'].iloc[2]
ventana = (aprobaciones['hora'].iloc[4] - personas['hora'].iloc[0]).total_seconds()
print(f'Ventana de la carrera (botón a escritura del webhook): {ventana:.0f} s')
hms = lambda td: str(td).split(' ')[-1] # duración como hh:mm:ss
print(f'Tiempo publicada: {hms(retirada - publicada)} | detección por una persona: {hms(clienta - publicada)}')
print(f'Detección por el propio sistema (panel de la mañana): {hms(panel - publicada)}')
# Línea de tiempo: la tarde completa y el acercamiento a los cuatro segundos de la carrera:
fig, (ax1, ax2) = plt.subplots(2, 1, figsize = (12, 7.4), gridspec_kw = {'height_ratios': [1, 1.25]})
carriles = {'Personas': 3, 'Webhook del botón (escritor 2)': 2, 'Reloj de caducidad (escritor 1)': 1, 'Flujo de publicación': 0}
hitos = [(dia(18, 0), 'Flujo de publicación', 'Borrador y\npedido de aprobación', '#2a78d6'),
(dia(18, 30), 'Personas', 'Escala a\nValeria', '#8a8a8a'),
(dia(19, 0), 'Personas', 'Escala a\nDon Ernesto', '#8a8a8a'),
(dia(20, 0, 1), 'Flujo de publicación', 'Publicada', '#d62728'),
(dia(20, 35), 'Personas', 'Aviso de\nuna clienta', '#8a8a8a'),
(dia(20, 42, 40), 'Flujo de publicación', 'Retirada\na mano', '#1f9d55')]
for hora, carril, texto, color in hitos:
ax1.plot(hora, carriles[carril], 'o', color = color, markersize = 10)
ax1.annotate(texto, (hora, carriles[carril]), xytext = (0, 14), textcoords = 'offset points',
ha = 'center', fontsize = 9)
ax1.axvspan(publicada, retirada, color = '#d62728', alpha = 0.12, label = 'Publicada sin aprobación válida (42 min)')
ax1.set_yticks(list(carriles.values()))
ax1.set_yticklabels(list(carriles.keys()), fontsize = 9)
ax1.set_ylim(-0.6, 3.9)
ax1.set_xlim(dia(17, 50), dia(21, 0))
ax1.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M', tz = CDMX))
ax1.legend(loc = 'lower right', fontsize = 9)
ax1.set_title('Jueves 16 de abril de 2026: la tarde del incidente', fontsize = 14)
cerca = [(personas['hora'].iloc[0], 'Personas', 'Don Ernesto pulsa\n"Rechazar" 19:59:58', '#8a8a8a'),
(dia(20, 0, 0), 'Reloj de caducidad (escritor 1)', 'Escribe "caducada"\n20:00:00', '#eb6834'),
(dia(20, 0, 1), 'Flujo de publicación', 'Switch: salida por defecto,\npublica 20:00:01', '#d62728'),
(dia(20, 0, 2), 'Webhook del botón (escritor 2)', 'Escribe "rechazada"\n20:00:02', '#7b3fb5')]
for hora, carril, texto, color in cerca:
ax2.plot(hora, carriles[carril], 's', color = color, markersize = 11)
ax2.annotate(texto, (hora, carriles[carril]), xytext = (0, 13), textcoords = 'offset points',
ha = 'center', fontsize = 9)
ax2.axvspan(personas['hora'].iloc[0], dia(20, 0, 2), color = '#eb6834', alpha = 0.15,
label = 'Ventana de la carrera: 4 segundos')
ax2.axvline(dia(20, 0, 0), color = '#52514e', linestyle = '--', linewidth = 1.2)
ax2.set_yticks(list(carriles.values()))
ax2.set_yticklabels(list(carriles.keys()), fontsize = 9)
ax2.set_ylim(-0.6, 3.9)
ax2.set_xlim(dia(19, 59, 56), dia(20, 0, 4))
ax2.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S', tz = CDMX))
ax2.legend(loc = 'lower left', fontsize = 9)
ax2.set_title('Acercamiento: dos escritores sobre la misma fila de aprobaciones', fontsize = 13)
plt.tight_layout()
plt.show()
hora_txt fuente evento estado
16/04 18:00:00 tabla aprobaciones Crear aprobación (Slack #aprobaciones) pendiente
16/04 18:30:00 tabla aprobaciones Escalado (Valeria, por Slack) pendiente
16/04 19:00:00 tabla aprobaciones Escalado (Don Ernesto, por WhatsApp) pendiente
16/04 19:59:58 WhatsApp de Don Ernesto Pulsa "Rechazar" pendiente
16/04 20:00:00 tabla aprobaciones Reloj de caducidad (Wait) (escritor 1) caducada
16/04 20:00:01 API de publicación Publicada TL-0416-PROMO-IG caducada
16/04 20:00:01 API de publicación Publicada TL-0416-PROMO-FB caducada
16/04 20:00:02 tabla aprobaciones Webhook del botón (escritor 2) rechazada
16/04 20:35:00 Mensaje directo de una clienta "¿Es cierto lo del 2x1?" rechazada
16/04 20:42:10 API de publicación Retirada a mano TL-0416-PROMO-IG rechazada
16/04 20:42:40 API de publicación Retirada a mano TL-0416-PROMO-FB rechazada
17/04 08:00:00 Cuadro de mando del Día 75 Aprobación válida: 35 de 36 rechazada
Ventana de la carrera (botón a escritura del webhook): 4 s
Tiempo publicada: 00:42:39 | detección por una persona: 00:34:59
Detección por el propio sistema (panel de la mañana): 11:59:59

La salida ordena doce hechos, y la columna de estado cuenta la historia mejor que cualquier frase. Hasta las 19:59:58 todo es pendiente: el pedido de aprobación, los dos escalados. A las 20:00:00 el reloj de caducidad escribe caducada; un segundo después, a las 20:00:01, las dos piezas están publicadas, con el registro diciendo caducada; y a las 20:00:02 llega el webhook del botón y escribe rechazada encima. Desde las 20:00:02, el registro afirma algo que el flujo nunca vio: que la aprobación terminó rechazada. Por eso a la mañana siguiente nadie entendía nada.
La gráfica superior muestra la tarde completa por carriles, uno por actor, y la franja roja de $42$ minutos en que las piezas estuvieron visibles. La inferior es el acercamiento a los $4$ segundos que van del dedo de Don Ernesto (19:59:58) a la escritura del webhook (20:00:02). En esos cuatro segundos hay dos escritores sobre la misma fila, en carriles distintos, que no saben el uno del otro. La Tabla 1 junta la evidencia con su fuente y lo que significaba cada hecho.
Tabla 1. Línea de tiempo del incidente de la publicación sin aprobación (incidente ficticio; horas de la Ciudad de México, jueves 16 y viernes 17 de abril de 2026).
| Hora | Fuente | Evento | Estado del registro | Comentario |
|---|---|---|---|---|
| 18:00:00 | Tabla ejecuciones y aprobaciones | TL Publicación multired v1 llama a TL Aprobaciones v1; aviso en #aprobaciones | pendiente | Pieza programada para las 20:00; plazo de dos horas |
| 18:30:00 | Tabla aprobaciones | Escalado a Valeria por Slack | pendiente | Valeria atendía la caja |
| 19:00:00 | Tabla aprobaciones | Escalado a Don Ernesto por WhatsApp | pendiente | Lo vio a tiempo y lo pensó |
| 19:59:58 | WhatsApp de Don Ernesto | Pulsa “Rechazar” | pendiente | La decisión existe, pero aún no llega |
| 20:00:00 | Tabla aprobaciones | El nodo Wait vence y escribe caducada | caducada | Escritor 1: el reloj |
| 20:00:01 | API de publicación | Se publican las piezas en Instagram y Facebook | caducada | El switch manda caducada a su salida por defecto |
| 20:00:02 | Tabla aprobaciones | El webhook del botón escribe rechazada | rechazada | Escritor 2: la persona; la base acepta una transición prohibida |
| 20:35:00 | Mensaje directo de una clienta | “¿Es cierto lo del 2x1?” | rechazada | Primera detección, humana |
| 20:42:10 y 20:42:40 | API de publicación | Valeria retira las dos piezas a mano | rechazada | Mitigación |
| Vie 8:00:00 | Cuadro de mando del Día 75 | “Aprobación válida: 35 de 36” | rechazada | Primera detección del propio sistema |
Con la tabla se miden los tiempos que el Día 29 enseñó a separar. El tiempo hasta detectar (o TTD, por sus siglas en inglés: “Time To Detect”) fue de $35$ minutos si se cuenta a la clienta, una detección humana y fuera del sistema. El tiempo hasta mitigar (o TTM, por sus siglas en inglés: “Time To Mitigate”) fue de $42$ minutos desde la publicación hasta la retirada de la segunda pieza. Y el tiempo hasta que el propio sistema se dio cuenta, sin ayuda de nadie, fue de $12$ horas: el panel de la mañana siguiente. Es la cifra más incómoda del día, porque dice que, si la clienta no hubiera escrito, la promoción habría seguido visible toda la noche.
La razón de esas doce horas es de diseño y no de descuido. El indicador del Día 75 se calcula una vez al día, en el lote de la madrugada; es un indicador de tendencia, pensado para el lunes de Don Ernesto, no una alarma. Para una acción irreversible y pública como publicar, el control no puede ser un promedio que se mira al día siguiente; tiene que ser una comprobación en el momento, dentro del propio flujo, antes de que la pieza salga. Esa idea vuelve en la corrección.
Diagnóstico
Con la línea de tiempo clara, toca preguntar por qué, con el mismo método de los días 29, 39 y 54: primero los cinco porqués, que siguen una cadena hasta algo que se puede cambiar; después el árbol de fallos, que dice qué combinación de condiciones fue necesaria; y al final la distinción entre lo que disparó el incidente y lo que lo causó.
Cinco porqués
Valeria los escribe en el cuaderno con Don Ernesto al lado, que corrige dos palabras y añade una pregunta:
- ¿Por qué se publicó una pieza rechazada? Porque cuando
TL Publicación multired v1decidió, a las 20:00:01, el estado que recibió deTL Aprobaciones v1no erarechazada, sinocaducada. - ¿Por qué el estado era
caducada? Porque el plazo de dos horas venció a las 20:00:00 y el rechazo, pulsado a las 19:59:58, llegó al servidor a las 20:00:02: el reloj escribió primero. - ¿Por qué
caducadallevó a publicar? Porque el switch del flujo de publicación tenía un solo caso,rechazada, que llevaba a bloquear la pieza, y una salida por defecto que seguía hacia la publicación. Todo lo que no fuerarechazadapublicaba. - ¿Por qué el switch tenía la salida por defecto hacia publicar? Porque el flujo de publicación se construyó el Día 72, cuando una aprobación solo podía terminar de dos maneras, aprobada o rechazada; en ese mundo, “todo lo que no sea rechazada” era lo mismo que “aprobada”. El estado
caducadanació dos días después, el Día 74, en otro workflow, y nadie volvió a mirar a quien lo llamaba. - ¿Por qué nadie lo probó? Porque la comprobación del Día 72 verificaba que existiera una aprobación con aprobador y con la huella del texto, no cuál era su estado; y porque no había ninguna prueba que pasara por el flujo un estado distinto de los dos conocidos.
La pregunta que añade Don Ernesto es la buena: "¿Y si la máquina hubiera llegado a tiempo, habría salido igual?" La respuesta corta es que sí, si él no hubiera contestado. Cualquier aprobación caducada, por un teléfono sin batería, una hornada que se alarga o una noche en que nadie mira, habría publicado igual. La carrera de cuatro segundos solo hizo que esta vez la caducidad coincidiera con un rechazo, y por eso el caso llamó la atención.
Árbol de fallos
El árbol de fallos parte del evento que no debe ocurrir, “pieza publicada sin aprobación válida”, y baja por las combinaciones de condiciones que lo producen. Usa dos compuertas. Una compuerta O se dispara si ocurre cualquiera de sus entradas; una compuerta Y, solo si ocurren todas. Con entradas independientes, sus probabilidades se combinan así:
$$ P_{\text{O}} = 1 - \prod_{i} (1 - p_i), \qquad P_{\text{Y}} = \prod_{i} p_i $$
La lógica de cada compuerta, aplicada a Trigo Lento:
- El evento superior es una compuerta O con tres ramas: un estado distinto de
aprobadaque pasa por una salida por defecto abierta, una aprobación falsificada (un botón cuya firma no se verifica, el problema del Día 50) y una publicación manual hecha fuera del flujo, por ejemplo desde la aplicación del teléfono. - La rama del incidente es una compuerta Y: hace falta que llegue un estado no aprobado y que el switch lo deje pasar. Basta con cerrar una de las dos entradas para que la rama valga cero.
- La entrada “llega un estado no aprobado” es otra compuerta O de la caducidad ($0.04$ por pieza, supuesto) y de un estado desconocido ($0.002$ por pieza, supuesto: un estado nuevo, una errata, un nulo). Esa entrada no se puede llevar a cero: las personas no siempre contestan a tiempo. Lo que sí se puede llevar a cero es la otra.
Los números de este árbol se calcularán más adelante, en la sección sobre falla cerrada, cuando se pueda comparar la versión 1 con la corregida. Por ahora basta con su estructura: la rama del incidente es una compuerta Y, y una compuerta Y se apaga cerrando cualquiera de sus entradas.
Disparador y causa
El diagnóstico separa dos cosas que la conversación del viernes por la mañana mezclaba:
- El disparador fue la condición de carrera: el rechazo llegó dos segundos tarde. Explica por qué el registro decía
rechazaday por qué el caso fue tan desconcertante. - La causa fue el valor por defecto que publica: un switch que trataba “cualquier cosa que no sea un no” como un sí. Con o sin carrera, cualquier aprobación caducada habría publicado igual.
La distinción decide la corrección. Arreglar solo la carrera (por ejemplo, alargando el plazo o acelerando el webhook) deja intacta la puerta abierta: la próxima caducidad sin botón publicará igual, y la probabilidad de que eso pase al menos una vez en un mes, como se verá enseguida, es del $77%$. Arreglar solo el valor por defecto sí elimina la publicación indebida, pero deja un registro capaz de contradecirse a sí mismo, que es justo lo que confundió a todos el viernes. Hacen falta las dos, y en este orden de importancia: primero la causa, después el disparador.
¿Qué tan rara era la carrera? Si se supone que, una vez escalada a Don Ernesto, la decisión cae en cualquier segundo de la última hora del plazo con la misma probabilidad, la fracción de decisiones que pierden contra el reloj es:
$$ P_{\text{carrera}} = \frac{L}{T} $$
donde:
- $L = 4$ s es la latencia entre el dedo y la escritura en la base (Meta entrega el webhook, n8n lo recibe y lo procesa).
- $T = 3,600$ s es la ventana en la que Don Ernesto decide, desde el escalado de las 19:00 hasta el vencimiento de las 20:00.
- $P_{\text{carrera}}$ es la probabilidad de que un botón pulsado dentro de esa ventana llegue después del plazo: $4 / 3,600 = 0.0011$, una de cada novecientas.
La carrera era muy rara; la caducidad, frecuente. Un arquitecto que solo ve el caso raro arregla el caso raro. El trabajo es ver el caso frecuente que el raro dejó al descubierto.
La condición de carrera
Una condición de carrera (en inglés, “race condition”) ocurre cuando el resultado de un sistema depende del orden en que se intercalan dos procesos que acceden al mismo recurso, y ese orden no está controlado. En Trigo Lento había dos escritores sobre la misma fila de aprobaciones: el reloj de caducidad del nodo Wait, que al vencer escribía caducada, y el webhook del botón de WhatsApp, que al llegar escribía la decisión. Ninguno miraba lo que había antes de escribir: los dos ejecutaban, en esencia, UPDATE aprobaciones SET estado = ? WHERE id = ?. Con esa sentencia rige la regla de “la última escritura gana” (“last write wins”): el estado final es el del último que llegó, aunque llegue tarde y aunque lo que había antes fuera un estado terminal.
La máquina de estados del Día 74 decía, en papel, que caducada es terminal: de ahí no se sale. Pero esa regla vivía en un diagrama, no en la base de datos, y la base aceptó una transición prohibida de caducada a rechazada. La defensa clásica tiene un nombre que viene de los procesadores: comparar e intercambiar (“compare and swap”), que escribe un valor solo si el valor actual es el que se esperaba, en una sola operación atómica. En SQL se escribe con una condición más en el WHERE: UPDATE aprobaciones SET estado = 'caducada' WHERE id = ? AND estado = 'pendiente'.
Si otro escritor llegó antes, la fila ya no está pendiente, la sentencia no toca nada y devuelve cero filas afectadas; quien escribe se entera de que perdió y actúa en consecuencia (lee lo que quedó, avisa a Slack). Encima de eso, un disparador de la base (un “trigger”) que rechaza cualquier cambio desde un estado terminal convierte la regla del diagrama en una regla de la base de datos.
El siguiente bloque lo reproduce con hilos de verdad. Crea una copia de la base con la tabla aprobaciones, un historial de transiciones y el disparador de estado terminal inmutable (activo solo para las filas de la versión 2), y lanza $1,000$ carreras por versión con concurrent.futures: en cada una, el reloj escribe a los $20$ milisegundos y el botón a los $20$ milisegundos más un desfase aleatorio entre $-15$ y $15$ milisegundos. La carrera se comprime de segundos a milisegundos para que corra en un minuto y medio, pero el mecanismo es el mismo, incluido el detalle del nodo Wait: si la espera ya se reanudó, el reloj no escribe:
# Reproducción de la condición de carrera con dos escritores en hilos sobre una copia de SQLite en Python:
ruta_bd = carpeta / 'trigo_lento_copia.sqlite'
ruta_bd.unlink(missing_ok = True)
TERMINALES = ('aprobada', 'rechazada', 'caducada')
def preparar(con):
con.executescript("""
CREATE TABLE aprobaciones (id INTEGER PRIMARY KEY, version TEXT, estado TEXT NOT NULL);
CREATE TABLE historial (id_aprobacion INTEGER, antes TEXT, despues TEXT);
CREATE TRIGGER registrar AFTER UPDATE OF estado ON aprobaciones
BEGIN INSERT INTO historial VALUES (NEW.id, OLD.estado, NEW.estado); END;
CREATE TRIGGER terminal_inmutable BEFORE UPDATE OF estado ON aprobaciones
WHEN NEW.version = 'v2' AND OLD.estado <> 'pendiente'
BEGIN SELECT RAISE(ABORT, 'transición prohibida desde un estado terminal'); END;""")
def conectar():
return sqlite3.connect(ruta_bd, timeout = 5, isolation_level = None, check_same_thread = False)
def escribir(con, id_, estado, version):
# v1: "la última escritura gana"; v2: comparar e intercambiar (solo si sigue pendiente):
sql = 'UPDATE aprobaciones SET estado = ? WHERE id = ?'
if version == 'v2':
sql += " AND estado = 'pendiente'"
try:
return con.execute(sql, (estado, id_)).rowcount
except sqlite3.IntegrityError:
return 0
def reloj_de_caducidad(id_, version, espera, reanudada):
time.sleep(espera)
con = conectar()
if reanudada.is_set(): # la decisión llegó antes: no hay caducidad
visto = 'rechazada'
elif escribir(con, id_, 'caducada', version) == 1:
visto = 'caducada'
else: # v2: otro escritor ganó; se lee lo que quedó
visto = con.execute('SELECT estado FROM aprobaciones WHERE id = ?', (id_,)).fetchone()[0]
con.close()
return visto
def webhook_del_boton(id_, version, espera, reanudada):
time.sleep(espera)
con = conectar()
filas = escribir(con, id_, 'rechazada', version)
if filas == 1:
reanudada.set() # reanuda la espera del flujo
con.close()
return 'aplicada' if filas == 1 else 'tardía: aviso a Slack'
con = conectar()
preparar(con)
REPETICIONES, PLAZO = 1000, 0.020 # 1 000 carreras; la caducidad, a los 20 ms
resultados = []
with ThreadPoolExecutor(max_workers = 2) as hilos:
for version in ['v1', 'v2']:
for i in range(REPETICIONES):
desfase = rng.uniform(-0.015, 0.015)
while abs(desfase) < 0.003: # separaciones de al menos 3 ms
desfase = rng.uniform(-0.015, 0.015)
id_ = con.execute('INSERT INTO aprobaciones (version, estado) VALUES (?, ?)', (version, 'pendiente')).lastrowid
reanudada = threading.Event()
f1 = hilos.submit(reloj_de_caducidad, id_, version, PLAZO, reanudada)
f2 = hilos.submit(webhook_del_boton, id_, version, PLAZO + desfase, reanudada)
visto, boton = f1.result(), f2.result()
final = con.execute('SELECT estado FROM aprobaciones WHERE id = ?', (id_,)).fetchone()[0]
resultados.append({'version': version, 'id': id_, 'boton_tarde': desfase > 0, 'visto': visto,
'final': final, 'boton': boton})
prohibidas = pd.read_sql("SELECT a.version, COUNT(*) AS n FROM historial h JOIN aprobaciones a ON a.id = h.id_aprobacion "
"WHERE h.antes <> 'pendiente' GROUP BY a.version", con).set_index('version')['n']
con.close()
carreras = pd.DataFrame(resultados)
carreras['publico_vio_otro'] = carreras['visto'] != carreras['final']
for version, g in carreras.groupby('version'):
print(f'{version}: botón después del plazo en {g.boton_tarde.sum()} de {len(g)} carreras')
print(' estado que vio el flujo de publicación:', g['visto'].value_counts().to_dict())
print(' estado final del registro: ', g['final'].value_counts().to_dict())
print(' el registro contradice lo que vio el flujo:', int(g['publico_vio_otro'].sum()),
'| transiciones prohibidas aceptadas:', int(prohibidas.get(version, 0)),
'| decisiones tardías avisadas:', int((g['boton'] != 'aplicada').sum()))
v1: botón después del plazo en 527 de 1000 carreras
estado que vio el flujo de publicación: {'caducada': 829, 'rechazada': 171}
estado final del registro: {'rechazada': 698, 'caducada': 302}
el registro contradice lo que vio el flujo: 527 | transiciones prohibidas aceptadas: 829 | decisiones tardías avisadas: 0
v2: botón después del plazo en 493 de 1000 carreras
estado que vio el flujo de publicación: {'rechazada': 509, 'caducada': 491}
estado final del registro: {'rechazada': 509, 'caducada': 491}
el registro contradice lo que vio el flujo: 0 | transiciones prohibidas aceptadas: 0 | decisiones tardías avisadas: 491
La salida merece leerse despacio, porque enseña dos cosas a la vez. La primera es que los milisegundos de un programa no obedecen a los retardos que uno escribe. En la versión 1, el botón se programó después del plazo en $527$ de las $1,000$ carreras, pero el flujo de publicación recibió caducada en $829$: el sistema operativo despierta los hilos cuando puede, una conexión a SQLite tarda lo que tarda y la comprobación “¿ya se reanudó la espera?” no es atómica con la escritura que la sigue. Las cifras exactas cambian de una ejecución a otra; no se puede razonar sobre el orden de dos escritores mirando el reloj de pared.
La segunda es lo que sí es estable. En la versión 1, el registro contradijo lo que vio el flujo en $527$ carreras y la base aceptó $829$ transiciones prohibidas: en unas, rechazada pisó a caducada, como en el incidente; en otras, caducada pisó a rechazada, el caso inverso, igual de grave. En la versión 2, con la escritura condicional y el disparador, las contradicciones y las transiciones prohibidas son cero, ejecución tras ejecución: el primero que escribe gana, el segundo recibe cero filas, lee lo que quedó y, si era el botón, deja un aviso de decisión tardía ($491$ en esta ejecución) para que una persona lo sepa. La versión 2 no elimina la carrera, que seguirá ocurriendo porque los relojes y los dedos no se coordinan; la vuelve inofensiva, porque el registro y el flujo ven siempre lo mismo.
La Figura 1 dibuja esa escena con la metáfora que Valeria usó para explicársela a su padre: dos manos que llegan al mismo interruptor con cuatro segundos de diferencia, y una cerradura que solo deja girar la primera.
Figura 1. Dos manos sobre el mismo interruptor: el reloj de caducidad escribe "caducada" a las 20:00:00 y el rechazo de Don Ernesto, pulsado a las 19:59:58, llega a la misma fila a las 20:00:02; sin guardia gana el último y el registro se contradice, con la escritura condicional gana el primero y el tardío solo avisa.
La máquina de estados, en la base y en el flujo
La máquina de estados del Día 74 tiene cuatro estados y solo tres transiciones permitidas: de pendiente a aprobada, a rechazada o a caducada. Los tres últimos son terminales. El siguiente bloque escribe esa máquina como datos, define las dos versiones de la decisión de publicar (la abierta, que imita el switch de la versión 1, y la cerrada) y las prueba con los cuatro estados conocidos y con tres que no deberían existir: 'Aprobada' con mayúscula, 'cancelada' (un estado que alguien podría añadir mañana) y None:
# Máquina de estados con transiciones permitidas y decidir_publicacion en versión abierta y cerrada en Python:
ESTADOS = ['pendiente', 'aprobada', 'rechazada', 'caducada']
TRANSICIONES = {'pendiente': {'aprobada', 'rechazada', 'caducada'}, 'aprobada': set(), 'rechazada': set(), 'caducada': set()}
def transicion_valida(antes, despues):
return despues in TRANSICIONES.get(antes, set())
def decidir_publicacion_abierta(estado):
# Como el switch de TL Publicación multired v1: un caso "rechazada" y la salida por defecto publica:
if estado == 'rechazada':
return 'bloquear'
return 'publicar'
def decidir_publicacion_cerrada(estado):
# Como TL Publicación multired v2: solo "aprobada" publica; todo lo demás se detiene y avisa:
if estado == 'aprobada':
return 'publicar'
return 'detener y avisar'
matriz = pd.DataFrame([[transicion_valida(a, d) for d in ESTADOS] for a in ESTADOS], index = ESTADOS, columns = ESTADOS)
print('Transiciones permitidas:', int(matriz.values.sum()), 'de', matriz.size)
print(matriz.replace({True: 'sí', False: '-'}).to_string())
casos = ESTADOS + ['Aprobada', 'cancelada', None]
tabla_verdad = pd.DataFrame({'estado': [repr(c) for c in casos],
'v1 abierta': [decidir_publicacion_abierta(c) for c in casos],
'v2 cerrada': [decidir_publicacion_cerrada(c) for c in casos],
'esperado': ['publicar' if c == 'aprobada' else 'no publicar' for c in casos]})
tabla_verdad['v1 correcta'] = (tabla_verdad['v1 abierta'] == 'publicar') == (tabla_verdad['esperado'] == 'publicar')
tabla_verdad['v2 correcta'] = (tabla_verdad['v2 cerrada'] == 'publicar') == (tabla_verdad['esperado'] == 'publicar')
print(tabla_verdad.to_string(index = False))
print('Casos correctos: v1', int(tabla_verdad['v1 correcta'].sum()), 'de', len(casos),
'| v2', int(tabla_verdad['v2 correcta'].sum()), 'de', len(casos))
Transiciones permitidas: 3 de 16
pendiente aprobada rechazada caducada
pendiente - sí sí sí
aprobada - - - -
rechazada - - - -
caducada - - - -
estado v1 abierta v2 cerrada esperado v1 correcta v2 correcta
'pendiente' publicar detener y avisar no publicar False True
'aprobada' publicar publicar publicar True True
'rechazada' bloquear detener y avisar no publicar True True
'caducada' publicar detener y avisar no publicar False True
'Aprobada' publicar detener y avisar no publicar False True
'cancelada' publicar detener y avisar no publicar False True
None publicar detener y avisar no publicar False True
Casos correctos: v1 2 de 7 | v2 7 de 7
La matriz confirma $3$ transiciones permitidas de $16$ posibles: todo lo que sale de un estado terminal está prohibido, y la base de la versión 2 lo hace cumplir con su disparador. La tabla de verdad es la radiografía del incidente. La versión abierta publica en $5$ de los $7$ casos y acierta solo en $2$: publica con pendiente, con caducada, con la errata 'Aprobada', con el estado nuevo 'cancelada' y con None. Solo bloquea lo único que su autora previó, rechazada. La versión cerrada acierta en los $7$: publica únicamente con 'aprobada', escrito exactamente así, y ante cualquier otra cosa se detiene y avisa.
Hay un detalle que vale oro: la versión cerrada no necesita conocer 'cancelada' para tratarlo bien. La falla cerrada protege contra lo que todavía no existe. La abierta, en cambio, necesita que alguien recuerde actualizarla cada vez que el mundo cambia, y el Día 74 demostró que nadie lo recuerda.
Falla cerrada frente a falla abierta
En 1975, Jerome Saltzer y Michael Schroeder publicaron The Protection of Information in Computer Systems, un artículo sobre la protección de la información en los sistemas de cómputo que todavía se cita en cualquier curso de seguridad. Entre sus ocho principios de diseño está el de valores por defecto seguros (“fail-safe defaults”): basar las decisiones de acceso en el permiso y no en la exclusión. Es decir, que la situación por defecto sea no tener acceso, y que el sistema enumere las condiciones bajo las cuales sí se permite. Su argumento es práctico: en un sistema grande siempre habrá casos que nadie consideró. Un diseño basado en permisos que se equivoca suele fallar negando el acceso, y eso se nota enseguida, porque alguien se queja. Un diseño basado en exclusiones que se equivoca suele fallar permitiendo, y eso puede pasar inadvertido.
El switch de la versión 1 era un diseño basado en exclusiones: enumeraba lo que no se publica (rechazada) y dejaba pasar lo demás. La corrección es invertirlo: enumerar lo que sí se publica (aprobada) y detener lo demás. Esa es la diferencia entre falla abierta y falla cerrada, y la Figura 2 la cuenta con las dos puertas del obrador cuando se va la luz: la del cuarto frío, que se cierra sola para no perder el frío, y la de emergencia, que se abre sola para que nadie quede atrapado.
Figura 2. La puerta que se abre sola y la que se cierra sola: con falla abierta, todo estado que no sea un no publica (17.28 piezas al año sin aprobación y 0.770 de probabilidad mensual); con falla cerrada, solo "aprobada" publica y lo demás se retiene y avisa (0 publicadas, 1.10 reprogramadas al año).
El árbol de fallos del diagnóstico pone números a esa diferencia. El siguiente bloque calcula el árbol con probabilidades supuestas por pieza, para la versión 1 (salida por defecto abierta, probabilidad $1$) y para la versión 2 (sin salida abierta, probabilidad $0$), y lo dibuja con sus compuertas:
# Árbol de fallos con probabilidades supuestas por rama y probabilidad del evento superior en Python:
def compuerta_o(ps):
return 1 - np.prod([1 - p for p in ps])
def compuerta_y(ps):
return float(np.prod(ps))
HOJAS = {'caducada': 0.04, 'desconocido': 0.002, 'falsificada': 0.0005, 'manual': 0.001} # por pieza, supuestos
SALIDA_ABIERTA = {'v1': 1.0, 'v2': 0.0}
arbol = {}
for version, abierta in SALIDA_ABIERTA.items():
llega = compuerta_o([HOJAS['caducada'], HOJAS['desconocido']])
rama_y = compuerta_y([llega, abierta])
superior = compuerta_o([rama_y, HOJAS['falsificada'], HOJAS['manual']])
arbol[version] = {'estado no aprobado llega': llega, 'rama Y': rama_y, 'evento superior': superior,
'piezas al año': superior * PUBLICACIONES_MES * 12}
print(pd.DataFrame(arbol).round(5).to_string())
print(f"La corrección divide el evento superior entre {arbol['v1']['evento superior'] / arbol['v2']['evento superior']:.1f}")
# Dibujo del árbol de fallos con compuertas Y y O:
def caja(ax, x, y, texto, color, ancho = 2.6, alto = 0.9):
ax.add_patch(FancyBboxPatch((x - ancho / 2, y - alto / 2), ancho, alto, boxstyle = 'round,pad=0.04',
facecolor = color, edgecolor = '#52514e', linewidth = 1.2))
ax.text(x, y, texto, ha = 'center', va = 'center', fontsize = 9.5, color = 'white' if color != '#f2f2f2' else '#222')
def puerta(ax, x, y, tipo):
if tipo == 'Y':
ax.add_patch(Rectangle((x - 0.35, y - 0.3), 0.7, 0.3, facecolor = '#ffd27f', edgecolor = '#52514e'))
ax.add_patch(Wedge((x, y), 0.35, 0, 180, facecolor = '#ffd27f', edgecolor = '#52514e'))
else:
ax.add_patch(Polygon([(x - 0.38, y - 0.3), (x, y - 0.12), (x + 0.38, y - 0.3), (x + 0.3, y + 0.05), (x, y + 0.42),
(x - 0.3, y + 0.05)], closed = True, facecolor = '#ffd27f', edgecolor = '#52514e'))
ax.text(x, y - 0.02, tipo, ha = 'center', va = 'center', fontsize = 10, weight = 'bold')
def unir(ax, x1, y1, x2, y2):
ax.plot([x1, x1, x2, x2], [y1, (y1 + y2) / 2, (y1 + y2) / 2, y2], color = '#52514e', linewidth = 1.1)
v1, v2 = arbol['v1'], arbol['v2']
fig, ax = plt.subplots(figsize = (13, 7.2))
caja(ax, 6.5, 6.4, f"Pieza publicada sin aprobación válida\nv1: {v1['evento superior']:.4f} | v2: {v2['evento superior']:.4f} por pieza",
'#d62728', ancho = 4.6, alto = 1.0)
puerta(ax, 6.5, 5.35, 'O')
unir(ax, 6.5, 5.9, 6.5, 5.75)
caja(ax, 3.0, 4.0, f"Estado no aprobado con salida\npor defecto abierta\nv1: {v1['rama Y']:.4f} | v2: {v2['rama Y']:.4f}",
'#2a78d6', ancho = 3.6, alto = 1.1)
caja(ax, 7.4, 4.0, f"Aprobación falsificada\n(botón sin firma verificada)\n{HOJAS['falsificada']}", '#8a8a8a', ancho = 3.2, alto = 1.1)
caja(ax, 11.2, 4.0, f"Publicación manual\nfuera del flujo\n{HOJAS['manual']}", '#8a8a8a', ancho = 3.0, alto = 1.1)
for x in (3.0, 7.4, 11.2):
unir(ax, 6.5, 5.05, x, 4.55)
puerta(ax, 3.0, 2.95, 'Y')
unir(ax, 3.0, 3.45, 3.0, 3.3)
caja(ax, 1.6, 1.6, f"Estado distinto de\n\"aprobada\" llega\n{v1['estado no aprobado llega']:.4f}", '#2a78d6', ancho = 2.6, alto = 1.1)
caja(ax, 4.9, 1.6, "Switch con salida por\ndefecto hacia publicar\nv1: 1.0 | v2: 0.0", '#eb6834', ancho = 2.9, alto = 1.1)
unir(ax, 3.0, 2.65, 1.6, 2.15)
unir(ax, 3.0, 2.65, 4.9, 2.15)
puerta(ax, 1.6, 0.6, 'O')
unir(ax, 1.6, 1.05, 1.6, 0.95)
ax.text(1.6, -0.15, f"caducada {HOJAS['caducada']} | desconocido {HOJAS['desconocido']}", ha = 'center', fontsize = 9.5)
ax.text(9.3, 1.4, f"Piezas al año sin aprobación válida\n(432 piezas): v1 {v1['piezas al año']:.2f} | v2 {v2['piezas al año']:.2f}",
ha = 'center', fontsize = 11, color = '#222')
ax.set_xlim(-0.2, 13.2)
ax.set_ylim(-0.5, 7.1)
ax.axis('off')
ax.set_title('Árbol de fallos del incidente (probabilidades supuestas por pieza)', fontsize = 14)
plt.tight_layout()
plt.show()
v1 v2
estado no aprobado llega 0.04192 0.04192
rama Y 0.04192 0.00000
evento superior 0.04336 0.00150
piezas al año 18.73007 0.64778
La corrección divide el evento superior entre 28.9

Con las probabilidades supuestas, una pieza tiene una probabilidad de $0.0419$ de llegar al switch con un estado distinto de aprobada, la misma en las dos versiones. En la versión 1, la compuerta Y vale lo mismo, porque la salida abierta vale $1$; con las otras dos ramas, el evento superior queda en $0.0434$ por pieza, unas $18.73$ piezas al año sin aprobación válida en $432$ publicaciones. En la versión 2, la rama Y vale $0$ y el evento superior baja a $0.0015$, que es lo que aportan las ramas que el flujo no controla (la publicación manual y la aprobación falsificada): $0.65$ piezas al año, $28.9$ veces menos. Observe la forma de la mejora: no se tocó la probabilidad de caducidad, que seguirá en el $4%$; se cerró la puerta por la que esa caducidad llegaba al público. Las dos ramas grises que quedan tienen sus propias defensas: la verificación de firmas del Día 50 protege la primera, y la regla de publicar solo desde el flujo, vigilada por la alerta inmediata que propone el análisis posterior, protege la segunda.
La imagen de las dos puertas de la Figura 2 recuerda algo importante: la falla cerrada no es siempre la correcta. La puerta de emergencia debe abrirse sola, porque lo peor que puede pasar con ella cerrada es peor que lo peor que puede pasar con ella abierta. La pregunta de diseño es, para cada flujo, qué es peor: actuar cuando no se debía o no actuar cuando se debía. Para Trigo Lento, la respuesta se resume en una regla: toda acción con efecto reputacional o económico, irreversible o difícil de deshacer, falla cerrada; toda acción interna, reversible o cuya ausencia hace más daño que su presencia, puede fallar abierta, siempre con aviso. La Tabla 2 aplica la regla a los flujos de la serie.
Tabla 2. Flujos de Trigo Lento con su modo de falla elegido, la acción por defecto ante un estado desconocido y su justificación.
| Flujo | Modo de falla | Acción por defecto ante un estado desconocido | Justificación |
|---|---|---|---|
TL Publicación multired (Día 72) | Cerrada | Retener la pieza, avisar en #alertas y detener la ejecución | Pública, irreversible en la práctica y obliga por ley a cumplir lo publicado |
| Cobros y reembolsos (Día 40) | Cerrada | No cobrar ni reembolsar; dejar la operación en revisión | Efecto económico directo sobre clientes |
| Cancelación de encargos (Día 38) | Cerrada | Mantener el encargo y preguntar a una persona | Cancelar por error deja a una clienta sin su pastel |
| Respuesta a quejas (Día 74) | Cerrada | Guardar el borrador; no enviar | Una mala respuesta a una queja daña la reputación |
| Correos a mayoristas (Día 47) | Cerrada | Dejar el borrador en Gmail; nunca enviar solo | Marisol exige facturas y confirmaciones sin errores |
| Acuse de recibo fuera de horario (Día 52) | Abierta con aviso | Enviar el acuse genérico y registrar el estado raro | Un cliente sin acuse se siente ignorado; el texto no promete nada |
| Recordatorio de recogida (Día 54) | Abierta con aviso | Usar la plantilla de respaldo y avisar | Perder el recordatorio cuesta más que enviarlo; ya tiene base y consentimiento |
| Resumen interno en Slack (Día 50) | Abierta con aviso | Publicar el resumen con la línea marcada como desconocida | Solo lo lee el equipo; ocultarlo esconde el problema |
| Informe del lunes (Día 67) | Abierta con aviso | Generar el informe con la sección vacía y una nota | Interno; un informe incompleto es mejor que ninguno |
La tabla tiene un patrón que conviene ver. Los cinco flujos que fallan cerrados tienen algo en común: su acción la ve o la paga alguien de fuera. Los cuatro que fallan abiertos son internos o, como el recordatorio, ya tienen una base para escribir y un respaldo probado. Y ninguno de los nueve falla en silencio: la columna del medio siempre termina en un aviso o en una persona.
La enumeración cerrada de estados como contrato
Hay una segunda lección en el incidente, más sutil. TL Aprobaciones v1 cambió (ganó el estado caducada) y TL Publicación multired v1, que lo llamaba, no se enteró. Entre un sub-workflow y quien lo llama hay un contrato: qué recibe, qué devuelve y qué valores puede tomar cada campo. Si el contrato no está escrito, cualquier cambio en un lado rompe el otro en silencio.
La corrección lo escribe de tres maneras:
- Una lista cerrada de estados. El campo
estadosolo puede valerpendiente,aprobada,rechazadaocaducada; la base lo hace cumplir con una restricción y el flujo que llama solo reconoceaprobadacomo permiso. - Una versión del contrato en la respuesta.
TL Aprobaciones v2devuelveversion_contrato: 2junto al estado. Quien lo llama comprueba que la versión es la que conoce; si mañana existe una versión 3 con un estado nuevo, el flujo de publicación se detiene en lugar de adivinar. - Una revisión de quien llama. Cada vez que cambia un sub-workflow, se revisan los flujos que lo usan. El inventario de flujos del Día 79 tendrá una columna para eso.
Así pues, la falla cerrada y el contrato se refuerzan: el contrato dice qué valores existen y la falla cerrada dice qué hacer con los que no deberían existir.
Cuánto cuesta cada puerta
Falta medir lo que la ficha del día pide medir: cuánto se gana y cuánto se paga. Con $n = 36$ piezas al mes y una probabilidad de caducidad $p = 0.04$, el número esperado de piezas que la falla abierta publica sin aprobación en un año y la probabilidad de que ocurra al menos una en un mes son:
$$ E[N_{\text{año}}] = 12 \cdot n \cdot p, \qquad P(\text{al menos una en un mes}) = 1 - (1 - p)^{n} $$
donde:
- $n = 36$ es el número de piezas que se publican en un mes y $p = 0.04$ la probabilidad supuesta de que la aprobación de una pieza caduque.
- $E[N_{\text{año}}]$ suma las caducidades esperadas de doce meses: $12 \cdot 36 \cdot 0.04 = 17.28$ piezas al año.
- $1 - (1 - p)^{n}$ es la probabilidad de que no todas las aprobaciones del mes terminen a tiempo: $1 - 0.96^{36} = 0.770$.
El incidente no fue mala suerte; era cuestión de semanas. Con la falla abierta, la probabilidad de publicar algo sin aprobación en un mes cualquiera era del $77%$, y Trigo Lento tardó dos días en estrenarla. El siguiente bloque lo comprueba con un Monte Carlo de $1,000$ años y mide el precio de la falla cerrada: las piezas retenidas que vuelven a pedir aprobación y el retraso que eso provoca, con un tiempo de nueva aprobación supuesto lognormal de mediana $35$ minutos:
# Monte Carlo de 1 000 años de publicaciones con falla abierta y con falla cerrada en Python:
ANIOS = 1000
caducadas = rng.binomial(PUBLICACIONES_MES, P_CADUCIDAD, size = (ANIOS, 12)) # por año y mes
sin_aprobacion_abierta = caducadas.sum(axis = 1) # falla abierta: cada caducada sale publicada
sin_aprobacion_cerrada = np.zeros(ANIOS, dtype = int) # falla cerrada: ninguna sale
retenidas = caducadas.sum(axis = 1) # falla cerrada: vuelven a pedir aprobación
retraso_min = rng.lognormal(np.log(35), 0.8, size = retenidas.sum()) # nueva aprobación, mediana 35 min
pierden_hueco = retraso_min > 120 # más de 2 h: se reprograma la pieza
print(f'Falla abierta: {sin_aprobacion_abierta.mean():.2f} piezas al año sin aprobación válida '
f'(analítico {PUBLICACIONES_MES * 12 * P_CADUCIDAD:.2f})')
print(f'P(al menos una en un mes): simulada {(caducadas > 0).mean():.3f} | analítica {1 - (1 - P_CADUCIDAD) ** PUBLICACIONES_MES:.3f}')
print(f'Años sin ninguna: {(sin_aprobacion_abierta == 0).sum()} de {ANIOS} | máximo en un año: {sin_aprobacion_abierta.max()}')
print(f'Falla cerrada: {sin_aprobacion_cerrada.mean():.2f} sin aprobación | {retenidas.mean():.2f} retenidas al año')
print(f'Retraso de la nueva aprobación: mediana {np.median(retraso_min):.1f} min | p90 {np.percentile(retraso_min, 90):.1f} min')
print(f'Retenidas que pierden su hueco (> 2 h) y se reprograman: {pierden_hueco.mean():.1%} '
f'= {pierden_hueco.sum() / ANIOS:.2f} piezas al año')
# Piezas sin aprobación válida por año y piezas retenidas con su retraso:
fig, (ax1, ax2) = plt.subplots(1, 2, figsize = (13, 4.8))
bordes = np.arange(-0.5, sin_aprobacion_abierta.max() + 1.5)
ax1.hist(sin_aprobacion_abierta, bins = bordes, color = '#d62728', alpha = 0.75, label = 'Falla abierta (v1)')
ax1.hist(sin_aprobacion_cerrada, bins = bordes, color = '#1f9d55', alpha = 0.85, label = 'Falla cerrada (v2): 0 siempre')
ax1.axvline(sin_aprobacion_abierta.mean(), color = '#52514e', linestyle = '--', label = f'Media abierta: {sin_aprobacion_abierta.mean():.2f}')
ax1.set_xlabel('Piezas publicadas sin aprobación válida en un año')
ax1.set_ylabel('Años simulados (de 1 000)')
ax1.set_title('Lo que sale sin permiso', fontsize = 13)
ax1.legend(fontsize = 9)
ax2.hist(retraso_min, bins = np.arange(0, 300, 10), color = '#2a78d6', alpha = 0.8)
ax2.axvline(120, color = '#d62728', linestyle = '--', label = 'Más de 2 h: se reprograma')
ax2.set_xlabel('Retraso de la nueva aprobación de una pieza retenida (minutos)')
ax2.set_ylabel('Piezas retenidas (1 000 años)')
ax2.set_title('El precio de la falla cerrada: esperar', fontsize = 13)
ax2.legend(fontsize = 9)
plt.tight_layout()
plt.show()
Falla abierta: 17.44 piezas al año sin aprobación válida (analítico 17.28)
P(al menos una en un mes): simulada 0.775 | analítica 0.770
Años sin ninguna: 0 de 1000 | máximo en un año: 32
Falla cerrada: 0.00 sin aprobación | 17.44 retenidas al año
Retraso de la nueva aprobación: mediana 34.8 min | p90 98.0 min
Retenidas que pierden su hueco (> 2 h) y se reprograman: 6.3% = 1.10 piezas al año

La simulación coincide con la cuenta: la falla abierta publica $17.44$ piezas al año sin aprobación válida (frente a $17.28$ analíticas) y la probabilidad de al menos una en un mes sale en $0.775$ (frente a $0.770$). En $1,000$ años simulados no hay ni uno solo sin incidentes, y el peor llega a $32$ piezas. La falla cerrada publica cero, siempre, como se ve en la barra verde de la izquierda.
El precio está a la derecha. Las mismas $17.44$ piezas al año quedan retenidas y vuelven a pedir aprobación. La mayoría se resuelve rápido (mediana de $34.8$ minutos, percentil 90 de $98$ minutos), y solo el $6.3%$ tarda más de dos horas y pierde su hueco: $1.10$ piezas al año que hay que reprogramar. Ese es el coste completo de cerrar la puerta: una pieza al año que sale otro día. Frente a $184.86$ USD por noche de incidente, la falla cerrada no es una precaución cara; es la opción barata.
La corrección
La corrección se hizo en dos tiempos. El viernes por la mañana, contener: Valeria desactivó TL Publicación multired v1 y publicó a mano, con Don Ernesto al lado, las dos piezas del viernes que ya estaban aprobadas. Por la tarde, corregir: dos workflows nuevos, TL Aprobaciones v2 y TL Publicación multired v2, que se describen ahora. Y el sábado, retirar, comunicar y honrar: la aclaración en las dos redes y los nueve pays.
TL Aprobaciones v2 conserva la forma de la versión 1 (crear la fila, avisar, esperar con límite) y cambia tres cosas:
- Transiciones validadas en Postgres. Las dos escrituras son condicionales (
WHERE id = $1 AND estado = 'pendiente') y devuelven la fila conRETURNING; si no devuelven nada, quien escribió sabe que llegó tarde. En la base, un disparador de estado terminal inmutable, como el de SQLite del Bloque 3, rechaza cualquier cambio desdeaprobada,rechazadaocaducada. - Un solo lector de la verdad. Después de la espera, el flujo no devuelve lo que él escribió, sino lo que lee de la fila (
Leer estado final): si el botón ganó la carrera por un milisegundo, devuelverechazada; si ganó el reloj,caducada, y el botón tardío recibe un aviso en#alertas. El flujo que llama y el registro ven siempre lo mismo. - La versión del contrato en la respuesta. El nodo final,
Responder con contrato v2, devuelve estado, aprobador, huella yversion_contrato: 2.
TL Publicación multired v2 cambia lo que de verdad importa:
- Un switch con un único caso,
aprobada. Su salida de respaldo (“Extra Output”, con el nombre “cualquier otro estado”) ya no lleva a publicar: lleva aRetener la pieza, aAvisar en Slacky aDetener sin aprobación, un nodo Stop and Error que marca la ejecución como fallida para que el workflow de errores de la instancia también avise. - Una segunda comprobación antes de publicar. Un IF exige la versión 2 del contrato, que la huella aprobada coincida con la del texto que va a salir (la regla del Día 72) y que haya aprobador. Dos puertas en serie para una acción irreversible no son redundancia; son la compuerta Y del árbol jugando a favor.
- El estado de la aprobación en el registro.
Registrar publicaciónguardaestado_aprobacion = 'aprobada'junto a cada publicación, para que el indicador del Día 75 se calcule con un dato que el flujo comprobó, no con uno que se infiere.
El JSON, con la estructura de exportación de n8n de varios workflows (una lista, como la que produce n8n export:workflow --all), identificadores de ejemplo y ningún secreto, es el siguiente:
[
{
"name": "TL Aprobaciones v2",
"nodes": [
{
"parameters": {"workflowInputs": {"values": [{"name": "pieza"}, {"name": "red"}, {"name": "texto"}, {"name": "huella"}]}},
"id": "0b1c7600-0000-4000-8000-000000000001",
"name": "Pedir aprobación",
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [0, 0]
},
{
"parameters": {"operation": "executeQuery", "query": "INSERT INTO aprobaciones (pieza, red, huella, estado, version_contrato, url_reanudar, caduca_en) VALUES ($1, $2, $3, 'pendiente', 2, $4, now() + interval '2 hours') RETURNING id, estado, caduca_en;", "options": {"queryReplacement": "={{ [$json.pieza, $json.red, $json.huella, $execution.resumeUrl] }}"}},
"id": "0b1c7600-0000-4000-8000-000000000002",
"name": "Crear aprobación",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [220, 0],
"credentials": {"postgres": {"id": "EJEMPLO-NO-REAL-PG01", "name": "Postgres TL aprobaciones"}}
},
{
"parameters": {"method": "POST", "url": "={{ $env.TL_SLACK_WEBHOOK_APROBACIONES }}", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ text: 'Aprobación pendiente: ' + $('Pedir aprobación').item.json.pieza, id_aprobacion: $json.id }) }}", "options": {"timeout": 10000}},
"id": "0b1c7600-0000-4000-8000-000000000003",
"name": "Avisar a quien aprueba",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [440, 0]
},
{
"parameters": {"resume": "webhook", "httpMethod": "POST", "limitWaitTime": true, "limitType": "afterTimeInterval", "resumeAmount": 2, "resumeUnit": "hours", "options": {}},
"id": "0b1c7600-0000-4000-8000-000000000004",
"name": "Esperar decisión",
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [660, 0],
"webhookId": "00000000-0000-4000-8000-0000000076aa"
},
{
"parameters": {"operation": "executeQuery", "query": "UPDATE aprobaciones SET estado = 'caducada', cerrada_en = now(), cerrada_por = 'reloj' WHERE id = $1 AND estado = 'pendiente' RETURNING id, estado;", "options": {"queryReplacement": "={{ [$('Crear aprobación').item.json.id] }}"}},
"id": "0b1c7600-0000-4000-8000-000000000005",
"name": "Caducar si sigue pendiente",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [880, 0],
"alwaysOutputData": true,
"credentials": {"postgres": {"id": "EJEMPLO-NO-REAL-PG01", "name": "Postgres TL aprobaciones"}}
},
{
"parameters": {"operation": "executeQuery", "query": "SELECT id, estado, aprobador, huella, version_contrato FROM aprobaciones WHERE id = $1;", "options": {"queryReplacement": "={{ [$('Crear aprobación').item.json.id] }}"}},
"id": "0b1c7600-0000-4000-8000-000000000006",
"name": "Leer estado final",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [1100, 0],
"credentials": {"postgres": {"id": "EJEMPLO-NO-REAL-PG01", "name": "Postgres TL aprobaciones"}}
},
{
"parameters": {"assignments": {"assignments": [{"id": "a7600001", "name": "estado", "value": "={{ $json.estado }}", "type": "string"}, {"id": "a7600002", "name": "aprobador", "value": "={{ $json.aprobador }}", "type": "string"}, {"id": "a7600003", "name": "huella", "value": "={{ $json.huella }}", "type": "string"}, {"id": "a7600004", "name": "version_contrato", "value": 2, "type": "number"}]}, "options": {}},
"id": "0b1c7600-0000-4000-8000-000000000007",
"name": "Responder con contrato v2",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [1320, 0]
},
{
"parameters": {"httpMethod": "POST", "path": "tl-decision-aprobacion", "responseMode": "onReceived", "options": {"rawBody": true}},
"id": "0b1c7600-0000-4000-8000-000000000008",
"name": "Botón de decisión",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [0, 300],
"webhookId": "00000000-0000-4000-8000-0000000076bb"
},
{
"parameters": {"operation": "executeQuery", "query": "UPDATE aprobaciones SET estado = $2, aprobador = $3, cerrada_en = now(), cerrada_por = 'persona' WHERE id = $1 AND estado = 'pendiente' AND $2 IN ('aprobada', 'rechazada') RETURNING id, estado, url_reanudar;", "options": {"queryReplacement": "={{ [$json.body.id_aprobacion, $json.body.decision, $json.body.aprobador] }}"}},
"id": "0b1c7600-0000-4000-8000-000000000009",
"name": "Registrar decisión si sigue pendiente",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [220, 300],
"alwaysOutputData": true,
"credentials": {"postgres": {"id": "EJEMPLO-NO-REAL-PG01", "name": "Postgres TL aprobaciones"}}
},
{
"parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2}, "conditions": [{"id": "c7600001", "leftValue": "={{ $json.id }}", "rightValue": "", "operator": {"type": "number", "operation": "exists", "singleValue": true}}], "combinator": "and"}, "options": {}},
"id": "0b1c7600-0000-4000-8000-000000000010",
"name": "¿Llegó a tiempo?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [440, 300]
},
{
"parameters": {"method": "POST", "url": "={{ $json.url_reanudar }}", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ estado: $json.estado }) }}", "options": {"timeout": 10000}},
"id": "0b1c7600-0000-4000-8000-000000000011",
"name": "Reanudar la espera",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [660, 200]
},
{
"parameters": {"method": "POST", "url": "={{ $env.TL_SLACK_WEBHOOK_ALERTAS }}", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ text: 'Decisión tardía o repetida: la aprobación ' + $('Botón de decisión').item.json.body.id_aprobacion + ' ya estaba cerrada; no se cambió nada.' }) }}", "options": {"timeout": 10000}},
"id": "0b1c7600-0000-4000-8000-000000000012",
"name": "Avisar decisión tardía",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [660, 400]
}
],
"connections": {
"Pedir aprobación": {"main": [[{"node": "Crear aprobación", "type": "main", "index": 0}]]},
"Crear aprobación": {"main": [[{"node": "Avisar a quien aprueba", "type": "main", "index": 0}]]},
"Avisar a quien aprueba": {"main": [[{"node": "Esperar decisión", "type": "main", "index": 0}]]},
"Esperar decisión": {"main": [[{"node": "Caducar si sigue pendiente", "type": "main", "index": 0}]]},
"Caducar si sigue pendiente": {"main": [[{"node": "Leer estado final", "type": "main", "index": 0}]]},
"Leer estado final": {"main": [[{"node": "Responder con contrato v2", "type": "main", "index": 0}]]},
"Botón de decisión": {"main": [[{"node": "Registrar decisión si sigue pendiente", "type": "main", "index": 0}]]},
"Registrar decisión si sigue pendiente": {"main": [[{"node": "¿Llegó a tiempo?", "type": "main", "index": 0}]]},
"¿Llegó a tiempo?": {"main": [[{"node": "Reanudar la espera", "type": "main", "index": 0}], [{"node": "Avisar decisión tardía", "type": "main", "index": 0}]]}
},
"settings": {"executionOrder": "v1", "timezone": "America/Mexico_City", "errorWorkflow": "EJEMPLO-NO-REAL-ERR"},
"pinData": {},
"meta": {"templateCredsSetupCompleted": true}
},
{
"name": "TL Publicación multired v2",
"nodes": [
{
"parameters": {"rule": {"interval": [{"field": "minutes", "minutesInterval": 15}]}},
"id": "0b1c7601-0000-4000-8000-000000000001",
"name": "Cada 15 minutos",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [0, 0]
},
{
"parameters": {"operation": "executeQuery", "query": "SELECT p.id, p.pieza, p.red, p.texto_final, p.huella, p.clave_idempotencia FROM publicaciones p WHERE p.estado = 'programado' AND p.hora_programada <= now() AND NOT EXISTS (SELECT 1 FROM registro_publicaciones r WHERE r.clave_idempotencia = p.clave_idempotencia) ORDER BY p.hora_programada LIMIT 10;", "options": {}},
"id": "0b1c7601-0000-4000-8000-000000000002",
"name": "Piezas por publicar",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [220, 0],
"credentials": {"postgres": {"id": "EJEMPLO-NO-REAL-PG02", "name": "Postgres TL solo publicaciones"}}
},
{
"parameters": {"source": "database", "workflowId": {"__rl": true, "value": "EJEMPLO-NO-REAL-APR2", "mode": "id"}, "workflowInputs": {"mappingMode": "defineBelow", "value": {"pieza": "={{ $json.pieza }}", "red": "={{ $json.red }}", "texto": "={{ $json.texto_final }}", "huella": "={{ $json.huella }}"}}, "options": {"waitForSubWorkflow": true}},
"id": "0b1c7601-0000-4000-8000-000000000003",
"name": "Pedir aprobación v2",
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.2,
"position": [440, 0]
},
{
"parameters": {"rules": {"values": [{"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2}, "conditions": [{"id": "s7600001", "leftValue": "={{ $json.estado }}", "rightValue": "aprobada", "operator": {"type": "string", "operation": "equals"}}], "combinator": "and"}, "renameOutput": true, "outputKey": "aprobada"}]}, "options": {"fallbackOutput": "extra", "renameFallbackOutput": "cualquier otro estado"}},
"id": "0b1c7601-0000-4000-8000-000000000004",
"name": "¿Estado de la aprobación?",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [660, 0]
},
{
"parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2}, "conditions": [{"id": "c7601001", "leftValue": "={{ $json.version_contrato }}", "rightValue": 2, "operator": {"type": "number", "operation": "equals"}}, {"id": "c7601002", "leftValue": "={{ $json.huella }}", "rightValue": "={{ $('Piezas por publicar').item.json.huella }}", "operator": {"type": "string", "operation": "equals"}}, {"id": "c7601003", "leftValue": "={{ $json.aprobador }}", "rightValue": "", "operator": {"type": "string", "operation": "notEmpty", "singleValue": true}}], "combinator": "and"}, "options": {}},
"id": "0b1c7601-0000-4000-8000-000000000005",
"name": "¿Contrato, huella y aprobador?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [880, 0]
},
{
"parameters": {"source": "database", "workflowId": {"__rl": true, "value": "EJEMPLO-NO-REAL-RED1", "mode": "id"}, "workflowInputs": {"mappingMode": "defineBelow", "value": {"pieza": "={{ $('Piezas por publicar').item.json.pieza }}", "red": "={{ $('Piezas por publicar').item.json.red }}"}}, "options": {"waitForSubWorkflow": true}},
"id": "0b1c7601-0000-4000-8000-000000000006",
"name": "Publicar por red",
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.2,
"position": [1100, 0]
},
{
"parameters": {"operation": "executeQuery", "query": "INSERT INTO registro_publicaciones (clave_idempotencia, pieza, red, hora_publicada, id_publicacion, aprobador, estado_aprobacion) VALUES ($1, $2, $3, now(), $4, $5, 'aprobada') ON CONFLICT (clave_idempotencia) DO NOTHING;", "options": {"queryReplacement": "={{ [$('Piezas por publicar').item.json.clave_idempotencia, $('Piezas por publicar').item.json.pieza, $('Piezas por publicar').item.json.red, $json.id_publicacion, $('¿Estado de la aprobación?').item.json.aprobador] }}"}},
"id": "0b1c7601-0000-4000-8000-000000000007",
"name": "Registrar publicación",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [1320, 0],
"credentials": {"postgres": {"id": "EJEMPLO-NO-REAL-PG02", "name": "Postgres TL solo publicaciones"}}
},
{
"parameters": {"operation": "executeQuery", "query": "UPDATE publicaciones SET estado = 'retenida', motivo = 'aprobación no válida: ' || $2 WHERE id = $1;", "options": {"queryReplacement": "={{ [$('Piezas por publicar').item.json.id, $json.estado || 'sin estado'] }}"}},
"id": "0b1c7601-0000-4000-8000-000000000008",
"name": "Retener la pieza",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [1100, 260],
"credentials": {"postgres": {"id": "EJEMPLO-NO-REAL-PG02", "name": "Postgres TL solo publicaciones"}}
},
{
"parameters": {"method": "POST", "url": "={{ $env.TL_SLACK_WEBHOOK_ALERTAS }}", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ text: 'Pieza retenida ' + $('Piezas por publicar').item.json.pieza + ': la aprobación no es válida. Pida una nueva aprobación.' }) }}", "options": {"timeout": 10000}},
"id": "0b1c7601-0000-4000-8000-000000000009",
"name": "Avisar en Slack",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1320, 260]
},
{
"parameters": {"errorMessage": "={{ 'Publicación detenida: la pieza ' + $('Piezas por publicar').item.json.pieza + ' no tiene una aprobación válida.' }}"},
"id": "0b1c7601-0000-4000-8000-000000000010",
"name": "Detener sin aprobación",
"type": "n8n-nodes-base.stopAndError",
"typeVersion": 1,
"position": [1540, 260]
}
],
"connections": {
"Cada 15 minutos": {"main": [[{"node": "Piezas por publicar", "type": "main", "index": 0}]]},
"Piezas por publicar": {"main": [[{"node": "Pedir aprobación v2", "type": "main", "index": 0}]]},
"Pedir aprobación v2": {"main": [[{"node": "¿Estado de la aprobación?", "type": "main", "index": 0}]]},
"¿Estado de la aprobación?": {"main": [[{"node": "¿Contrato, huella y aprobador?", "type": "main", "index": 0}], [{"node": "Retener la pieza", "type": "main", "index": 0}]]},
"¿Contrato, huella y aprobador?": {"main": [[{"node": "Publicar por red", "type": "main", "index": 0}], [{"node": "Retener la pieza", "type": "main", "index": 0}]]},
"Publicar por red": {"main": [[{"node": "Registrar publicación", "type": "main", "index": 0}]]},
"Retener la pieza": {"main": [[{"node": "Avisar en Slack", "type": "main", "index": 0}]]},
"Avisar en Slack": {"main": [[{"node": "Detener sin aprobación", "type": "main", "index": 0}]]}
},
"settings": {"executionOrder": "v1", "timezone": "America/Mexico_City", "executionTimeout": 9000, "saveDataSuccessExecution": "all", "errorWorkflow": "EJEMPLO-NO-REAL-ERR"},
"pinData": {},
"meta": {"templateCredsSetupCompleted": true}
}
]
Cinco detalles del JSON son decisiones, no configuración por defecto. La opción fallbackOutput va dentro de options, donde la documentación la coloca; fuera de ahí, según reportes de la comunidad de n8n, el editor no puede abrir el workflow. Las dos escrituras condicionales llevan alwaysOutputData: true, para que una sentencia que no afecta filas produzca un elemento vacío en lugar de cortar la ejecución, y el IF siguiente pueda distinguir “llegué a tiempo” de “llegué tarde”. El nodo Wait se reanuda por webhook con un límite de dos horas, y la dirección de reanudación ($execution.resumeUrl) se guarda en la fila, de modo que solo el botón que pasó la escritura condicional puede reanudar la espera. Las credenciales aparecen solo por nombre y las direcciones de Slack vienen de variables de entorno. Y executionTimeout sube a $9,000$ segundos, porque una ejecución que espera dos horas no debe morir a mitad de la espera.
Como en cada día de la serie, el JSON no se da por bueno a simple vista: se lee con Python, se comprueba que cada conexión apunta a un nodo existente, que las escrituras son condicionales, que ningún camino desde la salida de respaldo llega a Publicar por red y que no hay secretos, y se dibuja el flujo de publicación con la paleta de formas de la serie, con el paso humano en hexágono gris y la detención en rojo:
# Lectura de los workflows v2 con nodos_desde_n8n, comprobaciones de falla cerrada y dibujo en Python:
exportados = json.loads((carpeta / 'flujos' / 'n8n' / 'aprobaciones_y_publicacion_v2.json').read_text(encoding = 'utf-8'))
flujos = {wf['name']: wf for wf in exportados}
FORMAS = {'scheduleTrigger': ('disparador', '#1f9d55'), 'webhook': ('disparador', '#1f9d55'),
'executeWorkflowTrigger': ('disparador', '#1f9d55'), 'postgres': ('accion', '#2a78d6'),
'httpRequest': ('accion', '#2a78d6'), 'set': ('accion', '#2a78d6'), 'executeWorkflow': ('accion', '#2a78d6'),
'wait': ('humano', '#8a8a8a'), 'if': ('decision', '#eb6834'), 'switch': ('decision', '#eb6834'),
'stopAndError': ('error', '#d62728')}
HUMANOS = {'Pedir aprobación v2'} # subflujo que espera a una persona
def nodos_desde_n8n(wf):
nodos = [{'id': n['name'], 'tipo': n['type'].split('.')[-1],
'x': n['position'][0] / 100, 'y': -n['position'][1] / 100} for n in wf['nodes']]
aristas = [(o, d['node'], tipo, salida) for o, s in wf['connections'].items()
for tipo, lista in s.items() for salida, destinos in enumerate(lista) for d in destinos]
return nodos, aristas
def alcanzables(aristas, origen, salida = None):
# Nodos a los que se llega desde un nodo (y, si se indica, desde una de sus salidas):
pendientes = [d for o, d, _, s in aristas if o == origen and (salida is None or s == salida)]
vistos = set()
while pendientes:
n = pendientes.pop()
if n not in vistos:
vistos.add(n)
pendientes += [d for o, d, _, _ in aristas if o == n]
return vistos
def dibujar_flujo(nodos, aristas, titulo, rotulos):
fig, ax = plt.subplots(figsize = (15, 5.6))
pos = {n['id']: (n['x'], n['y']) for n in nodos}
for n in nodos:
forma, color = ('humano', '#8a8a8a') if n['id'] in HUMANOS else FORMAS[n['tipo']]
x, y = n['x'], n['y']
if forma == 'decision':
ax.add_patch(Polygon([(x - 0.9, y), (x, y + 0.55), (x + 0.9, y), (x, y - 0.55)], facecolor = color,
edgecolor = 'white', linewidth = 2))
elif forma == 'humano':
ax.add_patch(RegularPolygon((x, y), 6, radius = 0.72, orientation = np.pi / 2, facecolor = color,
edgecolor = 'white', linewidth = 2))
else:
estilo = 'round,pad=0.05,rounding_size=0.35' if forma == 'disparador' else 'round,pad=0.05'
ax.add_patch(FancyBboxPatch((x - 0.85, y - 0.4), 1.7, 0.8, boxstyle = estilo,
facecolor = color, edgecolor = 'white', linewidth = 2))
etiqueta = textwrap.fill(n['id'], 12 if forma in ('decision', 'humano') else 18, break_long_words = False)
ax.text(x, y + 0.08, etiqueta, ha = 'center', va = 'center', color = 'white', fontsize = 8.3)
ax.text(x, y - 0.3, n['tipo'], ha = 'center', va = 'center', color = 'white', fontsize = 6.5)
for o, d, _, salida in aristas:
(x1, y1), (x2, y2) = pos[o], pos[d]
if abs(y1 - y2) < 0.01:
ax.add_patch(FancyArrowPatch((x1 + 0.92, y1), (x2 - 0.92, y2), arrowstyle = '-|>', mutation_scale = 13,
color = '#52514e', linewidth = 1.4))
else:
ax.add_patch(FancyArrowPatch((x1, y1 - 0.56), (x2 - 0.92, y2), arrowstyle = '-|>', mutation_scale = 13,
color = '#52514e', linewidth = 1.4,
connectionstyle = 'angle,angleA=90,angleB=0,rad=0.1'))
for nodo, dx, dy, texto in rotulos:
ax.text(pos[nodo][0] + dx, pos[nodo][1] + dy, texto, fontsize = 9, color = '#52514e')
xs = [p[0] for p in pos.values()]
ys = [p[1] for p in pos.values()]
ax.set_xlim(min(xs) - 1, max(xs) + 1)
ax.set_ylim(min(ys) - 0.9, max(ys) + 0.9)
ax.set_aspect('equal')
ax.axis('off')
ax.set_title(titulo, fontsize = 14)
return ax
for nombre, wf in flujos.items():
nodos, aristas = nodos_desde_n8n(wf)
nombres = {n['id'] for n in nodos}
print(f"{nombre}: {len(nodos)} nodos | {len(aristas)} conexiones | destinos inexistentes:",
[d for _, d, _, _ in aristas if d not in nombres] or 'ninguno')
apr = flujos['TL Aprobaciones v2']
consultas = {n['name']: n['parameters']['query'] for n in apr['nodes'] if n['type'].endswith('postgres')}
for nombre, q in consultas.items():
if q.startswith('UPDATE'):
print(f' {nombre}: condicional = {"estado = " + chr(39) + "pendiente" + chr(39) in q} | RETURNING = {"RETURNING" in q}')
pub = flujos['TL Publicación multired v2']
nodos, aristas = nodos_desde_n8n(pub)
switch = next(n for n in pub['nodes'] if n['type'].endswith('switch'))
reglas = [c['rightValue'] for r in switch['parameters']['rules']['values'] for c in r['conditions']['conditions']]
print('Switch: casos =', reglas, '| salida de respaldo =', switch['parameters']['options']['fallbackOutput'])
print('Desde la salida de respaldo se llega a:', sorted(alcanzables(aristas, switch['name'], salida = 1)))
print('¿La salida de respaldo puede llegar a "Publicar por red"?', 'Publicar por red' in alcanzables(aristas, switch['name'], 1))
print('Caminos a "Publicar por red" que no pasan por el caso "aprobada":',
['Publicar por red' in alcanzables(aristas, o, s) for o, d, _, s in aristas if o == switch['name'] and s > 0])
texto_json = json.dumps(exportados, ensure_ascii = False)
print('Claves o URL de webhook escritas:', [p for p in ['hooks.slack.com', 'Bearer ', 'x-api-key', 'EAAG'] if p in texto_json] or 'ninguna')
dibujar_flujo(nodos, aristas, f"{pub['name']}, leído del JSON exportado",
[('¿Estado de la aprobación?', 0.55, 0.45, 'aprobada'), ('¿Estado de la aprobación?', 0.12, -1.05, 'cualquier otro estado'),
('¿Contrato, huella y aprobador?', 0.55, 0.45, 'sí'), ('¿Contrato, huella y aprobador?', 0.12, -1.05, 'no')])
plt.tight_layout()
plt.show()
TL Aprobaciones v2: 12 nodos | 10 conexiones | destinos inexistentes: ninguno
TL Publicación multired v2: 10 nodos | 10 conexiones | destinos inexistentes: ninguno
Caducar si sigue pendiente: condicional = True | RETURNING = True
Registrar decisión si sigue pendiente: condicional = True | RETURNING = True
Switch: casos = ['aprobada'] | salida de respaldo = extra
Desde la salida de respaldo se llega a: ['Avisar en Slack', 'Detener sin aprobación', 'Retener la pieza']
¿La salida de respaldo puede llegar a "Publicar por red"? False
Caminos a "Publicar por red" que no pasan por el caso "aprobada": [False]
Claves o URL de webhook escritas: ninguna

Los dos workflows pasan las comprobaciones: $12$ nodos y $10$ conexiones en TL Aprobaciones v2, $10$ nodos y $10$ conexiones en TL Publicación multired v2, ningún destino inexistente y ninguna clave escrita. Las dos escrituras de la aprobación son condicionales y llevan RETURNING. Y la comprobación central sale como debe: el switch tiene un solo caso, aprobada, su salida de respaldo llega solo a Retener la pieza, Avisar en Slack y Detener sin aprobación, y no hay ningún camino desde ella hasta Publicar por red. Esa búsqueda de alcanzables es la prueba que faltó el Día 74: no pregunta qué hace el flujo con los estados que uno conoce, sino adónde puede llegar lo que uno no conoce.
En el dibujo se lee la forma del diseño. Hay una sola línea que llega a publicar, y para recorrerla hay que pasar por el hexágono gris (una persona decide) y por dos rombos naranjas que dicen sí. Todo lo demás cae a la fila de abajo, que termina en rojo.
Retirar, comunicar y honrar
La parte técnica de la corrección es la más fácil de contar. La parte humana fue la más difícil de hacer. El viernes a las 10:00, con Don Ernesto, Valeria redactó la aclaración que salió en Instagram y en Facebook a las 11:00, aprobada por él desde el flujo nuevo (fue la primera pieza que pasó por TL Publicación multired v2). No culpaba a “un error técnico” ni a nadie: decía que una promoción no aprobada se había publicado por error y que, a quien la hubiera visto, se le cumpliría el sábado. El sábado, el mostrador tuvo una lista con una sola regla: a quien mencione el 2x1, se le cumple, sin pedir pruebas ni capturas. Llegaron nueve.
Don Ernesto, que entregó cuatro de esos pays él mismo, lo resumió a su manera: “Prefiero regalar nueve pays que discutir con una clienta en el mostrador. Pero no quiero regalar diez.” La décima es la que las pruebas deben impedir.
Pruebas que lo habrían detectado
Hubo una prueba el Día 72, y conviene ser justos con ella: comprobaba que cada pieza tuviera una aprobación registrada, con aprobador, y que la huella del texto aprobado coincidiera con la del texto que iba a salir. Era una buena prueba para el mundo del Día 72. Su hueco era que miraba la existencia de la aprobación y no su estado: una aprobación caducada también tiene fila, aprobador asignado y huella. La pregunta correcta no es “¿hay una aprobación?” sino “¿hay una aprobación válida?”, y la respuesta tiene que darla el propio flujo, en el momento de publicar, no un cuaderno que se ejecuta una vez.
La suite de regresión de hoy tiene tres capas:
- La tabla de verdad del Bloque 4: los cuatro estados conocidos más tres que no deberían existir. Es barata, determinista y atrapa cualquier valor por defecto abierto.
- Intercalados aleatorios de los dos escritores. En lugar de hilos, que no se pueden repetir igual dos veces, se simulan $500$ órdenes posibles con una decisión aleatoria (aprobada, rechazada o nadie pulsa) y una hora de llegada del botón entre $10$ segundos antes y $10$ segundos después del plazo.
- Tres comprobaciones por intercalado: que ninguna pieza con un estado distinto de
aprobadallegue a publicarse, que el registro final coincida con lo que vio el flujo y que no haya transiciones prohibidas.
El siguiente bloque las ejecuta contra las dos versiones:
# Pruebas de regresión: tabla de verdad, 500 intercalados aleatorios y comprobación de "aprobación válida" en Python:
def simular_intercalado(version, decision, llegada):
# El plazo vence en t = 0; llegada es el segundo en que el webhook del botón escribe (None si nadie pulsa):
estado, historial, visto = 'pendiente', [], None
eventos = sorted([(0.0, 'reloj')] + ([(llegada, 'boton')] if llegada is not None else []))
for t, quien in eventos:
nuevo = 'caducada' if quien == 'reloj' else decision
if quien == 'reloj' and visto is not None:
continue # la espera ya se reanudó: no hay caducidad
if version == 'v1' or estado == 'pendiente': # v1 escribe siempre; v2 solo si sigue pendiente
historial.append((estado, nuevo))
estado = nuevo
if visto is None:
visto = nuevo # lo primero que ocurre es lo que recibe el flujo
elif visto is None:
visto = estado
decidir = decidir_publicacion_abierta if version == 'v1' else decidir_publicacion_cerrada
publica = decidir(visto) == 'publicar'
return {'publica_sin_aprobada': publica and visto != 'aprobada',
'registro_contradice': estado != visto,
'transicion_prohibida': any(not transicion_valida(a, d) for a, d in historial)}
INTERCALADOS = 500
decisiones = rng.choice(['aprobada', 'rechazada', None], size = INTERCALADOS, p = [0.6, 0.25, 0.15])
llegadas = rng.uniform(-10, 10, size = INTERCALADOS) # segundos respecto del plazo
pruebas = []
for version in ['v1', 'v2']:
tv = tabla_verdad[f'{version} correcta'].all()
for d, t in zip(decisiones, llegadas):
r = simular_intercalado(version, d, None if d is None else round(float(t), 3))
pruebas.append({'version': version, 'tabla de verdad': tv, **r})
pruebas = pd.DataFrame(pruebas)
resumen = pruebas.groupby('version').agg(
publica_sin_aprobada = ('publica_sin_aprobada', 'sum'), registro_contradice = ('registro_contradice', 'sum'),
transicion_prohibida = ('transicion_prohibida', 'sum'))
resumen['casos fallidos'] = pruebas.assign(f = pruebas[['publica_sin_aprobada', 'registro_contradice', 'transicion_prohibida']]
.any(axis = 1)).groupby('version')['f'].sum()
resumen['casos aprobados'] = INTERCALADOS - resumen['casos fallidos']
print('Decisiones simuladas:', pd.Series(decisiones).fillna('nadie pulsa').value_counts().to_dict())
print('Tabla de verdad completa: v1', bool(tabla_verdad['v1 correcta'].all()), '| v2', bool(tabla_verdad['v2 correcta'].all()))
print(resumen.to_string())
veredicto = {v: 'APROBADA' if fila['casos fallidos'] == 0 and tabla_verdad[f'{v} correcta'].all() else 'FALLA'
for v, fila in resumen.iterrows()}
print('Veredicto de la suite:', veredicto)
Decisiones simuladas: {'aprobada': 308, 'rechazada': 120, 'nadie pulsa': 72}
Tabla de verdad completa: v1 False | v2 True
publica_sin_aprobada registro_contradice transicion_prohibida casos fallidos casos aprobados
version
v1 299 227 227 299 201
v2 0 0 0 0 500
Veredicto de la suite: {'v1': 'FALLA', 'v2': 'APROBADA'}
El veredicto es inequívoco. La versión 1 falla la tabla de verdad y, en los $500$ intercalados, publica sin aprobación válida en $299$: todas las caducidades, haya pulsado alguien tarde o no haya pulsado nadie. En $227$ de ellos, además, el registro contradice lo que vio el flujo y la base acepta una transición prohibida, que es exactamente el síntoma que confundió a todos el viernes. La versión 2 aprueba los $500$: cero publicaciones indebidas, cero contradicciones, cero transiciones prohibidas.
Observe por qué esta suite habría detectado el incidente antes de que ocurriera: no necesita reproducir la carrera de cuatro segundos, que es rarísima; le basta con pasar por el flujo un estado caducada, que es frecuente, o un None, que es trivial de escribir. Las pruebas buenas no persiguen el caso raro; prueban la frontera. Desde hoy, la suite corre con las pruebas de flujos del Día 43 cada vez que alguien toca TL Aprobaciones o cualquier flujo que lo llame, y el Día 77 la incorpora a los $30$ casos de prueba de la campaña de primavera, entre ellos el de “ninguna pieza sale sin aprobación”.
El análisis posterior sin culpables
El último paso es el documento que queda. Un análisis posterior sin culpables (en inglés, “blameless postmortem”) es el informe de un incidente escrito para aprender, no para señalar. El capítulo 15 de Site Reliability Engineering, el libro de ingeniería de confiabilidad de Google, lo define por su premisa: identificar las causas que contribuyeron al incidente sin acusar a ninguna persona ni a ningún equipo, partiendo de que todos actuaron con buena intención y con la información que tenían. James Reason, en Human Error, explicó por qué esa premisa no es ingenua: los errores de quien está en la primera línea suelen ser el último eslabón de condiciones latentes que el sistema llevaba tiempo acumulando. Y Sidney Dekker, en su guía de campo sobre el “error humano”, lo resume en una inversión: el error humano no es la causa de un incidente, es su síntoma.
En este incidente hay dos personas a las que sería fácil señalar, y el análisis las nombra para que quede claro por qué no:
- Don Ernesto pulsó bien. Recibió el pedido, lo pensó y lo rechazó dentro del plazo, por el canal que le dieron. Que su decisión tardara cuatro segundos en llegar no es algo que él pudiera ver ni controlar.
- Valeria construyó el flujo de publicación el Día 72, antes de que existiera el estado
caducada. Su switch era correcto para el mundo en que se escribió. Lo que faltó no fue cuidado, sino un mecanismo que avisara a quien llama cuando cambia lo que llama: un contrato versionado y una prueba de frontera.
La Figura 3 resume el cambio de mirada: la lupa del análisis se pone sobre el flujo, no sobre las personas. El análisis de Trigo Lento tiene las seis partes de los días 29, 39 y 54: resumen, impacto, línea de tiempo, causas, lo que salió bien y acciones. El siguiente bloque lo genera como un archivo Markdown en la carpeta incidentes, con la línea de tiempo tomada del Bloque 2 y las acciones con su tipo, su dueño, su fecha y su forma de verificar:
Figura 3. Buscar el sistema, no al culpable: la lupa del análisis posterior se pone sobre el flujo (el switch con salida por defecto, la escritura sin guardia, el contrato sin versión y la prueba que miraba la existencia y no el estado) y no sobre Don Ernesto, que pulsó bien, ni sobre Valeria, que construyó antes de que existiera el estado caducada.
# Generación del análisis posterior sin culpables en incidentes/2026-04-16_publicacion_sin_aprobacion.md en Python:
acciones = pd.DataFrame([
('Retirar las dos piezas y honrar el 2x1 a quien lo pida', 'corregir', 'Don Ernesto', '2026-04-18', '9 pays entregados y registrados'),
('Publicar una aclaración honesta en las dos redes', 'corregir', 'Valeria', '2026-04-17', 'Aclaración aprobada por Don Ernesto'),
('TL Publicación multired v2: solo "aprobada" publica', 'prevenir', 'Valeria', '2026-04-17', 'Bloque 9 en verde en cada cambio'),
('TL Aprobaciones v2: escritura condicional y estado terminal inmutable', 'prevenir', 'Valeria', '2026-04-20', '500 intercalados sin fallos'),
('Versión del contrato en la respuesta y lista cerrada de estados', 'prevenir', 'Valeria', '2026-04-20', 'Un estado nuevo detiene y avisa'),
('Alerta inmediata si una pieza sale sin "aprobada"', 'detectar', 'Valeria', '2026-04-21', 'Prueba con pieza caducada avisa en 1 min'),
('Aviso de decisión tardía a #alertas', 'detectar', 'Valeria', '2026-04-21', 'Botón pulsado tras el plazo avisa'),
('Revisión de contratos al cambiar un sub-workflow', 'prevenir', 'Don Ernesto', '2026-04-24', 'Lista firmada en el cambio siguiente'),
], columns = ['accion', 'tipo', 'dueno', 'fecha', 'verificacion'])
filas = linea[['hora_txt', 'fuente', 'evento']].values.tolist()
documento = '\n'.join([
'# Análisis posterior sin culpables: publicación sin aprobación válida (16 de abril de 2026)',
'', 'Incidente ficticio con fines didácticos. Sin datos personales de clientas.', '',
'## Resumen',
'Una promoción rechazada por Don Ernesto se publicó en Instagram y Facebook a las 20:00:01 y estuvo visible 42 minutos.',
'La aprobación caducó un segundo antes de que llegara el rechazo, y el flujo publicaba cualquier estado distinto de "rechazada".', '',
'## Impacto',
f'- 2 publicaciones durante 42 min; 9 clientas pidieron el 2x1: {9 * TICKET_PAY} MXN ({9 * TICKET_PAY / MXN_POR_USD:.2f} USD).',
'- Detección: una clienta a los 35 min; el sistema, a las 12 h (panel de la mañana).', '',
'## Línea de tiempo'] + [f'- {h} | {f} | {e}' for h, f, e in filas] + ['',
'## Causas',
'- Disparador: carrera entre el reloj de caducidad y el webhook del botón (ventana de 4 s).',
'- Causa: la salida por defecto del switch publicaba; el estado "caducada" (Día 74) no existía al diseñar el flujo (Día 72).',
'- Contribuyentes: escrituras sin guardia; la base aceptó caducada -> rechazada; la prueba del Día 72 miraba que existiera una aprobación, no su estado.', '',
'## Lo que salió bien',
'- Don Ernesto decidió a tiempo y por el canal correcto.',
'- Valeria retiró las piezas en 7 minutos desde el aviso de la clienta.',
'- El registro común del Día 75 permitió reconstruir cada segundo.', '',
'## Acciones'] + [f'- [{t}] {a} | {d} | {f} | {v}' for a, t, d, f, v in acciones.values])
ruta_doc = carpeta / 'incidentes' / '2026-04-16_publicacion_sin_aprobacion.md'
ruta_doc.parent.mkdir(exist_ok = True)
ruta_doc.write_text(documento + '\n', encoding = 'utf-8')
print(f'Escrito {ruta_doc.as_posix()} ({len(documento.splitlines())} líneas). Primeras líneas:')
print('\n'.join(documento.splitlines()[:16]))
print('Acciones por tipo:', acciones['tipo'].value_counts().to_dict(), '| por dueño:', acciones['dueno'].value_counts().to_dict())
Escrito trigo_lento/dia_76/incidentes/2026-04-16_publicacion_sin_aprobacion.md (45 líneas). Primeras líneas:
# Análisis posterior sin culpables: publicación sin aprobación válida (16 de abril de 2026)
Incidente ficticio con fines didácticos. Sin datos personales de clientas.
## Resumen
Una promoción rechazada por Don Ernesto se publicó en Instagram y Facebook a las 20:00:01 y estuvo visible 42 minutos.
La aprobación caducó un segundo antes de que llegara el rechazo, y el flujo publicaba cualquier estado distinto de "rechazada".
## Impacto
- 2 publicaciones durante 42 min; 9 clientas pidieron el 2x1: 3420 MXN (184.86 USD).
- Detección: una clienta a los 35 min; el sistema, a las 12 h (panel de la mañana).
## Línea de tiempo
- 16/04 18:00:00 | tabla aprobaciones | Crear aprobación (Slack #aprobaciones)
- 16/04 18:30:00 | tabla aprobaciones | Escalado (Valeria, por Slack)
- 16/04 19:00:00 | tabla aprobaciones | Escalado (Don Ernesto, por WhatsApp)
Acciones por tipo: {'prevenir': 4, 'corregir': 2, 'detectar': 2} | por dueño: {'Valeria': 6, 'Don Ernesto': 2}
El archivo tiene $45$ líneas y sus primeras dieciséis ya cuentan el incidente completo sin un solo adjetivo: qué pasó, cuánto costó ($3,420$ MXN, $184.86$ USD) y cuándo se detectó. Las $8$ acciones se reparten en $2$ que corrigen, $4$ que previenen y $2$ que detectan, como muestra la Tabla 3.
Tabla 3. Acciones del análisis posterior sin culpables, con su tipo, su dueño, su fecha compromiso y su verificación.
| Acción | Tipo | Dueño | Fecha compromiso | Verificación |
|---|---|---|---|---|
| Retirar las dos piezas y honrar el 2x1 a quien lo pida | Corregir | Don Ernesto | 18 de abril | 9 pays entregados y registrados |
| Publicar una aclaración honesta en las dos redes | Corregir | Valeria | 17 de abril | Aclaración aprobada por Don Ernesto |
TL Publicación multired v2: solo “aprobada” publica | Prevenir | Valeria | 17 de abril | Bloque 9 en verde en cada cambio |
TL Aprobaciones v2: escritura condicional y estado terminal inmutable | Prevenir | Valeria | 20 de abril | 500 intercalados sin fallos |
| Versión del contrato en la respuesta y lista cerrada de estados | Prevenir | Valeria | 20 de abril | Un estado nuevo detiene y avisa |
| Alerta inmediata si una pieza sale sin “aprobada” | Detectar | Valeria | 21 de abril | Una pieza caducada de prueba avisa en 1 minuto |
Aviso de decisión tardía a #alertas | Detectar | Valeria | 21 de abril | Un botón pulsado tras el plazo avisa |
| Revisión de contratos al cambiar un sub-workflow | Prevenir | Don Ernesto | 24 de abril | Lista firmada en el cambio siguiente |
Dos acciones tienen como dueño a Don Ernesto, y no es un gesto. La primera es suya porque el mostrador es suyo. La segunda, la revisión de contratos, lo convierte en el segundo par de ojos de cualquier cambio en un sub-workflow, con una lista de tres preguntas en castellano llano: qué cambió, quién usa lo que cambió y qué pasa si llega algo que nadie esperaba. No necesita leer JSON para hacer la tercera pregunta; de hecho, la hizo él el viernes por la mañana.
La sexta acción cierra el hueco de las doce horas. Además del indicador diario del panel, TL Publicación multired v2 escribe el estado de la aprobación en cada publicación, y una consulta cada minuto busca publicaciones de las últimas dos horas sin estado_aprobacion = 'aprobada'. Con la corrección no debería encontrar nada nunca; si encuentra algo, es que alguien publicó fuera del flujo, la rama gris del árbol, y el aviso llega en un minuto en lugar de en doce horas.
El análisis se leyó en voz alta el lunes 20 de abril, a las 7:30, antes de abrir, con las seis personas de Trigo Lento alrededor de la mesa de amasar. Duró veinte minutos. Uno de los panaderos preguntó lo que nadie había preguntado: qué pasaba si Don Ernesto aprobaba una pieza y después se arrepentía. La respuesta (la aprobación es terminal; arrepentirse es retirar la pieza y pedir una aprobación nueva) entró como nota en el manual de operación que el módulo 8 escribirá.
Consideraciones finales
El día empezó con un $35$ de $36$ en rojo y un registro que decía rechazada sobre una pieza publicada, y termina con una línea de tiempo segundo a segundo, una carrera reproducida mil veces, un disparador y una causa separados, dos workflows corregidos, una suite de regresión que la versión 1 suspende y la versión 2 aprueba, y ocho acciones con dueño y fecha. La pieza técnica de la corrección es pequeña: una condición más en dos UPDATE y un switch con un caso en lugar de una excepción. Lo grande es la regla que deja.
Las cifras tienen límites que conviene decir. El incidente es ficticio. La probabilidad de caducidad del $4%$ es un supuesto: el Día 74 midió tiempos de aprobación en un mes simulado, no en un año real, y la cifra verdadera la dirá el panel del Día 75 en unos meses. Las probabilidades del árbol de fallos son ilustrativas. El tiempo de nueva aprobación de las piezas retenidas es un supuesto lognormal. Y la carrera del Bloque 3 se ejecutó en milisegundos y en un solo equipo, de modo que sus conteos cambian de una ejecución a otra; lo que no cambia, y es lo que sostiene las conclusiones, es el cero de la versión 2.
Antes de dar por buena cualquier automatización que actúe en nombre de su negocio después de una aprobación, conviene hacerse algunas preguntas:
- ¿Qué hace su flujo con un estado que no conoce: lo detiene o lo deja pasar?
- ¿Cuántos procesos pueden escribir sobre el mismo registro, y qué impide que el último pise al primero?
- ¿Las reglas de su máquina de estados viven en un diagrama o en la base de datos?
- Cuando cambia un sub-workflow, ¿quién revisa los flujos que lo llaman?
- ¿Cuánto tarda su sistema, sin ayuda de un cliente, en darse cuenta de que hizo algo que no debía?
Un gran libro que habla de esto es The Field Guide to Understanding ‘Human Error’ (o Guía de campo para entender el “error humano”), de Sidney Dekker. Está escrito para la aviación, la medicina y la industria, pero su consejo central se aplica sin cambios a una panadería con seis personas y un servidor: cuando alguien parece haberse equivocado, pregunte por qué lo que hizo tenía sentido para esa persona en ese momento, y busque la respuesta en el sistema que la rodeaba.
Una aprobación que el sistema puede ignorar no es una aprobación; es una sugerencia. Trigo Lento ya no tiene sugerencias: cada pieza que sale lleva una aprobación registrada con su estado, su aprobador y su texto, y todo lo que no es un “sí” explícito se queda en el obrador.
Conclusiones clave
Diagnóstico
- El incidente (ficticio) publicó una promoción rechazada durante $42$ minutos; la detectó una clienta a los $35$ y el propio sistema a las $12$ horas, y honrarla a nueve clientas costó $3,420$ MXN ($184.86$ USD), más de cuatro meses del presupuesto de $45$ USD.
- Los cinco porqués llevan del síntoma a un switch diseñado el Día 72, cuando solo existían “aprobada” y “rechazada”, y a una prueba que comprobaba que hubiera una aprobación, no su estado.
- En el árbol de fallos, cerrar la salida por defecto baja el evento superior de $0.0434$ a $0.0015$ por pieza ($18.73$ a $0.65$ piezas al año) sin tocar la probabilidad de caducidad.
Carrera y falla cerrada
- La carrera fue el disparador: dos escritores sobre la misma fila con “la última escritura gana”; con
UPDATE ... WHERE estado = 'pendiente'y un estado terminal inmutable en la base, las contradicciones y las transiciones prohibidas bajan a cero. - El valor por defecto que publica fue la causa: con $36$ piezas al mes y un $4%$ de caducidad, la falla abierta publica $17.28$ piezas al año sin aprobación y la probabilidad de al menos una en un mes es $1 - 0.96^{36} = 0.770$.
- La falla cerrada de Saltzer y Schroeder publica cero y retiene esas piezas con una mediana de $34.8$ minutos; solo $1.10$ al año se reprograman. Las acciones con efecto económico o reputacional fallan cerradas; las internas pueden fallar abiertas, siempre con aviso.
Aprender sin culpables
- Las pruebas buenas prueban la frontera: la tabla de verdad con estados desconocidos y $500$ intercalados habrían suspendido la versión 1 ($299$ casos fallidos) sin necesidad de reproducir la carrera.
- Don Ernesto pulsó bien y Valeria construyó el flujo antes de que existiera el estado “caducada”: el análisis busca el sistema, no al culpable, y sus ocho acciones ($2$ corrigen, $4$ previenen, $2$ detectan) tienen dueño, fecha y verificación.
- El error del sistema no se traslada al consumidor: se retira, se aclara con honestidad por el mismo canal y se cumple lo publicado, como obliga la Ley Federal de Protección al Consumidor.
Mañana, en el Día 77: Sesión práctica: la campaña de primavera, de la idea a la publicación. Con las aprobaciones ya a prueba de carreras, Valeria preparará la campaña del 10 de mayo con todo lo construido en el módulo 7, desde el calendario y los borradores con fuentes hasta la publicación supervisada y el cuadro de mando, y comprobará con treinta casos de prueba que ninguna pieza sale sin aprobación, sin leyenda o con un precio distinto del catálogo.
Referencias
- Saltzer, J. H. y Schroeder, M. D. (1975). The Protection of Information in Computer Systems. Proceedings of the IEEE, 63(9), 1278-1308. https://doi.org/10.1109/PROC.1975.9939
- Vesely, W. E., Goldberg, F. F., Roberts, N. H. y Haasl, D. F. (1981). Fault Tree Handbook (NUREG-0492). U.S. Nuclear Regulatory Commission. https://www.nrc.gov/reading-rm/doc-collections/nuregs/staff/sr0492/
- Reason, J. (1990). Human Error. Cambridge University Press.
- Dekker, S. (2014). The Field Guide to Understanding ‘Human Error’ (3.ª ed.). Ashgate.
- Beyer, B., Jones, C., Petoff, J. y Murphy, N. R. (Eds.) (2016). Site Reliability Engineering. O’Reilly Media. Capítulo 15: Postmortem Culture: Learning from Failure. https://sre.google/sre-book/postmortem-culture/ (revisado en octubre de 2026).
- n8n (2026). Switch node (opción “Fallback Output”), Stop and Error node y Wait node. Documentación de n8n. https://docs.n8n.io/integrations/builtin/core-nodes/ (revisado en octubre de 2026).
- Cámara de Diputados del H. Congreso de la Unión (2026). Ley Federal de Protección al Consumidor (texto vigente), artículos 7 y 42. https://www.diputados.gob.mx/LeyesBiblio/ (revisado en octubre de 2026).

