Metadata-Version: 2.5
Name: sustrato
Version: 0.1.0
Summary: Agentes de código como funcionalidad embebida de tu aplicación: una tabla, un job, cero recursos nuevos.
Project-URL: Homepage, https://github.com/alexlqi/sustrato
Project-URL: Repository, https://github.com/alexlqi/sustrato
Project-URL: Issues, https://github.com/alexlqi/sustrato/issues
Project-URL: Changelog, https://github.com/alexlqi/sustrato/blob/master/CHANGELOG.md
Author-email: Alejandro Andrade <alexlqi@gmail.com>
License-Expression: CC-BY-ND-4.0
License-File: LICENSE
License-File: NOTICE
Keywords: agents,ai-agents,automation,durable-execution,fastapi,idempotency,llm,postgres
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Requires-Dist: pyyaml>=6.0
Provides-Extra: bedrock
Requires-Dist: boto3>=1.34; extra == 'bedrock'
Provides-Extra: celery
Requires-Dist: celery>=5.3; extra == 'celery'
Provides-Extra: dev
Requires-Dist: fastapi>=0.110; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
Provides-Extra: otel
Requires-Dist: opentelemetry-api>=1.24; extra == 'otel'
Provides-Extra: postgres
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
Description-Content-Type: text/markdown

# sustrato

**Agentes de código dentro de tu aplicación.** Una tabla, un job y un archivo YAML. Sin MCP, sin orquestador, sin infraestructura nueva.

Hoy, para que un agente toque tu app, alguien construye toda la tubería: servidor MCP, auth, tenants, idempotencia, webhooks, timers, auditoría. `sustrato` parte de que tu app ya resuelve todo eso para sus usuarios humanos, e invierte la relación: **tu app es el sustrato y el agente es un cliente**.

- **El agente es un usuario.** Opera tu app por su propia API HTTP, con la misma sesión y los mismos permisos que tu interfaz. Nunca tiene más poder que un usuario.
- **El compromiso es la primitiva.** El agente deja *"cuando llegue el pago, envía el recibo"* y se va. Tu app lo guarda como una fila y lo cumple.
- **La seguridad no se configura.** Decides qué rutas expones y, por cada mutación, cómo se deshace o si pide confirmación. Todo lo demás está encendido y no tiene interruptor.
- **La cognición se verifica.** Un compilador demuestra, por análisis estático, qué handlers necesitan LLM y que ningún texto de terceros llega a un efecto.

```
┌──────────────────────┐        ┌───────────────────────┐
│ Agente (code mode)   │        │ Interfaz de tu app     │
│ SDK tipado generado  │        │ humanos                │
└──────────┬───────────┘        └───────────┬───────────┘
           │ misma API, misma sesión        │
┌──────────▼────────────────────────────────▼───────────┐
│ Tu aplicación: un ciclo de vida, un despliegue         │
│  API HTTP ──► agent_commitments ──► tick (tu worker)   │
│  (rutas que ya existen)  (una tabla)  (SKIP LOCKED)    │
└──────────────────────────────────────┬────────────────┘
                                       │ solo compromisos cognitivos
                              ┌────────▼────────┐
                              │ LLM (puerto)    │ vLLM · Bedrock · Azure OpenAI · Anthropic
                              └─────────────────┘
```

## Instalación: un comando

```bash
pip install "sustrato[fastapi,postgres]"
sustrato init            # desde la raíz de tu proyecto
```

`sustrato init` detecta tu app FastAPI (o usa `openapi.json`), escribe `sustrato.yaml` con todo en `expose: false`, compila `sustrato.lock` y **instala `AGINCODE.md`** en la raíz: las instrucciones precisas para tu agente de código (reglas, flujo de trabajo, errores del compilador y el estado actual de tu contrato). Si el proyecto ya tiene `CLAUDE.md`, `AGENTS.md` o `.github/copilot-instructions.md`, les añade una línea que apunta a `AGINCODE.md`; no crea los que no existen.

