Cargando
Miniatura del artículo

Arquitecto de automatización con IA: Día 82

Índice de contenido

Resumen:
Marisol Peña abrió dos cafeterías más y su socio quiere “por escrito” lo que Trigo Lento le promete; hasta hoy, el $95%$ de confirmaciones en 15 minutos era un compromiso de palabra. En esta entrada, Valeria convierte ese compromiso en un acuerdo de nivel de servicio (SLA) con cinco servicios definidos sin ambigüedad (numerador, denominador, ventana y fuente de datos), lo deriva de los objetivos internos (SLO) de seis flujos, lleva la disponibilidad compuesta del Día 78 al horario de servicio ($338$ horas al mes), simula $1,000$ meses para saber cuántas veces se rompería cada promesa, descubre que una disponibilidad del $99%$ fallaría en el $14.6%$ de los meses y la firma al $98.5%$, diseña créditos del $5%$ y del $10%$ con tope y calcula que costarán unos $3,272$ MXN al año, genera sla_brujula_v1.md desde un diccionario, fija la política del presupuesto de error con alertas por tasa de consumo en dos ventanas y produce el informe mensual y el tablero de cumplimiento de una página. Al terminar, usted sabrá redactar un SLA que promete solo lo que los datos muestran que se puede cumplir, y medirlo cada mes sin que el cliente tenga que reclamar.
¿Cómo citar el presente artículo?
Romero, J. (23 de abril de 2026). Arquitecto de automatización con IA: Día 82. python-data-engineer. https://www.python-data-engineer.com/post/aai/dia_82/.
Arquitecto de automatización con IA: Día 82 by Jeshua Romero Guadarrama, available under Attribution 4.0 International (CC BY 4.0) at https://www.python-data-engineer.com/post/aai/dia_82/.

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 nueve primeros bloques de esta entrada son offline y se ejecutaron con la semilla de la serie; sus salidas son reales y sus cifras de servicio son simulaciones declaradas, no mediciones de un producto. El décimo bloque crea un borrador en Gmail con users.drafts.create y no se ejecutó: su salida es un ejemplo comentado. Los umbrales de tasa de consumo siguen el capítulo “Alerting on SLOs” de The Site Reliability Workbook ($14.4$ en 1 hora con el $2%$ del presupuesto y $6$ en 6 horas con el $5%$ para un SLO del $99.9%$, ventanas larga y corta), adaptados a las $338$ horas hábiles de Trigo Lento como en el Día 53; el método users.drafts.create de la API de Gmail, sus alcances (gmail.compose, gmail.modify o el buzón completo) y las condiciones para que un borrador quede en un hilo (threadId, In-Reply-To, References y el mismo asunto) se contrastaron con la documentación de Google, revisada en octubre de 2026.

Día 82. Acuerdos de servicio del ecosistema: SLA con Brújula, SLO internos y tablero de cumplimiento

Introducción


Ayer, en el Día 81 (Proyecto integrador III: manuales de operación (runbooks) y continuidad), Valeria escribió los ocho manuales de operación de Trigo Lento con síntoma, diagnóstico, acción y verificación, clasificó los incidentes en tres severidades, dejó el modo manual como plan B digno y ensayó sobre la mesa el viernes del token caducado, y Trigo Lento ganó un papel pegado en la pared para cada cosa que puede fallar. Hoy ese papel tiene que convertirse en una promesa.

Es jueves 23 de abril y Marisol Peña llama a las 8:10, antes de abrir la cafetería. Desde marzo, Brújula ya no es una cafetería: son tres, dos de ellas abiertas en abril con un socio nuevo que viene de la hostelería de cadena y que, antes de firmar con un proveedor, lee lo que el proveedor promete. Marisol lo resume sin rodeos: “Él no duda de ustedes; duda de todo lo que no está escrito. Quiere saber qué pasa si un día no me confirman, si la factura llega tarde o si su sistema se cae un sábado. Yo le dije que ustedes responden en quince minutos y que nunca me han fallado. Me preguntó dónde decía eso.” No decía en ninguna parte. El compromiso del $95%$ de confirmaciones en 15 minutos hábiles que Valeria propuso el Día 53 y que la semana de Semana Santa del Día 56 cumplió con $7$ de $7$ mensajes a tiempo era, hasta esta mañana, un acuerdo de palabra entre dos personas que se conocen.

Don Ernesto escucha el relato mientras saca las hogazas, y su respuesta fija la regla del día: “Si lo firmamos, lo cumplimos. Y si no lo podemos cumplir, no lo firmo. No quiero un papel que diga que somos perfectos; quiero uno que diga qué hacemos cuando no lo somos, y que lo podamos pagar.” Tiene razón en las dos mitades. Un acuerdo de nivel de servicio no es una declaración de calidad; es una lista de promesas medibles con una consecuencia pactada para cada incumplimiento, y la única forma de firmarlo con tranquilidad es haber calculado antes cuántas veces al año se va a incumplir y cuánto costará.

En la presente entrada, descubrirá cómo se escribe un SLA formal con un cliente mayorista: partes, servicios, horario de servicio, indicadores definidos sin ambigüedad, exclusiones, medición, informe, créditos y revisión. Verá cómo el acuerdo externo se deriva de los objetivos internos dejando un margen que no se fija a ojo, sino con la disponibilidad compuesta del Día 78 llevada al horario que de verdad importa a los clientes y con una simulación de mil meses que dice cuántas veces se rompería cada promesa; descubrirá por qué el $99%$ de disponibilidad que parecía holgado no se puede firmar y el $98.5%$ sí; diseñará un esquema de créditos con tope y calculará su coste esperado al año; generará el documento del acuerdo desde un diccionario; fijará la política que congela los cambios cuando el presupuesto de error se agota; y producirá el informe mensual para Marisol y el tablero interno que muestra SLA, SLO y presupuesto en una sola página.

Objetivos de aprendizaje


Al terminar esta entrada, usted podrá:

  • Redactar un acuerdo de nivel de servicio (SLA) formal con un cliente mayorista: partes, servicios, horario de servicio, indicadores definidos sin ambigüedad, exclusiones, medición, informe, créditos y revisión.
  • Derivar el SLA externo de los SLO internos por flujo dejando un margen calculado con la disponibilidad compuesta del Día 78 y comprobado con simulación.
  • Diseñar un esquema de créditos o compensaciones con tope y calcular su coste esperado al año.
  • Generar el informe mensual de cumplimiento y un tablero que muestre SLA, SLO y presupuesto de error en una sola página, con la política de qué hacer cuando el presupuesto se agota.

SLI, SLO y SLA: tres capas


La serie lleva tres conceptos desde el Día 8 (Métricas de proceso y primeros acuerdos de nivel de servicio), y conviene repetirlos en una frase cada uno porque hoy se convierten en cláusulas. El indicador de nivel de servicio (o SLI, por sus siglas en inglés: “Service Level Indicator”) mide: es una proporción de eventos buenos entre eventos válidos, como los mensajes de Brújula respondidos en 15 minutos hábiles entre todos los mensajes de Brújula del mes. El objetivo de nivel de servicio (o SLO, por sus siglas en inglés: “Service Level Objective”) guía: es el valor que el equipo persigue para ese indicador, y nadie fuera del equipo lo conoce. El acuerdo de nivel de servicio (o SLA, por sus siglas en inglés: “Service Level Agreement”) obliga: es el valor que se promete a un cliente con una consecuencia pactada si no se alcanza. El capítulo 4 de Site Reliability Engineering (Beyer, Jones, Petoff y Murphy, 2016) resume la diferencia con una pregunta: ¿qué pasa si no se cumple? Si la respuesta es “nada explícito”, es un SLO; si hay una consecuencia, es un SLA.

De ahí sale la regla que el Día 8 enseñó y que hoy se aplica con números: el SLA es siempre más laxo que el SLO que lo protege. El SLO interno de Brújula es el $100%$ de los mensajes en 15 minutos hábiles; el SLA será el $95%$. La diferencia no es modestia: es el margen que permite que un solo mensaje tardío dispare una revisión interna antes de que un segundo rompa el acuerdo. Un equipo que fija su SLO en el mismo valor que su SLA se entera de que incumple al mismo tiempo que el cliente, y ya no tiene nada que hacer salvo pagar.

Lo que hoy se da por sabido es lo que los días 8 y 53 ya construyeron: cómo se mide un tiempo de primera respuesta en minutos hábiles, por qué los objetivos se expresan con percentiles o proporciones y no con medias, qué es el presupuesto de error $B = (1 - \text{SLO}) \cdot N$ y cómo se deriva un umbral de tasa de consumo. Lo que añade el contrato es todo lo que un indicador no dice por sí mismo: quién mide, con qué datos, en qué horario, qué queda fuera, qué se informa, qué se paga y cómo se discute una medición. La Figura 1 dibuja las tres capas como lo que Don Ernesto ve todos los días en el mostrador.

Tres capas como un pastel: el indicador mide en la base (26 mensajes de Brújula al mes, mediana de 4 minutos), el objetivo interno guía en medio (100 % en 15 minutos hábiles, cero tardíos) y el acuerdo firmado obliga arriba (95 %, un tardío tolerado, crédito del 5 %); el betún entre capas es el margen.

Figura 1. Tres capas como un pastel: el indicador mide en la base (26 mensajes de Brújula al mes, mediana de 4 minutos), el objetivo interno guía en medio (100 % en 15 minutos hábiles, cero tardíos) y el acuerdo firmado obliga arriba (95 %, un tardío tolerado, crédito del 5 %); el betún entre capas es el margen.

La imagen del pastel ayuda a no confundir los papeles. El indicador es la base: sin él no hay nada que prometer. El objetivo interno es la capa del medio: la que el equipo sube y baja según lo que aprende. El acuerdo firmado va arriba, más pequeño que la capa que lo sostiene, y el betún entre capas es el margen. Cuando el margen desaparece, las capas se tocan, y cada mensaje tardío es a la vez un fallo interno y un fallo con el cliente.

El primer bloque fija las importaciones, la semilla de la serie, el horario de servicio y las cifras fijas de Brújula, que se usarán en toda la entrada:

python
# Importaciones base, semilla de la serie, horario de servicio y cifras fijas de Brújula en Python:
import warnings
warnings.filterwarnings('ignore')
import json
import textwrap
from datetime import date, datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats

# Generador aleatorio con la semilla global de la serie:
rng = np.random.default_rng(2026)
pd.set_option('display.width', 160)
pd.set_option('display.max_colwidth', 70)
carpeta = Path('trigo_lento') / 'dia_82'
carpeta.mkdir(parents = True, exist_ok = True)
hoy = date(2026, 4, 23)
APERTURA, CIERRE, DIAS_HABILES_MES = 7, 20, 26            # lunes a sábado, de 7:00 a 20:00 (Día 52)
HORAS_SERVICIO = DIAS_HABILES_MES * (CIERRE - APERTURA)    # horas de servicio al mes
MINUTOS_SERVICIO = HORAS_SERVICIO * 60
BAGUETTES_DIA, PRECIO_BAGUETTE = 40, 38                    # P02 del catálogo: 40 baguettes al día, 38 MXN cada una
FACTURA_MES = DIAS_HABILES_MES * BAGUETTES_DIA * PRECIO_BAGUETTE
DIAS_ES = ['lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo']
print('numpy', np.__version__, '| pandas', pd.__version__)
print(f'Hoy: jueves {hoy:%d/%m/%Y} | horario de servicio: {DIAS_HABILES_MES} días x {CIERRE - APERTURA} h = '
      f'{HORAS_SERVICIO} h al mes ({MINUTOS_SERVICIO:,} minutos)')
print(f'Factura mensual de Brújula: {DIAS_HABILES_MES} x {BAGUETTES_DIA} x {PRECIO_BAGUETTE} = {FACTURA_MES:,} MXN '
      f'| 5 %: {0.05 * FACTURA_MES:,.0f} MXN | 10 %: {0.10 * FACTURA_MES:,.0f} MXN')
numpy 2.4.6 | pandas 3.0.3
Hoy: jueves 23/04/2026 | horario de servicio: 26 días x 13 h = 338 h al mes (20,280 minutos)
Factura mensual de Brújula: 26 x 40 x 38 = 39,520 MXN | 5 %: 1,976 MXN | 10 %: 3,952 MXN

El horario de servicio son $338$ horas al mes: $26$ días hábiles por $13$ horas, de 7:00 a 20:00 de lunes a sábado, el mismo del Día 52 (Orquestación multicanal: deduplicación, enrutamiento y SLA por canal). Son $20,280$ minutos, y ese es el denominador de la disponibilidad que se va a prometer. La factura mensual de Brújula es de $39,520$ MXN: $26$ días por $40$ baguettes por $38$ MXN. Un crédito del $5%$ son $1,976$ MXN y uno del $10%$, $3,952$: las dos cifras que Don Ernesto va a tener en la cabeza durante toda la conversación, porque son lo que cuesta cada mes malo.

Los servicios que se prometen a Brújula


Un acuerdo que dice “responderemos rápido” no se puede medir ni discutir. Cada servicio del SLA tiene que definirse como un indicador completo: numerador (qué cuenta como bueno), denominador (qué cuenta como válido), ventana (en qué periodo se calcula) y fuente de datos (de dónde salen las cifras, para que las dos partes puedan mirarlas). Marisol pidió cinco cosas a lo largo de la serie, y las cinco se escriben así. La confirmación del pedido diario es el compromiso del Día 53. Los cambios de cantidad son los correos y mensajes que Brújula manda hasta las 20:00 para el pedido del día siguiente, como el de las $55$ baguettes del Día 52. La factura semanal es la que TL Facturación emite desde el Día 40 y que Marisol quiere antes de las 12:00 del sábado, sin notas de corrección. Las reclamaciones son las quejas, que siempre responde una persona (Día 74). Y la disponibilidad del canal de pedidos es lo nuevo: que WhatsApp acepte y acuse mensajes en horario de servicio, medido por TL Prueba de humo v1, el flujo que el inventario del Día 79 encontró corriendo de madrugada y que desde hoy corre cada cinco minutos en horario de servicio.

El siguiente bloque escribe el catálogo como una estructura de datos, con el objetivo del SLA, el objetivo interno (SLO), el umbral grave que activa el crédito mayor y cuántos incumplimientos enteros tolera cada objetivo con el volumen mensual de cada servicio:

