# Agent Squad — Journey completo: del signup al Canva de línea de producción

> Auditoría de arquitectura (Senior Architecture Audit) · Exportado del playground interactivo: https://playgrounds.digitalhubassist.ai/agent-squad-journey-audit.html

- Alcance: app.agentsquadai.com (web) · api-substrate.digitalhubassist.ai (substrato) · InsForge (BaaS)
- Fuente: código real del repo agent-squad-app + runbook server-topology (nada inferido sin evidencia)
- Fecha de corte: 2026-07-12

---

## **[HALLAZGO 0]** El journey auditado NO atraviesa AWS en su hot path — pero SÍ hay cuenta AWS real con componentes desplegados

La columna vertebral del journey (signup → onboarding → discovery → charter) es **SvelteKit en Vercel** (frontend + funciones serverless), **InsForge** (BaaS cloud: auth, Postgres con RLS, perfiles) y un **box Hetzner único** (178.104.101.213, 8 CPU/16 GB) que corre el substrato (API Hono en Bun vía systemd, Inngest self-hosted, Postgres/Timescale, Langfuse). No hay Cognito, API Gateway ni DynamoDB en ese camino. **PERO existe una cuenta AWS real del proyecto — `226585212364`, IAM user `remotion-lambda`, región us-east-1 (credenciales en `~/.aws` del box, credential chain estándar)** — con 4 componentes desplegados en sesiones anteriores que hoy operan como **failover y planos opt-in**, no en el camino caliente:

**Dato de auditoría clave:** el `apps/api/.env` de producción tiene **CERO variables AWS** (ni `AWS_*`, ni `TOOL_PLANE_ADAPTER`, ni `FORGE_SANDBOX_ADAPTER`, ni `MODEL_CLASS_*`) → con la config vigente, **ninguno de los 4 se ejecuta en tráfico de producción**. Son capacidad instalada: código cableado en puntos precisos + credenciales listas + verificación en vivo hecha. El call-site exacto de cada uno:

| Componente AWS real | Punto de invocación EXACTO en el código | Quién lo consumiría | Cómo se enciende / estado hoy |
|---|---|---|---|
| **Amazon Bedrock** (Runtime) | `apps/api/src/inngest/llm.ts:192` — el switch de `generateLLMText`: `if (target.provider === 'bedrock') → generateViaBedrock` (`model-plane/bedrock.ts`, credential chain estándar, región `AWS_REGION ?? us-east-1`) | **TODA llamada LLM del API** pasa por ese switch: Nova discovery (paso 06), charter (paso 09), compose, judges, FORGE | Env `MODEL_CLASS_<CLASE>=bedrock:<model-id>` o fila en `model_routing_policy`. Hoy: política = `claude-cli · claude-sonnet-4-5` para todas las clases → la rama nunca se toma. Inference profile Haiku 4.5 **verificado en vivo 2026-07-08**. Runbook: `llm-failover-max-to-api.md` |
| **Bedrock AgentCore · Gateway** | `tool-plane/resolve.ts` (`resolveToolPlaneAdapter`) — consumido en DOS puntos: `inngest/operations/runtime.ts:85` (runtime de operaciones de workflows) y `forge/registry.ts:135` (registro de tools de FORGE). mcp-client con firma **SigV4** | Los steps de workflows durables y FORGE al resolver herramientas remotas vía MCP | `TOOL_PLANE_ADAPTER=agentcore-gateway` + `AGENTCORE_GATEWAY_URL`. Hoy: default `self-hosted` (la rama agentcore lanza error si falta la URL, nunca se alcanza) |
| **Bedrock AgentCore · Code Interpreter** | `forge/sandbox-port.ts:62-73` — selector del sandbox: `FORGE_SANDBOX_ADAPTER === 'code-interpreter' → AgentCoreCodeInterpreterClient` (`forge/code-interpreter/client.ts`: Start/Invoke/StopCodeInterpreterSession; fallo AWS → `InfraError` kind 'infra', nunca fallo de generación) | La verificación/ejecución del código que genera FORGE | `FORGE_SANDBOX_ADAPTER=code-interpreter` + `AGENTCORE_CODE_INTERPRETER_ID`. Hoy: `'local'` (sandbox por-proceso en el box) |
| **AWS Secrets Manager** | `tool-plane/agentcore/gateway-provisioner.ts:118` — `getSecretString(secretId)` dentro de `provisionGateway` (`secrets/secrets-manager.ts`; el valor jamás se loguea) | **SOLO** el aprovisionamiento del Gateway — hoy únicamente lo dispara el script de operador `apps/api/scripts/toolplane-e2e.ts` (provisiona y desprovisiona en la prueba E2E) | Se enciende junto con el Gateway; no está en ningún request path de prod. String-only (sin SecretBinary), fail-hard si vacío |

Veredicto del auditor: los pasos del journey reportan lo que EJECUTA cada clic hoy; los componentes AWS aparecen donde tocan (ModelPlane, pasos 06 y 09) marcados como plan B/opt-in. Para el resto de conceptos pedidos, la equivalencia:

**Tabla de equivalencias conceptuales (pedido AWS → realidad verificada)**