- **Paquete aún no publicado:** mientras `sustrato` no esté en un índice, construye el wheel y úsalo como dependencia local o en tu `Dockerfile`: `pip wheel /ruta/a/sustrato --no-deps -w vendor/` y `pip install vendor/sustrato-*.whl`.
- `sustrato compile` y `sustrato discover` mantienen `AGINCODE.md` al día; `sustrato agent-docs --check` lo verifica en CI.
- Lo que escribas fuera de los marcadores `<!-- sustrato:begin agincode … -->` se conserva.
- Con `--store postgresql://…` también crea la tabla. Una instalación de pip no puede ejecutar código al instalarse (por diseño), así que el paso es este comando explícito, sin preguntas.

## Dónde encaja en el ciclo de vida de tu app

Una app Python + React + Docker ya tiene cinco momentos. `sustrato` agrega una pieza a cada uno y **ningún servicio nuevo**: es tu misma imagen, tu misma base y tu misma API.

| Momento | Qué ya tienes | Qué agrega sustrato | Comando |
| --- | --- | --- | --- |
| **Diseño** | rutas FastAPI, OpenAPI | `sustrato.yaml`: qué se expone y cómo se deshace | `sustrato discover` |
| **Build / CI** | lint, tests, imagen | contrato verificado, `sustrato.lock`, SDK sin deriva | `sustrato compile --check`, `sustrato sdk --check` |
| **Release** | migraciones, deploy | una tabla aditiva (`CREATE … IF NOT EXISTS`) | `sustrato migrate` |
| **Runtime** | contenedores `api` y `web` | un contenedor `worker` de la misma imagen + `mount()` en `api` | `sustrato worker` |
| **Operación** | logs, healthchecks, runbook | `/agent/health`, ledger, pausa sin deploy | `sustrato health`, `SUSTRATO_PAUSED=1` |

La interfaz React es el lado humano del mismo contrato: muestra las confirmaciones (`confirm: true`) y los compromisos en vivo con el SDK TypeScript generado.

### 1. Diseño: instalar y descubrir

```bash
pip install "sustrato[fastapi,postgres]"
sustrato init --app miapp.main:app --store postgresql://…   # discover + migrate + compile en un comando
```

`discover` lee tu router (o cualquier OpenAPI 3: Django, Express, Rails, NestJS…) y escribe `sustrato.yaml` con todo en `expose: false`.

```bash
sustrato discover --app miapp.main:app
sustrato discover --openapi openapi.json
```

Crea el motor **una sola vez, en un módulo que no importa la app** (p. ej. `miapp/agent.py`); lo importan la API y el worker. Así no hay import circular, ni siquiera con una app-factory:

```python
# miapp/agent.py
from sustrato import Executor, Lock, Manifest, Sustrato, open_store

engine = Sustrato(
    Manifest.load("sustrato.yaml"),
    open_store(DATABASE_URL),
    Executor(base_url=API_URL, identity=lambda p: {"Authorization": f"Bearer {token_for(p.user_id)}"}),
    lock=Lock.load("sustrato.lock"),
)   # registra solo los archivos de `handlers:` del YAML (con `engine` ya definido en ellos)
```

```python
# en tu app o factory
from miapp.agent import engine
from sustrato.fastapi import mount
mount(app, engine, principal=current_user_from_request)   # sin rutas expuestas es un no-op
```

`Executor(base_url=…)` hace que el worker opere tu app por red, como un usuario; `Executor(app=app, …)` (en proceso) solo si worker y app comparten proceso y la app no es una factory.

Enciende una ruta en el YAML, corre `sustrato compile`, y un agente ya puede operarla. Tus eventos de dominio entran con `engine.emit(...)` donde tu código ya sabe que algo ocurrió.

### 2. Build / CI: contrato y SDK sin deriva

```bash
sustrato compile --check                                   # falla con errores o si sustrato.lock está desactualizado
sustrato sdk --lang ts    --out web/src/api/sdk.ts    --check
sustrato sdk --lang react --out web/src/api/agent.ts --sdk-import ./sdk --check
sustrato sdk --lang py    --out agente/miapp_sdk.py   --check   # si tienes agentes en Python
```

