Published

El plan gobernado — hoja de ruta a la política de celda en el CATÁLOGO

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...

El plan gobernado — hoja de ruta a la política de celda en el CATÁLOGO

Fecha: 2026-08-14. La sesión de hoy cambió el terreno: el scan planning del catálogo
funciona
(/scan, no /plan — §0·2). Eso mueve la política de celda de «aplicarla en
el motor y pagar un impuesto por cada motor nuevo»
a «aplicarla en el plan, y que
cualquier motor la herede»
, que es donde está el estándar en 2026.

Regla de la casa: cada hecho lleva el comando que lo demuestra. Lo que no lo lleva va
marcado ⏳ u opinión.

Anula: la recomendación «salida A» de plano-gobernado-approach.md
§7·bis como destino — sigue valiendo como puente. Ver §7·bis·3 de aquel documento.

Se apoya en: escrituras-sustrato-debilidades.md
§4·bis (la relectura medida) y en el aplicador ya construido (lib/governance/policy-applier.ts).


0 · El punto de partida, medido hoy

0·1 · Lo que YA está

gate
🏁El catálogo planifica: POST …/tables/{t}/scanstatus=completed + plan-tasksf0c-scan-endpoint-real.ts
🏁El motor sabe hablarlo: Spark trae Iceberg 1.11.0visto en el summary de los snapshots
🏁El modelo de políticas: policies + policy_attachments + resolución por cadenalib/governance/policy.ts · 252 tests
🏁El aplicador de celda: (principal, objetos, políticas) → (filtros, máscaras), puro20 tests
🏁La cara ya traduce nombre lógico↔físico y namespace, y autoriza por operaciónen producción
🏁Los grants de usuario aplican (USER_AUTHZ=enforce) y PLAN_MANDA=on/api/diag/motor

0·2 · 🪤 La trampa que lo tuvo oculto — y que hay que recordar

El servidor anuncia una ruta y sirve otra: su /v1/config publica POST /v1/{prefix}/tables/{table}/plan (la constante V1_SUBMIT_TABLE_SCAN_PLAN de la spec) y el recurso registrado es {table}/scan. Ocho variantes de /plan dieron 404 y se concluyó «no lo sirve». Era falso.

Regla nueva: endpoints[] no sólo promete de más — puede nombrar mal lo que sí
existe
. Cuando la ruta anunciada falle, buscar la ruta real en el binario antes de
concluir ausencia (zipfile sobre el jar, strings de @Path).

0·3 · Lo que falta, y es corto

  1. ⛔ La cara no enruta /scan (routeCatalogRequest no lo conoce).
  2. ⛔ La cara anuncia endpoints: [] y no publica scan-planning-mode ⇒ ningún motor sabe que puede delegar.
  3. ⛔ El vending no se condiciona a la política ⇒ hoy la política sería esquivable.
  4. createTable roto (parche G·1 escrito, sin desplegar).

0·4 · La diferencia de diseño con Unity Catalog, y por qué es buena

UC aplica la política en el plan porque el catálogo es dueño de la política. Aquí Gravitino no conoce nuestras políticas —viven en Index—, así que:

motor → /scan → LA CARA (resuelve, autoriza, INYECTA política) → Gravitino (planifica)

⭐ Index sigue siendo fuente única: no se replica el modelo de políticas en el catálogo, que es la divergencia que mata a este tipo de sistemas. El precio es que la cara está en el camino caliente de la planificación — y por eso §3 lleva presupuesto de latencia.


1 · La tesis

La política deja de ser una propiedad de la consulta y pasa a ser una propiedad del
PLAN.
Quien pregunta recibe un plan que ya sólo contiene lo que puede ver; no hay
«consulta sin filtrar» en ningún punto del camino.

Consecuencia que ordena todo lo demás: si el motor puede obtener los bytes sin pasar por el plan, la política es decorativa. Por eso §4 (conmutación del vending) no es opcional ni posterior: es la mitad que cierra la puerta de atrás.


2 · Definición de HECHO — el listón de esta iteración

Ninguna fase se da por cerrada sin las seis:

Canary por el camino del PRODUCTO (runQuery), no por curl — un curl a la cara da 403 aunque todo esté bien: no reproduce su autorización por usuario
Control negativo que distinga, no que sólo mire «¿no fue 200?» (regla 80). Para política: dos principales, misma tabla, distinto resultado
Fallar cerrado: ante duda, se restringe. Una política que no se puede evaluar no se ignora
Reversible: bandera por workspace, y el camino viejo intacto hasta el cutover
Observable: cada plan filtrado deja qué política lo restringió (el aplicador ya devuelve procedencia)
Medido en frío y en vivo: tests + gate contra el catálogo real

⚠️ Y el trinquete de esta casa: «desplegado» no es «sirviendo lo desplegado» (regla 85). Todo gate se lee del runtime (/api/diag/motor), nunca de la consola de Vercel.


3 · Las fases

S·0 · Cerrar lo que sangraantes de construir encima

Nada de lo que sigue se prueba bien sobre un plano que no deja crear tablas.

  • Desplegar G·1 (la cara pregunta al catálogo en vez de compensar a ciegas).
  • Decidir el CTAS: descomponer en CREATE + relleno (pierde atomicidad) o esperar versión. ⚠️ La decisión afecta a la promesa del editor, no sólo al código.

Gate: crear una tabla desde el editor y que aparezca en Index y en el catálogo · w8-create-sin-ctas.ts verde · cero huérfanas (w8b-huerfanas-de-create.ts).


S·1 · La llave: enrutar /scan ⭐⭐⭐

Sin esto no se puede probar nada de lo demás. Es pequeño porque la cara ya sabe hacer lo difícil (resolver nombre, namespace, inquilino y privilegio).

  1. routeCatalogRequest reconoce POST …/tables/{t}/scan como operación gobernada.
  2. Exige TABLE_READ_DATA — es acceso al dato, no a la metadata. Mismo privilegio que loadTable con delegación, por el mismo motivo.
  3. Traduce nombre y namespace como el resto (reutilizar, no reescribir).
  4. Reenvía a {table}/scan — ⚠️ no a /plan: la ruta real (§0·2).

Gate: s1-gate-scan-por-la-cara.ts — un /scan por la cara devuelve plan-tasks · control negativo cruzado: la misma petición bajo el prefijo de otro inquilino no devuelve plan.

⚠️ El canary NO va por runQuery, y es a propósito: el /scan no lo pide el usuario, lo pide el motor. Hasta S·2 Spark ni lo intenta, así que un canary por runQuery no tocaría esta ruta ni con el cambio desplegado. Se reproduce al motor con su misma credencial (carbon-catalog-oauth-editor); un token cualquiera del realm mediría a otro sujeto.

🏁 Estado de S·1 (2026-08-14)

🏁 Implementado y probado en fríolib/governance/catalog-route.ts (enruta), catalog-authz.ts (privilegio) y route.ts (reescritura del segmento físico). 264/264 en lib/governance, 12 tests nuevos.

La decisión de fondo: planTableScan exige TABLE_READ_DATA, no TABLE_READ_PROPERTIES. Un plan es acceso al dato —trae las rutas de los ficheros—, así que tratarlo como metadata regalaría «saber dónde están las filas» a quien sólo podía «ver el catálogo».

🪤 Dos trampas que costaron su test:

  • La reescritura al nombre físico exigía rest.length === 4 exacto, así que un scan (5 tramos) habría viajado con el nombre lógico y el catálogo habría dicho 404 sobre una tabla que existe — leído como «no está» cuando era «no se tradujo».
  • Una tabla llamada scan no es un sufijo: …/tables/scan sigue siendo un commit sobre esa tabla. La diferencia la da la POSICIÓN, no el texto.

🏁🏁 S·1 · VERDE EN PRODUCCIÓN (2026-08-14, commit fc0b5c6)