| Concepto pedido (AWS) | Realidad en Agent Squad | Detalle verificable |
|---|---|---|
| Amazon Cognito | **InsForge Auth** | `https://iec6r486.us-east.insforge.app/api/auth/*` — users, sessions (client_type=server), refresh con rotación, verificación de email |
| API Gateway + Lambda | **Vercel Serverless Functions** (rutas `+server.ts` de SvelteKit, adapter-vercel) | Cada ruta `/api/*` del web es una función; p.ej. discovery declara `maxDuration: 90`. Vercel las ejecuta sobre AWS Lambda como detalle de SU plataforma (no es un recurso administrado por el proyecto) |
| ECS/EKS (compute persistente) | **systemd `agent-squad-api.service`** en Hetzner | Hono + Bun en `0.0.0.0:4000`, cwd `~/agent-squad-app/apps/api`; deploy por cron `auto-deploy-api.sh` (poll de main + marker + restart con health-gate) |
| DynamoDB / RDS | **Postgres de InsForge** (estado de la app) + **Postgres/Timescale del substrato** (:5433, grafo intents→plans→traces) | InsForge: `auth.users`, `public.user_access`, `profiles.app_state`. Substrato: container `substrate-postgres` |
| Step Functions | **Inngest self-hosted** | Container `substrate-inngest` (:8288), steps durables, waitForEvent para gates humanos. NO participa en el journey auditado (hasta el charter no se ejecuta ningún workflow) |
| CloudFront | **CDN de Vercel** + **nginx** en el box | nginx enruta `api-substrate.digitalhubassist.ai → 127.0.0.1:4000` con TLS |
| IAM (roles/policies) | **Capas de secretos y gates aplicativos** | Cookie httpOnly `insforge_session`, anon key vs **SERVICE_KEY** (bypass RLS, solo server), RLS `auth.uid() = user_id`, Bearer `SUBSTRATE_API_TOKEN` (comparación constant-time), gate `user_access.authorized` fail-closed |
| Región | Vercel (edge/iad1 gestionado por Vercel) · InsForge **us-east** (subdominio lo declara) · Hetzner: box único europeo (región exacta no verificada en el repo → no se afirma) |

---

# El journey en 12 pasos

## Paso 00 · Llegada — pantalla de bienvenida

**Ruta:** `/welcome`

**Flujo:** Browser → CDN Vercel → SvelteKit SSR (hooks.server.ts)

### Experiencia del usuario (UX)

- 👁 **Qué ve.** La pantalla pública de Agent Squad con dos caminos: crear cuenta o iniciar sesión (email + contraseña). Branding paper/ink/champagne.
- 🖱 **Qué hace.** Nada obligatorio todavía: es la única puerta. Toda ruta protegida visitada sin sesión lo trae de vuelta acá.
- 💬 **Estados posibles.** Si llegó rebotado de una ruta protegida, la pantalla es la misma (el rebote es un redirect 303 del servidor, sin mensaje de error).
- Estados: `redirect 303 desde ruta protegida`

### Verdad técnica cruda (backend / infra)

#### [INFRA] A · Componentes: Vercel (web) — no hay AWS propio en este hop

- **Hosting**: Vercel, proyecto del monorepo `apps/web` (adapter-vercel), dominio `app.agentsquadai.com`
- **CDN/Edge**: CDN de Vercel sirve assets; el HTML pasa por SSR (función serverless)
- **Equivalente pedido**: CloudFront + API Gateway → CDN Vercel + router de SvelteKit

#### [COMPUTE] B · Compute: el hook global corre en CADA request

`apps/web/src/hooks.server.ts` (función serverless de Vercel) clasifica la ruta:

- `PUBLIC_PREFIXES`: `/`, `/demo`, `/discover`, `/welcome`, `/auth` → pasan sin sesión.

- `PROTECTED_PREFIXES`: `/office`, `/onboarding`, `/squad-proposal`, `/activity`, `/outputs`, `/create`, `/queue`, `/tasks`, `/channels`… → exigen sesión + autorización.

#### [ACCESO/IAM] C · Controles de acceso ya activos en la puerta

- Cookie de sesión: `insforge_session` — **httpOnly, secure, sameSite=lax, path=/, maxAge 30 días**. En este paso aún no existe.

- Bypass de test E2E: existe una rama `dev && CI==='true' && NODE_ENV!=='production'` que inyecta un usuario mock — **guard triple**: en build de producción `dev` es false en compile-time y el bundler elimina la rama. Auditado: no alcanzable en prod.

#### [DATOS] E · Datos en tránsito

- **Entra**: GET /welcome (sin credenciales)
- **Sale**: HTML SSR + bundle JS. Ningún dato de usuario persiste todavía

---

## Paso 01 · Crear cuenta (signup + verificación de email)

**Ruta:** `/welcome`

**Flujo:** Browser (SDK InsForge) → InsForge Auth (us-east) → Email de verificación

### Experiencia del usuario (UX)