**El contrato se crea en el build, no se porta de otra máquina:** `sustrato build` (en el `Dockerfile` o el CI) descubre las rutas nuevas, compila y escribe `sustrato.lock`. La base es lo commiteado: el `sustrato.yaml` del commit manda y el `sustrato.lock` del commit es la versión anterior del nuevo (compatibilidad N-1). El build es determinista: imprime `sustrato.version=<hash>` y `sustrato build --expect <hash>` en producción garantiza que es el mismo contrato que validó el CI.

```dockerfile
COPY sustrato.yaml sustrato.lock ./          # la base commiteada
RUN sustrato build --app miapp.main:app      # o: --expect $CONTRACT_VERSION en el build de producción
```

`sustrato.lock` y los SDK generados se versionan en el repo; `--check` hace fallar el PR cuando alguien cambia el manifiesto y olvida regenerarlos, así el frontend y el backend nunca divergen. Haz la verificación en una etapa del `Dockerfile` o en el CI, antes de construir la imagen final:

```dockerfile
FROM python:3.12-slim AS verify
WORKDIR /app
COPY . .
RUN pip install -e ".[fastapi,postgres]" && sustrato compile --check

FROM verify AS runtime
CMD ["uvicorn", "miapp.main:app", "--host", "0.0.0.0"]
```

### 3. Release: la tabla, antes de arrancar las réplicas

La migración es aditiva e idempotente (no toca tus tablas ni instala triggers). Córrela donde ya corres tus migraciones: un paso de release o un servicio de una sola ejecución que `api` y `worker` esperan.

```bash
sustrato migrate --store "$DATABASE_URL" --ledger      # o: sustrato migrate --print postgres  (para tu DBA)
```

### 4. Runtime: `api`, `worker` y `web` en un mismo `compose`

```yaml
services:
  migrate:
    build: .
    command: sustrato migrate --store ${DATABASE_URL} --ledger
    depends_on: { db: { condition: service_healthy } }
  api:
    build: .
    command: uvicorn miapp.main:app --host 0.0.0.0
    environment: &env
      DATABASE_URL: ${DATABASE_URL}
      SUSTRATO_TICK_TOKEN: ${SUSTRATO_TICK_TOKEN}
    depends_on: { migrate: { condition: service_completed_successfully } }
  worker:                                   # la misma imagen: el job de sustrato
    build: .
    command: sustrato worker --engine miapp.agent:engine --heartbeat /tmp/sustrato.hb
    environment: *env
    healthcheck:
      test: ["CMD", "sustrato", "worker-health", "--heartbeat", "/tmp/sustrato.hb", "--max-age", "60"]
      interval: 30s
    stop_grace_period: 30s                  # SIGTERM: termina el tick en curso y sale
    depends_on: { migrate: { condition: service_completed_successfully } }
  web:                                      # React: ningún cambio de despliegue
    build: ./web
```

- **`worker`** corre `tick` en bucle: si reclamó trabajo vuelve a correr de inmediato (drena el backlog); si no, espera `--interval` (5 s). Un error de base o de red no lo mata: reintenta con backoff. Es seguro con N réplicas (`SKIP LOCKED`, sin líder), así que escalar es `docker compose up --scale worker=3`.
- Alternativas al contenedor `worker`, con el mismo `engine`: Celery (`sustrato.adapters.celery.register`), cron contra `POST /agent/tick`, Lambda o Azure Functions (ver "Producción").
- **Rolling deploys.** Durante un despliegue conviven la versión vieja y la nueva de `api`/`worker`; cada compromiso corre con el manifiesto con que nació, y los de una versión no desplegada quedan en `held`.

**La interfaz React.** El SDK TypeScript lleva el cliente del agente (`confirmations`, `decide`, `commitments`, `events`), y `--lang react` genera los hooks encima. El cliente usa la **sesión del usuario** que ya tiene tu SPA:

