Volver a la app AltLegal AI Administración

Manual de administración

Guía operativa para administradores: configurar modelos LLM, autorar casos de uso, gestionar equipos y permisos. Mezcla pasos de UI con cambios en código (YAML / .md / .env) cuando la UI todavía no cubre el caso.

Sólo para admins. Cambios en archivos de configuración (manifest, casos de uso, .env) requieren acceso al repositorio y reinicio de servicio. En desarrollo local: docker compose build app && docker compose up -d app. En producción (GCP): ./deploy.sh deploy rebuild + push imagen + nueva revisión Cloud Run (frontend y backend van en la misma imagen). Ver Despliegue. Las acciones marcadas UI se realizan desde la app sin redeploy. Las marcadas código requieren editar archivos.
Comandos para:

Modelos LLM — visión general

La plataforma usa un sistema de cadenas de modelos: cada caso de uso declara una lista ordenada de modelos preferidos. En tiempo de ejecución la cadena se resuelve aplicando filtros (permisos del usuario + claves API configuradas), y un wrapper resiliente (ResilientProvider) la recorre saltando al siguiente modelo si el actual falla con un error transitorio (HTTP 429/503/504, timeout, proveedor sin clave).

La resolución sigue este orden de capas, la primera que coincide gana (no se acumulan):

  1. model_chain_by_phase[phase] — override por fase (extracción, razonamiento, drafting, validación). Hoy el bucle del agente no emite fronteras de fase, así que esta capa está reservada para uso futuro.
  2. model_chain_by_tool[tool_name] — override por herramienta. Reservado: las herramientas del agente actuales no invocan al LLM por su cuenta.
  3. model_chain del caso de uso — la cadena base.

Después se concatena la cadena global de fallback (siempre presente). Esto resuelve el caso "el caso de uso pide modelos que no están configurados → caer al fallback general".

Proveedores y claves API código

Las claves se guardan en .env en la raíz del repo y se inyectan al contenedor vía docker-compose.yml. Variables soportadas:

Las claves viven como secretos de Google Secret Manager en el proyecto GCP (kebab-case: claude-api-key, deepseek-api-key, etc.) y se montan al servicio Cloud Run vía --set-secrets. El comando ./deploy.sh secrets sync sincroniza el .env.deploy local al Secret Manager (idempotente: crea versión nueva por cada secret que difiere; el SA tiene roles/secretmanager.secretAccessor). Proveedores soportados:

Variable de env (en runtime)ProveedorEstado
Google (Gemini)Funcional vía Vertex AI + ADC (sin API key — auth con la SA app-sa con roles/aiplatform.user). Migrado 2026-05-14.
LEGAL_AI_CLAUDE_API_KEYAnthropic (Claude)Funcional (secret claude-api-key en prod)
LEGAL_AI_DEEPSEEK_API_KEYDeepSeekFuncional vía SDK OpenAI-compatible (secret deepseek-api-key)
LEGAL_AI_OPENAI_API_KEYOpenAIEsqueleto — clase definida pero sin SDK conectado

Gemini ya NO usa API key: la migración a Vertex AI (commit e13ca71) reemplazó LEGAL_AI_GEMINI_API_KEY por autenticación ADC. La disponibilidad del provider Gemini se gate por LEGAL_AI_GOOGLE_CLOUD_PROJECT (env var no-secret).

Si una variable no está seteada, su proveedor queda no disponible: cualquier modelo de ese proveedor que aparezca en una cadena se filtra automáticamente al resolver. Si la clave existe pero el modelo falla en runtime, ResilientProvider registra un evento llm_chain_hop en los logs y salta al siguiente modelo.

Dos lugares para la clave (dev). Setear la variable solo en .env no basta: docker-compose.yml debe pasarla al contenedor con una línea como LEGAL_AI_DEEPSEEK_API_KEY: ${LEGAL_AI_DEEPSEEK_API_KEY:-}. Si agregas un proveedor nuevo, agrega ambos.
Rotar una clave en producción. Actualiza la variable en .env.deploy local, luego corre ./deploy.sh secrets sync — crea una versión nueva del secreto en Secret Manager. Cloud Run referencia :latest así que la nueva versión la recoge en el próximo cold start; para forzar adopción inmediata corre ./deploy.sh deploy (nueva revision). Las versiones previas quedan en historial para rollback (gcloud secrets versions list NAME).

Por qué las claves no se editan desde la UI

Decisión explícita de diseño: las claves API viven solo en variables de entorno (cargadas por Settings al boot vía pydantic-settings). No hay endpoint PUT /api/admin/api-keys, ni columna api_key en ninguna tabla. Para cambiar una clave: editar .env y reiniciar la app.

Permitir edición desde UI obligaría a persistirlas en DB (no hay otra forma de modificarlas en runtime sin restart). Eso introduce trade-offs concretos:

AspectoHoy: claves en .envAlternativa: claves en DB editables por UI
Cambio de clave Editar .env, redeploy del contenedor (~30s). UI → guardar → siguiente turno usa la nueva clave.
Superficie de exposición Plaintext sólo en archivos del host y memoria del contenedor. .env está en .gitignore. Plaintext en la tabla — visible para cualquier acceso a DB (replicas, dumps, herramientas de ops). Mitigable con encriptación en reposo + KMS, pero implementar bien es trabajo real.
Backups y replicas Las claves no aparecen en pg_dump. Restaurar un backup en otro entorno no expone las claves de producción. Cada dump contiene credenciales activas. Gestionar quién puede leerlos y por cuánto tiempo se vuelve un problema operacional.
Rotación Operación de ops: editar archivo + redeploy. No hay UI history; trazabilidad vía git/CI. Self-service, con audit log fácil de exponer (quién cambió, cuándo).
Multi-tenant (clave por cliente) No soportado: una clave global por proveedor. Trivial: tabla con tenant_id + clave + proveedor.
Validación atómica Settings se cargan al boot. Si una clave es inválida, el proveedor queda no-disponible y la cadena lo filtra silenciosamente; las claves presentes hacen raise ProviderNotConfiguredError ante intento de uso, lo que dispara el fallback de la cadena. Hay que validar al guardar (round-trip al proveedor) o aceptar que un usuario guarde una clave rota y descubra el error en producción.
Integración con secret managers 12-factor estándar. Funciona out-of-the-box con Kubernetes Secrets, AWS Secrets Manager, GCP Secret Manager (montar como env var). Bypass del secret manager — la DB se vuelve el sistema de gestión de secretos, lo cual no es su propósito.
Rollback ante incidente Revertir el commit del .env (vía Git) o restaurar el archivo desde backup. Operación reversible y auditable. Restaurar la fila en DB, pero en el ínterin cualquier sesión nueva ya usó la nueva clave.

El balance hoy se inclina al modelo env-only por tres razones acumulativas:

  1. La app es single-tenant — no necesita una clave por cliente. Si el roadmap pasa a SaaS multi-tenant, esa razón cae y vale revisar.
  2. La rotación es infrecuente — pasa cuando rota una API key (raro), no es flujo diario que valga UI dedicada.
  3. El costo de hacerlo bien es alto — encriptación en reposo, KMS, audit log de cambios, validación al guardar. Más trabajo del que ahorra hasta tener un volumen real de operación.

Si en algún momento se decide migrar a DB-editables, la implementación correcta debería incluir: encriptación con clave en KMS, audit log inmutable, validación round-trip al guardar, fallback automático a env si la fila DB está ausente, y mecanismo de rollback. No es trivial.

Lo que sí se edita desde UI hoy. Cadenas de modelos por caso de uso (vía editor de casos de uso), políticas de permisos, miembros de equipos, perfiles de usuario. Todo eso cambia configuración de comportamiento, no credenciales.

El manifiesto de modelos código

Fuente única de verdad: src/permissions/llm_models_manifest.yaml. Define qué modelos existen, a qué proveedor pertenecen, en qué tier están y cuál es su sustituto inmediato. Cualquier modelo no registrado aquí no se puede referenciar en las cadenas.

items:
  - id: claude-opus-4-7
    provider: anthropic
    label: "Claude Opus 4.7"
    category: "Anthropic"
    tier: high
    next: claude-sonnet-4-6
  - id: gemini-2.5-flash
    provider: google
    label: "Gemini 2.5 Flash"
    category: "Google"
    tier: mid
    next: gemini-2.5-flash-lite
  - id: gemini-2.5-flash-lite
    provider: google
    label: "Gemini 2.5 Flash Lite"
    category: "Google"
    tier: low
    next: null
  - id: deepseek-v4-flash
    provider: deepseek
    label: "DeepSeek V4 Flash"
    category: "DeepSeek"
    tier: low
    next: gemini-2.5-flash

tiers:
  high: [claude-opus-4-7, gemini-2.5-pro, gpt-5, deepseek-v4-pro]
  mid:  [claude-sonnet-4-6, gemini-2.5-flash, gpt-5-mini]
  low:  [claude-haiku-4-5, gemini-2.5-flash-lite, gpt-5-nano, deepseek-v4-flash]

global_fallback_chain:
  - gemini-2.5-flash-lite   # validado para chat común y análisis estándar
  - gemini-2.5-flash        # hop cuando lite rebota o caso pide más
  - deepseek-v4-flash       # salvavidas cross-vendor

Agregar un modelo nuevo

  1. Editar src/permissions/llm_models_manifest.yaml y agregar una entrada en items con id, provider (uno de anthropic | google | openai | deepseek), label, category, tier (high | mid | low) y next (otro id conocido o null).
  2. Agregar el id al array correspondiente del bloque tiers. El orden dentro del tier importa: define el orden por defecto cuando alguien usa tier:high.
  3. Si el proveedor todavía no tiene clase implementada (ej. OpenAI hoy), saltar al paso 5 — el catálogo lo aceptará pero las llamadas fallarán con ProviderNotConfiguredError hasta que se conecte el SDK.
  4. Reiniciar la app: docker compose build app && docker compose up -d app. Redesplegar: ./deploy.sh deploy. El manifest viaja dentro de la imagen Docker, así que cambios en YAML requieren rebuild.
  5. Validar con el script de auditoría: PYTHONPATH=. python scripts/audit-permission-catalog.py --check-chains. Imprime CHAIN AUDIT — OK si todo está consistente.