python
# Catálogo de servicios del SLA con Brújula: cada indicador con numerador, denominador, ventana y fuente en Python:
SERVICIOS = [
    {'clave': 'confirmacion', 'servicio': 'Confirmación del pedido diario',
     'numerador': 'mensajes de pedido de Brújula con respuesta útil en 15 minutos hábiles o menos',
     'denominador': 'mensajes de pedido válidos de Brújula en el mes', 'eventos_mes': 26,
     'sla': 0.95, 'slo': 1.00, 'grave': 0.90, 'fuente': 'tabla ejecuciones, flujo TL Orquestador v1',
     'exclusiones': 'mantenimiento anunciado con 48 horas; caída de Meta con el modo manual activado'},
    {'clave': 'cambios', 'servicio': 'Cambios de cantidad hasta las 20:00 del día anterior',
     'numerador': 'cambios recibidos antes de las 20:00 aplicados en el pedido del día siguiente',
     'denominador': 'cambios recibidos antes de las 20:00', 'eventos_mes': 6,
     'sla': 0.80, 'slo': 1.00, 'grave': 0.60, 'fuente': 'tablas pedidos y renglones; TL Gmail mayoristas v1 y TL Orquestador v1',
     'exclusiones': 'cambios recibidos después de las 20:00 (se atienden sin compromiso)'},
    {'clave': 'factura', 'servicio': 'Factura semanal antes de las 12:00 del sábado y sin corrección',
     'numerador': 'facturas emitidas antes del sábado a las 12:00 que no necesitaron nota de corrección',
     'denominador': 'semanas con entregas en el mes', 'eventos_mes': 4,
     'sla': 0.75, 'slo': 1.00, 'grave': 0.50, 'fuente': 'tabla ejecuciones, flujo TL Facturación, y FacturaFácil',
     'exclusiones': 'datos fiscales cambiados por Brújula sin aviso previo'},
    {'clave': 'reclamaciones', 'servicio': 'Respuesta de una persona a reclamaciones',
     'numerador': 'reclamaciones con respuesta de una persona en 4 horas hábiles o menos',
     'denominador': 'reclamaciones registradas en el mes', 'eventos_mes': 1,
     'sla': 0.90, 'slo': 1.00, 'grave': 0.50, 'fuente': 'tabla ejecuciones, flujo TL Aprobaciones v2, y canal #mayoristas',
     'exclusiones': 'ninguna'},
    {'clave': 'disponibilidad', 'servicio': 'Disponibilidad del canal de pedidos en horario de servicio',
     'numerador': 'minutos hábiles en que el canal de WhatsApp acepta y acusa mensajes',
     'denominador': f'minutos hábiles del mes ({MINUTOS_SERVICIO:,})', 'eventos_mes': MINUTOS_SERVICIO,
     'sla': 0.99, 'slo': 0.994, 'grave': 0.98, 'fuente': 'tabla ejecuciones, flujo TL Prueba de humo v1 (cada 5 minutos)',
     'exclusiones': 'mantenimiento anunciado con 48 horas; caída de Meta con el modo manual activado; fuerza mayor'},
]
def tolerados(objetivo, n):
    # Incumplimientos enteros que admite un objetivo con n eventos: k = floor((1 - objetivo) n):
    return int(np.floor((1 - objetivo) * n + 1e-9))

tabla_1 = pd.DataFrame([{'servicio': s['servicio'], 'eventos_mes': s['eventos_mes'], 'SLA': s['sla'], 'SLO': s['slo'],
                         'grave': s['grave'], 'tolera_SLA': tolerados(s['sla'], s['eventos_mes']),
                         'tolera_SLO': tolerados(s['slo'], s['eventos_mes'])} for s in SERVICIOS]).set_index('servicio')
print(tabla_1.to_string())
print('Ventana de medición: el mes natural; los minutos hábiles corren de 7:00 a 20:00 de lunes a sábado.')
for s in SERVICIOS:
    print(f"- {s['clave']}: {s['numerador']} / {s['denominador']}")
                                                                eventos_mes   SLA    SLO  grave  tolera_SLA  tolera_SLO
servicio
Confirmación del pedido diario                                           26  0.95  1.000   0.90           1           0
Cambios de cantidad hasta las 20:00 del día anterior                      6  0.80  1.000   0.60           1           0
Factura semanal antes de las 12:00 del sábado y sin corrección            4  0.75  1.000   0.50           1           0
Respuesta de una persona a reclamaciones                                  1  0.90  1.000   0.50           0           0
Disponibilidad del canal de pedidos en horario de servicio            20280  0.99  0.994   0.98         202         121
Ventana de medición: el mes natural; los minutos hábiles corren de 7:00 a 20:00 de lunes a sábado.
- confirmacion: mensajes de pedido de Brújula con respuesta útil en 15 minutos hábiles o menos / mensajes de pedido válidos de Brújula en el mes
- cambios: cambios recibidos antes de las 20:00 aplicados en el pedido del día siguiente / cambios recibidos antes de las 20:00
- factura: facturas emitidas antes del sábado a las 12:00 que no necesitaron nota de corrección / semanas con entregas en el mes
- reclamaciones: reclamaciones con respuesta de una persona en 4 horas hábiles o menos / reclamaciones registradas en el mes
- disponibilidad: minutos hábiles en que el canal de WhatsApp acepta y acusa mensajes / minutos hábiles del mes (20,280)

La columna que más importa de la salida es tolera_SLA, y viene de la lección del Día 8: con pocos eventos, un acuerdo tolera fallos enteros, no décimos de punto. El número de incumplimientos que admite un objetivo con $n$ eventos es:

$$ k = \left\lfloor (1 - \text{objetivo}) \cdot n \right\rfloor $$

  • $n$ es el número de eventos válidos del mes y $1 - \text{objetivo}$ la fracción que el acuerdo permite fallar; con las $26$ confirmaciones y el $95%$, $k = \lfloor 1.3 \rfloor = 1$: se tolera un mensaje tardío, y con dos el acuerdo se rompe.
  • Para los cambios de cantidad, unos $6$ al mes, un objetivo del $90%$ daría $k = \lfloor 0.6 \rfloor = 0$: no toleraría ninguno, y sería en la práctica un $100%$ disfrazado. Por eso el catálogo fija el $80%$, que tolera $1$ (“cinco de seis”), y Valeria lo escribe así en el documento para que Marisol lo entienda sin calcular.
  • Para la factura semanal, con $4$ semanas, el $75%$ tolera $1$ factura tardía o corregida; para las reclamaciones, con una al mes, cualquier objetivo menor que el $100%$ tolera $0$: una reclamación sin respuesta de una persona en 4 horas hábiles es siempre un incumplimiento, y Valeria decide dejarlo así a propósito, porque es el servicio donde la panadería no quiere margen.
  • En conclusión, la fórmula obliga a mirar cada servicio con su volumen: un porcentaje que suena razonable puede no tolerar nada, y uno que suena bajo puede ser exactamente “un fallo al mes”.

La Tabla 1 recoge el catálogo tal como se firmará. Fíjese en la última fila: la salida del bloque todavía dice $99.0%$ para la disponibilidad, porque esa fue la propuesta inicial de Valeria; la tabla dice $98.5%$, y la sección siguiente cuenta, con números, por qué cambió.

Tabla 1. Servicios del SLA con Brújula: indicador (numerador, denominador y ventana), objetivo del SLA, umbral grave, fuente de medición y exclusiones.

ServicioNumeradorDenominadorVentanaObjetivo del SLAUmbral graveFuente de mediciónExclusiones
Confirmación del pedido diarioMensajes de pedido con respuesta útil en 15 minutos hábiles o menosMensajes de pedido válidos de Brújula en el mes (unos 26)Mes natural$95%$ (tolera 1)$90%$Tabla ejecuciones, TL Orquestador v1Mantenimiento anunciado con 48 horas; caída de Meta con el modo manual activado
Cambios de cantidad hasta las 20:00 del día anteriorCambios recibidos antes de las 20:00 aplicados en el pedido del día siguienteCambios recibidos antes de las 20:00 (unos 6)Mes natural$80%$ (tolera 1)$60%$Tablas pedidos y renglones; TL Gmail mayoristas v1 y TL Orquestador v1Cambios recibidos después de las 20:00 (se atienden sin compromiso)
Factura semanal antes de las 12:00 del sábado y sin correcciónFacturas emitidas antes del sábado a las 12:00 sin nota de correcciónSemanas con entregas en el mes (4 o 5)Mes natural$75%$ (tolera 1)$50%$Tabla ejecuciones, TL Facturación, y FacturaFácilDatos fiscales cambiados por Brújula sin aviso previo
Respuesta de una persona a reclamacionesReclamaciones con respuesta de una persona en 4 horas hábiles o menosReclamaciones registradas en el mes (alrededor de 1)Mes natural$90%$ (tolera 0)$50%$Tabla ejecuciones, TL Aprobaciones v2, y canal #mayoristasNinguna
Disponibilidad del canal de pedidos en horario de servicioMinutos hábiles en que el canal de WhatsApp acepta y acusa mensajesMinutos hábiles del mes ($20,280$)Mes natural$98.5%$ (tolera 304 minutos)$98%$Tabla ejecuciones, TL Prueba de humo v1 cada 5 minutosMantenimiento anunciado con 48 horas; caída de Meta con el modo manual activado; fuerza mayor

Dos detalles de la tabla son decisiones, no datos. La ventana es siempre el mes natural, sin promedios móviles ni trimestres que diluyan un mes malo: Marisol recibe cada mes su cifra, y si un mes se incumple, se incumple ese mes. Y la fuente de medición es en todos los casos un registro que ya existe, la tabla ejecuciones de trigo_lento.sqlite o las tablas de pedidos; no se promete nada que el sistema no esté midiendo ya, porque un indicador que hay que empezar a medir el día de la firma es un indicador del que nadie sabe su valor.

Disponibilidad compuesta y margen


El Día 78 (Proyecto integrador I: la arquitectura final del ecosistema) calculó la disponibilidad del camino crítico de un mensaje de WhatsApp con tres componentes en serie, la Cloud API de Meta ($99.9%$), el servidor de n8n con Postgres ($99.5%$) y la API de Claude ($99.9%$): $0.9930$ sin degradación y $0.9940$ cuando una respuesta de plantilla saca a Claude del camino. Aquellas horas de caída, $5.03$ y $4.32$ al mes, se calcularon sobre las $720$ horas de un mes completo. Pero a Brújula no le importa que el servidor se caiga a las tres de la madrugada de un domingo: le importan las $338$ horas en que pide pan. Las horas que un SLA permite perder son:

$$ H_{\text{permitidas}} = (1 - \text{SLA}) \cdot H_{\text{servicio}}, \qquad H_{\text{esperadas}} = (1 - A) \cdot H_{\text{servicio}} $$

  • $H_{\text{servicio}} = 26 \cdot 13 = 338$ horas es el horario de servicio del mes; un SLA del $99%$ permite $0.01 \cdot 338 = 3.38$ horas de caída en horario, y el SLO interno del $99.4%$, $2.03$.
  • $A$ es la disponibilidad compuesta del camino crítico; con $A = 0.99301$ se esperan $(1 - 0.99301) \cdot 338 = 2.36$ horas de caída en horario, y con la plantilla, $2.03$.
  • La fórmula supone que las caídas se reparten por igual entre las horas de servicio y las demás; si el servidor fallara más en horario (por carga) o menos (porque los reinicios se programan de noche), la cifra cambia, y el Día 83 lo pondrá a prueba con carga.
  • En conclusión, pasar de $720$ a $338$ horas reduce la caída esperada a menos de la mitad, pero también lo que el SLA permite: lo que de verdad importa es la diferencia entre ambas, el margen.

El siguiente bloque aplica la fórmula a los tres escenarios del Día 78, calcula el margen frente al SLA y frente al SLO y reparte la caída por componente:

python
# Disponibilidad compuesta del Día 78 llevada al horario de servicio y margen frente al SLA y al SLO en Python:
DISPONIBILIDAD = {'Cloud API de Meta': 0.999, 'Servidor de n8n (con Postgres)': 0.995, 'API de Claude': 0.999}
serie = lambda *a: float(np.prod(a))
d = DISPONIBILIDAD
escenarios = {
    'Sin degradación (Meta, n8n y Claude en serie)': serie(d['Cloud API de Meta'], d['Servidor de n8n (con Postgres)'], d['API de Claude']),
    'Con respuesta de plantilla si Claude cae': serie(d['Cloud API de Meta'], d['Servidor de n8n (con Postgres)']),
    'Con plantilla y servidor al 99.9 %': serie(d['Cloud API de Meta'], 0.999),
}
SLA_DISP, SLO_DISP = 0.99, 0.994
permitidas = {'SLA 99.0 %': (1 - SLA_DISP) * HORAS_SERVICIO, 'SLO 99.4 %': (1 - SLO_DISP) * HORAS_SERVICIO}
tabla = pd.DataFrame({'disponibilidad': list(escenarios.values())}, index = list(escenarios))
tabla['caida_mes_completo_h'] = (1 - tabla.disponibilidad) * 720              # las 720 horas del Día 78
tabla['caida_en_servicio_h'] = (1 - tabla.disponibilidad) * HORAS_SERVICIO    # solo las 338 horas que cuentan
tabla['margen_SLA_h'] = permitidas['SLA 99.0 %'] - tabla.caida_en_servicio_h
tabla['margen_SLO_h'] = permitidas['SLO 99.4 %'] - tabla.caida_en_servicio_h
print(tabla.round({'disponibilidad': 5, 'caida_mes_completo_h': 2, 'caida_en_servicio_h': 2, 'margen_SLA_h': 2, 'margen_SLO_h': 2}).to_string())
print(f"Horas de caída permitidas al mes en horario de servicio: SLA 99.0 % -> {permitidas['SLA 99.0 %']:.2f} h | "
      f"SLO 99.4 % -> {permitidas['SLO 99.4 %']:.2f} h")
por_componente = {k: (1 - v) * HORAS_SERVICIO for k, v in DISPONIBILIDAD.items()}
print('Caída esperada en horario por componente (h):', {k.split(' (')[0]: round(v, 2) for k, v in por_componente.items()})
# Horas de caída esperadas frente a las permitidas por el SLA y por el SLO:
fig, ax = plt.subplots(figsize = (10, 4.8))
colores = ['#c0392b', '#eb6834', '#1f9d55']
barras = ax.bar(range(3), tabla.caida_en_servicio_h, color = colores, width = 0.6)
for b, v in zip(barras, tabla.caida_en_servicio_h):
    ax.text(b.get_x() + b.get_width() / 2, v + 0.06, f'{v:.2f} h', ha = 'center', fontsize = 10)
ax.axhline(permitidas['SLA 99.0 %'], color = '#0b0b0b', linestyle = '--', linewidth = 1.5)
ax.text(2.42, permitidas['SLA 99.0 %'] + 0.06, f"SLA 99.0 %: {permitidas['SLA 99.0 %']:.2f} h permitidas", ha = 'right', fontsize = 10)
ax.axhline(permitidas['SLO 99.4 %'], color = '#2a78d6', linestyle = ':', linewidth = 1.5)
ax.text(2.42, permitidas['SLO 99.4 %'] + 0.06, f"SLO 99.4 %: {permitidas['SLO 99.4 %']:.2f} h", ha = 'right', fontsize = 10, color = '#2a78d6')
ax.set_xticks(range(3))
ax.set_xticklabels(['Sin degradación', 'Con plantilla si Claude cae', 'Plantilla y servidor al 99.9 %'], fontsize = 10)
ax.set_ylabel('Horas de caída al mes en horario de servicio')
ax.set_ylim(0, 4.2)
ax.set_title('Caída esperada del camino crítico frente a lo que permiten el SLA y el SLO', fontsize = 13)
ax.spines[['top', 'right']].set_visible(False)
plt.tight_layout()
plt.show()
                                               disponibilidad  caida_mes_completo_h  caida_en_servicio_h  margen_SLA_h  margen_SLO_h