- ⌨️ **Qué hace.** Escribe email y contraseña y presiona crear cuenta. La página usa el SDK de InsForge directamente desde el navegador.
- 📬 **Qué ve después.** Mensaje de que debe verificar su email. Hasta no verificar, el login no lo dejará operar como cuenta plena.
- ♻️ **Caso borde real (auditado y corregido).** Si la cuenta ya existía SIN verificar (típico: se registró y nunca abrió el correo), el signup devuelve "ya existe". La UI ahora lo detecta y reenvía la verificación en vez de dejarlo atrapado.
- Estados: `✅ verificación reenviada` · `⚠️ cuenta ya existe → inicia sesión` · `❌ reenvío falló (transient) → reintenta`

### Verdad técnica cruda (backend / infra)

#### [INFRA] A · Componentes: InsForge Auth (equivalente de Cognito)

- **Servicio**: InsForge (BaaS cloud), base `https://iec6r486.us-east.insforge.app`
- **Recurso**: Tabla `auth.users` (cuentas) + flujo de verificación por email propio de InsForge
- **Región**: us-east (declarada en el subdominio)

#### [COMPUTE] B · Compute: SDK browser + 1 función serverless para el retry

**Camino feliz:** el browser llama `client.auth.signUp()` → `POST {INSFORGE_URL}/api/auth/users`. Sin tocar servidores propios.

**Retry de no-verificados:** función Vercel `POST /api/auth/magic/create` (`apps/web/src/routes/api/auth/magic/create/+server.ts`):

```
payload IN : { email, password }            // re-usa serverLogin para saber emailVerified
payload OUT: { ok: true, alreadyVerified }  // si ya estaba verificado NO reenvía nada
```

La decisión del cliente vive en `resolveSignupRetry(status, body)`: 200+false→"verificación enviada" · 200+true→"inicia sesión" · 401→"email en uso" · otro→"transient, reintenta" (un 5xx ya no se disfraza de "email en uso" — hallazgo corregido en review).

#### [ACCESO/IAM] C · Controles: anon key pública, nada privilegiado en el browser

- El SDK del browser usa `PUBLIC_INSFORGE_ANON_KEY` (pública por diseño, permisos mínimos).

- El **SERVICE_KEY nunca sale del servidor**; el retry corre en la función Vercel, no en el cliente.

- Operación admin equivalente (borrar un usuario zombie sin verificar) se hizo por `DELETE /api/auth/users` con Bearer SERVICE_KEY — solo operador, no expuesta en la app.

#### [DATOS] E · Datos

- **Persiste**: `auth.users`: fila con email, hash de contraseña, `emailVerified=false`
- **Todavía NO existe**: ni perfil operativo ni autorización de acceso (paso 3)
- **PII en tránsito**: email + password directo browser→InsForge por TLS

---

## Paso 02 · Login y decisión de destino (postLoginRedirect)

**Ruta:** `/welcome → destino según estado`

**Flujo:** Browser → Fn Vercel /api/auth/login → InsForge /api/auth/sessions → Cookie insforge_session → Redirect

### Experiencia del usuario (UX)

- ⌨️ **Qué hace.** Ingresa email + contraseña y presiona entrar.
- 🧭 **Qué ve.** No hay dashboard genérico: el sistema lo deposita EXACTAMENTE donde quedó su historia — onboarding sin terminar, propuesta de squad pendiente, acuerdo sin firmar, o su oficina.
- Estados: `→ /onboarding` · `→ /squad-proposal` · `→ /squad-charter` · `→ /office`
- ❌ **Error.** Credenciales inválidas → mensaje de error en el formulario; el server responde igual ante usuario inexistente o contraseña mala (fail-closed, sin filtrar cuál).
- Estados: `❌ credenciales inválidas`

### Verdad técnica cruda (backend / infra)

#### [COMPUTE] B · Compute: la función de login captura el refresh token

`POST /api/auth/login` (función Vercel) → `serverLogin()` en `lib/server/auth.ts`:

```
POST {INSFORGE_URL}/api/auth/sessions?client_type=server
headers: { apikey: PUBLIC_INSFORGE_ANON_KEY }
body   : { email, password }
OUT    : { accessToken (~15 min), refreshToken, user{ id, email, profile, emailVerified } }
```

El truco auditable: en modo web el refresh token vive en una cookie httpOnly DEL DOMINIO DE INSFORGE y el SDK no lo expone. Con `client_type=server` vuelve en el body y la app lo guarda en SU cookie. El mismo response trae el **perfil**, así el destino se decide sin fetch extra.

#### [ACCESO/IAM] C · Controles: la cookie de sesión propia

- **Cookie**: `insforge_session` = { accessToken, refreshToken } — httpOnly, secure, sameSite=lax, 30 días
- **Refresh**: El hook renueva vía `POST /api/auth/refresh?client_type=server` cuando el accessToken (~15 min) expira; el refreshToken ROTA en cada renovación
- **Fail-closed**: Cualquier error de InsForge → null → sin sesión (nunca sesión a medias)

#### [DATOS] E · Datos: la decisión de destino es una función pura

`lib/server/postLoginRedirect.ts` lee el perfil que volvió en el login:

```
onboarding_completed !== true      → /onboarding
app_state.squad.length === 0       → /squad-proposal
app_state.charter_confirmed !== true → /squad-charter
else                               → /office
```

Con esto el login ES el mecanismo de resumibilidad: el usuario retoma el journey en el paso exacto (incluye borradores, ver paso 4).