```tsx
import { App } from "./api/sdk";
import { useConfirmations } from "./api/agent";

const app = new App("/", { Authorization: `Bearer ${token}` });   // tu auth de siempre

function Bandeja() {
  const { items, approve, reject } = useConfirmations(app);       // se actualiza por SSE (/agent/events)
  return items.map((c) => (
    <li key={c.id}>
      {c.entity.type} {c.entity.id} · {c.motive}
      <button onClick={() => approve(c.id)}>Aprobar</button>
      <button onClick={() => reject(c.id)}>Rechazar</button>
    </li>
  ));
}
```

Si tu reverse proxy bufferiza respuestas, desactiva el buffering en `/agent/events` (`proxy_buffering off` en nginx).

### 5. Operación

```bash
sustrato health --url https://app/agent --token "$SUSTRATO_TICK_TOKEN"   # backlog, lag, circuito del LLM
sustrato ledger --store "$DATABASE_URL"                                  # verifica la cadena de hashes
SUSTRATO_PAUSED=1                                                        # pausa sin deploy: nada se pierde ni se ejecuta
```

Ver también "Producción en AWS y Azure" para el checklist de salida a producción.

## `sustrato.yaml`

Decides dos cosas, y son decisiones de producto que tomarías de todos modos al diseñar tu API: **qué se expone** y, **por cada mutación expuesta**, una de tres:

```yaml
profile: prod
handlers: [handlers.py]
events:
  MensajeInquilino:
    trusted: [contrato_id]          # el resto de los campos llega como Untrusted
routes:
  GET /contratos/{id}/saldo:
    expose: true
    batch: GET /contratos/saldos    # lectura en lote para cohortes
  POST /contratos/{id}/recordatorio:
    expose: true
    idempotent: true                # repetirla es inofensivo
  POST /contratos/{id}/cobrar:
    expose: true
    undo: POST /cargos/{cargo_id}/reversar
    settle:
      timeout: 2h
      verify: GET /cargos/por-clave
  POST /contratos/{id}/cobro-juridico:
    expose: true
    confirm: true                   # pasa por un humano en tu UI
```

- `GET` es lectura; cualquier otro método es efecto.
- Una mutación expuesta sin `undo`, `confirm` ni `idempotent` **no compila**.
- `undo` recibe los parámetros del paso original más los campos de su respuesta (aquí, `cargo_id`).
- `settle.verify` recibe los mismos parámetros y `?idempotency_key=…`: 200 = ocurrió, 404 = no ocurrió.

Lo demás (cognición de cada handler, versión) lo calcula `sustrato compile` y lo escribe en `sustrato.lock`. No lo editas a mano.

## Operar tu app desde un agente (sin MCP)

`sustrato sdk` genera un SDK autónomo y tipado (Python con `httpx`, TypeScript con `fetch`):

```bash
sustrato sdk --lang py --out arriendos_sdk.py
sustrato sdk --lang ts --out arriendos-sdk.ts
```

```python
from arriendos_sdk import App

app = App("https://arriendos.example.com", headers={"X-Api-Key": "…"})
c = app.contrato(123)
if c.saldo()["saldo"] > 0:            # GET /contratos/123/saldo
    c.recordatorio()                   # POST /contratos/123/recordatorio
```

Desde Claude Code, Codex o un script: instala el SDK, usa una credencial de usuario y escribe código. Un humano puede usar el mismo SDK; no es una interfaz para IA, es tu app operable por programa. `sustrato check-script agente.py` verifica que un script solo llame rutas expuestas.

Si un anfitrión solo habla MCP: `sustrato mcp --url https://… --header "X-Api-Key: …"` expone el manifiesto como servidor MCP stdio, generado y sin lógica propia.

## Compromisos

```python
# cuando llegue el pago del contrato 123, envía el recibo
app.on(c, "PagoRecibido", do=c.recibo)

# el día 28, entre 9:00 y 12:00, si hay saldo: recordatorio; se cancela solo si llega el pago
app.schedule(c, "dia_28", c.recordatorio, cron="0 9 28 * *", window=3 * 3600,
             when=c.saldo.where(">", 0, "saldo"), cancel_on=["PagoRecibido"])

# reprogramar = mismo motivo, nueva fecha (upsert, nunca duplica)
app.schedule(c, "dia_5", c.recordatorio, at="2026-12-07T09:00:00Z")
app.cancel(c, "dia_5")
```