✅ la cara lo enruta                      HTTP 200
✅ devuelve un plan                       status=completed · tareas=1
✅ acepta el nombre LÓGICO y lo traduce   bronze_clientes → ds_b8944d69…
✅ CONTROL CRUZADO con inquilino REAL     w_cccc70a5… → 404 «la tabla no existe en este catálogo»
✅ `/credentials` sigue cerrado           HTTP 404

⭐ El control cruzado se repitió con un inquilino real, no con el prefijo inventado del gate: con uno inventado, el 404 no distingue «no existe ese inquilino» de «no autorizado». Con uno real, el rechazo lo emite la cara tras resolver por inquilino — es aislamiento, no ausencia de ruta. ⚠️ Limitación honesta: hoy sólo un inquilino tiene datos, así que no se puede probar «ver el plan de la tabla de OTRO que sí existe». Vuelve a mirarse cuando haya un segundo.

🏁 Y G·1 quedó verificado EN VIVO con el mismo despliegue

w8-create-sin-ctas.ts contra producción: CREATE TABLE pasa, la tabla queda en Index y en el catálogo (cero huérfanas) y admite datos (MERGE verde). ⇒ ⭐ Eso decide la salida del CTAS de S·0: CREATE + relleno funciona hoy, sin tocar el catálogo ni esperar versión nueva. Lo que se pierde es la atomicidad, no la capacidad.


S·2 · Que el motor delegue — anunciarlo

La cara sirve su propio /v1/config con endpoints: []. Hay que publicar lo que soporta y scan-planning-mode.

⚠️ Con scan-planning-mode: server el cliente DEBE usar planTableScan — deja de planificar en cliente. Es un cambio de comportamiento del motor, así que canary por workspace, nunca global de golpe.

Gate: s2-gate-delegacion.ts — con el canary apagado, Spark planifica en cliente · encendido, el catálogo registra el /scan y la query devuelve las mismas filas. ⭐ Mismas filas es el control: si cambian sin política, algo se rompió en la traducción.

🏁🏁🏁 S·2 · VERDE EN PRODUCCIÓN (2026-08-14)

planes registrados en el catálogo:  6 → 9   (una por consulta ⇒ EL MOTOR DELEGA)
las tres consultas:                 3 / 40 / 112.650   ⇒ MISMAS filas que sin delegar
/v1/config:                         13 endpoints · ruta de la SPEC · scan-planning-mode=server

El desbloqueo fue S·2·b: la cara COMPLETA el plan. Gravitino da tokens y no tiene /tasks donde canjearlos — pero sus tokens no son opacos: llevan dentro el file-scan-task entero. La cara los materializa y devuelve el plan completo, la forma que la spec contempla con status=completed. Una llamada en vez de dos, y el cliente nunca necesita el endpoint que falta.

⚠️ TODO O NADA (lib/governance/scan-plan.ts, 12 tests): si un solo token no se puede materializar, no se convierte ninguno y la respuesta va intacta. Un plan a medias no daría error — daría menos filas.

⇒ ⏭️ S·3 queda desbloqueado, y en el sitio exacto: quien tiene las tareas del plan en la mano —completarPlan— es quien podrá recortarlas con la política de celda.

🪤 Dos trampas operativas de esta tanda

  • ⛔⛔ vercel rollback FIJA el alias, y un vercel --prod posterior no lo mueve: el deployment nuevo queda Ready y sirviendo… nadie. Se vio porque el /v1/config traía los 13 endpoints (código nuevo) pero no scan-planning-mode (código más nuevo aún) — dos versiones distintas a la vez, que es la firma de un alias congelado. Se arregla con vercel promote <url>. Verificar SIEMPRE por el contenido del runtime, no porque el deploy diga READY.
  • ⚠️ Y el bridge cachea la sesión de Spark: sin rollout restart, la sesión sigue con el /v1/config que leyó al nacer y no se entera de que ahora puede delegar.

📌 Histórico · el bloqueo que S·2·b resolvió

Lo que SÍ quedó hecho y verificado en producción:

🏁La cara acepta las dos rutas: /plan (la de la spec, la que manda el motor) y /scan (la que Gravitino registra), y traduce el sufijo al reenviar
🏁El /v1/config declara lo que sirve: 13 endpoints, antes []. Un test ata que cada anuncio se enrute — para no repetir el error de Gravitino
🏁El del plan se anuncia sólo con canary de workspace, y con la ruta de la spec
🏁scan-planning-mode: server hace que Spark delegue de verdad — el contador de Plan table scan del catálogo pasó de 2 a 5

⭐⭐ Hallazgo: anunciar el endpoint NO basta. Con los 13 endpoints publicados y el plan entre ellos, Spark siguió planificando en cliente —ni un Plan table scan, ni con sesión recién creada—. Lo que lo activa es scan-planning-mode, que no está en el rest-catalog-open-api.yaml: vive en el CLIENTE (RESTCatalogProperties, junto a rest-scan-planning.poll-timeout-ms y rest-scan-plan-id, con valores client/server). Se encontró abriendo su jar, no leyendo la spec.

⛔ Y ahí murió, en el paso siguiente:

UnsupportedOperationException: Server does not support endpoint:
  POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/tasks

El scan planning de la spec son dos llamadas: /plan devuelve tokens (plan-tasks) y /tasks los materializa en file-scan-tasks. Gravitino 1.2.0 sirve la primera y no la segunda — sus únicos sufijos son {table}, /metrics, /credentials y /scan, comprobado en el jar. Devuelve tokens que nadie puede canjear.

⚠️ Con el canary encendido, las lecturas de ese workspace se rompen. Pasó en producción y se revirtió con vercel rollback (4 s); lecturas verificadas de vuelta en 3 / 40 / 112.650. La variable SCAN_PLANNING_WORKSPACES se retiró de Vercel para que un despliegue futuro no lo reactive; el código se queda, inerte y probado.

⏭️ Para desbloquear, por coste creciente:

  1. Comprobar Gravitino 1.3.0 — la doc de 1.3 ya describe la caché de planes; hay que ver si además añadió /tasks. Es la vía barata y la primera que hay que mirar.
  2. Que el /plan devuelva file-scan-tasks directamente en vez de tokens (la spec lo permite cuando status=completed). Si es configurable en Gravitino, no haría falta /tasks.
  3. Implementar /tasks en la cara sintetizándolo: los tokens que devuelve Gravitino ya traen el file-scan-task serializado dentro (visto en f0c). ⚠️ Es acoplarse a un formato interno suyo — último recurso.

S·3 · El plan FILTRADO — conectar el aplicador ⭐⭐⭐

Aquí entra lo construido: el PlanTableScanRequest lleva filter y select.

  1. Resolver las políticas efectivas del dataset (policy-queries, ya existe).
  2. Llamar a aplicarPoliticaDeCelda (ya existe, puro).
  3. Inyectar el predicado en filter y recortar select.
  4. Anotar la procedencia en el evento de acceso.

⚠️ Presupuesto de latencia: esto entra en el camino caliente. Las políticas se cachean por (dataset, versión) como ya se cachea el prefijo y el token. Un plan que tarda más que la query que evita no es gobernanza, es un impuesto.

Gate — y es el que de verdad importa: dos principales, la misma tabla, distinto número de filas, por runQuery. Y el negativo simétrico: sin política, los dos ven lo mismo.

🏁🏁🏁 S·3 · VERDE EN PRODUCCIÓN (2026-08-14) — con una limitación honesta

①  sin política                                    3 filas   (referencia)
②  row_filter PODABLE (cliente_id > 1000)          0 filas   ⭐ la política se cumple
②b row_filter con RESIDUAL (cliente_id = 1)        DENEGADO  ⭐⭐ 403 de la cara
③  retirada la política                            3 filas   ⭐ control simétrico
④  row_filter SIN `expression`                     DENEGADO  ⭐ no se sirve sin filtrar

La decisión pendiente quedó CONTESTADA por medición, no por criterio: el select no recorta el esquema (se pidió una columna y devolvió las siete) ⇒ la máscara de columna no es aplicable en el plan, y por eso column_mask deniega en vez de fingir.

⛔⛔ Y el hallazgo que casi convierte S·3 en decoración

