Skip to main content
Glama
TatooCollado

Agent Lab MCP Server

by TatooCollado
README.md
# Agent Lab

[![CI](https://github.com/TatooCollado/agent-lab/actions/workflows/ci.yml/badge.svg)](https://github.com/TatooCollado/agent-lab/actions/workflows/ci.yml)
[![Production Smoke](https://github.com/TatooCollado/agent-lab/actions/workflows/production-smoke.yml/badge.svg)](https://github.com/TatooCollado/agent-lab/actions/workflows/production-smoke.yml)

Aplicación educativa para inspeccionar el flujo técnico de agentes de IA sobre datos empresariales. El proyecto muestra contratos, protocolos, llamadas a herramientas, resultados estructurados y trazas sanitizadas.

## Estado

Etapas 1 a 11 — Fundación, MCP, Agent Runtime, Auth/RBAC, A2A, evaluaciones, cloud, CI/CD, contratos deterministas, resiliencia y robustez semántica:

- frontend React + Vite;
- backend Node.js + Express;
- esquema y migraciones PostgreSQL;
- usuarios de aplicación `admin` y `viewer`;
- períodos de calendario deterministas;
- contrato técnico `TraceEvent`;
- tests unitarios y shell visual del Agent Lab;
- servidor MCP oficial sobre transporte `stdio`;
- siete herramientas MCP de solo lectura con respuestas estructuradas;
- consultas PostgreSQL parametrizadas mediante un rol de mínimo privilegio.
- Ollama con `qwen3:8b` para inferencia local y tool calling sin costo por token;
- OpenAI Responses API conservada como proveedor opcional;
- MCP Client local con descubrimiento y ejecución de herramientas;
- orquestador grounded y endpoint `POST /api/agent/query`;
- interfaz de consulta con respuesta y traza técnica real.
- autenticación con sesiones opacas persistidas en PostgreSQL;
- cookie `HttpOnly`, `SameSite=Strict` y expiración configurable;
- autorización RBAC con perfiles `admin` y `viewer`;
- alta auditada de usuarios y borrado transaccional de datos de RR. HH.
- dos agentes publicando Agent Cards A2A 1.0;
- delegación HR → Finanzas mediante JSON-RPC `SendMessage`;
- tarea financiera con ciclo de vida y Artifact estructurado;
- reporte de pérdidas por ausencias consultadas mediante MCP.
- suite de evaluaciones de comportamiento con assertions deterministas;
- casos de referencia, resultado vacío y frescura de PostgreSQL;
- fixture dinámica aislada con limpieza garantizada y verificación residual.
- timeout presupuestado, retry transitorio acotado y circuit breaker por instancia caliente;
- degradación segura que conserva el `answerPayload` grounded si falla sólo la narrativa;
- evaluación de resiliencia con inyección controlada de fallos.
- interpretación de español neutro, informal y rioplatense mediante propuesta semántica del LLM;
- validación backend de capability, schema, período, polaridad y límites antes de MCP;
- decisiones tipadas de aclaración y consulta no soportada sin acceso a PostgreSQL;
- benchmark lingüístico versionado con baseline before/after y estabilidad entre ejecuciones repetidas.

La aplicación está desplegada con frontend estático en Render, backend serverless en Vercel y PostgreSQL en Neon. GitHub Actions aplica quality gates y smoke tests contra producción.

## Estructura

```text
frontend/   React, inspector técnico y system index
backend/    API, dominio, migraciones, acceso PostgreSQL y trazas
```

## Requisitos

- Node.js 22 o superior.
- npm 10 o superior.
- Ollama 0.32 o superior y el modelo local `qwen3:8b`.
- PostgreSQL cloud con tres credenciales separadas cuando el proveedor lo permita.

## Instalación

```bash
npm --prefix backend install
npm --prefix frontend install
```

Copiar `backend/.env.example` a `backend/.env` y completar las URLs del proveedor PostgreSQL. Nunca utilizar la credencial propietaria en `DATABASE_READONLY_URL` o `DATABASE_ADMIN_URL`.

El proveedor predeterminado es Ollama local:

```dotenv
LLM_PROVIDER=ollama
OLLAMA_HOST=http://127.0.0.1:11434
OLLAMA_MODEL=qwen3:8b
```

Instalar Ollama y descargar el modelo una vez con `ollama pull qwen3:8b`. La inferencia usa CPU/GPU y almacenamiento locales, sin consumo de una API paga.

OpenAI continúa disponible como alternativa configurando `LLM_PROVIDER=openai`, `OPENAI_API_KEY` y `OPENAI_MODEL`. La clave sólo pertenece a `backend/.env`, que está ignorado por Git. Nunca debe enviarse al frontend ni incluirse en trazas.

## Base de datos

1. Crear la base PostgreSQL en el proveedor cloud.
2. Crear o configurar los roles siguiendo `backend/ops/database-roles.example.sql`.
3. Configurar las variables de entorno.
4. Ejecutar:

```bash
npm --prefix backend run db:migrate
npm --prefix backend run db:seed
npm --prefix backend run db:smoke
npm --prefix backend run db:verify-permissions
```

`db:smoke` usa exclusivamente `DATABASE_READONLY_URL` y consulta la vista `hr_late_arrivals` con parámetros de fecha.

El seed exige `SEED_ADMIN_PASSWORD` y `SEED_VIEWER_PASSWORD`, ambas con al menos 12 caracteres. No existen contraseñas predeterminadas en el repositorio.

## Desarrollo

En dos terminales:

```bash
npm run dev:backend
npm run dev:frontend
```

- Frontend: `http://localhost:5173`
- Backend: `http://localhost:3000`

## Verificación sin base cloud

```bash
npm test
npm run typecheck
npm run build
```

Los tests de calendario verifican:

- mes corriente desde el día 1 hasta hoy inclusive;
- mes calendario anterior completo;
- febrero de año bisiesto;
- últimos 30 días de calendario.

## Intervalos temporales

La interfaz habla de fechas inclusivas, pero internamente se utilizan intervalos semiabiertos:

```text
startInclusive <= timestamp < endExclusive
```

Esto evita depender de `23:59:59` y conserva correctamente la precisión de PostgreSQL.

## Trazabilidad

El frontend muestra eventos técnicos con:

- nombre del evento;
- tecnología;
- componente;
- categoría;
- conceptos;
- input y output sanitizados;
- duración y estado.

No se mostrarán credenciales, tokens de sesión ni razonamiento interno del modelo.

## MCP Server

El servidor utiliza el SDK oficial de Model Context Protocol para exponer:

- `count_employees`: cuenta empleados totales, activos e inactivos;
- `list_employees`: lista el directorio completo con legajo, nombre, departamento y estado;
- `find_employee`: busca por nombre o número de empleado;
- `summarize_employee_delays`: agrega las tardanzas históricas de una persona por nombre o legajo;
- `list_late_arrivals`: lista llegadas tarde por período y empleado opcional;
- `list_employees_without_late_arrivals`: calcula en PostgreSQL qué empleados activos no tuvieron llegadas tarde durante el período;
- `list_absences`: lista ausencias por período y empleado opcional.

Las herramientas declaran `readOnlyHint`, validan entrada y salida con Zod y devuelven tanto contenido textual como `structuredContent`. Los resultados incluyen fuente, fecha de consulta, período aplicado, cantidad total y señal de truncamiento. Cada llamada consulta PostgreSQL nuevamente; no hay caché en esta etapa.

Para iniciar el servidor MCP local:

```bash
npm run mcp:server
```

Para verificar descubrimiento, llamadas reales, datos sembrados y resultado vacío:

```bash
npm run mcp:smoke
```

`stdout` queda reservado al protocolo MCP; los errores operativos se envían a `stderr` y la respuesta al cliente se sanitiza.

## Agent Runtime

Flujo implementado:

```text
React → POST /api/agent/query → HrAgentOrchestrator
      → Ollama local + qwen3:8b (tool calling)
      → MCP Client → MCP Server → PostgreSQL
      → tool result → Ollama → respuesta + TraceEvent[]
```

El MCP Client descubre las herramientas disponibles, pero el router entrega al modelo una única definición de la allowlist controlada por ejecución. Los esquemas de function calling son estrictos y las llamadas paralelas están desactivadas para que cada ejecución sea simple de inspeccionar.

`grounded: true` significa que el orquestador verificó una llamada a una herramienta aprobada y recibió `structuredContent` antes de solicitar la respuesta final. No significa que exista una garantía matemática sobre cada token producido por el modelo; esa calidad debe medirse con evaluaciones.

El system prompt exige que los datos empresariales provengan exclusivamente de las herramientas, que los resultados vacíos se informen explícitamente y que el contenido recibido sea tratado como datos, no como instrucciones.

Prueba de integración determinista, sin consumir API:

```bash
npm run agent:smoke
```

Prueba real con Ollama local, MCP y Neon:

```bash
npm run agent:smoke:ollama
```

Prueba real con Groq, MCP y Neon:

```bash
npm run agent:smoke:groq
```

Prueba opcional con OpenAI, MCP y Neon:

```bash
npm run agent:smoke:openai
```

Endpoint:

```http
POST /api/agent/query
Content-Type: application/json

{"question":"¿Qué empleados llegaron tarde durante el último mes?"}
```

La respuesta contiene `answer`, `model`, `grounded`, `toolsUsed` y una secuencia de eventos técnicos sanitizados. No incluye tokens, credenciales ni razonamiento interno.

## Robustez semántica, routing validado y presentación determinista

El LLM recibe las siete capabilities controladas y propone exactamente una decisión. El backend no confía en esa propuesta: valida allowlist, schema Zod, período expresado por el usuario, polaridad y límites de negocio antes de permitir una llamada MCP:

```text
LLM propone → backend valida → MCP ejecuta → PostgreSQL → payload determinista
```

| Capacidad                              | Herramienta                            |
| -------------------------------------- | -------------------------------------- |
| contar empleados                       | `count_employees`                      |
| listar el directorio                   | `list_employees`                       |
| buscar una persona                     | `find_employee`                        |
| resumir demoras históricas             | `summarize_employee_delays`            |
| consultar llegadas tarde por período   | `list_late_arrivals`                   |
| consultar quién no tuvo llegadas tarde | `list_employees_without_late_arrivals` |
| consultar ausencias por período        | `list_absences`                        |

Además de las siete tools MCP, la planificación dispone de dos decisiones internas que nunca llegan a MCP: `request_clarification` y `reject_unsupported_query`. La primera devuelve `agent_clarification_required` cuando falta un período o existe ambigüedad; la segunda devuelve `unsupported_agent_query` cuando el pedido exige una capability, ranking, frecuencia o filtro inexistente. El catálogo público se consulta en `GET /api/agent/capabilities` y también aparece en el System index.

Las expresiones informales se interpretan por significado. Por ejemplo, llegar, entrar, caer, fichar o marcar tarde pueden referirse a `late_arrivals`; “sin tardanzas” y “siempre puntual” se interpretan como cero eventos sólo dentro de un período explícito. Expresiones como “banda”, “una bocha”, “siempre” o “seguido” nunca se convierten en cantidades inventadas.

Después de ejecutar MCP, `AnswerPresentation` valida el `structuredContent` mediante una unión discriminada de Zod. La API devuelve dos superficies separadas:

- `presentation`: `answerPayload` tipado, determinista y renderizado por un componente React específico;
- `answer`: narrativa grounded generada por el LLM, visible en un panel secundario identificado como no determinista.

Las cantidades, tablas, fechas, estados vacíos y metadatos de fuente se muestran desde `presentation`; no se extraen del texto del modelo. La Etapa 11 no modifica este contrato de la Etapa 9. La traza incorpora `llm.semantic_proposal.completed`, `agent.semantic_decision.validated` y `presentation.payload.validated` para separar propuesta, validación y representación determinista.

La consulta negada se implementa como diferencia de conjuntos: empleados activos menos empleados con al menos una llegada tarde dentro del período. PostgreSQL ejecuta esa semántica mediante `NOT EXISTS`; el LLM no calcula el complemento. Si Groq devuelve una respuesta final vacía o intenta una segunda tool call durante la finalización, el adaptador realiza un único reintento textual con los mismos datos grounded. El evento `llm.grounded_response.completed` informa `recovery=not_required`, el tipo de retry aplicado o el fallback a la presentación determinista.

## Resiliencia del proveedor LLM

La Etapa 10 contiene fallos externos mediante cuatro mecanismos explícitos:

| Técnica              | Política de demostración                              | Resultado                                                               |
| -------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
| timeout budget       | 12 segundos por intento                               | aborta una llamada que excede el presupuesto                            |
| bounded retry        | 1 retry transitorio                                   | reintenta `429`, timeout, red o `5xx`; no reintenta errores funcionales |
| circuit breaker      | abre con 3 fallos; prueba half-open a los 30 segundos | evita insistir contra un proveedor que permanece caído                  |
| graceful degradation | sólo después de una consulta MCP correcta             | conserva `presentation` grounded aunque no exista narrativa LLM         |

El endpoint público `GET /api/resilience` expone la política y el estado sanitizado del circuito, nunca credenciales. El agente se reutiliza dentro de cada instancia caliente para que el circuit breaker conserve estado entre requests. En Vercel cada instancia posee su propio circuito; coordinarlo globalmente exigiría un store distribuido y no se justifica para este laboratorio.

Si falla la planificación inicial, todavía no existe una llamada MCP ni datos grounded y la API devuelve un error tipado (`llm_timeout`, `llm_rate_limited`, `llm_provider_unavailable` o `llm_circuit_open`). Si falla únicamente la redacción final, la API responde exitosamente con la tabla determinista y emite `llm.grounded_response.degraded`.

## Autenticación y autorización

Las credenciales se validan contra hashes bcrypt en `app_users`. Al autenticar, el backend crea un token aleatorio, guarda únicamente su hash SHA-256 en `app_sessions` y entrega el token mediante una cookie `HttpOnly`. El frontend nunca accede al token.

La duración se configura con `SESSION_TTL_HOURS=8`.

Permisos de aplicación:

- `viewer`: puede consultar al agente y ver el índice técnico;
- `admin`: incluye las capacidades de consulta, alta de usuarios y borrado controlado de datos operativos.

El borrado administrativo no ejecuta `DROP DATABASE`. Elimina `attendance_records`, `employees` y `departments` dentro de una única transacción; conserva esquema, usuarios, sesiones y `audit_events`. Requiere la confirmación literal `DELETE HR DATA` y registra el resultado en auditoría.

El rol PostgreSQL `app_admin` no tiene `DROP`, `CREATE DATABASE`, superusuario ni membresía `neon_superuser`. Esta separación demuestra que RBAC de aplicación y privilegios de base de datos son capas distintas.

Prueba real de ambos usuarios y del ciclo completo de sesión:

```bash
npm run auth:smoke
```

## Agentes y A2A

El proyecto implementa A2A Protocol 1.0 con el SDK oficial `@a2a-js/sdk`:

- `Agente de RR. HH. con grounding`: consultas de empleados y asistencia mediante MCP;
- `Agente financiero de ausencias`: análisis económico determinista de ausencias.

Agent Cards:

```text
/.well-known/agent-card.json
/.well-known/hr-agent-card.json
/.well-known/finance-agent-card.json
```

Flujo financiero:

```text
Usuario → HR Agent / A2A Client
        → descubre Finance Agent Card
        → JSON-RPC SendMessage
        → Finance Agent Task: submitted → working
        → MCP list_absences → PostgreSQL
        → calculadora determinista
        → A2A Artifact application/json
        → Task completed → reporte + TraceEvent[]
```

Los endpoints A2A usan un bearer token interno aleatorio. La Agent Card describe el esquema de seguridad pero nunca contiene la credencial.

La base no contiene salarios. Por eso el reporte exige parámetros explícitos: moneda, costo diario, prima de reemplazo e impacto de productividad. La fórmula es:

```text
días × costo diario × (1 + prima de reemplazo + impacto de productividad)
```

El cálculo se presenta descompuesto como costo base, cobertura o reemplazo, pérdida de productividad e impacto total. El LLM no realiza la aritmética: una función TypeScript determinista calcula importes redondeados a dos decimales. Si MCP indica que el resultado fue truncado, el agente rechaza el cálculo para evitar un reporte incompleto.

La interfaz empresarial está presentada en español. Conserva sin traducir únicamente identificadores técnicos reales —por ejemplo eventos `TraceEvent`, herramientas MCP, modelos, protocolos y payloads JSON— para que la demostración siga siendo verificable. La vista financiera explica por separado la delegación A2A, la consulta MCP, la fórmula aplicada y el artefacto devuelto. Los escenarios Conservador, Base y Alto impacto cargan hipótesis didácticas visibles; la interfaz aclara que no provienen de Neon, MCP, Groq, el LLM ni un benchmark sectorial y permite reemplazarlas por valores propios.

La implementación utiliza tareas A2A en memoria porque el flujo es breve y síncrono. Para múltiples instancias o tareas largas, el `TaskStore` deberá migrarse a almacenamiento persistente.

Prueba real de Agent Card, A2A, MCP y Neon:

```bash
npm run a2a:smoke
```

## Evaluaciones del agente

Los tests unitarios validan funciones y contratos con dependencias controladas. La suite de evaluaciones mide el comportamiento completo del agente real con el modelo configurado, MCP y PostgreSQL.

Casos implementados:

- `employee-count`: comprueba que una pregunta de cantidad se enruta exclusivamente a `count_employees`;
- `employee-directory`: comprueba que una solicitud de nombres se enruta a `list_employees` y recupera el directorio;
- `employee-delay-summary`: comprueba la agregación determinista de demoras de Bruno Silva mediante `summarize_employee_delays`;
- `employees-without-late-arrivals`: comprueba routing de negación, diferencia de conjuntos y el resultado esperado `EMP-003`;
- `known-late-arrivals`: compara herramienta, grounding y cantidad contra el dataset sembrado;
- `unknown-employee`: exige resultado PostgreSQL vacío y una respuesta explícita sin datos inventados;
- `source-of-truth-freshness`: inserta un empleado temporal único y una llegada tarde, consulta el registro recién creado y comprueba que el agente observa la actualización.
- `finalization-failure-degradation`: inyecta un fallo controlado después de MCP y comprueba que el payload PostgreSQL continúa disponible.
- `semantic-robustness-v1`: ejecuta 80 formulaciones neutrales, formales, informales, rioplatenses y de frontera; mide intención, decisión, argumentos, temporalidad, ambigüedad y estabilidad.

La fixture dinámica usa el rol administrativo sólo durante la preparación y limpieza. La consulta del agente continúa usando el rol read-only. Un bloque `finally` elimina por UUID y número de empleado exactos; al finalizar, una consulta adicional exige que no existan empleados `EVAL-%` ni asistencias con fuente `agent-evaluation`.

Ejecución real con el proveedor LLM configurado, MCP y Neon:

```bash
npm run evals:run
npm run resilience:eval
npm run semantic:eval
npm run semantic:stability
```

`semantic:eval` recorre una vez los 80 casos y `semantic:stability` repite cinco veces el conjunto crítico. Ambos informan `validDecisionRate`, `intentRecognitionRate`, `toolSelectionRate`, `argumentExtractionRate`, `temporalInterpretationRate`, `exactOutcomeRate`, `stabilityRate`, `ambiguityPassRate` y `unsupportedPassRate`. Por defecto esperan 30 segundos entre llamadas para respetar el presupuesto gratuito de tokens de Groq y separar límites del proveedor de inestabilidad semántica. El baseline Stage 10 se conserva en `backend/evals/baselines/` y los resultados Stage 11 en `backend/evals/results/`.

Los demás comandos devuelven JSON reproducible con `passRate`, duración, checks esperados/reales y evidencia grounded por caso. Finalizan con código distinto de cero si falla una evaluación o si queda alguna fixture temporal. El caso de referencia presupone que el seed de demostración está presente.

## Explorador de datos y CRUD de demostración

La Etapa 12 incorpora una superficie controlada para que una empresa pruebe cómo los cambios operativos afectan las consultas del agente. No es un administrador genérico de PostgreSQL: expone únicamente `departments`, `employees` y `attendance_records` mediante endpoints tipados.

- `viewer` puede consultar el snapshot operativo, pero recibe `403` ante cualquier escritura;
- `admin` puede crear, editar y eliminar registros;
- cada payload se valida con Zod y cada valor llega a PostgreSQL como parámetro SQL;
- las escrituras usan la conexión administrativa dentro de una transacción y generan un `audit_event`;
- claves foráneas y unicidad evitan datos huérfanos o duplicados;
- `app_users`, `app_sessions` y `audit_events` no se entregan al navegador;
- no existe editor de SQL libre.

Las lecturas se ejecutan con `DATABASE_READONLY_URL`; las mutaciones usan `DATABASE_ADMIN_URL`. El listado de asistencia está acotado a los 200 registros más recientes para mantener una respuesta previsible en la demostración.

## Deployment cloud

El repositorio conserva `frontend/` y `backend/` separados, con dos superficies de despliegue:

- `agent-lab-ignac`: frontend Vite como Render Static Site;
- `agent-lab-api-ignac`: backend Express como una Vercel Function con Fluid Compute.

URLs de producción:

- aplicación: `https://agent-lab-ignac.onrender.com`;
- API: `https://agent-lab-api-ignac.vercel.app`;
- health check directo: `https://agent-lab-api-ignac.vercel.app/api/health`.

`render.yaml` sólo administra el frontend y reescribe `/api/*` hacia `https://agent-lab-api-ignac.vercel.app`. Para el navegador, autenticación y cookies continúan bajo el origen del frontend; el token de sesión permanece `HttpOnly` y no se expone a React.

`backend/vercel.json` declara Express, un máximo de 300 segundos y la región `gru1` (São Paulo), cercana a la base Neon. Vercel detecta el handler lazy exportado por `src/app.ts`; la aplicación y sus pools se inicializan al recibir la primera request de una instancia. `src/server.ts` conserva `app.listen()` para desarrollo local.

El transporte MCP se selecciona mediante `MCP_TRANSPORT`:

- `stdio`: desarrollo local; el cliente inicia un proceso MCP independiente;
- `in_process`: Vercel; cliente y servidor MCP se conectan con un par de transportes en memoria, sin perder el protocolo, contratos, validación ni tool discovery.

En desarrollo local, `LLM_PROVIDER=ollama` conserva `qwen3:8b`. En Vercel, `LLM_PROVIDER=groq` usa `openai/gpt-oss-20b`, que soporta function calling. El adaptador Groq fuerza al menos una herramienta y devuelve su resultado al modelo para producir la respuesta grounded.

Variables de producción requeridas en el proyecto Vercel:

```text
NODE_ENV=production
FRONTEND_ORIGIN=https://agent-lab-ignac.onrender.com
APP_TIMEZONE=America/Argentina/Buenos_Aires
SESSION_TTL_HOURS=8
PUBLIC_BASE_URL=https://agent-lab-api-ignac.vercel.app
MCP_TRANSPORT=in_process
LLM_PROVIDER=groq
GROQ_MODEL=openai/gpt-oss-20b
GROQ_API_KEY=<secret>
LLM_TIMEOUT_MS=12000
LLM_TRANSIENT_RETRIES=1
LLM_CIRCUIT_FAILURE_THRESHOLD=3
LLM_CIRCUIT_RESET_MS=30000
DATABASE_READONLY_URL=<secret>
DATABASE_ADMIN_URL=<secret>
A2A_INTERNAL_TOKEN=<secret-aleatorio-de-32-o-mas-caracteres>
```

El backend público añade headers con Helmet, rate limits, manejo explícito de errores y `GET /api/health`. Los límites en memoria son demostrativos y operan por instancia caliente; una aplicación productiva distribuida usaría un store compartido. PostgreSQL conserva usuarios, sesiones y datos, por lo que el filesystem serverless permanece descartable.

## CI/CD y quality gates

Cada `push` a `main` y cada pull request ejecutan `.github/workflows/ci.yml`. Backend y frontend se validan en jobs independientes y reproducibles sobre Node.js 22:

```text
checkout → npm ci → typecheck → build → tests → audit de dependencias productivas
```

`npm ci` instala exactamente el árbol fijado por cada `package-lock.json`. Los jobs sólo poseen permiso de lectura del repositorio, tienen timeout y cancelan ejecuciones anteriores de la misma rama. Ninguna credencial de producción se entrega al workflow de CI.

Vercel está conectado al repositorio con `backend/` como Root Directory; un commit aceptado en `main` genera el despliegue serverless. Render mantiene el frontend estático desde `frontend/`. Esta separación distingue dos controles:

- **quality gate previo al runtime:** tipos, compilación, tests y auditoría;
- **smoke test posterior al deployment:** contrato HTTP público realmente desplegado.

`.github/workflows/production-smoke.yml` escucha estados exitosos de deployment y también permite ejecución manual. `scripts/production-smoke.mjs` comprueba:

- health directo del backend Vercel;
- contrato de `/api/system` y etapa vigente;
- contrato público de `/api/resilience`;
- proxy `/api/*` servido bajo el origen Render;
- disponibilidad del documento HTML del frontend.

Ejecución local del mismo contrato de producción:

```bash
node scripts/production-smoke.mjs
```