Con esa primitiva tienes las tres formas de ejecución, sin nada nuevo:

| Forma | Cómo | Con qué |
| --- | --- | --- |
| Secuencial | Un plan es una lista de pasos en una fila; cada paso con `settle` y, si lo declara, `undo` | Tu worker |
| Paralela | N entidades, N compromisos; nunca dos compromisos de la misma entidad a la vez | Tu pool de workers + `SKIP LOCKED` |
| Larga duración | Una fila con `due_at`, o esperando un evento de dominio | Tu base de datos |

Lo que no implementas:

- Un compromiso por (tenant, entidad, motivo). Reprogramar es upsert; si el compromiso tiene pasos en vuelo, reprogramarlo responde `409` (cancélalo o espera a que termine) para no perder su historia de `undo`.
- `cancel_on` se aplica en la misma transacción que el evento. En Postgres, `changed = engine.emit(..., conn=tu_conexion)` la une a la transacción de tu app; llama a `engine.flush(changed)` después de tu `commit` para publicar las transiciones.
- Un evento re-entregado con el mismo `event_id` no vuelve a ejecutar un handler que ya terminó.
- Una corrida fallida o rechazada de un compromiso recurrente se compensa y el compromiso sigue con su siguiente corrida.
- Dispersión determinista: `due_at + hash(partición) mod window`.
- Versión fijada: cada compromiso recuerda el manifiesto con que nació.
- Planes: los pasos sin `undo` siempre van al final; si un paso falla, los anteriores con `undo` se revierten en orden inverso; si un `undo` falla, el compromiso queda en `requires_attention`.
- Resultado desconocido (5xx, red): se verifica con `settle.verify` o se reintenta con la **misma** clave de idempotencia. Una respuesta `202` espera un callback firmado en `POST /agent/callback/{id}`.
- Ledger: cada transición encadenada con hash (`prev_hash`), en tu log estructurado y opcionalmente en `agent_ledger` (`sustrato migrate --ledger`, `sustrato ledger --store …`).

Tus eventos de dominio entran con `engine.emit("PagoRecibido", tenant_id, Entity("contrato", id), data)`, donde tu app ya sabe que ocurrió algo.

## Cognición dentro de la app

La mayoría de los compromisos son deterministas: tu app los ejecuta sola y el LLM no aparece. Un handler **cognitivo** interpreta algo (un mensaje en lenguaje natural) y es un job más de tu app: llama al proveedor de LLM con un menú que tú escribiste, recibe una elección tipada y sigue con tu código.

```python
from datetime import date
from sustrato import Intent, Untrusted

class IntentInquilino(Intent):
    class PromesaPago:
        fecha: date
    class Disputa:
        motivo: Untrusted[str]
    class Otro:
        pass

@engine.on_event("MensajeInquilino", intents=IntentInquilino,
                 routes=["POST /contratos/{id}/tickets", "POST /contratos/{id}/recordatorio"],
                 run_as=lambda tenant: f"bot:{tenant}")
def respuesta(ev, intent, ctx):
    match intent:
        case IntentInquilino.PromesaPago(fecha):
            ctx.schedule("dia_5", "POST /contratos/{id}/recordatorio", at=fecha, cancel_on=["PagoRecibido"])
        case IntentInquilino.Disputa(motivo):
            ctx.note(motivo)                      # el texto se guarda; nunca llega a un efecto
            ctx.do("POST /contratos/{id}/tickets")
        case IntentInquilino.Otro():
            ctx.note("sin acción")
```

