# 07 - API del Agente Autónomo

Referencia técnica del módulo `agentapi`: cómo está construido, qué reutiliza y
cómo operarlo.

> Para **construir el agente consumidor** no uses este documento: usa
> `docs/api/GUIA_IMPLEMENTACION_AGENTE.md`, que es autocontenido y está escrito
> para quien programa el cliente.

---

## Por qué un módulo nuevo y no una ampliación de `ApiController`

Ya existía una API (`app/controllers/ApiController.php`) con Bearer token
validado contra la fila `ApiToken` de `configuraciones`. No se amplió por cuatro
defectos estructurales:

1. **Token único global de instalación**: sin identidad, sin scopes, sin
   expiración, sin revocación individual. Todas sus acciones corren con actor
   `0`, es decir sin atribución.
2. **Acepta el token por query string** (`$_REQUEST['api_token']`), donde acaba
   en los logs de acceso del servidor.
3. **Su endpoint POST es inalcanzable**: al ser una acción `'*'`,
   `CsrfMiddleware` corre antes del controlador; un cliente sin cookie no tiene
   token CSRF, el middleware aborta y `Controller::process()` responde con un
   redirect 302 al login. Nunca llega a `requireApiToken()`.
4. Sin idempotencia, sin límite de tasa propio, sin auditoría, sin formato de
   error uniforme.

`ApiController` **no se modificó** para no romper a quien lo consuma. Sus
defectos quedan documentados aquí por si se decide corregirlos aparte.

---

## Arquitectura

```
Agente externo (Authorization: Bearer kma_…)
        │  GET/POST {base}/?c=agentapi&a=<acción>
        ▼
AgentapiController
  loadAccessControl()   → todas las acciones '*' (la autorización es por token)
  getMiddlewareStack()  → [ AgentApiAuthMiddleware,
                            AgentApiRateLimitMiddleware,
                            AuthMiddleware ]
        │
        ▼
AgentApiModel          contexto de petición · envelope · auditoría HTTP
AgentApiCasesModel     filtros · serialización · matriz de transiciones · versión
AgentClaimsModel       lease atómico (claim / heartbeat / release)
AgentOperacionesModel  idempotencia por request_id
TicketsDiagnosticosModel  diagnóstico/solución + puerta de cierre
AgentAprobacionesModel    solicitudes de aprobación humana
        │  reutiliza (sin duplicar reglas)
        ▼
TicketsModel::addMessage / changeStatus / assignAgent / recordEvent /
              getPaginatedTickets / getTicketById / notifyInternalUsers …
TicketsAiAuditModel (auditoría IA, nivel automático)
```

### Por qué se reemplaza el pipeline de sesión

`AgentApiAuthMiddleware` sustituye a `AuthMiddleware`/`CsrfMiddleware` **solo**
en este módulo, porque:

- `AuthMiddleware` únicamente entiende `$_SESSION[appId]['User']`; un agente no
  tiene cookie.
- Toda denegación del pipeline termina en `Router::redirect_to_action(DIR_INDEX)`
  (`core/Controller.php:423-455`), nunca en 401/403. Inservible para un cliente
  máquina.
- `CsrfMiddleware` exige un token de formulario ligado a la sesión. Ese control
  protege contra el uso implícito de cookies, que aquí no existe.

El middleware **nunca devuelve `abort`**: responde JSON con el código HTTP
correcto y termina. `AuthMiddleware` se conserva al final del stack como defensa
en profundidad (módulo habilitado + acción declarada).

`Controller::process()` es `final`, pero `getMiddlewareStack()` no lo es: de ahí
que el diseño sea posible sin tocar `core/Controller.php`.

---

## Identidad: el usuario de servicio

El sistema no tenía actor para operaciones automatizadas: los flujos automáticos
(correo entrante, reglas, portal) usan actor `0`, que `recordEvent()` degrada a
`NULL`, dejando los eventos sin responsable.

`KleeAgentServiceUserSeeder` crea:

- rol `agente_ia` (código `agente_ia`),
- usuario `agente.ia` con contraseña **inutilizable** (bcrypt de un secreto
  aleatorio que no se conserva; el agente entra por token, nunca por login),
