Arquitecto de automatización con IA: Día 79
Índice de contenido
Resumen:
El Día 19, Trigo Lento tenía $8$ flujos y cabían en una tabla escrita a mano; hoy, a tres semanas de las vacaciones de Valeria, nadie sabe cuántos hay ni cuál llama a cuál. En esta entrada, Valeria deja de contar de memoria: un lector automático recorre los $61$ archivos JSON exportados a lo largo de la serie, reconoce $46$ versiones de $27$ procesos y deja $23$ flujos activos con $267$ nodos; a cada uno le pone dueño, suplente, criticidad, datos personales, proveedores y fecha de revisión; extrae las $30$ dependencias entre flujos, encuentra un ciclo, un huérfano y $8$ sub-workflows que nadie exportó; calcula que una semana de ausencia tiene un $5.45%$ de probabilidad de coincidir con una rotura sin relevo y la baja al $2.76%$ nombrando suplentes; escribe la matriz RACI, los $10$ hallazgos del gobierno y un calendario de revisiones, y genera inventario_flujos.md desde el código. Al terminar, usted sabrá construir el inventario de un ecosistema de automatizaciones a partir de sus propias exportaciones y gobernarlo con dueños, grafos y fechas en lugar de con memoria.
¿Cómo citar el presente artículo?
Romero, J. (20 de abril de 2026). Arquitecto de automatización con IA: Día 79. python-data-engineer. https://www.python-data-engineer.com/post/aai/dia_79/.
Arquitecto de automatización con IA: Día 79 by Jeshua Romero Guadarrama, available under Attribution 4.0 International (CC BY 4.0) at https://www.python-data-engineer.com/post/aai/dia_79/.
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. Los diez bloques de esta entrada son offline: leen los blueprints de Make y los workflows de n8n que la serie exportó en los días anteriores (con identificadores de ejemplo y sin secretos) y sus salidas son reales. La forma de referirse a un sub-workflow desde el nodo Execute Sub-workflow de n8n (workflowId como localizador de recurso, con value y cachedResultName) se contrastó con la documentación de n8n, revisada en octubre de 2026; la estructura de flow, routes y onerror de los blueprints de Make, con la de su API. Ningún bloque llama a la API de Claude.
Día 79. Proyecto integrador II: inventario de flujos y gobierno
Introducción
Ayer, en el Día 78 (Proyecto integrador I: la arquitectura final del ecosistema), Valeria dibujó el ecosistema de Trigo Lento en tres niveles, contexto, contenedores y flujos, calculó que el camino crítico de un pedido está disponible el $99.30%$ del tiempo y que el eslabón débil es el servidor propio y no la inteligencia artificial, escribió cinco registros de decisiones y comprobó qué dato sale a cada proveedor, y Trigo Lento ganó arquitectura_v1.md, el plano del obrador digital que Don Ernesto pidió. Ese plano tiene un hueco que Valeria vio al terminarlo: la sección “Flujos por dominio” la escribió de memoria.
Es lunes 20 de abril, y en el calendario de la administración hay un círculo rojo alrededor del lunes 11 de mayo: Valeria se va una semana, la primera desde que empezó la serie, y vuelve el 18. Faltan $21$ días. Anoche, repasando el plano, intentó responder una pregunta sencilla: ¿cuántos flujos corren hoy en la panadería? En el Día 19 eran $8$ y cabían en una tabla hecha a mano; desde entonces la serie construyó el escenario de pedidos en Make, lo migró a n8n, añadió el agente, la facturación, los lotes, el orquestador multicanal, el inventario por MCP, la publicación en redes y las aprobaciones. Valeria contó $19$, luego $22$, luego dejó de contar, porque cada flujo que recordaba le recordaba otro que llamaba a un tercero. Lo que de verdad no sabe es cuál llama a cuál: si TL Orquestador v1 se detiene, ¿qué más se detiene? Y si ella no está, ¿quién lo sabe?
Don Ernesto escucha la cuenta desde la puerta del obrador y la corta con una comparación que no es una broma: “En la alacena del obrador cada frasco tiene etiqueta: qué es, quién lo preparó y cuándo caduca. Si usted me dice que ahí dentro hay veintitantos frascos sin etiqueta y no sabe cuál se vacía si quita otro, entonces no tenemos una alacena, tenemos un montón. Quiero la lista completa, con nombre y apellido de quien responde por cada uno, antes de que se suba al autobús.” La objeción tiene dos partes y las dos se pueden resolver con código: la lista completa no se escribe de memoria, se lee de las exportaciones que la propia serie fue guardando; y el “quién responde” no es una columna más, es la pieza de gobierno que el Día 19 llamó dueño y suplente y que hoy, con $23$ flujos, hace falta de verdad.
En la presente entrada, descubrirá cómo se construye el inventario de un ecosistema de automatizaciones a partir de sus propias exportaciones: los lectores nodos_desde_blueprint y nodos_desde_n8n de los días 22 y 36 recorren todos los JSON de la carpeta flujos/, reconocen versiones, descartan borradores y fragmentos y dejan los flujos activos. Verá la ficha de cada flujo con sus campos obligatorios, una puntuación de criticidad por efecto en clientes, dinero y datos, y el grafo de dependencias extraído de los nodos Execute Sub-workflow, de las herramientas del agente, de los webhooks propios y del flujo de errores, con una búsqueda en profundidad escrita a mano que encuentra ciclos y huérfanos. Calculará cuántos flujos críticos dependen de una sola persona y la probabilidad de que una ausencia de una semana coincida con una rotura sin relevo, escribirá la matriz RACI de los seis procesos, fijará el calendario de gobierno y generará inventario_flujos.md desde el código, con los $10$ hallazgos que marcan el trabajo de los próximos días.
Objetivos de aprendizaje
Al terminar esta entrada, usted podrá:
- Generar el inventario completo de flujos leyendo automáticamente los blueprints de Make y los workflows de n8n exportados.
- Asignar a cada flujo dueño, suplente, criticidad, datos personales, proveedores y fecha de revisión.
- Construir el grafo de dependencias entre flujos y detectar ciclos, flujos huérfanos y concentración de responsabilidad.
- Definir una matriz RACI y un calendario de revisiones del gobierno ligero (enlace con el Día 19).
Inventario automático
El gobierno ligero del Día 19 se sostenía en tres preguntas: ¿qué automatizaciones corren en el negocio?, ¿quién responde por cada una? y ¿cuáles pueden hacer daño si fallan? Aquel día la primera se respondía con una tabla de $8$ filas que Valeria escribió mirando sus cuadernos. El problema de una tabla escrita a mano no es que sea trabajosa, es que se desactualiza en silencio: nadie la toca cuando sube una versión nueva, cuando un flujo se retira o cuando un sub-workflow nace dentro de otro. La única fuente que no miente es la exportación: el blueprint de Make y el JSON de n8n describen lo que la plataforma ejecuta, y la serie los fue guardando en trigo_lento/dia_N/flujos/ desde el Día 11.
La idea de hoy es, por tanto, invertir el orden: primero se leen las exportaciones y después se declara lo que el JSON no puede saber (quién responde, cuánto daño hace si falla). Las dos cosas se cruzan en una ficha por flujo, y si un flujo aparece en las exportaciones sin ficha declarada, el inventario lo dice en voz alta en lugar de dejarlo fuera. La Figura 1 dibuja ese inventario como lo que es para Don Ernesto: una alacena con un frasco por flujo y una etiqueta con lo que se preparó, quién lo cuida y cuándo hay que volver a mirarlo.
Figura 1. El inventario como alacena etiquetada: un estante por dominio y un frasco por cada uno de los 23 flujos activos, coloreado por criticidad (9 críticos, 6 medios y 8 bajos); cada etiqueta dice qué es, quién responde, cuánto daño hace si falla, qué datos contiene y cuándo caduca su revisión.
El primer bloque fija las importaciones, la semilla de la serie, la fecha de hoy y la semana de vacaciones:
# Importaciones base, semilla de la serie, fecha de hoy y carpeta del día en Python:
import warnings
warnings.filterwarnings('ignore')
import json
import re
import textwrap
from collections import Counter, defaultdict
from datetime import date, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch
# Generador aleatorio con la semilla global de la serie:
rng = np.random.default_rng(2026)
pd.set_option('display.width', 150)
pd.set_option('display.max_colwidth', 40)
carpeta = Path('trigo_lento') / 'dia_79'
carpeta.mkdir(parents = True, exist_ok = True)
hoy = date(2026, 4, 20)
vacaciones = (date(2026, 5, 11), date(2026, 5, 17)) # la semana de Valeria, después del 10 de mayo
DIAS = ['lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo']
print('numpy', np.__version__, '| pandas', pd.__version__)
print(f'Hoy: {DIAS[hoy.weekday()]} {hoy:%d/%m/%Y} | vacaciones de Valeria: {vacaciones[0]:%d/%m} a '
f'{vacaciones[1]:%d/%m} ({(vacaciones[1] - vacaciones[0]).days + 1} días, faltan {(vacaciones[0] - hoy).days})')
numpy 2.4.6 | pandas 3.0.3
Hoy: lunes 20/04/2026 | vacaciones de Valeria: 11/05 a 17/05 (7 días, faltan 21)
Hoy es lunes $20$ de abril y faltan $21$ días para la ausencia de Valeria, del $11$ al $17$ de mayo. El resto de la entrada trabaja hacia esa fecha: todo lo que el inventario encuentre tiene que estar resuelto, o al menos tener dueño, antes del $11$.
Dos lectores para dos formatos
Un blueprint de Make es una lista flow de módulos; cada router abre routes, cada una con su propio flow, y cada módulo puede llevar manejadores en onerror. Un workflow de n8n es una lista plana nodes y un diccionario connections indexado por el nombre del nodo de origen. Los lectores nodos_desde_blueprint (Día 22) y nodos_desde_n8n (Día 36) convierten los dos formatos en la misma pareja de listas, nodos y aristas, que la serie dibuja desde el Día 2. La función leer_exportacion decide qué lector usar mirando la forma del JSON, y acepta que un archivo contenga una lista de workflows, como ocurrió el Día 76, que exportó dos flujos juntos.
Lo que convierte una lista de archivos en un inventario son tres reglas de limpieza. Un archivo que no tiene ni flow ni nodes no es un flujo (es un pinData, un nodo suelto o el cuerpo de una petición guardado para la entrada) y se descarta. Un flujo cuyo nombre no empieza por TL es de otro negocio (el escenario del despacho contable Ledesma del Día 31) y también se descarta. Y un flujo con “fragmento” o “borrador” en el nombre es material didáctico, no producción. Lo que queda son versiones: TL Pedidos WhatsApp v1, v2, v2.1 y así hasta v3 son el mismo proceso, y solo la última versión de cada proceso está activa. El siguiente bloque recorre todos los archivos, aplica las tres reglas y deja un proceso por fila:
# Lectura automática de todos los flujos exportados con nodos_desde_blueprint y nodos_desde_n8n en Python:
def modulos_make(flujo):
# Recorre un blueprint de Make: módulos, rutas de los routers y manejadores de error.
for m in flujo:
yield m
for manejador in m.get('onerror') or []:
yield manejador
for ruta in m.get('routes') or []:
yield from modulos_make(ruta['flow'])
def nodos_desde_blueprint(bp): # Día 22: lector de blueprints de Make
nodos, aristas = [], []
def rec(flujo, previo):
for m in flujo:
nodos.append({'id': m['id'], 'tipo': m['module']})
if previo is not None:
aristas.append((previo, m['id']))
for manejador in m.get('onerror') or []:
nodos.append({'id': manejador['id'], 'tipo': manejador['module']})
aristas.append((m['id'], manejador['id']))
for ruta in m.get('routes') or []:
rec(ruta['flow'], m['id'])
previo = m['id']
rec(bp['flow'], None)
return nodos, aristas
def nodos_desde_n8n(wf): # Día 36: lector de workflows de n8n
nodos = [{'id': n['name'], 'tipo': n['type']} for n in wf['nodes']]
aristas = [(o, d['node']) for o, s in wf.get('connections', {}).items()
for lista in s.values() for salida in lista for d in (salida or [])]
return nodos, aristas
def leer_exportacion(ruta):
datos = json.loads(ruta.read_text(encoding = 'utf-8'))
for wf in (datos if isinstance(datos, list) else [datos]): # el Día 76 exportó dos flujos en un archivo
if isinstance(wf, dict) and isinstance(wf.get('flow'), list):
yield 'Make', wf, nodos_desde_blueprint(wf)
elif isinstance(wf, dict) and isinstance(wf.get('nodes'), list) and 'connections' in wf:
yield 'n8n', wf, nodos_desde_n8n(wf)
VERSION = re.compile(r'\s+v(\d+(?:\.\d+)?)$')
def proceso_y_version(nombre):
m = VERSION.search(nombre)
return VERSION.sub('', nombre).strip(), tuple(int(x) for x in m.group(1).split('.')) if m else (1,)
leidos, descartes, flujos_en_archivos = [], Counter(), 0
rutas = sorted(Path('trigo_lento').glob('dia_*/flujos/*/*.json'), key = lambda r: (int(r.parts[1][4:]), r.name))
for ruta in rutas:
exportes = list(leer_exportacion(ruta))
if not exportes:
descartes['no es un flujo (datos fijados, nodo suelto, petición)'] += 1
flujos_en_archivos += len(exportes)
for plataforma, wf, (nodos, aristas) in exportes:
if not wf['name'].startswith('TL '):
descartes['de otro negocio'] += 1
elif re.search(r'fragmento|borrador', wf['name']):
descartes['fragmento o borrador'] += 1
else:
proceso, version = proceso_y_version(wf['name'])
leidos.append({'proceso': proceso, 'version': version, 'plataforma': plataforma, 'dia': int(ruta.parts[1][4:]),
'nombre': wf['name'], 'nodos': len(nodos), 'conexiones': len(aristas), 'ruta': ruta.as_posix(), 'wf': wf})
exportes = pd.DataFrame(leidos)
SUSTITUIDOS = {'TL Formulario a hoja': 'TL Formulario (Día 22)', # cambió de nombre al pasar a Make
'TL Pedidos WhatsApp': 'TL Pedidos WhatsApp n8n (Día 45)'} # escenario de Make archivado
RETIRADOS = {'TL Importación histórica', 'TL Lote de importación'} # una sola corrida, el Día 41
ultimas = exportes.sort_values(['version', 'dia']).groupby('proceso').tail(1)
activos = ultimas[~ultimas.proceso.isin(set(SUSTITUIDOS) | RETIRADOS)].sort_values('dia').reset_index(drop = True)
print(f'Archivos JSON leídos: {len(rutas)} | flujos dentro de ellos: {flujos_en_archivos}')
for motivo, n in descartes.items():
print(f' descartados por {motivo}: {n}')
print(f'Versiones de Trigo Lento: {len(exportes)} | procesos distintos: {exportes.proceso.nunique()} | '
f'versiones antiguas: {len(exportes) - len(ultimas)} | sustituidos: {len(SUSTITUIDOS)} | retirados: {len(RETIRADOS)}')
print(f'Flujos activos: {len(activos)} | por plataforma: {activos.plataforma.value_counts().to_dict()} | '
f'nodos y módulos: {activos.groupby("plataforma").nodos.sum().to_dict()} | total: {activos.nodos.sum()}')
Archivos JSON leídos: 61 | flujos dentro de ellos: 55
descartados por no es un flujo (datos fijados, nodo suelto, petición): 7
descartados por fragmento o borrador: 8
descartados por de otro negocio: 1
Versiones de Trigo Lento: 46 | procesos distintos: 27 | versiones antiguas: 19 | sustituidos: 2 | retirados: 2
Flujos activos: 23 | por plataforma: {'n8n': 19, 'Make': 4} | nodos y módulos: {'Make': 37, 'n8n': 230} | total: 267
De $61$ archivos JSON salen $55$ flujos, y de ellos $46$ versiones de Trigo Lento pertenecientes a $27$ procesos distintos. Diecinueve versiones son antiguas (el escenario de pedidos de Make pasó por siete versiones entre los días 23 y 33), dos procesos fueron sustituidos por otro con nombre distinto (el formulario a hoja del Día 11 se convirtió en TL Formulario al pasar a Make, y TL Pedidos WhatsApp de Make se archivó cuando el Día 45 lo migró a n8n) y dos fueron retirados por diseño (las dos importaciones del Día 41 corrieron una sola vez). El resultado son $23$ flujos activos: $19$ en n8n y $4$ en Make, con $267$ nodos y módulos en total, $230$ en n8n y $37$ en Make.
Dos observaciones sobre esa cifra. La primera es que $23$ no es un número que Valeria hubiera adivinado: contó $19$ y luego $22$, y el inventario automático encontró uno más que ella había olvidado porque corre solo de madrugada. La segunda es que la lista de ayer, la de arquitectura_v1.md, nombraba $24$ flujos, y varios de ellos no existen como exportación: TL Errores v1, TL Responder por canal v1 o TL Remisiones v1 aparecen en el plano, se usan desde otros flujos y nadie guardó su JSON. Esa diferencia entre lo que la arquitectura dice y lo que la carpeta contiene es el primer hallazgo del día, y la sección del grafo lo medirá.
La ficha de cada flujo
La exportación dice qué hace un flujo; no dice quién responde por él ni cuánto daño hace si falla. Esa segunda mitad la declara Valeria en un diccionario FICHAS con, por proceso, el dominio al que pertenece, el dueño, el suplente, tres efectos de $0$ a $3$ (en clientes, en dinero y en datos personales), si tiene pruebas y la fecha de la última revisión. La función ficha cruza lo declarado con lo leído: busca en el texto del JSON los proveedores externos (Anthropic, Meta, Twilio, Google, Slack, LinkedIn y FacturaFácil, por los dominios y nombres de nodo que cada uno deja) y detecta si el flujo maneja datos personales buscando campos como telefono, WaId, correo o alergi en los parámetros. Si el JSON no los muestra pero Valeria declaró un efecto en datos mayor que cero, la ficha también marca datos personales: la detección automática suma, nunca resta.
# Ficha de cada flujo: datos declarados por Valeria y datos leídos del JSON, con conteos y gráfica en Python:
FICHAS = { # proceso: (dominio, dueño, suplente, efecto en clientes, dinero, datos de 0 a 3, pruebas, última revisión)
'TL Formulario': ('pedidos', 'Valeria', None, 2, 1, 2, True, '2026-02-22'),
'TL Recordatorio de recogida':('comunicación', 'Valeria', None, 3, 1, 2, False, '2026-02-26'),
'TL Brújula diario': ('pedidos', 'Valeria', 'Itzel', 3, 3, 1, True, '2026-03-04'),
'TL Prueba de humo': ('operación', 'Valeria', None, 0, 0, 0, False, '2026-03-06'),
'TL Clasificador IA': ('comunicación', 'Valeria', None, 1, 0, 1, True, '2026-03-08'),
'TL Facturación': ('pedidos', 'Valeria', None, 2, 3, 2, True, '2026-03-12'),
'TL Pastel con alergias': ('pedidos', 'Valeria', None, 3, 2, 3, True, '2026-03-16'),
'TL Pedidos WhatsApp n8n': ('pedidos', 'Valeria', None, 3, 3, 2, True, '2026-03-17'),
'TL Gmail mayoristas': ('comunicación', 'Valeria', None, 2, 2, 2, True, '2026-03-19'),
'TL WhatsApp secuencias': ('comunicación', 'Valeria', None, 2, 1, 2, False, '2026-03-20'),
'TL Slack operaciones': ('operación', 'Valeria', None, 1, 1, 1, False, '2026-03-22'),
'TL Orquestador': ('comunicación', 'Valeria', None, 3, 2, 2, True, '2026-03-28'),
'TL Métricas de atención': ('informes', 'Valeria', None, 0, 0, 1, False, '2026-03-25'),
'TL Monitor de plantillas': ('comunicación', 'Valeria', None, 2, 1, 0, False, '2026-03-26'),
'TL Lote nocturno': ('informes', 'Valeria', None, 0, 1, 1, False, '2026-04-01'),
'TL Consulta de inventario': ('inventario', 'Joaquín', 'Valeria', 1, 1, 0, False, '2026-04-04'),
'TL Informe del lunes': ('informes', 'Valeria', None, 0, 1, 0, True, '2026-04-08'),
'TL Agente de pedidos': ('pedidos', 'Valeria', None, 3, 2, 2, True, '2026-04-10'),
'TL Evaluación del agente': ('operación', 'Valeria', None, 0, 0, 1, True, '2026-04-11'),
'TL Alertas de flujos': ('operación', 'Valeria', None, 0, 0, 0, False, '2026-04-16'),
'TL Publicación multired': ('contenido', 'Valeria', None, 2, 1, 0, True, '2026-04-17'),
'TL Aprobaciones': ('operación', 'Valeria', None, 2, 3, 1, True, '2026-04-17'),
'TL Campaña primavera': ('contenido', 'Valeria', None, 2, 1, 0, True, '2026-04-18'),
}
PROVEEDORES = {'Anthropic': r'api\.anthropic\.com|lmChatAnthropic|anthropicApi', 'Meta': r'graph\.facebook\.com|Cloud API',
'Twilio': r'twilio', 'Google': r'googleapis|googleSheets|google-sheets|google-email', 'Slack': r'slack',
'LinkedIn': r'linkedin', 'FacturaFácil': r'facturafacil|FacturaFácil'}
PERSONALES = re.compile(r'telefono|teléfono|"From"|WaId|whatsapp:\+|correo|email|nombre_cliente|direccion|cliente_id|alergi', re.I)
def ficha(fila):
texto = json.dumps(fila.wf, ensure_ascii = False)
dominio, dueno, suplente, cli, din, dat, pruebas, revision = FICHAS[fila.proceso]
proveedores = [p for p, patron in PROVEEDORES.items() if re.search(patron, texto, re.I)]
if fila.plataforma == 'Make':
proveedores = ['Make'] + proveedores
return {'flujo': fila.nombre, 'plataforma': fila.plataforma, 'version': '.'.join(map(str, fila.version)),
'dominio': dominio, 'dueno': dueno, 'suplente': suplente or '', 'clientes': cli, 'dinero': din, 'datos': dat,
'detectados': bool(PERSONALES.search(texto)), 'personales': bool(PERSONALES.search(texto)) or dat > 0, 'proveedores': ', '.join(proveedores) or 'ninguno externo',
'pruebas': pruebas, 'revision': pd.Timestamp(revision), 'dia': fila.dia, 'nodos': fila.nodos}
inventario = pd.DataFrame([ficha(f) for f in activos.itertuples()])
sin_ficha = set(activos.proceso) - set(FICHAS)
print('Flujos leídos sin ficha declarada:', sorted(sin_ficha) or 'ninguno')
print(inventario[['flujo', 'plataforma', 'version', 'dominio', 'dueno', 'suplente', 'detectados', 'proveedores']].to_string(index = False))
print(f'Datos personales detectados en el JSON: {int(inventario.detectados.sum())} | declarados por Valeria sin detección: '
f'{int((inventario.personales & ~inventario.detectados).sum())} | con datos personales en total: {int(inventario.personales.sum())} de {len(inventario)}')
print(f'Con suplente: {int((inventario.suplente != "").sum())} | con pruebas: {int(inventario.pruebas.sum())} | '
f'proveedores externos distintos: {len(set(", ".join(inventario.proveedores).split(", ")) - {"ninguno externo"})}')
ORDEN_DOMINIOS = ['pedidos', 'comunicación', 'informes', 'contenido', 'inventario', 'operación']
conteo = pd.crosstab(inventario.dominio, inventario.plataforma).reindex(ORDEN_DOMINIOS).fillna(0).astype(int)
print(conteo.assign(total = conteo.sum(axis = 1)).to_string())
fig, ax = plt.subplots(figsize = (10, 4.6))
izquierda = np.zeros(len(conteo))
for plataforma, color in [('Make', '#7b4fc9'), ('n8n', '#eb6834')]:
ax.barh(conteo.index, conteo[plataforma], left = izquierda, color = color, label = plataforma, height = 0.6)
for i, v in enumerate(conteo[plataforma]):
if v:
ax.text(izquierda[i] + v / 2, i, str(v), ha = 'center', va = 'center', color = 'white', fontsize = 11)
izquierda += conteo[plataforma].to_numpy()
ax.invert_yaxis()
ax.set_xlabel('Flujos activos')
ax.set_title(f'Los {len(inventario)} flujos activos de Trigo Lento por dominio y plataforma', fontsize = 13)
ax.legend(frameon = False, loc = 'lower right')
ax.spines[['top', 'right']].set_visible(False)
plt.tight_layout()
plt.show()
Flujos leídos sin ficha declarada: ninguno
flujo plataforma version dominio dueno suplente detectados proveedores
TL Formulario v2 Make 2 pedidos Valeria True Make, Google
TL Recordatorio de recogida v1 Make 1 comunicación Valeria True Make, Google
TL Brújula diario v1 Make 1 pedidos Valeria Itzel True Make, Google
TL Prueba de humo v1 n8n 1 operación Valeria False ninguno externo
TL Clasificador IA v1 n8n 1 comunicación Valeria True Anthropic
TL Facturación n8n 1 pedidos Valeria True Anthropic, FacturaFácil
TL Pastel con alergias v1 Make 1 pedidos Valeria True Make, Anthropic, Twilio, Google
TL Pedidos WhatsApp n8n v2.1 n8n 2.1 pedidos Valeria True Anthropic, Twilio, Google
TL Gmail mayoristas v1 n8n 1 comunicación Valeria True Anthropic, Google
TL WhatsApp secuencias v1 n8n 1 comunicación Valeria True Meta
TL Slack operaciones v1 n8n 1 operación Valeria False Slack
TL Orquestador v1 n8n 1 comunicación Valeria True Slack
TL Métricas de atención v1 n8n 1 informes Valeria False Slack
TL Monitor de plantillas v1 n8n 1 comunicación Valeria False Slack
TL Lote nocturno v1 n8n 1 informes Valeria False Anthropic, Slack
TL Consulta de inventario v1 n8n 1 inventario Joaquín Valeria False Anthropic, Slack
TL Informe del lunes v1 n8n 1 informes Valeria False Anthropic, Slack
TL Agente de pedidos v4 n8n 4 pedidos Valeria True Anthropic, Twilio
TL Evaluación del agente v1 n8n 1 operación Valeria False Anthropic, Slack
TL Alertas de flujos v1 n8n 1 operación Valeria False Slack
TL Publicación multired v2 n8n 2 contenido Valeria False Slack
TL Aprobaciones v2 n8n 2 operación Valeria False Slack
TL Campaña primavera v1 n8n 1 contenido Valeria False Slack
Datos personales detectados en el JSON: 11 | declarados por Valeria sin detección: 5 | con datos personales en total: 16 de 23
Con suplente: 2 | con pruebas: 14 | proveedores externos distintos: 7
plataforma Make n8n total
dominio
pedidos 3 3 6
comunicación 1 5 6
informes 0 3 3
contenido 0 2 2
inventario 0 1 1
operación 0 5 5