Validaciones al boot. El sistema valida el manifiesto al iniciar y aborta si encuentra: next: apuntando a un id desconocido, miembros de tier que no existen, ciclos en el grafo de sustitución, o entradas sin provider. Errores tipográficos no llegan a producción.

Tiers (alta / media / baja)

Los tiers son grupos por capacidad. Existen tres: high (modelos premium con razonamiento profundo), mid (modelos estándar con buen balance) y low (modelos económicos y rápidos para tareas simples). Una cadena puede referenciar un tier completo con la sintaxis tier:<nombre>:

model_chain:
  - tier:high     # se expande a [claude-opus-4-7, gemini-2.5-pro, gpt-5, deepseek-v4-pro]
  - tier:mid      # se expande a [claude-sonnet-4-6, gemini-2.5-flash, gpt-5-mini]

El orden dentro del tier es el orden literal del array en el manifiesto. Para reordenar dentro de un tier, edita el array tiers.<nombre>. Para reordenar solo en un caso de uso particular, lista los modelos explícitamente en lugar de usar tier: (ver Reordenar modelos).

Cadena global de fallback

global_fallback_chain en el manifiesto. Esta lista siempre se concatena al final de la cadena resuelta de cada caso de uso. Asegura que si la cadena del caso de uso solo referencia modelos no configurados, el sistema cae a una opción que sí está disponible.

global_fallback_chain:
  - gemini-2.5-flash-lite
  - gemini-2.5-flash
  - deepseek-v4-flash

Decisión (2026-05-11): cascada Google barata-a-cara con DeepSeek-flash como red de seguridad cross-vendor. gemini-2.5-flash-lite validado en pruebas reales para chat conversacional, consultas cortas y análisis estándar. gemini-2.5-pro no está en el fallback ni es preferido en ningún caso de uso — queda como override-only desde el picker del chat para tareas que ameriten razonamiento más profundo.

Ejemplo de resolución concreta: caso de uso pide [claude-opus-4-7, gpt-5], solo está configurada Gemini. El resolver descarta los dos por env-filter, concatena global_fallback_chain, queda [gemini-2.5-flash-lite, gemini-2.5-flash, deepseek-v4-flash] (más cualquier hop adicional del grafo).

Grafo de sustitución (next:)

Cada modelo tiene un next: opcional que apunta a un sucesor "inmediato" para casos donde la cadena explícita se agota. Después de aplicar la cadena del caso de uso + el fallback global, el resolver hace una caminata del grafo desde el último modelo de la cadena resuelta y agrega los sucesores no vistos (filtrados por permisos y entorno).

En la práctica, la cadena de fallback global ya cubre la mayoría de los casos. El grafo agrega redundancia adicional. Si un modelo está aislado y nunca debe sustituirse, ponle next: null.

Cadenas por caso de uso código

Cada caso de uso (src/casos_de_uso/id.md) declara su cadena en el frontmatter YAML. Tres campos opcionales:

# Cadena base (la usual):
model_chain:
  - gemini-2.5-flash
  - gemini-2.5-flash-lite

# Override por herramienta (reservado, no activado en runtime):
model_chain_by_tool:
  search_laws: [tier:low]
  risk_matrix_compute: [tier:high]

# Override por fase (reservado, no activado en runtime):
model_chain_by_phase:
  drafting: [claude-opus-4-7]
  extraction: [tier:low]

Los tres aceptan tanto IDs literales (claude-opus-4-7) como referencias de tier (tier:high). El validador rechaza cualquier referencia desconocida al cargar el caso de uso (warning en logs, caso queda fuera del catálogo hasta corregir).

Reordenar modelos dentro de un caso de uso

Tres patrones según qué tan flexible necesitas ser:

Patrón A — referencia al tier (orden del manifiesto)

model_chain:
  - tier:low   # → [claude-haiku-4-5, gemini-2.5-flash, gpt-5-nano, deepseek-v4-flash]

El orden lo decide el manifest. No tienes control fino dentro del tier.

Patrón B — lista explícita (tu orden)

model_chain:
  - deepseek-v4-flash
  - gemini-2.5-flash
  - gpt-5-nano
  - claude-haiku-4-5

El resolver respeta el orden tal cual lo escribes.

Patrón C — mezcla (cabeza explícita + cola por tier)

model_chain:
  - deepseek-v4-flash    # primero, prioridad propia de este caso
  - tier:low             # resto del tier; deepseek-v4-flash deduplicado

El resolver dedupe preservando el primero encontrado, así que la cabeza explícita gana ante empates.

Override por herramienta — reservado

El campo model_chain_by_tool existe en el schema y se valida, pero las herramientas actuales no invocan al LLM por su cuenta — todas las llamadas pasan por el provider único del agente. Cuando alguna herramienta gane su propio call site al LLM (ejemplo futuro: un extractor que llama a Haiku para procesar un anexo grande), este override empezará a tener efecto.

Override por fase — reservado

Mismo estado: el campo model_chain_by_phase existe pero el bucle del agente hoy no marca fronteras de fase. La infra (resolve_chain(phase=...)) está lista; falta declarar dónde se activa cada fase. TODOs en el código (src/agent/agent.py, src/extraction/amount_extractor.py, src/extraction/outcome_classifier.py) marcan los puntos de futura conexión.

Verificar qué modelo respondió

Tres ventanas de observabilidad disponibles hoy:

1 — Auditoría declarativa (cadenas configuradas)

PYTHONPATH=. python scripts/audit-permission-catalog.py --check-chains

Imprime, por cada caso de uso, su cadena cruda (antes de aplicar permisos y filtros de entorno). Útil para confirmar que un cambio en YAML llegó al sistema.

2 — Logs estructurados del runtime

Cada llamada a resolve_chain emite un log INFO con campos raw_chain, fallback_env, chosen. Cada salto de fallback emite un WARN con from_model, to_model, error_kind. Tail típico:

docker compose logs -f app | grep -E "resolve_chain|llm_chain_hop|llm_chain_exhausted"
# Cloud Run streaming logs (project default = activo; agrega --project=<id> si difiere)
./deploy.sh logs | grep -E "resolve_chain|llm_chain_hop|llm_chain_exhausted"
# o directo:
gcloud run services logs tail altlegal-ai --region=europe-southwest1 \
  | grep -E "resolve_chain|llm_chain_hop|llm_chain_exhausted"

# o desde Logs Explorer en la consola GCP con filtro:
#   resource.type="cloud_run_revision"
#   resource.labels.service_name="altlegal-ai"
#   textPayload =~ "resolve_chain|llm_chain_hop|llm_chain_exhausted"

3 — Eventos en el stream del chat

El stream SSE del chat ahora emite dos eventos relevantes:

4 — Pill de modelo en el header del chat UI

La cabecera del chat muestra un pill con el nombre corto del modelo activo (lee los eventos del punto 3). Tres estados visuales:

Click en el pill abre un dropdown con la chain resuelta para el caso de uso (consume GET /api/chat/sessions/{id}/available-models). Cada opción muestra el label del manifest + tier hint (profundo / balanceado / rápido). Al elegir un modelo, SendMessageRequest.model_override viaja en el siguiente turno y LegalAgent._init_chain lo prepende a la chain. El override se limpia al cambiar de sesión o al re-elegir el preferido.

Lo que aún no hace esta app

Decisiones de scope tomadas en la v1 del sistema de cadenas. Cada uno tiene un plan de evolución registrado en el spec (docs/superpowers/specs/2026-05-01-llm-model-priority-fallback-design.md):

CapacidadEstadoWorkaround
UI de admin para editar model_chain de un caso de usoNo implementado (Fase 2)Editar el archivo .md del caso de uso y hacer commit + redeploy.
Override de cadenas por tenant (multi-cliente)Reservado (tabla model_chain_overrides nombrada pero no creada)
Provider OpenAI conectado a SDK realEsqueleto — siempre lanza ProviderNotConfiguredErrorUsar Gemini, Claude o DeepSeek hasta conectar el SDK.
Re-resolución por herramienta / por fase en runtimeInfra lista, call sites no conectadosEl campo se valida y se cachea pero no se aplica. Definir cuál tool/phase debe activarlo es trabajo futuro.
Fallback por costo / latencia (degradar si modelo es muy caro o lento)No implementadoSolo se hace fallback en errores duros (429/503/504/timeout/proveedor sin clave).
Cuotas de uso por modelo / por usuario / por equipoNo implementadoConfiar en los límites del proveedor upstream.
Bloqueo de modelo por proyecto sensible (ej. forzar Sonnet en proyectos con datos sensibles)No implementadoEditar el caso de uso correspondiente; o crear un caso de uso dedicado a "alta sensibilidad" con su propia cadena.
Adjuntos PDF / DOCX en proveedor DeepSeekNo soportado por DeepSeek upstreamUsar Gemini o Claude para sesiones con documentos adjuntos. La cadena se filtra automáticamente en estos casos.
UI muestra model_used debajo del turnoEl backend ya emite el evento; falta render en frontendInspeccionar en DevTools → Network → SSE.
Streaming con fallback mid-streamDiseño explícito: si ya se emitieron tokens, NO se hace fallback (se propaga el error)Esto es por diseño para evitar emisiones duplicadas. El usuario verá un error con respuesta parcial.

Casos de uso — qué son y dónde viven

Un caso de uso es una receta pre-configurada para una tarea recurrente: un prompt master, una cadena de modelos preferidos, una whitelist de herramientas, restricciones de temperatura y tokens, y metadatos para mostrarlo en el catálogo (icono, chips, sugerencias iniciales). Al activarlo en una sesión, el agente queda restringido a operar dentro de ese marco.

Los archivos viven en src/casos_de_uso/id.md. Cada archivo tiene dos secciones:

  1. Frontmatter YAML entre --- al inicio: metadatos, configuración del modelo, whitelist de herramientas, datos para el catálogo.
  2. Cuerpo Markdown: el prompt completo del agente (rol, misión, instrucciones, formato de respuesta esperado).

Frontmatter — campos