---

## Paso 03 · Gate de acceso — autorización por lista (user_access)

**Ruta:** `todas las rutas protegidas, en cada request`

**Flujo:** Hook SSR → InsForge REST (SERVICE_KEY) → public.user_access → pasa o /welcome

### Experiencia del usuario (UX)

- 🚪 **Qué ve.** Si su cuenta está autenticada pero NO habilitada por el equipo, cualquier intento de entrar a la app lo devuelve a /welcome. Es el control de acceso de la beta: cuenta ≠ acceso.
- ✅ **Cuando lo habilitan.** Un operador inserta su fila de autorización; al siguiente request entra normalmente (no requiere re-login).

### Verdad técnica cruda (backend / infra)

#### [ACCESO/IAM] C · Controles: el equivalente IAM real, con su porqué documentado

`readAccessAuthorized(userId)` en `lib/server/access.ts`:

```
GET {INSFORGE_URL}/api/database/records/user_access?user_id=eq.{userId}&select=authorized
Authorization: Bearer INSFORGE_SERVICE_KEY   // bypass RLS deliberado
```

- **Por qué service key** (comentario en el código): el SDK con token de usuario lee como ANON (no propaga el JWT al RLS) y la policy `auth.uid() = user_id` devolvía vacío → usuarios autorizados quedaban gateados. Ir directo al REST con service key es la corrección auditada.

- **Default-false:** solo `authorized === true` autoriza; fila ausente, null o malformada → false. Error de red → false. **Fail-closed en toda la cadena.**

- El userId viene de una sesión YA validada por el hook, así que filtrar por él con service key no abre lectura arbitraria.

#### [COMPUTE] B · Compute: corre dentro del hook, request a request

Secuencia del hook por request protegido: cookie → ¿accessToken vigente? → si no, refresh (rotación) → `locals.user` → `locals.accessAuthorized = readAccessAuthorized(id)` → `shouldGateAccess()` decide el redirect.

#### [DATOS] E · Datos

- **Tabla**: `public.user_access` (InsForge): { user_id, authorized, granted_by }
- **Alta**: Operación de operador vía REST con SERVICE_KEY (POST de la fila)
- **Se propaga como**: `locals.accessAuthorized` — lo re-chequean además los proxies del substrato (paso 6)

---

## Paso 04 · Onboarding Q1 — nombre de la oficina (con borrador persistente)

**Ruta:** `/onboarding · etapa 1 de 3`

**Flujo:** Browser (Threlte 3D) → debounce 500ms → Fn Vercel /api/user/state → InsForge profiles.app_state

### Experiencia del usuario (UX)

- 🏗 **Qué ve.** Una escena 3D (oficina en construcción con Nova voxel al fondo) y la primera pregunta: cómo se llama tu oficina. Indicador Q1 de 3.
- ⌨️ **Qué hace.** Escribe el nombre. Cada pausa de 500ms guarda un BORRADOR: si cierra el browser y vuelve mañana, el input aparece con lo que había escrito.
- 🖱 **Continuar.** El botón promueve el borrador a nombre definitivo y limpia el draft.

### Verdad técnica cruda (backend / infra)

#### [COMPUTE] B · Compute: un único endpoint de estado para toda la app

`POST /api/user/state` (función Vercel, `routes/api/user/state/+server.ts`):

```
IN : patch parcial de AppState   // p.ej. { onboarding_answers: { officeNameDraft: "Acme" } }
     cap de body 64KB (413 si excede) · 401 sin sesión
DO : read-merge-write sobre profile.app_state (mergeAppState + normalizeAppState)
OUT: app_state resultante
```

El cliente (`stores/userState.ts`) actualiza el store OPTIMISTA y encola el POST; `patchOnboardingAnswers` MERGEA (no reemplaza) — corrección de review: un writer parcial (el draft) ya no puede pisar lo que escribió el discovery.

#### [DATOS] E · Datos: el corazón del estado es UNA columna JSON

- **Persistencia**: `profiles.app_state` (InsForge Postgres) — shape: { squad, squads, squad_charter, charter_confirmed, onboarding_answers, installed, tutorial_seen }
- **Draft**: `onboarding_answers.officeNameDraft` — se promueve a definitivo al continuar
- **Consistencia**: last-writer-wins, SIN versión/ETag en el perfil (documentado en el código)

> ⚠️ **RIESGO** Escrituras concurrentes desde dos pestañas pueden pisarse (last-writer-wins sin ETag). Aceptado y documentado; mitigado por la cola secuencial del cliente.

#### [INFRA] A · Frontend: Threlte/three.js SOLO en escenas de onboarding

La escena 3D (`OnboardingScene.svelte`) es WebGL vía Threlte. Contraste deliberado con el Canva del charter (paso 9), que es CSS 3D puro por decisión ADR-1.

---

## Paso 05 · Onboarding Q2 — tipo de uso

**Ruta:** `/onboarding · etapa 2 de 3`

**Flujo:** Browser → Fn Vercel /api/user/state → InsForge profiles.app_state

### Experiencia del usuario (UX)