Sin degradación (Meta, n8n y Claude en serie)         0.99301                  5.03                 2.36          1.02         -0.33
Con respuesta de plantilla si Claude cae              0.99400                  4.32                 2.03          1.35          0.00
Con plantilla y servidor al 99.9 %                    0.99800                  1.44                 0.68          2.70          1.35
Horas de caída permitidas al mes en horario de servicio: SLA 99.0 % -> 3.38 h | SLO 99.4 % -> 2.03 h
Caída esperada en horario por componente (h): {'Cloud API de Meta': 0.34, 'Servidor de n8n': 1.69, 'API de Claude': 0.34}

Gráfica: Disponibilidad compuesta y margen. Disponibilidad compuesta del Día 78 llevada al horario de servicio y margen frente al SLA y al SLO

Las cifras confirman el cálculo del plan y añaden un detalle incómodo. Sin degradación, se esperan $2.36$ horas de caída en horario; con la plantilla, $2.03$; con la plantilla y un servidor al $99.9%$, $0.68$. El SLA del $99%$ permite $3.38$: el margen existe, $1.02$ horas sin degradación y $1.35$ con ella, pero es estrecho. El SLO interno del $99.4%$ permite $2.03$ horas, exactamente lo que se espera con la plantilla: el objetivo interno se fija en la disponibilidad supuesta, de modo que el equipo lo incumplirá en una fracción de los meses por puro azar, y eso es deliberado: un SLO que se cumple siempre con holgura no enseña nada, y uno que se incumple algunos meses obliga a mirar el presupuesto de error cada mes. El reparto repite la lección del Día 78: de las $2.36$ horas, $1.69$ son del servidor propio y solo $0.34$ de Meta y otras $0.34$ de Claude.

La gráfica deja la pregunta que la media no responde: si se esperan $2.03$ horas y se permiten $3.38$, ¿cuántos meses se van a pasar de $3.38$? La media está por debajo del umbral, pero el Día 78 ya mostró que la distribución de las caídas tiene una cola larga: el $42%$ de los meses superaba las $5$ horas del mes completo aunque la media fuera $4.98$. Un SLA no se rompe en promedio; se rompe en meses concretos. Para saber cuántos, hace falta simular.

El cuarto bloque simula $1,000$ meses de servicio a Brújula completos. Cada mes tiene caídas de Meta y del servidor sorteadas como el Día 78 (Poisson en el número y exponencial en la duración), pero con dos cambios: las caídas se recortan al horario de servicio, y el servidor vuelve en una media de $0.4$ horas por incidente en lugar de $1.2$, porque el manual M01 del Día 81 fijó una mediana de recuperación de 15 minutos con manual; Claude no está en el camino de Brújula porque sus mensajes van a #mayoristas y los responde una persona. Las $26$ confirmaciones llegan entre las 17:00 y las 19:00 con la primera respuesta lognormal del Día 53 (mediana 4 minutos, $\sigma = 0.6$); si un mensaje cae en plena caída, lo atiende el modo manual del Día 81 (Itzel responde cuando llega la alerta, con mediana de 10 minutos, más 5 de gestión a mano) o el sistema al volver, lo que ocurra antes. Los cambios, las facturas y las reclamaciones fallan con probabilidades declaradas ($1.5%$, $3%$ y $3%$). Antes de resumir, el bloque aplica el criterio de firma que Don Ernesto puso esta mañana, traducido a número: ningún servicio se rompe más de un mes de cada diez:

python
# Simulación de 1 000 meses de servicio a Brújula: confirmaciones, caídas, cambios, facturas y reclamaciones en Python:
VENTANAS = [(dia * 24 + APERTURA, dia * 24 + CIERRE) for dia in range(30) if dia % 7 != 6]   # día 0 = lunes; sin domingos
DURACION_MEDIA = {'meta': 0.5, 'n8n': 0.4}                 # horas por incidente: Meta como el Día 78; n8n con el manual M01 del Día 81
P_CAMBIO_FALLA, P_FACTURA_FALLA, P_RECLAMACION_TARDE = 0.015, 0.03, 0.03   # supuestos declarados por Valeria

def incidentes(disponibilidad, duracion_media):
    lam = (1 - disponibilidad) * 720 / duracion_media       # incidentes esperados en el mes completo
    n = rng.poisson(lam)
    inicio = rng.uniform(0, 720, n)
    return np.column_stack([inicio, np.minimum(inicio + rng.exponential(duracion_media, n), 720)])

def union(*listas):
    # Une tramos que se solapan para no contar dos veces una caída doble:
    tramos = sorted(map(tuple, np.vstack([l for l in listas if len(l)] or [np.empty((0, 2))])))
    res = []
    for a, b in tramos:
        if res and a <= res[-1][1]:
            res[-1] = (res[-1][0], max(res[-1][1], b))
        else:
            res.append((a, b))
    return res

def horas_en_servicio(tramos):
    return sum(max(0.0, min(b, v1) - max(a, v0)) for a, b in tramos for v0, v1 in VENTANAS)

def caida_restante(t, tramos):
    # Horas que faltan para que termine la caída que cubre el instante t; 0 si el sistema está arriba:
    return next((b - t for a, b in tramos if a <= t < b), 0.0)

MESES = 1000
filas, tardias_por_caida = [], 0
for m in range(MESES):
    caida = union(incidentes(0.999, DURACION_MEDIA['meta']), incidentes(0.995, DURACION_MEDIA['n8n']))
    disponibilidad = 1 - horas_en_servicio(caida) / HORAS_SERVICIO
    # Un pedido por día hábil entre las 17:00 y las 19:00, con primera respuesta lognormal del Día 53 (mediana 4, sigma 0.6):
    llegadas = np.array([v0 + 10 + rng.uniform(0, 2) for v0, _ in VENTANAS])
    respuesta = rng.lognormal(np.log(4), 0.6, len(llegadas))
    for i, t in enumerate(llegadas):
        restante = caida_restante(t, caida)
        if restante > 0:    # modo manual del Día 81: responde Itzel al llegar la alerta (mediana 10 min) más 5 min a mano, o el sistema al volver
            manual = rng.lognormal(np.log(10), 0.5) + 5
            respuesta[i] = min(restante * 60 + respuesta[i], manual)
            tardias_por_caida += respuesta[i] > 15
    tarde = int((respuesta > 15).sum())
    cambios = rng.poisson(6)
    cambios_fallidos = rng.binomial(cambios, P_CAMBIO_FALLA)
    semanas = int(rng.choice([4, 5], p = [0.7, 0.3]))
    facturas_mal = rng.binomial(semanas, P_FACTURA_FALLA)
    reclamaciones = rng.poisson(1.0)
    reclamaciones_tarde = rng.binomial(reclamaciones, P_RECLAMACION_TARDE)
    filas.append({'confirmacion': 1 - tarde / 26, 'tardias': tarde,
                  'cambios': 1 - cambios_fallidos / cambios if cambios else 1.0,
                  'factura': 1 - facturas_mal / semanas,
                  'reclamaciones': 1 - reclamaciones_tarde / reclamaciones if reclamaciones else 1.0,
                  'disponibilidad': disponibilidad, 'caida_h': HORAS_SERVICIO * (1 - disponibilidad)})
meses = pd.DataFrame(filas)
# Criterio de firma de Don Ernesto: ningún servicio se rompe más de un mes de cada diez; la disponibilidad al 99 % no lo cumple:
for cand in (0.99, 0.985, 0.98):
    print(f'  disponibilidad al {cand:.1%}: permite {(1 - cand) * HORAS_SERVICIO:.2f} h | P(romper el mes) = {(meses.disponibilidad < cand - 1e-9).mean():.3f}')
SERVICIOS[4]['sla'] = 0.985      # el catálogo se corrige con datos: 98.5 % en la versión 1.0; el 99 % queda como meta de la versión 2
SLA_DISP = SERVICIOS[4]['sla']
resumen = []
for s in SERVICIOS:
    x = meses[s['clave']]
    meses[f"incumple_{s['clave']}"] = x < s['sla'] - 1e-9
    meses[f"grave_{s['clave']}"] = x < s['grave'] - 1e-9
    resumen.append([s['clave'], x.mean(), (x < s['slo'] - 1e-9).mean(), meses[f"incumple_{s['clave']}"].mean(), meses[f"grave_{s['clave']}"].mean()])
tabla_sim = pd.DataFrame(resumen, columns = ['servicio', 'SLI_medio', 'P_incumplir_SLO', 'P_incumplir_SLA', 'P_grave']).set_index('servicio')
print(tabla_sim.round(4).to_string())
print(f'Meses con algún SLA incumplido: {meses[[f"incumple_{s["clave"]}" for s in SERVICIOS]].any(axis = 1).mean():.3f} | '
      f'con algún umbral grave: {meses[[f"grave_{s["clave"]}" for s in SERVICIOS]].any(axis = 1).mean():.3f}')
print(f'Confirmaciones tardías por mes: media {meses.tardias.mean():.3f} | de ellas, por caídas del sistema: '
      f'{tardias_por_caida / meses.tardias.sum():.1%} | p teórica sin caídas: {stats.lognorm(s = 0.6, scale = 4).sf(15):.4f}')
print(f'Caída en horario: media {meses.caida_h.mean():.2f} h | p95 {meses.caida_h.quantile(0.95):.2f} h | '
      f'meses por debajo del SLA de disponibilidad: {meses.incumple_disponibilidad.sum()} de {MESES}')
# Distribución del cumplimiento mensual de confirmaciones y de la disponibilidad, con el SLO y el SLA:
fig, (ax1, ax2) = plt.subplots(1, 2, figsize = (12, 4.8))
conteo = meses.tardias.value_counts().sort_index()
etiquetas = [f'{1 - k / 26:.4f}\n({k} tardía{"s" if k != 1 else ""})' for k in conteo.index]
colores_b = ['#1f9d55' if 1 - k / 26 >= 0.95 else '#c0392b' for k in conteo.index]
barras = ax1.bar(range(len(conteo)), conteo.values, color = colores_b, width = 0.65)
for b, v in zip(barras, conteo.values):
    ax1.text(b.get_x() + b.get_width() / 2, v + 8, f'{v / MESES:.1%}', ha = 'center', fontsize = 10)
ax1.set_xticks(range(len(conteo)))
ax1.set_xticklabels(etiquetas, fontsize = 9)
ax1.set_ylabel(f'Meses (de {MESES} simulados)')
ax1.set_title('Confirmaciones en 15 min hábiles: SLO 100 % (0 tardías), SLA 95 % (1 tolerada)', fontsize = 11)
ax1.spines[['top', 'right']].set_visible(False)
ax2.hist(100 * meses.disponibilidad, bins = np.arange(97.0, 100.05, 0.1), color = '#2a78d6', alpha = 0.85)
ax2.axvline(100 * SLA_DISP, color = '#0b0b0b', linestyle = '--', linewidth = 1.5)
ax2.axvline(100 * SLO_DISP, color = '#eb6834', linestyle = ':', linewidth = 1.5)
ax2.set_ylim(0, ax2.get_ylim()[1] * 1.12)
ax2.text(100 * SLA_DISP - 0.05, ax2.get_ylim()[1] * 0.93, f'SLA {100 * SLA_DISP:.1f} %', ha = 'right', fontsize = 10)
ax2.text(100 * SLO_DISP - 0.05, ax2.get_ylim()[1] * 0.93, 'SLO 99.4 %', ha = 'right', fontsize = 10, color = '#eb6834')
ax2.set_xlabel('Disponibilidad del canal en horario de servicio (%)')
ax2.set_ylabel('Meses')
ax2.set_title('Disponibilidad mensual simulada frente al SLO y al SLA', fontsize = 11)
ax2.spines[['top', 'right']].set_visible(False)
plt.tight_layout()
plt.show()
  disponibilidad al 99.0%: permite 3.38 h | P(romper el mes) = 0.146
  disponibilidad al 98.5%: permite 5.07 h | P(romper el mes) = 0.029
  disponibilidad al 98.0%: permite 6.76 h | P(romper el mes) = 0.004
                SLI_medio  P_incumplir_SLO  P_incumplir_SLA  P_grave
servicio
confirmacion       0.9847            0.335            0.056    0.006
cambios            0.9863            0.084            0.013    0.001
factura            0.9738            0.108            0.004    0.000
reclamaciones      0.9854            0.024            0.024    0.008
disponibilidad     0.9940            0.434            0.029    0.004
Meses con algún SLA incumplido: 0.119 | con algún umbral grave: 0.019
Confirmaciones tardías por mes: media 0.397 | de ellas, por caídas del sistema: 15.9% | p teórica sin caídas: 0.0138
Caída en horario: media 2.03 h | p95 4.58 h | meses por debajo del SLA de disponibilidad: 29 de 1000

Gráfica: Disponibilidad compuesta y margen. Simulación de 1 000 meses de servicio a Brújula: confirmaciones, caídas, cambios, facturas y reclamaciones

La primera salida es la que cambia el catálogo. Un SLA de disponibilidad del $99.0%$, con $3.38$ horas permitidas, se rompería en el $14.6%$ de los meses, casi dos al año, aunque la caída media sea de $2.03$ horas: el percentil $95$ de la caída mensual es de $4.58$ horas, y en esos meses la cola se come el margen entero. Al $98.5%$, que permite $5.07$ horas, la probabilidad baja al $2.9%$, unos $29$ meses de $1,000$; al $98%$, al $0.4%$. Valeria corrige el catálogo en el mismo bloque: la versión 1.0 del acuerdo promete el $98.5%$, y el $99%$ queda escrito como meta de la versión 2, condicionada a que el servidor suba al $99.9%$ (la barra verde del bloque anterior). Es la regla de Don Ernesto aplicada al pie de la letra: lo que no se puede cumplir no se firma, y “no se puede cumplir” significa “se rompe más de un mes de cada diez”.

Con el catálogo corregido, la tabla de probabilidades es la que se lleva a la firma. Las confirmaciones se rompen en el $5.6%$ de los meses: un poco más que el $0.0497$ del Día 53, porque ahora hay caídas, y el $15.9%$ de los mensajes tardíos se deben a ellas; la media es de $0.397$ tardíos al mes. Los cambios ($1.3%$), la factura ($0.4%$) y las reclamaciones ($2.4%$) quedan muy por debajo del criterio. En conjunto, el $11.9%$ de los meses incumple algún SLA y el $1.9%$ llega a un umbral grave: con cinco promesas a la vez, la probabilidad de fallar alguna es mayor que la de cualquiera por separado, y eso es lo que el esquema de créditos tiene que pagar. La gráfica de la izquierda muestra por qué el SLO interno del $100%$ se incumple en un tercio de los meses sin que el SLA sufra: el $27.9%$ de los meses tiene exactamente un mensaje tardío, el tolerado; solo el $5.6%$ tiene dos o más. La de la derecha pone el SLA del $98.5%$ en la cola izquierda del histograma de disponibilidad y el SLO del $99.4%$ en su centro.