CampoTipoObligatorioDescripción
idstringIdentificador único, slug en minúsculas-con-guiones (ej. pdp-eipd). 3–64 chars, regex ^[a-z0-9][a-z0-9-]{2,63}$. Inmutable post-crear (rompe sesiones existentes).
nombrestringEtiqueta visible (ej. "Evaluación de Impacto en Protección de Datos").
categoriastringAgrupación libre (ej. "Protección de datos", "Litigios").
descripcionstring ≤160Una línea para el catálogo.
iconoenumUno de los iconos conocidos: shield-check, scale-balance, document-check, pen-draft, gavel, etc. Lista completa en src/casos_de_uso/schema.py:KNOWN_ICONS.
model_chainlistNoCadena de modelos preferidos. Si se omite, se usa la cadena global de fallback.
model_chain_by_tooldictNoOverride por herramienta. reservado
model_chain_by_phasedictNoOverride por fase. reservado
temperaturafloat 0–2NoTemperatura del LLM. null usa el default del proveedor.
max_output_tokensint 256–16384NoCap de tokens de salida.
herramientas_permitidaslist<tool_id>NoWhitelist de herramientas del agente. null = todas. Lista completa en KNOWN_TOOL_NAMES.
mensaje_inicial_usuariostringNoMensaje pre-rellenado en el input al crear sesión.
chipslist 1–3Etiquetas cortas en el catálogo.
sugerencias_inicialeslist 3Tres ejemplos de prompts mostrados al crear sesión.
placeholder_inputstringPlaceholder del input principal.
ordenintNo (default 100)Orden de aparición en el catálogo.
habilitadoboolNo (default true)false oculta el caso del catálogo sin borrarlo.
trayectoriastringNoIdentificador de la trayectoria multi-paso a la que pertenece (si aplica).
pasoint 1–999NoNúmero de paso dentro de la trayectoria.
inputs_requeridos / outputs_producidoslist<artifact_type>NoTipos de artefactos que el paso consume / produce. Drives la lógica de "siguiente paso desbloqueado".
subpromptslist<string> (2–20)NoMulti-turno (ver sección dedicada). Si está set, el chat ejecuta cada entrada como un sub-turno LLM separado y acumula los outputs en un solo entregable. Requiere exactamente 1 tipo en outputs_producidos. Si es null, el caso es single-turn (default).

Cuerpo del prompt

Después del frontmatter, todo el resto del archivo es el prompt master del agente. Convención del repo: secciones numeradas en español (Rol, Misión, Corpus, Reglas, Formato de respuesta, etc.). Ejemplos en cualquier pdp-*.md. El cuerpo se inyecta literal como system_prompt en cada turno de la sesión asociada.

---
id: pdp-eipd
nombre: PDP — Evaluación de Impacto
# ... resto del frontmatter ...
---

## 1. Rol
Eres un consultor especializado en protección de datos personales bajo la
Ley N° 21.719. Operas como evaluador metódico que produce informes EIPD
estructurados...

## 2. Misión
- Identificar los tratamientos de datos personales que justifican una EIPD.
- Cuantificar riesgos por tipo de titular y categoría de dato.
...

Crear / editar un caso de uso en código código

  1. Copiar src/casos_de_uso/TEMPLATE.md a src/casos_de_uso/<id>.md. El nombre del archivo no necesita coincidir con el id, pero por convención sí.
  2. Editar el frontmatter respetando los tipos. id debe ser único entre todos los archivos del directorio.
  3. Escribir el cuerpo del prompt.
  4. Validar la sintaxis del frontmatter:
    PYTHONPATH=. python -c "from src.casos_de_uso.loader import load_all, list_enabled_use_cases; load_all(); print(len(list_enabled_use_cases()), 'casos cargados')"
    Si hay errores de validación se imprimen en stderr y el caso queda fuera del catálogo.
  5. Reiniciar la app o usar el endpoint POST /api/casos-de-uso/reload (admin only) para recargar sin redeploy.

Editar desde la UI UI

La app expone editor admin para casos de uso vía la API /api/admin/casos-de-uso/<id>. Permite:

El editor en la UI vive en el panel de configuración → pestaña "Casos de uso" (cuando está habilitado por permisos). Los cambios se persisten directamente al archivo .md. Nota: editar archivos en disco desde la UI implica que para preservar los cambios entre rebuilds, hay que commitear el archivo modificado al repositorio.

Pills informativas en el listado

El listado tabular de casos de uso muestra pills al lado del nombre cuando aplican:

Los criterios para mostrar las pills se resuelven en cliente (web/app.js función _renderList dentro del módulo de casos de uso) leyendo el frontmatter ya cargado del backend; no requieren llamada extra.

Cambios desde UI viven en el contenedor. Los archivos .md están dentro de la imagen Docker en runtime. Editar desde la UI los modifica en el container, pero un rebuild los pisa con la versión del repo. Tras editar en UI: docker compose cp altlegal-ai-app-1:/app/src/casos_de_uso/<id>.md src/casos_de_uso/ y commitear.
Casos de uso viven en DB (no en filesystem). Desde el phase-out 2026-05-19 los .md ya no son source-of-truth: la tabla tenant_altlegal.casos_de_uso en Cloud SQL es la verdad. Edits desde la UI persisten directamente en DB y sobreviven redeploys. El backup en el repo (backup/casos_de_uso_md/) sirve solo como fallback de re-hidratación manual si la tabla queda vacía por un drop/restore. Para evolucionar un caso: edita desde la UI; opcionalmente exporta el .md y commitealo al backup si quieres versionar el snapshot.

Herramientas permitidas

El campo herramientas_permitidas es una whitelist contra el set KNOWN_TOOL_NAMES definido en src/casos_de_uso/schema.py. Si se declara, el agente solo puede invocar esas herramientas durante la sesión. Si se omite (null), todas están disponibles.

Herramientas disponibles hoy:

Trayectorias y pasos

Una trayectoria agrupa casos de uso ordenados que avanzan un proyecto del cliente (ej. la trayectoria consultoria-pdp-21719 tiene 19 pasos). Para hacer que un caso de uso sea un paso de trayectoria:

trayectoria: consultoria-pdp-21719
paso: 50
inputs_requeridos:
- ficha-proyecto
outputs_producidos:
- mapa-de-datos

inputs_requeridos determina qué artefactos previos deben existir aprobados para que el paso esté "desbloqueado" en la UI del proyecto. outputs_producidos determina qué artefactos pasan a estar disponibles para pasos posteriores. Tipos válidos en KNOWN_ARTIFACT_TYPES (src/proyecto_artifacts/schema.py).

Multi-turno con subprompts

Algunos pasos producen entregables tan extensos que un solo turno LLM no alcanza — el modelo trunca por el cap de max_output_tokens, o pierde calidad cuando el output supera ~4 mil tokens. Para esos casos, declarás una lista de subprompts en el frontmatter: el chat ejecuta cada entrada como un sub-turno LLM separado, acumula los outputs y al final emite UN solo entregable consolidado del tipo declarado en outputs_producidos.

Cuándo usarlo

Schema

subprompts:
- "Redacta el eje 3.1 — Políticas generales y específicas. Estructura: Diagnóstico → Recomendaciones → Tabla de prioridad/complejidad/medios."
- "Redacta el eje 3.2 — Procedimientos y manuales operativos. Mismo formato."
- "Redacta el eje 3.3 — Reglamento Interno (RIOHS). Mismo formato."
# … hasta 20 entradas
outputs_producidos:
- seccion-iii-brechas       # exactamente UN tipo (validado por el schema)

Reglas y validaciones

Comportamiento en runtime

Ejemplos en el repo

UI esperada

Para que el abogado entienda por qué la respuesta llega en bloques, documenta el caso en web/ayuda-consultoria-pdp.html con la pill multi-turn — N sub-prompts y describe los ejes. Ver paso 130 / 140 como referencia.

Validación y reload

Equipos y permisos — modelo conceptual

El sistema combina cuatro entidades:

Equipo General (Default). Todo usuario autenticado se agrega automáticamente a este team en su primer login (auto-creado al boot vía migración 027). Las políticas que crees aquí aplican como baseline transversal: si un user nuevo del dominio no se asigna a ningún team especializado, hereda lo que esté configurado en General.

Este team no se puede archivar ni renombrar — la integridad del baseline depende de que siempre esté presente. Solo la descripción es editable. Resuelve el problema clásico donde un user sin team / profile heredaba implícitamente "todo permitido" porque las capas restrictivas vacías devolvían el universo.

Primer paso post-deploy: abre Equipos y permisos → "General" → Editar políticas, y configura los denies que aplican a todo el estudio (ej. deny menu_option=admin para que la pestaña Admin solo la vean los role='admin'; deny llm_model=claude-opus-* para reservar Opus a perfiles específicos).

Tipos de recurso permisionables

resource_typeQué controlaFuente del catálogo
toolHerramientas del agenteIntrospección (KNOWN_TOOL_NAMES)
paso_caso_usoCasos de uso con paso (parte de una trayectoria)Introspección
caso_uso_transversalCasos de uso sin paso (ej. consulta libre)Introspección
menu_optionEntradas del menú lateral (Historial, Favoritos, Documentos, etc.)Manifest curado (menu_manifest.yaml)
llm_modelModelos LLMManifest (llm_models_manifest.yaml)
documento_tipoTipos de documento (reservado, sin items en v1)Stub

Políticas y precedencia

Una política tiene cuatro campos clave:

Resolución para un usuario en runtime:

  1. user_override gana siempre. Una política con scope_type='user_override', scope_ref=<email> tiene precedencia absoluta. Si dice allow [X], X queda permitido aunque su team lo deny.
  2. profile luego. Aplica si el usuario tiene ese profile.
  3. team al final. Si el usuario pertenece a varios teams, sus políticas se combinan: cualquier deny gana entre teams (la unión de denies).
  4. Default cerrado: si no hay ninguna política allow que cubra el recurso, queda denegado. La excepción es admin.

Glob expansion: las policies pueden contener wildcards (*, ?, [...]) que se expanden contra el catálogo del resource_type al resolver. Ejemplo: "claude-opus-*" matchea cualquier id que empiece con claude-opus- en el manifest de modelos.

CRUD desde la UI UI

Configuración → pestaña "Equipos y permisos". Solo visible para usuarios con role=admin.

Gestión de equipos

Asignación de perfil

