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
- 🏁 El orden acordado está COMPLETO: OWNERSHIP · roles en Keycloak con composites · catalog_role dedicado · grants por objeto · la travesía.
- 🏁 El circuito de identidad funciona en vivo: sesión de Clerk → canje RFC 7523 →
token del realm con la persona, su
clerk_user_idy sus roles expandidos. - 🏁 La gobernanza se escribe en SQL y se aplica:
CREATE ROLE, los tresGRANT,GRANT ROLE … TO 'persona',SHOW ROLES— yREVOKE USE SCHEMAcorta de verdad. - ⭐⭐ El modelo dejó de ser aditivo: hay travesía (
CATALOG_USE/SCHEMA_USE), como Databricks UC y Snowflake. - ⛔⛔ Una estimación por poco cuesta un incidente: decía 1 afectado, el motor dijo 5, incluido el SQL Editor vivo. §2.
- ⛔⛔ Se vació el workspace principal y se restauró. §2. El trinquete que lo vigilaba era el equivocado y se sustituyó.
- 🏁 Desplegado: producción pasó de
17178a5a67b6a1c(Environment: Production,BUILD_ID x7o_XDvcYEu5GKAYwAal8), construido desde el worktree limpio. - 🏁 El carril del editor está VERIFICADO por HTTP, con sesión real:
CREATE ROLEcreó la fila ySHOW ROLESdevuelve el listado en la pantalla del editor. - ⚠️
.env.locales de la instancia dev de Clerk; el realm federa la live. Por eso la API de gobernanza da 502 en local.npm run dev:paladioes el modo que lo resuelve. - ⚠️ 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·0 | OWNERSHIP en el vocabulario — no implica ningún *_MANAGE_ACCESS | privilege.test.ts |
| 🏁 G·5·1 | Migración: el CHECK de C3 rechazaba OWNERSHIP; exclusividad por índice | g5-1, probado contra la base |
| 🏁 G·5·2 | El catalog_role dedicado | g5-2, con control negativo |
| 🏁 K·4-K·9 | El circuito de identidad: censo · composites · vínculo · JWT grant · canje | k9, en vivo |
| 🏁 G·6·1 | CATALOG_USE/SCHEMA_USE y la travesía en decidePrivilege | 423 tests |
| 🏁 G·6·2 | La travesía sembrada, medida con el motor | g6-2, 0 sin travesía |
| 🏁 G·6·3/4 | El parser entero y el carril en la ruta del editor | grant-sql*.test.ts |
| 🏁 G·6·5 | El flujo SQL completo, en vivo | g6-5, VERDE |
423 tests en lib/governance · tsc limpio.
2 · ⭐ Los hallazgos que ordenan lo que queda
-
⛔⛔ Un instrumento que reimplementa lo que mide, mide su propia reimplementación.
g6-0estimó el radio de daño con las implicaciones escritas a mano: dijo 1.g6-2, preguntando adecidePrivilegesobre la cadena real, dijo 5 — y entre elloslakekeeper-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.) -
⛔⛔ Una regla de limpieza correcta puede tener una premisa falsa. Se borraron de
workspace_memberslas 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:workspaces— ningún workspace con datos puede quedarse sin miembros. -
⭐⭐ La travesía no se hereda hacia arriba. Cada una se busca sobre la sub-cadena hasta su contenedor. Si no, un
SCHEMA_USEen el schema abriría el catálogo desde dentro yREVOKE CATALOG_USEdejaría de cortar — el modelo perdería su único beneficio. -
⭐ Los verbos de gobernanza son baratos porque no son verbos de datos.
INSERTes caro porque necesita resolución de nombres en la fuente;CTAS, porque nacer es un acto del catálogo.GRANTno necesita ninguna capacidad: no llega al motor. -
⭐ 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 ROLEScontestaría un error de catálogo. Hay test que rompe si alguien reordena esos dosif. -
⭐
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
| ⚠️ | 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
OWNERSHIPes 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
67b6a1cdesde el 08-15 (Environment: Production, construido en el worktree limpioC:/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, IdPclerk(brokering) +clerk-grant(canje RFC 7523), client confidencialcarbon-serverconoauth2.jwt.authorization.grant.{enabled,idp}. - Clerk live: plantilla JWT
keycloakconaudal realm,lifetime=60s. - 7 personas en el realm, vinculadas, con rol y composites expandidos.
USER_AUTHZ=enforce·PLAN_MANDA=on·PLAN_AUDITOR=shadowen 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.sql → keyword.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ía | lo 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
- ⚠️⚠️ Divergencia de roles LATENTE.
lunacorunaspainyfacebookdeveloper98sonadminen la base peroorg:memberen Clerk LIVE. Se conservó el admin (decisión del 08-16: nadie pierde capacidad hoy). PerohandleMembershipUpdatedrecalcula el rol desde Clerk: el día que alguien toque esas membresías, el webhook los baja aviewery parecerá un fallo. ⇒ Cerrarlo es subirlos aorg:adminen Clerk LIVE, no escribir el rol otra vez. - ⚠️ La autoría histórica sigue en ids DEV: 110 columnas / 57.392 filas
(
net_mutations28.186,objects27.042,datasets.created_by109…). Se verá «creado por desconocido». Migración aparte, medible cont1-alcance-del-reanclaje.ts. - ⚠️ El gemelo quedó cruzado:
node-developmenttiene ahora el ancla DEV y miembros con ids LIVE ⇒ inalcanzable para todos. Tiene 0 datasets ycheck:workspaceslo da verde porque tiene filas de miembros, así que el trinquete no lo ve. Es el residuo consciente del swap. - ⏭️ Sin verificar: si los
clerk_user_iddel realm de Keycloak son los live —como debería, porque federa la instancia live—, ahora coinciden por primera vez conworkspace_members. NecesitaKC_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ía | lo 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/governancedeja de acuñar la aserción y de canjear; usasujetoDeSesion(userId).- El SQL Editor usa el mismo resolvedor, en vez de construir el principal a mano.
keycloak-token.tsqueda 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/governance → 442 · tsc limpio |
| 🏁 | g6-5-gate-sql-en-vivo.ts → VERDE, 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ío | npx vitest run lib/governance → 434 · 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 foto | El 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.