Published

HANDOFF · 2026-08-14 (noche) — la política vive en el PLAN

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-14 (noche) — la política vive en el PLAN

Para qué es esto. La sesión llevó la política de celda del papel al camino: el motor
delega la planificación en el catálogo, la cara inyecta el filtro, y la credencial ya no
lo esquiva. S·0 → S·4 cerradas. Este documento es la foto autosuficiente para retomar.

⚠️ SUPERSEDE a handoff-2026-08-14-cierre.md (la foto
de la tarde). Aquél sigue valiendo para el cutover del catálogo; para el plan gobernado,
manda éste.

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

Entregable vivo: scan-planning-gobernado-approach.md.


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

  1. 🏁 El motor DELEGA la planificación en el catálogo (scan-planning-mode: server). Medido: los planes del catálogo suben uno por consulta.
  2. 🏁 La política de fila viaja en el PLAN y cambia lo que el usuario ve: 3 → 0 filas.
  3. 🏁 Y ya se sabe qué hace falta para que proteja: PARTITIONED BY (columna) de identidad (fase P). Si el filtro deja residual, el motor no lo aplica ⇒ la cara deniega en vez de enseñar de más. ⭐ Podar no bastabucket poda y deniega igual.
  4. La máscara de columna NO es aplicable en el plan (el select no recorta el esquema, medido) ⇒ column_mask deniega.
  5. 🏁 El vending está conmutado: con política, sólo recibe llave quien pasa por el plan — y sólo si el scan planning está activo para ese inquilino.
  6. ⚠️ La puerta contra un TERCERO no está medida: no existe hoy un principal que no sea de servicio. Probada en frío (16 tests), no en vivo.
  7. 🏁 createTable arreglado (G·1) y verificado en vivo: crea, sin huérfanas, y admite datos por MERGE.
  8. El CTAS sigue roto (stage-create + hook importTable). Salida decidida: CREATE + relleno funciona hoy; lo que se pierde es la atomicidad.
  9. ⚠️ El canary SCAN_PLANNING_WORKSPACES está ENCENDIDO para 7b500f4d…, el único inquilino con datos.
  10. ⏸️ Spark y el cluster siguen encendidos, y eso cuesta.

1 · El estado por fase

gate
🏁 S·0createTable (G·1): la cara pregunta al catálogo en vez de compensar a ciegasw8-create-sin-ctas.ts · w8b-huerfanas-de-create.ts
🏁 S·1/scan enrutado y gobernado · exige TABLE_READ_DATAs1-gate-scan-por-la-cara.ts
🏁 S·2la cara declara sus 13 endpoints + scan-planning-mode (canary por ws)s2-gate-delegacion.ts
🏁 S·2·bla cara completa el plan (plan-tasksfile-scan-tasks)17 tests · scan-plan.ts
🏁 S·3la política se inyecta en el plan; lo que no se sabe aplicar deniegas3-gate-politica-en-el-plan.ts
🏁 S·4el vending conmutado: no se vende a quien no pasa por el plans4-gate-vending-conmutado.ts
🏁 Pparticionar por la columna de política: el techo de S·3 es un requisito — y hay censop0-premisa-particion.ts · p0b-… · p1-gate-particion-gobernada.ts · p2-censo-servibilidad.ts
🏁 S·5·①②robustez: el reintento OCC ya existía (heredado) y ahora está atado y declarado en la tablas5-0-premisa-conflicto.ts · s5-gate-reintento-occ.ts · s5c-propiedades-de-robustez.ts
S·6etiquetas (object_tags tiene 10 filas como germen)

331 tests verdes en lib/governance · tsc limpio (los 3 preexistentes ya no están).


2 · ⭐ Los cinco hallazgos que ordenan lo que queda

  1. ⭐⭐⭐ El scan planning FUNCIONA, pero en /scan, no en /plan. El servidor anuncia una ruta y sirve otra. Ocho variantes de la anunciada dieron 404 y se llegó a concluir que no existía. ⇒ cuando la ruta anunciada falle, buscar la real en el binario.
  2. ⭐⭐⭐ Anunciar el endpoint no basta: hay que mandar scan-planning-mode: server, que no está en el rest-catalog-open-api.yaml — vive en el jar del cliente.
  3. ⭐⭐⭐ El motor no aplica un residual que no pidió. cliente_id > 1000 (podable) → 0 filas; cliente_id = 1 (residual) → las 3, incluidas las prohibidas. Sin esta comprobación, S·3 habría sido una decoración perfecta.
  4. ⭐⭐ Cortar el vending sin más rompe la lectura: Spark pide la credencial antes del plan. La regla no es «no vender», es «no vender a quien no pasa por el plan».
  5. ⭐⭐ Un 500 del catálogo no significa que no se haya escrito (el hook crea y falla al releer). Un reintento a ciegas duplica.