- fila en `permisos` para `ModuleName='Tickets'` con las acciones que
  `TicketsModel::isSupportUser()` consulta.

Esto último es indispensable: para un usuario **sin sesión**, `isSupportUser()`
resuelve por `PermisosModel::getPermissionsByRole()`, que sí lee la tabla. Sin
esa fila, todas las escrituras del agente fallarían.

Efecto práctico: cada evento, notificación y `UsuarioModificacion` queda
atribuido a un actor real y visible en la interfaz existente, sin tocar vistas.

---

## Tablas

| Tabla | Migración | Para qué |
|---|---|---|
| `agent_api_credenciales` | `…000033` | Credenciales: prefijo público, hash sha256, scopes, límites, expiración, revocación |
| `agent_api_solicitudes` | `…000034` | Auditoría HTTP por petición (incluye rechazos). Es la fuente del límite de tasa |
| `agent_api_operaciones` | `…000035` | Idempotencia. `UNIQUE(CredencialId, RequestId)` |
| `tickets_agente_claims` | `…000036` | Lease por caso. `UNIQUE(TicketId)` |
| `tickets_diagnosticos` | `…000037` | Diagnóstico y solución estructurados |
| `tickets_agente_aprobaciones` | `…000038` | Solicitudes de aprobación humana |
| `tickets.Version` | `…000039` | Testigo de concurrencia optimista |

Todas InnoDB/utf8mb4. La serie arranca en `000033` porque existe una migración
huérfana `2026_08_20_000032_add_ticket_contacto_empresa` (registrada en la base
de pruebas pero sin archivo en `main`, procedente de un worktree antiguo) que
colisionaría en número.

---

## Detalles de implementación que sostienen la corrección

### 1. `rowCount()` cuenta filas CAMBIADAS, no coincidentes

`core/Db.php` no activa `MYSQL_ATTR_FOUND_ROWS`. Verificado empíricamente:

```
UPDATE con el mismo valor      → rowCount = 0
UPDATE con valor distinto      → rowCount = 1
UPDATE mismo valor + contador  → rowCount = 1
```

Consecuencia: si un agente renovara su propio lease dentro del mismo segundo con
los mismos valores, la fila no cambiaría, `rowCount()` sería 0 y el servidor le
diría al **titular legítimo** que el caso lo tiene otro.

Por eso cada `UPDATE` de `AgentClaimsModel` incrementa un contador
(`TotalReclamos` / `TotalRenovaciones`): garantiza que la fila cambie siempre que
el `WHERE` coincida, y convierte `rowCount()` en una señal fiable.

Cubierto por `AgentClaimsTest::testElMismoAgentePuedeRenovarSuLeaseEnElMismoSegundo`.

### 2. El lease se adquiere sin transacción

`INSERT IGNORE` (crea la fila si nunca existió) seguido de un `UPDATE`
condicional cuyo `WHERE` **es** el bloqueo: solo modifica la fila quien la
encuentre libre, vencida o ya suya. Con el índice `UNIQUE(TicketId)`, dos
peticiones simultáneas no pueden ganar las dos.

El vencimiento es **perezoso**: un lease expirado lo reclama el siguiente agente
sin necesidad de un proceso de limpieza.

### 3. La versión se incrementa en la base, no en PHP

`Model::editFromParameters()` vincula todos los valores como parámetros y no
admite expresiones SQL, así que el incremento va en una sentencia aparte
(`TicketsModel::bumpVersion()`), invocada desde `updateTicketById()` — el punto
único de escritura de bajo nivel del ticket. Así la versión avanza **también**
cuando el cambio lo hace una persona desde la interfaz, que es lo que el agente
necesita detectar.

Es el **único cambio a código existente** de todo el módulo. Si la columna no
existiera, se ignora en silencio.

### 4. La serialización es lista blanca obligatoria

Las lecturas del framework usan `PDO::FETCH_BOTH`, así que cada fila llega con
claves numéricas y de texto duplicadas y con todas las columnas de la tabla.
Volcar esas filas al JSON duplicaría la carga y expondría columnas
indiscriminadamente. Cada campo publicado se nombra explícitamente en
`AgentApiCasesModel`: la lista blanca **es** el contrato de la API.