- **El LLM nunca ejecuta.** Elige una variante del menú y llena campos tipados; tu handler decide.
- **Lista blanca por handler.** `routes=[…]` es obligatoria en un handler cognitivo; cualquier otra ruta se rechaza.
- **`Untrusted`.** Los campos de eventos no declarados como `trusted` (incluidos números, que llegan como texto) y los campos de texto libre de un intent (`str` o `Untrusted[str]`) pueden guardarse (`ctx.note`) o mostrarse, pero no llegar a `ctx.do`, a una condición, a un ciclo ni a `ctx.schedule`. El compilador lo verifica (fuente primaria); en tiempo de ejecución se vuelve a verificar y las operaciones comunes de texto conservan la marca (defensa en profundidad).
- Los campos tipados (`date`, `int`, `bool`…) del intent sí pueden llegar a efectos: es la elección del menú.
- **En el peor caso**, una inyección elige otra opción del menú que tú escribiste, para una entidad, con efectos reversibles o confirmados.
- `run_as` es el usuario de tu app con cuya identidad corren los efectos (un usuario de servicio con los permisos que tú le des).

Adaptadores LLM (todos aceptan `base_url` y respetan `HTTPS_PROXY`):

| Adaptador | Para |
| --- | --- |
| `OpenAICompatibleLLM` | vLLM, Ollama, LM Studio, OpenAI; `azure=True` para Azure OpenAI (private endpoint) |
| `AnthropicLLM` | Anthropic o un gateway compatible |
| `BedrockLLM` | Amazon Bedrock (Converse API; `endpoint_url` para PrivateLink) |
| `FakeLLM` | pruebas |

Los adaptadores reintentan solos los fallos transitorios (red, 408/429/5xx) con jitter y `Retry-After` (`retries=2`). Si el proveedor sigue caído, un circuit breaker (`CircuitBreaker`, configurable con `Sustrato(llm_breaker=…)`) deja de llamarlo y difiere los compromisos cognitivos sin gastar sus intentos; `GET /agent/health` lo reporta en `llm_circuit_open`. Los deterministas siguen.

## Seguridad: lo que decides y lo que no

| Garantía | Qué significa |
| --- | --- |
| El agente es un usuario | Actúa con la identidad de un usuario de tu app (puerto `identity`). No hay "usuario agente" |
| Lista blanca | Nada se expone hasta que lo enciendes |
| Idempotencia | Clave derivada por paso; el middleware guarda la respuesta de toda mutación expuesta llamada por un agente (`X-Sustrato: 1` + `Idempotency-Key`); los 5xx no se guardan |
| `settle` siempre | Toda mutación tiene timeout y verificación |
| Menú cerrado | El LLM elige entre intenciones que tú escribiste |
| `Untrusted` | Texto de terceros nunca llega a un efecto |
| Presupuestos | Compromisos ejecutados por usuario por minuto/hora (incluidos recurrentes), por entidad por día y por tenant por tick; lo que excede se **difiere**, no falla. Un paso ya despachado siempre se verifica, sin importar el presupuesto |
| Callbacks firmados | HMAC por tenant, con rotación; salientes solo a `allowed_hosts` |
| Ledger | Transiciones encadenadas con hash |

**Un solo interruptor:** `profile: dev | prod`. En `dev`, las mutaciones se registran pero no se ejecutan (ensayo) y los presupuestos son laxos. En `prod` hay una sola postura.

## El compilador

```bash
sustrato compile            # verifica y escribe sustrato.lock
sustrato compile --check    # en CI: falla con errores o si el lock está desactualizado
```

**Cognición** por análisis de taint sobre el AST: `intent` es la fuente; argumentos de `ctx.do`/`ctx.schedule`/`ctx.cancel`, condiciones y retorno son sumideros fuertes; `ctx.note` es débil. Un efecto dentro de un bloque controlado por `intent` hereda el taint.

| Inferido | Cuándo |
| --- | --- |
| `none` | `intent` no aparece |
| `optional` | `intent` solo llega a `ctx.note`, o con alternativa determinista (`intent.fecha or ev.fecha`) |
| `required` | `intent` decide un efecto, una condición o un `schedule` |