- 🗂 **Qué ve.** Tres tarjetas: Personal (side project · solo), Empresa (equipo · multi-squad) y Ambos. Definen la zona inicial de la oficina.
- 🖱 **Qué hace.** Un clic en la tarjeta avanza a la etapa 3. Si abandona acá, el login lo trae de vuelta a esta etapa exacta (resumibilidad a nivel de PASO).

### Verdad técnica cruda (backend / infra)

#### [DATOS] E · Datos

- **Persiste**: `onboarding_answers.useType` (personal \| empresa \| mix) vía el mismo patch de /api/user/state
- **Efecto**: Semilla del layout inicial de la oficina (zonas)
- **Resumibilidad**: El load de /onboarding hidrata answers desde el perfil y calcula la etapa a mostrar — spec 2026-07-11-onboarding-resumability

#### [COMPUTE] B · Compute

Sin cómputo nuevo: mismo endpoint de estado del paso anterior. Cero backend adicional para esta pregunta.

---

## Paso 06 · Onboarding Q3 — Discovery: Nova diseña tu squad conversando

**Ruta:** `/onboarding · etapa 3 de 3`

**Flujo:** NovaPanel (browser) → Fn Vercel /api/substrate/discovery (90s) → nginx TLS → Hono :4000 /api/discovery/chat → ModelPlane → claude-sonnet-4-5 → de vuelta con squad_proposal

### Experiencia del usuario (UX)

- 👷‍♀️ **Qué ve.** El chat-personaje NovaPanel en su tema dark: Nova voxel con CASCO de constructora, burbuja de pensamiento «Construyendo tu oficina…», chips de arranque ("Necesito contenido semanal para mi marca"…), input estilo ChatGPT con botón + (adjuntos, deshabilitado).
- 💬 **Qué hace.** Cuenta qué necesita. Nova repregunta hasta entender la operación; mientras "piensa", su brazo va al mentón, aparece el halo dorado y el status cambia a DISEÑANDO TU SQUAD…
- 🃏 **La propuesta.** Cuando Nova entiende, aparece EN el hilo una tarjeta con 2-3 agentes (rol, función, porqué) y el CTA Confirmar squad. Puede seguir pidiendo ajustes antes de confirmar.
- Estados: `⏳ typing + halo` · `⚠️ límite de mensajes cerca (aviso)` · `❌ error de turno → Reintentar` · `429 demasiados intentos`
- 🔁 **Resumibilidad.** CADA turno se persiste: si se va a mitad de conversación, al volver el hilo completo se rehidrata y continúa donde estaba.

### Verdad técnica cruda (backend / infra)

#### [COMPUTE] B · Compute (hop 1): la función proxy en Vercel

`POST /api/substrate/discovery` (`+server.ts` con `export const config = { maxDuration: 90 }` — techo real de la función):

```
gate   : locals.user && locals.accessAuthorized   // 403 forbidden si falta cualquiera
ratelim: 6 req/min por userId (Map en memoria de la instancia)
IN     : { locale: 'es'|'en', messages: [{role, content}...] }   // historial COMPLETO por turno (stateless)
CALL   : postSubstrateDiscovery() → POST {SUBSTRATE_API_URL}/api/discovery/chat
         Authorization: Bearer SUBSTRATE_API_TOKEN · abort a 88s
OUT    : { ok, reply, squadProposal: [{role, function, why}] | null }
```

#### [INFRA] A · Infra (hop 2): nginx + systemd en el box Hetzner

- **Dominio**: `api-substrate.digitalhubassist.ai` → nginx → `127.0.0.1:4000` (TLS termina en nginx)
- **Servicio**: systemd `agent-squad-api.service` — Hono sobre Bun, `apps/api/src/index.ts`, env en `apps/api/.env`
- **Deploy**: cron `auto-deploy-api.sh`: poll de origin/main + ff-only + restart solo si el health responde 200 + marker `.agent-squad-api-deployed-sha`
- **Box**: Hetzner 178.104.101.213 · 8 CPU / 16 GB — comparte host con Inngest, Postgres, Langfuse, ClickHouse, MinIO

> ⚠️ **RIESGO** Single box sin HA: si el box cae, el discovery/charter degradan. El web ya es fail-soft en lecturas (timeout 2.5s → demo), pero estos POST conversacionales no tienen fallback.

#### [SUSTRATO/AGENTES] D · Sustrato/Agentes: el motor de Nova discovery

Endpoint real: `POST /api/discovery/chat` (`apps/api/src/routes/discovery.ts`):

- **Agente:** Nova (persona de arquitecta de squads, estilo Valeria), system prompt `buildDiscoverySystemPrompt` con catálogo cerrado de roles (`ROLE_IDS`) y regla dura anti-voseo (español neutro).

- **Modelo:** ModelPlane con `modelClass: 'compose'` → política actual `{ provider: 'claude-cli', model: 'claude-sonnet-4-5-20250929' }`. Providers alternativos listos: `anthropic-api` y **Amazon Bedrock** (cuenta AWS 226585212364, us-east-1, inference profile Haiku 4.5 verificado en vivo — ver Hallazgo 0); el switch es config, no deploy.

