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
- 🏁 El motor DELEGA la planificación en el catálogo (
scan-planning-mode: server). Medido: los planes del catálogo suben uno por consulta. - 🏁 La política de fila viaja en el PLAN y cambia lo que el usuario ve:
3 → 0 filas. - 🏁 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 basta —bucketpoda y deniega igual. - ⛔ La máscara de columna NO es aplicable en el plan (el
selectno recorta el esquema, medido) ⇒column_maskdeniega. - 🏁 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.
- ⚠️ 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.
- 🏁
createTablearreglado (G·1) y verificado en vivo: crea, sin huérfanas, y admite datos porMERGE. - ⛔ El CTAS sigue roto (
stage-create+ hookimportTable). Salida decidida:CREATE+ relleno funciona hoy; lo que se pierde es la atomicidad. - ⚠️ El canary
SCAN_PLANNING_WORKSPACESestá ENCENDIDO para7b500f4d…, el único inquilino con datos. - ⏸️ Spark y el cluster siguen encendidos, y eso cuesta.
1 · El estado por fase
| gate | ||
|---|---|---|
| 🏁 S·0 | createTable (G·1): la cara pregunta al catálogo en vez de compensar a ciegas | w8-create-sin-ctas.ts · w8b-huerfanas-de-create.ts |
| 🏁 S·1 | /scan enrutado y gobernado · exige TABLE_READ_DATA | s1-gate-scan-por-la-cara.ts |
| 🏁 S·2 | la cara declara sus 13 endpoints + scan-planning-mode (canary por ws) | s2-gate-delegacion.ts |
| 🏁 S·2·b | la cara completa el plan (plan-tasks → file-scan-tasks) | 17 tests · scan-plan.ts |
| 🏁 S·3 | la política se inyecta en el plan; lo que no se sabe aplicar deniega | s3-gate-politica-en-el-plan.ts |
| 🏁 S·4 | el vending conmutado: no se vende a quien no pasa por el plan | s4-gate-vending-conmutado.ts |
| 🏁 P | particionar por la columna de política: el techo de S·3 es un requisito — y hay censo | p0-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 tabla | s5-0-premisa-conflicto.ts · s5-gate-reintento-occ.ts · s5c-propiedades-de-robustez.ts |
| ⏳ S·6 | etiquetas (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
- ⭐⭐⭐ 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. - ⭐⭐⭐ Anunciar el endpoint no basta: hay que mandar
scan-planning-mode: server, que no está en elrest-catalog-open-api.yaml— vive en el jar del cliente. - ⭐⭐⭐ 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. - ⭐⭐ 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».
- ⭐⭐ 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 TABLEno pasa porrunQuery(«el motor no pudo planificarla») ⇒ quedó la tabla de pruebaw8_plain_mstadayl.INSERT … VALUESsigue vetado con un motivo caducado (verescrituras-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 declararlas —
s5c-…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-f44eaae192d7encendido 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.sqlAPLICADA a producción. - Worktree de despliegue en
C:/tmp/carbon-deploy(se retira congit worktree remove). - ⚠️ La fase P dejó tres tablas de prueba en el inquilino (no hay
TABLE_DROPen el editor):p0_part_clientes(particionada),p0_plain_clientesyp0_bucket_clientes.p1-gatelas 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).