# 05 · Garantías obligatorias

Seis garantías que hoy aporta la plataforma Asixto y que su sistema debe aportar en su lugar.
No son buenas prácticas genéricas: cada una corresponde a una forma concreta en que un agente
conversacional rompe datos reales. Todas son **comprobaciones bloqueantes** de la certificación.

Aplican a las herramientas de escritura. Las de solo lectura solo deben cumplir la 5 y la 6.

---

## 1 · Idempotencia en toda escritura

El Gateway envía `com.asixto/idempotencyKey` en el **`_meta`** de la llamada (no en los
argumentos) con una clave determinista por **operación lógica**: se deriva de la empresa, el nombre
de la herramienta y la firma de los argumentos, así que un doble intento del modelo la repite
idéntica. Es el mismo mecanismo que usan las herramientas nativas del agente.

Su servidor debe persistirla y, si la vuelve a recibir, **devolver el mismo resultado sin volver a
ejecutar**.

> **El orden importa, y es contraintuitivo: la consulta de la clave va ANTES de las guardas de
> propiedad y de estado terminal.** Si va después, pasa esto:
>
> ```
> 1er intento → cancela la cita → éxito
> 2º  intento → la guarda ve «ya cancelada» → VALIDATION
> ```
>
> …y el agente le dice al cliente que su cancelación falló, cuando en realidad funcionó. El estado
> cambió por **su propia primera ejecución**, así que no es una transición inválida: es el mismo
> trabajo pedido dos veces. Guarde **solo los éxitos**: un fallo transitorio debe poder reintentarse.

```python
async def cancelar_cita(cita_id: str, documento: str, *, meta: dict) -> dict:
    key = meta["com.asixto/idempotencyKey"]

    # 0 · PRIMERO la clave, antes de cualquier guarda.
    if (previo := await store.get(key)) is not None:
        return previo

    cita = await mi_backend.buscar_cita(cita_id)
    if cita is None:
        return error("NOT_FOUND", "No encontré esa cita.")

    # 1 · Propiedad.
    if cita.titular != documento:
        return error("PERMISSION", "Esa cita no está a nombre de quien me habla.")

    # 2 · Estado terminal.
    if cita.estado != "agendada":
        return error("VALIDATION", f"Esa cita ya está {cita.estado}.")

    resultado = await mi_backend.cancelar(cita_id)
    await store.set(key, resultado, ttl=7 * 24 * 3600)   # solo los éxitos
    return resultado
```

Requisitos del almacén: **TTL de al menos 7 días** y escritura **atómica** (`SET NX` o índice
único), no `get` seguido de `set` sin transacción.

Dos precisiones que evitan un error caro:

- **La clave incluye la conversación**, así que dos clientes finales distintos que pidan lo mismo
  con los mismos datos **no** comparten clave. Si su implementación deduplica por su cuenta usando
  solo los argumentos, sí las mezclaría: use la clave que le llega, no una propia.
- **Dentro de la misma conversación, la misma operación con los mismos argumentos es la misma
  operación.** Si el cliente quiere de verdad dos citas idénticas, algún argumento tendrá que
  distinguirlas; si no, la segunda le devolverá el resultado de la primera. En la práctica es el
  comportamiento correcto: casi siempre que eso ocurre es un doble intento del modelo.

**Por qué.** Hay dos causas reales de repetición, no hipótesis:

1. El modelo puede emitir la misma llamada dos veces en un mismo turno.
2. El transporte ya no reanuda flujos cortados: una respuesta interrumpida se reintenta como
   petición nueva.

**Si falta:** casos, citas o cotizaciones duplicados.

---

## 2 · Propiedad del registro

Toda operación sobre una entidad valida que **pertenece al contacto de la conversación**, no
solo que el identificador existe.

```
❌  cancelar_cita(cita_id)          → busca la cita y la cancela
✅  cancelar_cita(cita_id)          → busca la cita, verifica que su titular
                                      es el contacto en curso, y solo entonces cancela
```

**Un punto del diseño que conviene tener claro:** el Gateway **no le envía una identidad
autenticada del usuario final**. La conversación ocurre en WhatsApp, en un chat web o por teléfono;
ahí no hay sesión ni token de usuario. Quien dice ser el cliente lo afirma hablando.

Consecuencia práctica, y es responsabilidad suya:

1. Exponga una herramienta de **identificación** (documento, teléfono o correo) y haga que las
   operaciones sensibles exijan ese identificador como parámetro.
2. Valide que la entidad objetivo pertenece a ese identificador. Con
   `com.asixto/conversationId` puede además detectar si el identificador cambió a mitad de
   conversación, que es una señal de alarma que vale la pena registrar.
3. Para operaciones de alto impacto, no se conforme con el identificador: exija un dato que solo el
   titular conoce, o un código que su sistema envíe por su propio canal.

