# Contrato de integración: núcleo de dominio ↔ Laravel

El núcleo (`app/Domain`, `app/Infrastructure/Storage`) es PHP puro y está probado sin framework. Este documento fija **cómo debe conectarse** la capa de aplicación de Laravel para no romper las garantías que las pruebas verifican. Es un contrato de diseño: **la integración aún no existe ni se ha probado**.

## 1. Flujo de una decisión de workflow (caso de uso `CompleteTask`)

```text
Controller/API ──► FormRequest (valida forma) ──► CompleteTaskHandler
  1. Resolver AccessContext en backend (sesión/token + membresía)   → AccessPolicy::decide(..., 'tasks.approve', $tarea->tenant_id)
       TENANT_MISMATCH → 404;  resto de denegaciones → 403
  2. DB::transaction():
       a. SELECT instancia ... WHERE id=? AND tenant_id=? (la consulta SIEMPRE lleva tenant_id)
       b. $result = $engine->completeTask($state, $taskId, $outcome, $actorRef, $metadata, now(), $expectedVersion)
       c. UPDATE workflow_instances SET state_json=?, version=version+1
              WHERE id=? AND tenant_id=? AND version=:versionLeida      ← comparación y cambio atómicos
          0 filas afectadas → 409 WF_CONCURRENT_MODIFICATION (reintentar o informar)
       d. Proyectar $result->state['tasks'] a workflow_tasks (bandejas y filtros)
       e. Para cada $result->events: AuditChain::seal(evento, último hash de la cadena del tenant)
              → INSERT audit_events   (la cadena se lee con SELECT ... FOR UPDATE para serializar)
       f. Para cada $result->effects: INSERT background_jobs
              con idempotency_key = RetryPolicy::idempotencyKey(tipo, recurso, partes)   (UNIQUE)
       g. Si $state['status']==='completed': DossierStateMachine::transition(estadoActual, WorkflowEngine::dossierStatusFor(outcome))
  3. Tras el commit: nada se ejecuta "en caliente"; el cron drena background_jobs (INV-012).
```

Si `$result->replayed` es `true`, no hay nada que persistir: responder 200 con el estado actual (idempotencia).

## 2. Persistencia mínima añadida al modelo del SRS (sección 7)

| Tabla | Cambio respecto al SRS | Motivo |
|---|---|---|
| `workflow_instances` | `state_json` (JSON), `version` (INT, ≥1), `definition_hash` (CHAR 64) | Estado completo del motor + control optimista |
| `workflow_tasks` | Proyección de `state['tasks']`; índices `(tenant_id, assigned_user_id, status)`, `(tenant_id, assigned_role_code, status)`, `(tenant_id, due_at)` | Bandejas y detección de vencidas sin leer JSON |
| `audit_events` | Un hash de cadena **por tenant** (y una para eventos de plataforma); `UNIQUE (tenant_id, previous_hash)` | Impide dos eventos con el mismo predecesor (bifurcación) |
| `background_jobs` | `UNIQUE (idempotency_key)` | Efectos no duplicados (FR-JOB-005) |
| `idempotency_keys` (nueva) | `(tenant_id, client_id, key)` único, `fingerprint`, `state`, `response_json`, `expires_at` | `IdempotencyGuard` (FR-API-007) |
| `integration_events` | `UNIQUE (integration_connection_id, external_event_id)` | Respaldo de `ReplayStore` en BD |

`assigned_role_id` del SRS se sustituye por el **código** de rol en la proyección: el motor asigna por `role` (texto) y el rol de sistema es global (`roles.tenant_id` nulo).

## 3. Correspondencia errores → HTTP

| `errorCode` / razón | HTTP |
|---|---|
| `AccessDecision::TENANT_MISMATCH`, `WF_TASK_NOT_FOUND` | 404 |
| `UNAUTHENTICATED` | 401 |
| Resto de denegaciones de `AccessPolicy`, `WF_NOT_ASSIGNEE`, `WF_SEPARATION_OF_DUTIES` | 403 |
| `WF_CONCURRENT_MODIFICATION`, `WF_TASK_ALREADY_COMPLETED`, `WF_TASK_NOT_OPEN`, `WF_INSTANCE_NOT_RUNNING`, idempotencia `conflict` | 409 |
| `WF_OUTCOME_NOT_AVAILABLE`, `WF_DEFINITION_INVALID`, `WF_ASSIGNEE_INVALID` | 422 |
| Idempotencia `replay` | Respuesta guardada (misma que la original) |
| Límite de consumo | 429 |

El cuerpo de error usa la estructura de la sección 9.3 del SRS con `correlation_id`; nunca incluye trazas ni datos internos.

## 4. Reglas que la integración NO debe romper

1. **Toda consulta lleva `tenant_id`** del contexto autenticado; nunca el enviado por el cliente. Pruebas por recurso (NFR-SEC-013) con base de datos real.
2. La carga de un archivo hace `UploadPolicy::validate` con el **MIME detectado por `finfo`** sobre el contenido, no el `Content-Type` del navegador, y guarda con `StoragePathBuilder::keyFor`.
3. El hash SHA-256 se calcula con `StreamHasher` mientras se guarda, no leyendo el archivo entero en memoria.
4. Una definición se **valida con `WorkflowDefinitionValidator` antes de publicar**; al publicar se calcula `CanonicalJson::hash` y la versión pasa a inmutable (sin `UPDATE` posible desde la aplicación).
5. Webhooks: leer el cuerpo **crudo** antes de decodificar JSON; verificar con `WebhookVerifier` antes de cualquier efecto.
6. Destinos de integraciones: `SsrfGuard::check` + fijar la IP resuelta al conectar + `allow_redirects=false` + tiempos máximos.
7. Los textos libres de decisiones (`comment`) se guardan en `workflow_tasks`, **no** en `audit_events`.
8. El panel no ejecuta comandos ni SQL (FR-ADM-004).

## 5. Qué NO cubre el núcleo (pendiente de la integración)

Autenticación y sesiones, CSRF, persistencia y migraciones, `Policies` de Laravel, detección real de MIME, almacenamiento en disco, envío de correo, cola en BD y cron, límites de consumo (429), OpenAPI, interfaz y despliegue. Cada uno figura como *Pendiente* en `docs/trazabilidad.md`.