Las dos primeras líneas de la salida son la red de seguridad: ningún flujo leído carece de ficha declarada. Si mañana Valeria exporta un sub-workflow nuevo y olvida declararlo, la línea “Flujos leídos sin ficha declarada” lo nombrará. Después, la detección: $11$ flujos muestran datos personales en el propio JSON, $5$ más los tienen por declaración de Valeria sin que el JSON los delate (el orquestador, por ejemplo, recibe el teléfono en un campo que se llama remitente), y en total $16$ de los $23$ flujos tratan datos personales. Solo $2$ tienen suplente, $14$ tienen pruebas y los proveedores externos distintos son $7$.
La gráfica cuenta los flujos por dominio y plataforma. Pedidos y comunicación tienen $6$ cada uno; operación, $5$; informes, $3$; contenido, $2$; inventario, $1$. Make conserva $4$ flujos, los cortos que el Día 45 decidió no migrar, y los tres de pedidos de Make (formulario, Brújula y el pastel con alergias) son la cara visible del mostrador. La Tabla 1 es el inventario completo, ordenado por la puntuación de criticidad que calcula la sección siguiente, con los suplentes que la sección del factor autobús nombrará: es la alacena etiquetada, tal como quedará al final del día.
Tabla 1. Inventario final de los 23 flujos activos de Trigo Lento el 20 de abril de 2026, ordenado por criticidad, con los suplentes nombrados en esta entrada.
| Flujo | Plataforma | Versión | Dominio | Dueño | Suplente | Criticidad | Datos personales | Proveedores | Última revisión |
|---|---|---|---|---|---|---|---|---|---|
| TL Pastel con alergias v1 | Make | 1 | pedidos | Valeria | Joaquín | crítica | sí | Make, Anthropic, Twilio, Google | 16/03/2026 |
| TL Pedidos WhatsApp n8n v2.1 | n8n | 2.1 | pedidos | Valeria | falta | crítica | sí | Anthropic, Twilio, Google | 17/03/2026 |
| TL Agente de pedidos v4 | n8n | 4 | pedidos | Valeria | falta | crítica | sí | Anthropic, Twilio | 10/04/2026 |
| TL Brújula diario v1 | Make | 1 | pedidos | Valeria | Itzel | crítica | sí | Make, Google | 04/03/2026 |
| TL Orquestador v1 | n8n | 1 | comunicación | Valeria | Itzel | crítica | sí | Slack | 28/03/2026 |
| TL Facturación | n8n | 1 | pedidos | Valeria | falta | crítica | sí | Anthropic, FacturaFácil | 12/03/2026 |
| TL Recordatorio de recogida v1 | Make | 1 | comunicación | Valeria | Itzel | crítica | sí | Make, Google | 26/02/2026 |
| TL Aprobaciones v2 | n8n | 2 | operación | Valeria | falta | crítica | sí | Slack | 17/04/2026 |
| TL Gmail mayoristas v1 | n8n | 1 | comunicación | Valeria | Itzel | crítica | sí | Anthropic, Google | 19/03/2026 |
| TL Formulario v2 | Make | 2 | pedidos | Valeria | falta | media | sí | Make, Google | 22/02/2026 |
| TL WhatsApp secuencias v1 | n8n | 1 | comunicación | Valeria | Itzel | media | sí | Meta | 20/03/2026 |
| TL Campaña primavera v1 | n8n | 1 | contenido | Valeria | falta | media | no | Slack | 18/04/2026 |
| TL Monitor de plantillas v1 | n8n | 1 | comunicación | Valeria | Itzel | media | no | Slack | 26/03/2026 |
| TL Publicación multired v2 | n8n | 2 | contenido | Valeria | falta | media | no | Slack | 17/04/2026 |
| TL Slack operaciones v1 | n8n | 1 | operación | Valeria | falta | media | sí | Slack | 22/03/2026 |
| TL Clasificador IA v1 | n8n | 1 | comunicación | Valeria | falta | baja | sí | Anthropic | 08/03/2026 |
| TL Consulta de inventario v1 | n8n | 1 | inventario | Joaquín | Valeria | baja | no | Anthropic, Slack | 04/04/2026 |
| TL Lote nocturno v1 | n8n | 1 | informes | Valeria | falta | baja | sí | Anthropic, Slack | 01/04/2026 |
| TL Evaluación del agente v1 | n8n | 1 | operación | Valeria | falta | baja | sí | Anthropic, Slack | 11/04/2026 |
| TL Informe del lunes v1 | n8n | 1 | informes | Valeria | falta | baja | no | Anthropic, Slack | 08/04/2026 |
| TL Métricas de atención v1 | n8n | 1 | informes | Valeria | falta | baja | sí | Slack | 25/03/2026 |
| TL Alertas de flujos v1 | n8n | 1 | operación | Valeria | falta | baja | no | Slack | 16/04/2026 |
| TL Prueba de humo v1 | n8n | 1 | operación | Valeria | falta | baja | no | ninguno externo | 06/03/2026 |
Tres detalles de la tabla merecen una pausa. TL Facturación no lleva número de versión porque el Día 40 lo nombró así y nunca cambió; el lector le asigna la versión $1$ por defecto. TL Consulta de inventario v1 es el único flujo cuyo dueño no es Valeria: lo pidió y lo usa Joaquín, el panadero que consulta la harina desde Slack, y Valeria es su suplente; es también la única fila donde la columna de suplente ya estaba llena antes de hoy, junto con TL Brújula diario v1, en el que Itzel aprendió a intervenir el Día 19. Y la columna de datos personales dice “sí” en $16$ filas, que es exactamente la lista de tratamientos que la Ley Federal de Protección de Datos Personales en Posesión de los Particulares obliga a conocer: el inventario de flujos es, al mismo tiempo, el registro de tratamientos, y por eso la columna no se puede dejar en blanco.
Atención aquí: un inventario de flujos que incluye qué datos personales trata cada uno es un documento de cumplimiento, no solo de operación. Si el suyo dice que un flujo “no” trata datos personales y el JSON muestra un teléfono en un parámetro, la ley no acepta la ficha como excusa. Por eso la detección automática suma a lo declarado y nunca lo contradice a la baja, y por eso la revisión semestral de consentimientos del calendario de gobierno recorre esta columna, no la memoria de nadie.
Criticidad y riesgo
El Día 19 puntuó la criticidad de $8$ flujos con una escala de $1$ a $3$ y la cruzó con el riesgo de datos. Con $23$ flujos y cinco dominios hace falta una escala que separe mejor y que se pueda discutir con Don Ernesto efecto por efecto. La puntuación de hoy suma tres efectos declarados de $0$ a $3$ con pesos distintos:
$$ C_i = 3,c_i + 2,d_i + 2,p_i, \qquad \text{crítica si } C_i \ge 13, \quad \text{media si } 7 \le C_i < 13, \quad \text{baja si } C_i < 7 $$
La lógica detrás de esta puntuación es la siguiente:
- $c_i$ es el efecto en clientes si el flujo falla: $3$ cuando un cliente deja de recibir una confirmación o un pedido se pierde, $0$ cuando el fallo solo lo nota el equipo. Pesa $3$ porque es el daño que no se puede deshacer con una disculpa: la clienta del pastel de cumpleaños no vuelve.
- $d_i$ es el efecto en dinero: $3$ si el fallo cuesta ventas, facturas o cobros (Brújula, facturación, aprobaciones de gasto), $0$ si no mueve un peso.
- $p_i$ es el efecto en datos personales: $3$ si el flujo maneja alergias, direcciones y teléfonos a la vez (el pastel con alergias), $0$ si no trata a nadie.
- El máximo posible es $3 \cdot 3 + 2 \cdot 3 + 2 \cdot 3 = 21$. El umbral de $13$ exige al menos un efecto en $3$ y otro en $2$, o los tres en $2$ con uno en $3$; el de $7$ separa los flujos que hacen algo visible de los que solo informan.
- Los pesos son una decisión, no una ley, y por eso el bloque calcula también cuántos flujos resultarían críticos con otros pesos razonables.
# Puntuación de criticidad por efecto en clientes, dinero y datos, y nivel de cada flujo en Python:
PESOS = {'clientes': 3, 'dinero': 2, 'datos': 2}
UMBRAL_CRITICO, UMBRAL_MEDIO = 13, 7
inventario['puntos'] = sum(peso * inventario[c] for c, peso in PESOS.items())
inventario['criticidad'] = np.select([inventario.puntos >= UMBRAL_CRITICO, inventario.puntos >= UMBRAL_MEDIO],
['crítica', 'media'], 'baja')
ordenados = inventario.sort_values(['puntos', 'flujo'], ascending = [False, True])
print(ordenados[['flujo', 'clientes', 'dinero', 'datos', 'puntos', 'criticidad', 'dueno', 'suplente']].to_string(index = False))
print('Por nivel:', inventario.criticidad.value_counts().reindex(['crítica', 'media', 'baja']).to_dict())
criticos = inventario[inventario.criticidad == 'crítica']
print(f'Críticos de Valeria: {int((criticos.dueno == "Valeria").sum())} | sin suplente: '
f'{int(((criticos.dueno == "Valeria") & (criticos.suplente == "")).sum())}')
# Sensibilidad: ¿cuántos críticos con otros pesos razonables?
for pesos in [(3, 2, 2), (2, 2, 2), (3, 3, 1), (3, 1, 3)]:
p = pesos[0] * inventario.clientes + pesos[1] * inventario.dinero + pesos[2] * inventario.datos
umbral = round(UMBRAL_CRITICO * sum(pesos) / 7)
print(f' pesos {pesos} y umbral {umbral}: {int((p >= umbral).sum())} críticos')
flujo clientes dinero datos puntos criticidad dueno suplente
TL Pastel con alergias v1 3 2 3 19 crítica Valeria
TL Pedidos WhatsApp n8n v2.1 3 3 2 19 crítica Valeria
TL Agente de pedidos v4 3 2 2 17 crítica Valeria
TL Brújula diario v1 3 3 1 17 crítica Valeria Itzel
TL Orquestador v1 3 2 2 17 crítica Valeria
TL Facturación 2 3 2 16 crítica Valeria
TL Recordatorio de recogida v1 3 1 2 15 crítica Valeria
TL Aprobaciones v2 2 3 1 14 crítica Valeria
TL Gmail mayoristas v1 2 2 2 14 crítica Valeria
TL Formulario v2 2 1 2 12 media Valeria
TL WhatsApp secuencias v1 2 1 2 12 media Valeria
TL Campaña primavera v1 2 1 0 8 media Valeria
TL Monitor de plantillas v1 2 1 0 8 media Valeria
TL Publicación multired v2 2 1 0 8 media Valeria
TL Slack operaciones v1 1 1 1 7 media Valeria
TL Clasificador IA v1 1 0 1 5 baja Valeria
TL Consulta de inventario v1 1 1 0 5 baja Joaquín Valeria
TL Lote nocturno v1 0 1 1 4 baja Valeria
TL Evaluación del agente v1 0 0 1 2 baja Valeria
TL Informe del lunes v1 0 1 0 2 baja Valeria
TL Métricas de atención v1 0 0 1 2 baja Valeria
TL Alertas de flujos v1 0 0 0 0 baja Valeria
TL Prueba de humo v1 0 0 0 0 baja Valeria
Por nivel: {'crítica': 9, 'media': 6, 'baja': 8}
Críticos de Valeria: 9 | sin suplente: 8
pesos (3, 2, 2) y umbral 13: 9 críticos
pesos (2, 2, 2) y umbral 11: 9 críticos
pesos (3, 3, 1) y umbral 13: 9 críticos
pesos (3, 1, 3) y umbral 13: 10 críticos
Nueve flujos son críticos, seis medios y ocho bajos. Los dos primeros, el pastel con alergias y los pedidos de WhatsApp, llegan a $19$ puntos: los dos tocan clientes de lleno y los dos manejan datos sensibles o dinero. La sorpresa de la lista está en el octavo lugar: TL Aprobaciones v2 es crítico con $14$ puntos no porque hable con clientes, sino porque toda acción con efecto económico pasa por él; si se detiene, no se publica, no se envía el correo a Brújula y no se aprueba el pastel. Los flujos de informes y de operación quedan abajo, como corresponde: si el informe del lunes no sale, Don Ernesto lo pide por Slack y nadie pierde un pedido.
La línea más importante es la que no cambia: con los pesos $(3, 2, 2)$, $(2, 2, 2)$ y $(3, 3, 1)$ salen $9$ críticos, y solo al dar al dato personal el mismo peso que al cliente, $(3, 1, 3)$, entra un décimo. La lista de críticos es robusta a la opinión que se tenga sobre los pesos, y eso es lo que Valeria necesitaba para que Don Ernesto la firmara sin discutir cada número. La última cifra de la salida es la que importa para el autobús: de los $9$ críticos, $9$ tienen a Valeria como dueña y $8$ no tienen suplente.
El grafo de dependencias
Un inventario con $23$ filas sigue sin responder la pregunta de anoche: si el orquestador se detiene, ¿qué más se detiene? Para eso hace falta saber quién llama a quién, y esa información también está en las exportaciones, en cuatro lugares. En n8n, el nodo Execute Sub-workflow (n8n-nodes-base.executeWorkflow) guarda en parameters.workflowId el flujo que llama, como un localizador de recurso con el identificador en value y el nombre en cachedResultName; el nodo toolWorkflow del agente hace lo mismo para una herramienta; en settings.errorWorkflow cada flujo nombra el flujo de errores que lo atiende; y cualquier nodo HTTP que apunte a un webhook propio de Trigo Lento, en hook.ejemplo.invalid/trigo-lento/<camino>, es una llamada a otro flujo por la puerta de entrada. La función referencia resuelve las dos formas en que n8n guarda el identificador, y un diccionario IDS traduce los identificadores de ejemplo que la serie usó a los nombres de sus flujos.
Cada arista lleva además un estado que dice si el destino existe en el inventario, si existe con otra versión, si es un sub-workflow que nadie exportó o si es un webhook sin receptor. El siguiente bloque extrae las aristas de los $23$ flujos activos y añade una declarada por Valeria, porque TL Errores v1 avisa en el canal de Slack a través de TL Slack operaciones v1 y, como el propio flujo de errores no está exportado, nadie podía leerlo en un JSON:
# Extracción de dependencias: sub-workflows, herramientas, webhooks propios y flujo de errores en Python:
IDS = {'EJEMPLO-NO-REAL-APR2': 'TL Aprobaciones v2', 'EJEMPLO-NO-REAL-RED1': 'TL Publicar por red v1'}
ERRORES = {'Xe7rTb4mKq9wPz2G', 'EJEMPLO-NO-REAL-ERR', 'EJEMPLO-NO-REAL-45E', 'Er7v0000000000C1', '0000000000000028', 'TL Errores v1'}
WEBHOOK_PROPIO = re.compile(r'https://(?:web)?hook\.ejemplo\.invalid/trigo-lento/([\w-]+)')
DECLARADAS = [('TL Errores v1', 'TL Slack operaciones v1', 'subflujo')] # notas de Valeria: TL Errores no está exportado
def referencia(parametros):
wid = parametros.get('workflowId')
if isinstance(wid, dict):
return wid.get('cachedResultName') or IDS.get(wid.get('value'), wid.get('value'))
return IDS.get(wid, wid)
aristas = []
for fila in activos.itertuples():
wf = fila.wf
if fila.plataforma == 'n8n':
for n in wf['nodes']:
tipo = n['type'].split('.')[-1]
if tipo in ('executeWorkflow', 'toolWorkflow'):
aristas.append((fila.nombre, referencia(n['parameters']), 'herramienta' if tipo == 'toolWorkflow' else 'subflujo'))
error = wf.get('settings', {}).get('errorWorkflow')
if error:
aristas.append((fila.nombre, 'TL Errores v1' if error in ERRORES else error, 'error'))
for camino in WEBHOOK_PROPIO.findall(json.dumps(wf, ensure_ascii = False)):
aristas.append((fila.nombre, f'webhook /{camino}', 'webhook'))
aristas = list(dict.fromkeys(aristas + DECLARADAS)) # sin duplicados y en orden de aparición
dependencias = pd.DataFrame(aristas, columns = ['origen', 'destino', 'tipo'])
version_activa = {p: v for p, v in zip(activos.proceso, activos.version)}
nombre_activo = dict(zip(activos.proceso, activos.nombre))
caminos_webhook = {n['parameters'].get('path') for wf in activos.wf if 'nodes' in wf for n in wf['nodes']
if n['type'].endswith('.webhook')}
def estado_destino(destino):
if destino.startswith('webhook /'):
return 'webhook propio' if destino[9:] in caminos_webhook else 'webhook sin receptor'
proceso, version = proceso_y_version(destino)
if proceso in version_activa:
return 'en el inventario' if version == version_activa[proceso] else f'versión distinta (activa: {nombre_activo[proceso]})'
return 'sin exportar'
dependencias['estado'] = dependencias.destino.map(estado_destino)
print(f'Aristas: {len(dependencias)} | por tipo: {dependencias.tipo.value_counts().to_dict()}')
print(dependencias[dependencias.tipo != 'error'].assign(origen = dependencias.origen.str.replace('TL ', ''),
destino = dependencias.destino.str.replace('TL ', '')).to_string(index = False))
print(f'Aristas de error hacia TL Errores v1: {int((dependencias.tipo == "error").sum())}')
print('Destinos por estado:', dependencias.drop_duplicates('destino').estado.str.split(' \\(').str[0].value_counts().to_dict())
Aristas: 30 | por tipo: {'error': 14, 'subflujo': 14, 'webhook': 1, 'herramienta': 1}
origen destino tipo estado
Recordatorio de recogida v1 webhook /recordatorios webhook webhook sin receptor
Pedidos WhatsApp n8n v2.1 Enviar WhatsApp subflujo sin exportar
Orquestador v1 Responder por canal v1 subflujo sin exportar
Orquestador v1 Slack operaciones v1 subflujo en el inventario
Orquestador v1 Agente de pedidos v2 subflujo versión distinta (activa: TL Agente de pedidos v4)
Orquestador v1 Facturación subflujo en el inventario
Monitor de plantillas v1 WhatsApp secuencias v2 subflujo versión distinta (activa: TL WhatsApp secuencias v1)
Informe del lunes v1 Comprobar cifras subflujo sin exportar
Agente de pedidos v4 Herramienta buscar_politicas herramienta sin exportar
Agente de pedidos v4 Agente Opus con fuentes subflujo sin exportar
Evaluación del agente v1 Agente de pedidos v4 (modo prueba) subflujo sin exportar
Publicación multired v2 Aprobaciones v2 subflujo en el inventario
Publicación multired v2 Publicar por red v1 subflujo sin exportar
Campaña primavera v1 Aprobaciones v2 subflujo en el inventario
Campaña primavera v1 Publicación multired v2 subflujo en el inventario
Errores v1 Slack operaciones v1 subflujo en el inventario
Aristas de error hacia TL Errores v1: 14
Destinos por estado: {'sin exportar': 8, 'en el inventario': 4, 'versión distinta': 2, 'webhook sin receptor': 1}
Treinta aristas: $14$ de error (los $14$ flujos de n8n que declaran TL Errores v1 como flujo de errores), $14$ llamadas a sub-workflows, una herramienta y un webhook. La tabla muestra las $16$ que no son de error, y su columna de estado es un diagnóstico por sí sola. De los $15$ destinos distintos, solo $4$ están en el inventario; $8$ no están exportados, incluidos TL Errores v1, que atiende a todos los demás, TL Responder por canal v1, que es la salida del orquestador, y las dos piezas del agente del Día 69; $2$ apuntan a otra versión (el orquestador sigue llamando a TL Agente de pedidos v2 cuando el activo es el v4, y el monitor de plantillas llama a TL WhatsApp secuencias v2 cuando lo exportado es el v1); y el recordatorio de recogida de Make dispara un webhook /recordatorios que ningún flujo de n8n escucha. Ninguna de esas cuatro cosas rompía nada esta mañana, porque en producción los identificadores sí resuelven; lo que rompen es la capacidad de otra persona de entender el sistema desde sus archivos, que es exactamente lo que Valeria necesita antes del $11$ de mayo.
Ciclos, huérfanos y nudos
Con las aristas en la mano, el grafo se construye como un diccionario de listas de adyacencia, y las versiones distintas se resuelven al flujo activo del mismo proceso, porque lo que interesa es la dependencia entre procesos. Sobre ese grafo, una búsqueda en profundidad escrita a mano, con tres colores por vértice (sin visitar, en la pila, terminado), encuentra ciclos: cuando desde un vértice en la pila se alcanza otro que también está en la pila, la arista es de retroceso y cierra un ciclo, exactamente como lo describe el capítulo de búsqueda en profundidad de Cormen y colaboradores (2022). La función alcanzables reutiliza la misma búsqueda para contar cuántos flujos dependen transitivamente de cada uno, y huerfano marca los flujos de n8n que no tienen reloj, no tienen un webhook registrado en Meta, Slack o el monitor y a los que nadie llama. La Figura 2 dibuja la idea antes del código: una telaraña en la que cada hilo es una llamada, con un nudo del que cuelgan quince hilos.
Figura 2. La telaraña de dependencias: 14 hilos de error llegan al nudo TL Errores v1, que nadie exportó, y el nudo avisa por TL Slack operaciones v1, que a su vez declara el nudo como su flujo de errores (el ciclo); 15 flujos cuelgan de cada uno, 8 sub-workflows no están exportados y TL Clasificador IA v1 no tiene ningún hilo.
# Grafo de dependencias, ciclos y huérfanos con búsqueda en profundidad escrita a mano, y dibujo en Python:
def resolver(destino):
proceso, _ = proceso_y_version(destino)
return nombre_activo.get(proceso, destino) # una versión distinta apunta al flujo activo del proceso
grafo = defaultdict(list)
for o, d, _ in aristas:
grafo[o].append(resolver(d))
vertices = list(dict.fromkeys(list(activos.nombre) + [resolver(d) for _, d, _ in aristas] + [o for o, _, _ in aristas]))
def ciclos_dfs(grafo, vertices):
# Colores: 0 sin visitar, 1 en la pila de la búsqueda, 2 terminado. Una arista hacia un vértice
# en la pila (arista de retroceso) cierra un ciclo.
color, pila, encontrados = {v: 0 for v in vertices}, [], []
def visitar(v):
color[v] = 1
pila.append(v)
for w in grafo[v]:
if color[w] == 1:
encontrados.append(pila[pila.index(w):] + [w])
elif color[w] == 0:
visitar(w)
pila.pop()
color[v] = 2
for v in vertices:
if color[v] == 0:
visitar(v)
return encontrados
def alcanzables(v, vistos = None):
vistos = set() if vistos is None else vistos
for w in grafo[v]:
if w not in vistos:
vistos.add(w)
alcanzables(w, vistos)
return vistos
ciclos = ciclos_dfs(grafo, vertices)
print('Ciclos:', [' -> '.join(c) for c in ciclos] or 'ninguno')
ENTRADAS_EXTERNAS = {'pedidos-whatsapp-v2', 'agente-pedidos-v4', 'slack-acciones', 'slack-inventario', 'meta-plantillas',
'tl-entrada-mostrador', 'tl-decision-aprobacion', 'prueba-humo'} # registradas en Meta, Slack y el monitor
llamados = {resolver(d) for _, d, _ in aristas}
def huerfano(fila):
if fila.plataforma == 'Make':
return False # disparador propio en Make (webhook o programación)
tipos = {n['type'].split('.')[-1] for n in fila.wf['nodes']}
caminos = {n['parameters'].get('path') for n in fila.wf['nodes'] if n['type'].endswith('.webhook')}
tiene_reloj = bool(tipos & {'scheduleTrigger', 'manualTrigger'})
entrada_viva = bool(caminos & ENTRADAS_EXTERNAS) or fila.nombre in llamados
return not tiene_reloj and not entrada_viva
huerfanos = [f.nombre for f in activos.itertuples() if huerfano(f)]
print('Huérfanos (nadie los dispara):', huerfanos)
dependientes = {v: sum(v in alcanzables(u) for u in vertices if u != v) for v in vertices}
nudos = sorted(dependientes.items(), key = lambda x: (-x[1], x[0]))[:4]
print('Más dependientes transitivos:', nudos)
criticidad_de = dict(zip(inventario.flujo, inventario.criticidad))
heredada = {v for v in vertices if criticidad_de.get(v) != 'crítica' and any(
criticidad_de.get(u) == 'crítica' and v in alcanzables(u) for u in criticidad_de)}
print('Heredan criticidad crítica, sin ficha:', sorted(v for v in heredada if v not in criticidad_de))
print('Heredan criticidad crítica, con ficha de menor nivel:', sorted((v, criticidad_de[v]) for v in heredada if v in criticidad_de))
en_ciclo = {v for c in ciclos for v in c}
COLOR = {'crítica': '#c0392b', 'media': '#eb6834', 'baja': '#2a78d6'}
columnas = ORDEN_DOMINIOS + ['sin exportar']
dominio_de = dict(zip(inventario.flujo, inventario.dominio))
filas_col = defaultdict(int)
pos = {}
for v in vertices:
col = dominio_de.get(v, 'sin exportar')
pos[v] = (columnas.index(col) * 2.55, -filas_col[col] * 0.95)
filas_col[col] += 1
fig, ax = plt.subplots(figsize = (16, 10.5))
for j, col in enumerate(columnas):
ax.text(j * 2.55, 0.85, col, ha = 'center', fontsize = 12, fontweight = 'bold', color = '#34332f')
for o, d, tipo in aristas:
d = resolver(d)
(x1, y1), (x2, y2) = pos[o], pos[d]
lado = np.sign(x2 - x1) # sale por el borde que mira al destino
inicio, fin = ((x1 + 1.15 * lado, y1), (x2 - 1.15 * lado, y2)) if lado else ((x1, y1 - 0.36), (x2, y2 + 0.36))
arco = any(o in c and d in c for c in ciclos)
ax.add_patch(FancyArrowPatch(inicio, fin, arrowstyle = '-|>', mutation_scale = 11, shrinkA = 1, shrinkB = 1,
connectionstyle = 'arc3,rad=0.1', linewidth = 2.4 if arco else 0.9,
color = '#c0392b' if arco else ('#c9c7c1' if tipo == 'error' else '#52514e'),
linestyle = '--' if tipo == 'error' else '-', zorder = 1))
for v, (x, y) in pos.items():
nivel = criticidad_de.get(v)
relleno = COLOR[nivel] if nivel else 'white'
ax.add_patch(FancyBboxPatch((x - 1.15, y - 0.36), 2.3, 0.72, boxstyle = 'round,pad=0.03', facecolor = relleno,
edgecolor = '#c0392b' if v in heredada or v in en_ciclo else '#52514e', linewidth = 3 if v in en_ciclo else 1.2,
linestyle = '-' if nivel else '--', zorder = 2))
rotulo = textwrap.fill(v.replace('TL ', ''), 20, break_long_words = False)
ax.text(x, y, rotulo, ha = 'center', va = 'center', fontsize = 8.2, zorder = 3,
color = 'white' if nivel else '#34332f', fontweight = 'bold' if v in en_ciclo else 'normal')
for nivel, color in COLOR.items():
ax.scatter([], [], marker = 's', s = 120, color = color, label = f'criticidad {nivel}')
ax.scatter([], [], marker = 's', s = 120, facecolor = 'white', edgecolor = '#52514e', linestyle = '--', label = 'sin exportar (borde rojo: hereda crítica)')
ax.plot([], [], color = '#c9c7c1', ls = '--', label = 'flujo de errores')
ax.plot([], [], color = '#c0392b', lw = 2.2, label = 'ciclo')
ax.legend(loc = 'upper center', bbox_to_anchor = (0.5, 0.0), frameon = False, fontsize = 10.5, ncol = 3)
ax.set_xlim(-1.4, (len(columnas) - 1) * 2.55 + 1.4)
ax.set_ylim(-(max(filas_col.values()) - 1) * 0.95 - 0.5, 1.2)
ax.axis('off')
ax.set_title('Grafo de dependencias de Trigo Lento: quién llama a quién', fontsize = 15)
plt.tight_layout()
plt.show()
Ciclos: ['TL Errores v1 -> TL Slack operaciones v1 -> TL Errores v1']
Huérfanos (nadie los dispara): ['TL Clasificador IA v1']
Más dependientes transitivos: [('TL Errores v1', 15), ('TL Slack operaciones v1', 15), ('TL Agente Opus con fuentes', 2), ('TL Aprobaciones v2', 2)]
Heredan criticidad crítica, sin ficha: ['TL Agente Opus con fuentes', 'TL Enviar WhatsApp', 'TL Errores v1', 'TL Herramienta buscar_politicas', 'TL Responder por canal v1', 'webhook /recordatorios']
Heredan criticidad crítica, con ficha de menor nivel: [('TL Slack operaciones v1', 'media')]