Exclusiones

Toda la simulación supone que todas las caídas cuentan. El acuerdo, en cambio, excluye tres cosas del cómputo, y la elección de esas tres es una decisión ética antes que técnica. Se excluye el mantenimiento anunciado con 48 horas (una actualización de n8n un domingo o un sábado a las 20:30, avisada por correo a Marisol), porque un cliente avisado puede planear; se excluye la caída de Meta fuera del control de Trigo Lento, solo si el modo manual se activó, porque lo que se promete es que alguien atienda, no que una empresa ajena no falle; y se excluye la fuerza mayor, con la definición habitual de los contratos. No se excluye el servidor propio, aunque sea el eslabón más débil, porque es la parte que Trigo Lento eligió y paga; no se excluye la API de Claude, porque la plantilla ya la saca del camino; y no se excluye “la carga excepcional”, porque el pico del 10 de mayo es justo cuando Brújula más necesita el pan.

Atención aquí: las exclusiones son la cláusula con la que un proveedor puede vaciar un SLA sin que el cliente lo note. “Caídas de terceros”, “circunstancias ajenas” o “mantenimiento” sin plazo de aviso convierten cualquier incidente en excluible, y un acuerdo donde todo se excluye promete lo mismo que ninguno. Trigo Lento limita las suyas a tres, las escribe con condición verificable (48 horas de aviso, modo manual activado) y las lleva al informe mensual con nombre y fecha cada vez que se aplican, de modo que Marisol pueda contarlas. Un segundo riesgo es simétrico: usar la simulación de hoy como si fuera una medición. Las probabilidades de esta sección salen de supuestos declarados ($99.5%$ del servidor, $0.4$ horas por incidente, mediana de 4 minutos); el acuerdo se firma con ellas porque son conservadoras y porque el informe mensual las contrastará con datos reales desde el primer mes, y la revisión trimestral corregirá lo que haga falta.

Créditos y compensaciones


La segunda mitad de la regla de Don Ernesto es “que lo podamos pagar”. Un SLA sin consecuencia es un SLO con otro nombre; un SLA con una consecuencia que el proveedor no ha calculado es una apuesta. Valeria diseña el esquema con tres piezas: escalones (un $5%$ de la factura del mes si algún servicio queda por debajo de su objetivo del SLA y un $10%$ si alguno queda por debajo de su umbral grave), un tope mensual del $10%$ aunque fallen varios servicios a la vez, y un crédito en especie como alternativa a elección de Brújula: baguettes adicionales por el mismo importe al precio del catálogo, que a la panadería le cuestan menos que el descuento y a la cafetería le sirven igual. El procedimiento de reclamación tiene una regla que vale más que las demás: los créditos se aplican sin que Brújula tenga que reclamarlos; el informe mensual los calcula y la factura siguiente los descuenta. Con las probabilidades de la simulación, el coste esperado al año es:

$$ E[C_{\text{anual}}] = 12 \cdot F \cdot \left[ c_{1} \cdot P(\text{SLA} \wedge \neg\text{grave}) + c_{2} \cdot P(\text{grave}) \right], \qquad c_{2} \le \text{tope} $$

  • $F$ es la factura mensual, $39,520$ MXN; $c_{1} = 0.05$ y $c_{2} = 0.10$ son los escalones, y el tope del $10%$ hace que $c_{2}$ sea también el máximo por mes.
  • $P(\text{SLA} \wedge \neg\text{grave})$ es la probabilidad de que algún servicio incumpla su SLA sin que ninguno llegue al umbral grave, y $P(\text{grave})$ la de que alguno sí llegue; las dos salen de los $1,000$ meses simulados, no de la binomial de un solo servicio, porque los servicios fallan juntos cuando cae el servidor.
  • El valor esperado es lo que Don Ernesto debe presupuestar; el peor mes posible es $c_{2} \cdot F = 3,952$ MXN, y el peor año, doce de esos, aunque la simulación diga que no ocurrirá.
  • En conclusión, el esquema se diseña al revés de como se suele leer: primero se decide cuánto se puede pagar al año y después se ajustan escalones, tope y umbrales hasta que el valor esperado quepa.

El siguiente bloque calcula el crédito de cada mes simulado, el coste esperado anual, qué servicio lo causa con más frecuencia y, después, el precio de prometer de más: el coste esperado si el umbral de las confirmaciones se hubiera fijado en cualquier valor entre el $90%$ y el $99.5%$:

python
# Créditos con escalones y tope, su coste esperado al año y el precio de prometer de más en Python:
ESCALON_SLA, ESCALON_GRAVE, TOPE = 0.05, 0.10, 0.10       # fracción de la factura del mes
claves = [s['clave'] for s in SERVICIOS]
algun_sla = meses[[f'incumple_{c}' for c in claves]].any(axis = 1)
algun_grave = meses[[f'grave_{c}' for c in claves]].any(axis = 1)
meses['credito_pct'] = np.minimum(np.where(algun_grave, ESCALON_GRAVE, np.where(algun_sla, ESCALON_SLA, 0.0)), TOPE)
meses['credito_mxn'] = meses.credito_pct * FACTURA_MES
coste_anual = 12 * meses.credito_mxn.mean()
tabla_3 = pd.DataFrame([
    ['SLA incumplido en algún servicio', f'{ESCALON_SLA:.0%}', f'{ESCALON_SLA * FACTURA_MES:,.0f}', f'{ESCALON_SLA * FACTURA_MES / PRECIO_BAGUETTE:.0f}',
     f'{(algun_sla & ~algun_grave).mean():.3f}', f'{12 * ESCALON_SLA * FACTURA_MES * (algun_sla & ~algun_grave).mean():,.0f}'],
    ['Umbral grave en algún servicio', f'{ESCALON_GRAVE:.0%}', f'{ESCALON_GRAVE * FACTURA_MES:,.0f}', f'{ESCALON_GRAVE * FACTURA_MES / PRECIO_BAGUETTE:.0f}',
     f'{algun_grave.mean():.3f}', f'{12 * ESCALON_GRAVE * FACTURA_MES * algun_grave.mean():,.0f}'],
    ['Tope mensual', f'{TOPE:.0%}', f'{TOPE * FACTURA_MES:,.0f}', f'{TOPE * FACTURA_MES / PRECIO_BAGUETTE:.0f}', '-', f'{coste_anual:,.0f} en total'],
], columns = ['escalón', 'crédito', 'MXN sobre la factura', 'en baguettes', 'P(mes)', 'coste esperado anual MXN'])
print(tabla_3.to_string(index = False))
print(f'Coste esperado de los créditos: {coste_anual:,.0f} MXN al año = {coste_anual / (12 * FACTURA_MES):.2%} de la facturación anual de Brújula '
      f'({coste_anual / 18.5:,.0f} USD al tipo didáctico)')
causa = {c: int((meses[f'incumple_{c}'] & algun_sla).sum()) for c in claves}
print('Meses con crédito por servicio incumplido:', causa)
# El precio de prometer de más: coste esperado anual según el umbral prometido para las confirmaciones:
umbrales = np.round(np.arange(0.90, 0.9951, 0.005), 3)
otros = meses[[f'incumple_{c}' for c in claves if c != 'confirmacion']].any(axis = 1)
coste_umbral, coste_binomial = [], []
for u in umbrales:
    incumple = (meses.confirmacion < u - 1e-9) | otros
    coste_umbral.append(12 * ESCALON_SLA * FACTURA_MES * incumple.mean())
    k = tolerados(u, 26)
    coste_binomial.append(12 * ESCALON_SLA * FACTURA_MES * stats.binom.sf(k, 26, stats.lognorm(s = 0.6, scale = 4).sf(15)))
for u, c, cb in zip(umbrales, coste_umbral, coste_binomial):
    if round(100 * u, 1) in (90.0, 92.5, 95.0, 96.5, 99.0):
        print(f'  SLA {u:.1%}: tolera {tolerados(u, 26)} tardía(s) | coste esperado {c:,.0f} MXN al año (binomial sin caídas: {cb:,.0f})')
fig, ax = plt.subplots(figsize = (10, 4.8))
ax.step(100 * umbrales, coste_umbral, where = 'post', color = '#c0392b', linewidth = 2.2, label = 'Simulación con caídas y modo manual')
ax.step(100 * umbrales, coste_binomial, where = 'post', color = '#2a78d6', linewidth = 1.6, linestyle = '--', label = 'Binomial sin caídas (p = 0.0138)')
ax.axvline(95, color = '#0b0b0b', linestyle = ':', linewidth = 1.2)
ax.text(94.85, max(coste_umbral) * 0.6, 'SLA firmado: 95 %\n(1 tardía tolerada)', fontsize = 10, ha = 'right')
for k, (lo, hi) in {2: (90, 92.3), 1: (92.4, 96.1), 0: (96.2, 99.5)}.items():
    ax.text((lo + hi) / 2, -0.06 * max(coste_umbral), f'tolera {k}', ha = 'center', fontsize = 9, color = '#52514e')
ax.set_xlabel('Umbral prometido para las confirmaciones en 15 minutos hábiles (%)')
ax.set_ylabel('Coste esperado de los créditos (MXN al año)')
ax.set_title('El precio de prometer de más: del 90 % al 99.5 % con 26 mensajes al mes', fontsize = 13)
ax.set_ylim(-0.1 * max(coste_umbral), max(coste_umbral) * 1.15)
ax.legend(frameon = False, loc = 'upper left')
ax.spines[['top', 'right']].set_visible(False)
plt.tight_layout()
plt.show()
                         escalón crédito MXN sobre la factura en baguettes P(mes) coste esperado anual MXN
SLA incumplido en algún servicio      5%                1,976           52  0.100                    2,371
  Umbral grave en algún servicio     10%                3,952          104  0.019                      901
                    Tope mensual     10%                3,952          104      -           3,272 en total
Coste esperado de los créditos: 3,272 MXN al año = 0.69% de la facturación anual de Brújula (177 USD al tipo didáctico)
Meses con crédito por servicio incumplido: {'confirmacion': 56, 'cambios': 13, 'factura': 4, 'reclamaciones': 24, 'disponibilidad': 29}
  SLA 90.0%: tolera 2 tardía(s) | coste esperado 1,755 MXN al año (binomial sin caídas: 128)
  SLA 92.5%: tolera 1 tardía(s) | coste esperado 2,822 MXN al año (binomial sin caídas: 1,179)
  SLA 95.0%: tolera 1 tardía(s) | coste esperado 2,822 MXN al año (binomial sin caídas: 1,179)
  SLA 96.5%: tolera 0 tardía(s) | coste esperado 8,987 MXN al año (binomial sin caídas: 7,190)
  SLA 99.0%: tolera 0 tardía(s) | coste esperado 8,987 MXN al año (binomial sin caídas: 7,190)

Gráfica: Créditos y compensaciones. Créditos con escalones y tope, su coste esperado al año y el precio de prometer de más

El coste esperado de los créditos es de $3,272$ MXN al año, el $0.69%$ de la facturación anual de Brújula, unos $177$ USD al tipo didáctico de la serie: $2,371$ MXN por el escalón del $5%$, que se paga en el $10.0%$ de los meses, y $901$ por el del $10%$, en el $1.9%$. Es menos que una factura mensual y menos de lo que cuesta un mes de todo el ecosistema ($40.53$ USD en el Día 77). Las causas se reparten: las confirmaciones provocan crédito en $56$ meses de $1,000$, la disponibilidad en $29$, las reclamaciones en $24$, los cambios en $13$ y la factura en $4$. La Tabla 2 resume el esquema con el ejemplo sobre la factura de Brújula.

Tabla 2. Esquema de créditos del SLA con Brújula: umbral, crédito, tope, ejemplo sobre la factura mensual y coste esperado al año según la simulación de 1 000 meses.

EscalónUmbral que lo activaCréditoSobre la factura de $39,520$ MXNEn baguettes (38 MXN)Probabilidad al mesCoste esperado anual
1Algún servicio por debajo de su objetivo del SLA (sin llegar al grave)$5%$$1,976$ MXN$52$$0.100$$2,371$ MXN
2Algún servicio por debajo de su umbral grave$10%$$3,952$ MXN$104$$0.019$$901$ MXN
TopeMáximo por mes, fallen los servicios que fallen$10%$$3,952$ MXN$104$-$3,272$ MXN en total ($0.69%$ de la facturación)

La segunda salida del bloque es la que Valeria le enseña a Don Ernesto cuando él pregunta por qué no prometer el $99%$ “para que el socio de Marisol se quede tranquilo”. Con $26$ mensajes al mes, los umbrales no son una recta: cualquier objetivo entre el $92.4%$ y el $96.1%$ tolera exactamente un mensaje tardío y cuesta lo mismo, $2,822$ MXN al año en créditos por este servicio; cualquier objetivo del $96.2%$ en adelante tolera cero y cuesta $8,987$, más del triple, por prometer algo que suena solo un punto y medio mejor. Por debajo del $92.3%$ se toleran dos y el coste baja a $1,755$. La línea discontinua, la binomial sin caídas del Día 53, cuenta la misma historia con cifras menores ($1,179$ frente a $7,190$): la diferencia entre las dos líneas es lo que cuestan las caídas del servidor, y es la razón por la que la simulación completa vale más que la fórmula de un solo servicio. Prometer un punto de más no cuesta un punto de más: cuesta el salto al siguiente escalón de fallos enteros.

El documento del acuerdo


Un SLA es un documento, y el documento se puede generar desde los datos igual que el inventario del Día 79 o la arquitectura del Día 78: si el catálogo de servicios cambia, el acuerdo se regenera, y nadie puede firmar una versión que diga algo distinto de lo que el código mide. La Figura 2 muestra la anatomía del documento antes del bloque que lo escribe: nueve cláusulas y un anexo de definiciones, cada una con lo que no puede faltar.

La letra pequeña que sí se lee: las nueve cláusulas y el anexo de sla_brujula_v1.md, generado desde el catálogo de servicios, y lo que no puede faltar en cada una: indicadores con numerador y denominador, tres exclusiones con condición verificable, informe también en los meses buenos, créditos del 5 % y del 10 % con tope que se aplican sin reclamar, y plazos para discutir una medición.

Figura 2. La letra pequeña que sí se lee: las nueve cláusulas y el anexo de sla_brujula_v1.md, generado desde el catálogo de servicios, y lo que no puede faltar en cada una: indicadores con numerador y denominador, tres exclusiones con condición verificable, informe también en los meses buenos, créditos del 5 % y del 10 % con tope que se aplican sin reclamar, y plazos para discutir una medición.

