Published

HANDOFF · 2026-08-15 (tarde) — la gobernanza se escribe en SQL

Connect any source, model it as an ontology, transform it, and operationalize it, analytics, automation and machine learning, under one governed, self-hostable roof. --- Most teams stitch the...

HANDOFF · 2026-08-15 (tarde) — la gobernanza se escribe en SQL

Para qué es esto. La foto autosuficiente para retomar. La sesión cerró el orden
acordado entero
(①-⑤), montó el circuito de identidad de punta a punta y llevó el
plano de permisos hasta el SQL Editor.

⚠️ SUPERSEDE a handoff-2026-08-15-superficie-y-sujeto.md,
que lleva marcadas sus cinco afirmaciones caducadas.

Regla de la casa: cada hecho lleva el comando que lo demuestra.


0 · Lo que hay que saber en diez líneas

  1. 🏁 El orden acordado está COMPLETO: OWNERSHIP · roles en Keycloak con composites · catalog_role dedicado · grants por objeto · la travesía.
  2. 🏁 El circuito de identidad funciona en vivo: sesión de Clerk → canje RFC 7523 → token del realm con la persona, su clerk_user_id y sus roles expandidos.
  3. 🏁 La gobernanza se escribe en SQL y se aplica: CREATE ROLE, los tres GRANT, GRANT ROLE … TO 'persona', SHOW ROLES — y REVOKE USE SCHEMA corta de verdad.
  4. ⭐⭐ El modelo dejó de ser aditivo: hay travesía (CATALOG_USE/SCHEMA_USE), como Databricks UC y Snowflake.
  5. ⛔⛔ Una estimación por poco cuesta un incidente: decía 1 afectado, el motor dijo 5, incluido el SQL Editor vivo. §2.
  6. ⛔⛔ Se vació el workspace principal y se restauró. §2. El trinquete que lo vigilaba era el equivocado y se sustituyó.
  7. 🏁 Desplegado: producción pasó de 17178a5 a 67b6a1c (Environment: Production, BUILD_ID x7o_XDvcYEu5GKAYwAal8), construido desde el worktree limpio.
  8. 🏁 El carril del editor está VERIFICADO por HTTP, con sesión real: CREATE ROLE creó la fila y SHOW ROLES devuelve el listado en la pantalla del editor.
  9. ⚠️ .env.local es de la instancia dev de Clerk; el realm federa la live. Por eso la API de gobernanza da 502 en local. npm run dev:paladio es el modo que lo resuelve.
  10. ⚠️ El árbol tiene ~30 ficheros sin commitear que son trabajo en curso del owner (rediseño de Warehouse, sidebar, integraciones). No se tocaron.

1 · Lo cerrado, con su gate

gate
🏁 G·5·0OWNERSHIP en el vocabulario — no implica ningún *_MANAGE_ACCESSprivilege.test.ts
🏁 G·5·1Migración: el CHECK de C3 rechazaba OWNERSHIP; exclusividad por índiceg5-1, probado contra la base
🏁 G·5·2El catalog_role dedicadog5-2, con control negativo
🏁 K·4-K·9El circuito de identidad: censo · composites · vínculo · JWT grant · canjek9, en vivo
🏁 G·6·1CATALOG_USE/SCHEMA_USE y la travesía en decidePrivilege423 tests
🏁 G·6·2La travesía sembrada, medida con el motorg6-2, 0 sin travesía
🏁 G·6·3/4El parser entero y el carril en la ruta del editorgrant-sql*.test.ts
🏁 G·6·5El flujo SQL completo, en vivog6-5, VERDE

423 tests en lib/governance · tsc limpio.