- **Patrón LLM:** 1 llamada de 40s + 1 retry si el JSON/schema falla, devolviéndole al modelo el resumen del error de validación (summarizeZodError). Presupuesto total < abort de 88s del proxy.

- **Contrato de salida (zod, no prompt):** `TurnSchema = { reply ≤2000, squad_proposal: null | 2..3 × { role ∈ ROLE_IDS, function ≤300, why ≤300 } }` — el rango 2-3 agentes lo garantiza el SCHEMA.

- Rate limit de emergencia en el API: 20/min global para `discovery:global` (el bearer compartido no distingue usuarios — por eso el límite fino por-usuario vive en el proxy).

- **Inngest NO interviene:** discovery es una llamada síncrona; el motor durable entra recién cuando la oficina lanza workflows reales.

#### [ACCESO/IAM] C · Controles de este tramo

- Gate doble en el proxy: sesión + `accessAuthorized`.

- Bearer `SUBSTRATE_API_TOKEN` con comparación constant-time (`middleware/bearer-auth.ts`) — un solo token de servicio para la superficie expuesta.

- Validación de entrada en AMBOS lados: el proxy parsea (parseDiscoveryRequest) y el API re-valida con zod (mensajes ≤20, user ≤2000 chars, el último mensaje debe ser del usuario).

> ⚠️ **RIESGO** El rate limit del proxy vive en un Map por instancia serverless (no compartido); documentado — el techo global del API queda como red de emergencia.

#### [DATOS] E · Datos: persistencia por turno

