Published

Sustrato · 07 — la arquitectura entera, tras el viraje

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

Sustrato · 07 — la arquitectura entera, tras el viraje

Fecha de la medición: 2026-08-12 · ⭐ actualizado el 2026-08-13 (§13).
Sucede a sustrato-06
(08-11), 05, 04, 03,
02 y 01, todos congelados: la gracia de
numerarlos es poder ver qué era verdad en qué momento.

Regla, la de siempre: cada hecho lleva el comando que lo demuestra. Lo que no lo
lleva va marcado como pendiente (§10) u opinión (§11).

⚠️ Esta foto se COTEJÓ, no se copió. Los números de abajo se volvieron a medir el
08-12; ninguno se heredó del 06.

Manda sobre cualquier approach o runbook donde discrepen.


⭐ Lo añadido el 08-13 (§13): el brazo PG de la ingesta retirado (−808 líneas)
· y el hallazgo que reordena la ingesta-como-query: hay DOS carriles de un motor al
catálogo
y el canary T·1 midió el que el producto no usa. El del producto —la
puerta— ya está probado en producción.

⭐ Lo que cambió desde el 06, en seis líneas:

  1. 🏁 N·5 CERRADO y verificado en producción por el owner: la coordenada
    direcciona; los nueve nombres duplicados dejan de ser ambiguos.
  2. 🏁 El primer VERBO nuevo en producciónALTER TABLE … ADD COLUMN— y con su
    reconciliación de Index
    , que es la mitad que hace que no mienta.
  3. ⭐⭐⭐ VIRAJE DE PARADIGMA: la ontología no es un grafo, es el espacio de ACCIÓN.
    Tipos, permisos, validaciones y políticas son la ontología; el grafo es cómo se
    representa. El plano ontológico actual se rehace.
  4. ⭐⭐ El principio rector, explícito: se toma prestado el MODELO y se deriva
    en nuestro sustrato. Un tipo que no deriva del catálogo es una declaración; uno
    derivado es un contrato.
  5. La fricción con Iceberg NO existe —no aplica seguridad de celda por diseño, y
    ningún formato lo hace—. La fricción es dónde aplicamos.
  6. ⭐⭐⭐ Los grants de usuario son la pieza que falta en CINCO sitios — verbos,
    políticas, superficie del cliente, Actions y errores. Una ausencia, cinco síntomas.

1 · Qué estamos construyendo, en un párrafo

El cliente compra CONTEXTO, no velocidad. Nuestro producto es conocimiento
consumible por agentes
, y el lakehouse es el sustrato que lo hace posible.

Y desde el 08-12, con el norte afilado:

Los bytes no saben de nadie. El catálogo lo sabe todo y es donde se APLICA. Todo lo
demás son proyecciones que leen por la misma puerta, y por eso heredan la política sin
reimplementarla.

Un agente no consume un espacio de exploración: consume un ESPACIO DE ACCIÓN
ACOTADO.


2 · El mapa, por capas

 ┌── PROYECCIONES ────────────────────────────────────────────────┐
 │ SQL Editor · dashboards · pipelines · notebooks · la ontología │
 │ ⚠️ sólo el SQL Editor pasa hoy por la puerta                   │
 └──────────────────────────────┬─────────────────────────────────┘
        ⭐ JUNCTION — LA PUERTA (lib/compute)
        loadItemForConsumption · runQuery
                  ┌────────────┴────────────┐
        REGISTRO DE MOTORES          LA CARA /api/iceberg
        spark (el único elegible)    Iceberg REST gobernado
        duckdb · karma · mlrunner · pg          │
                  └────────────┬────────────┘
        ┌──────────────────────┴────────────────────┐
        │  CINTURA ESTRECHA: Iceberg + Lakekeeper   │
        └──────────────────────┬────────────────────┘
                     Cloudflare R2 (Parquet)

 En paralelo, EL PLANO DE CONTROL:  PostgreSQL (Index) · OpenFGA · Keycloak · Clerk

3 · Cada capa, medida hoy

3·1 · Bytes — Iceberg + R2

FormatoApache Iceberg · runtime iceberg-spark-runtime-4.1_2.13-1.11.0.jar
AlmacenamientoCloudflare R2, bucket lakehouse, WEUR
Perímetrowarehouse-writer RW · duck-server RO · Lakekeeper · ml-runner ninguna

⚠️ Lo que Iceberg NO da, y es por DISEÑO: seguridad de fila y de columna. No es una carencia suya —Delta y Hudi igual—: la seguridad pertenece al catálogo y al motor. ⇒ migrar de formato no compraría nada en esa materia (§5·3).

3·2 · Catálogo — Lakekeeper

TipoIceberg REST (type=rest), en Railway
Warehouses9 — 1 compartido + 8 por inquilino · 7 de 8 ya se sirven del suyo
Endpoints declarados25
planTableScanNO lo implementa — ninguno de plan / plan-tasks
…/tables/{table}/credentials: el vendado es endpoint de primera clase

Comandos: scripts/warehouse/f0-scan-planning.ts · scripts/warehouse/n2-que-es-w-uuid.ts

3·3 · Cómputo — GKE

carbon-connect-server-0                        1/1  Running    ← Spark 4.1.2
spark-bridge-…-f9xnd                           1/1  Running    ← el puente HTTP→Connect
spark-connect-server-…-exec-1 / exec-2         1/1  Running    ← 2 executors
nodo: 223m CPU (5 %) · 5.132 Mi (38 %)
MotorPapel
sparkel único ELEGIBLE: elegirMotorDeLectura devuelve spark en sus dos ramas
duckdbdisponible, no elegido en ninguna rama
karma · mlrunner · pgvivos, no elegibles para live-SQL

⚠️ La suspensión NO vive en el cluster: clusterOperation.stopped del CR + las réplicas del puente. Un cluster RUNNING con el cómputo a cero da un 503: unconditional drop overload del Gateway, que miente: no es carga, es ausencia de destino.

3·4 · La puerta — Junction

Dos entradas (loadItemForConsumption, runQuery). Resuelve catálogo lógico → binding físico → valida la tenencia tabla por tabla → construye el GovernanceContext → elige motor → despacha.

Y desde hoy, el PLAN del motor puede mandar (PLAN_MANDA): el destino de una escritura se le pregunta al plan en vez de sacarlo del texto, con fallo cerrado.

3·5 · Index — el plano de control, medido

datasets .................. 123        catalogs / schemas ..... 10 / 16
workspaces / members ......  8 / 12
principal_grants .......... 368        🏁 de USUARIO: 12 sujetos (F·2·0, derivados)
object_types / link / action  17 / 4 / 4
objects ................... 27.042     action_type_executions ....... 4

⇒ La retícula existe, tiene la forma correcta (securable_kind+securable_id, con herencia por cadena) y desde el 08-12 tiene sujetos humanos: 12, todos derivados de workspace_members (F·2·0). §5·6 y §12.

3·6 · Producción — medida desde el propio runtime

deployment ...... dpl_7E5oAuA7S87oweMg2GJfweGUvM7c  → app.paladio.io
selección ....... lectura: spark · escritura: spark
interruptores ... planManda: true · auditor: shadow · coordenada: ESTRICTA
                  ⭐ userAuthz: SHADOW  (F·2·0·②, desplegado el 08-12)

USER_AUTHZ=shadow VIVO y leído desde el runtime (/api/diag/motor), no desde la consola de Vercel — la distinción no es pedantería: «que la variable esté puesta no es lo mismo que «llega al runtime», y esa diferencia ya costó una tarde».

⚠️ Y el modo shadow no tiene síntoma visible: la puerta mide y calla, y sólo escribe cuando denegaría. Con los 12 usuarios sembrados, la salida esperada es CERO líneas — que es indistinguible de «el camino nunca se ejecutó». Por eso lo que prueba que decide es el gate g2 (17/17), no el silencio del log.

⚠️ El despliegue sale del working tree local (no hay integración con git) ⇒ se despliega desde un git worktree limpio.

3·7 · ⚠️ El plano ontológico — legacy, y decidido

action_types tiene la forma correcta de una Action de Foundry —contrato, target_object_type_id, can_execute_action_type, versiones, trazas, denial_contexty dos defectos que lo invalidan:

  1. escribe los objetos en PG, sin pasar por la puerta;
  2. tiene su propio modelo de permisos, distinto de principal_grants.

🏁 Se rehace (decisión del owner, §5·2). Y es barato: 4 ejecuciones en toda su historia — es arquitectura sin tráfico.


4 · Carbon SQL y el parser — dónde están

Carbon SQL v1 = Apache Spark SQL 4.1.2, bundle 2026_08. El catálogo de divergencias corta de verdad: blockingForSpark rechaza antes del round-trip.

⚠️ Los 7 interruptores 🔴 siguen sin pinnear en el cluster: casan por ser los defaults de Spark 4.1.2, no por decisión nuestra.

Quién parsea: el motor audita (shadow) y, desde hoy, manda en el destino de escritura. Los escáneres de texto siguen construyendo el plan.

Medido en P·0: AnalyzePlan de Spark Connect no devuelve objetos estructurados —14 métodos, ninguno responde «qué objetos toca»—. Pero el texto del plan lleva más de lo que buscábamos: coordenada segmentada, el privilegio que el motor exige (__required_write_privileges__, compuesto) y el rol destino/fuente. ⇒ el texto del plan ES el contrato, y plan-reader deja de ser un raspador provisional para ser pieza central.


