# ¿La gestión de solicitudes con la bóveda bloqueada puede repetir una llamada?

Una bóveda bloqueada debe crear un límite temporal firme. Una solicitud que llega a una puerta de enlace de acciones antes de que se levante ese límite debe fallar en ese momento. No debe quedarse en una cola, sobrevivir a una reconexión ni volverse elegible porque alguien se autentique después en la bóveda.

Parece obvio hasta que pruebas una pila de agentes real. Los agentes reintentan. Los clientes MCP se reconectan. Las bibliotecas HTTP repiten solicitudes después de perder una respuesta. Los workers conservan trabajos. Una interfaz puede mostrar una denegación mientras otro componente ya ha retenido suficiente estado para ejecutar la llamada más tarde. Si solo observas la tarjeta de aprobación o la transcripción del agente, puedes pasar por alto la parte peligrosa.

La prueba descrita aquí responde a una pregunta concreta: ¿la puerta de enlace descartó las llamadas recibidas mientras la bóveda estaba bloqueada o el desbloqueo hizo que alguna de esas llamadas antiguas se ejecutara? Usa un receptor HTTP controlado, dos identificadores de solicitud únicos y pruebas procedentes de ambos lados de la puerta de enlace. Hazla antes de confiar a un agente autónomo cualquier endpoint que pueda modificar dinero, infraestructura, código fuente o datos de clientes.

## El bloqueo de la bóveda debe cortar una acción, no posponerla

La regla esperada es sencilla: cuando la bóveda está bloqueada, la puerta de enlace deniega todas las acciones que la necesitan. Desbloquearla cambia la respuesta para una llamada posterior. No cambia la respuesta para una llamada que ya llegó.

La diferencia importa porque una solicitud tiene un ciclo de vida. Un proceso escribe bytes en stdin. Un shim MCP analiza mensajes JSON-RPC. La puerta de enlace identifica una acción configurada, comprueba si puede usar un secreto, inyecta las credenciales si está permitido, abre una conexión saliente y devuelve un resultado. Un error puede conservar la solicitud en varios puntos de ese recorrido.

Un diseño seguro trata la decisión de bloqueo como definitiva para esa invocación. La puerta de enlace puede registrar la denegación, pero no debe conservar un cierre ejecutable, el cuerpo de una solicitud serializada, un trabajo saliente ni un token de reintento que pueda ejecutarse más tarde con una bóveda recién abierta.

A menudo se confunden dos comportamientos distintos:

- **Un reintento nuevo** es una llamada nueva que el agente envía después de observar un error o un cambio de estado.
- **Una repetición** es la ejecución de la llamada original, retenida por la puerta de enlace o por uno de sus helpers mientras el acceso estaba denegado.

Un reintento nuevo puede ser legítimo, aunque sigue necesitando la autorización normal. Una repetición cruza un límite de seguridad sin una decisión nueva. Si los confundes, puedes dar por superada la prueba al desbloquear la bóveda, ver que una solicitud llega al destino y asumir que procedía de un reintento deliberado.

El Model Context Protocol no resuelve este problema de diseño. Su transporte stdio usa mensajes JSON-RPC delimitados por saltos de línea entre un proceso de servidor iniciado por el cliente y el propio cliente. Las solicitudes JSON-RPC con un `id` reciben una respuesta correlacionada, mientras que las notificaciones no reciben ninguna. Esas reglas del protocolo te dan una correlación útil, pero no definen si una puerta de enlace puede conservar una acción denegada para ejecutarla después. Esa decisión debe quedar explícita en tu puerta de enlace.

## El momento de llegada es anterior a la llamada HTTP saliente

Una solicitud llega cuando la puerta de enlace tiene información suficiente para decidir si debe ejecutarla, no cuando el servidor de destino ve el tráfico. Si la bóveda está bloqueada en ese momento, la denegación debe producirse antes de inyectar credenciales y antes de entregar el trabajo a cualquier componente que pueda sobrevivir a la decisión.

Aquí es donde las pruebas se vuelven imprecisas. Alguien bloquea la bóveda, indica al agente que llame a una API, espera un error, desbloquea la bóveda y comprueba que no aparezca ninguna solicitud de inmediato. Esa prueba no detecta reintentos retrasados, workers bloqueados, grupos de conexiones ni temporizadores de reintento del cliente. Tampoco detecta que la llamada haya llegado al destino antes de que la interfaz mostrara el error.