cliente_id > 1000 (podable)  → 0 filas ✅
cliente_id = 1    (residual) → 3 filas ⛔ ¡incluidas las prohibidas!

Un filtro se resuelve de dos formas: poda (el catálogo descarta el fichero entero por sus estadísticas — el dato no llega al motor) o residual (el fichero contiene filas que casan y otras que no, y el catálogo delega el resto en el motor).

Spark aplica el residual de los filtros que ÉL pidió, y ignora el que le llega en el plan sin haberlo pedido. Así que un filtro no podable no protege, y servirlo habría sido el control decorativo perfecto: existe en la base de datos, no en el camino, y no da un error.

Con política inyectada, la cara comprueba el plan que vuelve y DENIEGA si queda residuo (tieneResidualPendiente). Es lo que separa una política de una decoración.

⚠️ La limitación, dicha sin maquillar: la política de fila por esta vía sólo protege si el filtro queda RESUELTO. En el resto de casos la tabla no se sirve por el plan, y su lectura tendría que aplicar la política en el motor. No es un defecto nuestro: es lo que da de sí el mecanismo.

🪤 Esta frase decía antes «se resuelve por poda — es decir, si la tabla está particionada
u ordenada por esa columna», y era imprecisa en el punto que importa.
La fase P lo
midió: podar no es lo que protege. Una tabla sin particionar poda igual (3 tareas → 1)
y deja residuo; bucket poda igual (4 → 1) y deja residuo. Lo único que resuelve es una
partición que determine el valor: identity. Y «ordenada» no basta — el orden da
estadísticas, que podan, no deciden.

⏭️ De ahí salió el trabajo siguiente, y ya no es S·3: la fase P.


S·4 · Cerrar la puerta de atrás — la conmutación del vending (F·1) ⭐⭐⭐

«Si una tabla tiene política de celda, el catálogo NO vende credencial para ella.»

Es la regla que F·1 dejó escrita y nunca se implementó: credential-vending.ts sólo usa la cadena de securables para averiguar el dueño, jamás pregunta si hay política. Sin esto, todo S·3 se esquiva pidiendo la llave.

Es literalmente lo que hace UC: «tables with row filters or column masks are NOT supported when using credential vending».

Gate: s4-gate-vending-conmutado.ts.

🏁 S·4 · HECHO (2026-08-14) — con la regla afinada por una medición

⚠️ «No vender» a secas ROMPE la lectura, y está medido: Spark pide loadTable con credencial ANTES de pedir el plan, así que sin ella no leería ni los ficheros que la poda sí autorizó. Cortar el vending habría apagado las tablas con política para todo el mundo — que no es gobernar, es apagar.

La regla precisa no es «no vender», es NO VENDER A QUIEN NO PASA POR EL PLAN:

Con política de celda, sólo se entrega credencial cuando ① quien pide es un motor que
planifica por la puerta
y ② el scan planning está ACTIVO para ese inquilino.

② no es burocracia: con el canary apagado, nuestro propio motor planifica en cliente y no aplica ninguna política. Confiar en él entonces sería confiar en algo que ese día no hace. La confianza sale de una condición medible, no de un nombre en una lista blanca.

① sin política          loadTable con delegación → 200 + llave    (el 95 % no cambia)
② con row_filter        el motor de la puerta    → 200 + llave    (no se rompe nada)
③ S·3 sigue verde tras S·4 — sin regresión

🏁🏁🏁 S·4 · CERRADO EN VIVO (2026-08-15) — la deuda, pagada

La versión anterior de este apartado decía: «no se puede afirmar que la puerta esté
cerrada contra un tercero, sólo que la lógica lo dice»
. Ya se puede afirmar, y está
medido.

Faltaba un principal que no fuera de servicio, y no existía ninguno: el realm tenía 16 clients y ni una persona que pidiera un token. K·1 lo creó y K·1·claim hizo que la cara dejara de suponer que todo token es un servicio. Gate k3-gate-s4-en-vivo.ts:

①  la persona entra en `ws-viewer`      5 privilegios   ⇒ prueba que el claim SIRVE:
                                                           los grants van por `sub`