5 · ⭐⭐⭐ EL VIRAJE — lo que cambió de paradigma

5·1 · La ontología es el espacio de ACCIÓN, no de exploración

La ontología de Foundry funciona con agentes por las ACTIONS —operaciones de
escritura tipadas, validadas y con permisos— no por ser un grafo.

Tipos, permisos, validaciones y políticas SON la ontología. El grafo, los traversales y SPARQL son cómo se representa. Ontop y el patrón VKG bajan de rango: correctos para navegar, no son el núcleo.

Y su corolario: entidad ↔ tabla · relación ↔ FK y linaje · clasificación ↔ etiqueta · atributo ↔ columna · acción ↔ verbo gobernado · interfaz ↔ la puerta. La ontología no es un sistema nuevo: es un mapeo.

5·2 · El principio rector: tomar el MODELO, DERIVAR en nuestro sustrato

Un tipo que no deriva del catálogo es una DECLARACIÓN. Un tipo derivado del catálogo
es un CONTRATO.

El plano ontológico legacy copió la forma de Foundry sin plano de datos debajo: tiene el vocabulario correcto y no gobierna nada. La lección no es «no copiar» — es que el modelo se estudia, la derivación se construye, y el producto se evita salvo prueba en contrario.

5·3 · La fricción con Iceberg no existe; la fricción es dónde se aplica

Iceberg no da seguridad de celda por diseño. Lo que sí es un problema estructural:

⛔⛔ Hoy vendamos credencial STS para que el motor lea el Parquet directo. Mientras
eso siga, cualquier política de fila es DECORATIVA.

🏁 Y lo contesta el estándar, no nosotros: UC deshabilita el vending en tablas con filtros o máscaras; Polaris manda el grano fino «a la capa del motor»; Dremio lo declara como arquitectura. ⇒ la decisión es POR TABLA y automática: la existencia de política conmuta el modo de servir esa tabla. Decidido por el owner: se prescinde del vending para tablas y vistas con política.

5·4 · Tres capas, y cada una en su sitio

metadata  →  ETIQUETAS que alimentan la política
catálogo  →  frontera GRUESA (¿puede ver la tabla?) + vending SI no hay política
motor     →  grano FINO: filtro de fila · máscara de columna · proyección

5·5 · Lo que queda parcialmente obsoleto del paradigma anterior

QuéPor qué
«No dependeremos de server-side scan planning» (07-08)Es el mecanismo de la industria para política entre motores. Reabierta
⛔⛔El vendado directo§5·3
🟡«El cómputo es plural y desechable»Sólo se sostiene si la política se aplica en el catálogo
🟡La retícula por securable uno a unoEl modelo maduro concede sobre etiquetas
🟡Karma con planificación propiaQuien planifica en cliente se salta la política
🟡OpenMetadata como fuente de verdadDegradado a productor de etiquetas: la verdad vive en Index

5·6 · ⭐⭐⭐ Una pieza ausente, cinco síntomas

Los grants de usuario (0 de 368) bloquean cinco frentes que parecían independientes:

FrenteSe atasca enY es lo mismo
VerbosDML_VERBS_PERMITIDOSProhíbe globalmente porque no puede autorizar por objeto
Políticas de celdano se pueden probarUna política sin sujetos no tiene a quién aplicarse
Superficie del clienteno existe«Que el cliente defina sus roles» es administrar grants
Actionstienen su propio permisoSe fabricó porque el bueno no llegaba a los usuarios
Errores«no existe» vs «no puedes»La forma del error es una decisión de autorización

Cuando el modelo de autorización no alcanza, cada superficie se fabrica el suyo.


6 · Lo que está PROBADO — con su gate

Vigentes del 06: Spark arranca · lee por la cara · vending acotado · 8/8 de aislamiento · W0-W5 · C0-C5 · P·0-P·6 · el paradigma de nombres entero (N·3-N·6).

Lo añadido el 08-12:

QuéEvidencia
🏁N·5 · la coordenada DIRECCIONA — cuatro defectos encadenadosn4-la-coordenada-desambigua.ts · verificado en producción por el owner
🏁ALTER TABLE … ADD COLUMN en producción, con reconciliación de Indexp5-canary-alter-add-column.ts → 4/4
🏁El plan del motor MANDA en el destino, con fallo cerradop3-canary-plan-manda.ts → 4/4
🏁El privilegio compuesto ya no se corta (INSERT,DELETE)corpus + 36 fixtures del motor real
🏁F·0 · Lakekeeper NO planifica en servidorf0-scan-planning.ts
🏁F·1 · el vendado, contestado por el estándarUC · Polaris · Dremio · Snowflake
🏁Sesión de planificación — rompe la circularidad de orden9 tests, incluido lo que NO lleva
🏁El copiado del editor en JSON, sin romperse con BIGINTcontrol negativo
768 tests (50 ficheros) · tsc sin errores nuevos · 35 commits desde el 06
check:sin-espejo 🏁 · check:carbon-sql-contract ✅
check:plan-diferencial → 34 comparados · 13 discrepancias (0 nuevas)
                        · empates que PIERDEN coordenada: 0

7 · Lo que está ABIERTO

Bloqueantes del norte

🏁 F·2·0 · los grants de USUARIOHECHO 08-12 (§12): 12 sujetos derivados · aplicador cableado y apagado (USER_AUTHZ=off) ⇒ queda encenderlo
🏁 Future grants por esquemaNO HACEN FALTA (§12): la herencia por la cadena ya lo es. Medido sobre los 123 datasets
Política de celdaNo hay modelo, ni etiquetas, ni aplicador
Un solo modelo de tipos y permisosHoy hay dos planos que no se hablan (§3·7)

Deuda del plano de ESCRITURA (medida el 08-12)

⛔⛔Una escritura toca UNA tabla (writeTarget?: string) — y el catálogo ya declara POST …/transactions/commit, sin estrenar
Sin reintento ante conflicto de commit (Iceberg es optimista: el perdedor debe reintentar)
Sin nivel de aislamiento declarado (serializable vs snapshot)
⚠️Fallo parcial con reconciliación manual (ControlPlaneUnrecordedError)
⚠️Una sentencia por ejecución ⇒ sin transacción multi-sentencia

Deuda de sustrato

⚠️ 4 fallos preexistentes en lib/lakehouse · el inquilino con 123 tablas sin mudar · BRIDGE_JWT_SECRET simétrico · el puente no escala (1 réplica) · OpenFGA inerte · los 7 interruptores 🔴 sin pinnear · CPUS_ALL_REGIONS = 12 · ⛔ un BIGINT pierde precisión al llegar al cliente · el cluster se llama trino-compute-clone.


8 · Las prioridades — por DAÑO

P0 · lo que YA cobra intereses
     1. ⭐ ENCENDER `USER_AUTHZ`         ← los grants existen y NO gobiernan (§12)
     2. el BIGINT del transporte        ← corrompe dato de usuario, en silencio
     3. reintento ante conflicto        ← con agentes escribiendo, se nota el día 1

P1 · el plano gobernado
     4. modelo de políticas (F·2)  ·  etiquetas desde la ingesta (F·3)
     5. el aplicador de celda (F·4)     ← en el MOTOR, construido para mudarse
     6. commit multi-tabla              ← la capacidad ya está comprada

P2 · el plano ontológico, REHECHO
     7. un modelo de tipos · una retícula · la Action escribiendo por la puerta
     8. y sólo entonces el grafo, como proyección

P3 · sustrato  ⛔ bajo el techo de CPUS_ALL_REGIONS = 12

9 · Las reglas que la práctica dejó