### 5. La matriz de transiciones vive solo en la capa API

`TicketsModel::changeStatus()` acepta cualquier estado hacia cualquier otro (no
hay máquina de estados en el sistema, y las «transiciones permitidas» solo
existen como texto en `HelpdeskmanualController.php`). La matriz del agente se
aplica en `AgentApiCasesModel::validateTransition()`, **antes** de llamar al
modelo. El flujo humano conserva toda su libertad, incluido cerrar directamente.

Verificado por `tests/Integration/…` y por la prueba de regresión del flujo
humano.

### 6. La puerta de cierre es una consulta, no una convención

«No cerrar sin diagnóstico, solución y verificación» se comprueba con
`TicketsDiagnosticosModel::puedeResolver()`, que consulta filas reales. Por eso
el diagnóstico se guarda estructurado y no como texto libre dentro de un
comentario: una convención sobre texto no es verificable.

---

## Límite de tasa

`AgentApiRateLimitMiddleware` **no** reutiliza `RateLimitMiddleware` por dos
razones: aquél solo actúa en POST (dejaría las lecturas sin límite) y su cubeta
está cerrada por IP, identidad incorrecta para un agente que puede correr en
varias máquinas.

El conteo se hace sobre `agent_api_solicitudes`, que ya se escribe para
auditoría: el límite es **por credencial**, sobrevive a un reinicio y no depende
de un fichero con bloqueo exclusivo global.

**Propiedad a tener en cuenta**: los rechazos también se auditan, así que un
cliente que siga insistiendo tras un `429` mantiene su ventana llena. Es
disuasorio a propósito, pero conviene saberlo.

---

## Auditoría: tres capas

| Capa | Dónde | Qué registra |
|---|---|---|
| HTTP | `agent_api_solicitudes` | Una fila por petición, incluidos 401/403/404/429/503. Con IP, método, duración, tenant |
| Negocio | `tickets_eventos` | Eventos nuevos: `AgenteReclamado`, `AgenteLiberado`, `DiagnosticoRegistrado`, `SolucionRegistrada`, `AprobacionSolicitada`, `AprobacionResuelta`, más los de siempre (`CambioEstado`, `Asignado`, `Escalado`…) |
| IA | `tickets_ia_ejecuciones` | Operaciones `api_*` con `Nivel = NIVEL_AUTOMATICO (3)`, estrenando el nivel que el subsistema de IA dejó declarado y sin uso |

Las escrituras dejan además su payload y resultado en `agent_api_operaciones`.

---

## Configuración

Se resuelve en `app/config/AgentApiConfig.php` desde `.env`. Es una clase aparte
y no un método de `Config` para que el módulo sea autocontenido y no altere la
resolución de configuración que corre en cada petición del sistema.

| Clave | Por omisión | Nota |
|---|---|---|
| `AGENT_API_ENABLED` | `false` | Interruptor general. Con `false` responde 503 aunque el token sea válido |
| `AGENT_API_RATE_LIMIT_PER_MIN` | `120` | Respaldo si la credencial no define el suyo |
| `AGENT_API_LEASE_DEFAULT_MIN` | `15` | |
| `AGENT_API_LEASE_MAX_MIN` | `60` | Techo del lease |
| `AGENT_API_HIGH_RISK_MODE` | `deny` | `deny` / `approval` / `allow`. **Un valor no reconocido cae en `deny`** |
| `AGENT_API_CLAIM_ASSIGNED` | `false` | ¿Puede reclamar casos con técnico humano asignado? |
| `AGENT_API_STATE_CLAIMABLE` | `Nuevo,Abierto` | |
| `AGENT_API_STATE_WORKING` | `En progreso` | |
| `AGENT_API_STATE_WAITING` | `En espera` | |
| `AGENT_API_STATE_RESOLVED` | `Resuelto` | Estado terminal para el agente |
| `AGENT_API_STATE_RELEASED` | `Abierto` | Destino al liberar |
| `AGENT_API_OPS_RETENTION_DAYS` | `30` | |
| `AGENT_API_MAX_COMMENT_CHARS` | `20000` | |
| `AGENT_API_MAX_PAGE_SIZE` | `100` | |