```
E031 handlers.py:24  handler respuesta declara cognition=none pero el intent alcanza efectos → inferido: required
E044 handlers.py:31  un valor Untrusted alcanza ctx.do(body=…)
E050 sustrato.yaml   POST /contratos/{id}/cerrar está expuesta sin undo, confirm ni idempotent
E061 sustrato.yaml   GET /contratos/{id}/resumen está en sustrato.lock y ya no está expuesta; márcala deprecated…
```

| Código | Regla |
| --- | --- |
| E001 | manifiesto o handler inválido |
| E031 / W012 | cognición declarada menor / mayor que la inferida |
| E044 | `Untrusted` alcanza un sumidero fuerte |
| E050 | mutación expuesta sin `undo`, `confirm` ni `idempotent` |
| E051 / E052 / E053 | `undo`, `settle.verify` o `batch` apuntan a una ruta inválida |
| E061 | ruta del lock removida sin pasar por `deprecated` (compatibilidad N-1) |
| E062 / E065 | archivo de handlers inexistente / handler duplicado |
| E063 / E064 | handler cognitivo sin `routes=[…]` / con rutas no expuestas |
| E070 | motivo de `schedule`/`cancel` no literal |
| E071 | esperas, I/O, `eval`, reflexión sobre evento/intent/ctx, `ctx` pasado a otra función o definiciones anidadas |
| E072 | `match` sobre `intent` no exhaustivo o variante desconocida |
| E073 | ruta no expuesta o fuera de la lista blanca |
| E074 | un paso sin `undo` antes de uno con `undo` |

Los handlers viven en un subconjunto del lenguaje: sin reflexión ni despacho dinámico sobre `intent` o valores `Untrusted`, y `ctx` no sale del handler. El análisis sigue alias (`e = ev`), escrituras en contenedores (`b["x"] = …`, `l.append(…)`) y une las ramas de `if`/`match`/`try`/ciclos. Lo que no analiza son funciones auxiliares: un helper puede transformar datos, pero no producir efectos, porque no recibe `ctx`.

## Puertos y adaptadores

| Puerto | On-premise | AWS | Azure |
| --- | --- | --- | --- |
| Store | `PostgresStore`, `SQLiteStore` | RDS / Aurora Postgres | Azure Database for PostgreSQL |
| Worker | Celery, cron → `POST /agent/tick` | `adapters.aws_lambda` + EventBridge | `adapters.azure_functions` (Timer Trigger) |
| Events | `engine.emit(...)` en tu código | igual | igual |
| Identity | función `Principal → headers` | igual (o tokens de Cognito) | igual (o Entra ID) |
| LLM | vLLM, Ollama | Bedrock, Anthropic | Azure OpenAI |
| Confirm | `/agent/confirmations` en tu UI | + callable `confirm=` (Slack) | + callable `confirm=` (Teams) |

Para desarrollo y pruebas: `SQLiteStore(":memory:")`, `FakeClock`, `FakeLLM`. Un compromiso de 30 días se prueba en milisegundos.

## Producción en AWS y Azure

- **Instalación aditiva.** Una tabla, un índice. Sin rutas expuestas, el middleware es un no-op; la prueba `tests/test_surface.py::test_mount_is_a_noop_for_non_agent_traffic` está pensada para copiarse a tu CI.
- **`sustrato.lock` y N-1.** Una ruta del lock no se borra de golpe: `deprecated`, despliegue, y se quita en el siguiente ciclo.
- **Réplicas y rolling deploys.** `tick` es seguro con N instancias (`SKIP LOCKED`, sin líder). Cada escritura es optimista (`updated_at` + `prev_hash`) y el lease se renueva tras cada paso asentado: si otro tick toma el compromiso, el anterior se detiene sin más efectos. Conviven dos versiones; cada compromiso corre con la suya.
- **Rollback.** Los compromisos de una versión no desplegada quedan en `held` y se reanudan cuando vuelve.
- **Serverless.** `tick` es acotado y reanudable (N compromisos o M segundos). El adaptador de Lambda usa el tiempo restante de la invocación.
- **Red.** El único tráfico nuevo es al LLM, con `base_url` privado.
- **Observabilidad.** Logs estructurados (`sustrato`, `sustrato.ledger`), spans OpenTelemetry si está instalado, y `GET /agent/health` (backlog, `due_at` más antiguo, tokens por tenant).
- **Pausa sin deploy.** `SUSTRATO_PAUSED=1`: nada se pierde, nada se ejecuta.
- **Degradación por partes.** LLM caído: solo se difieren los cognitivos. Base cargada: `tick` reduce su lote.