Dentro de un equipo, para cada miembro se puede setear el profile (abogado | paralegal | sin perfil) directamente con un selector inline.

Editor de políticas

Para cada equipo (o profile, o user_override), aparece un editor con un selector de tipo de recurso → checkboxes con los items del catálogo + campo de "globs adicionales". Al guardar, se hace upsert: si ya existía una policy con ese (scope, resource_type), se reemplaza. Borrar todos los checkboxes y los globs equivale a "todo denegado por default" para ese recurso/scope.

Cualquier cambio invalida la caché. Cada upsert/delete de policy llama a invalidate_all() en el resolver de permisos y de cadenas LLM. Los efectos son inmediatos para todas las sesiones nuevas; sesiones en curso terminan con su provider ya construido.

Patrones glob

Útil para reglas amplias sin enumerar cada id. Ejemplos prácticos:

# Negar a paralegales el acceso a cualquier modelo Opus (futuro o presente):
scope: profile=paralegal
resource_type: llm_model
mode: deny
resource_ids: ["claude-opus-*"]

# Permitir a un equipo solo herramientas de causas judiciales:
scope: team=Litigios
resource_type: tool
mode: allow
resource_ids: ["query_cases", "get_case_*", "court_statistics", "search_documents"]

# Permitir a un usuario específico todos los pasos de PDP:
scope: user_override=ana@altlegal.cl
resource_type: paso_caso_uso
mode: allow
resource_ids: ["pdp-*"]

Auditoría

Hay dos capas de auditoría complementarias:

  1. Catálogo de permisos vs políticas existentes — el script scripts/audit-permission-catalog.py detecta inconsistencias estáticas (orphans + cadenas LLM rotas).
  2. Histórico de mutaciones — toda creación / edición / borrado de team, miembro, perfil o política queda registrada en tenant_altlegal.audit_events (eventos policy_upserted, policy_deleted, team_*, profile_updated, user_role_changed). Visible en Configuración → Actividad → Eventos. Detalle en Actividad — telemetría.

Modos del script:

Ambos retornan exit code 1 si encuentran inconsistencias — útiles para CI o git hooks.

Bypass de admin

Usuarios con role='admin' bypasean toda la lógica de permisos: ven todos los menús, todas las herramientas, todos los modelos, todos los pasos. La cadena LLM resuelta para un admin no aplica filtros de permisos (sí aplica el filtro de claves API configuradas — un admin sin claves de Anthropic igual no puede llamar a Claude).

Cambiar el rol de un usuario hoy es operación de DB:

docker compose exec db psql -U postgres -d legal_ai -c \
  "UPDATE tenant_altlegal.users SET role='admin' WHERE email='dtorres@altlegal.cl';"
# Conectar via Cloud SQL Auth Proxy (corre detrás de IAM, no expone la DB pública):
gcloud sql connect altlegal-ai-pg --user=postgres --database=legal_ai \
  --quiet << 'EOF'
UPDATE tenant_altlegal.users SET role='admin' WHERE email='dtorres@altlegal.cl';
EOF

El usuario debe cerrar sesión y volver a entrar para que el cambio surta efecto.

No quitarse el último admin. El sistema no impide que un admin se quite a sí mismo el rol. Si lo haces y eras el único admin, perderás acceso a "Equipos y permisos" hasta que alguien (o un script) restaure el rol vía DB. Convención: siempre dejar al menos dos admins por instancia.

Impersonación — "Ver como otro usuario" UI

Para debuggear permisos sin loguearse como otro usuario, un admin puede iniciar una sesión de solo lectura que se comporta como si fuera otro user. Útil para validar "¿el paralegal Y realmente no puede aprobar artefactos? ¿el team Z ve el menú Configuración?" sin interrumpir al usuario afectado.

Cómo se inicia

  1. Configuración → Usuarios.
  2. Junto a cada user (excepto tú mismo) aparece el botón "Ver como".
  3. Click → confirm modal. Acepta para iniciar.
  4. La página recarga. Aparece un banner amarillo arriba: "Estás viendo como X (sesión real: Y). Modo lectura.".
  5. Mientras dure la impersonación, todos los menús, casos de uso, modelos LLM, sesiones, etc. se filtran como los vería el target.
  6. Para terminar: click en Salir en el banner.

Modo lectura forzado

Mientras hay una sesión de impersonación activa, un middleware del backend bloquea cualquier request HTTP que no sea GET / HEAD / OPTIONS con respuesta 423 Locked. Excepción: los endpoints del propio sistema de impersonación (POST /api/admin/impersonate/{id} y DELETE /api/admin/impersonate) siempre pasan.

Esto previene que un admin haga mutaciones (crear sesiones, aprobar artefactos, subir documentos, cambiar permisos) bajo la identidad de otro user — escenario que ensuciaría el audit log y abriría una puerta a auditorías ambiguas.

Mecánica interna

Limitaciones conocidas

ACL por documento — cómo funciona

Sistema de acceso granular a documentos individuales (Phase 4 — 2026-05-02; profile-scope agregado 2026-05-10). Ortogonal al sistema de permisos por tipo de recurso: aquí cada documento tiene su propia lista de equipos, perfiles o personas que pueden verlo y usarlo.

Default opt-in restrictivo: al crearse, un documento es visible para cualquier usuario autenticado del estudio (preserva el comportamiento histórico). Para volverlo confidencial, el propietario activa Restringir acceso y elige quién puede acceder. Spec: docs/superpowers/specs/2026-05-02-document-acl-design.md.

Schema (tenant_altlegal):

OR de scopes: cualquier match grants. Un doc con ACL [team X, profile Abogado] es accesible a un paralegal en team X (match team) Y a un abogado fuera de X (match profile). El usuario que pierde membresía de team X y no tiene perfil Abogado pierde acceso inmediatamente (no se cachea grant cross-session).

Orden de resolución

El resolver src/documents/acl.py::user_can_access_document evalúa así, short-circuit en el primer True:

  1. Documento no existe o soft-deleted → False.
  2. Documento no restringido (is_restricted = false) → True. Salta todo el resto. Es el caso común; cuesta una sola query.
  3. Ownerdocument.uploader_email == user.email → True. El propietario siempre ve su documento; no se puede revocar desde la UI.
  4. Admin bypassuser.role == 'admin' → True.
  5. User-scope ACL match — existe una fila con scope_type='user' y scope_ref=str(user.id) → True.
  6. Profile-scope ACL match — el usuario tiene user.profile definido (abogado / paralegal) y existe una fila con scope_type='profile' y scope_ref=user.profile → True. Usuarios sin perfil (NULL) no matchean esta rama; deben recibir acceso por team o user individual.
  7. Team-scope ACL match — el usuario es miembro de algún equipo en la lista de ACL (filtrando equipos soft-deleted) → True.
  8. Caso contrario → False, registra WARN document_access_denied en logs.

Hay un helper bulk paralelo, filter_accessible_document_ids(user_id, document_ids) -> set[int], optimizado para listados (una query por capa, no N por documento).

Caché 60s TTL keyed por (user_id, document_id). Invalidación al cambiar is_restricted, al modificar ACL, o al cambiar membership de team.

Dónde se aplica el ACL

Cuatro sitios consultan el resolver:

SitioComportamiento
list_documents(viewer_user_id=...) en src/documents/service.py Post-filtra el resultado por filter_accessible_document_ids. Picker UI oculta los documentos restringidos a los que el usuario no tiene acceso. Llamado por GET /api/documents.
attach_document(user_id=...) en src/chat/attach_service.py Antes de adjuntar, llama al resolver. Si denegado, raises DocumentAccessDeniedError → la route lo traduce a HTTP 403 con detalle "No tienes acceso a este documento."
Tool anonymize_with_dlp(documento_id) El dispatcher en execute_tool consulta el resolver con el doc id antes de invocar el handler. Si denegado, retorna string al LLM: "Documento N no accesible para este usuario. Contacta al propietario."
Tool extract_structured_form(fuente_documento_id) Mismo mecanismo que anonymize.
Las herramientas get_document_text y search_documents NO se gatean. Operan sobre tenant_altlegal.documentos (corpus jurisprudencia público del scraper SCJ), un namespace distinto. La ACL aplica solo sobre user_documents (archivos subidos por usuarios).

Compartir desde la UI UI

Dos puntos de configuración de ACL:

El modal de Compartir muestra:

Al guardar, el badge 🔒 Restringido aparece junto al nombre del documento en la tabla. La caché del resolver se invalida automáticamente para ese document_id.

Permission gate: solo el propietario del documento o un admin puede abrir el modal y guardar cambios. Otros usuarios reciben HTTP 403.

API endpoints código

GET    /api/documents/{id}/acl     # estado actual + labels (team name, user email)
POST   /api/documents/{id}/acl     # idempotente; wipe + reinsert
DELETE /api/documents/{id}/acl     # equivalente a POST {is_restricted: false, teams:[], users:[]}

GET    /api/share-targets          # lista teams + users para los pickers (cualquier autenticado)

POST body:

{
  "is_restricted": true,
  "teams": ["<team_uuid>", "<team_uuid>"],
  "users": [17, 42],
  "profiles": ["abogado"]
}

Errores: 400 ante UUID de team, user_id, o perfil desconocido (válidos: abogado, paralegal); 403 para non-owner non-admin; 404 doc no encontrado o soft-deleted.

Auditoría: cada mutación emite un INFO log estructurado document_acl_changed con actor_user_id, document_id, before, after. Cada denial (resolver) emite WARN document_access_denied.

Quién puede subir documentos Política

Reglas hardcoded en los endpoints POST /documents/upload y POST /documents/drive-pick (Fase B ACL, 2026-05-10):

DestinoPermitido aEndpointRespuesta a no autorizados
Biblioteca (visible para todo el estudio por default) Solo role='admin' Sin session_id en el body HTTP 403 con mensaje redirigiendo a chat o Drive del matter
Sesión de chat individual (upload-y-attach, no entra a biblioteca pública) Cualquier user con user.profile != NULL (abogado o paralegal); admins siempre Con session_id en el body HTTP 403 con mensaje pidiendo perfilamiento al admin
Carpeta Drive del matter (sync por scraper, no endpoint API) Permisos de Drive, fuera del scope de la app Si el user no tiene write en la carpeta Drive del matter, Google lo bloquea.

