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.
ADR-G029 — repo de mundos personales/privados nunca en registry.json committeado — vive en registry.private.json gitignored
Section titled “ADR-G029 — repo de mundos personales/privados nunca en registry.json committeado — vive en registry.private.json gitignored”Contexto: registry.json es el manifiesto de mundos del universo, committeado y visible a cualquiera con acceso al repo ai-os (toda la org). Registraba el repo de TODOS los mundos, incluidos los personales/privados no-submodule (alborstudio, lippia-alba, cordoba-morales-order) — el gate real de acceso a esos repos es el ACL de GitHub (colaborador explícito), pero registry.json igual exponía nombre+URL de mundos que nunca se quiso listar como colaborativos, a cualquier miembro de la org que abriera el archivo o corriera un script que lo leyera (ver _shared/scripts/clone-private-worlds.ps1).
Decisión: el campo repo de un mundo personal/privado en registry.json va siempre en null. La URL real se registra en _shared/memory/registry.private.json (gitignored, nunca se commitea), con un _shared/memory/registry.private.json.example committeado como plantilla de formato. Cualquier script/skill que necesite resolver el repo de un mundo debe leer registry.json primero y, si viene null, caer a registry.private.json si existe.
Consecuencias: cada usuario arma su propio registry.private.json local copiando el .example — no hay URL de mundo privado en el historial de git del universo desde este ADR en adelante (URLs previas ya commiteadas en commits viejos siguen en el historial; rotar/considerar privado ese repo si el nombre expuesto importa). Reutilizable para cualquier mundo futuro que sea personal y cuya sola existencia/URL no deba ser visible a toda la org. Ver _shared/scripts/clone-private-worlds.ps1, README sección “Multi-usuario”, sesión 2026-08-09.
ADR-G030 — Formulario de contacto en sitio nuevo: siempre config (allowlist + widget genérico), nunca deploy nuevo ni subdominio de email
Section titled “ADR-G030 — Formulario de contacto en sitio nuevo: siempre config (allowlist + widget genérico), nunca deploy nuevo ni subdominio de email”Contexto: ladder-api reemplazó el Worker de Resend legacy (mapping CLIENTS hardcodeado, sin allowlist/rate-limit/auditoría/captcha) por un dominio contact-form propio (POST /forms/contact, GET /forms/widget.js). El patrón es reutilizable por cualquier mundo/producto del universo que sirva landings de clientes con formulario de contacto — no solo ladder-web.
Decisión: cada sitio nuevo que necesite formulario de contacto sigue esta vía, sin excepción, sin reinventar por cliente:
- Allowlist + destino por sitio, vía admin —
PATCH /sites/:slug/contact-configconallowedOrigins(jsonb array, apex+www+staging) ynotifyEmail. Lo configura el operador (Amed), nunca el cliente ni un env var por sitio. - Widget genérico, no JS por cliente —
<form data-ladder-form="<slug>">+<script src=".../forms/widget.js">servido desde el backend. Alta de cliente nuevo = HTML snippet en su landing + 1 llamada admin, cero deploy de código. - Un solo Turnstile site key para todos los hostnames — agregar el hostname nuevo al site key existente en Cloudflare, nunca crear un widget/site key por cliente.
- Remitente de email compartido y ya verificado —
forms@ladderdev.com(dominio raíz, DKIM verificado en Resend) sirve para cualquier sitio nuevo;replyTo= email del visitante. No crear subdominio (send.<dominio>) ni verificar DNS nuevo por cliente — eso solo se justifica con volumen alto o newsletters propias. Consecuencias: onboarding de contacto para un sitio nuevo es 100% config (paso 1) + copy-paste de snippet (paso 2), sin tocar código ni DNS. Detalle completo de implementación y procedimiento paso a paso →worlds/ladder-api/docs/adr/004-contact-form-endpoint.mdyworlds/ladder-api/docs/contact-form-integration.md. Si un mundo futuro no comparte backend conladder-api, replicar el mismo patrón (allowlist admin + widget genérico + Turnstile compartido + sender ya verificado) en vez de volver al patrón Worker+mapping-hardcodeado descartado acá.
ADR-G031 — Script Umami siempre vía env vars (VITE_UMAMI_SRC/VITE_UMAMI_WEBSITE_ID), nunca hardcodeado en HTML
Section titled “ADR-G031 — Script Umami siempre vía env vars (VITE_UMAMI_SRC/VITE_UMAMI_WEBSITE_ID), nunca hardcodeado en HTML”Contexto: alborstudio (React) hardcodea el <script> de Umami directo en index.html — src="https://analytics.ladderdev.com/script.js" + data-website-id="0cf53f5c-..." fijos en el código. freemanstyle (Vite vanilla, scaffold CFD) en cambio usa el reemplazo nativo %VITE_X% de Vite en HTML crudo — %VITE_UMAMI_SRC%/%VITE_UMAMI_WEBSITE_ID% en las 8 páginas — resuelto desde .env en build, sin tocar código para cambiar servidor o sitio.
Decisión: todo mundo nuevo (Vite/vanilla o React+Vite) implementa Umami vía env vars, patrón freemanstyle, no el patrón hardcodeado de alborstudio (legacy, no migrar retroactivo salvo que se toque ese archivo de todos modos):
<script defer src="%VITE_UMAMI_SRC%" data-website-id="%VITE_UMAMI_WEBSITE_ID%"></script>Vars (documentar siempre en docs/STACK.md y .env.example del mundo, marcadas “Opcional” si el sitio aún no tiene cuenta creada en la instancia self-host):
VITE_UMAMI_SRC— URL completa del script tracker. Self-host universo:https://analytics.ladderdev.com/script.js. Cloud Umami:https://cloud.umami.is/script.js.VITE_UMAMI_WEBSITE_ID— website-id del sitio, generado al crear el sitio en el panel Umami (self-host o cloud). Un id por dominio, nunca reusar el de otro mundo. Cómo setear: (1) local/dev →.envdel mundo (gitignored, copiar de.env.example); (2) prod → variable de entorno en el panel de build de Cloudflare Pages del proyecto (Settings → Environment variables), mismo nombre exactoVITE_UMAMI_SRC/VITE_UMAMI_WEBSITE_ID, scope Production (+ Preview si se quiere trackear previews). Sin.envlocal ni env var en Cloudflare, Vite deja el placeholder%VITE_X%literal sin resolver en el HTML servido — no rompe el build, pero el script no carga (analytics no configurado, no un error). Consecuencias: reutilizable en cualquier mundo Vite/vanilla nuevo con Umami. Sitio nuevo = crear cuenta en el panel Umami → copiar website-id → setear las 2 vars en Cloudflare Pages, cero cambio de código.alborstudioqueda como deuda a migrar si se vuelve a tocar suindex.html. Ver sesión 2026-08-21,worlds/freemanstyle/src/*.html,worlds/alborstudio/index.html:8.
ADR-G032 — Error tracking client-side sin SDK: POST directo al store endpoint compatible Sentry (GlitchTip)
Section titled “ADR-G032 — Error tracking client-side sin SDK: POST directo al store endpoint compatible Sentry (GlitchTip)”Contexto: sitio estático (freemanstyle, Vite/vanilla, sin backend propio) necesitaba capturar errores JS del navegador y reportarlos a GlitchTip (self-hosted, protocolo compatible Sentry). Primer intento fue server-side (Cloudflare Pages Function, var sin prefijo) — corregido a client-side por pedido explícito del usuario: la var debe ir VITE_-prefijada (VITE_GLITCHTIP_DSN) para exponerse al bundle vía import.meta.env.
Decisión: el protocolo de ingestión de Sentry/GlitchTip es un POST JSON simple — no justifica agregar @sentry/browser (u otro SDK) a un sitio chico. Patrón: listeners nativos window.addEventListener('error'|'unhandledrejection') → parsear el DSN con new URL() nativo (username = public key, pathname = project id) → fetch(${protocol}//${host}/api/${projectId}/store/, { headers: { 'X-Sentry-Auth': 'Sentry sentry_version=7, sentry_client=..., sentry_key=<public_key>' }, body: JSON.stringify({ message, level, platform, exception: { values: [...] } }) }). Sin DSN configurada → no-op silencioso, nunca rompe el sitio.
Consecuencias: reutilizable en cualquier mundo Vite/vanilla (o SPA sin backend propio) que necesite error tracking básico sin el peso de un SDK completo (breadcrumbs, session replay, batching). Si el volumen de tráfico crece y se necesita más contexto por error, reevaluar hacia el SDK real. Ver worlds/freemanstyle/src/js/errors.js, ADR-003 de freemanstyle, sesión 2026-08-21.
ADR-G033 — Rutas de command/args en .mcp.json raíz siempre absolutas (${AI_OS_ROOT}), nunca relativas
Section titled “ADR-G033 — Rutas de command/args en .mcp.json raíz siempre absolutas (${AI_OS_ROOT}), nunca relativas”Contexto: mcp-orchestrator y time-tracker en .mcp.json raíz usaban rutas relativas (./mcp-orchestrator/src/index.js). Andaban al abrir sesión de Claude Code en la raíz del universo (cwd = raíz, la ruta relativa resolvía bien), pero fallaban (✘ failed) al abrir sesión dentro de worlds/<mundo>/ — Claude Code resuelve rutas relativas de .mcp.json contra el cwd de lanzamiento, no contra la carpeta donde vive el archivo .mcp.json. Root cause invisible en el warning de la UI (solo decía “failed”, sin ENOENT explícito).
Decisión: cualquier server MCP stdio definido en el .mcp.json raíz (heredado por todos los mundos) que invoque un script del propio universo (node ./ruta/index.js) debe usar ruta absoluta vía ${AI_OS_ROOT} (env var de sistema ya documentada, ver [readme][002]), nunca relativa — sin importar desde qué mundo se abra la sesión.
Consecuencias: reutilizable para cualquier MCP server nuevo agregado al .mcp.json raíz. Si un server “conecta en la raíz pero falla en los mundos”, primer sospechoso es ruta relativa en command/args, no env vars. Ver ai-os/.mcp.json, sesión 2026-08-22.
ADR-G034 — Repos en memoria vía factory por dominio, para correr un backend hexagonal sin su DB real en QA/tests visuales
Section titled “ADR-G034 — Repos en memoria vía factory por dominio, para correr un backend hexagonal sin su DB real en QA/tests visuales”Contexto: ladder-api (Hono + Drizzle + arquitectura hexagonal) necesitaba correr completo sin Postgres para permitir QA visual del frontend (ladder-web) sin depender de Docker/túnel SSH a producción. Cada ruta HTTP instanciaba new Drizzle*Repository(db) inline — el puerto (domain/*/**.repository.ts) ya estaba desacoplado a nivel de use-case, pero no había un swap point real en la capa de infraestructura.
Decisión: infrastructure/persistence/repository-factory.ts central, un getter por puerto (getLeadRepository(), etc.) que devuelve new Drizzle*Repository(db) o una instancia singleton de In-Memory*Repository según process.env.USE_FAKE_REPOS === 'true'. Los fakes son singletons a nivel módulo (const fakeX = new InMemoryXRepository() fuera de la función) porque el estado tiene que sobrevivir entre requests dentro del mismo proceso — a diferencia de los repos Drizzle, que son envoltorios sin estado sobre un db compartido y se pueden recrear por request sin costo. Cuando dos puertos del mismo dominio necesitan comportamiento cruzado (ej. un repo que hace join+fallback contra otra tabla, ver service-price/service-price-region de ladder-api), el repo en memoria “principal” recibe el otro repo en memoria por constructor y replica la misma lógica de fallback que la versión Drizzle — no se comparte una tabla Map cruda entre clases no relacionadas.
Verificación real, no solo tsc: (1) chequeo instanceof confirmando que el factory devuelve la clase correcta según la env var; (2) smoke test HTTP end-to-end contra cada endpoint público con USE_FAKE_REPOS=true, confirmando explícitamente que una lectura que en DB real tendría datos sembrados devuelve vacío (prueba negativa de que no cayó a la real por accidente).
Consecuencias: reutilizable en cualquier mundo backend hexagonal (Hono/Express/Fastify + ORM) que quiera desacoplar QA/demo de su DB real sin mockear a nivel de test unitario. Fuera de alcance: subsistemas con su propio storage no mockeable por este mecanismo (ej. Better Auth, que sigue necesitando Postgres real para sesión/usuario) — documentarlo explícito como excepción, no forzar el swap ahí. Ver worlds/ladder-api/src/infrastructure/persistence/repository-factory.ts, [backend][005] en TASKS.md, sesión 2026-08-24. Reusado tal cual en freeman-api (2026-09-13, [db][001]): misma factory + test/setup.ts como setupFiles de vitest para que la env var esté antes del primer import de rutas.
ADR-G035 — En Windows/Git Bash, kill $(cat pidfile) no mata el node.exe real de un server backgroundeado — usar taskkill //F //PID
Section titled “ADR-G035 — En Windows/Git Bash, kill $(cat pidfile) no mata el node.exe real de un server backgroundeado — usar taskkill //F //PID”Contexto: smoke test de ladder-api con npm run dev > log 2>&1 & echo $! > pidfile seguido de kill $(cat pidfile) para apagarlo. El PID capturado por $! en Git Bash es el del wrapper/job de bash, no el del node.exe real subyacente en Windows — el kill “funciona” (no da error) pero el proceso real sigue vivo y el puerto sigue LISTENING. Consecuencia real en ladder-api: un segundo smoke test asumió que el puerto estaba libre para un server nuevo con env vars distintas (USE_FAKE_REPOS=true); en realidad los requests siguieron cayendo en el server viejo (sin esa env var), que escribió filas de test en la base de producción real sin que nada lo advirtiera.
Decisión: (1) nunca confiar en que un kill previo mató el proceso real — verificar con netstat -ano | grep ":$PUERTO" que no queda ningún LISTENING antes de asumir el puerto libre; (2) si sigue vivo, matar el PID real (el de la columna del netstat, no el $! de bash) con taskkill //F //PID <pid> — doble slash obligatorio, Git Bash reinterpreta /F//PID como rutas de filesystem con slash simple y el comando falla silenciosamente contra el flag equivocado.
Consecuencias: reutilizable en cualquier mundo/sesión que levante servers en background en Windows vía Git Bash para smoke tests. Aplica el mismo principio que ADR-G027 (nunca confiar en el estado asumido de un puerto/proceso, verificar empírico) pero para “¿de verdad maté esto?” en vez de “¿qué está corriendo ahí?”. Ver worlds/ladder-api/TASKS.md [backend][005], sesión 2026-08-24.
ADR-G037 — Contrato JWT entre auth-service emisor y gateway consumidor: los claims custom van en el mismo diseño doc que el middleware que los lee, verificados con un test de round-trip
Section titled “ADR-G037 — Contrato JWT entre auth-service emisor y gateway consumidor: los claims custom van en el mismo diseño doc que el middleware que los lee, verificados con un test de round-trip”Contexto: ladder-gateway’s requireOrgAccess (implementado y marcado done, [auth][008] del gateway) ya leía payload.role/payload.activeOrganizationId del JWT para setear los headers de confianza X-User-Role/X-Org-Id hacia las APIs downstream — pero el plugin jwt de ladder-auth-service (el emisor) nunca definía esos claims (definePayload ausente, payload default = solo el user). El gap existía desde el propio doc de diseño (docs/gateway-auth-centralizada.md): el snippet de config del lado emisor documentaba jwt({ jwt: { expirationTime: '10m' } }) sin definePayload, mientras el snippet del lado gateway (sección aparte del mismo doc) ya asumía esos claims. Nadie lo notó porque ambos lados “compilan” y pasan sus propios tests unitarios por separado — el contrato entre servicios nunca se verificó junto hasta que se investigó un cutover no relacionado ([migracion][009]).
Decisión: (1) cuando un servicio emisor de JWT y un servicio consumidor viven en mundos separados, el doc de diseño que describe el contrato debe mostrar AMBOS snippets (emisor + consumidor) con los mismos nombres de claim, nunca un lado con el claim y el otro con un comentario tipo “esto ya lo hace el JWT” sin el código real; (2) el lado emisor añade un test de round-trip real — sign-up→sign-in→setear el estado que alimenta el claim (ej. organization/set-active)→pedir el JWT→decodificar el payload→assert sobre el claim exacto que el consumidor espera — no alcanza con un test que solo confirma “el endpoint devuelve un JWT válido” (eso pasa igual si el payload viene vacío).
Consecuencias: reutilizable en cualquier par de mundos con relación emisor/consumidor de JWT (o cualquier contrato de payload/headers entre servicios separados) — el bug es estructuralmente invisible a tsc/tests unitarios de un solo lado, solo lo agarra un test de integración real o una revisión manual cruzada de ambos snippets del doc de diseño. Ver worlds/ladder-auth-service/docs/gateway-auth-centralizada.md §3.3/§4.4, worlds/ladder-auth-service/src/infrastructure/auth/better-auth.config.ts, test/auth-flows.spec.ts, [auth][017] en TASKS.md, sesión 2026-09-03.
ADR-G036 — Tabla pgvector de tooling universo (doc_chunks): schema SQL standalone + scripts .cjs reusando pg de un mundo vía NODE_PATH, nunca CLAUDE.md en el corpus indexado
Section titled “ADR-G036 — Tabla pgvector de tooling universo (doc_chunks): schema SQL standalone + scripts .cjs reusando pg de un mundo vía NODE_PATH, nunca CLAUDE.md en el corpus indexado”Contexto: se construyó _shared/skills/doc-search (skill /search-docs) para búsqueda semántica sobre la documentación Markdown del universo, tabla doc_chunks en la misma DB ladder que ya usa time-tracker. Esta máquina no tiene psql/jq en PATH (a diferencia del patrón original de persistent-memory, que asume ambos). Un primer intento de apply-schema.mjs (ESM) falló con Cannot find package 'pg' a pesar de NODE_PATH apuntando a worlds/ladder-api/node_modules — NODE_PATH solo afecta la resolución de require() (CommonJS), nunca la de import (ESM). Una corrida de ingesta inicial demasiado amplia (glob recursivo sobre worlds/ completo) intentó recorrer symlinks de .claude/skills espejados en ~20 mundos y colgó/se hizo lenta. A mitad de la ingesta ampliada, Amed corrigió: ningún CLAUDE.md de ningún mundo debe estar en el corpus indexado.
Decisión: (1) tablas de infraestructura de tooling a nivel universo (no dominio de ninguna app) van en schema SQL standalone (schema.sql + script apply-schema.cjs), nunca como migración Drizzle/Prisma de un mundo específico — mismo criterio que ai_os_memory/work_sessions; (2) cualquier script Node del universo que necesite reusar un paquete (pg, etc.) ya instalado en worlds/<mundo>/node_modules sin duplicar el install debe ser CommonJS (.cjs) y usar NODE_PATH, nunca .mjs/ESM — la resolución de módulos difiere; (3) psql/jq no se pueden asumir disponibles en toda máquina del universo — scripts de tooling que necesiten DB+HTTP van en Node puro (pg + fetch nativo), con fallback bash+psql+jq solo donde ya esté confirmado que existen; (4) ningún CLAUDE.md de ningún mundo (ni el root) entra jamás a un corpus de búsqueda/embeddings — son instrucciones de comportamiento para el agente, no documentación de producto o arquitectura; ingerirlos contaminaría resultados de búsqueda con contenido que no responde “cómo está configurado/documentado X”. Ojo con filtros de purga por patrón amplio (ILIKE '%CLAUDE.md'): pueden atrapar documentos legítimos cuyo nombre de archivo coincida por accidente (ej. un doc titulado literalmente ...claude.md) — filtrar por ruta exacta o patrón ^(worlds/[^/]+/)?CLAUDE\.md$, no substring.
Consecuencias: reutilizable en cualquier tabla/skill de tooling universo futura (ej. si el chatbot RAG llega a compartir infra). Ver _shared/skills/doc-search/, TASKS.md [docsearch][045..047], sesión 2026-08-29.
ADR-G039 — Reglas de comportamiento de agente (ladder YAGNI, estilo terso) se propagan distinto en Claude Code vs OpenCode: plugin nativo vs texto en AGENTS.md
Section titled “ADR-G039 — Reglas de comportamiento de agente (ladder YAGNI, estilo terso) se propagan distinto en Claude Code vs OpenCode: plugin nativo vs texto en AGENTS.md”Contexto: el universo usa dos plugins de Claude Code para moldear cómo trabaja el agente — caveman (comprime prosa de salida) y ponytail (fuerza ladder YAGNI antes de escribir código). Ambos están instalados como plugin real en Claude Code (~/.claude/settings.json → enabledPlugins), con hook SessionStart que auto-activa el modo sin acción manual (/caveman//ponytail solo cambian nivel o apagan). OpenCode no lee ~/.claude/plugins/ — cada mundo trae su propio opencode.json. (Actualizado 2026-09-11): caveman sí tiene instalación nativa para OpenCode — installer oficial (npx -y github:JuliusBrussee/caveman -- --only opencode) instala el plugin global en ~/.config/opencode/plugins/caveman/ + comandos/agentes/skills, parchea el opencode.jsonc global (los configs de opencode en el mismo directorio se mergean, no se reemplazan — convive con el opencode.json de headroom) y appendea el ruleset a ~/.config/opencode/AGENTS.md (marcadores caveman-begin/end) → auto-activa en toda sesión OpenCode de cualquier mundo, sin activación manual (paridad con el hook SessionStart de Claude Code). La afirmación previa de esta ADR — que caveman tenía “puerto propio” en plugins/caveman/plugin.js referenciado desde el opencode.json raíz — era falsa: ese archivo nunca existió en disco ni se commiteó (referencia rota, eliminada del opencode.json raíz); el mecanismo correcto es el config global, no un path relativo al repo. ponytail tampoco tenía plugin para OpenCode al momento de [opencode][050] (2026-09-10), pero el upstream ya lo shippea: plugin nativo @dietrichgebert/ponytail (npm v4.9.0, sección “OpenCode” del README oficial), instalado el mismo 2026-09-11 como entrada plugin global en ~/.config/opencode/opencode.jsonc junto a caveman — inyecta el ruleset en cada turno, niveles lite/full/ultra/off, comandos /ponytail* e inyección en subagentes. Con ambos plugins nativos, la sección “Scope de código” del AGENTS.md raíz se recortó a un puntero + la única regla propia no cubierta por el plugin (fix de bug = causa raíz en la función compartida, no parche en cada caller). El texto plano en AGENTS.md queda como vía de fallback solo para reglas de comportamiento sin plugin nativo — sigue siendo la decisión de referencia para ese caso.
Decisión: cuando una regla de comportamiento de agente no tiene plugin nativo para una herramienta (OpenCode), se porta como texto plano a AGENTS.md — el archivo de contexto que OpenCode sí lee (vía "instructions": ["../../AGENTS.md"] en el opencode.json de cada mundo). El ladder de ponytail vive ahora en la sección “Scope de código” de AGENTS.md raíz, en vez de intentar escribir un plugin OpenCode a medida solo para paridad. Efecto: mismo comportamiento buscado (YAGNI, sin abstracciones no pedidas, diff más corto que resuelve), pero como contexto que el modelo lee y sigue, no como hook que se auto-activa — no ahorra tokens de output como sí lo hace un plugin de compresión de prosa (caveman), reduce en cambio el código generado.
Consecuencias: reutilizable para cualquier regla de comportamiento futura (de un plugin de Claude Code) que se quiera extender a mundos que corren bajo OpenCode — revisar primero si existe puerto/plugin nativo para OpenCode (como caveman); si no existe, texto en AGENTS.md es la vía, no reescribir el plugin. Todo mundo nuevo debe registrar "instructions": ["../../AGENTS.md"] en su opencode.json desde el scaffold inicial para heredar esto automático — agregarlo al template _shared/templates/world.opencode.json si existe, o al procedimiento de /new-world-*, evita repetir la auditoría manual hecha en [opencode][050] de TASKS.md. Ver AGENTS.md raíz, sesión 2026-09-10.
ADR-G038 — Cliente de API tipado (openapi-typescript+openapi-fetch) contra un contrato sin operationId: indexar por método+ruta, y los mocks de fetch en tests deben imitar un Response real
Section titled “ADR-G038 — Cliente de API tipado (openapi-typescript+openapi-fetch) contra un contrato sin operationId: indexar por método+ruta, y los mocks de fetch en tests deben imitar un Response real”Contexto: ladder-web reemplazó fetch() manual por un cliente tipado (openapi-typescript genera paths desde el YAML del contrato, openapi-fetch lo consume) para poder derivar automáticamente qué endpoints de ladder-api usa realmente el frontend (manifest x-consumers, insumo para scopear un proxy gateway). El contrato de ladder-api (registerPath() de @asteasolutions/zod-to-openapi) no trae operationId en ninguna de sus 81 operaciones. Migrar 8 archivos rompió 15 tests vitest existentes cuyos mocks de fetch y aserciones asumían la firma clásica fetch(url, options).
Decisión: (1) cuando el contrato OpenAPI no trae operationId, indexar toda la tooling derivada (manifest de consumo, drift-check, generación de rutas de gateway) por el par método+ruta (GET /sites/{slug}) en vez de forzar operationId en el generador del backend solo por conveniencia de un consumidor — openapi-fetch ya tipa paths nativamente por esa clave, no lo necesita; (2) openapi-fetch invoca fetch(request: Request) con un único objeto Request, no fetch(url, options) — cualquier mock de test debe extraer datos de ese objeto (request.url, .method, .clone().json()), nunca de un segundo argumento; (3) openapi-fetch también llama response.headers.get('Content-Length') para decidir cómo parsear el body — un mock de Response sin .headers (objeto Headers real, no un plain object) rompe el parseo en silencio (no throw visible, simplemente data/error quedan mal poblados); (4) si un test reasigna globalThis.fetch = vi.fn(...) a mitad de ejecución (después de que el cliente ya se creó), el cliente sigue usando la referencia vieja — createClient() captura fetch una sola vez, en el momento de instanciarse; el fix es .mockImplementationOnce()/.mockImplementation() sobre la instancia ya capturada, nunca reasignar el global.
Consecuencias: reutilizable en cualquier mundo que adopte openapi-typescript+openapi-fetch sobre un contrato sin operationId, y en cualquier suite de tests que migre de fetch() crudo a ese cliente. Ver worlds/ladder-web/docs/adr/002-cliente-api-tipado-openapi.md, worlds/ladder-web/tests/utils.js (readFetchRequest()), sesión 2026-09-03.
ADR-G045 — Coolify: healthcheck necesita curl/wget en la imagen final, Pre-deployment Command corre fuera del env inyectado por Infisical, networking interno estable vía Network Alias
Section titled “ADR-G045 — Coolify: healthcheck necesita curl/wget en la imagen final, Pre-deployment Command corre fuera del env inyectado por Infisical, networking interno estable vía Network Alias”Contexto: primer deploy real de ladder-auth-service (Dockerfile con Infisical CLI como entrypoint, ver ADR previo de Infisical Opción A) a Coolify falló en tres puntos independientes, cada uno con síntoma distinto y causa no obvia desde el error inicial.
Decisión — tres reglas independientes, todas confirmadas empíricamente contra un deploy real:
- Healthcheck necesita curl/wget en la imagen final. El healthcheck default de Coolify para apps HTTP es un
GET http://localhost:PORT/healthejecutado dentro del contenedor — si elDockerfilepurgacurl/wgettras usarlos para instalar algo (ej.apt-get purge -y curldespués de instalar la CLI de Infisical, por higiene de tamaño de imagen), el healthcheck falla concurl: not foundy Coolify hace rollback aunque el proceso Node esté sano y escuchando. Nunca purgar el HTTP client usado por el healthcheck, aunque parezca dead weight post-install. - Pre-deployment Command corre fuera del proceso envuelto por Infisical. Coolify ejecuta ese campo vía
docker exec <contenedor> sh -c '<comando>'contra el entorno real del contenedor — que no incluye los secrets que elCMDdel Dockerfile solo exporta dentro del subproceso hijo spawneado porinfisical run(los secrets nunca tocan el env del contenedor en sí, viven solo en el proceso hijo). Un comando de pre-deploy que necesiteDATABASE_URLu otro secret (ej.npm run db:deploy) falla con “Please provide required params” aunque el pipeline de Infisical funcione perfecto en runtime. Fix: el propio Pre-deployment Command debe repetir el wrapper completo (export INFISICAL_TOKEN=$(infisical login ...) && infisical run --token=$INFISICAL_TOKEN ... -- <comando real>) — sin envolverlo en unsh -c '...'extra, porque Coolify ya envuelve el contenido del campo en su propiosh -c '...'; el doble wrap produce quoting anidado corrupto (File name too long, exit 127). - Networking interno estable = Network Alias, nunca el nombre de contenedor. El nombre real del contenedor sigue el patrón
<uuid-recurso>-<timestamp-redeploy>— el UUID es estable, pero el sufijo cambia en cada redeploy/restart, así que cualquier otro servicio (ej.ladder-gateway) que apunte a ese nombre completo pierde conectividad en el próximo redeploy. Fix: el campo Network Alias (Advanced settings, disponible en recursos tipo “Application”) fija un hostname Docker DNS estable que sobrevive a redeploys — usarlo siempre para dar nombre a un servicio interno consumido por otro mundo, en vez de asumir el nombre de contenedor o el dominio público sslip.io (ese último funciona pero hace hairpin por el proxy público, innecesario para tráfico interno). Para recursos tipo “Service” (one-click: Uptime Kuma, Umami, GlitchTip, Glances), el equivalente persistente es el toggle Connect To Predefined Network →coolify— undocker network connectmanual no sobrevive a la recreación del contenedor. Consecuencias: reutilizable en cualquier mundo desplegado en Coolify con Dockerfile+Infisical. Diagnóstico rápido:curl: not founden logs de healthcheck → regla 1; error de “missing param” en un comando de pre-deploy que sí anda manual dentro del contenedor → regla 2; 502/conexión rechazada entre dos servicios internos que andaba justo después del deploy y dejó de andar tras un restart → regla 3. Verworlds/ladder-auth-service/Dockerfile(commit7c5e366, PR #2),[ops][018]enTASKS.md, sesión 2026-09-08.
Addendum 2026-09-09: regla 1 refinada tras nuevo fallo — con health_check_command sin customizar (null), el comando default que Coolify genera usa específicamente wget, no curl, aunque curl esté instalado. Instalar solo curl en la imagen (ej. porque ya se necesita para el setup de otra CLI) no alcanza — hay que instalar wget también, o setear health_check_command explícito con el binario que sí está presente. Log distintivo: /bin/sh: 1: wget: not found con el proceso Node arrancando bien (listening on port ...) justo antes. Regla adicional (4): el EXPOSE del Dockerfile es metadata pura, no fija el puerto real — el puerto en que escucha el proceso lo decide el código (process.env.PORT o hardcode) en runtime. El campo health_check_port de Coolify debe coincidir con ese puerto real, no con el EXPOSE; un mismatch produce el mismo síntoma (unhealthy → rollback) una vez resuelto el binario del healthcheck. Ver worlds/ladder-auth-service/Dockerfile (commit dc08cd7, PR #3), sesión 2026-09-09.
Addendum 2026-09-24/25 — instalación del CLI de Infisical: nunca vía script curl | bash de Cloudsmith. Ese endpoint (packagecloud.io/cloudsmith para infisical-cli) empezó a devolver 404 permanente el 2026-09-16 — Infisical migró su distribución a artifacts-cli.infisical.com sin mantener el endpoint viejo ni redirigir; no fue un outage temporal, cualquier reintento del mismo script sigue fallando indefinidamente. Fix confirmado en ladder-auth-service (Dockerfile commit b63faa0) y replicado igual en freeman-api (ver su ADR-008): instalar el CLI vía npm install -g @infisical/cli en vez del script de distribución — además de esquivar este 404 de raíz, es más estable en general (no depende de qué gestor de paquetes de SO decida usar el script instalador). Señal de alerta: build de Docker que fallaba sano hasta hace poco y ahora rompe en el paso de instalar el CLI con 404, sin ningún cambio propio en el Dockerfile. Ver worlds/ladder-auth-service/TASKS.md [ops][024], worlds/freeman-api/docs/adr/008-infisical-secrets-coolify-cd.md, sesión 2026-09-24/25.
ADR-G040 — Servicio detrás de gateway/reverse-proxy: cualquier env var usada para construir URLs absolutas de callback debe apuntar al dominio público del gateway, nunca al dominio propio del servicio
Section titled “ADR-G040 — Servicio detrás de gateway/reverse-proxy: cualquier env var usada para construir URLs absolutas de callback debe apuntar al dominio público del gateway, nunca al dominio propio del servicio”Contexto: ladder-auth-service (Better Auth) vive solo en red interna Docker, sin dominio público propio de uso real — el único punto público de la arquitectura es ladder-gateway (https://gateway.ladderdev.com), que proxea /api/auth/*. BETTER_AUTH_URL (usada por Better Auth para construir redirect_uri en OAuth) estaba seteada al dominio sslip.io que Coolify auto-genera para el servicio (útil para debug directo, pero no es la URL real que el usuario final visita). Google OAuth generaba redirect_uri=http://<uuid>.<ip>.sslip.io/api/auth/callback/google — funcional a nivel de “el endpoint responde 200”, pero habría sido rechazado por Google (redirect_uri_mismatch) en el flujo real, porque esa URL nunca fue registrada en Google Cloud Console (se registra la URL pública real, la del gateway).
Decisión: en cualquier arquitectura con un servicio detrás de un gateway/reverse-proxy, toda env var que el servicio use para construir (no solo servir) URLs absolutas — callbacks OAuth, links de verificación de email, redirect_uri, cookies con dominio explícito, etc. — debe apuntar al dominio público del gateway, nunca al dominio directo/interno/sslip.io del servicio. Verificar esto no es opcional ni asumible por “el env var existe y tiene una URL válida”: hay que probar el flujo real (ej. POST /api/auth/sign-in/social y leer el redirect_uri generado) contra el deploy real, porque el bug es 100% silencioso hasta que un proveedor externo (Google) lo rechaza.
Consecuencias: reutilizable en cualquier mundo desplegado detrás de un gateway propio del universo. Antes de dar por buena una integración OAuth/callback nueva, siempre confirmar el valor exacto de la URL generada contra el dominio público esperado, no solo que el endpoint devuelva 200. Ver worlds/ladder-auth-service, [auth][007b] en TASKS.md, sesión 2026-09-08.
ADR-G041 — MCP coolify (@masonator/coolify-mcp) detrás de Cloudflare Access necesita headers extra vía --header, no alcanza con el token de Coolify; una deploy key SSH dedicada por repo, nunca reutilizada
Section titled “ADR-G041 — MCP coolify (@masonator/coolify-mcp) detrás de Cloudflare Access necesita headers extra vía --header, no alcanza con el token de Coolify; una deploy key SSH dedicada por repo, nunca reutilizada”Contexto: coolify.ladderdev.com vive detrás de Cloudflare Zero Trust Access. Al copiar el .mcp.json de ladder-api (servidor coolify, solo COOLIFY_BASE_URL/COOLIFY_ACCESS_TOKEN) a aciky-backend, la primera llamada a cualquier tool mcp__coolify__* devolvió el HTML de login de Cloudflare Access en vez de JSON — el paquete solo mandaba Authorization: Bearer <token>, sin los headers CF-Access-Client-Id/CF-Access-Client-Secret que Access exige antes de dejar pasar el request a Coolify (mismo Service Token que ya usa el job deploy de ci.yml vía curl -H). El propio README de @masonator/coolify-mcp documenta la solución: flag repetible --header "Key: Value" en args.
Aparte, al crear la app nueva en Coolify (application create_key) se confirmó que cada aplicación (ladder-api, ladder-gateway, ladder-auth-service) tiene su propia deploy key SSH dedicada en Coolify (private_keys), agregada como Deploy Key read-only en el repo GitHub correspondiente — nunca se reutiliza una key entre repos, aunque todos sean del mismo team/org.
Decisión: (1) cualquier .mcp.json con servidor coolify apuntando a una instancia detrás de Cloudflare Access debe incluir los dos headers en args, no solo el token:
"args": ["-y", "@masonator/coolify-mcp", "--header", "CF-Access-Client-Id: <id>.access", "--header", "CF-Access-Client-Secret: <secret>"](2) para desplegar un mundo nuevo en un proyecto/servidor Coolify ya existente: generar un par SSH dedicado (ssh-keygen -t ed25519 -N ""), agregar la pública como Deploy Key read-only en el repo (gh repo deploy-key add <pub> --repo <org>/<repo> --title "<nombre>-deploy" — sin -w ya es read-only), subir la privada a Coolify (private_keys create), y recién ahí crear la app (application create_key) referenciando ese private_key_uuid — nunca el private_key_uuid de otra app existente. Descartar el par local (archivo temporal) apenas se confirma el uuid devuelto por Coolify.
(3) antes de crear una app nueva, get_infrastructure_overview da de un vistazo servidor/proyecto/apps/DBs existentes — usarlo para replicar la misma ubicación (mismo servidor+proyecto+environment_uuid) que un mundo hermano ya desplegado, en vez de asumir.
Consecuencias: reutilizable en cualquier mundo nuevo que se despliegue a una instancia Coolify compartida detrás de Cloudflare Access. El COOLIFY_WEBHOOK_URL del secret de GitHub Actions se arma como https://<host-coolify>/api/v1/deploy?uuid=<app-uuid> (GET + Authorization: Bearer <COOLIFY_API_TOKEN>, mismos headers CF-Access) — no lo entrega ningún tool MCP directo, se construye a mano con el uuid que devuelve application create_*. Ver worlds/aciky-backend/.mcp.json (gitignored), worlds/aciky-backend/TASKS.md [infra][002], sesión 2026-09-06.
ADR-G042 — Security headers (CSP incluida) en un sitio GitHub Pages: Cloudflare Transform Rules, no _headers del repo; CSP siempre mergeada completa, nunca reemplazada por fragmento
Section titled “ADR-G042 — Security headers (CSP incluida) en un sitio GitHub Pages: Cloudflare Transform Rules, no _headers del repo; CSP siempre mergeada completa, nunca reemplazada por fragmento”Contexto: aciky-frontend corre en GitHub Pages (no soporta archivo _headers estilo Netlify/Cloudflare Pages) con Cloudflare como proxy/CDN al frente. Al aplicar 6 headers de seguridad (HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy) y luego iterar la CSP contra errores reales de consola (chat widget con embeddings client-side vía @xenova/transformers/onnxruntime-web, importado dinámicamente desde esm.sh), el usuario pegó una CSP nueva completa en el campo de Cloudflare sin mergearla con la existente — perdió silenciosamente connect-src https://api.aciky.org y tumbó todo el sitio (auth, events, activities, testimonials, routes, spaces, settings) hasta que se detectó por el patrón de errores en consola.
Decisión: (1) sitios en GitHub Pages configuran cualquier header de seguridad vía Cloudflare Transform Rules (Response Header Transform Rule, acción “Establecer estático”), nunca vía archivo en el repo — GitHub Pages lo ignora; (2) cada iteración de CSP se entrega al usuario como string completo ya mergeado con todo lo que el dominio necesita (no solo el fix puntual del error nuevo), con advertencia explícita de pegar el string entero reemplazando el campo, no agregar a mano; (3) verificar el CSP realmente vigente con curl -sI https://<dominio> | grep -i content-security-policy en vez de confiar en lo que el usuario reporta o en lo que cita un error de consola — el navegador cachea CSP vieja y el mensaje de error puede citar una versión desactualizada, dando falsa alarma de que el fix no se aplicó.
Patrón de debug: iterar en base a errores reales de consola tras cada cambio (nunca intentar anticipar todo el árbol de dependencias de un script de terceros de antemano) — un widget de chat de terceros puede importar librerías dinámicamente en runtime (esm.sh, CDN de modelos ML) sin que el script-tag inicial lo revele; solo curl directo al archivo del widget (si es público) expone esas dependencias.
Consecuencias: reutilizable en cualquier mundo con frontend en GitHub Pages + Cloudflare, y en cualquier iteración de CSP con feedback de consola en vivo. Ver worlds/aciky-frontend, sesión 2026-09-08.
Segunda ocurrencia (2026-09-10, worlds/ladder-web, Cloudflare Pages con public/_headers, no GitHub Pages): mismo patrón de dependencia (@xenova/transformers + onnxruntime-web para embeddings client-side del chat), confirmando que la lista de dominios es predecible y reutilizable de antemano en vez de descubrirla 100% por iteración. Lista completa de connect-src/script-src necesaria para este stack específico: https://esm.sh (script-src, import dinámico de la librería) + 'wasm-unsafe-eval' (script-src, runtime WASM) + https://huggingface.co (connect-src, dominio raíz — el wildcard *.huggingface.co NO lo cubre, solo subdominios) + https://*.hf.co (connect-src — huggingface.co redirige 302 la descarga de pesos del modelo a us.aws.cdn.hf.co, dominio corto distinto) + https://cdn.jsdelivr.net (connect-src — onnxruntime-web baja ahí su binario ort-wasm-simd.wasm). Empezar cualquier CSP nueva para este stack ya con estos 5 orígenes en vez de iterar de cero. Ver worlds/ladder-web/public/_headers, PRs #49-#52, memoria de sesión project_ladder-web-chat-csp en worlds/chatbot.
ADR-G043 — Gateway sirve solo lo consumido: manifest x-consumers en el contrato → gateway-routes.json generado → vendoreado (copia, no symlink) al repo del gateway
Section titled “ADR-G043 — Gateway sirve solo lo consumido: manifest x-consumers en el contrato → gateway-routes.json generado → vendoreado (copia, no symlink) al repo del gateway”Contexto: patrón nacido primero en ladder (ladder-web+ladder-gateway, ADR-G038 cubre el cliente tipado; esto cubre el paso siguiente, scopear qué expone el gateway) y adaptado después a aciky (aciky-frontend+aciky-backend+ladder-gateway, mismo gateway sirviendo un segundo backend). El gateway no debe exponer el contrato completo de un backend, solo las rutas que un frontend realmente consume — pero mantener esa lista a mano diverge del código real apenas alguien agrega un apiFetch() nuevo sin avisar.
Decisión: (1) cada operación del contrato OpenAPI que un frontend consume se anota x-consumers: [web] (o el nombre del consumidor); un script escanea las llamadas reales del código y falla en CI si hay drift entre lo anotado y lo usado (ver ADR-G038 para el detalle de indexar por método+ruta cuando el contrato no trae operationId); (2) un segundo script filtra el contrato por esa anotación y genera gateway-routes.json ({method, path, operationId}) en el repo del frontend; (3) el repo del gateway (mundo separado, deploy independiente) vendorea una copia de ese archivo (npm run sync:gateway-routes o equivalente, nunca symlink — a diferencia de ADR-G017, acá los dos repos no comparten filesystem en producción) y genera handlers de proxy exactos por {method, path} a partir de esa copia, reusando el middleware de auth/JWKS existente sin modificarlo (truco: registrar con prefix: "", no-op en cualquier .replace(prefix, "") interno).
Gap estructural a vigilar en cada adopción nueva del patrón: el gateway sigue clasificando visibilidad (público/protegido/admin) vía GET /__manifest en el backend downstream, fail-closed a "protected" si el backend no lo expone o no comparte el mismo auth-service emisor de JWT que el gateway verifica — un backend con auth propio desacoplado (caso aciky-backend, su propio sistema de login, no ladder-auth-service) deja todo el tráfico real en 401 hasta que se resuelva esa migración de auth, aunque el gateway y el manifest estén completos y testeados. No es un bug del gateway, es una dependencia cross-mundo explícita a documentar en el repo del backend (spec, no código directo si aplica la convención de “nunca editar backend directo”).
Consecuencias: reutilizable en cualquier par frontend+backend que comparta ladder-gateway (o un gateway con el mismo diseño) como proxy único. Antes de dar por funcional el proxy en producción, confirmar explícitamente que el backend downstream expone /__manifest Y comparte auth-service con el gateway — ninguna de las dos cosas es opcional para tráfico real, aunque el resto de la Fase (rutas generadas, tests, build) esté verde. Ver worlds/aciky-frontend/docs/adr/0001-openapi-typed-client-endpoint-manifest.md, worlds/ladder-gateway [routing][013] (commit 251b945), worlds/aciky-frontend/backend-specs/gateway-manifest-endpoint.md, sesión 2026-09-10/11.
ADR-G044 — Un MCP stdio cachea su binario en memoria al conectar; reconstruirlo en disco no recarga el proceso vivo
Section titled “ADR-G044 — Un MCP stdio cachea su binario en memoria al conectar; reconstruirlo en disco no recarga el proceso vivo”Contexto: se extendió infisical-mcp (fork propio, [infisical][001]) para soportar tags/secretComment/metadata en create-secret/update-secret. Tras npm run build (tsup) + tsc --noEmit limpios, se probó la tool nueva en vivo contra GATEWAY_SHARED_SECRET real sin pasar secretValue (para verificar que solo tocara metadata) — el servidor MCP conectado en esa sesión seguía ejecutando el dist/index.js viejo (cacheado en el proceso Node ya arrancado), que traía un bug preexistente (secretValue: data.secretValue ?? "") y vació el valor real del secreto. Detectado de inmediato en el eco de la respuesta y restaurado con un valor capturado antes en la misma conversación; sin ese valor a mano el incidente habría sido irreversible.
Decisión: un servidor MCP stdio se levanta una sola vez al conectar la sesión y no relee su archivo de entrada — reconstruir/editar el binario en disco (dist/index.js, o equivalente en otro runtime) nunca actualiza un proceso ya corriendo, hace falta reconectar el server (/mcp) o reiniciar la sesión. Corolario: nunca probar una tool de un MCP local recién modificado/rebuildeado contra un recurso real (secret, fila de DB, API externa) sin antes confirmar que el proceso recargó el código nuevo — probar primero contra un recurso descartable, o reconectar antes de la primera llamada real.
Consecuencias: reutilizable en cualquier mundo/sesión que desarrolle o parchee un MCP server propio (infisical-mcp, mcp-orchestrator, time-tracker, doc-search-mcp) y quiera probarlo en la misma sesión donde lo buildeó. Ver infisical-mcp/src/index.ts, TASKS.md [infisical][002b], sesión 2026-09-10.
ADR-G049 — Feature frontend+backend deben salir juntas: zod strip silencioso hace que la UI mienta
Section titled “ADR-G049 — Feature frontend+backend deben salir juntas: zod strip silencioso hace que la UI mienta”Contexto: freemanstyle (panel admin) + freeman-api (Hono+zod). Se agregó al panel un picker de “productos destacados” para los correos; el schema del broadcast aún no conocía el campo products.
Decisión/lección: zod (default) descarta claves desconocidas sin error. Un campo nuevo del frontend enviado a un schema viejo del backend NO falla: se ignora y la acción “funciona” sin el feature — el admin ve productos seleccionados y los correos salen sin ellos. Regla: cualquier feature que cruce el contrato frontend↔backend se implementa en ambos lados en el mismo cambio (o se feature-flagea en el frontend hasta que el backend la acepte). Verificar con test end-to-end que el backend recibió y persistió el campo nuevo, no solo que respondió 200.
Consecuencias: reutilizable en cualquier mundo con Hono/zod o validación de schema tolerant al extra. Duplicar la lección del ADR-006 de freemanstyle.
ADR-G050 — new URL(path, base): un path con slash inicial descarta cualquier segmento de path de base
Section titled “ADR-G050 — new URL(path, base): un path con slash inicial descarta cualquier segmento de path de base”Contexto: freeman-api construía URLs contra ladder-auth-service con
new URL('/oauth2/authorize', AUTH_SERVICE_URL), esperando que
AUTH_SERVICE_URL pudiera incluir un prefijo (https://host/api/auth). El
resultado siempre resolvía a https://host/oauth2/authorize, sin el
prefijo — 404 en producción, indistinguible a simple vista de un problema de
configuración/deploy (llevó a descartar erróneamente redeploys, restarts y
persistencia de env vars antes de encontrar la causa real).
Decisión: la semántica de URL() es JS estándar (WHATWG), no un bug de
plataforma — un path que arranca con / siempre se resuelve absoluto
desde el origin de base, ignorando su path. Cualquier prefijo fijo de un
servicio downstream (ej. /api/auth de Better Auth) va hardcodeado como
constante en el código del cliente (AUTH_BASE_PATH + template literal), y
la env var que apunta al servicio (AUTH_SERVICE_URL) se documenta y usa
como origin puro, nunca con path.
Consecuencias: reutilizable en cualquier mundo cliente de
ladder-auth-service (o de cualquier API con prefijo fijo) que construya
URLs con new URL(). Señal de alerta: un 404 persistente pese a confirmar
el valor correcto en cada capa (DB, API, UI, container nuevo) — sospechar
del código de construcción de la URL antes de seguir iterando sobre
infraestructura.
ADR-G046 — Comandos per-contexto en OpenCode: wrapper global que cata el .claude/commands/ del contexto actual, no symlink fijo
Section titled “ADR-G046 — Comandos per-contexto en OpenCode: wrapper global que cata el .claude/commands/ del contexto actual, no symlink fijo”Contexto: OpenCode no lee .claude/commands/ — solo .opencode/commands/ (proyecto) y ~/.config/opencode/commands/ (global). session-start/session-end existen por contexto (la raíz tiene su versión orquestador en .claude/commands/, cada mundo la suya vía symlink, ladder-web una hand-crafted), así que no pueden ser un único symlink fijo como el resto del catálogo. La primera versión del wrapper global resolvía ../../_shared/commands/<cmd>.md primero: funcionaba desde mundos, pero desde la raíz caía al fallback _shared/commands/<cmd>.md (versión genérica de mundo) y la del orquestador nunca cargaba.
Decisión: para comandos per-contexto, el wrapper global es un !cat`` con cadena .claude/commands/<cmd>.md || ../../_shared/commands/<cmd>.md || _shared/commands/<cmd>.md || echo <aviso> — cada contexto carga SU archivo .claude/commands/ (raíz = orquestador, mundo = el suyo, respetando versiones hand-crafted), ../../_shared/ cubre mundos sin symlink propio, y el aviso sale fuera de ai-os. Corolario: los comandos globales se registran al arrancar la sesión — una sesión abierta antes de crear/editar el wrapper no lo ve hasta reiniciar.
Consecuencias: patrón reutilizable para exponer en OpenCode cualquier comando per-contexto sin duplicar archivos por mundo (una definición global + los archivos que Claude Code ya usa igual). Ver TASKS.md [opencode][054], sesión 2026-09-11.
ADR-G047 — Agente en WSL corriendo scripts npm de un mundo cuyo node_modules se instaló desde Windows: invocar el node.exe de Windows con argv de rutas Windows, nunca el node de nvm/Linux
Section titled “ADR-G047 — Agente en WSL corriendo scripts npm de un mundo cuyo node_modules se instaló desde Windows: invocar el node.exe de Windows con argv de rutas Windows, nunca el node de nvm/Linux”Contexto: freeman-api en D:\ (working tree compartido Windows/WSL). El npm install se hizo desde PowerShell, así que node_modules trae binarios opcionales win32 (esbuild, drizzle-kit). El agente en WSL corre npm run build/test/db:migrate con su node de nvm Linux y esbuild revienta con “You installed esbuild on another platform”. Además, dos trampas de invocación: (1) ejecutar npm.cmd directo desde bash lo interpreta como script de shell y falla; (2) pasarle a node.exe rutas estilo WSL (/mnt/c/...) como argumento las resuelve relativas al cwd de Windows (D:\mnt\c\..., MODULE_NOT_FOUND).
Decisión: (1) desde WSL, los scripts npm de un mundo con node_modules instalado desde Windows se corren con el node de Windows: "/mnt/c/Program Files/nodejs/node.exe" 'C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js' run <script> — los argv de rutas van estilo Windows (C:\...); solo el cwd se traduce solo (/mnt/d/... → D:\...). (2) drizzle-kit generate desde bash sin TTY dispara prompt interactivo de rename (clack) cuando detecta drop+create de tablas de nombre similar, y NO acepta printf '\n' por pipe — generar en 2 pasos (primero un generate con solo el drop, luego otro con los creates) elimina el prompt por construcción. (3) el node de nvm Linux sigue sirviendo para scripts standalone que no tocan node_modules del mundo (JS puro como pg con --env-file, one-offs de smoke).
Consecuencias: aplica a cualquier mundo del universo trabajado desde WSL con installs hechos desde Windows (machine compartida). Relacionado: el túnel SSH abierto en PowerShell escucha en el localhost de Windows — WSL2 con networking en modo mirrored lo ve como localhost propio (confirmado empíricamente en freeman-api, 2026-09-13); con NAT no pasa y hay que verificar antes de asumir. Matar servers levantados así con taskkill //F //PID <pid del netstat> (ADR-G035). Ver worlds/freeman-api/README.md §Base de datos, sesión 2026-09-13.
ADR-G048 — CMS sobre sitio estático: content store opaco por página + fallback estático total + lectura tolerante de idiomas
Section titled “ADR-G048 — CMS sobre sitio estático: content store opaco por página + fallback estático total + lectura tolerante de idiomas”Contexto: freemanstyle (sitio estático, Cloudflare Pages, i18n client-side es/en) necesitaba todo el contenido editable desde su panel admin vanilla, sin dejar de ser un sitio estático servible sin backend. El backend (freeman-api, Hono+zod) es otro mundo con regla de “no editar backend directo: spec en su TASKS.md”.
Decisión: tres piezas que juntas eliminan la coordinación: (1) store opaco por página — la API guarda {page, data: {es, en}, version} sin validar la estructura interna (whitelist de page, auth, límite de tamaño solamente); el frontend es dueño del shape, así el schema evoluciona sin tocar backend. GET nunca responde 404 para página no publicada: responde 200 con data: null (el browser loguea los 404 como error de consola ante cada visitante). (2) fallback estático total — el front siempre renderiza el HTML estático si no hay doc (404/red/caído): el sitio nunca depende de la API para existir, editar es opt-in por página, y crawlers sin JS ven contenido real. (3) lectura tolerante + fallback de idioma — los campos bilingual se leen con pickLang(value): acepta string (schema viejo) o {es, en} (nuevo), y si falta el idioma activo cae a es — el backend puede migrar schemas de entidades existentes (productos, popup) sin coordinar deploy con el front. Editor: borrador en localStorage por página + confirm al publicar si solo se editó un idioma.
Consecuencias: reutilizable en cualquier mundo con sitio estático + API propia que quiera contenido editable sin headless-CMS ni redeploys. Costos asumidos: contenido dinámico invisible para crawlers sin JS (mitigado por el fallback estático, que queda como piso SEO), y los mirrors estáticos de otros idioma solo refrescan en deploy. Ver worlds/freemanstyle/docs/adr/007-sitio-editable-content-store.md, src/js/content.js, tareas [content][001-004] de freeman-api/TASKS.md, sesión 2026-09-13.
ADR-G051 — Un MCP stdio con CONNECTION_CLOSED puede ser node_modules nunca instalado, no solo el binario viejo cacheado de ADR-G044
Section titled “ADR-G051 — Un MCP stdio con CONNECTION_CLOSED puede ser node_modules nunca instalado, no solo el binario viejo cacheado de ADR-G044”Contexto: MCP infisical (infisical-mcp/dist/index.js, ver ADR-G044) apareció como “failed to connect” (CONNECTION_CLOSED) al arrancar la sesión. La sospecha inicial fue el mismo patrón de ADR-G044 (binario cacheado en memoria tras un rebuild) — descartado corriendo el server standalone (node infisical-mcp/dist/index.js): tiraba Error: Cannot find module '@infisical/sdk' (MODULE_NOT_FOUND) al primer require, antes de tocar ninguna credencial. El checkout tenía dist/index.js commiteado (excepción documentada en ADR-G001 del propio paquete) pero node_modules/ — gitignored, como en cualquier repo Node — nunca se había instalado en esta máquina/clon.
Decisión: ante un MCP stdio con CONNECTION_CLOSED o “failed to connect”, diagnosticar corriendo el comando del server directo en una shell (node <script> o el command+args exacto del .mcp.json) antes de asumir causa — un crash real al arrancar (MODULE_NOT_FOUND, stack trace) es indistinguible de un fallo de auth/red visto solo desde el harness, que únicamente reporta “conexión cerrada” sin motivo. Si el error es MODULE_NOT_FOUND de un paquete de package.json, npm install en la carpeta del server (nunca asumir que el dist/ commiteado implica dependencias instaladas) y reconectar la sesión (mismo paso final que ADR-G044, pero causa raíz distinta: acá no hubo rebuild, nunca hubo install).
Consecuencias: reutilizable en cualquier MCP standalone del universo con paso de build/dependencias propio (infisical-mcp, y cualquier fork/clone futuro con el mismo patrón de dist/ commiteado). Tras un git clone/pull que solo trae código, un MCP con dependencias npm no reinstaladas se ve exactamente igual desde el harness que uno con binario viejo cacheado (ADR-G044) — el diagnóstico standalone es lo único que distingue ambos casos. Ver infisical-mcp/, sesión 2026-09-25.