El grafo encuentra las tres cosas que Valeria no podía ver en la lista. Hay un ciclo: TL Errores v1 avisa por TL Slack operaciones v1, y TL Slack operaciones v1 declara a TL Errores v1 como su flujo de errores; si el aviso a Slack falla, el fallo se envía al flujo de errores, que intenta avisar por Slack. En n8n el error de un flujo de errores no vuelve a dispararlo, así que el ciclo no es un bucle infinito, pero sí es un silencio: el fallo que más conviene ver es el que no se puede avisar. Hay un huérfano: TL Clasificador IA v1, el clasificador del Día 37, que el agente del Día 38 absorbió y que sigue activo sin que nada lo dispare. Y hay dos nudos de los que cuelgan $15$ flujos cada uno: el flujo de errores y el de Slack. En el dibujo, el nudo está a la derecha, con borde rojo y línea discontinua, porque es la pieza de la que más depende todo y la única que nadie exportó.
La última parte de la salida aplica una regla que el inventario del Día 19 no tenía: la criticidad se hereda hacia abajo. Si un flujo crítico llama a otro, el llamado es tan crítico como el que llama, aunque su ficha diga otra cosa. Seis destinos sin ficha heredan criticidad crítica, entre ellos el flujo de errores, el webhook sin receptor y las piezas del agente; y TL Slack operaciones v1, con ficha “media”, hereda “crítica” porque el orquestador y las aprobaciones pasan por él. En la gráfica, esos flujos llevan borde rojo. La consecuencia práctica se verá en los hallazgos: un flujo “medio” sin pruebas y sin suplente del que dependen $15$ flujos es, en realidad, el más urgente de la lista.
Concentración y factor autobús
El Día 19 formuló el factor autobús para $8$ flujos: cuántas personas tendrían que faltar para que un flujo quedara sin nadie que lo atienda. La respuesta era una, y se trató con formación. Hoy la pregunta tiene fecha: la semana del $11$ al $17$ de mayo, Valeria no estará, y lo que importa no es el factor por flujo sino cuántos flujos críticos quedan sin relevo y qué probabilidad hay de que uno de ellos se rompa justo esa semana. Con la tasa de rotura del Día 19, $p = 0.03$ por flujo y mes, y una semana de $m = 7 / 30.44$ meses, la probabilidad de que al menos uno de $k$ flujos sin relevo se rompa durante la ausencia es:
$$ P_{\text{rotura}} = 1 - (1 - p)^{k , m}, \qquad m = \frac{7}{30.44} $$
La lógica detrás de esta fórmula es la siguiente:
- $(1 - p)^{m}$ es la probabilidad de que un flujo sobreviva una semana si su tasa de rotura es $p$ por mes; elevarla a $k$ es suponer que los $k$ flujos se rompen de forma independiente, lo que es un supuesto conservador cuando comparten una causa (una clave caducada rompe varios a la vez).
- Lo que se resta de $1$ es la probabilidad de que ninguno se rompa; lo que queda es la de que al menos uno falle sin que haya quien lo repare.
- Nombrar suplentes no baja $p$: los flujos se rompen igual. Baja $k$, que es el número de roturas que nadie atendería, y por eso el efecto del gobierno se mide en $k$ y no en $p$.
- El bloque contrasta la fórmula con una simulación de $200,000$ semanas, para que la cifra no dependa de haber escrito bien la potencia.
Además de la probabilidad, conviene medir la concentración: cuánto del trabajo de operar los flujos críticos recae en una sola persona. El índice de Herfindahl, que la economía usa para medir la concentración de un mercado, sirve tal cual: si $s_j$ es la fracción de los papeles críticos (como dueño o como suplente) que recaen en la persona $j$,
$$ H = \sum_{j} s_j^{2}, \qquad H = 1 \text{ si todo recae en una persona}, \quad H = \tfrac{1}{n} \text{ si se reparte entre } n $$
La lógica detrás de este índice es la siguiente:
- Elevar al cuadrado castiga las fracciones grandes: una persona con el $90%$ aporta $0.81$ al índice y nueve con el $1%$ aportan $0.0009$ entre todas.
- No mide competencia ni formación, mide reparto; por eso se calcula sobre los papeles asignados (dueño y suplente de flujos críticos), que es lo que el inventario puede cambiar hoy.
- El objetivo no es $H = 1/n$, repartir todo entre todos, sino bajar de un valor que hoy está cerca de $1$ a uno en el que una ausencia no deje huecos.
El bloque calcula $k$, $P_{\text{rotura}}$ y $H$ para el inventario de hoy y para el de después de nombrar suplentes: Itzel, de mostrador, en los cinco flujos de comunicación con clientes, que es lo que ya hace a mano cuando hablan por WhatsApp, y Joaquín en el pastel con alergias, porque es quien lee las alergias en el obrador:
# Carga de responsabilidad por persona, factor autobús y riesgo de la semana de vacaciones en Python:
P_ROTURA = 0.03 # Día 19: roturas por flujo y mes
m = 7 / 30.44 # una semana en meses
NUEVOS_SUPLENTES = {'TL Recordatorio de recogida v1': 'Itzel', 'TL Gmail mayoristas v1': 'Itzel',
'TL Orquestador v1': 'Itzel', 'TL WhatsApp secuencias v1': 'Itzel',
'TL Monitor de plantillas v1': 'Itzel', 'TL Pastel con alergias v1': 'Joaquín'}
despues = inventario.copy()
despues['suplente'] = despues.apply(lambda f: NUEVOS_SUPLENTES.get(f.flujo, f.suplente), axis = 1)
PERSONAS = ['Valeria', 'Itzel', 'Joaquín', 'Don Ernesto']
def carga(tabla):
c = tabla[tabla.criticidad == 'crítica']
return pd.DataFrame({'dueño': c.dueno.value_counts(), 'suplente': c[c.suplente != ''].suplente.value_counts()}
).reindex(PERSONAS).fillna(0).astype(int)
def riesgo(tabla, ausente = 'Valeria'):
c = tabla[tabla.criticidad == 'crítica']
k = int(((c.dueno == ausente) & (c.suplente == '')).sum())
return k, 1 - (1 - P_ROTURA) ** (k * m)
def herfindahl(tabla):
operan = carga(tabla).sum(axis = 1)
s = operan / operan.sum()
return float((s ** 2).sum())
semanas = 200_000
for nombre, tabla in [('antes', inventario), ('después', despues)]:
k, p = riesgo(tabla)
sim = (rng.random((semanas, k)) < 1 - (1 - P_ROTURA) ** m).any(axis = 1).mean() if k else 0.0
print(f'{nombre:>8}: críticos sin relevo si Valeria falta: k = {k} | P(fórmula) = {p:.4f} | '
f'P(simulada, {semanas} semanas) = {sim:.4f} | Herfindahl = {herfindahl(tabla):.3f}')
print(carga(inventario).add_suffix(' antes').join(carga(despues).add_suffix(' después')).to_string())
print(f'Una vacación al año durante 5 años: P(al menos una semana con rotura sin relevo) antes = '
f'{1 - (1 - riesgo(inventario)[1]) ** 5:.3f}, después = {1 - (1 - riesgo(despues)[1]) ** 5:.3f}')
fig, axes = plt.subplots(1, 2, figsize = (12.5, 4.6), sharey = True)
for ax, (titulo, tabla) in zip(axes, [('Antes: hoy', inventario), ('Después: suplentes nombrados', despues)]):
c = carga(tabla)
ys = np.arange(len(PERSONAS))
ax.barh(ys - 0.18, c['dueño'], 0.36, color = '#c0392b', label = 'críticos como dueño')
ax.barh(ys + 0.18, c['suplente'], 0.36, color = '#2a78d6', label = 'críticos como suplente')
for y, (a, b) in enumerate(zip(c['dueño'], c['suplente'])):
ax.text(a + 0.15, y - 0.18, str(a), va = 'center', fontsize = 10)
ax.text(b + 0.15, y + 0.18, str(b), va = 'center', fontsize = 10)
k, p = riesgo(tabla)
ax.set_title(f'{titulo}\n{k} críticos sin relevo; P(rotura en la semana) = {p:.3f}', fontsize = 11.5)
ax.set_yticks(ys, PERSONAS)
ax.set_xlim(0, 10.5)
ax.spines[['top', 'right']].set_visible(False)
axes[0].invert_yaxis()
axes[0].legend(frameon = False, loc = 'lower right', fontsize = 9.5)
plt.tight_layout()
plt.show()
antes: críticos sin relevo si Valeria falta: k = 8 | P(fórmula) = 0.0545 | P(simulada, 200000 semanas) = 0.0540 | Herfindahl = 0.820
después: críticos sin relevo si Valeria falta: k = 4 | P(fórmula) = 0.0276 | P(simulada, 200000 semanas) = 0.0275 | Herfindahl = 0.500
dueño antes suplente antes dueño después suplente después
Valeria 9 0 9 0
Itzel 0 1 0 4
Joaquín 0 0 0 1
Don Ernesto 0 0 0 0
Una vacación al año durante 5 años: P(al menos una semana con rotura sin relevo) antes = 0.244, después = 0.131