Frontend: los botones Subir documento e Importar desde Drive en la biblioteca tienen data-admin-only y se ocultan via applyAdminOnlyGates() a usuarios no-admin. Si admin entra a "Ver como" un viewer, los botones se ocultan también. El backend igual rechaza con 403 — la UI es solo para no mostrar botones inútiles.

Promoción futura a policy-driven: hoy la regla está hardcoded. Cuando llegue el caso real de "el team X puede subir a biblioteca pero el Y no", se eleva a resource_type='feature' con manifest yaml y items upload_library, upload_chat, ambos editables desde el policy editor. Ver entry en docs/next-phases.md § "Acceso granular a documentos".

Lo que aún no hace esta app

Tracking en docs/next-phases.md § "Phase 4 follow-ups → Acceso granular a documentos":

CapacidadEstadoWorkaround
Toggle Restringir acceso al subir el documento (2026-05-08).Modal automático antes del upload.
Endpoint /api/documents/{id}/acl/preview con count de personas que efectivamente verán el doc (resolución teams → miembros, perfiles → usuarios con ese perfil)No implementadoEl owner inspecciona miembros del team manualmente en la admin UI.
Profile-scope ACL (compartir con todos los abogado) (2026-05-10, migration 032).Picker "Perfiles con acceso" en el modal de Compartir.
Inheritance automático desde matter / proyectoNo implementadoCompartir explícitamente con los teams del matter.
Default-restricted (everyone-readable se vuelve opt-in)No
Audit log persistente de mutaciones de ACL (Phase 4.2 → tabla audit_events, evento doc_acl_changed con payload before / after).Configuración → Actividad → Eventos.
Audit log de accesos exitosos (read tracking)NoPhase 4.2 captura mutaciones, no lecturas. Trigger: requisito de compliance "quién leyó qué".
Tag-based ACL via documento_tipo (clasificación, no per-row)Slot reservado, collector retorna []Usar ACL por documento; los dos pueden coexistir cuando el feature aterrice.
Sticky-attach: cambios de ACL no afectan adjuntos de sesiones existentesComportamiento documentado (2026-05-10): el resolver corre al adjuntar, no por turno. Una sesión que ya tiene el doc adjuntado conserva acceso aunque el user pierda el ACL. Tools user-doc-specific (anonymize_with_dlp, extract_structured_form) sí re-checan.Si necesitas forzar la salida, admin detacha manualmente desde el chat.
Gating de upload policy-driven (override per-team)Hardcoded admin-only en biblioteca, profile-required en chat. Ver § Quién puede subir.Cuando llegue requisito real de override per-team, promover a resource_type='feature'.
UI muestra "Compartido con N personas" en el listadoSolo el badge 🔒 booleanoClick "Compartir…" para ver la lista efectiva.

Accesos a Drive — Modelo C

La app gestiona accesos a las carpetas de Drive de cada matter (proyecto) sincronizándolos con el ACL de la app. La cuenta de servicio app-sa@altlegal-ai.iam.gserviceaccount.com mantiene los permisos del folder del matter alineados con las políticas de tipo resource_type='matter'.

Modelo de seguridad:

QuiénAcceso al Shared Drive rootAcceso por matter folder
Admins / partnersSí (Content manager)Heredado del root
Usuarios non-adminNoSólo si tienen ACL en el matter; la SA comparte el folder con su email automáticamente
Per-user folder (AltLegal AI Docs — <email>)N/ACompartido explícito con el user al login (sobrevive la revocación del root)
Folder CompartidosN/ACompartido con team General (todos los users autenticados)

Regla central: si un usuario non-admin no tiene ACL en un matter, no puede acceder al folder del matter ni vía la app ni vía Drive UI directo. El ACL de la app es la fuente de verdad.

Layout del Shared Drive

Convención operativa (la app no impone la ruta — sólo gestiona permisos al folder id linkeado):

Shared Drive root
├── Documentos/                       ← reservado para chat + Compartidos
│   ├── PROD/
│   │   ├── Compartidos/              ← shared con team General (todos)
│   │   └── <user-email>/             ← per-user session uploads
│   └── LOCAL/                        ← idem para env local
└── Clientes/
    └── <Cliente>/                    ← folder padre del cliente (opcional)
        └── <Proyecto>/               ← matter folder (linkeado a matter.drive_folder_id)
            └── ...                   ← contenido del proyecto
No mezclar matters en Documentos/. Ese branch se reserva para la infraestructura del chat (sessions, Compartidos). Los matters viven en Clientes/<Cliente>/<Proyecto>/ para que los grants manuales sobre la carpeta del cliente cascadeen naturalmente a sus proyectos.

Antes de vincular: la cuenta de servicio app-sa@altlegal-ai.iam.gserviceaccount.com debe tener acceso al folder. Si no lo tiene, el endpoint responde HTTP 400 con instrucción explícita.

  1. Crea (o ubica) el folder en Clientes/<Cliente>/<Proyecto>/ del Shared Drive.
  2. En Drive UI → Compartir → pega app-sa@altlegal-ai.iam.gserviceaccount.com como Editor o Content manager. Sin notificación.
  3. En la app → vista del matter → "Vincular carpeta de Drive" → pega el ID o la URL. El validador acepta ambos formatos (URL → ID se normaliza automáticamente).
  4. Al guardar, el link valida acceso de la SA (preflight) y dispara reconcile inmediato. Si ya existían policies sobre ese matter, los grants se aplican al instante.

Conceder accesos por policy

El mecanismo de granularidad vive en Configuración → Equipos → Políticas, sección Proyectos (Matters). Cada matter es un recurso independiente: una policy lista UUIDs específicos de matters, no clientes ni patrones.

Acceso por team

Ejemplo: Cliente X con 4 proyectos. Team Judicial debe ver los 2 laborales; team Corporativo los 2 de M&A.

team='Judicial'    → resource_type='matter' mode='allow' resource_ids=[uuid_laboral_1, uuid_laboral_2]
team='Corporativo' → resource_type='matter' mode='allow' resource_ids=[uuid_mya_1, uuid_mya_2]

Resultado en Drive:

Acceso por user_override

Para accesos individuales sin meter al usuario en un team:

Compartir manualmente desde Drive UI

El reconcile sólo gestiona permisos directos en la carpeta raíz del matter (matter.drive_folder_id). Los grants heredados se ignoran (vía permissionDetails.inherited=true). Esto define qué shares manuales sobreviven:

Dónde compartes manualmente¿Sobrevive al reconcile?Notas
En la carpeta raíz del matterNo — el siguiente reconcile la revocaPara accesos recurrentes, crea un user_override allow policy en la app y la SA lo gestiona
En un subfolder o archivo dentro del matterDrive permite permisos a nivel de archivo. El reconcile no toca children, sólo la raíz.
En la carpeta del cliente padre (Clientes/<Cliente>/)Los grants en ancestros se propagan a los proyectos hijos por herencia y aparecen marcados como inherited=true en la lista de permisos, así que el reconcile los filtra y no los toca.
En el root del Shared DriveSí (idem ancestro)Otorga acceso a todo el drive — equivale a admin de facto. Usar sólo para partners.
Patrón "auditor externo, todos los proyectos del cliente". Comparte la carpeta Clientes/<Cliente>/ directamente con el auditor desde Drive UI. Los grants heredados sobreviven al reconcile del app. El auditor ve todos los proyectos del cliente sin que la app interfiera.
Patrón anti — compartir directo en la raíz del matter. Si compartes Caso Acme/ con auditor@externa.cl desde Drive UI, el próximo cambio de policy revoca el grant. Para accesos recurrentes en un solo proyecto, usa user_override allow en la app — la SA lo aplica y sobrevive todos los reconciles.

Cuándo se ejecuta el reconcile

El reconcile (reconcile_matter_acl_to_drive) corre como tarea asyncio fire-and-forget cuando:

Latencia esperada: el endpoint HTTP retorna inmediato. El reconcile toma 1-5s por matter; el usuario ve el folder en su Drive ~1-5s después del cambio de policy.

Comportamiento del reconcile:

  1. Carga el matter; si drive_folder_id es NULL, retorna skipped_no_folder=True y registra el audit event sin tocar Drive.
  2. Resuelve los emails autorizados desde permission_policies (expande team via team_members; user_override via user id; profile-scope queda como warning + skip en v1).
  3. Lista permisos directos del folder vía Drive API (filtra heredados y no-user).
  4. Aplica diff: grants nuevos + revokes de los que sobran. Cada grant/revoke es independiente; un fallo no detiene los demás.
  5. Emite audit event matter_drive_sync con el resumen completo.

Auditoría y verificación

Cada reconcile emite un evento matter_drive_sync en tenant_altlegal.audit_events. Payload:

{
  "folder_id": "1abc...",
  "granted": ["fsanchez@altlegal.cl"],
  "revoked": [],
  "unchanged_count": 3,
  "errors": [],
  "skipped_no_folder": false
}

Para verificar la última sync de un matter (consola psql vía Cloud SQL Proxy):

SELECT created_at, payload
FROM tenant_altlegal.audit_events
WHERE event_type = 'matter_drive_sync'
  AND target_id = '<matter_id>'
ORDER BY created_at DESC LIMIT 3;

Para ver el log en Cloud Run:

gcloud logging read 'resource.type=cloud_run_revision \
  AND resource.labels.service_name=altlegal-ai \
  AND textPayload:"matter_drive_sync"' \
  --limit=20 --freshness=1h --project=altlegal-ai

Troubleshooting

SíntomaCausa probableAcción
HTTP 400 al vincular folder con mensaje "La cuenta de servicio app-sa@... no puede acceder al folder"El SA no tiene permiso sobre el folder.Compartir el folder con app-sa@altlegal-ai.iam.gserviceaccount.com como Editor desde Drive UI; reintentar.
reconcile registra 404 al listar/crear permisosSA perdió acceso al folder (admin lo quitó) o el folder fue movido a la papelera.Volver a compartir el folder con la SA; si fue eliminado, desvincular del matter (drive_folder_id = NULL) o re-linkear a uno nuevo.
Audit event muestra skipped_no_folder: trueEl matter no tiene drive_folder_id.No es error — la policy queda registrada y aplicará cuando se vincule un folder.
Auditor externo desaparece después de un cambio de policyEstaba shared directo en la raíz del matter.Mover el share a la carpeta del cliente padre (heredado, sobrevive); o crear user_override allow policy.
userRateLimitExceeded en backfill masivoDrive API rate limit con muchos matters seguidos.Re-correr el backfill; o intercalar sleep 1 entre matters editando el script.
User cambió de email y no ve el folderEl share quedó con el email viejo.Forzar reconcile del matter: PYTHONPATH=. python scripts/backfill-matter-drive-permissions.py --apply --matter-id <uuid>