②  sin política                         200 + credencial   (el 95 % no cambia)
③  ⭐⭐⭐ CON política de celda           403, SIN credencial
④  el MOTOR, misma tabla y política     sí recibe          ⇒ discrimina de verdad

Por qué ① no es un preliminar sino parte del control: sin grants, la persona recibe un 403 de autorización y nunca llega al vending. Un gate que se conformara con ese 403 diría «cerrado» porque no puede ni leer — mediría la puerta de antes, no la conmutación. Por eso primero se le concede lectura y entonces se comprueba que no recibe llave.

⭐⭐ Y ④ es el que separa «la conmutación discrimina» de «la tabla está apagada para todos»: mismo objeto, misma política, dos principales, distinto resultado.


P · Particionar por la columna de política — el techo de S·3, convertido en requisito ⭐⭐⭐

La pregunta: S·3 dejó tablas que, con política, dejan de leerse. ¿Es eso un techo
del mecanismo, o un requisito que se puede cumplir y comprobar?

🏁🏁 P · MEDIDO EN PRODUCCIÓN (2026-08-14)

P·0 — la premisa (p0-premisa-particion.ts). Dos tablas con los mismos datos y una sola diferencia, medidas contra el catálogo con el filtro cliente_id = 1:

particióntareas¿poda?residual
(ninguna)3 → 1{eq cliente_id 1}pendiente
bucket(4, cliente_id)4 → 1pendiente
identity(cliente_id)3 → 1trueresuelto

⭐⭐⭐ PODAR NO ES LO QUE PROTEGE. Los tres podan. Lo que decide es si el residual queda resuelto, y eso sólo lo da una partición que determine el valor. Con bucket la partición no es inyectiva —varios ids caen en el mismo bucket—, así que saber la partición no decide el predicado y el residuo sobrevive (p0b-que-transformacion-resuelve.ts).

⭐ Y se comprobó primero que el PARTITIONED BY sobrevive al camino del producto: la tabla nace en el catálogo con identity(cliente_id). Sin eso, lo demás no significaría nada.

P·1 — el gate por el camino del producto (p1-gate-particion-gobernada.ts): la misma política, dos tablas, distinto desenlace.

①  sin política, las dos tablas          3 filas / 3 filas   (parten iguales)
②  sobre la PARTICIONADA                 1 fila    ⭐⭐⭐ se sirve, y filtrada
③  sobre la PLANA                        DENEGADO  ⭐⭐ el caso de S·3, sin cambios
④  retiradas                             3 / 3     ⭐ control simétrico

La limitación de S·3 deja de ser un techo y pasa a ser un REQUISITO CONOCIDO.

P·2 — que el sistema sepa decirlo. Hasta aquí, la única forma de saber si una tabla con política se podía leer era intentar leerla. Tres piezas lo arreglan:

lib/governance/partition-fit.tspuro, 16 tests. Cruza las columnas que la política restringe contra las que la partición determina. Exige identity — y resuelve por source-id, no por el nombre del campo de partición, que es libre
el 403 nombra la columnaantes decía «particiona la tabla por esa columna»; ahora dice cuál, y desaconseja bucket por su nombre — cumplir el consejo con bucket dejaba la tabla igual de muerta
p2-censo-servibilidad.tscensa el catálogo: cuántas tablas con política son servibles y cuántas están en el limbo. Verificado que distingue (1 y 1 sobre las tablas de P·0)

⚠️ partition-fit NO es el punto de aplicación, y está escrito en su cabecera: quien decide si un plan se sirve sigue siendo tieneResidualPendiente sobre el plan real. Esto es diagnóstico: avisar antes, explicar después, censar. Si discrepan, manda el plan.

🪤 Y un falso negativo que habría dolido

residualResuelto reconocía true pero no {"type":"boolean","value":true} — la forma que el file-scan-task de f0c trae medida. No abría agujero (el error caía del lado seguro), pero denegaba una tabla que sí está gobernada: un 403 diciéndole «particiona por esa columna» a quien ya la había particionado. Arreglado, con las dos formas fijadas en test.