Usa tres marcas de tiempo registradas desde lugares independientes:

1. `T_lock`: cuando se confirmó que la bóveda estaba bloqueada.
2. `T_attempt`: cuando el agente envió el identificador de la solicitud antigua.
3. `T_unlock`: cuando la bóveda volvió a abrirse.

Después de `T_unlock`, sigue observando el receptor. La espera debe superar todos los reintentos y tiempos de espera configurados en el cliente, la puerta de enlace y cualquier intermediario. Si no conoces esos valores, no elijas un retraso corto que resulte tranquilizador. Averígualos primero o usa un receptor que permanezca disponible el tiempo suficiente para revelar una entrega retrasada.

Una afirmación de aceptación útil es más precisa que «la solicitud bloqueada falló»:

> Para el identificador `locked-...`, el receptor controlado registra cero ejecuciones antes y después de `T_unlock`; para el identificador `fresh-...`, enviado solo después de `T_unlock`, el receptor registra exactamente una ejecución.

La afirmación detecta ambos lados del fallo. Revela una llamada antigua que se ejecuta más tarde y demuestra que la prueba no falló simplemente porque el receptor o la configuración de la acción estuvieran rotos.

No uses el mismo contenido para ambas llamadas. Si las dos indican `deploy=true`, no podrás identificar cuál llegó. Incluye el identificador de la solicitud en la ruta URL, en un campo JSON inofensivo y en un encabezado, si la acción configurada lo permite. La redundancia resulta útil porque revela reescrituras o almacenamiento en caché accidentales.

## Las colas y los reintentos ocultan los fallos de repetición

Los fallos de repetición más peligrosos no suelen ser espectaculares. Normalmente proceden de código habitual de fiabilidad escrito por alguien que supuso que un error de autorización se comporta como un fallo de red transitorio.

Considera una secuencia típica incorrecta. El agente envía una llamada de herramienta MCP mientras la bóveda está bloqueada. El shim acepta el mensaje y crea un elemento de trabajo interno. La comprobación de la bóveda devuelve un error de bloqueo, pero el worker lo clasifica como reintentable porque nunca se llegó al destino. El emisor se desconecta o la sesión termina. Más tarde, el usuario desbloquea la bóveda. El worker se despierta, encuentra una credencial utilizable y envía la solicitud HTTP original.

La interfaz puede parecer correcta durante toda la secuencia. El agente original recibió un error. El usuario vio la bóveda bloqueada. El destino recibió una credencial válida solo después del desbloqueo. Aun así, la puerta de enlace transportó una acción a través de un límite donde debería haber terminado.

Estos son los patrones que conviene buscar:

- Un wrapper genérico de reintentos captura todos los errores salvo la entrada mal formada.
- Una cola de trabajos persistente almacena la intención antes de decidir sobre la bóveda.
- Un future o una promise espera al desbloqueo en lugar de devolver un error definitivo.
- Una ruta de reconexión vuelve a enviar una solicitud en memoria después de que el proceso cliente haya desaparecido.
- Un helper en segundo plano mantiene el estado de reintento de forma independiente de la barrera de la bóveda.

La recomendación popular de «reintentar todas las operaciones de red fallidas» es incorrecta en este límite. Es popular porque los fallos de transporte de red son frecuentes y los reintentos suelen mejorar la entrega. Una bóveda bloqueada no es un fallo de transporte. Es una negativa explícita a usar una autoridad. Clasifícala como definitiva para esa invocación.

Esto también se aplica a la cancelación. La desconexión de un cliente no significa necesariamente que se haya cancelado una solicitud HTTP o SSE. La especificación de transporte MCP indica que una desconexión puede ocurrir en cualquier momento y no debe interpretarse por sí sola como una cancelación; cuando el cliente quiere cancelar, requiere una notificación explícita de cancelación. Ese comportamiento tiene sentido para trabajos largos, pero hace que el estado local de la puerta de enlace sea aún más importante: una llamada denegada no debe seguir siendo ejecutable solo porque el estado del transporte se haya vuelto ambiguo.