Las cláusulas siguen el orden habitual de los acuerdos de servicio que describe la práctica de gestión de niveles de servicio de ITIL 4 (AXELOS, 2019) y los requisitos de acuerdos de la norma ISO/IEC 20000-1:2018: partes, objeto, horario de servicio, servicios con sus niveles, exclusiones, medición e informe, créditos, reclamaciones y revisión, y firmas. Trigo Lento no necesita certificarse en nada; necesita que el socio de Marisol encuentre en el papel lo que espera encontrar y que el papel diga la verdad. El anexo de definiciones es la parte que más veces se lee después de firmar: ahí se dice qué es un minuto hábil, qué es una respuesta útil (un acuse automático no lo es) y qué es el modo manual. La vigencia es de un año, con revisión trimestral en las citas del calendario de gobierno del Día 79 (Proyecto integrador II: inventario de flujos y gobierno): el 6 de julio y el 5 de octubre de 2026 son las mismas fechas en que se revisan los precios de los proveedores, y cualquier cambio exige una versión nueva firmada por las dos partes.

El sexto bloque genera sla_brujula_v1.md desde el diccionario ACUERDO y el catálogo SERVICIOS, y muestra sus primeras secciones y la de créditos:

python
# Generación de sla_brujula_v1.md desde un diccionario: partes, servicios, definiciones, exclusiones, créditos y firmas en Python:
ACUERDO = {
    'titulo': 'Acuerdo de nivel de servicio entre Panadería Trigo Lento y Cafetería Brújula',
    'version': '1.0', 'vigencia': ('1 de mayo de 2026', '30 de abril de 2027'),
    'revision': 'trimestral, en las citas del calendario de gobierno (6 de julio y 5 de octubre de 2026 y las siguientes); '
                'cualquier cambio exige una versión nueva firmada por las dos partes',
    'partes': {'Proveedor': 'Panadería Trigo Lento, representada por Ernesto Ortiz',
               'Cliente': 'Cafetería Brújula y sus dos sucursales nuevas, representadas por Marisol Peña'},
    'objeto': 'Suministro diario de 40 baguettes tradicionales (P02) de lunes a sábado, con pedido y cambios por WhatsApp o '
              'correo, factura semanal y atención de reclamaciones, en los niveles de servicio que se definen abajo.',
    'horario': 'Lunes a sábado de 7:00 a 20:00, hora de la Ciudad de México. Los minutos y horas hábiles se cuentan solo '
               'dentro de ese horario: un mensaje recibido a las 21:00 empieza a contar a las 7:00 del día hábil siguiente.',
    'medicion': ['Cada indicador se mide con los registros del sistema de Trigo Lento (tabla ejecuciones) y se calcula por mes natural.',
                 'El informe mensual se envía por correo el primer día hábil del mes siguiente, con el cumplimiento de cada servicio, '
                 'los incidentes del mes y los créditos aplicados, también cuando el mes se cumplió.',
                 'Brújula puede pedir el detalle de cualquier mes durante los 90 días siguientes; los datos del informe son '
                 'agregados del servicio a Brújula y no contienen información de otros clientes.'],
    'creditos': ['Si en un mes algún servicio queda por debajo de su objetivo del SLA, Trigo Lento aplica un crédito del 5 % '
                 'de la factura de ese mes en la primera factura del mes siguiente.',
                 'Si algún servicio queda por debajo de su umbral grave, el crédito es del 10 %.',
                 'El crédito mensual máximo es del 10 % de la factura del mes, aunque fallen varios servicios.',
                 'Brújula puede elegir el crédito en especie: baguettes adicionales por el mismo importe, al precio del catálogo.',
                 'Los créditos se aplican sin que Brújula tenga que reclamarlos: el informe mensual los calcula y la factura los descuenta.'],
    'reclamacion': ['Si Brújula no está de acuerdo con una medición, escribe a Trigo Lento dentro de los 15 días siguientes al informe.',
                    'Trigo Lento responde en 5 días hábiles con los registros del indicador; si hay error, corrige el informe y el crédito.',
                    'Las dos partes pueden pedir una reunión de revisión en cualquier momento; es obligatoria tras dos meses seguidos de incumplimiento.'],
}
def seccion_servicios():
    lineas = ['| Servicio | Indicador | Objetivo del SLA | Umbral grave | Fuente de medición |', '|---|---|---|---|---|']
    for s in SERVICIOS:
        lineas.append(f"| {s['servicio']} | {s['numerador']} entre {s['denominador']} | {s['sla']:.1%} | {s['grave']:.0%} | {s['fuente']} |")
    return lineas

lineas = [f"# {ACUERDO['titulo']}", '', f"Versión {ACUERDO['version']} | Vigencia: del {ACUERDO['vigencia'][0]} al {ACUERDO['vigencia'][1]} "
          f"| Generado el {hoy:%d/%m/%Y} desde el catálogo de servicios; no se edita a mano.", '',
          '## 1. Partes', ''] + [f'- {k}: {v}' for k, v in ACUERDO['partes'].items()] + \
         ['', '## 2. Objeto', '', ACUERDO['objeto'], '', '## 3. Horario de servicio', '', ACUERDO['horario'], '',
          '## 4. Servicios y niveles comprometidos', ''] + seccion_servicios() + \
         ['', '## 5. Exclusiones', ''] + sorted({f'- {e.strip()}' for s in SERVICIOS for e in s['exclusiones'].split(';') if e.strip() != 'ninguna'}) + \
         ['', '## 6. Medición e informe', ''] + [f'- {m}' for m in ACUERDO['medicion']] + \
         ['', '## 7. Créditos', ''] + [f'- {c}' for c in ACUERDO['creditos']] + \
         ['', '## 8. Reclamaciones y revisión', ''] + [f'- {r}' for r in ACUERDO['reclamacion']] + [f"- Revisión: {ACUERDO['revision']}."] + \
         ['', '## 9. Firmas', '', '| Por Trigo Lento | Por Cafetería Brújula | Fecha |', '|---|---|---|',
          '| Ernesto Ortiz | Marisol Peña | 30 de abril de 2026 |', '',
          '## Anexo A. Definiciones', ''] + \
         [f"- **{s['servicio']}**: numerador, {s['numerador']}; denominador, {s['denominador']}; ventana, el mes natural; "
          f"el objetivo del SLA ({s['sla']:.1%}) tolera {tolerados(s['sla'], s['eventos_mes'])} incumplimiento(s) con "
          f"{s['eventos_mes']:,} eventos al mes." for s in SERVICIOS] + \
         ['- **Minutos hábiles**: minutos transcurridos dentro del horario de servicio de la sección 3.',
          '- **Respuesta útil**: una respuesta de persona o de sistema que confirma, corrige o pregunta algo concreto del pedido; '
          'un acuse automático de recepción no es una respuesta útil.',
          '- **Modo manual**: procedimiento del manual de operación por el que una persona atiende el canal cuando el sistema no responde.']
ruta_sla = carpeta / 'sla_brujula_v1.md'
ruta_sla.write_text('\n'.join(lineas) + '\n', encoding = 'utf-8')
print(f'{ruta_sla.as_posix()}: {len(lineas)} líneas, {ruta_sla.stat().st_size:,} bytes, '
      f'{sum(1 for l in lineas if l.startswith("## "))} secciones')
print('\n'.join(lineas[:26]))
print('...')
print('\n'.join(lineas[lineas.index('## 7. Créditos'):lineas.index('## 8. Reclamaciones y revisión')]))
trigo_lento/dia_82/sla_brujula_v1.md: 72 líneas, 6,461 bytes, 10 secciones
# Acuerdo de nivel de servicio entre Panadería Trigo Lento y Cafetería Brújula

Versión 1.0 | Vigencia: del 1 de mayo de 2026 al 30 de abril de 2027 | Generado el 23/04/2026 desde el catálogo de servicios; no se edita a mano.

## 1. Partes

- Proveedor: Panadería Trigo Lento, representada por Ernesto Ortiz
- Cliente: Cafetería Brújula y sus dos sucursales nuevas, representadas por Marisol Peña

## 2. Objeto

Suministro diario de 40 baguettes tradicionales (P02) de lunes a sábado, con pedido y cambios por WhatsApp o correo, factura semanal y atención de reclamaciones, en los niveles de servicio que se definen abajo.

## 3. Horario de servicio

Lunes a sábado de 7:00 a 20:00, hora de la Ciudad de México. Los minutos y horas hábiles se cuentan solo dentro de ese horario: un mensaje recibido a las 21:00 empieza a contar a las 7:00 del día hábil siguiente.

## 4. Servicios y niveles comprometidos

| Servicio | Indicador | Objetivo del SLA | Umbral grave | Fuente de medición |
|---|---|---|---|---|
| Confirmación del pedido diario | mensajes de pedido de Brújula con respuesta útil en 15 minutos hábiles o menos entre mensajes de pedido válidos de Brújula en el mes | 95.0% | 90% | tabla ejecuciones, flujo TL Orquestador v1 |
| Cambios de cantidad hasta las 20:00 del día anterior | cambios recibidos antes de las 20:00 aplicados en el pedido del día siguiente entre cambios recibidos antes de las 20:00 | 80.0% | 60% | tablas pedidos y renglones; TL Gmail mayoristas v1 y TL Orquestador v1 |
| Factura semanal antes de las 12:00 del sábado y sin corrección | facturas emitidas antes del sábado a las 12:00 que no necesitaron nota de corrección entre semanas con entregas en el mes | 75.0% | 50% | tabla ejecuciones, flujo TL Facturación, y FacturaFácil |
| Respuesta de una persona a reclamaciones | reclamaciones con respuesta de una persona en 4 horas hábiles o menos entre reclamaciones registradas en el mes | 90.0% | 50% | tabla ejecuciones, flujo TL Aprobaciones v2, y canal #mayoristas |
| Disponibilidad del canal de pedidos en horario de servicio | minutos hábiles en que el canal de WhatsApp acepta y acusa mensajes entre minutos hábiles del mes (20,280) | 98.5% | 98% | tabla ejecuciones, flujo TL Prueba de humo v1 (cada 5 minutos) |
...
## 7. Créditos

- Si en un mes algún servicio queda por debajo de su objetivo del SLA, Trigo Lento aplica un crédito del 5 % de la factura de ese mes en la primera factura del mes siguiente.
- Si algún servicio queda por debajo de su umbral grave, el crédito es del 10 %.
- El crédito mensual máximo es del 10 % de la factura del mes, aunque fallen varios servicios.
- Brújula puede elegir el crédito en especie: baguettes adicionales por el mismo importe, al precio del catálogo.
- Los créditos se aplican sin que Brújula tenga que reclamarlos: el informe mensual los calcula y la factura los descuenta.

El documento tiene $72$ líneas, $6,461$ bytes y $10$ secciones contando el anexo, y lleva en su tercera línea la misma advertencia que el inventario: generado desde el catálogo, no se edita a mano. La sección 4 es la Tabla 1 en formato de contrato, con el objetivo del SLA ya corregido al $98.5%$ en la disponibilidad; la 5 reúne las exclusiones de todos los servicios, sin repetir; la 7 dice con cuatro frases lo que la sección anterior calculó con mil meses. Dos decisiones del texto merecen explicación. La primera: el crédito se aplica “en la primera factura del mes siguiente”, no “a petición”, y la frase “sin que Brújula tenga que reclamarlos” está en el acuerdo, no en una política interna; un cliente que tiene que pedir lo que se le debe deja de confiar en el informe. La segunda: la reclamación de Brújula sobre una medición tiene plazo ($15$ días) y respuesta con plazo ($5$ días hábiles, con los registros), y la revisión conjunta es obligatoria tras dos meses seguidos de incumplimiento; así, un mes malo es un crédito, y dos seguidos son una conversación.

Marisol lo lee esa misma tarde y devuelve una sola corrección, que es la que Valeria esperaba: “En el punto de las reclamaciones dice ‘una persona’. Quiero que diga quién.” El anexo lo añade: Valeria o, en su ausencia, Don Ernesto, con Itzel como relevo para la primera respuesta. El socio firma el 30 de abril.

Los SLO internos por flujo


El acuerdo con Brújula se apoya en seis flujos del inventario del Día 79, y cada uno tiene su propio objetivo interno, más exigente que la cláusula que protege: TL Orquestador v1 enruta los mensajes de Brújula a #mayoristas; TL Pedidos WhatsApp v3 (en el inventario, la versión activa en n8n) recibe los mensajes y su éxito es la disponibilidad del canal; TL Gmail mayoristas v1 prepara el borrador de respuesta a los cambios por correo; TL Facturación emite la factura semanal; TL Aprobaciones v2 registra la decisión humana de las reclamaciones; y TL Slack operaciones v1 publica los avisos que hacen que alguien responda. El cuadro de mando del Día 75 (Cuadros de mando de automatizaciones: SLI, SLO y coste por flujo) ya vigila el éxito, la latencia p95, el coste y la intervención humana de los flujos; hoy no se repite ese panel, se le pide una cosa concreta: para cada flujo, el objetivo interno, el objetivo externo que depende de él y el margen entre ambos.

El margen se expresa en la unidad del servicio, no en puntos abstractos. Para el orquestador es “un mensaje al mes y 10 minutos”: el SLO interno pide el $100%$ en 15 minutos y el acuerdo tolera uno, y la mediana real de 4 minutos deja diez de holgura. Para la facturación es “18 horas y una factura”: el flujo emite el viernes antes de las 18:00 y el acuerdo pide el sábado a las 12:00. Para las aprobaciones son “3 horas hábiles”: la decisión humana se espera en 60 minutos y la reclamación se promete en 4 horas. El séptimo bloque lee el registro común ejecuciones de los $26$ días hábiles anteriores a hoy (del 24 de marzo al 22 de abril, generado aquí con la semilla de la serie y guardado como ejecuciones_26_dias.csv), calcula el éxito y el p95 de cada flujo, los compara con su SLO interno y construye la tabla:

