Decisiones arquitectónicas globales
Decisiones arquitectónicas globales (ADRs reutilizables)
Section titled “Decisiones arquitectónicas globales (ADRs reutilizables)”ADR-G001 — Supabase como backend por defecto
Section titled “ADR-G001 — Supabase como backend por defecto”Contexto: varios proyectos necesitan auth, DB, storage, vectores. Decisión: Supabase estándar (RLS, Edge Functions, pgvector, Storage, Realtime). Consecuencias: vigilar egress en free-tier (ver ADR-G002).
ADR-G002 — Egress de Storage: migrar a Cloudflare R2 al superar free-tier
Section titled “ADR-G002 — Egress de Storage: migrar a Cloudflare R2 al superar free-tier”Contexto: Studio Albor superó el egress gratuito de Supabase Storage. Decisión: servir assets pesados desde R2 con proxy en Edge Function. Consecuencias: reutilizable en cualquier mundo con assets pesados.
ADR-G004 — Vitest sobre Jest en proyectos Vite
Section titled “ADR-G004 — Vitest sobre Jest en proyectos Vite”Contexto: proyectos con Vite + TypeScript strict + plugins no-jsdom-compatibles (ej. @cloudflare/vite-plugin).
Decisión: Vitest con vitest.config.ts separado que omite plugins incompatibles.
Patrones clave:
- Constructor global:
vi.stubGlobal('X', class { constructor() { return mock } }) - navigator props:
Object.defineProperty(navigator, 'prop', { configurable:true, value: ... }) - Mock módulo con imports en store:
vi.hoisted()para declarar mocks antes del factory devi.mock - Zustand reset entre tests:
useStore.setState({ ...initialState })Consecuencias: reutilizable en cualquier mundo con Vite + React. Ver ADR 007 de alborstudio.
ADR-G005 — Traefik dynamic config para sobrescribir headers en servicios Coolify con imágenes pre-built
Section titled “ADR-G005 — Traefik dynamic config para sobrescribir headers en servicios Coolify con imágenes pre-built”Contexto: servicios desplegados en Coolify con imágenes Docker del registry oficial (ej. Umami v3) pueden tener cabeceras HTTP hardcodeadas en el build (ej. CSP frame-ancestors 'self'). Las variables de entorno del runtime no afectan código compilado.
Decisión: crear fichero YAML en /data/coolify/proxy/dynamic/<servicio>.yml que defina:
- Un middleware
headers.customResponseHeaderscon la cabecera a sobreescribir. - Un router con
priority: 1000(supera routers auto-generados por Coolify ~100) que aplique el middleware. - El servicio se referencia con sufijo
@docker(proveedor Docker de Traefik).
El directorio /data/coolify/proxy/dynamic/ tiene hot-reload automático — sin restart de Traefik.
Riesgo: Coolify “Reset Proxy” sobreescribe el directorio. Si se hace, hay que recrear el fichero. Consecuencias: reutilizable en cualquier mundo que use Coolify + Traefik. Ver ADR 008 de alborstudio.
ADR-G003 — Claude Design → Claude Code handoff via docs/design/
Section titled “ADR-G003 — Claude Design → Claude Code handoff via docs/design/”Contexto: necesitamos un flujo cerrado exploración visual → implementación.
Decisión: los bundles de Claude Design van a docs/design/
ADR-G006 — CMS de contenido editable: bloques flexibles (page_sections jsonb) en vez de tabla rígida por página
Section titled “ADR-G006 — CMS de contenido editable: bloques flexibles (page_sections jsonb) en vez de tabla rígida por página”Contexto: portales con páginas de marketing (Home, Nosotros, Servicios…) que un admin panel debe poder editar sin migraciones nuevas cada vez que cambia el layout.
Decisión: 3 tablas genéricas — pages (slug+meta), page_sections (page_id, section_key, section_type, title_es/en, subtitle_es/en, body_es/en jsonb, position), page_images (page_id, section_key, storage_path, alt_es/en). El componente React mapea section_key → JSX fijo (clases/iconos/layout en código); solo texto/imagen es dinámico. Un hook genérico (usePageContent(slug)) sirve cualquier página nueva sin código adicional.
Excepción: contenido largo no estructurado (términos legales, políticas) usa en cambio una columna content_es/en text con HTML renderizado vía dangerouslySetInnerHTML — forzar el patrón de bloques jsonb en prosa larga con estructura interna (tablas, tarjetas) no aporta valor.
Mismo patrón aplicable a specs/tabs de producto variables (product_sections).
Consecuencias: admin panel puede ser un editor genérico único (lista de secciones reordenable) en vez de un formulario por página. Costo: body_es/en no tiene schema tipado a nivel BD, cada section_type define su propia forma documentada solo en el componente consumidor — aceptable con pocos section_type estables (~8). Ver ADR-002 de lippia-alba.
ADR-G007 — Subida de assets desde el navegador: proxy en Edge Function, no presigned URL
Section titled “ADR-G007 — Subida de assets desde el navegador: proxy en Edge Function, no presigned URL”Contexto: paneles admin que suben imágenes/archivos a R2 desde el navegador (crop + compresión client-side) sin exponer el secretAccessKey de R2 en el bundle público.
Decisión: el navegador sube el archivo a un Edge Function propio (verifica sesión + rol admin vía RPC is_admin(), mismo patrón que RLS); el Edge Function firma y hace el PUT a R2 server-to-server con aws4fetch. Se prefiere sobre presigned URL directo porque evita configurar CORS en el bucket de R2 (paso manual en Cloudflare dashboard) — el único CORS necesario es el del propio Edge Function. Mitigar el límite de payload del Edge Function comprimiendo siempre en cliente a un techo conservador (~6MB) antes de subir.
Consecuencias: reutilizable en cualquier mundo con panel admin + R2. Si se necesita subir archivos pesados (video), reevaluar hacia presigned URL y aceptar configurar CORS en el bucket. Ver ADR-003 de lippia-alba.
ADR-G008 — Listas editables sin migración: reusar tabla EAV site_settings con formato valor|Etiqueta
Section titled “ADR-G008 — Listas editables sin migración: reusar tabla EAV site_settings con formato valor|Etiqueta”Contexto: un dropdown de formulario (ej. “Área de Interés” en un formulario de contacto) tenía sus opciones hardcodeadas en el componente React. El usuario quiere poder editarlas desde el panel admin sin que cada lista nueva requiera una tabla y un componente de admin a medida.
Decisión: si el proyecto ya tiene una tabla key/value_es/value_en genérica (patrón site_settings, ver ADR-G006), una lista de opciones se codifica como texto plano multilínea en el mismo value_es/value_en: una opción por línea, formato valor|Etiqueta (valor = id estable usado en lógica/BD, Etiqueta = texto localizado mostrado). El componente admin genérico (textarea por fila settings.map()) no necesita cambios — solo se agrega la fila vía migración y una entrada en el mapa de labels legible. El componente consumidor parsea con un split('\n').map(line => line.split('|')).
Consecuencias: cero migración de schema nueva, cero componente admin nuevo, edición inmediata desde /admin. Límite: no sirve para listas con más de 2 campos por opción (ahí sí conviene una tabla real) ni para listas que necesiten reordenamiento drag-and-drop. Ver uso en lippia-alba (contact_interest_areas).
ADR-G009 — Arquitectura hexagonal (Ports & Adapters) para desacoplar Supabase en SPA React, sin capa HTTP de entrada
Section titled “ADR-G009 — Arquitectura hexagonal (Ports & Adapters) para desacoplar Supabase en SPA React, sin capa HTTP de entrada”Contexto: hooks de React llamando supabase.from()/supabase.auth.* directo — sin un único lugar que documente la convención de llamada (Edge Functions vs RPC vs REST) ni un punto de swap si cambia el backend. Bug concreto que motivó la revisión: un hook reimplementaba a mano el fetch a una Edge Function (fetch('/functions/v1/...') + getSession() manual para el token) en vez de usar supabase.functions.invoke(), que ya adjunta el token de sesión activo.
Decisión: src/features/<feature>/{domain,application,adapters} por dominio — domain/types.ts (DTOs propios, sin imports de supabase-js/Database), application/ports.ts (interfaces outbound) + application/useCases.ts (funciones puras que reciben el port como parámetro), adapters/supabase<Nombre>.ts (objeto que implementa el port; único lugar que importa el cliente Supabase). En una SPA sin capa HTTP/CLI de entrada, el hook de React ES el inbound adapter + composition root — llama a los use-cases, nunca al cliente directo.
Simplificaciones deliberadas frente al ejemplo canónico del skill hexagonal-architecture: (1) adapters como objetos/funciones planas, no clases — TypeScript es structural typing, el swap funciona igual sin la ceremonia de constructor injection; (2) sin domain layer con entidades ricas cuando el dominio es CRUD/CMS sin reglas de negocio — los DTOs planos ya desacoplan el shape; (3) los formularios admin de CRUD directo de filas pueden seguir tipando con los tipos generados (Tables<>/TablesUpdate<>>) — son shapes planos sin dependencia real del SDK, no justifican una traducción extra.
Convención de llamada que este patrón fuerza a documentar una sola vez (en el adapter, no repetida por hook): Edge Functions vía supabase.functions.invoke(name, { body }), RPC vía supabase.rpc(name, params) — nunca fetch() manual a /functions/v1/*.
Consecuencias: reutilizable en cualquier mundo React+Supabase de este universo. Costo: más archivos por dominio (domain/application/adapters) que un hook monolítico — aceptable porque cada feature nueva es ~4-6 archivos pequeños, no una jerarquía de clases. Ver ADR-004 de lippia-alba.
ADR-G011 — Dashboards/paneles admin desde un template visual: datos reales + estados vacíos, nunca números de ejemplo del mockup
Section titled “ADR-G011 — Dashboards/paneles admin desde un template visual: datos reales + estados vacíos, nunca números de ejemplo del mockup”Contexto: se recibió un template HTML/mockup de dashboard admin (ventas, visitas, gráfico, “tareas pendientes”) para un proyecto donde el e-commerce real (productos/pedidos) aún no existía — 0 filas en BD, sin analytics configurado.
Decisión: cada widget del mockup se audita contra fuentes de datos reales disponibles antes de portarlo: (1) si existe tabla real → query real, con estado vacío explícito (“Aún no hay pedidos”) en vez de fabricar el número de ejemplo del mockup; (2) si la métrica no tiene fuente real (ej. “visitas web” sin analytics instalado) → sustituir por una métrica real distinta disponible, no dejar el placeholder falso; (3) si el widget es contenido inventado sin tabla que lo respalde (ej. lista de “tareas pendientes” con nombres de personas ficticias) → eliminarlo o sustituirlo por algo real (ej. accesos rápidos de navegación), nunca copiarlo tal cual; (4) UI decorativa sin feature detrás (buscador, botón “Nuevo Reporte” no conectado) → eliminar, no dejar controles que no hacen nada.
Consecuencias: el dashboard se ve más vacío al lanzar (antes de sembrar datos reales) que el mockup original, pero nunca miente. Decisión tomada junto al usuario vía pregunta explícita antes de implementar — no asumir. Ver lippia-alba (AdminDashboard.tsx, sesión 2026-07-03).
ADR-G013 — Deploy de Edge Functions multi-archivo: solo se suben archivos en el grafo de imports estático
Section titled “ADR-G013 — Deploy de Edge Functions multi-archivo: solo se suben archivos en el grafo de imports estático”Contexto: al extraer templates HTML de una Edge Function a archivos separados para reutilizarlos, se crearon como .html sueltos leídos en runtime vía Deno.readTextFile(new URL('./_templates/x.html', import.meta.url)). El deploy reportó éxito pero la función daba BOOT_ERROR al invocarla.
Decisión: el mecanismo de deploy de Edge Functions (vía MCP deploy_edge_function o supabase functions deploy) solo empaqueta archivos alcanzables por import estático desde el entrypoint — no sube archivos sueltos sin importar aunque estén en el array de files pasado a la llamada, y Deno.readTextFile a un archivo así falla en runtime porque no existe en el bundle. Cualquier contenido auxiliar (templates, configs, datos estáticos) debe ser un módulo .ts/.js que exporta el contenido como valor (export const template = \…`), importado con import normal desde el entrypoint — nunca un archivo leído desde disco en runtime. Consecuencias: reutilizable en cualquier mundo que separe templates/contenido de una Edge Function en archivos propios. Verificar siempre con una llamada real (curl) tras el deploy, no solo confiar en que la respuesta del deploy diga “status”:“ACTIVE” — eso no garantiza que la función bootee al recibir un request. Ver lippia-alba (supabase/functions/send-email/_templates/`, sesión 2026-07-05, ADR-006).
ADR-G015 — Versionado de Edge Functions: manifest VERSIONS.json + health-check ?health embebido, sin CI que lo automatice
Section titled “ADR-G015 — Versionado de Edge Functions: manifest VERSIONS.json + health-check ?health embebido, sin CI que lo automatice”Contexto: proyecto con ~23 Edge Functions Deno donde no hay forma barata de saber, sin invocar cada una manualmente, si lo desplegado en Supabase coincide con el código fuente local — el dashboard de Supabase no expone un hash/versión legible del código activo.
Decisión: (1) cada Edge Function exporta su propio export const VERSION = "x.y.z" (semver, bump manual en cada cambio funcional) y llama a handleHealthCheck(req, name, VERSION, corsHeaders) (helper en _shared/version.ts) como primera línea del handler — un GET con ?health devuelve { name, version } sin tocar la BD ni requerir auth; (2) un manifest VERSIONS.json en la raíz de edge_functions/ lista cada function con su path, slug desplegado real (puede diferir del nombre de archivo — ver send-newletter-resend.ts → slug send-newsletter), deployed: bool, version esperada, updated_at; (3) un script (check:edge-versions) hace GET ?health a cada slug desplegado y compara contra el manifest, reportando drift.
Deliberadamente NO automatizado en CI: el bump de VERSION y el update de VERSIONS.json son pasos manuales post-deploy — se aceptó el riesgo de olvido a cambio de no acoplar el pipeline de deploy (vía MCP deploy_edge_function, no supabase functions deploy en CI) a un paso extra de versionado.
Consecuencias: reutilizable en cualquier mundo con múltiples Edge Functions y sin CI de deploy automatizado. Costo: el manifest puede quedar desactualizado si alguien deploya sin bumpear — el script de check solo detecta drift si se corre, no lo previene. Ver alborstudio (supabase/edge_functions/VERSIONS.json, _shared/version.ts, scripts/check-edge-versions.ts, sesión 2026-07-08).
ADR-G016 — upload-sarif nunca funciona en repos privados sin GitHub Advanced Security (GHAS) pagado
Section titled “ADR-G016 — upload-sarif nunca funciona en repos privados sin GitHub Advanced Security (GHAS) pagado”Contexto: job de CI con Semgrep que sube el reporte SARIF a la Security tab de GitHub vía github/codeql-action/upload-sarif@v3. Fallaba en un repo privado bajo plan GitHub Free, sin importar los permissions: del job (security-events: write incluido).
Decisión: confirmado con gh api repos/{owner}/{repo}/code-scanning/alerts → 403 "Advanced Security must be enabled for this repository to use code scanning." GHAS (code scanning, secret scanning push protection, etc.) es una feature de pago para repos privados — sin ella, upload-sarif no puede funcionar nunca, es un límite de plan, no de permisos ni de configuración del workflow. Reemplazar por actions/upload-artifact@v4 (sube el SARIF como artifact descargable del run) cuando no se tenga certeza de que el repo tiene GHAS habilitado.
Consecuencias: reutilizable en cualquier mundo con repo privado + Semgrep/CodeQL en CI. Antes de invertir tiempo debugueando upload-sarif, verificar primero con la llamada gh api de arriba si GHAS está disponible en el repo — evita gastar ciclos ajustando permisos de un job que estructuralmente no puede pasar. Ver alborstudio (.github/workflows/world-react.ci.yml, commit 2c6d968, sesión 2026-07-22).
ADR-G017 — Contratos de API entre mundos front/backend: namespace por producto + symlink + drift-check genérico por hook
Section titled “ADR-G017 — Contratos de API entre mundos front/backend: namespace por producto + symlink + drift-check genérico por hook”Contexto: el universo empezará a tener mundos que son pares front/backend (o un backend sirviendo varios frontends). No existía forma de que un frontend supiera que el contrato de API de su backend cambió, ni convención para compartir el archivo del contrato sin duplicarlo.
Decisión: cada producto tiene una carpeta _shared/contracts/<producto>/api-contract.yaml, symlink al artefacto real generado por el mundo backend (worlds/<producto>-backend/dist/api-contract.yaml) — el backend es la única fuente de verdad, nunca se copia el archivo. Ambos mundos del par se registran en _shared/contracts/registry.json (mundo → ruta del contrato, relativa a _shared/). Un hook SessionStart genérico (_shared/scripts/check-contract-drift.sh, wired en _shared/templates/world.settings.json) compara el hash SHA-256 del contrato contra el guardado en .claude/.contract-hash de la sesión anterior y avisa si cambió; primera ejecución en un mundo solo establece la línea base, no avisa. Un mundo sin entrada en el registry (ej. un mundo sin par, como alborstudio) no activa nada — no requiere exclusión manual. Lookup del registry via node -e en vez de jq (Node es dependencia dura de todo el stack del universo, jq no viene con Git Bash en Windows).
Consecuencias: reutilizable en cualquier mundo front/backend nuevo — se aplica automáticamente vía /new-world-* al copiar el template de settings. Mundos existentes no se ven afectados (ninguno tiene contrato registrado hoy). Ver _shared/contracts/README.md para el procedimiento de registro.
ADR-G018 — Modelos Anthropic en world.settings.json requieren chequeo periódico de deprecations
Section titled “ADR-G018 — Modelos Anthropic en world.settings.json requieren chequeo periódico de deprecations”Contexto: _shared/templates/world.settings.json fija los alias de modelo (ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_SMALL_FAST_MODEL, CLAUDE_CODE_SUBAGENT_MODEL) que hereda cada mundo nuevo. Anthropic deprecia/renombra alias sin aviso dentro del universo — no hay CI que lo detecte.
Decisión: comando /check-model-deprecations (_shared/commands/check-model-deprecations.md) consulta https://platform.claude.com/docs/es/about-claude/model-deprecations y actualiza los 4 valores en _shared/templates/world.settings.json si cambiaron, dejando este ADR como registro de “valores vigentes”. Correr manualmente antes de crear un mundo nuevo, o vía el cron de sesión que lo invoca periódicamente (ver README del comando — limitado a la sesión activa de Claude Code, no persiste entre reinicios). Valores vigentes al 2026-07-25 (actualizado tras detectar claude-opus-4-1 obsoleto desde 2026-06-05, retiro 2026-08-05, reemplazo claude-opus-4-8):
ANTHROPIC_MODEL=claude-sonnet-5ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-5ANTHROPIC_SMALL_FAST_MODEL=claude-haiku-4-5-20251001CLAUDE_CODE_SUBAGENT_MODEL=claude-sonnet-4-5Consecuencias: mundos ya creados no se actualizan retroactivamente (cada uno copió el template al nacer) — si un alias deprecado rompe un mundo existente, corregir su .claude/settings.json local además del template. El comando debe actualizar este bloque de valores y la fecha cada vez que detecte un cambio real.
ADR-G019 — Mundos colaborativos se registran como submodule + negación puntual en .gitignore
Section titled “ADR-G019 — Mundos colaborativos se registran como submodule + negación puntual en .gitignore”Contexto: worlds/* está excluido por defecto en el .gitignore raíz (cada mundo tiene su propio repo/git, no versionado dentro del universo). Un mundo que necesita ser visible/trabajable por otro usuario que clone el repo ai-os es caso distinto a “público en internet” — es visibilidad dentro del universo para colaboración multi-usuario.
Decisión: un mundo colaborativo se registra como git submodule real: .gitmodules con [submodule "worlds/<mundo>"] (path/url/branch), gitlink (modo 160000) agregado al índice del repo raíz vía git update-index --add --cacheinfo 160000,<sha>,worlds/<mundo> (evita que git submodule add intente clonar sobre un directorio ya poblado si el directorio ya existía con contenido), y una línea !worlds/<mundo> agregada al .gitignore inmediatamente debajo del comentario que documenta la convención (línea worlds/*). Todos los demás mundos siguen excluidos individualmente por defecto — la negación es explícita por mundo, nunca global.
Consecuencias: un clone nuevo del universo debe correr git submodule update --init --recursive (o git clone --recurse-submodules) para materializar el contenido de todos los mundos colaborativos registrados — ver procedimiento completo en el README raíz, sección “Multi-usuario”. Reutilizable para cualquier mundo futuro que pase de individual a colaborativo.
ADR-G023 — Symlinks reales en Windows: mklink vía .bat, siempre ruta absoluta
Section titled “ADR-G023 — Symlinks reales en Windows: mklink vía .bat, siempre ruta absoluta”Contexto: crear symlinks reales en Windows en este universo (contratos de API, on-clear.sh por mundo, imports de new-world-import) falla con los métodos obvios: ln -s (Git Bash) cae en silencio a copiar el contenido (sin error, pero no es symlink real); New-Item -ItemType SymbolicLink (PowerShell) tira NewItemSymbolicLinkElevationRequired aunque Developer Mode esté activo. Aparte, un symlink con ruta relativa calculada a mano es frágil — un error de conteo de niveles .. lo deja roto sin fallo visible (caso real: on-clear.sh en worlds/finances-backend/worlds/finances-frontend, sesión 2026-07-29).
Decisión: (1) crear el symlink con cmd.exe’s mklink — con Developer Mode activo es el único mecanismo que funciona sin admin. Desde el Bash tool (Git Bash), invocar cmd //c directo con rutas con espacios rompe por quoting; solución: escribir un .bat temporal en el scratchpad con cd /d "<ruta>" + mklink <link> <target>, y ejecutarlo con cmd //c "<ruta-al-bat>" (doble slash). (2) el target del symlink siempre en ruta absoluta ($AI_OS_ROOT/...), nunca relativa — elimina la clase de bug de conteo de ... _shared/scripts/link-world-agents.ps1 (función New-SymlinkSafe) ya implementa el patrón completo (intenta New-Item, cae a mklink, ruta absoluta) — reusar ese script en vez de reescribir el .bat a mano cuando el caso encaje con su firma.
Consecuencias: reutilizable en cualquier mundo/script del universo que necesite symlinks reales en Windows. Ver sesión de creación de _shared/contracts/ladder/api-contract.yaml como symlink real, y el incidente de on-clear.sh en finances-backend/finances-frontend.
ADR-G027 — Suites de test que apuntan a un server legacy “real” nunca deben defaultear al puerto donde corre producción real, aunque sea “solo para desarrollo”
Section titled “ADR-G027 — Suites de test que apuntan a un server legacy “real” nunca deben defaultear al puerto donde corre producción real, aunque sea “solo para desarrollo””Contexto: finances-api corre en paralelo a finances-backend (legacy), que sigue sirviendo tráfico real de producción en :3001 de forma permanente (proceso siempre activo, mismo host que el dev machine). El harness de la suite de equivalencia (test/equivalence/helpers/setup.ts) defaulteaba LEGACY_BASE_URL a http://localhost:3001 asumiendo que ahí correría una instancia local apuntando a una copia de la BD (finances_copy) — pero el proceso de producción real ocupa ese puerto de forma constante, así que cualquier corrida de la suite sin la instancia local explícitamente levantada primero terminó pegándole a producción real sin que nada lo detectara. Resultado: 41 usuarios de test + datos en cascada creados en la BD real de producción, descubiertos solo cuando una migración incremental posterior crasheó por un FK violation expuesto por esos datos huérfanos.
Decisión: (1) el default de la URL de un “server legacy” en un test harness nunca debe coincidir con el puerto real de producción, aunque la intención sea “vas a levantar una copia ahí” — mover el default a un puerto que producción nunca ocupa (ej. :4001 en vez de :3001); (2) agregar un guard explícito que tire error duro (no warning) si la URL resuelta apunta al puerto de producción conocido, sin importar si vino del default o de una env var explícita — cero confianza en que quien corre el suite recuerde la convención.
Consecuencias: reutilizable en cualquier mundo con un backend legacy siempre-activo corriendo en paralelo a uno nuevo en el mismo host. Regla general: si un puerto puede ser ocupado por un proceso de producción real, ningún test harness debe defaultear ahí — el costo de un puerto “no obvio” es mínimo comparado con el de contaminar datos reales. Ver finances-api/test/equivalence/helpers/setup.ts, finances-api/scripts/cleanup-test-junk.ts, TASKS.md task 009, sesión 2026-08-03.
ADR-G028 — Trigger on_auth_user_created no puebla retroactivamente cuentas preexistentes
Section titled “ADR-G028 — Trigger on_auth_user_created no puebla retroactivamente cuentas preexistentes”Contexto: chatbot agregó una tabla profiles (roles admin/user) poblada por un trigger after insert on auth.users. Al aplicar la migración al proyecto real y probar /admin, la cuenta del propio admin (creada antes de la migración) quedaba sin fila en profiles — la policy role !== 'admin' la redirigía como si fuera un user normal, sin error visible.
Decisión: cualquier migración que introduzca un trigger after insert on auth.users (u otro patrón de “puebla tabla satélite al crear fila padre”) debe incluir en la misma migración, o en una migración de backfill inmediatamente siguiente, un insert into <tabla_satelite> (...) select ... from auth.users where id not in (select id from <tabla_satelite>) — el trigger solo cubre inserts futuros, nunca las filas ya existentes en la tabla disparadora. Diagnosticar este síntoma corriendo select id, email, role from profiles (o equivalente) directo en el SQL editor para confirmar filas faltantes antes de sospechar de RLS/policies.
Consecuencias: reutilizable en cualquier mundo Supabase que agregue una tabla de perfil/rol/settings-por-usuario poblada por trigger sobre auth.users en un proyecto que ya tiene usuarios reales. Ver chatbot/supabase/migrations/20260803230000_roles.sql, 20260803233000_backfill_profiles.sql, sesión 2026-08-03.