3 · ⏭️ LO SIGUIENTE

🏁 ① Particionar por la columna de política — HECHO (ver §3·bis)

🏁 ② S·5·①② — HECHO (ver §3·ter). ⏭️ Queda S·5·③: el reintento tras un 500

La cara ya pregunta al catálogo en vez de inferir del !res.ok, pero no está medido que un reintento tras 500 no duplique (§4·4). Y sigue abierto relajar el aislamiento a snapshot, que es la palanca real contra la contención.

⭐ ③ Cerrar la puerta contra un tercero, de verdad

S·4 está probado en frío. Hace falta un principal que no sea de servicio para medirlo.

⚠️ ④ Deudas menores

  • El CTAS (decidir si se descompone en CREATE + relleno).
  • DROP TABLE no pasa por runQuery («el motor no pudo planificarla») ⇒ quedó la tabla de prueba w8_plain_mstadayl.
  • INSERT … VALUES sigue vetado con un motivo caducado (ver escrituras-sustrato-debilidades.md §4·bis).
  • Se perdió el commit multi-tabla al migrar: Gravitino no expone /transactions/commit.


3·bis · 🏁 La fase P — el techo de S·3, convertido en requisito

Lo medido, en tres líneas:

sin partición            3 → 1 tareas · poda SÍ · residual PENDIENTE  ⛔ deniega
bucket(4, cliente_id)    4 → 1 tareas · poda SÍ · residual PENDIENTE  ⛔ deniega
identity(cliente_id)     3 → 1 tareas · poda SÍ · residual `true`     ✅ SE SIRVE, filtrado

⭐⭐⭐ Podar no es lo que protege — los tres podan. Lo que decide es que el residual quede resuelto, y eso sólo lo da una partición que determine el valor. Por eso el consejo dice identity y desaconseja bucket por su nombre: cumplirlo con bucket dejaba la tabla igual de muerta, y encima con la sensación de haberlo hecho.

Gate P·1, por el camino del producto: la misma política sobre dos tablas iguales → la particionada devuelve 1 fila de 3, la plana deniega. Control simétrico verde.

Y ahora el sistema sabe decirlo antes de leer: lib/governance/partition-fit.ts (puro, 16 tests), el 403 nombra la columna que falta, y p2-censo-servibilidad.ts dice cuántas tablas están en el limbo (verificado que distingue: 1 servible, 1 en limbo).

🪤 De paso, un falso negativo cazado: residualResuelto no reconocía {"type":"boolean","value":true} —forma real, medida en f0c— y habría denegado una tabla correctamente particionada. Arreglado, con las dos formas en test.

⏭️ Lo que P deja abierto: ① nadie avisa al adjuntar la política (no hay API de policy_attachments; el diagnóstico está listo para ese enganche); ② particionar una tabla que ya tiene datos no está resuelto —los ficheros viejos conservan su spec, así que habría que reescribirla, no sólo re-particionarla— y no está medido.

⚠️ El cambio del mensaje 403 está en el repo y NO desplegado. No altera qué se sirve ni a quién —sólo el texto del motivo—, pero hasta que se despliegue el runtime sigue diciendo «particiona la tabla por esa columna» sin decir cuál.



3·ter · 🏁 S·5·①② — el reintento no faltaba: faltaba saberlo

s5-0   commit sobre snapshot obsoleto  → 409 CommitFailedException   ⇒ REINTENTABLE
       el mismo con el snapshot al día → 200                          ⇒ era el conflicto
gate   12 escritores concurrentes      → 34 conflictos REALES · 12/12 · +12 filas exactas

⭐⭐⭐ El reintento OCC ya existía, heredado del cliente Iceberg, y la cara no lo rompe (reenvía el 409 íntegro). La afirmación de escrituras-sustrato-debilidades.md §4·2 —«no hay ningún bucle ⇒ una escritura se la come el usuario»— era cierta del código y falsa del sistema. Se estuvo a punto de construir un bucle al lado de uno que funciona.

⭐⭐ El gate cuenta los conflictos del catálogo: si no suben, no hubo carrera y el veredicto es NO CONCLUYENTE, nunca verde. Un gate de concurrencia que no provoca conflicto aprueba siempre y no prueba nada.

🏁 Y lo que sí faltaba está hecho: las siete propiedades de robustez (4 de reintento + 3 de aislamiento) no estaban declaradas en ninguna tabla. Ahora viven en el catálogo (lib/governance/table-robustness.ts) y las tablas nacen con ellas, así que cualquier motor las hereda sin configurar nada.

⚠️ Sin maquillar: ① no está probado que subir los reintentos arregle el caso de 8 escritores (8/8, 7/8, 8/8 — la caída fue con las propiedades puestas); ② una vez se cayó la sesión compartida del bridge y se llevó 5 escrituras en el mismo milisegundo (Channel closed), con una de ellas escribiendo pese a reportar error — no reproducible en 3 intentos; ③ S·5·③ sigue pendiente: que un reintento tras 500 no duplique.