2 · ⭐ Los hallazgos que ordenan lo que queda

  1. ⛔⛔ Un instrumento que reimplementa lo que mide, mide su propia reimplementación. g6-0 estimó el radio de daño con las implicaciones escritas a mano: dijo 1. g6-2, preguntando a decidePrivilege sobre la cadena real, dijo 5 — y entre ellos lakekeeper-spark-editor, el único actor vivo. Desplegar con el primero habría roto el SQL Editor. (Ya había pasado en M3·F0 con los reconocedores privados de la route.)

  2. ⛔⛔ Una regla de limpieza correcta puede tener una premisa falsa. Se borraron de workspace_members las cuentas «que la instancia live de Clerk no conoce». La regla era razonable; el hecho es que el workspace principal pertenece a una organización de la instancia de DESARROLLO, así que esas cinco eran todos sus miembros. La app empezó a dar 404. ⇒ Lo que salva no es afinar el criterio, es vigilar el daño: npm run check:workspacesningún workspace con datos puede quedarse sin miembros.

  3. ⭐⭐ La travesía no se hereda hacia arriba. Cada una se busca sobre la sub-cadena hasta su contenedor. Si no, un SCHEMA_USE en el schema abriría el catálogo desde dentro y REVOKE CATALOG_USE dejaría de cortar — el modelo perdería su único beneficio.

  4. Los verbos de gobernanza son baratos porque no son verbos de datos. INSERT es caro porque necesita resolución de nombres en la fuente; CTAS, porque nacer es un acto del catálogo. GRANT no necesita ninguna capacidad: no llega al motor.

  5. El orden del carril en la ruta es un REQUISITO. Medido: la gramática de catálogo reclama las tres formas de SHOW. Con el carril detrás, SHOW ROLES contestaría un error de catálogo. Hay test que rompe si alguien reordena esos dos if.

  6. GRANT ROLE … TO 'persona' no contradice «a roles, no a personas». Aquella regla prohíbe colgar un privilegio de una persona; ésta la mete dentro del rol, que es el mecanismo que la regla presupone.


2·bis · Lo que se vio al usarlo de verdad (y no salía en ningún test)

🏁 El carril funciona con sesión real

CREATE ROLE data_analyst_role[{role:"data_analyst_role", created:true}], y la fila está en principal_roles de node-development-2 con created_by = el usuario. SHOW ROLES devuelve los seis roles con su descripción. G·6·4 cerrado por la ruta, no por el ejecutor.

⭐⭐⭐ El SÉPTIMO sitio donde vive la lista de verbos: el RESALTADO

SHOW ROLES no se pinta como verbo en el editor, y en CREATE ROLE sólo se colorea CREATE. La causa está en components/workspace/tabs/editor-carbon/CarbonSqlEditor.tsx: Monaco tiene su propia lista de keywords (CARBON_SQL_MONACO_LANGUAGE + las sugerencias de autocompletado, ~línea 249).

Esta sesión contó seis sitios que conocen los verbos —sql-parse, DML_VERBS_PERMITIDOS, la clasificación del ledger, WRITE_VERB, compensate, contract— y concluyó que la propagación era el síntoma de tratar el SQL como TEXTO. Faltaba uno, y es el único que el usuario ve. Un verbo que la puerta ejecuta pero el editor no colorea parece no existir.

⇒ Añadir los verbos de gobernanza al resaltado es trivial. Lo que no lo es: ese fichero no aparece en ningún censo de verbos, así que el siguiente verbo volverá a olvidarlo.

🏁 CERRADO el 08-16 — y ni lo uno ni lo otro salió como se preveía: ver §8. Lo
«trivial» no funcionaba (los lenguajes de Monaco se pisan solos), y el censo existe
ahora porque el editor dejó de tener lista, no porque se le añadiera a una.

⛔ El rol se crea y la UI no lo enseña

No es un fallo de la UI: /api/governance acuña la aserción con la plantilla keycloak, que sólo existe en la instancia LIVE de Clerk. En local (claves dev) da «plantilla 'keycloak': Not Found» — el error es exacto y la pantalla lo enseña tal cual.

⚠️ Y destapa una inconsistencia introducida en esta sesión: hay dos caminos de identidad para el mismo plano. El editor autoriza con el id de sesión de Clerk directamente; /api/governance exige el token del realm. Hoy resuelven al mismo sujeto cuando ambos existen, pero no es defendible: o se unifican hacia el realm (y el editor deja de funcionar en local) o se decide que el id de sesión basta y la puerta se simplifica.


3 · ⛔ Lo que está ABIERTO, por orden de daño

