Agent Squad · Substrate · Programa de inducción

Cómo abordar el onboarding
por misiones

El journey completo — de la creación de cuenta a la reconstrucción total de una corrida — dividido en 16 hitos. Cada uno: concepto anclado en código, tarea práctica, y una validación de trazabilidad que descubre sus valores en runtime.

Regla 1 — Código = verdad
"Si no está en el código, no existe." Cada afirmación cita su archivo:línea; los docs son mapa, no aspiración.
Regla 2 — Todo dinámico
Ninguna validación lleva IDs/tokens pegados a mano: se descubren del sistema vivo. El estado estático de hoy migrará a dinámico.
16
Hitos
2
Planos
6
Control
9
Ejecución
3+1
Capas de traza

El mapa · dos planos + frontera

Plano identidad / control · apps/web + InsForge
A1 CuentaA2 GateA3 OnboardingA4 SquadA5 OfficeA6 Puente
▼ frontera · cruza solo workspace_id (bearer) ▼
Plano de ejecución · substrato (system-of-record)
E1 IngestaE2 NovaE3 PlanE4 TraceE5 StepsE6 EvalE7 GateE8 Salida
Tuberías de apoyo + trazabilidad
B ConocimientoD FORGETP-13 Reconstrucción

Los 16 hitos · concepto · tarea · validación

0 / 16
Concepto