⏭️ Lo que P deja abierto

  1. ⭐⭐ Nadie avisa al ADJUNTAR la política. Hoy no hay API de policy_attachments —se insertan en Postgres—, así que no hay punto donde colgar el aviso. diagnosticarServibilidad ya está listo para ese enganche el día que exista: es una llamada.
  2. ⚠️ Particionar una tabla que ya tiene datos no está resuelto: Iceberg admite cambiar la spec, pero los ficheros viejos conservan la suya, así que el residual seguiría pendiente para ellos. Una tabla en el limbo con datos hay que reescribirla, no sólo re-particionarla. No medido.
  3. ⚠️ La partición se elige al crear, y la política se adjunta después — el orden natural está invertido. Mientras no exista ①, el censo es la red.
  4. ⏳ Y sigue en pie la salida A (aplicar el filtro en el motor) para las tablas que no se puedan particionar. P no la sustituye: la vuelve opcional en vez de obligatoria.

S·5 · Robustez del write path — lo que resultó estar medio hecho ⭐⭐⭐

🏁🏁 S·5·① · EL REINTENTO YA EXISTÍA (2026-08-14)

La premisa era: «no hay un solo bucle de reintento en nuestro camino». Cierto de nuestro código, falso del sistema: quien commitea es Spark, y el cliente Iceberg ya reintenta. Lo único que hacía falta era comprobar que la cara no lo rompe — está en medio del commit, y si tradujera el 409 a un 500, el motor lo leería como fallo definitivo y el reintento existiría estando roto, sin un solo error que lo dijera.

s5-0 · un commit sobre un snapshot obsoleto  → HTTP 409 · CommitFailedException  ✅
       el MISMO commit con el snapshot al día → HTTP 200   ⭐ el control que distingue

No se construyó ningún bucle. Se ATÓ el heredado con s5-gate-reintento-occ.ts:

12 escritores concurrentes → 34 conflictos REALES · 12/12 acaban · +12 filas exactas

⭐⭐ La pieza que hace honesto ese gate: cuenta los CommitFailedException del catálogo antes y después. Si el contador no sube, no hubo carrera y el veredicto es NO CONCLUYENTE — nunca verde. «Lanzo dos escrituras y las dos acaban» aprueba igual cuando no se solapan: da confianza sin dar evidencia, y seguiría verde el día que el reintento se rompa.

🏁 S·5·② · La robustez, DECLARADA en la tabla

Medido: ninguna tabla declaraba una sola de las siete propiedades — los cuatro parámetros del reintento y los tres del aislamiento venían del defecto del motor.

⭐ Van en la tabla, no en la configuración de Spark, por la misma razón que la política viaja en el plan: lo que vive en el catálogo lo hereda cualquier motor. Y las tablas nacen con ellas (conRobustez en el createTable de la cara), así que no dependen de un barrido que alguien recuerde correr. ⚠️ Rellena, no pisa: quien declaró la suya se la queda.

⚠️ El aislamiento se declara al valor que ya regía (serializable): no cambia el comportamiento, lo hace visible. Relajarlo a snapshot es otra decisión, con su medición.

⚠️ Lo que S·5 NO puede afirmar

  1. Que subir los reintentos arregle el caso de 8 escritores. Intermitente: 8/8, 7/8, 8/8 — y esa caída fue con las propiedades puestas. Sin probar.
  2. Una vez, con 8 concurrentes, se cayó la sesión de Spark Connect: 5 escrituras murieron en el mismo milisegundo con Cannot invoke RPC: Channel closed!. El bridge comparte una sola sesión cacheada, así que al romperse se lleva todo lo que hubiera en vuelo. No reproducible en 3 intentos ⇒ riesgo observado, no techo medido. Y en esa tanda una escritura que reportó ERROR sí había entrado (+4 filas, 3 éxitos): la trampa de siempre, ahora también en el bridge.
  3. El reintento tras un 500 sigue sin gate propio (§4·4): la cara ya pregunta al catálogo en vez de inferir del !res.ok, pero no está medido que no duplique.