Las 45 anteriores siguen vigentes. Lo que añadió el 08-12:

  1. ⭐⭐⭐ UN MODELO SE COPIA; UNA IMPLEMENTACIÓN SIN SU SUSTRATO, NO. Copiar la forma de Foundry sin plano de datos produjo un plano con el vocabulario correcto que no gobierna nada. La pregunta que lo evita: ¿de qué dato NUESTRO deriva esto?
  2. ⭐⭐ CUANDO EL MODELO DE AUTORIZACIÓN NO ALCANZA, CADA SUPERFICIE SE FABRICA EL SUYO. can_execute_action_type no es una rareza: es el síntoma. Una pieza ausente explicaba cinco síntomas que parecían independientes.
  3. ⭐⭐ UN MENSAJE SÓLO ES HONESTO SI SU PRIMERA LÍNEA YA ES LA VERDAD. Arreglar la cola del rechazo y dejar el titular equivocado no es arreglarlo: quien lo lee se para en la primera frase.
  4. ⭐⭐ UN GUARDIÁN CON CONDICIÓN DE RETIRADA HAY QUE RELEERLA — Y DOS EN UNA SESIÓN YA NO ES CASUALIDAD. ALTER e INSERT bloqueados por motivos caducados. Cada entrada de una lista de prohibiciones debería llevar su condición de retirada y la fecha en que se comprobó.
  5. ⭐⭐ UN VERBO SIN SU RECONCILIACIÓN NO ES UN VERBO NUEVO: ES UNA MENTIRA NUEVA. ADD COLUMN sin actualizar Index dejaría Iceberg con 8 columnas e Index diciendo 7, sin que nada falle.
  6. ⭐⭐ EL CANARY VE LO QUE 768 TESTS NO. Los dos gates que bloqueaban el ALTER —una tercera lista de verbos y la cualificación del destino— salieron en vivo, con todos los tests en verde.
  7. UNA SONDA QUE MEZCLA DOS CAUSAS NO MIDE: OPINA. La de F·0 confundió «ruta inexistente» con «tabla no encontrada»; la de P·1 pintó de READ lo que el motor no decía. El veredicto se apoya en lo autoritativo, y se dice cuál es.
  8. PREGUNTARLE AL ESTÁNDAR ES MÁS BARATO QUE DECIDIR. F·1 se planteó como una decisión binaria y global; la industria ya la tenía resuelta por tabla y automática — mejor y más barata que la que íbamos a tomar.
  9. ⭐⭐⭐ UNA SONDA QUE NO LLEGA AL CASO NO LO APRUEBA: LO IGNORA — Y SE LEE IGUAL. Los dos gates de F·2·0 salieron verdes sin haber medido nunca al viewer, porque el único inquilino con datasets sólo tiene owner y admin. Un verde con cobertura parcial es indistinguible de un verde completo, así que el gate tiene que decir sobre QUÉ pobló — y un veredicto con cero comprobaciones no es un permiso: es que no había nada que comprobar.
  10. ⭐⭐ ANTES DE COPIAR UN MECANISMO, COMPROBAR SI SU PROBLEMA ES EL NUESTRO. Los future grants de Snowflake existen porque allí un grant de esquema no alcanza a las tablas. El nuestro hereda por la cadena ⇒ la pieza entera sobraba. Preguntarle al estándar es barato (53); comprobar si su problema es el tuyo sale gratis y ahorra más.
  11. ⭐⭐ UNA DERIVACIÓN QUE NO RETIRA NO ES UNA DERIVACIÓN: ES UN VOLCADO. La mitad que se olvida de una proyección es la baja — degradar a alguien tiene que QUITARLE el rol viejo. Y por eso se cablea donde el origen cambia, no en un script que hay que acordarse de correr.
  12. UNA COLUMNA CON NOMBRE DE DUEÑO NO ES EL DUEÑO. datasets.created_by mezcla usuarios y servicios: dice quién lo tecleó. Derivar autorización de ella habría metido servicios como sujetos humanos.

10 · Los documentos que importan

DocPara qué
estela foto completa. Manda sobre todo lo demás
⭐⭐⭐ index-gobernanza-mapa.mdla DEFINICIÓN DE HECHO: dónde estamos concepto a concepto frente al estándar · los 5 planos de autoridad · qué hace cada jugador
⭐⭐⭐ plano-gobernado-approach.mdel approach VIVO del plano gobernado · F·0-F·7 · bitácora por sesión
⭐⭐ escrituras-sustrato-debilidades.mdpor qué un verbo cuesta tanto, y qué le falta a la escritura
⭐⭐ parser-al-motor-spec.md · p1-inventario-verbos.mdel parser al motor · las 28 formas medidas
errores-gobernados-approach.mdcondición + SQLSTATE + parámetros · y por qué toca a OpenFGA
⭐⭐ carbon-sql-contract-approach.mdel contrato y los corpus
🧊 06 · 05 · 04 · 03 · 02 · 01congelados

Los gates que se corren

# en frío — sin red, sin motor
npm run check:sin-espejo
npm run check:carbon-sql-contract
npm run check:plan-diferencial
npx vitest run lib/compute lib/warehouse lib/governance     # 768

# contra el motor (kubectl exec al puente)
kubectl exec -i -n spark <pod> -- python3 - < scripts/bridge/p1-inventario-verbos.py

# por la puerta (necesitan .env.local)
npx dotenv -e .env.local -- npx tsx scripts/governance/p3-canary-plan-manda.ts
npx dotenv -e .env.local -- npx tsx scripts/governance/p5-canary-alter-add-column.ts
npx dotenv -e .env.local -- npx tsx scripts/warehouse/f0-scan-planning.ts
npx tsx scripts/warehouse/n4-la-coordenada-desambigua.ts

# el runtime de producción, que dice lo que VE
curl -H "x-diag-key: $SECRET" https://app.paladio.io/api/diag/motor

11 · ⭐ Lo primero que hay que hacer en la próxima sesión

🏁 F·2·0 está HECHA y shadow está VIVO en producción (§12). Lo siguiente:

① LEER la sombra — a quién denegaría. Y con los 12 sembrados, lo esperado es NADA:
   por eso hay que provocar el caso, no esperar a que aparezca
② el CANARY del camino entero — HTTP, no la función que la puerta llama
③ y sólo entonces `enforce`, con su control negativo en vivo
④ ⛔ MEDIR SI VENDAMOS STS DE VERDAD — bloquea el paso 5 entero (mapa §8·7)

⚠️ Hoy los grants existen y todavía no impiden nada: shadow mide y calla. enforce es una variable y un despliegue, y es lo que separa «modelado» de «gobernado».


12 · 🏁 F·2·0 — LOS GRANTS DE USUARIO (2026-08-12, sesión 2)

Detalle completo: plano-gobernado-approach.md §7·octies.

QuéEvidencia
🏁⭐⭐ 12 sujetos humanos en la retícula, ninguno declarado — proyección de workspace_membersg1-derivar-grants-de-usuario.ts --apply24/24
🏁⭐⭐ La separación de deberes, en vivo: sólo owner concede; admin administra el dato y no la retículag2-aplicador-de-usuario.ts17/17
🏁El aplicador para HUMANOS (user-authz.ts), cableado en lectura, escritura y CREATE · USER_AUTHZ off/shadow/enforcetsc · 788 tests
🏁⭐⭐ Los future grants NO hacen falta: la herencia por la cadena ya lo esg3-future-grants.ts5/5 sobre los 123 datasets
🏁La proyección se rehace donde la membresía cambia (3 handlers de Clerk + el alta)clerk-webhook/route.ts · tenant-provisioning.ts ③·bis

Y tres cosas que sólo se supieron al medir:

  1. datasets.created_by no es «el dueño»: es «quién lo tecleó» — 14 datasets lo tienen apuntando a un SERVICIO. Por eso la propiedad sale del primer corte con motivo, no por olvido.
  2. ⭐⭐⭐ El guardián del CREATE llevaba su condición de retirada escrita y caducótercera vez (regla 49).
  3. ⚠️ Dos sondas verdes que no habían medido el caso viewer — el único inquilino con datos sólo tiene owner y admin. Ver la regla 54.

13·1 · 🏁 El brazo PG de la ingesta, RETIRADO

dataset-writer.ts llevaba dentro un motor de escritura completo contra dataset_rowsPostgresBackend (INSERT multi-fila · UPSERT · COPY FROM STDIN con tabla temporal) y writeDatasetRowsPg, el control-plane que compartía con la puerta—. La tabla no existe desde el 2026-07-31.

−808 líneas · 6 ficheros · check:dataset-rows-seam  9 → 0 en dataset-writer.ts
cae con él el brazo `postgres` de `write()` en junction.ts, que delegaba aquí

La clasificación previa era incompleta, y ahí está la lección: release-agosto.md los había catalogado como «ES EL TUBO ⇒ NADA, se borra con la Fase A». Tenían DOS dueños — la puerta compartía writeDatasetRowsPg, y la puerta no muere con la Fase A. La pregunta «¿de quién es este rescoldo?» sólo sirve si se contesta buscando llamantes, no leyendo el nombre del fichero.

13·2 · ⭐⭐⭐ HAY DOS CARRILES DE UN MOTOR AL CATÁLOGO, y sólo uno es el del producto

① POR LA PUERTA     runQuery → Junction → Spark → la cara → Lakekeeper
   la puerta le da al motor  namespace FÍSICO + nombre LÓGICO   junction.ts:1918
   🏁 el carril del producto: W0-W5 · P·3 · P·5 · N·5, todo medido en producción

② MOTOR EXTERNO     Spark configurado a mano contra /api/iceberg
   coordenada LÓGICA de punta a punta
   ⛔ el que midió el canary T·1 de la ingesta — y donde estaba su 404

El canary de la ingesta-como-query montó el carril ② para probar algo que la puerta ya hacía por el ①. El approach había escrito «el sustrato ya está puesto; lo que falta es dejar de rodearlo»… y lo rodeó.

13·3 · 🏁 Lo que el carril ② tenía roto, medido y reparado

scripts/warehouse/t10-resolucion-coordenada.ts:

materializeTable    estampa   iceberg_namespace = el FÍSICO (opaco)
physicalFromLogical buscaba   .eq('iceberg_namespace', el que pide el MOTOR)

123 datasets · 95 con lógica == física (el espejo) · ⛔ 8 DIVERGEN
8/8 NO RESUELVEN por su nombre lógico · 7 son `sql_editor.create_table`
        ↓ T·1·0
0/8 invisibles · control negativo 5/5
QuéCriterio
🏁 T·1·0physicalFromLogical resuelve por (workspace, schema_id, name) — la MISMA clave con la que materializeTable comprueba la colisión. La ubicación queda de respaldoEl que CREA y el que RESUELVE usan la misma clave
🏁 T·1·acompensateIfPhantom — compensa el fallo POSTERIOR al materialise (el CTAS atómico son tres peticiones)La duda no borra: se pregunta al catálogo, no se infiere del !res.ok
🏁 T·1·breconcileDropInIndex — soltar la tabla la suelta de IndexNo acotado por service; hoy es el único sitio posible: DROP TABLE no es verbo de la puerta