Checklist:

- [ ] `sustrato migrate --print postgres` revisado por el DBA
- [ ] `worker` desplegado (o `tick` en Celery/timer gestionado; `SUSTRATO_TICK_TOKEN` si usas `POST /agent/tick`)
- [ ] `sustrato compile --check` y `sustrato sdk --check` en CI; `sustrato.lock` y los SDK generados en el repo
- [ ] `base_url` del LLM a un endpoint privado o modelo local
- [ ] `/agent/health` conectado a una alarma de backlog
- [ ] `SUSTRATO_PAUSED` documentado en el runbook de guardia
- [ ] `profile: prod`

## Ejemplo completo

`examples/arriendos/` es una app FastAPI de cobro de arriendos multitenant con todo lo anterior: rutas con `undo`, `confirm` y `settle`, eventos de pago y de mensajes de WhatsApp, un handler determinista y uno cognitivo.

```bash
uvicorn examples.arriendos.app:app --reload
python -m examples.arriendos.demo --contratos 2000
```

La demo agenda el recordatorio del día 28 para 2,000 contratos con 70 % ya pagados: una lectura en lote omite los 1,400 pagados sin ejecutarlos y el LLM no se llama ni una vez.

## Desarrollo

Para mejorar el paquete mismo (entorno, pruebas, mapa del código, cómo se prueba la instalación): [`DEVELOPING.md`](DEVELOPING.md). La suite (115 pruebas) incluye 8 réplicas de `tick` sobre el mismo Postgres verificando que cada compromiso se ejecuta una sola vez y que ninguna entidad corre en paralelo, y una prueba de regresión por cada hallazgo de la revisión independiente. El CI (`.github/workflows/ci.yml`) corre ruff, mypy, `sustrato compile --check`, `sustrato sdk --check` y la suite contra Postgres 16 en Python 3.11–3.13.

## Estado

Implementado: núcleo Python completo (manifiesto, discover FastAPI/OpenAPI, compromisos, tick, settle, undo, confirmaciones, presupuestos, cohortes, held/rollback, pausa, ledger), stores SQLite y Postgres, superficie FastAPI, compilador, SDK generado Python y TypeScript, servidor MCP generado, worker de larga duración para contenedores, hooks de React, comprobación de deriva del SDK, adaptadores Celery, Lambda y Azure Functions, adaptadores LLM.

Roadmap:

- [ ] Runtime TypeScript (Express/NestJS/Prisma); hoy TypeScript tiene el SDK cliente
- [ ] Stores MySQL y SQL Server (el DDL ya está en `sustrato migrate --print`)
- [ ] Helpers de eventos desde señales del ORM (SQLAlchemy, Django) y CDC
- [ ] Integración Django (middleware y vistas)
- [ ] Carril cognitivo en lote con batch APIs de los proveedores
- [ ] Benchmark público: 10,000 entidades, costo por compromiso

## Licencia

[CC BY-ND 4.0](LICENSE): uso libre (también comercial), sin obras derivadas, con atribución y sin garantía ni responsabilidad del autor. Referencia siempre la **última versión**: enlaza `https://pypi.org/project/sustrato/` sin número de versión y declara la dependencia sin tope (`sustrato` o `sustrato>=X.Y`, nunca `==` ni `<`). Detalle de la atribución y aclaraciones en [NOTICE](NOTICE).

Autor: Alejandro Andrade ([@alexlqi](https://github.com/alexlqi)), autor de [proMCP](https://github.com/alexlqi/proMCP).