El workspace con los 128 datasets vive en una organización de la instancia DEV — 🏁 cerrado el 08-16 por SWAP con el gemelo, §9
⚠️Dos caminos de identidad para el mismo plano — 🏁 cerrado el 08-16 hacia la SESIÓN, §10
⚠️El resaltado del editor no conoce los verbos de gobernanza — 🏁 cerrado el 08-16, §8
⚠️8 principals de servicio se llaman lakekeeper-* y el catálogo es Gravitino. Dos apuntan a motores retirados (trino dormido desde el 08-08, duck-server fuera del read-path): identidades con grants vivos y sin servicio detrás
⚠️MANAGE GRANTS no existe como privilegio delegable — hoy la autoridad la da decideGrantAuthority (OWNERSHIP o *_MANAGE_ACCESS), que basta pero no se puede repartir
⚠️La base de control no es reproducible: 261 migraciones, sin tracker, aplicadas manual y selectivamente. Bloquea separar entornos, y es un riesgo mayor que la mezcla que se limpió
⚠️La UI de Roles no está verificada en pantalla — sólo tsc y tests
⚠️Los dedicated:<rol> van a proliferar: un paquete por rol que reciba un grant explícito. Correcto, pero conviene decidir si la UI los oculta

4 · ⏭️ Por dónde seguir

①  🏁 CERRADO el 08-16 — ver §8, y NO era trivial
②  🏁 CERRADO el 08-16 — ver §10. Ganó la SESIÓN, y el canje quedó inerte
③  🏁 CERRADO el 08-16 — ver §9. Fue un SWAP, no una migración
④  Atar/desatar paquetes desde la UI de Roles               ← hoy los LEE; crear rol ya escribe
⑤  MANAGE GRANTS como privilegio delegable
⑥  Reconciliar la base de control                           ← desbloquea separar entornos

⏭️ Lo que manda ahora es ④, y por una razón nueva: ② dejó la gobernanza funcionando en local (era el 502 de la plantilla keycloak), así que la UI de Roles por fin se puede desarrollar y verificar en pantalla — que es justo lo que §3 dice que nunca se hizo.

⚠️ Y hay una deuda con fecha: subir a org:admin en Clerk LIVE a lunacorunaspain y facebookdeveloper98. Ver §9. No corre prisa hasta que alguien toque esas membresías, y ese día parecerá un fallo del sistema.

Decisiones vigentes, para no repetirlas

  • A ROLES, no a personas — para privilegios. Para membresía, GRANT ROLE … TO.
  • Keycloak = membresía de rol; Clerk = pertenencia al workspace.
  • El dueño es un ROL, y OWNERSHIP es exclusivo.
  • La travesía se IMPLICA desde los agregados, no se concede una a una (serían 390 filas).
  • Carbon SQL sigue siendo Spark SQL ANSI. De Databricks se toma la semántica de la travesía, no el dialecto.

5 · 🪤 Trampas nuevas de esta sesión

⛔⛔Las claves de producción de Clerk están atadas al dominio. Poner pk_live_ en .env.local rompe el login en localhost. El modo correcto es un subdominio real y puerto 443 obligatorio: en cualquier otro, el navegador mete el puerto en el Origin y Clerk lo rechaza con un mensaje que habla de dominios
⛔⛔vercel env pull NO descifra las variables sensibles: 188 de 198 vuelven vacías
getUsersByIds mandaba user_id[]=, que Clerk IGNORA — devolvía una página arbitraria de usuarios. 200, array bien formado, longitud correcta. Nada aguas abajo podía notarlo
principal_grants JOINea principal_role_members: un rol SIN MIEMBROS no produce ni una fila, por muchos grants que tenga. Hizo que un gate aprobara sin significar nada
⚠️Los protocol mappers son POR CLIENTE. El de clerk_user_id estaba en carbon-app y el token del canje lo emite carbon-server: 200, persona y roles… y el claim sin llegar
⚠️Un IdP de brokering no sirve para el canje: hace falta el tipo dedicado jwt-authorization-grant. Escribirle jwtAuthorizationGrant al oidc se guarda sin error y no habilita nada
⚠️Un control negativo que falla demasiado pronto no distingue nada. Un probe con un JWT malformado moría en el parseo y contestaba lo mismo antes y después de cada cambio