El journey empieza en el plano de identidad, dueño InsForge. Tres altas (magic link 15 min / OAuth / password), todas crean/autentican un user de InsForge (lib/server/auth.ts, routes/api/auth/*). Sesión en cookie insforge_session httpOnly (hooks.server.ts:24-31), access token ~15 min refrescado server-side. Fail-closed: error → null. Frontera: al nacer, la cuenta solo existe en InsForge; el substrato no se entera. Nudo: crear cuenta ≠ tener acceso ≠ crear squad.

Tarea

Crear una cuenta de prueba real (email que controles) por magic link en https://app.agentsquadai.com. *(No hay signup-con-password en el código —solo sessions login, refresh, oauth/exchange—; por eso el alta va por magic link, que sí existe: magicLink.ts.)*

Validación dinámica
read -rp "email de prueba: " EMAIL; read -rsp "password: " PW; echo
# TP-A1 · autenticar por el MISMO endpoint que auth.ts:30 → user.id DINÁMICO
RESP=$(curl -s -X POST "$IBASE/api/auth/sessions?client_type=server" \
  -H "content-type: application/json" -H "apikey: $IANON" \
  -d "{\"email\":\"$EMAIL\",\"password\":\"$PW\"}")
UID=$(echo "$RESP" | jq -r '.user.id // empty')
[ -n "$UID" ] && echo "TP-A1 OK · sesión válida · user.id=$UID" || { echo "TP-A1 FAIL (fail-closed)"; }
# TP-A2 · el gate del user recién nacido — query EXACTA de access.ts:38 con el UID descubierto
curl -s -H "Authorization: Bearer $ISK" \
  "$IBASE/api/database/records/user_access?user_id=eq.$UID&select=authorized" | jq -c \
  | sed 's/^/TP-A2 · user_access = /'   # []  → no autorizado (fail-closed) → gateado a /welcome
Evidencia

línea TP-A1 (user.id=…) + línea TP-A2 ([]).

Concepto

Autenticado ≠ autorizado. hooks.server.ts + access.ts: si authenticated && !authorized && isProtectedRoute → redirige a /welcome (shouldGateAccess, puro). La autorización se lee de public.user_access con el SERVICE_KEY (bypass RLS; el token del usuario leería como ANON y daría vacío). Fail-closed: ausencia/error → false. El grant es a mano (no hay escritura self-serve en la app).

Tarea

(a) Con la cuenta gateada del H1, intentá entrar a /office → observá el redirect a /welcome. (b) Como operador, otorgá el acceso a mano. (c) Reintentá → ahora pasa.

Validación dinámica
# usa $UID del H1 (o redescubrilo con el login). $COOKIE = valor de insforge_session del navegador.
# (a) ANTES: gateado
curl -s -o /dev/null -w "A2-antes · %{http_code} → %{redirect_url}\n" \
  -b "insforge_session=$COOKIE" https://app.agentsquadai.com/office
# (b) grant a mano (operador) — tabla user_access, columnas de access.ts
curl -s -X POST "$IBASE/api/database/records/user_access" -H "Authorization: Bearer $ISK" \
  -H "content-type: application/json" -d "{\"user_id\":\"$UID\",\"authorized\":true}" | jq -c
# (c) DESPUÉS: pasa el gate
curl -s -H "Authorization: Bearer $ISK" \
  "$IBASE/api/database/records/user_access?user_id=eq.$UID&select=authorized" | jq -c \
  | sed 's/^/A2-despues · user_access = /'   # [{"authorized":true}]
Evidencia

el redirect_url a /welcome antes, y authorized:true después.

Concepto

El estado por-usuario vive en InsForge profile.app_state (JSON), no en el substrato. POST /api/user/state hace read-merge-write sobre el perfil (routes/api/user/state/+server.ts), preservando el resto de campos; concurrencia = last-writer-wins (sin ETag). El onboarding persiste app_state.onboarding_answers (lib/appState.ts).

Tarea

Completar el onboarding en la app con la cuenta ya autorizada (H2).

Validación dinámica
curl -s -X POST -b "insforge_session=$COOKIE" -H "content-type: application/json" \
  -d '{}' https://app.agentsquadai.com/api/user/state | jq '.app_state.onboarding_answers'
# Evidencia: onboarding_answers ≠ null (las respuestas persistieron).
Evidencia

Concepto

El squad es config de app_state.squads[] (roster, colores B/G/O/P/T/R, zonas, status) — lib/squads/store.ts. En el WRITE se re-sanitiza (sanitizeSquads: pick explícito de campos; junk no persiste). Nudo crítico: crear el squad NO crea un workspace de ejecución — el intent siempre usa SUBSTRATE_WORKSPACE_ID fijo (substrate.ts:25). El squad.id y el workspace_id son cosas distintas.

Tarea

Armar el squad en /hire/squad-proposal/squads.

Validación dinámica
# (1) el squad vive en app_state
curl -s -X POST -b "insforge_session=$COOKIE" -H "content-type: application/json" \
  -d '{}' https://app.agentsquadai.com/api/user/state | jq '.app_state.squads'
# (2) prueba del SEAM: el workspace de ejecución NO es el squad — es fijo
echo "workspace de ejecución (fijo): $WSID"
grep -n "SUBSTRATE_WORKSPACE_ID" $WEB/src/lib/server/substrate.ts
# Evidencia: squads[] con shape válido + confirmación de que $WSID ≠ squad.id
Evidencia

Concepto

/office es la superficie de lanzamiento. Su load (office/+page.server.ts) pide un recap mínimo al substrato (fetchSubstrateActivity limit=1): shipped = traces succeeded 24h, review = artifacts pending_review. Fail-soft: si el substrato no responde, usa números demo. El office lee el squad de app_state y muestra el read-model — nunca el grafo.

Tarea

Abrir /office autorizado y observar el recap real.

Validación dinámica
curl -s "http://127.0.0.1:4000/api/workspaces/$WSID/activity?limit=1" \
  -H "Authorization: Bearer $TOKEN" | jq '{shipped: .stats.succeeded24h, review: .stats.pendingReview}'
# Evidencia: los números que ve el office == los del substrato (o fallback si null).
Evidencia

Concepto

Los proxies routes/api/substrate/* son la única puerta app→substrato. Aplican gate doble (locals.user && locals.accessAuthorized → si no, 403), agregan el bearer (SUBSTRATE_API_TOKEN) y el workspace_id fijo, y mapean workflowId→intent (launchCatalog.ts buildIntentPayload). El navegador manda solo {workflowId, input} — el payload real del intent se arma server-side.

Tarea

Disparar un launch desde el office y observar que pasa por el proxy.

Validación dinámica
# (1) sin sesión → 403 (gate doble)
curl -s -o /dev/null -w "sin-sesion · %{http_code}\n" -X POST \
  https://app.agentsquadai.com/api/substrate/compose -H "content-type: application/json" -d '{}'
# (2) el bearer va SOLO server-side: nunca aparece en el bundle del browser
grep -rn "SUBSTRATE_API_TOKEN" $WEB/src/lib/server/ | wc -l   # >0 en /server
grep -rn "SUBSTRATE_API_TOKEN" $WEB/src/routes/ | grep -v "/api/" | wc -l  # 0 fuera de server
# Evidencia: 403 sin sesión + token confinado a lib/server.
Evidencia

Concepto

Dos puertas de entrada (CONCEPTS.md): /compose = lenguaje natural → Nova interpreta y decide el desenlace; /intents = estructurado (crons, el proxy de un MATCH) → compila el plan directo desde su PlanTemplate. Regla: *compose = "decile a Nova qué querés"; intents = "ya sé qué workflow correr".*

Tarea

Declarar un intent estructurado y capturar su id (dinámico).

Validación dinámica
# declara un intent con valores VÁLIDOS y captura intent.id de la respuesta (no lo pegás)
INTENT=$(curl -s -X POST "http://127.0.0.1:4000/api/intents" -H "Authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d "{\"workspace_id\":\"$WSID\",\"kind\":\"answer_question\",\"subject_label\":\"onboarding smoke test\",\"acceptance_criteria_ref\":\"answer.accuracy@1\"}")
IID=$(echo "$INTENT" | jq -r '.intent.id // empty')
echo "intent.id dinámico: $IID"   # vacío → revisá el 4xx: kind fuera del enum o ref sin @N
# TP-0 · el intent existe y no está en error
q "SELECT id,status FROM intents WHERE id='$IID';"
Evidencia

intent.id + fila en intents con status válido.

Concepto

Nova produce 1 de 4 desenlaces (MATCH/PLAN/FORGE/CANNOT). En PLAN, compila un plan = DAG de steps que referencian operaciones del catálogo cerrado (handle-intent-declared.ts). Regla dura: *catálogo cerrado — los steps solo usan operaciones registradas* (resolveOperation tira si el ref no existe).

Tarea

Desde el intent del H7, encontrar su plan y steps.

Validación dinámica
PID=$(q "SELECT id FROM plans WHERE intent_id='$IID' ORDER BY 1 LIMIT 1;")
echo "plan.id dinámico: $PID"
# TP-1 · cada operation_ref del plan (deberían resolver todos en el catálogo)
q "SELECT ordinal, step_id, operation_ref FROM steps WHERE plan_id='$PID' ORDER BY ordinal;"
Evidencia

los steps con sus operation_ref en orden.

Concepto

Una trace = una corrida del plan (queued→running→awaiting_human→succeeded). El executor durable (Inngest, execute-plan.ts) corre cada step con retries. Trazabilidad en 3+1 capas (observability-coverage.md): (1) step_executions — SIEMPRE, aun sin Langfuse; (2) span Langfuse por step con id determinista ${trace_id}-${stepId}; (3) generation LLM anidada; (4) trace top-level + OTel + alerts.

Tarea

Descubrir la trace más reciente y reconstruir sus steps.

Validación dinámica
TR=$(q "SELECT id FROM traces WHERE workspace_id='$WSID' ORDER BY started_at DESC LIMIT 1;")
echo "trace.id dinámico: $TR"
# TP-2 · correlación
q "SELECT status, inngest_run_id FROM traces WHERE id='$TR';"
# TP-3 · capa 1: una fila por CADA step, sin fallos inesperados
q "SELECT step_id, status, cost, error FROM step_executions WHERE trace_id='$TR' ORDER BY started_at;"
Evidencia

el trace.id, su status/inngest_run_id, y la tabla de step_executions.

Concepto

Antes de que un doc/media entre a los vectores pasa por anonimización PII (Presidio, 127.0.0.1:8400) — custodia dual: el original (con PII) se marca confidencial e intacto; a document_chunks/reranker/LLM solo entra texto anonimizado (<PERSONA>, <EMAIL>…). Si Presidio cae, no indexa nada y encola en cuarentena (fail-safe). Embeddings locales e5-small ONNX ($0). Ref: ARCHITECTURE.md §capa de privacidad, document-anonymize.ts.

Tarea

Ingestar un doc con PII de prueba y verificar que el chunk indexado no tiene PII cruda.

Validación dinámica
# descubre el doc/chunks más recientes del workspace
DOC=$(q "SELECT document_id FROM document_chunks WHERE workspace_id='$WSID' ORDER BY created_at DESC LIMIT 1;")
# TP-4 · el contenido indexado está anonimizado (buscá etiquetas, no PII cruda)
q "SELECT security_level, left(content,120) FROM document_chunks WHERE document_id='$DOC' LIMIT 3;"
# TP-5 · conteo de chunks + que existe embedding
q "SELECT count(*) FROM document_chunks WHERE document_id='$DOC' AND embedding IS NOT NULL;"
Evidencia

chunks con <ETIQUETAS> (no PII), security_level, y conteo con embedding.

Concepto

document.query@1.0.0 NO es un endpoint HTTP — es una operación (SuperSkill document-query) que corre como step durable dentro de un plan. Se lanza vía POST /api/workspaces/:id/superskills/document-query/launch (overrides en body.constraints, solo claves del input schema — superskills.ts:171), o vía compose/intents. Input real (document-query.ts:63): { question, top_n?, session_id?, clearance_level? } — el campo es question, no query. Recuperación híbrida (vector e5 + léxico ts_rank_cd por RRF) + reranker Cohere Rerank 4 Pro (degrada a RRF si el proveedor cae). El gate RBAC es levelsAtOrBelow(clearance) (substrate/query/clearance.ts): publico→['publico'], interno→['publico','interno'], confidencial→los tres; fail-closed (ausencia → publico). Ojo: el output EvidenceOut (document-query.ts:38) lleva chunk_idno security_level; para auditar RBAC se resuelve el nivel del chunk_id contra el índice.

Tarea

(a) probar el gate RBAC en aislamiento; (b) sobre un step document.query real, resolver los chunk_id de la evidencia a su security_level y confirmar que ninguno supera el clearance.

Validación dinámica
# TP-6a · el gate RBAC en aislamiento (determinista, siempre corre) — la función real del filtro
cd /home/clawd/agent-squad-app/apps/api && bun -e "import {levelsAtOrBelow} from './src/substrate/query/clearance'; \
  console.log('publico     →', levelsAtOrBelow('publico')); \
  console.log('confidencial→', levelsAtOrBelow('confidencial'))"
# esperado: publico → ['publico']  ·  confidencial → ['publico','interno','confidencial']

# TP-6b · sobre datos vivos: los chunk_id de la evidencia de un document.query real → su nivel real
SE=$(q "SELECT trace_id||'|'||step_id FROM step_executions se
        WHERE EXISTS (SELECT 1 FROM steps s WHERE s.plan_id=(SELECT plan_id FROM traces WHERE id=se.trace_id)
                      AND s.step_id=se.step_id AND s.operation_ref ILIKE 'document.query%')
        ORDER BY started_at DESC LIMIT 1;")
[ -n "$SE" ] && q "SELECT dc.security_level, count(*)
   FROM step_executions se,
        jsonb_array_elements(se.outputs_snapshot->'answer'->'evidence') ev
        JOIN document_chunks dc ON dc.chunk_id = ev->>'chunk_id'
   WHERE se.trace_id='${SE%|*}' AND se.step_id='${SE#*|}' GROUP BY dc.security_level;" \
  || echo "aún no hay un step document.query; lanzá el SuperSkill 'document-query' con clearance publico primero"
Evidencia

TP-6a (la jerarquía fail-closed) + TP-6b (los niveles reales de la evidencia — ninguno por encima del clearance usado).

Concepto

El evaluator (evaluator.run, system:evaluator) corre como un step más del plan, normalmente después de producir el artifact y antes del gate humano: valida el artifact contra los criterios de aceptación y deja un verdict. Una decisión humana puede sobreescribirlo (CONCEPTS.md). Orden típico: …compose → evaluator.run → publish → human_gate.

Tarea

Encontrar el step evaluator de una trace y su verdict.

Validación dinámica
# TP-9 · el step evaluator y su resultado (reusa $TR del H9)
q "SELECT se.step_id, s.operation_ref, se.status, se.verdict
   FROM step_executions se JOIN steps s ON s.plan_id=(SELECT plan_id FROM traces WHERE id='$TR')
     AND s.step_id=se.step_id
   WHERE se.trace_id='$TR' AND s.operation_ref ILIKE '%eval%';"
Evidencia

el step evaluator.run con status=succeeded + su verdict.

Concepto

El human_gate / run-gate suspende la corrida esperando aprobación humana de un resultado (waitForEvent, durable ≤72h, sin consumir recursos). Mientras espera, la trace está awaiting_human y el step del gate tiene exec: null. La decisión entra por /api/approvals y resume la corrida. (Distinto del build-gate de FORGE, que aprueba *capacidades*.)

Tarea

Encontrar una trace suspendida y aprobar su artifact pendiente.

Validación dinámica
# TP-10 · descubrir una trace suspendida y su artifact pendiente
TRW=$(q "SELECT id FROM traces WHERE workspace_id='$WSID' AND status='awaiting_human' ORDER BY started_at DESC LIMIT 1;")
ART=$(q "SELECT id FROM artifacts WHERE workspace_id='$WSID' AND produced_by->>'trace_id'='$TRW' AND status='pending_review' LIMIT 1;")
echo "trace suspendida=$TRW · artifact pendiente=$ART"
# aprobar (resume la corrida)
curl -s -X POST "http://127.0.0.1:4000/api/approvals" -H "Authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d "{\"workspace_id\":\"$WSID\",\"artifact_id\":\"$ART\",\"decision\":\"approved\"}" | jq -c
# verificar el resume
q "SELECT status FROM traces WHERE id='$TRW';"
Evidencia

el artifact pendiente descubierto, el 201 del approve, y la trace ya no awaiting_human.

Concepto

La salida son artifacts (kind, summary, content_addr sha256, status, produced_by.{trace_id,step_id}) y claims (sujeto-predicado-objeto + provenance: trace_id+step_id → FK al step exacto que lo produjo). Ese linaje es la trazabilidad: de un resultado podés ir hasta su origen (CONCEPTS.md §la cadena).

Tarea

Seguir un claim hasta el step exacto que lo produjo.

Validación dinámica
# TP-7 · claims con su provenance (reusa $TR)
q "SELECT subject, predicate, provenance->>'step_id' AS step, confidence
   FROM claims WHERE workspace_id='$WSID' AND provenance->>'trace_id'='$TR';"
# TP-12 · artifacts con su produced_by
q "SELECT kind, status, content_addr, produced_by->>'step_id' AS step
   FROM artifacts WHERE workspace_id='$WSID' AND produced_by->>'trace_id'='$TR';"
Evidencia

claims con provenance.step_id y artifacts con produced_by.step_id — el linaje cerrado.

Concepto

FORGE (4º desenlace) construye capacidades puras en runtime: generate spec+tests+código → verify en sandbox bwrap (red denegada, sin secrets) → build-gate humano → registry. Doble gate de seguridad: assertPureContract + staticSafetyCheck (forge/safety.ts). El dispatch de la capacidad forjada también corre en bwrap (no in-process) — el static check por regex es defensa en profundidad, no el muro (forge/sandbox.ts:executeInSandbox, forge/registry.ts). Off por defecto (FORGE_ENABLED).

Tarea

(Lectura, sin forjar en prod) inspeccionar candidatos pendientes y confirmar que el aislamiento de kernel está activo.

Validación dinámica
# TP-11 · candidatos en build-gate (si los hay)
curl -s "http://127.0.0.1:4000/api/forge/pending?workspace_id=$WSID" \
  -H "Authorization: Bearer $TOKEN" | jq '.pending | length'
# confirmar bwrap activo (el muro de verify Y execute) — probe idéntico al de sandbox.ts
bwrap --ro-bind / / --unshare-all --die-with-parent /usr/bin/true && echo "bwrap OK (namespaces sin privilegios)"
cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns   # 1 + perfil /etc/apparmor.d/bwrap = correcto
Evidencia

el conteo de pendientes + el probe bwrap en exit 0.

Concepto

La prueba final del substrato: con solo un trace_id respondés qué steps corrieron, en qué orden, con qué inputs/outputs, cuánto costó cada uno, qué verdict dio el evaluador, dónde falló, y —para los steps LLM— el prompt exacto y los tokens (observability-coverage.md). buildTraceDetail ensambla head + steps + artifacts + claims.

Tarea

Reconstruir una corrida entera desde su trace_id y cruzarla con Langfuse.

Validación dinámica
# TP-13 · la vista de traza completa (endpoint espejo del que usa el office/demo)
curl -s "http://127.0.0.1:4000/api/workspaces/$WSID/traces/$TR" -H "Authorization: Bearer $TOKEN" \
  | jq '{intent: .trace.intent_statement, steps: [.steps[] | {op: .operation_ref, status: .exec.status}], artifacts: (.artifacts|length), claims: (.claims|length)}'
# capa 2/3 · el mismo trace en Langfuse (span id determinista ${trace_id}-${stepId})
LFHOST=$(grep ^LANGFUSE_HOST $API/.env | cut -d= -f2-); echo "Langfuse: ${LFHOST:-http://127.0.0.1:3030} → buscar trace $TR"
Evidencia

el JSON reconstruido (intent + steps + conteos) desde un solo trace_id, y el trace correspondiente visible en Langfuse.

▸ Bootstrap común (config descubierta del entorno — la usan varios hitos)
# --- config descubierta del entorno (nunca hardcodeada) ---
API=/home/clawd/agent-squad-app/apps/api
WEB=/home/clawd/agent-squad-app/apps/web
DBURL=$(grep ^SUBSTRATE_DB_URL   $API/.env | cut -d= -f2-)
TOKEN=$(grep ^SUBSTRATE_API_TOKEN $API/.env | cut -d= -f2-)     # NO imprimir en pantalla compartida
WSID=$(grep  ^SUBSTRATE_WORKSPACE_ID $WEB/.env | cut -d= -f2-)  # el workspace FIJO de hoy
IBASE=$(grep ^INSFORGE_URL $WEB/.env | cut -d= -f2- | sed 's:/*$::')
IANON=$(grep ^PUBLIC_INSFORGE_ANON_KEY $WEB/.env | cut -d= -f2-)
ISK=$(grep   ^INSFORGE_SERVICE_KEY $WEB/.env | cut -d= -f2-)
q(){ psql "$DBURL" -At -c "$1"; }   # helper: query dinámica al grafo