⚠️ Y por qué nadie lo había notado: el carril ① manda el namespace físico ya resuelto, así que nunca ejerce la resolución por coordenada lógica. p4_fx_a es a la vez una de las 8 invisibles y la fixture contra la que P·3 y P·5 pasan 4/4.

13·4 · Lo que queda abierto de aquí

🔄T·1·e — el canary de la ingesta, reescrito POR LA PUERTA (runQuery, como P·3/P·5): sin cluster, sin preview, sin bypass
La pregunta real de la Fase A deja de ser «¿sirve la cara el CTAS atómico?» y pasa a ser «¿puede runQuery nombrar una fuente externa en el SELECT — que es la Fase B
DatasetWriter.deleteAll() ya no suelta la tabla Iceberg: deja tabla y bytes huérfanos. Gemelo de T·1·b por el lado del ciclo de vida
⚠️datasets.storage_backend se escribe 'postgres' mientras los bytes van a R2
⚠️El .delete() mudo de handleAbortTransaction — el último acceso ejecutable a la tabla borrada

13·5 · ⭐⭐⭐ El estándar, consultado — y proyectado

Databricks y Snowflake, sobre la ingesta. El modelo es el mismo en los dos, y el detalle está en ingesta-como-query-approach.md §4·sexies:

① la FUENTE es un OBJETO del catálogo, con grants
   UC: CONNECTION (securable) + FOREIGN CATALOG · privilegios USE CONNECTION / CREATE FOREIGN CATALOG
   Snowflake: CATALOG INTEGRATION · EXTERNAL VOLUME · SECRET · CATALOG-LINKED DATABASE
② la CREDENCIAL vive en el objeto y se gobierna por privilegio — no viaja en el plan
③ la INGESTA es una SENTENCIA sobre ese objeto
Qué nos hace
Confirmala ingesta como sentencia es el modelo del sector — y entra por el catálogo gobernado, que es el viraje de §13·2
⭐⭐⭐ Corrigefederar ≠ ingerir: ninguno hace CDC con un CTAS. ⇒ del tubo muere la mitad de snapshot; la de CDC sigue siendo un conector (lo que lib/workers/cdc ya es)
🏁 Cierra T·3el secreto no necesita un broker: es un securable que el motor consume por referencia. La retícula ya la tenemos; falta el objeto
Deja obsoletola fuente como fila cifrada en user_credentials · y el destino elegido en el ALTA (el destino lo pone la sentencia — de ahí las 17 de 26 fuentes en un main.default que nadie eligió)
🟡 Explicapor qué el linaje no llega a la fuente: no es un nodo del catálogo. En UC lo es, y por eso el linaje sale gratis

13·6 · 🏁 La MALLA de gobernanza, censada — y dos cosas que no eran lo que parecían

scripts/governance/m0-censo-de-la-malla.ts · detalle en index-gobernanza-mapa.md §4·bis.

⛔ primitivas que NO EXISTEN (construir) ..... 11
🟡 primitivas VACÍAS (poblar / cablear) ......  6
Hallazgo
⭐⭐⭐La política ya está construida. policies + policy_attachments existen (mig. 20261266), con invariante en trigger y resolución con herencia probada — 0 filas, 0 consumidores. Falta extender el tipo (row_filter, column_mask): un CHECK y un zod, no una entidad
⭐⭐⭐Cinco vocabularios de «etiqueta» y ninguno gobierna: object_tags (10, sobre la copia materializada que se rehace), 3 de MLflow, 2 del canvas, 1 de Gmail. Es el diagnóstico de la mig. 20261266 sobre políticas, repetido con etiquetas y sin contar
⭐⭐La conexión y el secreto no son securables: user_credentials (26) y api_connections (1), dos superficies, ninguna concedible ⇒ «¿quién puede ingerir de dónde?» no es hoy una pregunta contestable
SHARING no estaba ni como concepto en el mapa: 18 conceptos sobre cómo entra y cómo se lee, ninguno sobre cómo sale gobernado. Se añaden como 19-21

El orden del plano se reordena solo: la política baja (ya está construida) y la conexión sube (el estándar la puso en el centro y no la teníamos ni como concepto).

13·7 · ⭐⭐ Apache Atlas — evaluado, y la respuesta es el modelo sí, el sistema no

Detalle en index-gobernanza-mapa.md §5·bis.

⭐⭐⭐ Lo que señala bienEl mapa no tenía ni un concepto de linaje, y es donde viven nuestros activos: cambian de estado por ETL·ELT·ML·ad-hoc. Se añaden como conceptos 22 (el PROCESO como entidad) y 23 (propagación de clasificación por la arista)
⛔⛔ Por qué no se absorbeJanusGraph+HBase+Solr+Kafka+ZK bajo CPUS_ALL_REGIONS=12 con el nodo al 82 % · no aplica nada (aplica Ranger, sobre Hive/Impala) · la integración tag→policy es de Cloudera, no del OSS · y sería un segundo modelo de tipos y de permisos, el defecto exacto por el que el plano ontológico se rehace
⭐⭐ El dato que reordenaPolaris 1.5.0 (mayo 2026) admite Ranger como autorizador externo del catálogo ⇒ si algún día se quiere Ranger, el camino es el catálogo, no Atlas
🏁 Y lo que ya teníamosmergeUpstreamProvenance (junction.ts:350) ya propaga por la arista en cada escritura por la puerta —counterparty, edcTransferId, agreementId, upstreamOrigins—. Le faltan etiquetas que propagar, vivir en el objeto y no en la txn, y no callar cuando una fuente no resuelve

13·8 · ⭐⭐⭐ ABSORBER vs RECONSTRUIR — y no era una disyuntiva

Detalle en index-gobernanza-mapa.md §5·quater.

Se absorbe lo que DESCRIBE. Se deriva lo que DECIDE. Son dos planos, no dos
opciones — y el mapa ya lo tenía escrito sin sacarle la consecuencia: «Atlas /
OpenMetadata / DataHub aplican en NINGÚN sitio: describen»
. Eso no era una pega:
era la señal de que se pueden absorber sin riesgo de retícula doble.

Spark ──spark.extraListeners──▶ OpenLineage ──▶ OpenMetadata
  el linaje NO se construye: lo EMITE el motor, y a nivel de COLUMNA
⭐⭐⭐ OpenLineageEl productor. Es una spec, no un producto: un jar y config en el CR de Spark. No consume CPUS_ALL_REGIONS, y sus eventos sobreviven a cualquier consumidor
⭐⭐ OpenMetadataEl consumidor: catálogo, discovery, tags/glossary, contratos, y entidades de primera clase —tabla · topic · container · pipeline · ML model—. Consume OpenLineage de serie
🏁 Y ya lo teníamosLa VM carbon-openmetadata existe… y hoy está CAÍDA (22 ⛔ · 8585 ⛔). La absorción empieza por reencender lo que ya se pagó
Lo que se queda apagadoLas policies y roles de OM — igual que el OpenFGA de Lakekeeper. Encenderlos sería la segunda retícula
⚠️ Lo que NO resuelveSigue sin aplicar nada · el linaje sólo cubre lo que pasa por Spark (la ingesta actual no emite) · y hay un segundo almacén que operar

Atlas queda descartado por entero (§13·7) y su papel lo cubren dos piezas más ligeras, una de ellas ya desplegada. Y el criterio para no equivocarse de plano: si apagar esa pieza deja pasar una operación que antes se rechazaba, no era descriptiva.

13·9 · 🏁 L·0 y L·1 — el motor YA EMITE su linaje (2026-08-13)

infra/gke/l1-openlineage-gate.yaml · Job desechable, sólo lectura, sin tocar el CR carbon-connect (cablearlo allí reinicia Spark Connect y con él la sesión caliente).

GATE 1  el listener CARGA con Spark 4.1.2-stackable26.7.0   ← compatibilidad NO documentada
GATE 3  lectura de carbon.main.default.bronze_clientes OK   ← por LA CARA
GATE 4  eventos = 16 · con inputs Y outputs = 6
GATE 5  outputs con columnLineage = 9
GATE FINAL  VERDE

El coste de la absorción es una coordenada Maven y tres --confio.openlineage:openlineage-spark_2.13:1.52.0 + spark.extraListeners—, el mismo mecanismo que ya trajo Iceberg y el driver JDBC.