6 · Comandos que se corren

# el plano de permisos
npx dotenv -e .env.local -- npx tsx scripts/governance/g6-2-travesia-en-vivo.ts
npx dotenv -e .env.local -- npx tsx scripts/governance/g6-5-gate-sql-en-vivo.ts   # el gate
npx dotenv -e .env.local -- npx tsx scripts/governance/g3-acoplamiento-de-roles.ts

# la identidad (KC_ADMIN_* salen de `railway variables --service keycloak --kv`)
KC_ADMIN_USER=KC_ADMIN_PASS=… npx tsx scripts/governance/k4-censo-del-realm.ts
KC_ADMIN_USER=KC_ADMIN_PASS=… npx tsx scripts/governance/k7-jwt-grant-setup.ts
CLERK_SECRET_KEY=sk_live_… … npx tsx scripts/governance/k9-gate-canje-en-vivo.ts <email>

# los trinquetes
npm run check:workspaces      # ningún workspace con datos sin miembros
npm run check:superficie

# en frío
npx vitest run lib/governance          # 423
NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit

# el dev con el dominio real (necesita la línea de hosts, ver el preflight)
npm run dev:paladio

7 · Estado del entorno

  • Producción sirve 67b6a1c desde el 08-15 (Environment: Production, construido en el worktree limpio C:/tmp/carbon-deploy). ⚠️ Verificar siempre por el CONTENIDO del runtime: sin sesión, Clerk devuelve 404 a cualquier /api/* a propósito —también a rutas que llevan meses—, así que un 404 ahí no significa que la función no propagara. El discriminante es la URL directa del deployment, donde sale el 401 real.
  • .env.local → instancia dev de Clerk (las live viven en .env.paladio, ignorado).
  • Keycloak: realm lakehouse, IdP clerk (brokering) + clerk-grant (canje RFC 7523), client confidencial carbon-server con oauth2.jwt.authorization.grant.{enabled,idp}.
  • Clerk live: plantilla JWT keycloak con aud al realm, lifetime=60s.
  • 7 personas en el realm, vinculadas, con rol y composites expandidos.
  • USER_AUTHZ=enforce · PLAN_MANDA=on · PLAN_AUDITOR=shadow en prod.

8 · 🏁 ① El SÉPTIMO SITIO, cerrado (08-16) — y la trampa que escondía

Lo que se pedía era trivial. Lo que costó fue que se viera.

El diagnóstico, medido

El sql de Monaco 0.55.1 (la misma versión en node_modules y en el CDN que carga @monaco-editor/react) conoce GRANT, REVOKE, USAGE y CATALOG, pero no ROLE, ROLES, GRANTS, SHOW ni MODIFY. Eso es el síntoma del 08-15, exacto.

⛔⛔ La trampa: lo obvio pasa todos los tests y NO funciona en pantalla

Extender el lenguaje de serie —cargar su definición y volver a registrarla con las keywords añadidas— es lo evidente. Verde en 434 tests, y sin efecto en el navegador. Los lenguajes básicos se cargan PEREZOSAMENTE y su cargador registra su propio tokenizer al resolver: pisa lo que se le pusiera antes, dé igual beforeMount u onMount. Medido con monaco.editor.tokenize en el navegador: ROLE seguía saliendo identifier.sql.

⇒ Lo que funciona es un lenguaje propio (carbon-sql), que nadie pisa porque nadie lo conoce. Hereda del sql de serie, incluido su tokenPostfix: '.sql' — así que los scopes no cambian y el tema de Carbon vale sin tocarlo. El modelo nace en sql y se PROMUEVE al dialecto: si loader (API interna) desapareciera, el peor caso es el editor de hoy, no uno sin colorear.

Es la lección del 08-15 otra vez, en otro plano: un gate que no puede medir el hecho aprueba sin significar nada. Aquí lo único que distinguía era el navegador.

⭐ La cura del séptimo sitio no es acordarse: es que no tenga lista

El editor no tiene vocabulario propio. LEXICO_DE_GOBERNANZA y FORMAS_DE_GOBERNANZA se derivan de MAPEO/SOBRE en lib/governance/grant-sql.ts y se importan. El regex de esGobernanza también se construye desde SENTENCIAS_DE_GOBERNANZA, así que el reconocedor y la lista ya no pueden discrepar. El octavo verbo llega al resaltado el día que llega al parser, sin que nadie se acuerde.

lib/governance/grant-sql-resaltado.test.ts (11 tests) es el censo que faltaba: rompe si el editor vuelve a copiar una forma, si alguien vuelve a extender el sql de serie, o si la promoción sale del montaje. Y mide el pintado, no la lista: instancia el Monarch real y comprueba identifier.sqlkeyword.sql.

⚠️ Un matiz que destapó el propio test: SELECT y CREATE son a la vez privilegios y verbos de datos. El editor los tiene escritos como verbos de datos, que es donde les toca; el censo sólo persigue las formas exclusivas de gobernanza.


9 · 🏁 ③ EL RE-ANCLAJE — los 128 datasets ya viven en una organización LIVE (08-16)

Aplicado en producción. node-development-2 estaba anclado a una organización que sólo existe en la instancia de DESARROLLO ⇒ ningún usuario live podía entrar. Ya no.

Lo que la medición cambió del plan

lo que se creíalo que dijo el motor
«mover el ancla»workspaces.clerk_org_id es NOT NULL y ÚNICO, y la org LIVE ya la usaba el gemelo ⇒ no se mueve, se INTERCAMBIA
«con re-anclar basta»no: la puerta la abre la ORG, pero el rol y los grants salen de workspace_members, que guardaba ids DEV ⇒ habrían entrado a un workspace donde el catálogo les deniega todo
«habrá que migrar la identidad»110 columnas y 57.392 filas llevan ids dev — pero casi todo es autoría histórica. Lo que GOBIERNA son 6 filas

El swap no mueve un solo byte. Las coordenadas de datos (catálogo, storage, las 128 tablas) derivan del workspace_id, que no cambia. Sólo cambia quién puede entrar.

⭐⭐ Y existía un gemelo. node-development —0 datasets— ya tenía la org LIVE y sus seis miembros con los ids live. El re-anclaje no fue una migración: fue un intercambio entre dos workspaces que ya se conocían.

Lo aplicado, en una transacción

workspaces[node-development-2].clerk_org_id   org_3CLz6s24…(DEV) → org_3CPJD81…(LIVE)
workspaces[node-development]  .clerk_org_id   org_3CPJD81…(LIVE) → org_3CLz6s24…(DEV)
workspaces[node-development-2].owner_user_id  user_3AaI8oS… → user_3CPJBXP…
workspace_members  5 ids remapeados (rol INTACTO) + 1 alta (reontech.developer, viewer)

⚠️ El valor intermedio es obligatorio: el índice único no es diferible, así que un intercambio directo viola la unicidad a mitad de sentencia.

El mapeo se RESUELVE por email en cada corrida, preguntando a las dos instancias. Un id de Clerk copiado a mano en un script de permisos es la clase de dato que nadie vuelve a verificar; si un email no resuelve, el script se planta antes de tocar nada.

Verificado

🏁t0-censo-de-tenencia.ts«todo workspace con datos está anclado a una organización LIVE»
🏁g1-derivar-grants-de-usuario.ts --apply → 6 altas, 5 bajas, y 27 verdes / 0 rojos sobre la cadena real hasta un dataset
🏁npm run check:workspaces → VERDE, 6 miembros en el de 128 datasets
⏭️Falta la prueba humana: que alguien inicie sesión en producción y abra el workspace. No se puede hacer desde aquí

⛔ Lo que este cambio DEJA ABIERTO — y no es menor

  1. ⚠️⚠️ Divergencia de roles LATENTE. lunacorunaspain y facebookdeveloper98 son admin en la base pero org:member en Clerk LIVE. Se conservó el admin (decisión del 08-16: nadie pierde capacidad hoy). Pero handleMembershipUpdated recalcula el rol desde Clerk: el día que alguien toque esas membresías, el webhook los baja a viewer y parecerá un fallo. ⇒ Cerrarlo es subirlos a org:admin en Clerk LIVE, no escribir el rol otra vez.
  2. ⚠️ La autoría histórica sigue en ids DEV: 110 columnas / 57.392 filas (net_mutations 28.186, objects 27.042, datasets.created_by 109…). Se verá «creado por desconocido». Migración aparte, medible con t1-alcance-del-reanclaje.ts.
  3. ⚠️ El gemelo quedó cruzado: node-development tiene ahora el ancla DEV y miembros con ids LIVE ⇒ inalcanzable para todos. Tiene 0 datasets y check:workspaces lo da verde porque tiene filas de miembros, así que el trinquete no lo ve. Es el residuo consciente del swap.
  4. ⏭️ Sin verificar: si los clerk_user_id del realm de Keycloak son los live —como debería, porque federa la instancia live—, ahora coinciden por primera vez con workspace_members. Necesita KC_ADMIN_* para comprobarlo.

Comandos

CLERK_SECRET_KEY_LIVE=$(grep '^CLERK_SECRET_KEY=' .env.paladio | cut -d= -f2-) \
  npx dotenv -e .env.local -- npx tsx scripts/governance/t0-censo-de-tenencia.ts
#  … t1-alcance-del-reanclaje.ts        el radio, preguntado a la base
#  … t2-reanclar-a-organizacion-live.ts [--aplicar]   plan por defecto

Para revertir: el mismo swap al revés. Los tres scripts son idempotentes y T·2 se planta si el estado no es el que midió.


10 · 🏁 ② LOS DOS CAMINOS DE IDENTIDAD — unificados hacia la SESIÓN (08-16)

La inconsistencia que §2·bis dejó abierta: el editor autorizaba con el id de sesión de Clerk y /api/governance exigía el token del realm. Decidido y aplicado: el id de sesión basta, y la puerta se simplifica.

⭐⭐⭐ Lo que decidió, y no fue una opinión

No eran dos identidades: eran dos maneras de llegar al mismo string. El canje producía { kind:'user', id: <el user_… de Clerk> } — exactamente lo que la sesión ya tenía— después de dos saltos de red. Y de los tres beneficios que la cabecera de la ruta se atribuía, la medición dejó uno:

lo que decíalo que se midió
«una sola identidad»la había ya: los dos caminos daban el mismo valor
«llegan los roles del realm, expandidos»nadie los consume. Los roles que deciden los da rolesDelPrincipal(id, workspaceId), que lee la BASE
«estar en Clerk deja de bastar»cierto — pero era un efecto colateral del canje, no un control escrito en ninguna parte

⇒ Un rodeo que no cambia ninguna decisión no es defensa: es complejidad. Y ésta costaba tres modos de fallo propios y la gobernanza rota en local — el 502 que §2·bis describía como «el rol se crea y la UI no lo enseña».

Lo aplicado

  • lib/governance/sujeto.ts — nuevo, y el fichero entero existe para tener un solo sitio donde se decide quién es la persona. Su cuerpo es una línea a propósito: lo que se fija no es el cálculo, es el punto de decisión.
  • /api/governance deja de acuñar la aserción y de canjear; usa sujetoDeSesion(userId).
  • El SQL Editor usa el mismo resolvedor, en vez de construir el principal a mano.
  • keycloak-token.ts queda INERTE y declarado como tal en su cabecera. Se conserva entero: es la pieza de H3, y borrarlo convertiría H3 en rehacer el circuito de identidad completo.

⚠️ Lo que se pierde, dicho en voz alta

El canje comprobaba de rebote que la persona estuviera aprovisionada y vinculada en el realm. Ya no se comprueba. Si esa garantía se quiere, hay que escribirla como requisito explícito — un control que nadie escribió es un control que nadie mantiene.

⭐ El trinquete guarda las DOS mitades

lib/governance/sujeto.test.ts (8 tests) impide lo obvio —que alguien vuelva a construir el sujeto a mano o a reintroducir el canje en silencio— y también lo que es fácil romper simplificando de más: que las caras de MÁQUINA (/api/iceberg/v1, /api/management/v1, /api/storage/credential) conserven principalFromRequest. Ahí no hay sesión de navegador: el bearer del realm no es un rodeo, es la única identidad que existe. No son un tercer camino duplicado: son otro tipo de sujeto.

Verificado

🏁npx vitest run lib/governance442 · tsc limpio
🏁g6-5-gate-sql-en-vivo.tsVERDE, y REVOKE USE SCHEMA sigue cortando
Ese gate concedió el rol a user_3CPJBXPo… — el id LIVE: ③ y ② se confirman mutuamente
⏭️Falta la prueba humana: abrir la UI de Roles en local con sesión y ver que ya no da 502

11 · ⛔⛔ EL ALTA PERDÍA A LOS CREADORES — causa corregida y 5 workspaces reparados (08-16)

5 de 8 workspaces tenían 0 miembros. La primera hipótesis —daño de la limpieza del 08-15— la desmintió la cabecera de k5e: allí había 12 filas, 5 borradas, y las 7 supervivientes estaban todas en otros tres workspaces. Estos cinco nunca tuvieron ninguna.

La causa, y el comentario que decía lo contrario

En handleMembershipCreated, si el workspace aún no existía:

// Workspace may not exist yet if organization.created is still in flight.
// Clerk retries; we just 2xx and let the next delivery retry.
return;   // ← devolvía 200

⛔⛔ Clerk reintenta con no-2xx. Al contestar 200 el evento quedaba entregado y no volvía nunca. Y la carrera no es excepcional: organizationMembership.created del creador puede llegar antes que organization.created.

El patrón encaja exacto: los workspaces con miembros son los que recibieron invitaciones POSTERIORES, cuando el workspace ya existía. Los que sólo habrían tenido a su creador estaban a cero. Lo confirmó la reparación de forma independiente: en los cinco, el único miembro es el creador.

⭐ Es la misma forma de fallo que el resto del día: todo lo demás decía que estaba bien. tenant_warehouses en active, tenant_provisioning_gaps a cero, y check:workspaces VERDE — porque su invariante es «workspace con datos sin miembros», y éstos están vacíos.

Lo hecho

🏁La causa: el handler lanza, la ruta contesta 500 y Clerk reintenta de verdad
🏁resolveAppRole sale del route a lib/workspace/rol-de-clerk.ts — reparar con otra regla daría un estado que el siguiente evento deshace
🏁t3-restaurar-membresias.ts — busca por el EFECTO (workspaces sin nadie dentro), no por slugs cableados, y deriva cada fila de Clerk
🏁5 filas escritas · grants derivados (5 altas, y la segunda pasada da 0/0: idempotente) · check:workspaces VERDE con 8/8

⏭️ Lo que queda de aquí

⚠️ El trinquete no vigila esto: un workspace vacío y sin gente lo trata como borrador. Ahora se sabe que también puede ser un alta rota. El invariante que lo distinguiría es «ningún workspace cuya ORGANIZACIÓN tiene miembros puede tener cero» — pero exige preguntar a Clerk, y hoy el check corre sólo contra la base. Mientras tanto, t3 en modo plan lo detecta.

Verificado

🏁 en fríonpx vitest run lib/governance434 · tsc limpio
🏁 en el navegador/dev-sql-editor-carbon-preview con el código real: modelo en carbon-sql, y SHOW ROLES · CREATE ROLE · GRANT … TO ROLE los tres en keyword.sql
⚠️ sin fotoEl panel del navegador no compone frames en esta sesión. El color se cierra por el tema: la regla { token: 'keyword', fontStyle: 'bold' } casa por prefijo con keyword.sql ⇒ morado en negrita

⏭️ Lo que no entra aquí: el Monarch propio de Spark SQL que pide C4 (la comilla doble sigue sin modelarse, ver la nota C1 en el editor). carbon-sql es su primer trozo y deja el sitio hecho. Y docs/warehouse-sql/carbon-sql-verbos.md sigue siendo sólo de verbos de DATOS: se genera contra el motor, y los de gobernanza no llegan al motor a propósito.