**Los estados son configurables porque el sistema los resuelve por nombre**
(`TicketsModel::getEstadoIdByNombre`). Si alguien renombra un estado en
Administración → Mesa de Ayuda, hay que ajustarlos aquí. `?c=agentapi&a=health`
devuelve `configured_states_missing` precisamente para detectar ese desajuste.

---

## Operación

### Credenciales

```bash
php bin/agent-credential list
php bin/agent-credential create --name="Agente soporte 01"
php bin/agent-credential create --name="Solo lectura" --scopes=cases:read,attachments:read
php bin/agent-credential create --name="Con caducidad" --expires=2026-12-31 --rate=60 --lease-max=30
php bin/agent-credential rotate --id=3
php bin/agent-credential revoke --id=3 --reason="Equipo dado de baja"
```

- El token se muestra **una sola vez**. No se puede recuperar: si se pierde, se
  rota.
- Del token solo se guarda `sha256(secreto)`. No se usa bcrypt a propósito: el
  secreto ya tiene entropía completa (no es una contraseña humana), el
  estiramiento de clave no aporta y costaría una verificación lenta en cada
  petición.
- Revocar tiene efecto **inmediato**; rotar invalida el token anterior al
  instante.
- Sin `--scopes`, se concede el conjunto de trabajo habitual (todo salvo
  `cases:escalate`, que se otorga explícitamente).

### Aprobaciones

```bash
php bin/agent-approval list                     # pendientes
php bin/agent-approval list --all --ticket=42
php bin/agent-approval show --id=3
php bin/agent-approval approve --id=3 --user-id=1 --comment="Revisado"
php bin/agent-approval reject  --id=3 --user-id=1 --comment="No corresponde"
```

La API del agente **no** expone la resolución: si pudiera aprobar sus propias
solicitudes, el control no serviría. `--user-id` es obligatorio y debe ser
personal de soporte; queda registrado en la solicitud y en el historial del caso.

Aprobar **no ejecuta** la operación: la habilita y la deja registrada. Quien la
ejecute lo hace por las vías normales, con sus propias validaciones.

---

## Pruebas

El repositorio no tenía suite de pruebas; se creó con este módulo.

```bash
DB_NAME=klee_mesa_pruebas vendor/bin/phpunit
DB_NAME=klee_mesa_pruebas vendor/bin/phpunit --testsuite unit
DB_NAME=klee_mesa_pruebas vendor/bin/phpunit --testsuite integration
```

- **`tests/Unit`** — lógica pura: configuración, scopes, formato del token,
  validación de `request_id`, saneado de texto, normalización del lease, carga de
  clases. No toca la base.
- **`tests/Integration`** — base real: credenciales, lease, idempotencia, puerta
  de cierre, matriz de transiciones, versión, aprobaciones, serialización y
  contención de inyección de prompt.

**Guarda de seguridad**: `tests/bootstrap.php` se niega a arrancar las pruebas de
integración si el nombre de la base no contiene `prueba` o `test` (las pruebas
escriben y borran). Para saltarla de forma consciente:
`KLEE_TEST_ALLOW_ANY_DB=1`.

Las pruebas de integración crean sus propios casos y los borran al terminar;
nunca modifican datos preexistentes.