## Crea un receptor que haga visible cada ejecución

Un receptor controlado ofrece mejores pruebas que la transcripción de un chat con el agente. Te indica si una llamada saliente llegó realmente, qué identificador llevaba y cuándo llegó. Mantenlo aislado de producción y haz que su único efecto secundario sea un registro local de solo anexado.

Ejecuta este pequeño receptor Python en una máquina y un puerto accesibles para tu puerta de enlace. Acepta solicitudes POST, escribe una línea JSON por cada llegada y devuelve una respuesta correcta inofensiva. Registra deliberadamente solo una marca corta del encabezado de autorización, no la credencial.

```python
# receiver.py
from http.server import BaseHTTPRequestHandler, HTTPServer
from datetime import datetime, timezone
import hashlib
import json

LOG = "receiver-events.jsonl"

class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length).decode("utf-8", errors="replace")
        auth = self.headers.get("Authorization", "")
        auth_marker = hashlib.sha256(auth.encode()).hexdigest()[:12] if auth else None
        event = {
            "received_at": datetime.now(timezone.utc).isoformat(),
            "method": self.command,
            "path": self.path,
            "request_id": self.headers.get("X-Replay-Test-Id"),
            "auth_marker": auth_marker,
            "body": body,
        }
        with open(LOG, "a", encoding="utf-8") as log:
            log.write(json.dumps(event) + "\n")
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(b'{"received":true}')

    def log_message(self, format, *args):
        return

HTTPServer(("127.0.0.1", 8787), Receiver).serve_forever()
```

Inícialo con:

```bash
python3 receiver.py
```

El archivo de salida tendrá un aspecto similar a este:

```json
{"received_at":"2026-07-22T16:42:12.103841+00:00","method":"POST","path":"/replay-test/fresh-8f1c","request_id":"fresh-8f1c","auth_marker":"a4d7e02c1b9f","body":"{\"kind\":\"fresh\"}"}
```

No pongas un secreto activo en el cuerpo de la solicitud. Configura la acción de prueba con una credencial de prueba específica y de privilegios reducidos en la bóveda, y deja que la puerta de enlace la inyecte mediante el canal HTTP habitual. La huella del encabezado del receptor solo demuestra que llegó algún valor de autorización; no escribe el valor en un archivo que podría sobrevivir a la prueba.

Antes de la prueba con la bóveda bloqueada, envía una solicitud normal mientras la bóveda está abierta. Confirma que el receptor la registra y que la ruta del endpoint configurado es correcta. Después, elimina `receiver-events.jsonl` o muévelo a otro lugar. Empezar con un registro vacío evita que una solicitud anterior de configuración contamine el resultado.

## Ejecuta la prueba con dos llamadas deliberadamente distintas

La prueba necesita un identificador antiguo que se intente mientras la bóveda está bloqueada y un identificador nuevo que se cree solo después del desbloqueo. Usa etiquetas que parezcan aleatorias, pero anótalas antes de comenzar. Las etiquetas fáciles de reconocer simplifican la comparación durante la auditoría.

Por ejemplo:

```text
old request ID:   locked-3d4a
fresh request ID: fresh-91ce
```

Prepara una instrucción para el agente o una solicitud de cliente MCP que llame a tu acción HTTP configurada con esta información:

```json
{
  "path": "/replay-test/locked-3d4a",
  "headers": {
    "X-Replay-Test-Id": "locked-3d4a"
  },
  "body": {
    "kind": "locked-period-attempt",
    "request_id": "locked-3d4a"
  }
}
```

El nombre exacto de la herramienta y la forma de sus argumentos dependen de la interfaz de acción que hayas configurado. No simules una prueba aprobada llamando directamente al receptor con un comando de shell. La solicitud debe recorrer la misma ruta de agente, shim MCP, puerta de enlace, bóveda y acción HTTP en la que piensas confiar.

Ahora ejecuta la secuencia sin improvisar:

1. Confirma que el registro del receptor está vacío y que la bóveda está bloqueada.
2. Inicia un proceso de agente nuevo y envía la llamada `locked-3d4a`.
3. Captura el error del lado del agente y la hora. No vuelvas a enviar la solicitud.
4. Mantén el proceso del agente activo durante un breve periodo de observación y después termínalo. Así detectarás tanto los reintentos inmediatos como los vinculados al proceso.
5. Abre la bóveda y espera durante todo el periodo de observación. Inspecciona el registro del receptor varias veces. `locked-3d4a` debe seguir ausente.
6. Solo después de esa espera, inicia un proceso de agente nuevo y envía `fresh-91ce`. El receptor debe registrar ese identificador una vez.

Mantén las llamadas antigua y nueva en procesos de agente separados. De lo contrario, una aprobación de sesión o una caché interna del cliente puede confundir el resultado. Estás comprobando si una acción antigua puede cruzar el límite de bloqueo, no si un único proceso recuerda algún estado de autorización.

Si la llamada bloqueada genera una solicitud de aprobación después del desbloqueo sin que el agente envíe una llamada nueva, detente. Es una prueba de que la intención se conservó. Si el receptor recibe `locked-3d4a` en cualquier momento después del desbloqueo, considera que la prueba de seguridad ha fallado, aunque el servidor devuelva 200 sin modificar datos.

## Inspecciona ambos registros, pero no sustituyas el receptor por logs

El registro de la puerta de enlace ayuda a reconstruir lo que esta creyó que había ocurrido. El receptor demuestra lo que ocurrió fuera de la puerta de enlace. Necesitas ambos porque cualquiera de las dos fuentes por separado puede crear una falsa sensación de seguridad.

Sallyport conserva un registro Sessions para las ejecuciones de los agentes y un registro Activity para las llamadas individuales, ambos proyectados desde un único registro de auditoría cifrado y encadenado mediante hashes. Su verificador sin conexión está disponible con `sp audit verify` y no necesita la clave de la bóveda para validar la cadena. Ejecuta ese comando después de la prueba y conserva el resultado junto con el registro del receptor y la transcripción del agente.

Usa los registros para responder preguntas concretas:

- ¿La llamada antigua creó una entrada de actividad y muestra un resultado denegado?
- ¿Qué proceso de agente y qué autoridad de firma de código registró la sesión?
- ¿Existe alguna actividad posterior con el identificador de solicitud antiguo, la ruta del endpoint o una ventana temporal coincidente?
- ¿Comenzó una sesión nueva para la llamada posterior al desbloqueo?
- ¿La verificación de auditoría se completa correctamente para los registros que recopilaste?

No supongas que una entrada de registro que dice «denegado» demuestra que la solicitud nunca salió de la máquina. Un registro recoge la explicación de la puerta de enlace sobre su propia decisión. El receptor controlado aporta la comprobación independiente. Del mismo modo, la ausencia de una entrada puede indicar que usaste términos de búsqueda incorrectos, que las marcas de tiempo no estaban sincronizadas o que la prueba no utilizó la acción prevista. Por eso es importante la solicitud nueva correcta.

Para una prueba de equipo repetible, guarda cuatro elementos bajo un único identificador de ejecución: la transcripción del agente, el archivo JSONL del receptor, la exportación del registro o capturas que identifiquen las llamadas y el resultado de `sp audit verify`. Evita incluir valores secretos en cualquiera de ellos.

## Las notificaciones, los lotes y las reconexiones necesitan casos separados

Una prueba aprobada con una sola solicitud no cubre todas las formas de mensaje que puede enviar un cliente agente. Las solicitudes con identificadores son las más fáciles de probar porque JSON-RPC exige que la respuesta lleve el mismo identificador. Las notificaciones no tienen identificador ni reciben respuesta, así que eliminan la prueba habitual de que la puerta de enlace las rechazó. JSON-RPC indica explícitamente que un servidor no debe responder a las notificaciones.

Para una ruta compatible con notificaciones, incluye en el contenido saliente una marca visible en el receptor, como `notification-77b2`. Bloquea la bóveda, provoca que la notificación se emita una vez, desbloquea y comprueba que la marca nunca llegue. No deduzcas que hay seguridad a partir del silencio de la interfaz del cliente, porque el silencio es el comportamiento esperado del protocolo.

Si tu cliente o shim acepta entradas por lotes, estas merecen su propia prueba. Incluye dos llamadas inofensivas en el lote: una durante el bloqueo y otra que solo se envíe después del desbloqueo en un lote separado. No pongas llamadas antiguas y nuevas en el mismo lote, porque una puerta de enlace que procese parte del lote antes de un cambio de estado puede producir un resultado imposible de interpretar.