Lo que aún no hace

CapacidadEstadoWorkaround
Expansión de policies con scope_type='profile' sobre mattersv1 logea warning + skipUsar team o user_override; profile→users es una iteración futura.
Rol por policy (writer vs reader vs commenter)Hardcoded a writer (Contributor)~10 líneas de código si se necesita per-policy. Trigger: requisito real de view-only por team.
Botón "Forzar reconcile" desde la UI del matterNo — sólo via scriptEdita y guarda cualquier policy del matter; el upsert dispara reconcile.
Revocación automática del Shared Drive root para usuarios non-adminPaso manual de Workspace adminDocumentado en docs/runbooks/drive-per-matter-acl.md.
Notificación al usuario cuando recibe acceso a un matterNo (se usa sendNotificationEmail=False)El folder aparece silenciosamente en "Compartido conmigo" del user. Trigger: feedback de usuarios pidiendo notificación.

Actividad — telemetría y audit log

Phase 4.2 (mayo 2026) shippea persistencia de eventos críticos del sistema. Resuelve dos preguntas que antes solo respondían los logs de stdout:

Implementación: dos tablas narrow bajo tenant_altlegal, escritura asíncrona / fail-soft (detalle), viewer paginado en la pestaña Actividad de Configuración.

Acceso: admin-only. Las dos sub-vistas y los tres endpoints /api/admin/* requieren role='admin' — un viewer recibe HTTP 403.

Eventos auditables

21 tipos de eventos en producción, divididos por dominio. Cada fila en audit_events persiste: event_type, actor_user_id (FK users.id con SET NULL en delete), actor_email (snapshot — sobrevive el delete del user), target_type + target_id (referencia al objeto mutado) y payload JSONB con la diff o snapshot relevante.

Dominioevent_typeCuándo dispara
Políticaspolicy_upsertedCrear o editar una política (incluye scope_type, scope_ref, resource_type, mode, resource_ids).
policy_deletedEliminar política (snapshot del scope antes del delete).
Equipos y perfilteam_createdPOST /api/admin/teams.
team_updatedPATCH equipo. Payload con before / after.
team_archivedSoft-delete (default team es protegido — no dispara).
team_member_added / team_member_removedPOST / DELETE en /teams/{id}/members.
profile_updated / user_role_changedPATCH perfil (abogadoparalegal) o cambio de role (adminviewer).
Artefactos (entregables PDP)artifact_submittedCada vez que create_artifact persiste un draft (tool submit_artifact o POST directo).
artifact_approvedPOST /matters/{id}/artifacts/{aid}/approve.
artifact_rejectedDELETE /matters/{id}/artifacts/{aid} sobre un draft. Payload con tipo, version, source_session_id. Alimenta el ratio aprobación de la sub-pestaña Calidad PDP.
artifact_exportedExport a Google Doc — payload incluye drive_doc_id.
Proyectosmatter_createdPOST /clients/{cid}/matters.
matter_archivedSoft-delete del matter (cascade a sesiones + Drive trash).
Documentosdoc_uploadedUpload directo o pick desde Drive — payload incluye name, mime_type, size_bytes, source.
doc_deletedSoft-delete del documento (snapshot de name pre-delete).
doc_acl_changedUpsert o clear de ACL — payload con before / after.
Sesiones de chatchat_session_createdPOST /api/chat/sessions.
chat_session_associated_to_matterMover sesión orphan → proyecto genérico.
chat_session_deletedDELETE de la sesión (bloqueado si produjo entregables aprobados).
Impersonación adminimpersonation_startedPOST /api/admin/impersonate/{user_id}. Payload con email, role y profile del target. Actor es siempre el admin real.
impersonation_endedDELETE /api/admin/impersonate.

Lo que NO se loggea: mensajes individuales de chat (volumen alto, valor bajo — el histórico ya vive en chat_messages); lecturas de cualquier tipo (lista de docs, queries al corpus).

El catálogo de eventos es un Literal cerrado en src/audit/types.py. Agregar uno nuevo requiere code edit + spec update — no se puede inyectar tipos arbitrarios desde la UI.

Uso LLM (tokens y latencia)

Cada llamada exitosa a ResilientProvider.generate() o generate_stream() persiste una fila en tenant_altlegal.llm_call_log con:

Edge cases:

Pestaña Actividad UI

Configuración → Actividad. Solo visible para admins. Dos sub-vistas:

Eventos

Uso LLM

El % fallback es la métrica más útil para detectar problemas de proveedor: si Claude Sonnet sube de 0% a 15%, probablemente Anthropic está rate-limiteando — momento de revisar la cadena fallback en el manifiesto LLM.

Calidad PDP UI

Sub-pestaña dedicada al ciclo de vida de los entregables. Cruza eventos artifact_submittedartifact_approved / artifact_rejected por target_id (id del artefacto) y agrupa por tipo. Por cada tipo de entregable muestra:

Selector segmented por bucket: últimos 7 / 30 días / este mes / total. El filtro aplica al evento terminal — un draft submitido fuera de la ventana sigue contando si su aprobación cae dentro.

Flujo de uso típico: tras una semana de operación, ordenar por aprobados + descartados descendente para ver los tipos más frecuentes; revisar % aprobación bajo (<70 sugiere mejorar el prompt master del caso de uso); revisar TTA p90 alto (>1 día sugiere reasignar aprobadores).

API endpoints código

Todos admin-only (require_admin). Útiles para automatización, reporting externo o exportar a un BI.

EndpointQuery paramsDevuelve
GET /api/admin/audit/events event_type (CSV), actor_user_id, target_type, since / until (ISO date), limit (≤500), offset {events: [...], total: int}
GET /api/admin/llm-usage/aggregate bucket ∈ {this_month, last_7_days, last_30_days}, group_by ∈ {model, provider, caso_de_uso, user} [{key, total_calls, tokens_in, tokens_out, total_tokens, avg_latency_ms, hop_rate}, ...]
GET /api/admin/llm-usage/calls since / until, user_id, model_id, limit (≤500), offset {calls: [...], total: int} — listado raw
GET /api/admin/metrics/artifact-flow bucket ∈ {last_7_days, last_30_days, this_month, all_time} {rows: [{tipo, approved_count, rejected_count, in_progress_count, approval_rate, tta_median_seconds, tta_p90_seconds, tta_count}], totals: {…}}

Garantía fail-soft

Tanto log_event como log_llm_call wrap su trabajo en un try / except Exception: una falla de DB, un payload malformado o un cierre del event loop nunca rompen la acción del usuario que los disparó. En caso de error:

Implicación: si la pestaña Actividad muestra menos eventos de los esperados, hay que revisar los logs por audit_write_failed. La ausencia de la fila NO significa que la acción no ocurrió.

El log_llm_call usa asyncio.create_task fire-and-forget, de modo que el writer abre su propia DB session — esto desacopla la escritura del request lifecycle (la respuesta del usuario sale antes de que el row se escriba).

Crecimiento y purga

Volumen esperado en uso interno:

No hay TTL automático en v1. Si en algún momento el tamaño preocupa, purga manual recomendada (siempre con backup primero):

# Snapshot antes de borrar
docker compose exec db pg_dump -U postgres -d legal_ai \
    -t tenant_altlegal.audit_events \
    -t tenant_altlegal.llm_call_log \
    > /tmp/telemetry-snapshot-$(date +%Y%m%d).sql

# Conservar últimos 12 meses de audit
docker compose exec db psql -U postgres -d legal_ai -c "
DELETE FROM tenant_altlegal.audit_events
 WHERE created_at < now() - interval '12 months';
"

# Conservar últimos 90 días de llm_call_log
docker compose exec db psql -U postgres -d legal_ai -c "
DELETE FROM tenant_altlegal.llm_call_log
 WHERE created_at < now() - interval '90 days';
"
# Snapshot a Cloud Storage antes de borrar (Cloud SQL exporta directo a GCS)
gcloud sql export sql altlegal-ai-pg \
    gs://altlegal-ai-backups/telemetry-$(date +%Y%m%d).sql \
    --database=legal_ai \
    --table=tenant_altlegal.audit_events,tenant_altlegal.llm_call_log

# Conservar últimos 12 meses de audit
gcloud sql connect altlegal-ai-pg --user=postgres --database=legal_ai \
  --quiet << 'EOF'
DELETE FROM tenant_altlegal.audit_events
 WHERE created_at < now() - interval '12 months';
EOF

# Conservar últimos 90 días de llm_call_log
gcloud sql connect altlegal-ai-pg --user=postgres --database=legal_ai \
  --quiet << 'EOF'
DELETE FROM tenant_altlegal.llm_call_log
 WHERE created_at < now() - interval '90 days';
EOF

Phase 6 va a automatizar esto con un job programado.

Lo que aún no hace esta app

Decisiones de scope al cerrar Phase 4.2. Tracking en docs/next-phases.md § "Telemetría operacional":

CapacidadEstadoWorkaround
Gráficos / dashboards visuales (tendencias, top users, distribución por modelo)No (Phase 4.3)La data está disponible vía /api/admin/llm-usage/aggregate; armar un dashboard externo (Grafana, Metabase) hasta que aterrice.
Costos en USD por modelo / proyecto / mesNo (Phase 5)Tokens están registrados; multiplicar manualmente por la pricing table del proveedor.
Audit log de mensajes individuales de chatExcluido v1 (volumen alto, valor bajo)Histórico de chat ya en chat_messages; query directa a DB.
Audit log de lecturas (read tracking)NoPhase 4.2 captura solo mutaciones. Trigger: requisito de compliance "quién leyó qué".
Streaming export a CSV / JSON desde el viewerNoVía API: curl ... | jq.
Replay / undo desde el audit logNo (audit es read-only)Reverter cambios desde el flujo admin correspondiente.
Retención automática (TTL purge)No (Phase 6)Purga manual; ver § Crecimiento y purga.
Cuotas / billing basados en llm_call_logNo (Phase 5)Tracking ya en su lugar; falta UI de límites + alertas.

Despliegue — visión general

La plataforma corre en dos entornos:

EntornoStackComando primario
Desarrollo local Docker Compose en la máquina del desarrollador (app + Postgres + pgvector). docker compose up -d --build
Producción GCP: Cloud Run (API + frontend estático servido por la misma imagen), Cloud SQL (Postgres), Secret Manager (claves API), Cloud Storage (assets / backups), Cloud Logging. ./deploy.sh <subcomando>

El frontend (HTML + JS + CSS) detecta el host al cargar esta página y muestra los comandos del entorno apropiado. Puedes flipear manualmente con el toggle del top.

deploy.sh código

Script único e idempotente, en la raíz del repo. Reemplaza un set ad-hoc de gcloud + Makefiles que tenía que recordar cada admin. Toda operación verifica el estado actual antes de aplicar cambios — correrlo dos veces seguidas no rompe nada.

Naming canónico (per gcp-conventions skill): project altlegal-ai (acepción al patrón altlegal-app-<workload> por ser inmutable), region europe-southwest1 (co-localizada con Vertex AI), Cloud SQL altlegal-ai-pg, Cloud Run service altlegal-ai, SAs app-sa y jobs-sa, secrets en kebab-case (claude-api-key, database-url, etc.).

Subcomandos disponibles:

SubcomandoHaceCuándo usarlo
initHabilita APIs (Cloud Run, SQL, Secret Manager, Artifact Registry, IAM, Vertex AI, etc.), crea SAs app-sa + jobs-sa con roles mínimos, Artifact Registry repo, bucket de deploy artifacts. Verifica el bucket de uploads (creado por vertex-bootstrap.sh). Crea Cloud SQL Postgres 15 + DB legal_ai.Una vez por proyecto. Idempotente: si algo ya existe, se saltea.
secrets syncLee .env.deploy, crea/actualiza secrets en Secret Manager (kebab-case naming), bind a secretAccessor para ambas SAs. Skip silente para vars vacías. Bumpea versiones; Cloud Run las recoge en próximo cold start.Bootstrap inicial + rotar claves API + agregar proveedor LLM.
deploydocker buildx --platform=linux/amd64 de la imagen → push a Artifact Registry → gcloud run deploy nueva revisión. Frontend (web/*) viaja en la misma imagen (FastAPI StaticFiles), no hay CDN separado.Cambios en código Python, web/*, casos de uso (.md backupeados), manifest YAML, dependencias.
migrateCloud Run Job one-shot que corre alembic upgrade head contra Cloud SQL. Reusa la imagen del Cloud Run service. SA jobs-sa con cloudsql.client.Después de pull con nuevas migraciones. Nota: el entrypoint.sh del container también corre alembic upgrade head al boot, así que un deploy suele bastar para nuevas migrations.
seed-corpus [--force]Genera dump SELECTIVO del DB local (allowlist de tablas BCN + PJUD + casos_de_uso + extracted) → sube a GCS → gcloud sql import con --user=postgres. El dump incluye TRUNCATE … RESTART IDENTITY CASCADE al inicio (idempotente sobre destinos con data parcial).Primer deploy. Re-import del corpus tras un drop. Destructivo sobre las tablas del allowlist — pide confirmación TTY salvo --force.
seed-adminCloud Run Job que ejecuta scripts/seed-prod-bootstrap.py — upsert del user admin (LEGAL_AI_AUTH_SEED_ADMIN_EMAIL) y crea team "General" si no existe.Opcional: el primer login OAuth crea el admin automáticamente, y el lifespan del FastAPI crea el team General al boot.
oauthImprime instrucciones manuales para crear el OAuth Client en Google Console (la API iap oauth-brands fue deprecada 2026-03-19, ya no es scriptable).Una vez al levantar el proyecto. Tras crear el client, llenar LEGAL_AI_GOOGLE_OAUTH_CLIENT_ID + LEGAL_AI_GOOGLE_OAUTH_CLIENT_SECRET en .env.deploy y correr secrets sync + deploy.
all [--force]Pipeline greenfield: initsecrets syncdeploymigrateseed-corpusseed-admin. OAuth sigue siendo manual entremedio.Levantamiento inicial de un proyecto.
statusImprime existencia/no de cada recurso (APIs, SAs, Cloud SQL, AR, buckets, Cloud Run, secrets, Jobs).Diagnostico, auditoria de drift.
urlImprime URL pública del Cloud Run service.Tras un deploy nuevo, copiar la URL para OAuth redirect o testing.
logsgcloud run services logs tail.Debugging en runtime.
helpImprime ayuda con el flow recomendado paso-a-paso.
Pre-requisitos del entorno local. gcloud CLI autenticado (gcloud auth login + ADC para Vertex), Docker con buildx (linux/amd64 cross-build desde Apple Silicon), gsutil, python3. El .env.deploy debe tener al menos DB_PASSWORD + LEGAL_AI_SESSION_SECRET (auto-generables: openssl rand). Los demás secrets son opcionales (vacíos = provider no disponible).

Bootstrap inicial de un entorno

Correr una vez al crear un proyecto GCP nuevo. Flow recomendado paso-a-paso:

# 1. Una vez por máquina
gcloud auth login
gcloud config set project altlegal-ai
gcloud auth application-default login   # ADC para Vertex desde local

# 2. Configurar .env.deploy
cp .env.deploy.example .env.deploy
# Editar: DB_PASSWORD, LEGAL_AI_SESSION_SECRET (openssl rand)
# Opcional: LEGAL_AI_CLAUDE_API_KEY, LEGAL_AI_DEEPSEEK_API_KEY, etc.

# 3. Pipeline greenfield (cada subcomando es idempotente)
./deploy.sh init                  # ~10 min (Cloud SQL es lento)
./deploy.sh deploy                # primer Cloud Run (sin secrets aún; URL listo)
./deploy.sh oauth                 # instrucciones para crear OAuth Client
                                  # — paso manual en Google Console
# editar .env.deploy con OAuth client_id + client_secret
./deploy.sh secrets sync          # sube secrets a Secret Manager
./deploy.sh deploy                # re-deploy: Cloud Run picks up secrets
./deploy.sh migrate               # alembic via Cloud Run Job (idempotente)
./deploy.sh seed-corpus           # dump local + import a Cloud SQL
./deploy.sh seed-admin            # opcional: user admin + team General

# 4. Verificar
./deploy.sh status                # todo ✓
./deploy.sh url                   # URL pública del Cloud Run service

# 5. Dominio custom (opcional, una vez)
gcloud beta run domain-mappings create \
    --service altlegal-ai \
    --domain app.altlegal.cl \
    --region europe-southwest1

Cambios iterativos

Workflows típicos por tipo de cambio:

CambioComandoTiempo aprox
Edité web/* (HTML/JS/CSS) o código Python./deploy.sh deploy~3-5 min (build + push + revision swap)
Cambié un caso de uso (vía UI admin)Instantáneo. Persiste en DB, sobrevive redeploys.
Cambié el manifest YAML de LLM models./deploy.sh deploy~3-5 min (manifest va en la imagen)
Agregué una migración Alembic + código que la requiere./deploy.sh deploy (el entrypoint corre alembic upgrade head al boot)~3-5 min
Roté una clave API./deploy.sh secrets sync && ./deploy.sh deploy~3-5 min (nueva revision picks up :latest)
Solo cambió un secret (sin código)./deploy.sh secrets sync + cold start natural (o gcloud run services update --update-env-vars=RELOAD_AT=$(date +%s))~30s
Re-importar corpus tras drop./deploy.sh seed-corpus --force~3-5 min
Rollback a revisión anteriorgcloud run services update-traffic altlegal-ai --to-revisions=<rev>=100 --region=europe-southwest1<30s
Cloud Run = sin downtime. Cada deploy crea una revisión nueva. El tráfico migra cuando la nueva pasa healthchecks; si fallan, la anterior sigue activa. Un deploy con bug NO tira el servicio — basta el rollback manual via update-traffic. Las migraciones de DB sí son irreversibles sin alembic downgrade; evita correrlas si no estás seguro.

Dominio personalizado — visión general

La app es accesible vía https://altlegal.paralegal.technology (dominio gestionado en GoDaddy, mapeado a Cloud Run mediante domain mapping nativo). Certificado SSL provisto por Google Cloud (managed) — auto-renueva sin intervención.

El servicio se sirve desde europe-west1 (Bélgica) aunque la base de datos sigue en europe-southwest1 (Madrid). Razón abajo.

Por qué Bélgica para Cloud Run

El gcloud beta run domain-mappings create NO está disponible en Madrid (europe-southwest1). Google sólo soporta domain mappings nativos en un subconjunto de regiones (entre ellas europe-west1, europe-west4, us-central1). Las alternativas eran:

Se eligió la segunda. Cloud SQL queda en Madrid (no se movió para evitar migración de datos). Cloud Run conecta vía Cloud SQL Auth Proxy cross-region, latencia despreciable.

ComponenteRegiónRazón
Cloud Run serviceeurope-west1 (Bélgica)Soporta domain mappings
Artifact Registryeurope-west1Co-localizado con Cloud Run
Cloud SQL altlegal-ai-pgeurope-southwest1 (Madrid)Permanece — no se migró DB
Vertex AI callseurope-southwest1 (primary), europe-west4 (fallback)Latencia Bélgica→Madrid ~5ms aceptable
Bucket de backups EU multi-regionEU (multi-region)DR cross-region

Crear / modificar domain mapping código

gcloud beta run domain-mappings create \
    --service=altlegal-ai \
    --domain=altlegal.paralegal.technology \
    --region=europe-west1 \
    --project=altlegal-ai

El comando devuelve los records DNS requeridos. Mientras DNS no esté propagado el cert managed espera en estado CertificateProvisioning.

Verificar estado del mapping:

gcloud beta run domain-mappings describe \
    --domain=altlegal.paralegal.technology \
    --region=europe-west1 --project=altlegal-ai \
    --format='value(status.conditions[].type,status.conditions[].status)'

Salida esperada en estado operativo: Ready=True · CertificateProvisioned=True · DomainRoutable=True.

Records DNS en GoDaddy

Una vez creado el mapping, agregar el CNAME al dominio en GoDaddy:

Type:  CNAME
Name:  altlegal
Value: ghs.googlehosted.com.
TTL:   600

Propagación típica: 5-30 min. Verificar con:

dig altlegal.paralegal.technology +short
# Debe devolver: ghs.googlehosted.com. seguido de una IP de Google
El dominio raíz debe estar verificado en Google Search Console antes de crear el mapping. Si no, el comando falla con FAILED_PRECONDITION: domain not verified. Verificación es one-time por dominio raíz (no por subdominio).

OAuth redirect URIs código

Tras activar el dominio, agregar a Google Cloud Console → APIs & Services → Credentials → OAuth client altlegal-ai:

Sin esto, el login falla con redirect_uri_mismatch. Mantener los URIs anteriores (URL *.run.app) como respaldo durante el cutover.

SSL cert managed

Google provee y renueva automáticamente un certificado SSL gestionado para el dominio mapeado. Estado:

gcloud beta run domain-mappings describe \
    --domain=altlegal.paralegal.technology \
    --region=europe-west1 --project=altlegal-ai \
    --format='value(status.conditions[?type="CertificateProvisioned"].status)'

Si el cert queda en Unknown > 1 h tras configurar el CNAME: probable issue de DNS (no propagó al validador de Google). Revisar dig. Si FAILED_NOT_VISIBLE: el record CNAME no está visible públicamente todavía.

Rollback / re-mapping

Si el dominio nuevo falla y necesitas volver al URL *.run.app

El URL automático altlegal-ai-tpuelbed5a-ew.a.run.app sigue activo siempre, en paralelo al dominio custom. Apuntar usuarios a esa URL es rollback inmediato. Sin acciones.

Mover el dominio a otra región / service

# 1. Eliminar el mapping actual
gcloud beta run domain-mappings delete \
    --domain=altlegal.paralegal.technology \
    --region=europe-west1 --project=altlegal-ai

# 2. Crear el nuevo mapping en la región/service destino
gcloud beta run domain-mappings create \
    --service=<nuevo-service> \
    --domain=altlegal.paralegal.technology \
    --region=<nueva-region> --project=altlegal-ai

# 3. Actualizar el CNAME en GoDaddy si la región nueva da un destino distinto
# 4. Esperar re-provisionamiento del cert (~15 min)

Borrar mapping permanentemente

gcloud beta run domain-mappings delete \
    --domain=altlegal.paralegal.technology \
    --region=europe-west1 --project=altlegal-ai

Quita el dominio del service; el cert managed se descarta. Borrar también el CNAME en GoDaddy.

Backups Cloud SQL — visión general

La instancia altlegal-ai-pg tiene 4 mecanismos de respaldo combinados que cubren distintos escenarios de recovery:

MecanismoFrecuenciaRetenciónStorageCubre
Automated backupDiario 04:00 UTC7 backupsMadridSnapshot completo, restore in-place o a clone
PITR (transaction log)Continuo7 díasMadridRestore a timestamp exacto, RPO <1 min
Snapshot filtrado manualOn-demandIndefinidoMadrid (altlegal-ai-backups)Sync local↔prod sin sessions/audit/logs
Cross-region weekly exportDomingo 03:00 UTC90 días (versions)EU multi-region (altlegal-ai-backups-eu)DR cross-region
RPO real: <1 minuto (vía PITR). RTO: 5-45 min según escenario. Disaster recovery cross-region: hasta 7 días con el export semanal. Costo extra total: <$3 USD/mes.
Runbook detallado: docs/runbooks/db-backups.md · Procedimientos de restore: docs/runbooks/db-restore.md.

Listar backups disponibles

gcloud sql backups list \
    --instance=altlegal-ai-pg \
    --project=altlegal-ai

Devuelve una tabla con columnas ID, WINDOW_START_TIME, ERROR, STATUS, INSTANCE. El ID es el handle que necesitas para hacer restore. Identifica el backup del día anterior por WINDOW_START_TIME.

PITR — Point-in-Time Recovery

Habilitado el 2026-06-06. Cloud SQL archiva transaction logs continuamente — permite restaurar a cualquier timestamp dentro de los últimos 7 días con granularidad de segundo.

Restore a punto exacto en tiempo (NO destructivo — crea instancia nueva):

gcloud sql instances clone altlegal-ai-pg altlegal-ai-pg-restored \
    --point-in-time='2026-06-06T03:45:00Z' \
    --project=altlegal-ai

Tras crear el clone, validar contenido, exportar lo necesario, y eliminar el clone. Costo del clone: ~$1/día mientras vive.

Costo PITR: ~$1-2 USD/mes adicionales (transaction log storage). Habilitado con:

gcloud sql instances patch altlegal-ai-pg \
    --enable-point-in-time-recovery \
    --project=altlegal-ai

Cross-region weekly export DR

Cloud Scheduler dispara semanalmente un export gestionado por Cloud SQL Admin API hacia un bucket multi-region EU. Sobrevive un outage completo de Madrid.

ComponenteValor
Bucket destinogs://altlegal-ai-backups-eu (location: EU, multi-region)
Object versioningHabilitado — preserva versiones tras overwrite
Lifecycle90 días → delete (versions live y non-current)
Scheduler jobaltlegal-ai-pg-weekly-export en europe-west1
Frecuencia0 3 * * 0 UTC (domingo 03:00, ~00:00 Chile)
Service accountbackup-scheduler@altlegal-ai.iam.gserviceaccount.com
Costo<$1 USD/mes (storage + egress single-region → multi-region)

Verificar próximo fire + última ejecución:

gcloud scheduler jobs describe altlegal-ai-pg-weekly-export \
    --location=europe-west1 --project=altlegal-ai \
    --format='value(state,scheduleTime,lastAttemptTime,status)'

Trigger manual (fuera de schedule):

gcloud scheduler jobs run altlegal-ai-pg-weekly-export \
    --location=europe-west1 --project=altlegal-ai

Listar versiones disponibles en el bucket EU:

gcloud storage ls --all-versions -l gs://altlegal-ai-backups-eu/

Snapshot filtrado manual

Script scripts/backup-prod-snapshot.sh genera un pg_dump filtrado que excluye chat_sessions, audit_events, llm_call_log, notifications. Útil para sync local↔prod o pre-flight antes de cambios riesgosos.

# Snapshot + upload a GCS Madrid:
bash scripts/backup-prod-snapshot.sh --gcs-bucket=altlegal-ai-backups

# Sólo local:
bash scripts/backup-prod-snapshot.sh --output=/tmp/snapshot.sql.gz

Atómico: TRUNCATE + INSERT envueltos en una sola transacción (fix del incidente 2026-05-26). Si el import falla a mitad, rollback automático — la DB NO queda vacía.

Restore (rollback) — dos rutas

Ruta A — restore in-place (DESTRUCTIVO)

Sobrescribe altlegal-ai-pg completamente. Todo lo posterior al snapshot se pierde sin recuperación. Cloud Run desconecta y reconecta automático (~5 min downtime). Usar sólo si estás seguro de que la DB actual está corrupta o quieres descartar lo de hoy.
gcloud sql backups restore <BACKUP_ID> \
    --restore-instance=altlegal-ai-pg \
    --project=altlegal-ai

Ruta B — restore a instancia temporal (NO destructivo, recomendado)

Crea una nueva instancia con los datos del snapshot. Te conectas, copias lo que necesites, lo importas a la instancia productiva. Cuando terminas, borras la temporal.

# 1. Crear instancia temporal desde el backup
gcloud sql backups restore <BACKUP_ID> \
    --restore-instance=altlegal-ai-pg-snapshot \
    --project=altlegal-ai

# 2. Conectarse vía Cloud SQL Auth Proxy
gcloud sql connect altlegal-ai-pg-snapshot \
    --user=postgres \
    --database=legal_ai \
    --project=altlegal-ai

# 3. Exportar las tablas / filas que necesitas. Ejemplo:
#    pg_dump -h ... -U postgres -d legal_ai \
#      -t tenant_altlegal.casos_de_uso \
#      --data-only --column-inserts > restore.sql
#    Luego en la productiva:
#    psql -h ... -U postgres -d legal_ai < restore.sql

# 4. Cuando terminas, eliminar la temporal para no acumular costo
gcloud sql instances delete altlegal-ai-pg-snapshot \
    --project=altlegal-ai \
    --quiet
Usar ruta B salvo que tengas certeza absoluta. La A pierde info irrecuperable. La temporal cuesta ~$1/día mientras vive — barata para la confianza que da.

Configuración de backups

Verificar config actual:

gcloud sql instances describe altlegal-ai-pg \
    --project=altlegal-ai \
    --format='yaml(settings.backupConfiguration)'

Modificar horario o retención:

# Cambiar la hora del backup diario (UTC)
gcloud sql instances patch altlegal-ai-pg \
    --backup-start-time=03:00 \
    --project=altlegal-ai

# Cambiar la retención (cuántos backups guardar antes de rotar)
gcloud sql instances patch altlegal-ai-pg \
    --retained-backups-count=14 \
    --project=altlegal-ai

# Deshabilitar backups (NO recomendado)
gcloud sql instances patch altlegal-ai-pg \
    --no-backup \
    --project=altlegal-ai

Verificación periódica recomendada

CheckFrecuenciaCómo
Último automated backup SUCCESSFULDiario (automatizable)gcloud sql backups list --instance=altlegal-ai-pg --limit=1
Scheduler job ENABLEDMensualgcloud scheduler jobs describe altlegal-ai-pg-weekly-export --location=europe-west1
Bucket EU recibe export semanalSemanalgcloud storage ls -l gs://altlegal-ai-backups-eu/ (mtime debe avanzar cada domingo)
Drill de restore (clone temporal + validar)TrimestralVer docs/runbooks/db-restore.md § Escenario 1 paso 4

Gaps reconocidos (no implementados, evaluar trigger):