> `composer.json` declara los scripts con rutas de Windows (`vendor\bin\`), que
> no funcionan en Linux. Use los comandos directos de arriba. El script `test`
> además apuntaba a una suite inexistente antes de este módulo.

### Prueba de concurrencia

La exclusión mutua se verificó con procesos genuinamente paralelos, no con el
servidor de desarrollo de PHP (que es monohilo por omisión y serializa las
peticiones, invalidando la prueba). Para repetirla, use varios procesos PHP con
una barrera de sincronización, o `PHP_CLI_SERVER_WORKERS=8` si prueba por HTTP.

---

## Endpoints

Contrato de rutas: `{base}/?c=agentapi&a=<acción>`. `Id` acepta el Id numérico o
el `NumeroCaso`.

| Acción | Método | Scope | Riesgo |
|---|---|---|---|
| `health` | GET | — (token válido) | bajo |
| `capabilities` | GET | `cases:read` | bajo |
| `cases` | GET | `cases:read` | bajo |
| `case` | GET | `cases:read` | bajo |
| `history` | GET | `cases:read` | bajo |
| `comments` | GET | `cases:read` | bajo |
| `attachments` | GET | `attachments:read` | bajo |
| `attachmentDownload` | GET | `attachments:read` | **alto** |
| `claim` | POST | `cases:claim` | medio |
| `heartbeat` | POST | `cases:claim` | bajo |
| `release` | POST | `cases:claim` | medio |
| `comment` (interno) | POST | `comments:write` | bajo |
| `comment` (público) | POST | `comments:write` | **alto** |
| `diagnosis` | POST | `solutions:write` | bajo |
| `solution` | POST | `solutions:write` | medio |
| `changeStatus` | POST | `status:write` | medio |
| `escalate` | POST | `cases:escalate` | medio |
| `requestApproval` | POST | cualquiera de escritura | bajo |
| `operation` | GET | `cases:read` | bajo |

El contrato completo (parámetros, cuerpos, ejemplos) está en
`docs/api/GUIA_IMPLEMENTACION_AGENTE.md`, `docs/api/agent-openapi.yaml` y la
colección `docs/api/agent-postman.json`.

---

## Limitaciones conocidas

1. **La tabla `tickets` no tiene columna `TenantId`.** El aislamiento
   multi-tenant que `HasTenant` aplica automáticamente **no cubre los casos**.
   Es preexistente y ajeno a este módulo, pero si algún día se opera con varios
   tenants sobre la misma base, el agente vería casos de todos.
2. **Rutas bonitas `/api/agent/…`**: requerirían reglas de rewrite en el
   VirtualHost (en este servidor `AllowOverride None` ignora `.htaccess`). El
   contrato canónico es `?c=agentapi&a=…`.
3. **El modo `approval` de alto riesgo se comporta como `deny`**: registra la
   necesidad de aprobación pero no aplica la operación automáticamente tras
   aprobarla. Aplicar un payload aprobado de forma automática es una decisión
   pendiente.
4. **No hay interfaz de aprobación**: se resuelve por `bin/agent-approval`.
5. **`ApiController` sigue con sus defectos** (token global, aceptado por query
   string, POST inalcanzable). No se tocó a propósito.
6. **Penalización por insistir**: los `429` se auditan, así que un cliente que
   martillee mantiene su ventana llena.

---

## Archivos del módulo

```
app/config/AgentApiConfig.php                   configuración desde .env
app/controllers/AgentapiController.php          19 acciones
app/models/AgentApiModel.php                    contexto, envelope, auditoría
app/models/AgentApiCredencialesModel.php        credenciales y autenticación
app/models/AgentApiCasesModel.php               filtros, serialización, transiciones
app/models/AgentClaimsModel.php                 lease atómico
app/models/AgentOperacionesModel.php            idempotencia
app/models/AgentAprobacionesModel.php           aprobaciones
app/models/TicketsDiagnosticosModel.php         diagnóstico/solución, puerta de cierre
core/middleware/AgentApiAuthMiddleware.php      Bearer token, JSON 401/403/404/503
core/middleware/AgentApiRateLimitMiddleware.php límite por credencial
bin/agent-credential                            CLI de credenciales
bin/agent-approval                              CLI de aprobaciones
database/migrations/2026_08_25_0000{33..39}_*    7 migraciones
database/seeders/KleeAgentServiceUserSeeder.php  rol, usuario de servicio, permisos
tests/                                           suite PHPUnit (unit + integration)
docs/api/GUIA_IMPLEMENTACION_AGENTE.md           guía para el agente consumidor
docs/api/agent-openapi.yaml                      especificación OpenAPI
docs/api/agent-postman.json                      colección Postman
```

Archivos existentes modificados: `app/config/Modules.php` (entrada nueva),
`.env.example` (claves nuevas) y `app/models/TicketsModel.php` (incremento de
versión).