⭐⭐⭐ Y la identidad viene RESUELTA. Nombra la tabla por su ubicación (s3://lakehouse :: warehouse/019f1a5f-…) y pone la coordenada en el facet symlinks:

{"namespace": "https://app.paladio.io/api/iceberg", "name": "main.default.bronze_clientes"}

⇒ namespace = la cara, name = la coordenada lógica. No hay traductor que escribir. Y la forma es nuestro propio paradigma de nombres: el estándar llegó por su cuenta a nombre ≠ ubicación. ⚠️ Con su trampa gemela: quien lea name en vez de symlinks repetirá T·1·0, resolviendo por ubicación y perdiendo el nombre.

Un regalo que no buscábamos: cada transformación de columna trae "masking": false. El estándar ya modela si una transformación enmascara ⇒ cuando exista la máscara de columna, el linaje sabrá por dónde viajó un dato enmascarado.

Y la VM de OpenMetadata NO EXISTE — no está caída: no está en ninguno de los tres proyectos. Pero infra/openmetadata/ + su runbook están versionados ⇒ L·2 no es investigar, es reejecutar, con una corrección: OM veía el Warehouse por Trino, y Trino salió. La ruta nueva es mejor —linaje por OpenLineage, identidad por Index— y no necesita motor de perfilado.

13·10 · La topología: OM y OL en el cluster, con el presupuesto medido

CPUS_ALL_REGIONS   límite 12 · uso 4   ⇒ quedan 8   (cabe un n2-standard-4 más)
nodo actual        CPU 88 % · memoria 87 %          ⇒ ⛔ no cabe AHÍ

Node pool aparte con taint (Spark no puede sufrir desalojos por esto) · la BD de OM en la base gestionada de europe-west1 que ya hay que crear para el catálogo · OpenSearch en el cluster con PVC, porque un índice es reconstruible.

⭐⭐⭐ El argumento que lo decide no es la elegancia: lo que se perdió hoy no fue una
máquina, fue un DESPLIEGUE NO DECLARADO.
Un manifiesto se vuelve a aplicar; un
scp + docker compose up sólo se vuelve a ejecutar, y sólo si alguien se acuerda.

13·11 · ⭐⭐⭐ La capa agéntica NO se construye sobre OM

Detalle en index-gobernanza-mapa.md §5·sexies.

El espacio de ACCIÓN no se puede proyectar. Es (lo que existe) ∩ (lo que ESTE
principal puede hacer)
: lo primero lo sabe OM, lo segundo sólo lo sabe Index. Un
agente que pregunte a OM recibe lo que OM sabe, no lo que puede tocar — y para
filtrarlo, OM necesitaría nuestra retícula dentro, que es la segunda retícula.

Una sola superficie agéntica, la nuestra. El agente no habla con OM: hablamos nosotros. Su MCP no se expone — autoriza con sus roles, así que exponerlo lo convertiría en decisor (regla 66).

Y lo que se perdería es la CUÑA: la tesis es que el cliente compra contexto y que la ventaja es el catálogo NATIVO. Con el contexto servido por OM, la ontología derivaría de su modelo y el catálogo dejaría de ser nativo. La incomodidad del owner no era arquitectónica: era de posicionamiento.

🧪 La prueba del algodón: si apagar OM nos deja sin contexto, lo pusimos en el sitio equivocado. Lo que OM produce aterriza en Index como propuesta, y la verdad vive en Index. Si OM desaparece —como desapareció su VM— perdemos la superficie de curación y el perfilador, no el contexto.

El orden, y no es indiferente: primero el modelo en Index (etiquetas · contrato · proceso como securable), después OM como sitio donde se curan y enriquecen. Al revés, el modelo lo dictaría su esquema.

13·12 · ⭐⭐⭐ Activos heterogéneos — y el punto de aplicación que no tenemos

Detalle en index-gobernanza-mapa.md §5·septies.

La alternativa más completa ya está en el cluster. Gravitino gobierna CATALOG · SCHEMA · TABLE · COLUMN · FILESET · TOPIC · ROLE · METALAKE + catálogo de modelos, con RBAC unificado entre catálogos (1.2.0). Es literalmente la lista del owner. UC OSS es el modelo (todo activo es securable, 3 niveles) pero su servidor está en 0.1/sandbox y sin ABAC. Polaris no cubre modelos ni tópicos; Atlas y Egeria caen por el mismo criterio (§13·7).

Y hacia dónde va el estándar: UC lleva sus ABAC Grant Policies a modelos y anuncia servicios MCP y AGENTES como securables. Nuestro PrincipalKind ya admite 'agent' — y sigue sin un solo sujeto de ese tipo.

La consecuencia que no estaba vista:

tabla Iceberg    ⇒ la cara + la puerta     🏁
topic · fileset · modelo   ⇒ NINGUNO       ⛔

Nuestro punto de aplicación cubre un tipo de activo. Gobernar los demás no es añadir filas a securable_kind: es construir tres puertas nuevas o usar las de Gravitino.

El primer activo NO-TABLA que se quiera gobernar FUERZA la rama A/B del §8·quinquies·②. Recomendación: A para tablas (nuestro punto de aplicación ya funciona) y diseñar B para el resto —Gravitino aplica, Index proyecta—, que es el mismo patrón de workspace_members → grants, con otro destino.

13·13 · ⭐⭐⭐ La frontera Index ↔ Gravitino — y Gravitino NO APLICA

Detalle y diagrama en index-gobernanza-mapa.md §5·octies.

De su propia doc de control de acceso (0.9.0-incubating):

«Gravitino doesn't support metadata authentication… won't check the privileges when
Gravitino receives the requests.»

Administra quién tiene qué y DELEGA la aplicación (push-down a Ranger o al sistema de abajo). El puesto de «el que dice NO» sigue vacante, y hoy lo ocupamos nosotros. ⚠️ Reserva: en 1.2.0 medimos @AuthorizationExpression en planTableScan ⇒ su postura cambió entre versiones; el alcance no — aplicará sobre operaciones de su API, nunca sobre los bytes. [medir en 1.2+]

Qué de Index quedaría opacado: la taxonomía (sí, solape real) y el vocabulario de la retícula (no su aplicación). NO: el ledger, access_events (registra decisiones de NUESTRA puerta), las políticas, y el lazo con el producto — project_files, tiers, la coordenada de la UI—. Index no es un catálogo: es un plano de control de PRODUCTO.

Y Gravitino consume OpenLineage: framework pluggable, sink a Marquez y plugin de Spark que traduce los identificadores a los suyos, con linaje de columna entre catálogos (fileset · Iceberg · Hudi · Paimon · Hive · Model). Resuelve de serie la traducción que L·1 dejó vista. ⚠️ Y abre un solape que hay que decidir: dos sinks de linaje (Gravitino y OM) sobre los mismos eventos. [decidir cuál es autoritativo]

La frontera, en cuatro verbos: INDEX define · LA PUERTA aplica · EL CATÁLOGO registra · OM describe.

13·14 · 🏁 El IRC de Gravitino contra Lakekeeper — MEDIDO (2026-08-13)

GET /iceberg/v1/config desde dentro del cluster · detalle en index-gobernanza-mapa.md §5·octies·⑥.

Lakekeeper (25)Gravitino IRC (24)
Registro + CAS
credentials (vending)medido — era el riesgo: hoy Spark lee con vended-credentials
⭐⭐ tables/{t}/planla diferencia real
namespaces/{ns}/register✅ ⇒ migrar es re-registrar
transactions/commit✅ declaradono lo declara
Warehouses por inquilino · soft-delete 7d · OIDC⇒ traducir · ⏳ · ⏳

El IRC de Gravitino es un Lakekeeper con /plan y sin commit multi-tabla. Un cambio en cada dirección, no una mejora limpia. ⚠️ Y el commit multi-tabla está listado en §7 como «la capacidad ya está comprada» contra la deuda «una escritura toca UNA tabla»: migrar hoy la desharía. [medir si 1.2+ lo declara]

⚠️⚠️ Y la confusión que hay que cortar: lo desplegado (G·1) es el IRC —Iceberg y nada más—. Tópicos, filesets, modelos, RBAC unificado y linaje son del servidor COMPLETO, que no está desplegado. Es la regla 8·ter aplicada a nosotros: no contar como propia una capacidad que no está encendida.

13·15 · 🏁 DECIDIDO: el catálogo pasa a ser el IRC de Gravitino

Entregable: irc-cutover-approach.md. Decisión del owner (08-13). Lo medido que la sostiene:

Lakekeeper      25 endpoints · ⛔ sin /plan · 222 ms desde el cómputo
Gravitino IRC   24 endpoints · ✅ /plan · ✅ credentials · ⛔ transactions/commit · 5,2 ms
prefijo         w_aaaa → 404 NoSuchCatalogException  ⇒ ⭐ SÍ enruta por catálogo configurado
⭐⭐ Se gana/plan · 40× de latencia · el catálogo dentro del cluster · la puerta al metalake
Se pierdeEl commit multi-tabla —declarado y sin estrenar, pero es la pieza que cierra la deuda «una escritura toca UNA tabla»— y el alta de warehouse por API (con el IRC standalone es config + redespliegue)
No se tocaLa gobernanza: la cara sigue delante, Index sigue siendo la única retícula

Y una simplificación grande: el IRC no necesita OIDC propio. Vive detrás de la cara en ClusterIP sin ruta externa, y el único que le habla es la cara. El OIDC de Keycloak no hay que rehacerlo: hay que no necesitarlo — con la condición de que ningún motor pueda apuntarle directamente.

C·0 manda sobre todo lo demás: hoy el IRC corre sobre SQLite en un emptyDir. Un catálogo es donde se serializa el commitsin base durable no hay cutover.

13·16 · 🏁 C·0·a — el IRC sobre Postgres, y el estado SOBREVIVE al reinicio

infra/gke/c0a-irc-postgres.yaml · detalle en irc-cutover-approach.md §6·bis.

driver:  la imagen sólo traía sqlite-jdbc  ⇒ initContainer con LA MISMA imagen copia
         libs/ (174 jars) + otro baja postgresql-42.7.4.jar  ⇒ sin forkear imagen
gate:    crear ns + tabla → MATAR el pod → los tres siguen, con el MISMO table-uuid
         y el MISMO metadata-location. Limpiado después (204/204, namespaces []).

El registro es durable. Queda C·0·b: la base gestionada en europe-west1 (sqladmin.googleapis.com no está habilitada en trino-k8s) — y es cambiar una URL.

⚠️ Dos trampas medidas:curl: (23) ERROR on write era permisos (uid 100 sobre volumen de root), no red. ② HTTP 000 con el pod Ready y las labels correctas: el Service apunta a targetPort: http y al reescribir el Deployment perdí el NOMBRE del puerto ⇒ EndpointSlice con PORTS <unset>. Y kubectl get endpoints miente en 1.33+ (devuelve <none>): lo autoritativo es get endpointslices.

13·17 · 🏁 C·0·b — carbon-catalog, la base del catálogo, EN PIE

carbon-catalog · POSTGRES_16 · europe-west1 · db-f1-micro · 10 GB SSD · backups 03:00
⭐ IP PRIVADA 10.99.0.3 (VPC peering sobre `default`) ⇒ sin IP pública, sin Auth Proxy
   y sin credencial de servicio: el pod habla por la VPC
coste ~10-15 €/mes

El gate, contra la base gestionada: crear ns + tabla → matar el pod → los tres sobreviven con el MISMO table-uuid y el MISMO metadata-location. Limpiado (204/204).

⚠️ Y una corrección de número: loadTable real da 64-86 ms frente a los ~222 ms de Lakekeeper ⇒ ~3×, no 40×. El 40× era la conexión TCP al catálogo (1,3 vs 46 ms) y sigue siendo cierto; pero un loadTable lee el metadata.json de R2, y esos ~66 ms no se mueven. Publicar el 40× como la mejora de la operación sería vender el número que mide la parte, no el todo.

Y lo que esta base gana cuando Index se mude a ella (irc-cutover-approach.md §8): ① cierra 4·4 —el commit y su asiento en UNA transacción, la única de las siete debilidades de escritura que la absorción puede cerrar, y se cierra con topología, no con código—; ② la gobernanza deja de cruzar el Atlántico (119 ms → 1-3 ms, y la cara pregunta en CADA petición); ③ el JOIN vuelve a ser un JOIN: catálogo ∩ retícula calculado en SQL, que es literalmente el espacio de acción de un agente. ⚠️ El precio no son los 6,5 MB: son las consultas que cruzan Index con las tablas de producto. [medir]

13·18 · 🏁 C·1 — los 9 catálogos-de-inquilino, declarados

⚠️ El paradigma no cambia —sigue siendo un inquilino → un espacio aislado—; cambia la pieza y su nombre: warehouse (Lakekeeper) → catalog (Gravitino). ⭐ El prefijo REST sigue siendo w_<hex> ⇒ la cara no cambia una línea.

Pero «catálogo» ya significa otra cosa aquí: catalogs en Index (10) son main, karma… lo que el usuario ve en catalog.schema.table. Dos niveles distintos ⇒ en los docs, catálogo-de-inquilino vs catálogo lógico.

Y al medir no eran 9:

Lakekeeper  11 warehouses  (8 prod · 2 test · 1 lakehouse)     Index  8 tenant_warehouses
⛔⛔ el inquilino con TODO el dato (w_7b500f4d…) tiene dedicated=false
    ⇒ la cara le sirve EL COMPARTIDO, y su warehouse propio está activo SIN MOTIVO
⚠️ dos inquilinos tienen su key-prefix en `test/`, no en `prod/` (deriva del perímetro)

Declarados los 9 = 8 + el compartido, cada uno con su key-prefix real —los test/ incluidos: la deriva se limpia en su paso, no de tapadillo en éste—. 117 líneas de config, credenciales en un Secret, y el conf/ compuesto en un initContainer porque rewrite_config.py lo borra y lo reescribe (⭐ y conserva las claves que no conoce: verificado leyendo el script).

GATE C·1  9/9 responden 200 · control negativo: w_noexiste → 404

Los nueve están vacíos: el registro de las ~82 tablas es C·3.

13·19 · 🏁 C·2 — el vending EJERCIDO · y un bloqueante que sale de él

Primero salió ROJO, y ése es el hallazgo:

IllegalArgumentException: There are no credential provider for the catalog.

El IRC DECLARA …/tables/{t}/credentials y no vendía nada. Ese endpoint es lo que hizo que el cutover pareciera viable — declarar no es servir. Leyendo sólo endpoints[], el camino de lectura se habría caído el día del cambio.

Con credential-providers puesto: CTAS ✅ · lectura ✅ (50 filas) · y ⭐ el fichero aterrizó bajo el key-prefix de su catálogo-de-inquilino ⇒ el aislamiento de almacenamiento sobrevive al cambio de pieza.

⚠️⚠️ La salvedad, y es bloqueante: el proveedor que funciona con R2 es s3-secret-key — la llave LARGA. s3-token (STS) no sirve: R2 no habla STS, la misma razón que descartó a Polaris. ⇒ Eso asciende a bloqueante la pregunta abierta del mapa §8·7·①: «hoy vendamos STS» NO está medido (sts-enabled:false vive en la ruta de warehouses dedicados, y el compartido se configuró fuera de ese código).

Lakekeeper hoy entrega la llave larga ⇒ s3-secret-key es EQUIVALENTE
Lakekeeper hoy vende STS              ⇒ el cutover es REGRESIÓN de seguridad

[medir] antes del cutover real — un GET …/credentials contra el compartido.

13·20 · ⛔⛔ C·2·b — LAKEKEEPER VENDE TEMPORAL (~855 s) ⇒ el cutover se para

scripts/warehouse/c2b-que-vende-lakekeeper.ts (sólo lee, y mira credenciales sin enseñarlas: claves y forma del valor, nunca el valor).

loadTable con delegación → s3.session-token (692 chars) · expiration-time
                           client.refresh-credentials-endpoint
GET …/credentials        → ⏱️ caduca en 855 s (~14 min)

El cutover con s3-secret-key cambiaría una llave que caduca en 15 min por la llave PERMANENTE de R2. Es una regresión de seguridad, no un matiz.C·3 no se ejecuta.

⚠️ Corrección a lo que escribí el mismo día: dije «R2 no habla STS» como causa. Falso: R2 sí da temporales y Lakekeeper sabe pedirlas. La limitación es de Gravitino (s3-token va por AssumeRole, que R2 no expone). No es que el almacenamiento no pueda: es que el catálogo nuevo no sabe.

🏁 Y cierra la pregunta abierta del 08-12 (mapa §8·7·①): «hoy vendamos STS (900 s)» era cierta — 855 s medidos. Con ella queda confirmado el §5·3: mientras se venda credencial, la política de fila es decorativa.

⭐⭐⭐ La salida que convierte el bloqueante en oportunidad: REMOTE SIGNING. El catálogo firma cada petición y no entrega llave ninguna — que es exactamente el paso que el mapa §8·7·② proponía para conmutar el vendado, el que hoy bloquea la política de celda. No son dos obras: es una.

13·21 · ⛔⛔ CORRECCIÓN — el remote signing NO es el estándar, y D queda retirada

Detalle en index-gobernanza-mapa.md §5·nonies.

Lo medido contra el estándar
¿Quién firma?Nadie: el catálogo VENDE. La credencial temporal es el mecanismo de primera clase de la spec (…/credentials, storage-credentials). El remote signing es una extensión del cliente, no la recomendación
¿Por qué la fricción?Estructural, no de implementación: Iceberg es un formato de ficheros; quien recibe el fichero lo lee entero. Firmar da grano de fichero, no de fila
¿Cómo lo resuelven?Cortándolo. UC, literal: «tables with row filters or column masks are not supported with credential vending» y «you cannot use Iceberg REST … to access tables with row filters or column masks». Snowflake, Polaris, Dremio y Trino: igual — motor de confianza
La excepciónUC anunció Preview de grano fino para motores externos (first and only cross-engine ABAC, por etiquetas). Es la frontera del sector, hoy

D (remote signing) RETIRADA. Su premisa —«el vending hay que abolirlo igual»— es falsa: el vending temporal ES el estándar; lo que se corta es el acceso directo a las tablas con política, que no cuesta latencia por fichero.

tabla SIN política → vending temporal por el catálogo      ← lo de hoy = el estándar
tabla CON política → fuera de Iceberg REST; la sirve LA PUERTA (Junction+Spark)

⭐⭐ Y la mejor noticia: el motor de confianza no hay que construirlo — lo tenemos. Junction + plan-reader + Spark es la pieza que UC llama su motor, y el SQL Editor ya pasa por ahí. Falta la política (concepto 7) y las etiquetas (6), no la infraestructura.

⚠️ El bloqueo del cutover sigue, por el motivo CONTRARIO: no hay que abolir el vending, hay que conservarlo ⇒ la salida buena pasa a ser B (que Gravitino sepa vender temporales de R2).

13·22 · 🏁 SALIDA B · el patrón del provider de R2 — ya existe, y se calca

Detalle en irc-cutover-approach.md §6·septies.

Lakekeeper   vende R2 vía  POST /accounts/{id}/r2/temp-access-credentials   (Apache-2.0, legible)
Gravitino    SPI documentado: org.apache.gravitino.credential.CredentialProvider
             registro `credential-providers = <nombre>` · jar al CLASSPATH del IRC
             y `S3TokenCredential` ya existe (accessKeyId·secret·sessionToken·expiration)

⭐⭐ El jar ya sabemos meterlo: es el initContainer de C·0·a. ⇒ B baja de «contribuir a un proyecto ajeno» a «un jar nuestro en un directorio que ya montamos».

Dos vías para la temporal, y la elección no es indiferente:

API de Cloudflare (Lakekeeper)⭐ JWT local
Reduna llamada externa por vendingcero: se computa
Permiso⚠️⚠️ token «Admin Read & Write» de la CUENTAel parent secret que ya tenemos

⛔⛔ Hallazgo que no buscábamos: si Lakekeeper usa esa API —su doc dice que sí—, nuestro catálogo tiene hoy un token Admin de la cuenta de Cloudflare: un permiso mayor que la llave larga que tanto nos preocupaba. [verificar]

Se calca la FORMA (vended temporal como S3TokenCredential), no el MECANISMO: vía JWT local. Regla 46, y aquí la derivación es estrictamente mejor — sin latencia en el camino de lectura y sin token de administración.

Gates: B·0 (reproducir el JWT a mano con curl, con control negativo de caducidad) → B·1 provider → B·2 jar+config → B·3 re-correr C·2 → B·4 paridad ~900 s. ⚠️ B·0 antes de escribir una línea de Java: media hora, y si falla todo lo demás sobra.

13·23 · Reglas que deja el 08-13

  1. ⭐⭐⭐ ANTES DE CANARIZAR UN CAMINO, MIRAR POR CUÁL ENTRA EL PRODUCTO. Se montó un Job de Spark en GKE para probar lo que el SQL Editor ya hacía en producción. Un canary del carril equivocado no mide de menos: mide otra cosa, y sus hallazgos parecen tuyos.

  2. ⭐⭐ UNA HIPÓTESIS QUE EXPLICA EL SÍNTOMA NO ES LA CAUSA HASTA QUE SE MIDE. El 404 se atribuyó al «CTAS atómico» porque encajaba con lo recién aprendido. Era dos líneas de WHERE. La diferencia de coste entre las dos explicaciones era enorme.

  3. ⭐⭐ UN RESCOLDO SE CLASIFICA POR SUS LLAMANTES, NO POR SU FICHERO. «Es del tubo, se borra con él» era falso: la puerta compartía la pieza y la puerta no muere.

  4. LA DUDA NO BORRA. Una compensación que se dispara con !res.ok convierte un fallo transitorio en pérdida de gobierno. Se pregunta al catálogo y se actúa sobre un «no existe» comprobado.

  5. ⭐⭐⭐ DOS COSAS QUE SE HACEN CON EL MISMO SQL NO SON LA MISMA COSA. Federar y ingerir se parecen —las dos acaban en un SELECT sobre una fuente ajena— y el sector las tiene separadas a propósito: el espejo read-only para leer sin copiar, un conector con captura de cambios para el delta. Fundirlas nos habría llevado a intentar el CDC con un CTAS. Regla 55 otra vez, por el otro lado: no basta con comprobar si su problema es el nuestro; hay que comprobar si su pieza resuelve dos problemas y nosotros veíamos uno.

  6. ⭐⭐ UNA DECISIÓN PEDIDA EN EL SITIO EQUIVOCADO NO SE CONTESTA MAL: SE DEJA SIN CONTESTAR. El asistente pide el destino al DAR DE ALTA la fuente, y 17 de 26 lo saltan ⇒ acaban en un main.default que nadie eligió. En el estándar la conexión no tiene destino: lo pone la sentencia. El síntoma parecía de UX y era del modelo.

  7. ⭐⭐⭐ «NO EXISTE» Y «EXISTE VACÍA» SON DOS PLANES DISTINTOS, Y SE LEEN IGUAL. Un inventario que sólo distingue hecho / no hecho mandó a construir la entidad de política, que estaba construida, probada y sin consumidores desde una migración anterior. El censo tiene que separar construir de cablear, porque el coste difiere en un orden de magnitud.

  8. ⭐⭐ UN CONCEPTO QUE FALTA EN EL MAPA PARECE RESUELTO. El mapa tenía 18 conceptos sobre cómo entra y cómo se lee el dato, y ninguno sobre cómo sale: sharing no estaba ⛔ — no estaba en absoluto. Una casilla vacía se ve; una fila que no existe, no. Los huecos del encuadre son más caros que los huecos del plan.

  9. ⭐⭐⭐ SE ABSORBE LO QUE DESCRIBE; SE DERIVA LO QUE DECIDE. «Absorber o reconstruir» parecía una disyuntiva y son dos planos distintos: el descriptivo es voluminoso, estándar y no decide nada ⇒ traerlo hecho es puro ahorro; el de autoridad es pequeño, nuestro y es lo único que dice NO ⇒ derivarlo es la única forma de que no haya dos verdades. El criterio para saber en cuál estás: si apagar esa pieza deja pasar una operación que antes se rechazaba, no era descriptiva.

  10. ⭐⭐⭐ DESCRIBIR PIDE TIPOS ABIERTOS; GOBERNAR PIDE TIPOS CERRADOS. Lo más vistoso de Atlas —el typeDef extensible en runtime— es lo único que no hay que derivar: existe porque su oficio es representar cualquier sistema del mundo. Un securable que ningún punto de aplicación sabe aplicar no gobierna: es una fila. UC y Gravitino tienen conjuntos cerrados por la misma razón. ⇒ La pregunta que lo decide: ¿este tipo nuevo trae su punto de aplicación?

  11. ⭐⭐⭐ UN SISTEMA NO SOBRA POR PEQUEÑO: SOBRA POR ENTERO. Atlas trae tres cosas que necesitamos —proceso como entidad, propagación por el linaje, activos heterogéneos— y cinco que no: su despliegue, su motor de aplicación, su modelo de tipos, su modelo de permisos y su dependencia de Ranger sobre motores que no usamos. La pregunta no es «¿aporta?» sino «¿cuántas de sus piezas tendría que apagar para que encaje?» — si son más de las que enciendo, lo que quiero es su modelo.

  12. ⭐⭐⭐ UN DESPLIEGUE NO DECLARADO SE PIERDE SIN QUE NADIE SE ENTERE. La VM de OpenMetadata se levantó con un runbook manual y desapareció: nadie lo supo hasta que una sonda tocó el puerto. Lo que la habría salvado no es la nube ni el k8s, es que el despliegue sea un artefacto del repo. ⇒ Corolario para elegir dónde vive un estado: la criticidad decide, y el criterio es si su pérdida es reconstruible. Perder una proyección es reingerir; perder el catálogo es perder el gobierno — y por eso el mismo argumento no vale para los dos.

  13. ⭐⭐⭐ EL ESPACIO DE ACCIÓN NO SE PUEDE PROYECTAR. Es (lo que existe) ∩ (lo que este principal puede), y las dos mitades viven en sistemas distintos: la primera en la proyección descriptiva, la segunda en la retícula. Quien sirva contexto a un agente tiene que poseer las dos — si no, entrega un catálogo de cosas que el agente no puede usar, y eso es peor que no entregar nada, porque planifica sobre él y fracasa al ejecutar. ⇒ La capa agéntica se construye sobre quien TIENE la retícula, y consume del resto.

  14. ⭐⭐ SI APAGARLO TE DEJA SIN NADA, LO PUSISTE EN EL SITIO EQUIVOCADO. La prueba del algodón de cualquier absorción: lo que la pieza produce tiene que aterrizar en nuestro sustrato como propuesta. Si al apagarla se cae el producto, no la absorbimos: dependemos de ella.

  15. ⭐⭐⭐ UN SECURABLE SIN PUERTA ES UNA FILA. Ampliar securable_kind a tópicos, volúmenes y modelos parece un cambio de modelo y es tres puntos de aplicación nuevos. La pregunta que ordena la ambición: ¿por dónde pasa hoy una lectura de ese activo, y quién la puede parar? Si la respuesta es «por ningún sitio», gobernarlo empieza por construir —o adoptar— la puerta, no la tabla.

  16. ⭐⭐⭐ ADMINISTRAR NO ES APLICAR, Y CASI TODO EL MERCADO ADMINISTRA. Gravitino tiene un RBAC unificado precioso… y su propia doc dice que no comprueba privilegios: delega. Atlas igual (delega en Ranger). OM igual. ⇒ Antes de preguntar «¿esta pieza nos quita trabajo de gobernanza?», preguntar «¿dónde está su punto de aplicación?» — si la respuesta es «en otro sistema», lo que compras es vocabulario y administración, no gobierno. Y el vocabulario se proyecta; el gobierno, no.

  17. ⭐⭐⭐ REESCRIBIR UN RECURSO PIERDE LO INVISIBLE. Sustituir un Deployment entero en vez de parchearlo se llevó el nombre de un puerto (targetPort: http), y el síntoma fue HTTP 000 con el pod Ready, la IP correcta y las labels correctas — que se lee como red o como servidor caído, cuando el servidor respondía perfectamente en localhost. Lo que desempata siempre es preguntar POR DENTRO. Corolario: kubectl get endpoints miente en k8s 1.33+ (<none>, porque el recurso legacy ya no se puebla); lo autoritativo es get endpointslices.

  18. ⭐⭐ UN NÚMERO QUE MIDE LA PARTE NO SE PUBLICA COMO SI MIDIERA EL TODO. El «40×» del catálogo era la conexión TCP (1,3 vs 46 ms) y es cierto; la operación completa —loadTable, que lee el metadata.json de R2— sólo mejora , porque R2 domina y no se mueve. La mejora sigue siendo real: lo que no es real es el titular.

  19. ⭐⭐⭐ UN endpoints[] ES UN FOLLETO MUY BIEN ESCRITO. El IRC declaraba …/tables/{t}/credentials —y ése fue el argumento de que el cutover era viable— y no vendía nada: There are no credential provider for the catalog. Declarar no es servir. Un endpoint sólo cuenta cuando algo lo ha EJERCIDO, y el gate que lo ejerce hay que correrlo antes de decidir, no después.

  20. ⭐⭐⭐ «ESO HABRÍA QUE QUITARLO DE TODAS FORMAS» ES LA COARTADA MÁS CÓMODA PARA JUSTIFICAR UN RODEO. Se usó para proponer remote signing como salida al bloqueo del cutover, y el estándar dice lo contrario: el vending temporal es su mecanismo central; lo que se corta es el acceso directo a las tablas con política, que es otra cosa. Antes de apoyarse en «hay que quitarlo igual», comprobar que el estándar también lo quita.

  21. ⭐⭐⭐ EL GRANO FINO EXIGE UN MOTOR DE CONFIANZA — NADIE LO APLICA EN EL CATÁLOGO. UC, Snowflake, Polaris, Dremio y Trino resuelven la política de fila sacando la tabla del acceso directo, no aplicándola en el catálogo. Es estructural: Iceberg es un formato de ficheros y quien recibe el fichero lo lee entero. ⇒ La pregunta no es «cómo aplico el filtro en el catálogo» sino «quién es mi motor de confianza» — y en nuestro caso ya existe: la puerta.

  22. ⭐⭐ CONTAR LOS HOMÓNIMOS ES PARTE DE MEDIR. Cinco vocabularios de «etiqueta» en el mismo esquema, ninguno gobernando. Buscar por nombre —y abrir lo que no esperabas— es la mitad barata de no dar nada por supuesto: object_tags ya tenía confidence y source, o sea que el vocabulario se pensó una vez… colgando del objeto equivocado.

  23. ⭐⭐⭐ UN CONTROL NEGATIVO QUE SÓLO MIRA «¿NO FUE 200?» NO CONTROLA NADA. B·0 acuñó la temporal de R2 con un claim que R2 no admite: contestó 400 InvalidArgument a las cuatro pruebas, y los tres controles negativos —caducada, fuera de prefijo, escritura con scope de lectura— salieron verdes, porque un token malformado se rechaza igual que uno caducado. El fallo tiene que fallar POR SU MOTIVO: InvalidArgument (400, la forma) y AccessDenied (403, el permiso) contestan preguntas distintas, y leer el <Code> en vez del «no-200» es lo que las separa.

  24. ⭐⭐ CUANDO ALGO NO PASA, PRIMERO SEPARA TU MITAD DE LA SUYA. Antes de tocar el cómputo del JWT, se pidió una temporal a Cloudflare y se firmó con nuestro SigV4: 200. Ese peldaño —una pieza legítima ajena por nuestra maquinaria— convirtió «algo falla» en «los claims fallan» sin depurar nada. Sin él tocaba el firmador, que estaba bien.

  25. ⭐⭐ UN EJEMPLO DE LA DOCUMENTACIÓN NO ES UN CONTROL. El código publicado por Cloudflare para acuñar temporales de R2 incluye un claim (actions) que R2 rechaza en toda forma, incluso vacío — mientras acepta claims inventados. Lo escrito por el proveedor describe lo que pretendía, no lo que su servicio hace: sólo su servicio contesta.

  26. ⭐⭐⭐ LO QUE DESPACHA POR CLASE NO ATIENDE A TU NOMBRE. Gravitino traduce la credencial al cliente Iceberg con un instanceof de la clase concreta, no con el tipo declarado: una Credential propia habría hecho que el IRC arrancara, vendiera y entregara nombres que el motor no lee — fallo silencioso, visible sólo como un 403 al leer. De ahí la forma del calco: se copia la CLASE que el consumidor reconoce y se deriva el MECANISMO que hay detrás. Y el corolario: cuando extiendes algo ajeno, lee cómo te consume, no sólo cómo te registra.

  27. ⭐⭐ CENSA CON EL MECANISMO DEL CONSUMIDOR, NO CON EL QUE TENGAS A MANO. Para saber qué providers había en el classpath se barrieron 174 jars con unzip: cero resultados, porque la imagen no trae unzip y el 2>/dev/null se comió el error. La lectura natural —«no hay ninguno»— era lo contrario de la verdad (hay nueve). Rehecho con el ServiceLoader que usa el propio Gravitino, salió el censo real. Un censo que no puede fallar en voz alta no es un censo — hermana de la 64 y de la 80.

  28. ⭐⭐⭐ «DESPLEGADO» NO ES «SIRVIENDO LO DESPLEGADO». Un loadTable contestó «There are no credential provider» con la clave ya escrita en el conf del pod nuevo y el rollout status en verde: kubectl port-forward deploy/… se había enganchado al pod que aún terminaba. La conclusión natural —«la configuración no llega»— era falsa, y habría mandado a rediseñar algo que funcionaba. Cuando la medición contradice a la configuración, sospecha primero de a QUIÉN estás preguntando. Prima de la 76.

  29. ⭐⭐ NO CONFUNDAS EL ÁMBITO QUE PONES CON EL QUE TE DAN. Nuestro provider acota el vending al prefijo de la tabla, y eso es nuestro. Pero el eje lectura/escritura le llega ya decidido por el checkAccess de Gravitino, que sin authz cableado dice que sí a todo ⇒ toda credencial sale read-write. Medir lo que aportas exige separar la parte que decides de la que heredas — si no, se acaba publicando como propia una garantía que depende de un if ajeno que hoy está en true.

  30. ⭐⭐⭐ ENRUTAR NO ES AISLAR, Y UN 404 DE CONTROL NO DISTINGUE LAS DOS COSAS. C·1 declaró 9 catálogos-de-inquilino, midió 9/9 en 200 con w_noexiste → 404, y se anotó que «el JdbcCatalog separa por catalog_name». Lo primero era verdad —el nombre ENRUTA— y lo segundo era un supuesto: los 9 comparten espacio de metadatos, y un inquilino lista, carga y recibe credencial read-write sobre las tablas de otro. El control negativo probó que un nombre inventado no existe; jamás probó que dos nombres válidos no se vean. Para aislamiento, el control tiene que ser CRUZADO: crear en A y preguntar por B. Hermana de la 76 — declarar no es servir, y enrutar no es separar.

  31. ⭐⭐⭐ ANTES DE EXTENDER, PREGUNTA CÓMO LO RESUELVE EL SECTOR. Íbamos a escribir un IcebergConfigProvider propio para que el catálogo leyera nuestro control plane. El estándar hace lo contrario y lo hacen los cuatro (Lakekeeper, Polaris, Unity Catalog, Gravitino): el catálogo es dueño de su registro de inquilinos y se muta por su Management API; el control plane LLAMA, no es leído. La pieza ya existía de serie (dynamic-config-provider) y el código propio habría metido una credencial del control plane en el plano de datos, duplicado la regla de nombrado y atado el arranque del catálogo a la cara. Escribir la extensión era el rodeo; el trabajo era desplegar la pieza que faltaba.

  32. ⭐⭐⭐ UN 200 NO DICE QUÉ LLEVA DENTRO. Tras mover el catálogo al provider dinámico, loadTable seguía devolviendo 200 y los clientes habrían funcionado igual — sirviendo la llave larga. Lo delataba una línea de log: Generate credential: s3-secret-key. Cuando lo que importa no es si respondió sino con qué, el gate tiene que mirar el contenido, y conviene que mire también lo que el servidor CREE estar haciendo.

  33. ⭐⭐ LA FUENTE QUE COPIAS PUEDE NO HABER TENIDO NUNCA EL VALOR BUENO. Se copiaron los 9 catálogos desde el Secret… que decía s3-secret-key: el volteo a r2-token lo hacía un sed del initContainer sobre el conf, así que el valor correcto sólo existía en memoria del pod. Migrar «la configuración» migró el estado ANTERIOR a la corrección. Cuando algo se arregla en el arranque y no en el origen, el origen sigue mintiendo.

  34. ⭐⭐⭐ ENCENDER LA AUTENTICACIÓN ROMPE TODO CHEQUEO ANÓNIMO — Y EN CADA PISO. El mismo fallo apareció dos veces con dos caras distintas: el pod se quedó 0/1 con los dos servidores arrancados y sirviendo (la readinessProbe pegaba a /api/version, que ahora devuelve 401), y después el balanceador devolvió 503 con el certificado ACTIVE, el DNS resolviendo, la ruta Accepted y el pod sano (su health check es un GET / anónimo). En los dos casos el síntoma apunta a la red y la causa es el permiso. La cura es la misma arriba y abajo: probar por TCP, que verifica lo que importa —que el puerto escucha— sin necesitar credencial. Al cerrar una puerta, hay que ir a buscar a todos los que llamaban sin llamar.