🪤 Y una lección cara: el gate llegó a acusar al sistema de perder datos (faltaban 5 filas, sólo 2 fallos) y el defecto era suyo — reutilizaba cliente_id entre tandas, así que el WHEN NOT MATCHED no insertaba. Un control mal diseñado fabrica fallos graves inexistentes y manda a arreglar lo que funciona.

⚠️ En el repo y NO desplegado: el 403 que nombra la columna (fase P) y las propiedades de robustez al crear tabla. Y las 107 tablas existentes siguen sin declararlass5c-…ts --aplicar va de una en una (S5_TABLA).


4 · 🪤 Trampas operativas (nuevas, y caras)

⛔⛔vercel rollback FIJA el alias, y un vercel --prod posterior no lo mueve: el deployment nuevo queda Ready sirviendo a nadie. Síntoma: el runtime mezcla dos versiones a la vez. Se arregla con vercel promote <url>. Verificar por el CONTENIDO del runtime, nunca por el READY
⚠️El bridge cachea la sesión de Spark: sin kubectl rollout restart deploy/spark-bridge -n spark no relee el /v1/config y no se entera de que puede delegar
⚠️El árbol de trabajo era la fuente del deploy: el cutover del 08-14 nunca se había commiteado, así que un worktree limpio de HEAD habría desplegado la cara pre-cutover. Ya está commiteado (fc0b5c6); mantenerlo así
⚠️Los namespaces se le preguntan a Index, no se descubren; el separador es 0x1F

5 · Comandos que se corren

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

# los gates del plan gobernado (necesitan Spark encendido)
export CARA_CREDENTIAL="$(kubectl get secret carbon-catalog-oauth-editor -n spark -o jsonpath='{.data.credential}' | base64 -d)"
npx dotenv -e .env.local -- npx tsx scripts/warehouse/s1-gate-scan-por-la-cara.ts
npx dotenv -e .env.local -- npx tsx scripts/warehouse/s2-gate-delegacion.ts
npx dotenv -e .env.local -- npx tsx scripts/warehouse/s3-gate-politica-en-el-plan.ts
npx dotenv -e .env.local -- npx tsx scripts/warehouse/s4-gate-vending-conmutado.ts

# la fase P — el requisito de partición (P·0 crea las tablas que P·1 necesita)
npx dotenv -e .env.local -- npx tsx scripts/warehouse/p0-premisa-particion.ts
npx dotenv -e .env.local -- npx tsx scripts/warehouse/p0b-que-transformacion-resuelve.ts
npx dotenv -e .env.local -- npx tsx scripts/warehouse/p1-gate-particion-gobernada.ts
npx dotenv -e .env.local -- npx tsx scripts/warehouse/p2-censo-servibilidad.ts   # sólo lee

# S·5 — la robustez de escritura (el gate necesita kubectl para contar los conflictos)
npx dotenv -e .env.local -- npx tsx scripts/warehouse/s5-0-premisa-conflicto.ts   # con CARA_CREDENTIAL
S5_ESCRITORES=12 npx dotenv -e .env.local -- npx tsx scripts/warehouse/s5-gate-reintento-occ.ts
npx dotenv -e .env.local -- npx tsx scripts/warehouse/s5c-propiedades-de-robustez.ts [--aplicar]

# ¿delega el motor de verdad? (el contador tiene que SUBIR)
kubectl exec deploy/gravitino-server -n catalog -c gravitino-server -- \
  sh -c 'grep -c "Plan table scan" /root/gravitino/logs/gravitino-server.log'

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

# desplegar: SIEMPRE desde el worktree limpio, y PROMOVER
cd C:/tmp/carbon-deploy && git checkout <commit> && npx vercel --prod --yes
# si el alias no se mueve:  npx vercel promote <url>

6 · Estado del entorno (⚠️ leer antes de tocar)

  • SCAN_PLANNING_WORKSPACES=7b500f4d-cd7b-44be-8850-f44eaae192d7 encendido en Vercel Production y en .env.local. Apagarlo devuelve la planificación al cliente y deja de aplicar la política.
  • Migración 20261280_policy_celda.sql APLICADA a producción.
  • Worktree de despliegue en C:/tmp/carbon-deploy (se retira con git worktree remove).
  • ⚠️ La fase P dejó tres tablas de prueba en el inquilino (no hay TABLE_DROP en el editor): p0_part_clientes (particionada), p0_plain_clientes y p0_bucket_clientes. p1-gate las necesita, así que no se barren sin sustituirlas.
  • Spark (carbon-connect + 2 ejecutores + bridge) y el cluster, encendidos.
  • Commits de la sesión: fc0b5c6 (cutover + S·1) → 8b35370 (S·4).