Hoy, $k = 8$ flujos críticos quedarían sin relevo si Valeria faltara, y la probabilidad de que al menos uno se rompa durante la semana es $P = 0.0545$ por la fórmula y $0.0540$ en la simulación: una de cada dieciocho semanas de vacaciones empezaría con un flujo crítico roto y nadie que lo supiera arreglar. Con los seis suplentes, $k$ baja a $4$ y la probabilidad a $0.0276$, la mitad. El índice de Herfindahl pasa de $0.820$ a $0.500$: Valeria sigue siendo dueña de los $9$ críticos (nadie propone que deje de serlo), pero Itzel pasa de $1$ a $4$ papeles de suplente y Joaquín de $0$ a $1$. La última línea mira más lejos: si Valeria toma una semana al año durante cinco años, la probabilidad de que al menos una de esas semanas coincida con una rotura sin relevo baja del $24.4%$ al $13.1%$.
Los $4$ críticos que siguen sin relevo son los pedidos de WhatsApp, el agente, la facturación y las aprobaciones, y no es un descuido: son los que exigen entender n8n por dentro, y una semana de formación no los cubre. Para ellos la respuesta no es un suplente que repare, sino un manual que diga qué hacer mientras Valeria no está (la libreta y el teléfono de la sección “Si algo falla” del Día 78) y el simulacro del $4$ de mayo que el calendario de gobierno programa una semana antes de la ausencia. Un suplente no es quien sabe arreglar el flujo; es quien sabe que se rompió, a quién llamar y cómo atender al cliente mientras tanto.
Don Ernesto mira la gráfica de la derecha y hace la cuenta a su manera: “Entonces Itzel cuida cuatro frascos y Joaquín uno, y los cuatro que quedan sin nadie son los que se rompen menos o los que yo mismo puedo tapar con la libreta. Eso lo firmo. Lo que no firmo es que usted siga siendo la única dueña de nueve; eso no lo arregla un suplente, lo arregla que alguien más aprenda, y ese alguien, en lo que toca al dinero, soy yo.” Esa frase es la que la sección siguiente convierte en una matriz.
RACI
Dueño y suplente responden por un flujo. Pero los procesos del negocio (atender un pedido, publicar una pieza, cerrar el mes) atraviesan varios flujos y varias personas, y para ellos hace falta otra herramienta: la matriz RACI, que para cada proceso dice quién es responsable de hacerlo (R, responsible), quién rinde cuentas de que se haga bien y aprueba (A, accountable), a quién se consulta antes de decidir (C, consulted) y a quién se informa después (I, informed). Es una de las herramientas clásicas de la gestión de procesos de negocio (Dumas y colaboradores, 2018) y el marco COBIT 2019 de ISACA la usa para asignar las prácticas de gobierno de la tecnología. Trigo Lento no necesita COBIT; necesita sus dos reglas: una sola A por proceso (si dos personas rinden cuentas, ninguna lo hace) y al menos una R (un proceso sin quien lo haga no es un proceso).
La Figura 3 dibuja la matriz como lo que Don Ernesto ya conoce: el horario del obrador que cuelga junto al horno, con los procesos en las filas, las personas en las columnas y una letra en cada cruce. El bloque la escribe como un DataFrame y comprueba las dos reglas en código, de modo que la próxima vez que alguien edite la matriz la comprobación falle antes de que se imprima:
Figura 3. La matriz RACI como horario del obrador: seis procesos en las filas, cuatro personas en las columnas y en cada cruce quién hace (R), quién rinde cuentas y aprueba (A), a quién se consulta (C) y a quién se informa (I); el código comprueba que cada proceso tenga una sola A y al menos una R.
# Matriz RACI por proceso con sus dos comprobaciones en Python:
RACI = pd.DataFrame({ # R hace el trabajo, A rinde cuentas (una sola por fila), C se consulta, I se informa
'Valeria': {'pedidos': 'R', 'comunicación': 'A', 'informes': 'R', 'contenido': 'R', 'inventario': 'C', 'operación': 'A/R'},
'Don Ernesto': {'pedidos': 'A', 'comunicación': 'I', 'informes': 'A', 'contenido': 'A', 'inventario': 'A', 'operación': 'I'},
'mostrador': {'pedidos': 'C', 'comunicación': 'R', 'informes': 'I', 'contenido': 'C', 'inventario': 'I', 'operación': 'I'},
'panaderos': {'pedidos': 'I', 'comunicación': '-', 'informes': 'I', 'contenido': 'C', 'inventario': 'R', 'operación': '-'},
}).reindex(ORDEN_DOMINIOS)
print(RACI.to_string())
una_a = RACI.apply(lambda f: sum('A' in x for x in f) == 1, axis = 1)
con_r = RACI.apply(lambda f: any('R' in x for x in f), axis = 1)
print('Una sola A por proceso:', bool(una_a.all()), '| al menos una R:', bool(con_r.all()),
'| A por persona:', {p: int(RACI[p].str.contains('A').sum()) for p in RACI})
Valeria Don Ernesto mostrador panaderos
pedidos R A C I
comunicación A I R -
informes R A I I
contenido R A C C
inventario C A I R
operación A/R I I -
Una sola A por proceso: True | al menos una R: True | A por persona: {'Valeria': 2, 'Don Ernesto': 4, 'mostrador': 0, 'panaderos': 0}
Tabla 2. Matriz RACI de los seis procesos de Trigo Lento: R hace, A rinde cuentas y aprueba, C se consulta, I se informa.
| Proceso | Valeria | Don Ernesto | Mostrador (Itzel) | Panaderos (Joaquín) | Flujos del inventario |
|---|---|---|---|---|---|
| Pedidos | R | A | C | I | Pedidos WhatsApp, Agente de pedidos, Facturación, Formulario, Brújula diario, Pastel con alergias |
| Comunicación | A | I | R | - | Orquestador, Recordatorio de recogida, Gmail mayoristas, WhatsApp secuencias, Monitor de plantillas, Clasificador IA |
| Informes | R | A | I | I | Informe del lunes, Lote nocturno, Métricas de atención |
| Contenido | R | A | C | C | Publicación multired, Campaña primavera |
| Inventario | C | A | I | R | Consulta de inventario |
| Operación | A/R | I | I | - | Aprobaciones, Slack operaciones, Alertas de flujos, Evaluación del agente, Prueba de humo |
La matriz dice en seis filas lo que Don Ernesto dijo en una frase. Él rinde cuentas (A) de cuatro procesos: pedidos, informes, contenido e inventario, todo lo que mueve dinero o lleva el nombre de la panadería, igual que en las aprobaciones del Día 74. Valeria rinde cuentas de la comunicación y de la operación, que son los dos procesos técnicos, y hace (R) los pedidos, los informes y el contenido. Itzel hace la comunicación: es quien contesta a las clientas cuando el flujo escala y quien, desde hoy, es suplente de cinco de sus flujos. Joaquín hace el inventario, porque la consulta de harina es suya. Las dos comprobaciones pasan: cada fila tiene exactamente una A y al menos una R, y Don Ernesto concentra $4$ de las $6$ A, lo que no es una concentración peligrosa, porque rendir cuentas no exige saber n8n, exige mirar el informe y decir que sí o que no.
La única celda discutible es la de operación, donde Valeria es A y R a la vez. En un equipo de seis personas no hay a quién más darle la operación técnica, y la matriz lo dice en lugar de inventar un responsable de papel. Es también el recordatorio de que la RACI no resuelve el factor autobús: lo hace visible.
Calendario de gobierno
Un inventario sin fechas vuelve a ser una tabla escrita a mano en tres meses. El gobierno ligero del Día 19 proponía revisiones; el de hoy las pone en un calendario con tres ritmos. Mensual, el primer lunes de cada mes: los costes frente a los $45$ USD y el consumo por flujo, con el panel del Día 75. Trimestral, en julio y octubre: los precios de los proveedores (la Tabla A y la Tabla C de la serie cambian con cada revisión de Meta y de Anthropic), los permisos de las cuentas y las credenciales, que es lo que el Día 80 auditará por primera vez. Semestral, en octubre: los consentimientos de WhatsApp y el aviso de privacidad, recorriendo la columna de datos personales de la Tabla 1. Y una cita única, el $4$ de mayo, una semana antes de la ausencia: el simulacro con Itzel y Joaquín, en el que Valeria rompe a propósito un flujo de comunicación y mira qué hacen.
Antes del calendario, los hallazgos. El siguiente bloque recorre el inventario y el grafo y escribe los diez hallazgos del gobierno con su cifra y su acción. Dos de ellos necesitan datos que todavía no se han mirado: las credenciales que comparten varios flujos (en el JSON de n8n cada nodo lleva el nombre y el identificador de su credencial, nunca el secreto) y las revisiones vencidas, con un plazo de $30$ días para los críticos y $90$ para los demás:
# Hallazgos del gobierno: suplentes, credenciales compartidas, pruebas, revisiones y dependencias en Python:
credenciales = defaultdict(set)
for fila in activos.itertuples():
for n in fila.wf.get('nodes', []):
for tipo, c in (n.get('credentials') or {}).items():
credenciales[(c['name'], c['id'])].add(fila.nombre)
compartidas = {k: v for k, v in credenciales.items() if len(v) >= 3 and 'solo' not in k[0]}
vence = lambda f: f.revision + pd.Timedelta(days = 30 if f.criticidad == 'crítica' else 90)
despues['vence'] = despues.apply(vence, axis = 1)
vencidas = despues[despues.vence < pd.Timestamp(hoy)]
con_twilio = list(despues.loc[despues.proveedores.str.contains('Twilio'), 'flujo'])
hallazgos = [
('Críticos sin suplente', f'{riesgo(inventario)[0]} (quedan {riesgo(despues)[0]})', 'Nombrar a Itzel y a Joaquín'),
('Credenciales compartidas con escritura', f'{len(compartidas)}: ' + '; '.join(f'{k[0]} en {len(v)} flujos' for k, v in compartidas.items()),
'Una credencial por flujo y rol de solo lectura'),
('Flujos sin pruebas', f'{int((~despues.pruebas).sum())} de {len(despues)} (críticos: {int((~despues.pruebas & (despues.criticidad == "crítica")).sum())})', 'Casos mínimos antes del Día 83'),
('Revisiones vencidas', f'{len(vencidas)}: ' + ', '.join(v.replace('TL ', '') for v in vencidas.flujo), 'Revisar esta semana'),
('Sub-workflows sin exportar', str(int((dependencias.drop_duplicates('destino').estado == 'sin exportar').sum())), 'Exportar y darles ficha'),
('Referencias a otra versión', str(int(dependencias.estado.str.startswith('versión').sum())), 'Apuntar a la versión activa'),
('Webhooks sin receptor', str(int((dependencias.estado == 'webhook sin receptor').sum())), 'Corregir la URL o retirar el paso'),
('Huérfanos', ', '.join(huerfanos) or 'ninguno', 'Retirar con su bitácora'),
('Ciclos', str(len(ciclos)), 'Avisar por Slack directo desde TL Errores'),
('Proveedor que deja de ser respaldo hoy', f'Twilio en {len(con_twilio)}: ' + ', '.join(f.replace('TL ', '') for f in con_twilio),
'Pasar a la Cloud API o retirar la rama'),
]
for h in hallazgos:
print(f'{h[0]}: {h[1]} -> {h[2]}')
for (nombre, cid), flujos in compartidas.items():
print(f' {nombre}: {sorted(f.replace("TL ", "") for f in flujos)}')
Críticos sin suplente: 8 (quedan 4) -> Nombrar a Itzel y a Joaquín
Credenciales compartidas con escritura: 2: Postgres TL en 5 flujos; Claude x-api-key TL en 6 flujos -> Una credencial por flujo y rol de solo lectura
Flujos sin pruebas: 9 de 23 (críticos: 1) -> Casos mínimos antes del Día 83
Revisiones vencidas: 6: Recordatorio de recogida v1, Brújula diario v1, Facturación, Pastel con alergias v1, Pedidos WhatsApp n8n v2.1, Gmail mayoristas v1 -> Revisar esta semana
Sub-workflows sin exportar: 8 -> Exportar y darles ficha
Referencias a otra versión: 2 -> Apuntar a la versión activa
Webhooks sin receptor: 1 -> Corregir la URL o retirar el paso
Huérfanos: TL Clasificador IA v1 -> Retirar con su bitácora
Ciclos: 1 -> Avisar por Slack directo desde TL Errores
Proveedor que deja de ser respaldo hoy: Twilio en 3: Pastel con alergias v1, Pedidos WhatsApp n8n v2.1, Agente de pedidos v4 -> Pasar a la Cloud API o retirar la rama
Postgres TL: ['Alertas de flujos v1', 'Consulta de inventario v1', 'Facturación', 'Métricas de atención v1', 'Slack operaciones v1']
Claude x-api-key TL: ['Consulta de inventario v1', 'Evaluación del agente v1', 'Facturación', 'Gmail mayoristas v1', 'Informe del lunes v1', 'Lote nocturno v1']
Tabla 3. Hallazgos del gobierno el 20 de abril de 2026, con su cifra, la acción acordada, el responsable y la fecha.
| # | Hallazgo | Cifra | Acción | Responsable | Fecha |
|---|---|---|---|---|---|
| 1 | Flujos críticos sin suplente | $8$ de $9$; quedan $4$ tras esta entrada | Nombrar a Itzel (5 flujos) y a Joaquín (1); manual y simulacro para los $4$ restantes | Valeria | 4 de mayo |
| 2 | Credenciales compartidas con escritura | $2$: Postgres TL en $5$ flujos y Claude x-api-key TL en $6$ | Una credencial por flujo y rol de solo lectura donde baste | Valeria | Día 80 |
| 3 | Flujos sin pruebas | $9$ de $23$ (crítico: $1$, el recordatorio de recogida) | Casos mínimos antes de las pruebas de punta a punta del Día 83 | Valeria | Día 83 |
| 4 | Revisiones vencidas | $6$ críticos con más de $30$ días | Revisar esta semana, empezando por el recordatorio ($53$ días) | Valeria e Itzel | 24 de abril |
| 5 | Sub-workflows sin exportar | $8$, incluido TL Errores v1 | Exportar a flujos/, declarar su ficha y versionarlos | Valeria | 24 de abril |
| 6 | Referencias a otra versión | $2$ (agente v2 y secuencias v2) | Apuntar a la versión activa y volver a exportar | Valeria | 24 de abril |
| 7 | Webhooks sin receptor | $1$ (/recordatorios desde Make) | Corregir la URL o retirar el paso | Valeria | 24 de abril |
| 8 | Flujos huérfanos | $1$ (TL Clasificador IA v1) | Retirar con su bitácora, como el Día 19 | Valeria | 30 de abril |
| 9 | Ciclos | $1$ (errores y Slack) | Avisar por Slack directo desde TL Errores v1, sin pasar por el flujo de Slack | Valeria | 30 de abril |
| 10 | Proveedor que deja de ser respaldo hoy | Twilio en $3$ flujos de pedidos | Pasar la rama a la Cloud API o retirarla | Valeria | 30 de abril |
Los hallazgos son la lista de trabajo del módulo. Tres tienen número de día en lugar de fecha porque son el tema de una entrada: las credenciales compartidas son el hilo del que tirará mañana el Día 80 (Postgres TL con permiso de escritura en cinco flujos, entre ellos las métricas, que solo leen; y una misma clave de Claude en seis flujos, incluida la evaluación del agente, que debería gastar de otro tope), y los flujos sin pruebas son la materia del Día 83. El décimo hallazgo tiene fecha de hoy por una razón que no está en el JSON: el Día 56 cortó WhatsApp a la Cloud API el $6$ de abril con Twilio como respaldo durante dos semanas, y las dos semanas terminan hoy; tres flujos de pedidos siguen con una rama hacia Twilio que ya no tiene a nadie detrás.
El último bloque escribe el calendario y genera inventario_flujos.md desde el código: una tabla resumen, una ficha por flujo con todo lo calculado (versión, día de exportación, nodos, dueño, suplente, puntuación, datos, proveedores, a quién llama, pruebas y fechas) y el calendario de gobierno al final. El archivo lleva en su segunda línea la advertencia que más importa: no se edita a mano, se regenera. Si alguien quiere cambiar un dueño, cambia FICHAS y vuelve a ejecutar; así el inventario nunca puede decir algo distinto de lo que dicen las exportaciones.
# Generación de inventario_flujos.md con las fichas y el calendario de gobierno en Python:
def primer_lunes(anio, mes):
d = date(anio, mes, 1)
return d + timedelta(days = (7 - d.weekday()) % 7)
calendario = []
for mes in range(5, 13):
lunes = primer_lunes(2026, mes)
calendario.append((lunes, 'mensual', 'Costes frente a los 45 USD y consumo por flujo'))
if mes in (7, 10):
calendario.append((lunes, 'trimestral', 'Precios de proveedores, permisos y credenciales'))
if mes == 10:
calendario.append((lunes, 'semestral', 'Consentimientos de WhatsApp y aviso de privacidad'))
calendario.append((date(2026, 5, 4), 'única', 'Simulacro de la semana de vacaciones con Itzel y Joaquín'))
calendario = sorted(calendario)
choques = [c for c in calendario if vacaciones[0] <= c[0] <= vacaciones[1]]
lineas = ['# Inventario de flujos de Trigo Lento', '',
f'Generado el {hoy:%d/%m/%Y} desde las exportaciones de `flujos/`. No editar a mano: se regenera.', '',
'| Flujo | Plataforma | Dominio | Criticidad | Dueño | Suplente | Próxima revisión |', '|---|---|---|---|---|---|---|']
for f in despues.sort_values(['puntos', 'flujo'], ascending = [False, True]).itertuples():
lineas.append(f'| {f.flujo} | {f.plataforma} | {f.dominio} | {f.criticidad} | {f.dueno} | {f.suplente or "falta"} | {f.vence:%d/%m/%Y} |')
for f in despues.sort_values('flujo').itertuples():
salidas = sorted({resolver(d) for o, d, _ in aristas if o == f.flujo})
lineas += ['', f'## {f.flujo}', '',
f'- Plataforma y versión: {f.plataforma}, versión {f.version}, exportado el Día {f.dia}, {f.nodos} nodos o módulos',
f'- Dueño: {f.dueno}; suplente: {f.suplente or "falta"}',
f'- Criticidad: {f.criticidad} ({f.puntos} puntos: clientes {f.clientes}, dinero {f.dinero}, datos {f.datos})',
f'- Datos personales: {"sí" if f.personales else "no"}; proveedores: {f.proveedores}',
f'- Llama a: {", ".join(salidas) or "ninguno"}; pruebas: {"sí" if f.pruebas else "no"}',
f'- Última revisión: {f.revision:%d/%m/%Y}; próxima: {f.vence:%d/%m/%Y}']
lineas += ['', '## Calendario de gobierno', ''] + [f'- {d:%d/%m/%Y} ({t}): {q}' for d, t, q in calendario]
ruta_md = carpeta / 'inventario_flujos.md'
ruta_md.write_text('\n'.join(lineas) + '\n', encoding = 'utf-8')
print(f'{ruta_md.as_posix()}: {len(lineas)} líneas, {ruta_md.stat().st_size} bytes, {len(despues)} fichas')
print('\n'.join(lineas[:9]))
print('...')
inicio = lineas.index('## TL Agente de pedidos v4')
print('\n'.join(lineas[inicio:inicio + 8]))
print('Calendario:', len(calendario), 'citas | en la semana de vacaciones:', choques or 'ninguna')
for d, t, q in calendario:
print(f' {DIAS[d.weekday()]} {d:%d/%m/%Y} {t:<10} {q}')
trigo_lento/dia_79/inventario_flujos.md: 251 líneas, 11879 bytes, 23 fichas
# Inventario de flujos de Trigo Lento
Generado el 20/04/2026 desde las exportaciones de `flujos/`. No editar a mano: se regenera.
| Flujo | Plataforma | Dominio | Criticidad | Dueño | Suplente | Próxima revisión |
|---|---|---|---|---|---|---|
| TL Pastel con alergias v1 | Make | pedidos | crítica | Valeria | Joaquín | 15/04/2026 |
| TL Pedidos WhatsApp n8n v2.1 | n8n | pedidos | crítica | Valeria | falta | 16/04/2026 |
| TL Agente de pedidos v4 | n8n | pedidos | crítica | Valeria | falta | 10/05/2026 |
...
## TL Agente de pedidos v4
- Plataforma y versión: n8n, versión 4, exportado el Día 69, 15 nodos o módulos
- Dueño: Valeria; suplente: falta
- Criticidad: crítica (17 puntos: clientes 3, dinero 2, datos 2)
- Datos personales: sí; proveedores: Anthropic, Twilio
- Llama a: TL Agente Opus con fuentes, TL Errores v1, TL Herramienta buscar_politicas; pruebas: sí
- Última revisión: 10/04/2026; próxima: 10/05/2026
Calendario: 12 citas | en la semana de vacaciones: ninguna
lunes 04/05/2026 mensual Costes frente a los 45 USD y consumo por flujo
lunes 04/05/2026 única Simulacro de la semana de vacaciones con Itzel y Joaquín
lunes 01/06/2026 mensual Costes frente a los 45 USD y consumo por flujo
lunes 06/07/2026 mensual Costes frente a los 45 USD y consumo por flujo
lunes 06/07/2026 trimestral Precios de proveedores, permisos y credenciales
lunes 03/08/2026 mensual Costes frente a los 45 USD y consumo por flujo
lunes 07/09/2026 mensual Costes frente a los 45 USD y consumo por flujo
lunes 05/10/2026 mensual Costes frente a los 45 USD y consumo por flujo
lunes 05/10/2026 semestral Consentimientos de WhatsApp y aviso de privacidad
lunes 05/10/2026 trimestral Precios de proveedores, permisos y credenciales
lunes 02/11/2026 mensual Costes frente a los 45 USD y consumo por flujo
lunes 07/12/2026 mensual Costes frente a los 45 USD y consumo por flujo
El archivo tiene $251$ líneas y $23$ fichas, y las primeras líneas muestran la tabla resumen ordenada por criticidad, con la próxima revisión de cada flujo; seis de esas fechas ya pasaron. La ficha del agente de pedidos es un buen ejemplo de lo que el inventario sabe de un flujo sin que nadie lo haya escrito a mano: $15$ nodos, exportado el Día 69, $17$ puntos de criticidad, tres destinos (dos de ellos sin exportar) y una revisión que vence el $10$ de mayo, la víspera de la ausencia, que el calendario adelanta al simulacro del $4$. La Tabla 4 recoge las doce citas del calendario, y el bloque comprueba lo único que no podía dejar al azar: ninguna cae en la semana del $11$ al $17$ de mayo.
Tabla 4. Calendario de gobierno de Trigo Lento de mayo a diciembre de 2026: doce citas, siempre el primer lunes del mes.
| Fecha | Ritmo | Qué se revisa | Con qué |
|---|---|---|---|
| Lunes 4 de mayo | Única | Simulacro de la semana de vacaciones con Itzel y Joaquín | Manuales del Día 81 y este inventario |
| Lunes 4 de mayo | Mensual | Costes frente a los $45$ USD y consumo por flujo | Panel del Día 75 |
| Lunes 1 de junio | Mensual | Costes y consumo por flujo | Panel del Día 75 |
| Lunes 6 de julio | Mensual | Costes y consumo por flujo | Panel del Día 75 |
| Lunes 6 de julio | Trimestral | Precios de proveedores, permisos y credenciales | Tablas A, B y C de la serie; auditoría del Día 80 |
| Lunes 3 de agosto | Mensual | Costes y consumo por flujo | Panel del Día 75 |
| Lunes 7 de septiembre | Mensual | Costes y consumo por flujo | Panel del Día 75 |
| Lunes 5 de octubre | Mensual | Costes y consumo por flujo | Panel del Día 75 |
| Lunes 5 de octubre | Trimestral | Precios de proveedores, permisos y credenciales | Tablas A, B y C; auditoría del Día 80 |
| Lunes 5 de octubre | Semestral | Consentimientos de WhatsApp y aviso de privacidad | Columna de datos personales de la Tabla 1 y consentimientos.json |
| Lunes 2 de noviembre | Mensual | Costes y consumo por flujo | Panel del Día 75 |
| Lunes 7 de diciembre | Mensual | Costes y consumo por flujo | Panel del Día 75 |
El calendario cuesta poco: una hora el primer lunes de cada mes, dos los trimestres, y una mañana el $4$ de mayo. El Día 19 calculó que el gobierno ligero completo costaba menos de una hora al mes para $8$ flujos; con $23$, el inventario se regenera solo y la hora se gasta en leer los hallazgos, no en escribir la tabla. Lo que no cabe en una hora al mes no es gobierno ligero, y lo que no tiene fecha no es gobierno.
Consideraciones finales
El inventario de hoy no inventó ninguna pieza: leyó las que la serie fue dejando y las puso en orden. Que ese orden revelara un flujo olvidado, ocho sub-workflows nunca exportados, dos referencias a versiones viejas, un webhook que nadie escucha, un ciclo y un huérfano no dice nada malo de Valeria; dice lo que pasa en cualquier ecosistema que crece de $8$ a $23$ flujos en dos meses sin que nadie tenga por tarea contarlos. La diferencia entre un montón y una alacena, como dijo Don Ernesto, no es el número de frascos, es la etiqueta. Y la etiqueta, para que no mienta, tiene que salir del frasco y no de la memoria.
Las cifras tienen sus límites. La puntuación de criticidad suma efectos declarados por una persona, y aunque la lista de críticos resiste a tres juegos de pesos, el cuarto añade un flujo; conviene revisar los efectos cada trimestre y no tomar los $9$ como un hecho. La probabilidad de rotura de la semana usa la tasa del $3%$ mensual del Día 19, que es un supuesto didáctico y no una medición, y supone roturas independientes cuando una clave caducada rompe varios flujos a la vez: la cifra real es seguramente más alta, y por eso el simulacro del $4$ de mayo importa más que el decimal. La detección de datos personales y de proveedores busca patrones en el texto del JSON y puede fallar en los dos sentidos: por eso suma a lo declarado y no lo sustituye. Y el grafo solo ve las dependencias que las exportaciones muestran; una dependencia por una tabla compartida de Postgres, como la que el Día 45 encontró en el modo sombra, no deja arista en ningún JSON.
Hay también una frontera ética que el inventario no debe cruzar. La matriz RACI, la carga por persona y el calendario de revisiones existen para que el sistema sobreviva a una ausencia, no para medir a nadie: el inventario cuenta flujos por persona, no errores por persona, igual que el panel del Día 75 mide la intervención humana por flujo y nunca por empleado. Las credenciales compartidas que la Tabla 3 señala se sustituirán por cuentas de servicio con el mínimo privilegio, no por cuentas personales con más permisos. Y el registro de tratamientos que la columna de datos personales constituye es una obligación legal que esta entrada cumple de paso, no un trámite que pueda quedarse en una columna sin revisar.
Para quien quiera la base de lo que hoy se hizo con código, Fundamentals of Business Process Management (Dumas, La Rosa, Mendling y Reijers, 2018) dedica sus capítulos de identificación de procesos a exactamente este trabajo, construir la arquitectura de procesos de una organización antes de mejorarla, y es la mejor introducción a por qué un inventario viene antes que cualquier rediseño. El marco COBIT 2019 de ISACA (2019) es el extremo opuesto en tamaño, cuarenta objetivos de gobierno para una empresa grande, y vale la pena hojearlo para ver de qué está hecho un gobierno completo y decidir con conocimiento qué parte minúscula de él necesita una panadería de seis personas. Y la búsqueda en profundidad con tres colores, que hoy encontró el ciclo, está explicada con todo rigor en Introduction to Algorithms (Cormen, Leiserson, Rivest y Stein, 2022).
Un ecosistema de automatizaciones no es suyo mientras no pueda listarlo desde sus propios archivos, decir quién responde por cada pieza y saber qué se detiene cuando una falla; todo lo demás es memoria, y la memoria se va de vacaciones.
Conclusiones clave
Inventario
- El inventario se lee, no se escribe: de $61$ archivos JSON salen $46$ versiones de $27$ procesos y, tras descartar versiones antiguas, sustituidos y retirados, $23$ flujos activos con $267$ nodos, $19$ en n8n y $4$ en Make; Valeria había contado $22$.
- La ficha cruza lo declarado (dominio, dueño, suplente, tres efectos, pruebas, revisión) con lo leído (proveedores y datos personales por patrones en el JSON); la detección suma y nunca resta, y $16$ de $23$ flujos tratan datos personales, lo que convierte el inventario en el registro de tratamientos.
- La criticidad $C_i = 3c_i + 2d_i + 2p_i$ con umbral $13$ da $9$ críticos, $6$ medios y $8$ bajos, y la lista de críticos resiste a tres juegos de pesos;
inventario_flujos.mdse regenera desde el código y no se edita a mano.
Dependencias
- Las $30$ aristas salen de cuatro lugares del JSON:
executeWorkflow,toolWorkflow,settings.errorWorkflowy los webhooks propios; de $15$ destinos, solo $4$ están en el inventario, $8$ nunca se exportaron, $2$ apuntan a versiones viejas y $1$ es un webhook sin receptor. - La búsqueda en profundidad con tres colores encontró un ciclo entre el flujo de errores y el de Slack, un huérfano (
TL Clasificador IA v1) y dos nudos de los que dependen $15$ flujos cada uno; la criticidad se hereda hacia abajo, yTL Slack operaciones v1pasa de media a crítica. - Lo que la arquitectura de ayer nombraba de memoria y la carpeta no contiene (
TL Errores v1,TL Responder por canal v1) es el primer hallazgo, y se exporta esta semana.
Responsables
- Con $k = 8$ críticos sin relevo, una semana de ausencia tiene $P = 1 - 0.97^{8 \cdot 7/30.44} = 0.0545$ de coincidir con una rotura sin nadie que la atienda; con Itzel en cinco flujos y Joaquín en uno, $k = 4$ y $P = 0.0276$, y el índice de Herfindahl baja de $0.820$ a $0.500$.
- La RACI tiene una sola A por proceso y al menos una R: Don Ernesto rinde cuentas de pedidos, informes, contenido e inventario; Valeria, de comunicación y operación; Itzel hace la comunicación y Joaquín el inventario.
- El calendario fija doce citas, mensuales de costes, trimestrales de precios y permisos, semestral de consentimientos y el simulacro del $4$ de mayo, ninguna en la semana de ausencia; los diez hallazgos tienen cifra, acción, responsable y fecha, y dos de ellos, las credenciales compartidas y los flujos sin pruebas, son los días 80 y 83.
Mañana, en el Día 80: Auditoría de seguridad del ecosistema: secretos, permisos y simulacro de restauración. El segundo hallazgo de la Tabla 3 dejó una pregunta incómoda, y al tirar del hilo de las credenciales compartidas Valeria descubrirá cuántas llaves de la panadería andan por ahí, con qué permisos y a nombre de quién; Don Ernesto, que cuenta las llaves del obrador cada noche, querrá la misma cuenta para las de la casa digital.
Referencias
- Dumas, M., La Rosa, M., Mendling, J. y Reijers, H. A. (2018). Fundamentals of Business Process Management (2.ª ed.). Springer. https://doi.org/10.1007/978-3-662-56509-4
- ISACA (2019). COBIT 2019 Framework: Introduction and Methodology. ISACA. https://www.isaca.org/resources/cobit
- Cormen, T. H., Leiserson, C. E., Rivest, R. L. y Stein, C. (2022). Introduction to Algorithms (4.ª ed.). MIT Press. (Capítulo de búsqueda en profundidad y detección de ciclos.)
- n8n (2026). Execute Sub-workflow node y Error Trigger node. Documentación de n8n. https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.executeworkflow/ (revisado en octubre de 2026).
- Make (2026). Referencia de la API: blueprints de escenarios (
flow,routes,onerror). Make Developer Hub. https://developers.make.com/api-documentation (revisado en octubre de 2026). - Cámara de Diputados del H. Congreso de la Unión (2025). Ley Federal de Protección de Datos Personales en Posesión de los Particulares. https://www.diputados.gob.mx/LeyesBiblio/ (revisado en octubre de 2026).