🪤 Y el gate que fabricó un fallo que no existía

La segunda ejecución acusó al sistema de perder datos: con 8 escritores faltaban 5 filas y sólo 2 escrituras habían fallado. Pintaba a lo peor posible —commits que dicen éxito y no dejan la fila— y era mentira: el gate reutilizaba los mismos cliente_id entre tandas, así que su WHEN NOT MATCHED THEN INSERT no tenía nada que insertar. Lo delató el catálogo: había append con total-records sin subir.

Un control mal diseñado no sólo aprueba de más — también FABRICA un fallo grave que no existe, y manda a arreglar lo que funciona. El gate ahora deriva los ids del reloj y compara el TOTAL de la tabla, que es lo que lo habría desmentido en el sitio.


S·6 · Las etiquetas (F·3) — para que N tablas no cuesten N políticas

La política madura se escribe sobre etiquetas, no sobre objetos. Ya hay germen (object_tags, 10 filas, con category, confidence y source). Nacen en la ingesta.

Gate: un dataset ingerido llega con sus etiquetas · una política escrita sobre una etiqueta rige en una tabla nueva sin tocar la política.


4 · El orden, y por qué

S·0  cerrar lo que sangra        ← no se prueba nada encima de un plano roto
S·1  enrutar /scan               ← la llave: sin ella nada es medible
S·2  anunciar el modo            ← que el motor delegue, con canary por workspace
S·3  inyectar la política        ← lo que ya está construido, conectado
S·4  conmutar el vending         ← sin esto, S·3 es decorativo
S·5  robustez de escritura       ← antes de los agentes, no después
S·6  etiquetas                   ← lo que hace la política sostenible

⚠️ S·3 y S·4 son una sola entrega funcional. Desplegar S·3 sin S·4 crea el peor estado posible: un control que parece existir y se rodea pidiendo una credencial. Si hay que parar en medio, se para antes de S·3, no entre S·3 y S·4.


5 · Riesgos abiertos

createTable sigue roto y el CTAS no tiene salida elegida (S·0)
Los grants no alcanzan al objeto: securable_kind sólo usa workspace y schema, y ningún humano tiene TABLE_WRITE_DATA. La política discrimina por rol de workspace, no por persona sobre tabla
⚠️Se perdió el commit multi-tabla: Lakekeeper exponía /transactions/commit, Gravitino no lo expone. §4·1 lo daba por «comprado y sin estrenar» — ya no está comprado
⚠️La cara entra en el camino de la planificación ⇒ su latencia y su disponibilidad pasan a ser las del scan. Presupuesto y caché en S·3
⚠️Gravitino 1.2 vs 1.3: la caché de planes (scan-plan-cache-*) está documentada en 1.3.0; hay que medir qué de eso responde en 1.2.0 antes de contar con ello
⚠️El despliegue embarca el árbol, no el commit — desplegar desde worktree limpio

6 · Comandos que se corren

# el estado del catálogo y del plan
npx dotenv -e .env.local -- npx tsx scripts/warehouse/f0c-scan-endpoint-real.ts
npx dotenv -e .env.local -- npx tsx scripts/warehouse/w6-canary-escritura-gravitino.ts --dry

# el plano de escritura y sus huérfanas
npx dotenv -e .env.local -- npx tsx scripts/warehouse/w8-create-sin-ctas.ts
npx dotenv -e .env.local -- npx tsx scripts/warehouse/w8b-huerfanas-de-create.ts

# la malla, desde Postgres (no desde los docs)
npx dotenv -e .env.local -- npx tsx scripts/governance/v0-relectura-desde-postgres.ts
npx dotenv -e .env.local -- npx tsx scripts/governance/v1-alcance-y-verbos.ts

# en frío
npx vitest run lib/governance          # 252 verdes
npx tsc --noEmit                       # 5 errores preexistentes, ninguno de este frente

# el runtime — NUNCA la consola de Vercel
curl -H "x-diag-key: $SPARK_BRIDGE_JWT_SECRET" https://app.paladio.io/api/diag/motor