Si no coincide, devuelva `error_type: "PERMISSION"` (no un 403 crudo) para que el agente lo explique
y escale.

**Si falta:** un usuario cancela la cita de otro o corrige datos ajenos mencionando un
identificador plausible. Con un modelo de lenguaje de intermediario, un identificador plausible es fácil de producir.

---

## 3 · Guardas de estado terminal

Rechace transiciones inválidas con un error de negocio claro:

| Entidad | Estado terminal | Rechazar |
|---|---|---|
| Cita | cancelada, atendida | modificar, cancelar de nuevo |
| Oportunidad | cerrada ganada o perdida | mover etapa, reabrir |
| Cotización | aceptada, rechazada, vencida | volver a resolver, reenviar |
| Caso | cerrado | reabrir sin caso nuevo |

**Si falta:** el agente insiste, el usuario recibe una confirmación falsa y su sistema queda con
estados imposibles.

---

### 3.1 · No confíe en el orden dentro de un turno

El modelo puede pedir dos herramientas en el mismo turno (por ejemplo, consultar franjas y crear la
cita) y **el orden en que las recibe su servidor no está garantizado**: el runtime solo garantiza
precedencia en un caso concreto de agenda, no en general.

Consecuencia: **toda mutación revalida sus propios identificadores** contra su sistema en el momento
de ejecutarse. No asuma que la lectura que resolvió el id ya ocurrió, ni que el estado que devolvió
sigue vigente. Si el id no resuelve o el estado cambió, rechace con `VALIDATION`
([página 06](./06-errors.md)) en vez de operar sobre algo que ya no existe.

---

## 4 · El dato crítico lo decide su sistema

Fechas, montos, existencias e identificadores se **resuelven y validan en su lado**. Si el
agente propone algo inconsistente, rechácelo con un error entendible en vez de aceptarlo.

| Dato | Regla |
|---|---|
| Fecha y hora | Valide contra su disponibilidad real, al nivel de **instante**, no de día: una franja de hoy cuya hora ya pasó es pasada. Rechace lo ocupado aunque venga solicitado. |
| Monto | Recalcule desde su catálogo y su lista de precios. Nunca acepte el total que le manden. |
| Existencia | Verifique en el momento de la operación, no en el de la consulta. |
| Identificador de entidad | Resuelva contra lo que el cliente ya vio en la conversación. Si es ambiguo, devuelva error de ambigüedad en vez de elegir. |

**Por qué.** Es el fallo más costoso de todos: un modelo interpreta «el próximo martes» y
acierta casi siempre. Un acierto «casi siempre» no es suficiente para agendar ni para cobrar.

---

## 5 · Salida mínima y saneada

Devuelva solo lo necesario para responder. Prohibido: identificadores internos, claves
primarias, datos de terceros, campos administrativos, trazas, tokens, rutas internas.

El resultado entra al contexto del modelo, queda en la traza de la conversación y, en una
llamada telefónica, puede terminar leído en voz alta.

Límite duro: **3.000 caracteres** por respuesta (ahí recorta el Gateway) y el `limit` que reciba
como tope de elementos ([página 07](./07-limits.md)).

---

## 6 · Error con el tipo canónico

Todo error lleva `error_type` (uno de los **seis** valores canónicos) y un `user_message` apto para
el usuario final. Forma exacta y tabla de los seis en la [página 06](./06-errors.md).

La distinción que más importa: **un dato ausente y un sistema no disponible son eventos
distintos**. Si los colapsa, el agente dirá «no tenemos eso» cuando la causa real es que su sistema no
responde, y lo dirá con la misma seguridad con la que dice todo lo demás.

Y el caso que más veces se implementa mal: **una búsqueda sin resultados no es un error**. Es un
éxito con lista vacía.

---

## Cómo se verifica

| Garantía | Comprobación de la certificación |
|---|---|
| 1 | Dos llamadas con la misma clave en `_meta`: el efecto debe ocurrir **una sola vez** |
| 2 | Llamada con un identificador que no pertenece al contacto: `PERMISSION` |
| 3 | Transición sobre una entidad en estado terminal: `VALIDATION` con el estado actual |
| 3.1 | Mutación con un identificador que la lectura del mismo turno no devolvió: rechazada |
| 4 | Franja ocupada y monto alterado: deben rechazarse |
| 5 | Inspección de la respuesta: sin identificadores internos ni datos de terceros, ≤ 3.000 caracteres |
| 6 | Provocar tres casos distinguibles: sin resultados (éxito), dato inexistente (`NOT_FOUND`) y dependencia caída (`SYSTEM`) |

Las comprobaciones que escriben se ejecutan **solo** contra el entorno de pruebas que usted
declara.