- **Cada turno**: el cliente hace patch de `onboarding_answers.discovery_*` (hilo + estado) vía /api/user/state — con guard anti-loop (solo persiste si los mensajes cambiaron)
- **Privacidad**: el historial COMPLETO viaja a cada turno (stateless) y la conversación queda en el perfil; el roadmap ya contempla event-log con TTL/retención en store separado
- **Transformación**: JSON del modelo llega como texto → extractJson (recorta fences ```) → zod → objeto tipado

---

## Paso 07 · Confirmar el squad propuesto

**Ruta:** `/onboarding → /squad-proposal`

**Flujo:** NovaActionCard (CTA en el hilo) → persist onboarding → Nova celebra 900ms → goto /squad-proposal

### Experiencia del usuario (UX)

- ✅ **Qué hace.** Presiona Confirmar squad en la tarjeta del hilo. Nova celebra (brazos arriba + confetti + status ¡SQUAD LISTO!) y ~1 segundo después navega a la propuesta visual.
- ⚠️ **Si la persistencia falla.** El botón vuelve a estado normal con error visible y opción de reintentar — no navega con datos a medias.
- Estados: `🎉 celebración 900ms` · `❌ persistence_failed → retry`

### Verdad técnica cruda (backend / infra)

#### [DATOS] E · Datos: el cierre del onboarding es un patch

- **Persiste**: `onboarding_answers.discovery_squad` (la propuesta confirmada) + `onboarding_completed: true` en el perfil
- **Orden**: PRIMERO persiste (await + verificación), DESPUÉS celebra y navega — el bug de carrera que borraba discovery_squad con un $effect concurrente se corrigió con merge-writer + guard
- **Efecto en el router**: postLoginRedirect deja de mandar a /onboarding

#### [COMPUTE] B · Compute

Mismo `/api/user/state`. La celebración es CSS puro en NovaPanel (prop `celebrating`); el timer de 900ms vive en el cliente.

---

## Paso 08 · Squad Proposal — conoce a tus agentes en 3D

**Ruta:** `/squad-proposal`

**Flujo:** Threlte 3D (browser) → setSquad → Fn /api/user/state → goto /squad-charter

### Experiencia del usuario (UX)

- 🧍 **Qué ve.** La escena 3D con los 2-3 muñecos voxel de su squad, cada uno en su propio lugar sobre su zona de color (separación por formationSpots — se corrigió el solapamiento). Rail derecho con las cards de cada agente.
- 🖱 **Qué hace.** Puede clickear cada agente para previsualizarlo, restablecer la propuesta, y confirmar para pasar al Acuerdo de Operación.

### Verdad técnica cruda (backend / infra)

#### [COMPUTE] B · Compute

Página `routes/squad-proposal/+page.svelte`: `confirmSquad()` → `await setSquad(squad)` → `goto('/squad-charter')`. El squad definitivo (con nombres, skin, hair, zona, x/z) nace acá desde la propuesta del discovery.

#### [DATOS] E · Datos

- **Persiste**: `app_state.squad` — array de AgentDef: { id, name, role, zone, x, z, status, skin, hair, backstory }
- **Efecto en el router**: squad.length > 0 → el login ya no manda a /squad-proposal
- **Escena**: formationSpots [[-3,0.3],[0,0.1],[2.9,-0.3]] — un spot por zona de color

---

## Paso 09 · Entrada al Canva — Nova redacta el Acuerdo de Operación

**Ruta:** `/squad-charter`

**Flujo:** Guard onMount → Fn Vercel /api/substrate/charter (90s) → Hono :4000 /api/discovery/charter → claude-sonnet-4-5 (75s) → persistCharter → profiles

### Experiencia del usuario (UX)

- ⏳ **Primera vez.** Pantalla de preparación con las iniciales de sus agentes rebotando mientras Nova redacta el acuerdo (puede tomar decenas de segundos con 3 agentes).
- Estados: `⏳ preparando acuerdo` · `❌ error → Reintentar`
- 🏭 **Qué ve al cargar.** El diorama de línea de producción: sus agentes en puestos con escritorio+monitor, puestos TÚ (figuras de espaldas, anónimas) donde aprueba, pizarra-marcador, puerta SALIDA → CLIENTE, buzón ENTREGAS, y el documento recorriendo la línea con placa de estado (EN PRODUCCIÓN → TU APROBACIÓN → ENTREGADO).
- ↩️ **Si recarga.** El charter persistido se rehidrata sin llamar al LLM otra vez (0 llamadas si ya existe — el e2e lo asserta).

### Verdad técnica cruda (backend / infra)

#### [COMPUTE] B · Compute: guard + primer turno

```
onMount:
  hidrata appState del perfil (solo si el store está vacío — evita pisar
  el squad recién persistido en navegación SPA)
  squad.length === 0  → goto('/office')                 // guard
  ¿squad_charter válido en el perfil? → initCharterState(persisted)  // 0 LLM
  si no → runTurn([], null) → POST /api/substrate/charter
```

El proxy (`maxDuration: 90`) reenvía a `POST /api/discovery/charter` con Bearer. El request lleva `{ locale, squad (2-3), messages, charter? }` — en turnos de ajuste el charter vigente viaja en **snake_case** (`nova_line`); la conversión camelCase↔snake_case está en el cliente (sin ella, todo segundo turno daba 400).

#### [SUSTRATO/AGENTES] D · Sustrato/Agentes: el motor del charter

- **Agente:** Nova con system prompt `buildCharterSystemPrompt` — redacta alcance/gates/responsabilidad por agente, español neutro.

- **Modelo:** mismo ModelPlane `compose` → claude-sonnet-4-5 vía claude-cli (Bedrock disponible como failover por config — Hallazgo 0). Timeout **75s por intento** (con 3 agentes, 40s daba timeout → 502; corregido). Retry solo por JSON inválido.

- **Contrato zod:** `CharterSchema = { nova_line ≤300, agents: 2..3 × { name, role ∈ ROLE_IDS, alcance 1..3×≤200, gates ≤3 × { id /^[a-z0-9_]+$/, label ≤160, enabled }, responsabilidad 1..2×≤200 } }`.

- Rate limit API: 20/min global `charter:global`; el proxy pone el límite por usuario antes.

#### [DATOS] E · Datos

- **Persiste**: `app_state.squad_charter` — persistCharter verifica leyendo el store tras escribir (sameCharter) y expone retry si falló
- **Validación defensiva**: el cliente re-valida el shape del charter recibido (validCharter) antes de renderizar: bad_payload → error visible
- **Estado del hilo**: los turnos del chat del charter NO persisten (a diferencia del discovery); el charter SÍ

#### [ACCESO/IAM] C · Controles

Mismo patrón del paso 6: gate doble user+authorized en el proxy, Bearer constant-time en el API, zod en ambos lados, abort 88s < maxDuration 90.

---

## Paso 10 · Interacción con el Canva — avatares, cards, gates y chat

**Ruta:** `/squad-charter (pantalla actual)`

**Flujo:** CSS 3D DOM (0 backend) → toggle → optimista + /api/user/state + turno LLM → NovaPanel chat → /api/substrate/charter

### Experiencia del usuario (UX)

- 🎯 **Seleccionar un puesto.** Clic (o teclado) sobre un agente del diorama: spotlight del color de su zona, aro champagne, tag elevado con punta; los demás se atenúan. El panel-contrato de la izquierda cambia a ese agente (alcance / gates con switches / responsabilidad).
- 🎛 **Togglear un gate.** El switch responde INSTANTÁNEO (optimista). En el diorama, el checkpoint TÚ reacciona en cadena: lámpara pop, tramo iluminado, prueba de sello y ✓ flotante. Nova comenta el cambio en el chat.
- Estados: `switch <300ms` · `❌ persistence_failed → retry`
- 💬 **Chatear con Nova.** NovaPanel (ahora con GAFAS de revisora y pensamiento «Afinando tu acuerdo…») acepta ajustes en lenguaje natural: "quiero más control", "explícame el flujo". Cada respuesta puede traer un charter actualizado que redibuja la línea. Límite de 20 mensajes con aviso.
- 🎬 **La maqueta viva.** El documento recorre la línea con easing, espera VISIBLE en tus checkpoints, recibe el sello (squash + ✓), entra al buzón con recibo ✓ ENTREGADO. Los agentes teclean en ráfagas desfasadas y sus monitores pulsan. Decisión de producto: es una SIMULACIÓN del acuerdo (el trabajo real llegará en la vista de operación).

### Verdad técnica cruda (backend / infra)

#### [INFRA] A · El Canva es CSS 3D DOM puro — decisión ADR-1

- Cero WebGL/Threlte acá: voxels construidos por `nova-voxel.ts` (builder DOM: cajas de 6 caras con sombreado por cara), animaciones CSS, UN solo requestAnimationFrame (el runner del documento).

- Todo el game-feel (estados del doc, gate físico, selección magnética, celebración) corre en el navegador. **Este paso genera CERO tráfico de backend** salvo lo que sigue.

- Accesibilidad: puestos con role=button + teclado; decorativo con aria-hidden; prefers-reduced-motion con fallback estático completo.

#### [COMPUTE] B · Compute: qué dispara CADA interacción

- **Seleccionar puesto**: NADA de red — estado local (selectedIdx con clamp sincrónico safeIdx)
- **Toggle de gate**: (1) charter local actualizado optimista → (2) POST /api/user/state (persistCharter, verificado) → (3) turno automático a Nova: POST /api/substrate/charter con el toggle como mensaje del usuario → (4) prop lastToggle dispara el one-shot visual en la escena
- **Mensaje de chat**: POST /api/substrate/charter (mismo pipeline del paso 9); si vuelve charter nuevo → persistCharter + la línea se re-deriva (deriveLine) y el runner adopta la ruta al reinicio del loop
- **Runner del doc**: rAF client-side; buildPhases desde waypoints; se reconstruye en cada reset del loop

El e2e 23 asserta el contrato crítico: `aria-checked` cambia en <300ms y hay EXACTAMENTE 1 POST al charter por toggle.

#### [DATOS] E · Datos: deriveLine, la función que dibuja la verdad

```
deriveLine(charter):
  un slot por agente · un slot TÚ tras cada agente CON gates definidos
  (despacho si es el último, escritorio si no) · checkpoint activo si
  algún gate enabled · waypoints con pause en los TÚ activos
  → slots (posiciones x 8..86, puerta 93) + ruta del documento
```

El montaje voxel separa **estructura** de **estado**: la firma estructural incluye apariencia del agente (id+skin+hair+zone) — un toggle NO remonta la escena; un cambio de agente por Nova SÍ.

#### [SUSTRATO/AGENTES] D · Agentes: Nova revisora, en su tercer rol del journey

El MISMO personaje transversal (NovaPanel) con atuendo por momento: constructora (casco) en discovery → revisora (gafas champagne) en el charter → arquitecta base en el resto. Gestos reactivos: pensar (mano al mentón + halo), celebrar (brazos + confetti), saludo al montar, parpadeo. El estado bajo su nombre narra: REVISANDO EL ACUERDO… / ¡SQUAD ACTIVADO!

---

## Paso 11 · Aprobar y activar el squad — el acuerdo queda en firme

**Ruta:** `/squad-charter → /office`

**Flujo:** CTA en el hilo → Fn /api/user/state {charter_confirmed} → celebración 1100ms (Nova + diorama) → goto /office

### Experiencia del usuario (UX)

- ✍️ **Qué hace.** Presiona «Aprobar y activar mi squad» (tarjeta al final del hilo, patrón ChatGPT, con la nota: cualquier cambio posterior reabre el acuerdo).
- 🎉 **Qué ve.** Celebración doble sincronizada: Nova celebra abajo (brazos + confetti) y el diorama responde ENTERO — la línea se enciende de izquierda a derecha, los agentes brincan, tus sellos sellan, un documento dorado recorre el flujo y aparece «¡ACUERDO APROBADO!». 1.1s después entra a su oficina.
- 🔁 **Reglas del estado.** Volver al charter muestra el badge de acuerdo en firme (sin CTA). Si pide un ajuste a Nova, el acuerdo vuelve a NO confirmado y hay que re-aprobar. Recargar no repite la celebración.
- Estados: `✓ ACUERDO EN FIRME` · `ajuste → vuelve a no-confirmado`

### Verdad técnica cruda (backend / infra)

#### [DATOS] E · Datos: la activación es UN booleano de perfil

- **Persiste**: `app_state.charter_confirmed = true` (setCharterConfirmed). Cualquier setSquadCharter posterior lo RESETEA a false — regla "re-editar reabre el acuerdo"
- **Router**: postLoginRedirect ahora resuelve /office: el journey auditado queda completo
- **Timing**: confirm → await persist → celebrating=true → 1100ms fijos → goto(/office). La celebración es CSS one-shot; el e2e depende de ese presupuesto

#### [SUSTRATO/AGENTES] D · Hallazgo de cierre: qué NO pasa todavía

**Aprobar el acuerdo NO crea nada en el substrato.** Ni intent, ni plan, ni registro en el Postgres del grafo, ni evento Inngest. La "activación" es estado de perfil en InsForge. Los workflows reales (intent → plan → steps → traces → gates humanos vía Inngest waitForEvent) nacen DESPUÉS, cuando la oficina lanza trabajo. Auditoría: correcto para el alcance actual, pero el nombre "activar mi squad" promete más de lo que ejecuta — cuando se cablee el Frente A (workflows reales desde la app), este paso es el candidato natural a emitir el primer evento.

#### [ACCESO/IAM] C · Controles

Mismo endpoint de estado autenticado por cookie (401 sin sesión). Sin privilegios nuevos: aprobar es escribir tu propio perfil.

---

*Auditoría generada desde el código fuente real (rutas, schemas zod, middleware, runbooks) — Agent Squad · 2026-07-12*