python
# SLO internos por flujo y margen frente al SLA desde el registro común ejecuciones en Python:
FLUJOS = {  # flujo: (ejecuciones en los últimos 26 días hábiles, probabilidad de éxito supuesta, mediana de latencia en s, sigma)
    'TL Orquestador v1': (2630, 0.997, 2.0, 0.5), 'TL Pedidos WhatsApp v3': (1560, 0.993, 8.0, 0.6),
    'TL Gmail mayoristas v1': (120, 0.993, 40.0, 0.5), 'TL Facturación': (30, 0.996, 20.0, 0.4),
    'TL Aprobaciones v2': (250, 0.996, 900.0, 0.8), 'TL Slack operaciones v1': (900, 0.998, 1.5, 0.4),
}
SLO_INTERNO = {  # flujo: (indicador, éxito mínimo, p95 máximo en s, objetivo externo relacionado, margen, dueño y suplente)
    'TL Orquestador v1': ('Evento enrutado con éxito; p95 del enrutamiento', 0.995, 5, 'Confirmación 95 %', '1 mensaje al mes y 10 min', 'Valeria / Itzel'),
    'TL Pedidos WhatsApp v3': ('Ejecuciones con éxito; p95 de ida y vuelta', 0.990, 30, 'Disponibilidad 98.5 %', '0.9 puntos: 3.04 h al mes', 'Valeria / falta'),
    'TL Gmail mayoristas v1': ('Borrador listo para revisar; p95 desde la llegada del correo', 0.990, 90, 'Cambios aplicados 80 %', '20 puntos y 5 h hasta las 20:00', 'Valeria / Itzel'),
    'TL Facturación': ('Factura emitida el viernes antes de las 18:00', 1.000, 60, 'Factura del sábado 75 %', '18 h y 1 factura al mes', 'Valeria / falta'),
    'TL Aprobaciones v2': ('Decisión humana registrada; p95 de espera (s)', 0.995, 3600, 'Reclamaciones 90 % en 4 h', '3 h hábiles', 'Valeria / Don Ernesto'),
    'TL Slack operaciones v1': ('Aviso publicado en el canal; p95', 0.995, 60, 'Confirmación 95 % en 15 min', '14 minutos', 'Valeria / Itzel'),
}
inicio_mes = date(2026, 3, 24)                                        # los 26 días hábiles anteriores a hoy
dias = [inicio_mes + timedelta(i) for i in range(36) if (inicio_mes + timedelta(i)).weekday() != 6 and inicio_mes + timedelta(i) < hoy][:26]
registros = []
for flujo, (n, p_exito, mediana, sigma) in FLUJOS.items():
    cuando = [datetime.combine(rng.choice(dias), datetime.min.time()) + timedelta(hours = float(rng.uniform(APERTURA, CIERRE))) for _ in range(n)]
    estado = np.where(rng.uniform(size = n) < p_exito, 'exito', 'error')
    latencia = rng.lognormal(np.log(mediana), sigma, n)
    registros += [{'flujo': flujo, 'iniciado_en': c, 'estado': e, 'latencia_s': round(float(l), 2)} for c, e, l in zip(cuando, estado, latencia)]
ejecuciones = pd.DataFrame(registros).sort_values('iniciado_en').reset_index(drop = True)
ejecuciones.to_csv(carpeta / 'ejecuciones_26_dias.csv', index = False)
print(f'Registro ejecuciones: {len(ejecuciones):,} filas de {ejecuciones.flujo.nunique()} flujos, del {dias[0]:%d/%m} al {dias[-1]:%d/%m}')
filas = []
for flujo, (indicador, exito_min, p95_max, externo, margen, dueno) in SLO_INTERNO.items():
    x = ejecuciones[ejecuciones.flujo == flujo]
    exito = (x.estado == 'exito').mean()
    p95 = float(np.percentile(x.loc[x.estado == 'exito', 'latencia_s'], 95))
    cumple = exito >= exito_min - 1e-9 and p95 <= p95_max
    filas.append([flujo, indicador, f'{exito_min:.1%} / {p95_max} s', f'{exito:.4f} / {p95:.1f} s', 'sí' if cumple else 'no', externo, margen, dueno])
tabla_2 = pd.DataFrame(filas, columns = ['flujo', 'indicador', 'SLO interno', 'medido', 'cumple', 'SLA externo', 'margen', 'dueño / suplente'])
print(tabla_2.drop(columns = ['indicador']).to_string(index = False))
print(f"Flujos que cumplen su SLO interno: {(tabla_2.cumple == 'sí').sum()} de {len(tabla_2)} | "
      f"críticos sin suplente en esta lista: {(tabla_2['dueño / suplente'].str.endswith('falta')).sum()}")
Registro ejecuciones: 5,490 filas de 6 flujos, del 24/03 al 22/04
                  flujo    SLO interno            medido cumple                 SLA externo                          margen      dueño / suplente
      TL Orquestador v1    99.5% / 5 s    0.9966 / 4.4 s     sí           Confirmación 95 %       1 mensaje al mes y 10 min       Valeria / Itzel
 TL Pedidos WhatsApp v3   99.0% / 30 s   0.9949 / 21.2 s     sí       Disponibilidad 98.5 %       0.9 puntos: 3.04 h al mes       Valeria / falta
 TL Gmail mayoristas v1   99.0% / 90 s   0.9917 / 90.9 s     no      Cambios aplicados 80 % 20 puntos y 5 h hasta las 20:00       Valeria / Itzel
         TL Facturación  100.0% / 60 s   1.0000 / 29.8 s     sí     Factura del sábado 75 %         18 h y 1 factura al mes       Valeria / falta
     TL Aprobaciones v2 99.5% / 3600 s 0.9920 / 3205.6 s     no   Reclamaciones 90 % en 4 h                     3 h hábiles Valeria / Don Ernesto
TL Slack operaciones v1   99.5% / 60 s    0.9989 / 2.9 s     sí Confirmación 95 % en 15 min                      14 minutos       Valeria / Itzel
Flujos que cumplen su SLO interno: 4 de 6 | críticos sin suplente en esta lista: 2

El registro tiene $5,490$ ejecuciones de los seis flujos en $26$ días, y $4$ de los $6$ flujos cumplen su SLO interno. Los dos que no lo cumplen, Gmail y las aprobaciones, no rompen ninguna cláusula del acuerdo, y esa es exactamente la función del margen: el borrador de Gmail tardó $90.9$ segundos en el p95 frente a $90$ de objetivo, una décima que a Brújula le da igual porque el cambio se aplica hasta las 20:00; y las aprobaciones registraron el $99.2%$ de decisiones frente al $99.5%$ interno, con un p95 de $53$ minutos que sigue tres horas por debajo de las 4 que se prometen. Los dos incumplimientos internos entran en el calendario de revisión, no en el informe a Marisol. La Tabla 3 recoge la correspondencia completa.

Tabla 3. SLO internos por flujo: indicador, objetivo interno, medición de los últimos 26 días hábiles, objetivo externo relacionado, margen y dueño con suplente.

FlujoIndicador internoSLO internoMedidoCumpleObjetivo externo que protegeMargenDueño / suplente
TL Orquestador v1Evento enrutado con éxito; p95 del enrutamiento$99.5%$ / 5 s$0.9966$ / $4.4$ sSíConfirmación $95%$ en 15 min1 mensaje al mes y 10 minValeria / Itzel
TL Pedidos WhatsApp v3Ejecuciones con éxito; p95 de ida y vuelta$99.0%$ / 30 s$0.9949$ / $21.2$ sSíDisponibilidad $98.5%$0.9 puntos: $3.04$ h al mesValeria / falta
TL Gmail mayoristas v1Borrador listo para revisar; p95 desde la llegada del correo$99.0%$ / 90 s$0.9917$ / $90.9$ sNoCambios aplicados $80%$20 puntos y 5 h hasta las 20:00Valeria / Itzel
TL FacturaciónFactura emitida el viernes antes de las 18:00$100%$ / 60 s$1.0000$ / $29.8$ sSíFactura del sábado $75%$18 h y 1 factura al mesValeria / falta
TL Aprobaciones v2Decisión humana registrada; p95 de espera$99.5%$ / 3 600 s$0.9920$ / $3,205.6$ sNoReclamaciones $90%$ en 4 h3 h hábilesValeria / Don Ernesto
TL Slack operaciones v1Aviso publicado en el canal; p95$99.5%$ / 60 s$0.9989$ / $2.9$ sSíConfirmación $95%$ en 15 min14 minutosValeria / Itzel

La última columna repite un hallazgo del Día 79 que el acuerdo vuelve urgente: dos de los seis flujos que sostienen una promesa firmada, el de pedidos y el de facturación, no tienen suplente. Hasta hoy era un riesgo interno; desde el 30 de abril es un riesgo con crédito. Valeria lo lleva a la cita del 4 de mayo con Itzel y Joaquín, y el documento del acuerdo no se firma con un “falta” en esa columna: Itzel queda como suplente del flujo de pedidos y Don Ernesto, que ya firma las facturas, del de facturación.

Presupuesto de error y política


El SLO interno de disponibilidad del $99.4%$ equivale a un presupuesto de error de $(1 - 0.994) \cdot 20,280 = 121.7$ minutos de caída al mes en horario de servicio; el SLA del $98.5%$ permite $304.2$. El presupuesto no es una cifra para mirar a fin de mes: es una cantidad que se gasta y una política que dice qué hacer según cuánto quede. La política de Trigo Lento tiene tres estados, y la Tabla 4 los fija antes del código.

Tabla 4. Política del presupuesto de error de disponibilidad: estado según la fracción gastada, qué se permite y quién decide.

EstadoPresupuesto gastadoQué se permite en los flujos del camino críticoQué se priorizaQuién decide volver al estado anterior
NormalMenos del $75%$Cambios versionados con pruebas (Día 43) y revisiónLo planificadoNadie: es el estado por defecto
Solo correccionesDel $75%$ al $100%$Solo correcciones de defectos y cambios que reducen riesgoFiabilidad sobre funciones nuevasValeria, al empezar el mes siguiente
Congelación$100%$ o másNingún cambio salvo los del manual de incidentesRecuperar el servicio y revisar la causaDon Ernesto, con el informe del mes en la mano

La política se dispara con la tasa de consumo, como el Día 53, pero con una diferencia: la disponibilidad no tiene “pocos eventos”, porque se mide en minutos continuos, así que las reglas no necesitan mínimo de eventos y pueden usar ventanas más cortas. La tasa y su umbral son las mismas fórmulas del Día 53 (Métricas y SLA de la atención: percentiles, presupuesto de error y alertas):

$$ r = \frac{e}{1 - \text{SLO}}, \qquad r^{*} = \frac{f \cdot H}{w} $$

  • $e$ es la fracción de la ventana en que el canal estuvo caído; durante una caída completa, $e = 1$ y $r = 1 / 0.006 = 166.7$: una hora de caída gasta el presupuesto $166.7$ veces más rápido de lo sostenible.
  • $r^{}$ es la tasa que gasta la fracción $f$ del presupuesto mensual en una ventana de $w$ horas, con $H = 338$ horas hábiles; la regla rápida avisa si en 1 hora se gasta el $5%$ ($r^{} = 0.05 \cdot 338 / 1 = 16.9$) y la lenta, si en 6 horas se gasta el $10%$ ($r^{*} = 5.63$).
  • El libro de trabajo de SRE recomienda para un SLO del $99.9%$ sobre $720$ horas avisar con $14.4$ en 1 hora y $6$ en 6 horas; con $338$ horas y un SLO del $99.4%$ los umbrales cambian, pero el razonamiento es el mismo: se decide cuánto presupuesto se está dispuesto a perder antes de enterarse.
  • En conclusión, con la disponibilidad la regla rápida salta a los pocos minutos de una caída (basta un $5%$ de la hora, tres minutos, para que $r = 0.05 / 0.006 = 8.3$, y seis minutos para superar $16.9$); la regla lenta sirve para las degradaciones intermitentes que ninguna caída aislada delata.

El octavo bloque simula el mes de abril con dos incidentes: el servidor de n8n caído el martes 14 de 17:20 a 18:45, en plena franja de pedidos de Brújula, y la Cloud API de Meta con un parpadeo de 9 minutos el lunes 20 a las 8:12. Calcula el consumo del presupuesto por día, evalúa las dos reglas, recorre los estados de la política, calcula el cumplimiento de abril de los cinco servicios y dibuja el tablero:

python
# Presupuesto de error del mes con un incidente simulado, alertas por tasa de consumo en dos ventanas y tablero en Python:
DIAS_ABRIL = [date(2026, 4, 1) + timedelta(i) for i in range(30) if (date(2026, 4, 1) + timedelta(i)).weekday() != 6]
PRESUPUESTO_MIN = (1 - SLO_DISP) * MINUTOS_SERVICIO       # minutos de caída que el SLO interno tolera al mes
INCIDENTES = [  # (inicio, fin, componente, excluido del SLA): dos caídas en abril, ninguna cubierta por una exclusión
    (datetime(2026, 4, 14, 17, 20), datetime(2026, 4, 14, 18, 45), 'Servidor de n8n', False),
    (datetime(2026, 4, 20, 8, 12), datetime(2026, 4, 20, 8, 21), 'Cloud API de Meta', False),
]
# Minutos caídos por hora hábil del mes (26 días x 13 horas = 338 horas):
caido = np.zeros(HORAS_SERVICIO)
for ini, fin, _, excluido in INCIDENTES:
    if excluido:
        continue
    for h in range(HORAS_SERVICIO):
        dia, hora = DIAS_ABRIL[h // 13], APERTURA + h % 13
        a, b = datetime.combine(dia, datetime.min.time()) + timedelta(hours = hora), datetime.combine(dia, datetime.min.time()) + timedelta(hours = hora + 1)
        caido[h] += max(0.0, (min(fin, b) - max(ini, a)).total_seconds() / 60)
consumo_dia = pd.Series(caido.reshape(26, 13).sum(axis = 1), index = DIAS_ABRIL)
restante = PRESUPUESTO_MIN - consumo_dia.cumsum()
# Tasa de consumo r = e / (1 - SLO) en ventanas de 1 y 6 horas hábiles; umbral r* = f H / w (Día 53):
umbral = lambda f, w: f * HORAS_SERVICIO / w
REGLAS = {'rápida (1 h)': (1, umbral(0.05, 1)), 'lenta (6 h)': (6, umbral(0.10, 6))}
avisos = {}
for nombre, (w, limite) in REGLAS.items():
    e = np.convolve(caido, np.ones(w))[:HORAS_SERVICIO] / (60 * w)      # fracción de la ventana con el canal caído
    r = e / (1 - SLO_DISP)
    disparos = np.flatnonzero(r >= limite)
    rachas = [h for h in disparos if h - 1 not in set(disparos)]
    avisos[nombre] = [(DIAS_ABRIL[h // 13], APERTURA + h % 13 + 1, round(float(r[h]), 1)) for h in rachas]
    print(f'Regla {nombre}: umbral r >= {limite:.2f} | avisos: ' + '; '.join(f'{d:%d/%m} al cerrar las {hh}:00 (r = {rr})' for d, hh, rr in avisos[nombre]))
def estado_politica(fraccion_gastada):
    return 'congelación' if fraccion_gastada >= 1 else 'solo correcciones' if fraccion_gastada >= 0.75 else 'normal'
gastado = consumo_dia.cumsum() / PRESUPUESTO_MIN
politica = gastado.map(estado_politica)
cambios_estado = politica[politica != politica.shift()]
print(f'Presupuesto del SLO interno: {PRESUPUESTO_MIN:.1f} min | gastado en abril: {consumo_dia.sum():.0f} min ({gastado.iloc[-1]:.1%}) | '
      f'restante: {restante.iloc[-1]:.1f} min | SLA: {(1 - SLA_DISP) * MINUTOS_SERVICIO:.1f} min permitidos')
print('Estados de la política:', '; '.join(f'{d:%d/%m}: {e}' for d, e in cambios_estado.items()))
# Cumplimiento de abril por servicio: el pedido del martes 14 llegó a las 17:41, en plena caída, y salió en modo manual a los 17 min:
abril = {'confirmacion': 25 / 26, 'cambios': 7 / 7, 'factura': 4 / 4, 'reclamaciones': 1 / 1,
         'disponibilidad': 1 - consumo_dia.sum() / MINUTOS_SERVICIO}
tolera = {s['clave']: tolerados(s['sla'], s['eventos_mes']) for s in SERVICIOS}
usados = {'confirmacion': 1, 'cambios': 0, 'factura': 0, 'reclamaciones': 0, 'disponibilidad': int(consumo_dia.sum())}
credito_abril = max([ESCALON_GRAVE if abril[s['clave']] < s['grave'] - 1e-9 else ESCALON_SLA if abril[s['clave']] < s['sla'] - 1e-9 else 0 for s in SERVICIOS])
for s in SERVICIOS:
    print(f"  {s['clave']:<15} SLI {abril[s['clave']]:.4f} | SLA {s['sla']:.3f} {'cumple' if abril[s['clave']] >= s['sla'] - 1e-9 else 'INCUMPLE'} | "
          f"SLO {s['slo']:.3f} {'cumple' if abril[s['clave']] >= s['slo'] - 1e-9 else 'incumple (revisión interna)'}")
print(f'Crédito de abril: {credito_abril:.0%} = {credito_abril * FACTURA_MES:,.0f} MXN')
# Tablero de cumplimiento del mes en una sola página:
fig, ejes = plt.subplots(2, 2, figsize = (12.5, 8.2))
ax = ejes[0, 0]
nombres = ['Confirmación', 'Cambios', 'Factura', 'Reclamaciones', 'Disponibilidad']
y = np.arange(len(SERVICIOS))
valores = [100 * abril[s['clave']] for s in SERVICIOS]
ax.barh(y, valores, color = ['#1f9d55' if abril[s['clave']] >= s['slo'] - 1e-9 else '#eb6834' if abril[s['clave']] >= s['sla'] - 1e-9 else '#c0392b' for s in SERVICIOS], height = 0.55)
ax.scatter([100 * s['sla'] for s in SERVICIOS], y, marker = '|', s = 500, color = '#0b0b0b', zorder = 3, label = 'SLA')
ax.scatter([100 * s['slo'] for s in SERVICIOS], y, marker = '|', s = 500, color = '#2a78d6', zorder = 3, label = 'SLO')
for yi, v in zip(y, valores):
    ax.text(v - 0.3, yi, f'{v:.2f} %', va = 'center', ha = 'right', fontsize = 9, color = 'white', fontweight = 'bold')
ax.set_yticks(y)
ax.set_yticklabels(nombres)
ax.invert_yaxis()
ax.set_xlim(70, 101)
ax.legend(loc = 'upper center', bbox_to_anchor = (0.5, -0.04), ncol = 2, frameon = False, fontsize = 9)
ax.set_title('1. Cumplimiento de abril por servicio (verde: SLO; naranja: solo SLA)', fontsize = 11)
ax = ejes[0, 1]
ax.step(range(1, 27), restante.values, where = 'post', color = '#2a78d6', linewidth = 2)
ax.axhspan(0.25 * PRESUPUESTO_MIN, 0, color = '#eb6834', alpha = 0.15)
ax.axhspan(0, -40, color = '#c0392b', alpha = 0.15)
ax.axhline(0, color = '#0b0b0b', linewidth = 1)
ax.text(1.3, 0.25 * PRESUPUESTO_MIN - 4, 'solo correcciones (queda < 25 %)', fontsize = 9, va = 'top')
ax.text(1.3, -8, 'congelación de cambios (presupuesto agotado)', fontsize = 9, va = 'top')
ax.set_ylim(-40, PRESUPUESTO_MIN + 10)
ax.set_xlabel('Día hábil de abril')
ax.set_ylabel('Minutos de caída que aún tolera el SLO')
ax.set_title(f'2. Presupuesto de error de disponibilidad ({PRESUPUESTO_MIN:.0f} min al mes)', fontsize = 11)
ax = ejes[1, 0]
for i, (ini, fin, comp, _) in enumerate(INCIDENTES):
    minutos = (fin - ini).total_seconds() / 60
    ax.barh(i, minutos, color = '#c0392b' if 'n8n' in comp else '#52514e', height = 0.5)
    ax.text(minutos + 2, i, f'{DIAS_ES[ini.weekday()]} {ini:%d/%m %H:%M} a {fin:%H:%M}: {minutos:.0f} min, {comp}', va = 'center', fontsize = 9)
ax.set_yticks(range(len(INCIDENTES)))
ax.set_yticklabels([f'Incidente {i + 1}' for i in range(len(INCIDENTES))])
ax.invert_yaxis()
ax.set_xlim(0, 260)
ax.set_xlabel('Minutos de caída en horario de servicio')
ax.set_title('3. Incidentes del mes (ninguno excluido del SLA)', fontsize = 11)
ax = ejes[1, 1]
margen_usado = [usados[c] / tolera[c] if tolera[c] else (1.0 if usados[c] else 0.0) for c in [s['clave'] for s in SERVICIOS]]
ax.barh(y, [100 * m for m in margen_usado], color = ['#c0392b' if m >= 1 else '#eb6834' if m >= 0.75 else '#1f9d55' for m in margen_usado], height = 0.55)
for yi, c in zip(y, [s['clave'] for s in SERVICIOS]):
    ax.text(100 * margen_usado[yi] + 1.5, yi, f'{usados[c]} de {tolera[c]} tolerado(s)' if tolera[c] else 'no tolera ninguno; 0 usados', va = 'center', fontsize = 9)
ax.set_yticks(y)
ax.set_yticklabels(nombres)
ax.invert_yaxis()
ax.set_xlim(0, 140)
ax.set_xlabel('Margen del SLA ya usado (%)')
ax.set_title(f'4. Margen del SLA y crédito del mes: {credito_abril * FACTURA_MES:,.0f} MXN de {TOPE * FACTURA_MES:,.0f} de tope', fontsize = 11)
for a in ejes.flat:
    a.spines[['top', 'right']].set_visible(False)
fig.suptitle('Tablero de cumplimiento de Brújula: abril de 2026 (mes simulado)', fontsize = 14)
plt.tight_layout()
plt.show()
Regla rápida (1 h): umbral r >= 16.90 | avisos: 14/04 al cerrar las 18:00 (r = 111.1); 20/04 al cerrar las 9:00 (r = 25.0)
Regla lenta (6 h): umbral r >= 5.63 | avisos: 14/04 al cerrar las 18:00 (r = 18.5)
Presupuesto del SLO interno: 121.7 min | gastado en abril: 94 min (77.3%) | restante: 27.7 min | SLA: 304.2 min permitidos
Estados de la política: 01/04: normal; 20/04: solo correcciones
  confirmacion    SLI 0.9615 | SLA 0.950 cumple | SLO 1.000 incumple (revisión interna)
  cambios         SLI 1.0000 | SLA 0.800 cumple | SLO 1.000 cumple
  factura         SLI 1.0000 | SLA 0.750 cumple | SLO 1.000 cumple
  reclamaciones   SLI 1.0000 | SLA 0.900 cumple | SLO 1.000 cumple
  disponibilidad  SLI 0.9954 | SLA 0.985 cumple | SLO 0.994 cumple
Crédito de abril: 0% = 0 MXN

Gráfica: Presupuesto de error y política. Presupuesto de error del mes con un incidente simulado, alertas por tasa de consumo en dos ventanas y tablero

Las dos reglas avisan del incidente del martes 14 al cerrar la hora de 18:00, la rápida con $r = 111.1$ y la lenta con $18.5$; el parpadeo de Meta del lunes 20 solo dispara la rápida ($r = 25.0$ en la hora de 8:00 a 9:00), porque nueve minutos en seis horas no llegan al umbral lento. Abril gasta $94$ de los $121.7$ minutos del presupuesto interno, el $77.3%$, y quedan $27.7$: la política pasa de “normal” a “solo correcciones” el 20 de abril, y así seguirá hasta mayo. No se llega a la congelación; pero desde el día 20 ningún cambio de funciones entra en los flujos del camino crítico, lo que, a diez días del Día del Niño, es exactamente la prudencia que Don Ernesto pediría. Frente al SLA, los $94$ minutos son el $31%$ de los $304$ permitidos.

El cumplimiento de abril por servicio dice lo que el acuerdo va a informar y lo que el equipo va a revisar, que no es lo mismo. La disponibilidad queda en el $99.54%$: cumple el SLA y el SLO. Los cambios, la factura y las reclamaciones, al $100%$. Las confirmaciones quedan en el $96.15%$: $25$ de $26$, porque el pedido del martes 14 llegó a las 17:41, en plena caída, y salió en modo manual a los 17 minutos. Ese mensaje cumple el SLA (tolera uno) e incumple el SLO interno (tolera cero), y eso dispara la revisión interna: la alerta de servidor caído tardó 12 minutos en llegar a Itzel, y la corrección es que llegue en menos de 10. El crédito de abril es de $0$ MXN. La Figura 3 dibuja el tablero con la metáfora que Don Ernesto propuso al ver la gráfica: semáforos por servicio y un costal de harina que se vacía.

El tablero de cumplimiento de abril: un semáforo por servicio (verde cumple el SLO, naranja solo el SLA, rojo incumple el acuerdo) y el presupuesto de error de disponibilidad como un costal de harina de 121.7 minutos del que quedan 27.7; por debajo del 25 % solo se hacen correcciones y, vacío, se congelan los cambios del camino crítico.

Figura 3. El tablero de cumplimiento de abril: un semáforo por servicio (verde cumple el SLO, naranja solo el SLA, rojo incumple el acuerdo) y el presupuesto de error de disponibilidad como un costal de harina de 121.7 minutos del que quedan 27.7; por debajo del 25 % solo se hacen correcciones y, vacío, se congelan los cambios del camino crítico.

El tablero de cuatro cuadros es el que Valeria mirará cada lunes junto al panel del Día 75. El primero pone el cumplimiento de cada servicio con dos marcas, la negra del SLA y la azul del SLO, y colorea la barra en verde si cumple los dos y en naranja si solo cumple el acuerdo: las confirmaciones son la única barra naranja. El segundo es el presupuesto de error como un depósito que baja a escalones, con la franja de “solo correcciones” y la de congelación; se ve el escalón grande del día 12 y el pequeño del día 17 (días hábiles). El tercero lista los incidentes con su duración y su componente, y dice que ninguno se excluyó. El cuarto convierte el margen del SLA en una barra por servicio: las confirmaciones usaron $1$ de $1$ tolerado, la barra roja; la disponibilidad, $94$ de $304$ minutos.

El informe mensual y el tablero de cumplimiento


El acuerdo promete un informe el primer día hábil de cada mes, “también cuando el mes se cumplió”. El informe no es el tablero: el tablero es interno y muestra SLO y presupuesto; el informe es para Marisol y muestra solo lo que se le prometió, con los incidentes y los créditos, y sin datos de otros clientes. Cabe en una página y se genera desde los mismos resultados del bloque anterior, de modo que no puede decir algo distinto de lo que el tablero dice. El noveno bloque lo escribe en Markdown:

python
# Informe mensual para Marisol generado en Markdown desde los resultados del mes en Python:
def fila_informe(s):
    v, sla = abril[s['clave']], s['sla']
    estado = 'cumplido' if v >= sla - 1e-9 else 'incumplido'
    detalle = {'confirmacion': '25 de 26 pedidos confirmados en 15 minutos hábiles; el del martes 14 tardó 17 minutos',
               'cambios': '7 de 7 cambios aplicados al día siguiente', 'factura': '4 de 4 facturas antes del sábado a las 12:00, sin correcciones',
               'reclamaciones': '1 reclamación (lunes 6), respondida por Valeria en 1 hora y 40 minutos hábiles',
               'disponibilidad': f'{consumo_dia.sum():.0f} minutos de caída en {MINUTOS_SERVICIO:,} de servicio'}[s['clave']]
    return f"| {s['servicio']} | {v:.2%} | {sla:.1%} | {estado} | {detalle} |"
informe = [f'# Informe mensual de servicio a Cafetería Brújula: abril de 2026', '',
           f'Preparado el {hoy:%d/%m/%Y} con los registros de Trigo Lento. Acuerdo versión {ACUERDO["version"]}; los datos son '
           'agregados del servicio a Brújula y no contienen información de otros clientes.', '',
           '## Resumen', '',
           f'- Servicios cumplidos: {sum(abril[s["clave"]] >= s["sla"] - 1e-9 for s in SERVICIOS)} de {len(SERVICIOS)}.',
           f'- Crédito aplicado este mes: {credito_abril:.0%} ({credito_abril * FACTURA_MES:,.0f} MXN).',
           f'- Incidentes en horario de servicio: {len(INCIDENTES)}, con {consumo_dia.sum():.0f} minutos de caída en total; ninguno se excluyó del cómputo.',
           '- Lo que cambia en mayo: el pedido del martes 14 se confirmó en modo manual a los 17 minutos; el objetivo interno es cero '
           'tardíos, así que revisamos el aviso de servidor caído para que llegue antes de 10 minutos.', '',
           '## Cumplimiento por servicio', '',
           '| Servicio | Cumplimiento | Objetivo | Estado | Detalle |', '|---|---|---|---|---|'] + [fila_informe(s) for s in SERVICIOS] + \
          ['', '## Incidentes', ''] + \
          [f'{i + 1}. {DIAS_ES[ini.weekday()].capitalize()} {ini:%d/%m} de {ini:%H:%M} a {fin:%H:%M} ({(fin - ini).total_seconds() / 60:.0f} minutos): {comp}. '
           + ('Afectó al pedido del día, confirmado a mano a los 17 minutos.' if 'n8n' in comp else 'Sin pedidos afectados.')
           for i, (ini, fin, comp, _) in enumerate(INCIDENTES)] + \
          ['', '## Créditos', '', f'Ningún servicio quedó por debajo de su objetivo del SLA; no corresponde crédito. '
           f'El margen usado fue de 1 confirmación tardía de 1 tolerada y de {consumo_dia.sum():.0f} minutos de caída de '
           f'{(1 - SLA_DISP) * MINUTOS_SERVICIO:.0f} permitidos.', '',
           'Si no está de acuerdo con alguna medición, responda a este correo dentro de los 15 días siguientes; le enviaremos los registros.', '',
           'Valeria Ortiz, Panadería Trigo Lento']
ruta_informe = carpeta / 'informe_brujula_2026-04.md'
ruta_informe.write_text('\n'.join(informe) + '\n', encoding = 'utf-8')
print(f'{ruta_informe.as_posix()}: {len(informe)} líneas, {ruta_informe.stat().st_size:,} bytes')
print('\n'.join(informe))
trigo_lento/dia_82/informe_brujula_2026-04.md: 33 líneas, 2,150 bytes
# Informe mensual de servicio a Cafetería Brújula: abril de 2026

Preparado el 23/04/2026 con los registros de Trigo Lento. Acuerdo versión 1.0; los datos son agregados del servicio a Brújula y no contienen información de otros clientes.

## Resumen

- Servicios cumplidos: 5 de 5.
- Crédito aplicado este mes: 0% (0 MXN).
- Incidentes en horario de servicio: 2, con 94 minutos de caída en total; ninguno se excluyó del cómputo.
- Lo que cambia en mayo: el pedido del martes 14 se confirmó en modo manual a los 17 minutos; el objetivo interno es cero tardíos, así que revisamos el aviso de servidor caído para que llegue antes de 10 minutos.

## Cumplimiento por servicio

| Servicio | Cumplimiento | Objetivo | Estado | Detalle |
|---|---|---|---|---|
| Confirmación del pedido diario | 96.15% | 95.0% | cumplido | 25 de 26 pedidos confirmados en 15 minutos hábiles; el del martes 14 tardó 17 minutos |
| Cambios de cantidad hasta las 20:00 del día anterior | 100.00% | 80.0% | cumplido | 7 de 7 cambios aplicados al día siguiente |
| Factura semanal antes de las 12:00 del sábado y sin corrección | 100.00% | 75.0% | cumplido | 4 de 4 facturas antes del sábado a las 12:00, sin correcciones |
| Respuesta de una persona a reclamaciones | 100.00% | 90.0% | cumplido | 1 reclamación (lunes 6), respondida por Valeria en 1 hora y 40 minutos hábiles |
| Disponibilidad del canal de pedidos en horario de servicio | 99.54% | 98.5% | cumplido | 94 minutos de caída en 20,280 de servicio |

## Incidentes

1. Martes 14/04 de 17:20 a 18:45 (85 minutos): Servidor de n8n. Afectó al pedido del día, confirmado a mano a los 17 minutos.
2. Lunes 20/04 de 08:12 a 08:21 (9 minutos): Cloud API de Meta. Sin pedidos afectados.

## Créditos

Ningún servicio quedó por debajo de su objetivo del SLA; no corresponde crédito. El margen usado fue de 1 confirmación tardía de 1 tolerada y de 94 minutos de caída de 304 permitidos.

Si no está de acuerdo con alguna medición, responda a este correo dentro de los 15 días siguientes; le enviaremos los registros.

Valeria Ortiz, Panadería Trigo Lento

El informe tiene $33$ líneas y cuatro partes: un resumen de cuatro viñetas (servicios cumplidos, crédito, incidentes y lo que cambia el mes que viene), la tabla de cumplimiento con el detalle en palabras de cada servicio, la lista de incidentes con fecha, hora, duración y si afectaron a un pedido, y la sección de créditos, que en abril dice que no corresponde ninguno y cuánto margen se usó. La cuarta viñeta del resumen es la que Marisol pidió el Día 53 (“si un mes fallan, prefiero enterarme por usted que por mi reloj”) llevada un paso más allá: el informe cuenta el mensaje de 17 minutos aunque el acuerdo lo tolere, y dice qué se va a corregir. Un informe que solo habla cuando incumple enseña al cliente a temer el correo; uno que cuenta también el margen usado enseña a leerlo.

El informe sale por Gmail como borrador, en el hilo de Brújula, con el archivo adjunto, y lo envía una persona después de leerlo, con la regla del Día 47 (Automatización de Gmail: leer, clasificar y redactar borradores con OAuth): el flujo tiene el alcance gmail.compose, que permite crear borradores, y ningún nodo de envío. Para que el borrador quede dentro del hilo, la API de Gmail exige tres cosas que el código cumple: el threadId del hilo en el mensaje, las cabeceras In-Reply-To y References con el identificador del último mensaje, y el mismo asunto. El décimo bloque busca el hilo por su asunto fijo, construye el correo MIME con email.message y el informe como adjunto, lo codifica en base64 seguro para URL y crea el borrador con users.drafts.create; llama a un servicio real y no se ejecutó para escribir la entrada:

python
# Borrador del correo mensual con el informe adjunto en el hilo de Brújula mediante users.drafts.create de la API de Gmail en Python (bloque API, no se ejecuta aquí):
import base64
import os
from email.message import EmailMessage
from pathlib import Path
import requests

BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'
# El token de acceso sale del entorno (OAuth con el alcance gmail.compose del Día 47); nunca se escribe en el código:
cabeceras = {'Authorization': f"Bearer {os.environ['GMAIL_ACCESS_TOKEN']}"}
informe_md = (Path('trigo_lento') / 'dia_82' / 'informe_brujula_2026-04.md').read_text(encoding = 'utf-8')

# El hilo de Brújula se busca por su asunto fijo; el borrador debe llevar threadId y el mismo asunto para quedar dentro de él:
hilos = requests.get(f'{BASE}/threads', headers = cabeceras, timeout = 30,
                     params = {'q': 'subject:"Servicio a Cafetería Brújula" from:marisol.p@ejemplo.com', 'maxResults': 1}).json()
hilo = hilos['threads'][0]
ultimo = requests.get(f"{BASE}/threads/{hilo['id']}", headers = cabeceras, timeout = 30, params = {'format': 'metadata'}).json()['messages'][-1]
encabezados = {h['name']: h['value'] for h in ultimo['payload']['headers']}

correo = EmailMessage()
correo['To'] = encabezados['From']
correo['Subject'] = encabezados['Subject'] if encabezados['Subject'].startswith('Re:') else f"Re: {encabezados['Subject']}"
correo['In-Reply-To'] = encabezados['Message-ID']
correo['References'] = f"{encabezados.get('References', '')} {encabezados['Message-ID']}".strip()
correo.set_content(
    'Estimada Marisol:\n\nLe adjunto el informe de servicio de abril: 5 de 5 servicios cumplidos, 2 incidentes (94 minutos de caída en '
    '20 280 de servicio) y ningún crédito. El pedido del martes 14 se confirmó en modo manual a los 17 minutos; es el único punto '
    'que revisamos este mes.\n\nQuedo atenta a cualquier duda sobre las mediciones.\n\nValeria Ortiz\nPanadería Trigo Lento')
correo.add_attachment(informe_md.encode('utf-8'), maintype = 'text', subtype = 'markdown', filename = 'informe_brujula_2026-04.md')
cuerpo = {'message': {'threadId': hilo['id'], 'raw': base64.urlsafe_b64encode(correo.as_bytes()).decode('ascii')}}
borrador = requests.post(f'{BASE}/drafts', headers = cabeceras, json = cuerpo, timeout = 30)
borrador.raise_for_status()
print('Borrador:', borrador.json()['id'], '| hilo:', borrador.json()['message']['threadId'], '| etiqueta:', borrador.json()['message']['labelIds'])
print('Lo envía una persona desde Gmail después de leerlo; el flujo no tiene permiso de envío.')

# Salida de ejemplo (puede variar):
# Borrador: r-EJEMPLO-NO-REAL-0082 | hilo: EJEMPLO-NO-REAL-t001 | etiqueta: ['DRAFT']
# Lo envía una persona desde Gmail después de leerlo; el flujo no tiene permiso de envío.

La salida de ejemplo muestra el identificador del borrador, el hilo en el que quedó y la etiqueta DRAFT que la API le pone; el token de acceso se lee del entorno y nunca aparece en el código. El borrador lo abre Valeria el lunes 4 de mayo, el mismo día de la cita mensual de costes del calendario de gobierno, lo lee y lo envía; si ese mes hubiera un crédito, el informe lo diría y la factura de la primera semana de mayo lo descontaría sin que Marisol escribiera una línea.

Consideraciones finales


Un SLA es la parte del ecosistema que un cliente puede leer. Todo lo demás, los flujos, los manuales, el inventario, la arquitectura, es invisible para Marisol; lo que ella y su socio tienen en la mano son cinco promesas, tres exclusiones, dos escalones de crédito y un informe mensual. Que esas pocas páginas digan la verdad depende de que cada promesa esté atada a un registro que ya existe, de que el margen entre lo que se promete y lo que se persigue se haya calculado con la distribución de los meses malos y no con la media, y de que la consecuencia de fallar se haya presupuestado antes de firmar. La simulación de hoy cambió una cifra del acuerdo antes de que nadie la firmara, y ese es su mejor resultado: una disponibilidad del $99%$ que se habría roto dos meses al año se quedó en el $98.5%$, que se rompe uno de cada treinta y cuatro.

Lo que las cifras no dicen también importa. Las probabilidades salen de supuestos declarados, y el primer informe real, el de mayo, será la primera prueba de que esos supuestos se parecen al mundo; la revisión trimestral existe para corregirlos. Los créditos están calculados con la factura de hoy, y Brújula acaba de crecer: si el pedido sube a $60$ baguettes, el coste esperado sube en la misma proporción y el tope también, y el acuerdo lo recoge porque los porcentajes se aplican a la factura del mes, no a una cifra fija. Y el tablero muestra lo que el sistema mide; una reclamación que Marisol hace por teléfono a Don Ernesto y que nadie registra no aparece en ningún cuadro, por lo que la última regla del día es la más antigua de la serie: todo lo que se promete se registra, y lo que no se registra no se promete.

Un gran libro que habla de esto es The Site Reliability Workbook (Beyer, Murphy, Rensin, Kawahara y Thorne, 2018), cuyos capítulos sobre la implantación de SLO y las alertas por tasa de consumo son la fuente de las dos ventanas de hoy; su apéndice sobre la política del presupuesto de error es, en una página, lo que la Tabla 4 adapta a una panadería de seis personas. Léalo después del capítulo 4 del primer libro, y lea las dos cosas con un cliente concreto en la cabeza, porque la diferencia entre un SLO y un SLA es, al final, que en el segundo hay alguien al otro lado de la mesa.

Un acuerdo de nivel de servicio no promete que nada fallará: promete cuánto puede fallar, cómo se sabrá y qué pasará entonces. Trigo Lento firma el 30 de abril cinco promesas que sus datos dicen que puede cumplir once meses de cada doce, con un crédito que cuesta menos de una factura al año y un informe que cuenta también los meses buenos. Lo que el socio de Marisol quería por escrito ya está escrito, y lo que Don Ernesto exigió también: nada de lo que está en el papel se firmó sin calcular antes cuántas veces se incumplirá.

Conclusiones clave


Tres capas

  • El SLI mide, el SLO guía y el SLA obliga; el acuerdo es siempre más laxo que el objetivo interno que lo protege, y el margen entre ambos es lo que permite revisar por dentro antes de pagar por fuera.
  • Cada servicio del acuerdo se define con numerador, denominador, ventana y fuente de datos, y se promete solo lo que el sistema ya mide: cinco servicios atados a la tabla ejecuciones y a las tablas de pedidos.
  • Con pocos eventos, un objetivo tolera fallos enteros, $k = \lfloor (1 - \text{objetivo}) , n \rfloor$: el $95%$ con $26$ mensajes tolera uno, el $80%$ con $6$ cambios tolera uno y el $90%$ con una reclamación no tolera ninguno.

Margen y créditos

  • En horario de servicio ($338$ horas) el camino crítico pierde $2.36$ horas al mes sin degradación y $2.03$ con plantilla, frente a $3.38$ que permitiría un SLA del $99%$; pero la cola de los meses malos (p95 de $4.58$ horas) rompería ese $99%$ en el $14.6%$ de los meses, y el acuerdo se firma al $98.5%$, que se rompe en el $2.9%$.
  • Con cinco promesas a la vez, el $11.9%$ de los meses incumple algún SLA y el $1.9%$ llega a un umbral grave; los créditos del $5%$ y del $10%$ con tope cuestan $3,272$ MXN al año, el $0.69%$ de la facturación de Brújula.
  • Prometer de más cuesta a saltos: entre el $92.4%$ y el $96.1%$ de confirmaciones el coste esperado es de $2,822$ MXN al año, y del $96.2%$ en adelante, $8,987$, porque se deja de tolerar el único mensaje tardío.

Informe y tablero

  • El documento sla_brujula_v1.md se genera desde el catálogo con nueve cláusulas y un anexo, y de los seis flujos que lo sostienen, cuatro cumplen su SLO interno y los dos que no (Gmail y aprobaciones) no rompen ninguna cláusula gracias al margen.
  • El presupuesto interno de disponibilidad es de $121.7$ minutos al mes; abril gastó $94$ ($77.3%$) con dos incidentes, las reglas de 1 y 6 horas avisaron al cerrar la hora del primero y la política pasó a “solo correcciones” el día 20 sin llegar a la congelación.
  • Abril cumple los cinco servicios y no genera crédito, pero el pedido del martes 14 salió a los 17 minutos en modo manual: cumple el SLA, incumple el SLO y dispara la revisión interna; el informe lo cuenta igual, y sale como borrador de Gmail que envía una persona.

Mañana, en el Día 83: Proyecto integrador IV: pruebas de punta a punta y de caos. A una semana del Día del Niño y a dos y media del 10 de mayo, Valeria querrá saber si el ecosistema aguanta un sábado de pico antes de que llegue: pondrá a prueba los contratos de los webhooks, la regresión de los prompts con 200 mensajes etiquetados, cincuenta escenarios de punta a punta, fallos inyectados (429 y 529 de Claude, n8n caído, firmas inválidas) y el triple de la carga del pico, y decidirá por escrito si el sistema está listo.

Referencias


  1. Beyer, B., Jones, C., Petoff, J. y Murphy, N. R. (Eds.) (2016). Site Reliability Engineering: How Google Runs Production Systems. O’Reilly Media. (Capítulo 4, “Service Level Objectives”.) https://sre.google/sre-book/service-level-objectives/ (revisado en octubre de 2026).
  2. Beyer, B., Murphy, N. R., Rensin, D. K., Kawahara, K. y Thorne, S. (Eds.) (2018). The Site Reliability Workbook: Practical Ways to Implement SRE. O’Reilly Media. (Capítulo 2, “Implementing SLOs”; capítulo 5, “Alerting on SLOs”; apéndice B, política de presupuesto de error.) https://sre.google/workbook/alerting-on-slos/ (revisado en octubre de 2026).
  3. ISO/IEC (2018). ISO/IEC 20000-1:2018 Information technology: Service management, Part 1: Service management system requirements (3.ª ed., confirmada en 2023). ISO. https://www.iso.org/standard/70636.html (revisado en octubre de 2026).
  4. AXELOS (2019). ITIL Foundation: ITIL 4 Edition. TSO. (Práctica de gestión de niveles de servicio.) https://www.axelos.com/resource-hub/blog/itil_4_practitioner_service_level_management_practice (revisado en octubre de 2026).
  5. Google (2026). Gmail API: users.drafts.create y Managing threads. Google Workspace Developers. https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.drafts/create y https://developers.google.com/workspace/gmail/api/guides/threads (revisado en octubre de 2026).
comments powered by Disqus