Las pruebas de reconexión deben variar una sola cosa cada vez. Ejecuta estos casos en pruebas separadas:

- Mantén el proceso del agente activo durante el desbloqueo.
- Termina el proceso del agente antes del desbloqueo.
- Reinicia solo el cliente MCP o el shim antes del desbloqueo.
- Desconecta la ruta de red después del error de bloqueo y vuelve a conectarla después del desbloqueo.

La expectativa no cambia. El identificador antiguo del receptor nunca debe aparecer. Si un identificador antiguo aparece solo después de una reconexión, has encontrado una ruta de repetición vinculada a la recuperación del transporte y no a la interfaz de la bóveda.

## La aprobación por sesión no puede reparar una acción retenida

La autorización por sesión y la aprobación por llamada deciden si una acción actual puede continuar. No hacen segura la conservación de una acción que fue rechazada mientras la bóveda estaba bloqueada.

El orden importa. La barrera de la bóveda es absoluta: mientras esté bloqueada, toda acción se deniega. Solo después de que se abra, la puerta de enlace puede considerar el proceso que hay detrás de una decisión de autorización de sesión o solicitar aprobación por llamada para una credencial configurada. Invertir este modelo mental lleva a los equipos a preguntarse si una tarjeta de aprobación anterior debería autorizar una acción retrasada. No debería, porque la llamada bloqueada ya no tendría que existir como trabajo ejecutable.

Prueba los límites por separado. Primero demuestra que una llamada realizada durante el bloqueo nunca se ejecuta después del desbloqueo. Después, con la bóveda abierta, comprueba que un proceso de agente nuevo produce el comportamiento esperado de autorización de sesión. Por último, si una credencial tiene activada la aprobación por llamada, verifica que cada uso nuevo vuelve a solicitarla. Combinar las tres cosas en una ejecución larga dificulta localizar los fallos.

También existe un detalle sutil relacionado con la identidad del proceso. Una aprobación de sesión pertenece a una ejecución concreta de un proceso de agente, no a la idea de «el mismo asistente». Cuando reinicies un proceso, trátalo como nuevo hasta que la puerta de enlace lo identifique y autorice según sus propias reglas. No dejes que un script de prueba oculte esto reutilizando un proceso con una conexión antigua.

## Convierte el resultado en un criterio de lanzamiento

Ejecuta esta prueba cada vez que cambies el envío de la puerta de enlace, el código del ciclo de vida de la bóveda, el shim MCP, el comportamiento de los reintentos, la configuración del cliente HTTP o el helper que realiza acciones SSH. Un fallo de repetición suele entrar durante un cambio de fiabilidad porque el código parece inofensivo en la revisión: una cola, un manejador de reconexión o una cláusula `catch` demasiado amplia.

Un lanzamiento debe fallar si alguna de estas afirmaciones es falsa:

- El receptor controlado tiene cero entradas para cada identificador enviado mientras la bóveda estaba bloqueada, incluso después de abrirla.
- Un identificador nuevo enviado después del desbloqueo llega una vez al receptor mediante la misma acción configurada.
- Un cliente o agente reiniciado no puede hacer que aparezca el identificador antiguo.
- Los registros identifican los eventos denegados y permitidos esperados sin duplicados inexplicables.
- La cadena de auditoría se verifica correctamente para las pruebas conservadas.

Siempre que sea posible, incorpora el caso negativo a una prueba de integración automatizada. El arnés de pruebas debe bloquear la bóveda, enviar la llamada antigua, abrir la bóveda, esperar durante una ventana de reintento limitada y comprobar que el receptor no contiene el identificador antiguo. Después debe enviar la llamada nueva y comprobar que llega una vez. Mantén el receptor local y desechable para que la prueba no tenga más autoridad que su propio archivo de registro.

El resultado incorrecto se resume fácilmente: una persona abre la bóveda para autorizar la siguiente acción y, en su lugar, se ejecuta una acción anterior. No aceptes una puerta de enlace que solo muestre el error correcto de bloqueo. Haz que demuestre, mediante un observador externo, que olvidó la solicitud antigua.
