Published

HANDOFF · 2026-08-14 — la foto para seguir sin releerlo todo

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 — la foto para seguir sin releerlo todo

⚠️⚠️ SUPERSEDIDO PARA EL CATÁLOGO por handoff-2026-08-14-cierre.md
(la foto del CIERRE de la sesión). Este documento sigue valiendo para la ingesta (§3) y la
gobernanza (§4)
; para el cutover del catálogo, manda el otro.

Para qué es esto. La sesión del 08-13/14 tocó cuatro frentes y dejó una decisión
grande a medio ejecutar. Este documento es lo que hay que leer antes de seguir:
dónde estamos, qué está encendido, qué está bloqueado y por qué, y en qué orden se
continúa.

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

Manda sobre este documento: sustrato-07.md §13 (los hechos) ·
index-gobernanza-mapa.md (el encuadre).


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

  1. 🏁 El brazo PG de la ingesta está RETIRADO (−808 líneas). Queda un solo camino.
  2. ⭐⭐⭐ La ingesta-como-query cambió de carril: el canary medía el camino que el producto no usa. La Fase A es runQuery, no un Job de Spark contra la cara.
  3. 🏁 El motor YA EMITE su propio linaje a nivel de columna (OpenLineage), y la identidad viene resuelta en el facet symlinks.
  4. ⭐⭐ La malla de gobernanza está censada: 11 primitivas no existen, 6 existen y están vacías — y esa distinción reordenó el plan.
  5. 🏁 DECIDIDO: el catálogo pasa de Lakekeeper al IRC de Gravitino. C·0 y C·1 verdes.
  6. ⛔⛔ El cutover está BLOQUEADO en C·2·b: Lakekeeper vende credenciales temporales (855 s) y Gravitino sólo sabe entregar la llave larga contra R2.
  7. Se retiró la salida «remote signing»: no es el estándar. El estándar vende.
  8. La salida elegida es B: un CredentialProvider propio para R2 — el patrón ya existe (Lakekeeper lo hace) y Gravitino tiene SPI documentado.
  9. 🏁 carbon-catalog (Cloud SQL, europe-west1, IP privada) en pie y probada.
  10. ⏸️ El cluster está SUSPENDIDO. Nada corre. La base sí.

1 · El estado de la infraestructura, exacto

1·1 · Cluster GKE (trino-k8s · trino-compute-clone · europe-west1-b)

1 nodo n2-standard-4 · CPU 47 % · memoria 19 %   (con todo suspendido)
CPUS_ALL_REGIONS  límite 12 · uso 4              ⇒ margen para UN nodo más

SUSPENDIDO:
  sparkconnectserver/carbon-connect   clusterOperation.stopped = true
  deploy/spark-bridge   (ns spark)    0/0
  deploy/gravitino-irc  (ns catalog)  0/0

⚠️ Suspender NO ahorra dinero: el nodo se paga igual. Libera CPU/memoria. Para ahorrar habría que escalar el node pool a 0. ⚠️ gravitino-irc quedó a 0/0 sin escalarlo explícitamente — probablemente la reconciliación del operador al patchear el CR. Para reactivarlo: kubectl scale deploy/gravitino-irc -n catalog --replicas=1.

1·2 · La base del catálogo — lo único que sigue vivo

carbon-catalog · Cloud SQL POSTGRES_16 · europe-west1 · db-f1-micro · 10 GB SSD
IP PRIVADA 10.99.0.3   (VPC peering sobre `default`, rango 10.99.0.0/20)
backups 03:00 · storage auto-increase · ~10-15 €/mes · state RUNNABLE

Sin IP pública, sin Cloud SQL Auth Proxy y sin credencial de servicio: el pod habla por la VPC. Menos piezas y menos secretos. ⭐ No consume CPUS_ALL_REGIONS — no compite con el cluster.

1·3 · Secrets en el cluster (ns catalog)

SecretQué lleva
irc-cloudsqlpassword de carbon-catalog (rotada el 08-13) + host
irc-catalogsel fragmento de config con los 9 catálogos (117 líneas, con credenciales)
gravitino-r2endpoint · accessKeyId · secretAccessKey de R2
carbon-catalog-oauth-etl (ns spark)credencial OAuth de lakekeeper-spark-etl

El .conf del IRC contiene credenciales en claro: NO volcarlo. Ya se filtró una y hubo que rotarla en caliente.

1·4 · Lo que NO existe (y se creía que sí)

  • La VM de OpenMetadata NO EXISTE — ni en trino-k8s, ni en primeval-rain-504307-a4, ni en compact-idiom-qszp9. No está caída: no está. ⭐ Pero su stand-up está versionado: infra/openmetadata/ + docs/runbooks/openmetadata-standup.md.

2 · El cutover del catálogo — dónde está exactamente

Entregable: irc-cutover-approach.md · decisión del owner (08-13).

GateEstado
🏁C·0·a el IRC sobre Postgres (driver por initContainer)VERDE
🏁C·0·b la base durable carbon-catalogVERDE — el estado sobrevive a matar el pod
🏁C·1 los 9 catálogos-de-inquilino declaradosVERDE — 9/9 + control negativo
🟡C·2 el vending ejercidoMecanismo VERDE · paridad ROJA
⛔⛔C·2·b ¿qué vende Lakekeeper?TEMPORAL, 855 sBLOQUEA
⏸️C·3 re-registro de ~82 tablascongelado hasta resolver C·2·b
⏸️C·4 el CAS bajo concurrenciapendiente

2·1 · Por qué se hace (y qué NO aporta)

⭐⭐/plan — el sitio donde poner la política. Lakekeeper no lo tiene
Latencia: loadTable 64-86 ms vs ~222 ms ⇒ (⚠️ no 40×: eso era la conexión TCP; R2 aporta ~66 ms que no se mueven)
Catálogo dentro del cluster · y la puerta al metalake
Se pierde transactions/commit — declarado y sin estrenar, pero es la pieza que cierra la deuda «una escritura toca UNA tabla»
⚠️La atomicidad NO mejora por Gravitino: el CAS es del catálogo en ambos casos. Lo que la mejora es la topología — catálogo + Index en la misma base ⇒ commit y asiento en UNA transacción ⇒ cierra 4·4

2·2 · El bloqueo, y la salida elegida

Lakekeeper vende TEMPORAL: s3.session-token · expiration-time
                           client.refresh-credentials-endpoint · ⏱️ 855 s
Gravitino contra R2:       sólo `s3-secret-key` = la llave LARGA
⇒ migrar hoy sería una REGRESIÓN de seguridad

Se retiró la salida D (remote signing). El estándar vende; el remote signing es una extensión del cliente. Y la política de fila no se resuelve firmando: el sector la resuelve sacando la tabla del acceso directo (§4).

Salida elegida: B — un CredentialProvider propio. El patrón existe:

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

⭐⭐ El jar ya sabemos meterlo: es el initContainer de C·0·a. No hay que forkear la imagen ni esperar a un release.

Dos vías para la temporal, y NO calcamos la de Lakekeeper:

API de CloudflareJWT local
Reduna llamada por vendingcero: se computa
Permiso⚠️⚠️ token «Admin Read & Write» de la CUENTAel parent secret que ya tenemos

⛔⛔ Hallazgo abierto: si Lakekeeper usa esa API, hoy tiene un token Admin de la cuenta de Cloudflare — un permiso mayor que la llave larga que nos preocupaba. [verificar]

2·3 · 🏁🏁🏁 B·0, B·1 y B·2 VERDES (08-14) — C·2·b DESBLOQUEADO · ⏭️ sigue B·3

QuéGate
🏁B·0 Reproducir la vía JWT a manoGET a un objeto real con X-Amz-Security-Token · y control negativoVERDE
🏁B·1 El CredentialProvider en JavaMismo JWT ⇒ mismo secreto derivadoVERDE · 20/20
🏁B·2 Jar al classpath + credential-providers = r2-token en los 9El IRC arranca sin «no credential provider»VERDE · y VENDE
🏁B·3 Re-correr C·2 — Spark escribe y lee con vended-credentialsCTAS + lectura verdes y la credencial con expirationVERDE · 11/11
🏁B·4 Paridad con LakekeeperVentana ~900 s + session-tokenVERDE · 900 vs 855 s, y acotada

⛔⛔⛔ El vending deja de bloquear… y aparece un bloqueante peor: los 9 catálogos-de-inquilino COMPARTEN espacio de metadatos. Ver §2·4. | | B·3 Re-correr C·2 | CTAS + lectura verdes, con expiration | | | | B·4 Paridad | Ventana ~900 s + session-token ⇒ deja de ser regresión | |

Medido (scripts/warehouse/b0-jwt-r2-temporal.ts, contra el bucket lakehouse real, sin cluster) — detalle en irc-cutover-approach.md §6·octies:

0·b  temporal de CLOUDFLARE + nuestro SigV4               200   ⇐ el desempate
①    local, claims mínimos { bucket, scope }              200
①·b  local + paths.prefixPaths                            200
       · el MISMO token, objeto FUERA del prefijo         403 AccessDenied  ⭐⭐
①·c  local + actions (¡el del ejemplo oficial!)           400 InvalidArgument ⛔
②    la MISMA credencial, CADUCADA                        403
③    PUT con scope object-read-only                       403 AccessDenied
  • ⭐⭐⭐ No sólo autentica: ACOTA. prefixPaths recorta de verdad ⇒ el vending por-inquilino se puede computar sin una llamada de red. B·1 tiene suelo.
  • ⭐⭐ El hallazgo §2·2 queda contestado: la API de temporales respondió 200 con nuestro token Workers R2 Storage: Edit. No exige «Admin Read & Write» de cuenta. ⚠️ No reabre la vía A: sigue costando un RTT por vending y sigue queriendo un token de API en el catálogo. Cae el argumento del permiso, no la decisión.
  • La doc oficial no se cumple: actions da 400 en toda forma, incluso [] — mientras que un claim inventado pasa. No se usa en B·1.
  • 🪤 La primera pasada dio tres verdes falsos (§7).

B·1infra/gravitino-r2-credential/: dos clases, cero dependencias, jar de ~8 KB, y el gate corre dentro del build (docker build --target gate .), así que un jar que no pasa B·1 no llega a existir. ⭐ Se compila contra la imagen que se despliega«compila» y «carga en el pod» son la misma afirmación.

  • ⭐⭐⭐ El hallazgo caro: toIcebergProperties despacha por instanceof de la clase concreta, no por el tipo. Una Credential propia ⇒ el cliente recibe s3-session-token en vez de s3.session-token, el IRC arranca y vende igual, y el motor no encuentra la credencial. ⇒ se devuelve la S3TokenCredential de serie; lo nuestro es sólo el tipo.
  • ⚠️ El tipo es r2-token: censado con ServiceLoader, la distribución ya declara 9 providers, s3-token incluido ⇒ repetir nombre = «Multiple credential providers found».
  • Ningún secreto nuevo: reutiliza s3-endpoint / s3-access-key-id / s3-secret-access-key, que ya son el parent con el que se firma.

B·2infra/gke/b2-r2-credential-provider.yaml (supersede el Deployment de C·0·a) + scripts/warehouse/b2-vending-temporal.ts. Medido contra el IRC desplegado:

initContainers  jar en libs/ (7.895 B) · «9 catalogos tenian provider, 9 quedan en r2-token»
loadTable con vended-credentials
  s3.session-token ✅   token → `jwt/…` (el nuestro)
  scope        object-read-write
  prefixPaths  ["_probe-gravitino/_b2_probe/sonda_…/"]   ⇐ ⭐ ACOTADA a ESA tabla
  ventana      900 s                                     ⇒ paridad con los ~855 s
control negativo · el MISMO loadTable SIN la cabecera → ninguna clave de credencial
  • El volteo se hace en el initContainer, no en el Secret: irc-catalogs lleva credenciales en claro; el sed corre dentro del pod y el cambio queda en git. Y sustituye en sitio en vez de fiarse de «la última clave gana», con el invariante comprobado ANTES de arrancar ⇒ un cambio de forma falla ruidosamente en vez de degradar a la llave larga.
  • ⚠️⚠️ El límite honesto: el read/write NO lo decidimos nosotros. Un loadTable de lectura recibió object-read-write porque el privilegio viene de MetadataAuthzHelper.checkAccess(...) — el control de autorización de Gravitino, que en el IRC suelto no está cableado y dice que sí a todo. Encaja con §4·2: el catálogo administra, no aplica. El prefijo es nuestro; la mitad read/write espera a la puerta.

2·4 · ⛔⛔⛔ EL BLOQUEANTE NUEVO — los 9 catálogos no están aislados

Encontrado leyendo el GATE 1 de B·3: apareció un namespace en el catálogo del inquilino que se había creado por la ruta sin prefijo. Medido:

namespaces por cada ruta   (sin prefijo)· lakehouse/ · w_7b500f4d…/ · w_0936227f…/ · w_1548b5ae…/
                           TODAS devuelven el mismo  [["_b2_probe"]]     ⇐ creado UNA vez

createTable en el inquilino A       → 200 · s3://lakehouse/test/w_7b500f4d…/…/tabla_de_A
listTables  POR EL PREFIJO DE B     → 200 · ve tabla_de_A
loadTable   POR EL PREFIJO DE B     → 200 · devuelve la location de A
  … con `vended-credentials`        → scope object-read-write
                                      prefixPaths ['test/w_7b500f4d…/…/tabla_de_A/']  ⛔⛔

Un inquilino puede listar, cargar y obtener credencial read-write sobre los bytes de otro. No es fallo del vending —el provider acota bien a la tabla que le piden—; el fallo es que B no debería poder pedirla.

C·3 NO se ejecuta. Meter el dato real de 8 inquilinos en un espacio de metadatos compartido convierte un problema de configuración en una exposición entre inquilinos.

⚠️ Hoy NO hay exposición en producción: el IRC es ClusterIP inerte y la cara sigue sirviendo por Lakekeeper. Es bloqueante del cutover, no incidente.

⚠️ Lo que C·1 midió era el ENRUTADO, no el aislamiento. Su nota «el JdbcCatalog separa por catalog_name» es falsa en este despliegue. El prefijo w_<hex> elige dónde aterrizan los bytes nuevos, no un espacio de metadatos propio.

🏁 LA CAUSA, MEDIDA (infra/gke/b3b-censo-catalog-name.yaml, sólo SELECT) — con una tabla creada en A y otra en B por sus prefijos:

 catalog_name | tablas        catalog_name | table_namespace | count
--------------+--------      --------------+-----------------+-------
 jdbc         |      2        jdbc         | ns_de_A         |     1
(1 row)                       jdbc         | ns_de_B         |     1

catalog_name = 'jdbc' para los nueve — y jdbc no es el nombre de ningún catálogo, es el tipo de backend: se quedaron con el valor por defecto.

⇒ ⭐ El arreglo es una línea por catálogo, y es el mecanismo que la propia doc de Gravitino señala («Catalog name in JDBC backend is used to isolate namespace and tables»):

gravitino.iceberg-rest.catalog.<nombre>.catalog-backend-name = <nombre>

⚠️⚠️ AHORA, antes de C·3: cambiar el catalog_name de un catálogo con contenido deja huérfanas sus filas en iceberg_tables. Hoy los 9 están vacíos ⇒ el cambio es gratis.

2·5 · 🏁 ARREGLADO Y COMPROBADO EN CRUZ (C·1·b)

Aplicado en el initContainer de b2-r2-credential-provider.yaml, derivando los nombres del propio fichero ⇒ un inquilino nuevo queda aislado sin tocar el manifiesto.

compose-conf: catalog-backend-name: 9 catalogos, 9 nombres propios puestos

scripts/warehouse/c1b-aislamiento-cruzado.ts
  ① createTable en A                                200
  ② control positivo · A ve lo suyo                 ✅   ⇐ el arreglo no rompió A
  ③ ⭐ list/loadTable POR EL PREFIJO DE B            404 · el ns ni existe para B
  ④ ⭐ B pide delegación sobre la tabla de A         404 · sin credencial
  ⑤ el namespace de A no aparece para B             B ve: []

C·3 deja de estar bloqueado por el aislamiento.

2·6 · 🏁 «Nueva organización = nuevo catálogo» — montado y medido (D·0…D·3)

⭐⭐⭐ El estándar del sector anuló la idea de escribir un provider propio. Lakekeeper, Polaris, Unity Catalog y Gravitino hacen lo mismo: 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. Ya lo hacíamos así con Lakekeeper — lo desviado era desplegar el IRC suelto, que sin servidor detrás sólo sabe leer config estática.

D·0servidor Gravitino completo sobre carbon-catalog. ⭐ Sin initContainers: la imagen ya trae el driver PG
D·0·bsu esquema, sacado de la propia imagen: 2 tablas → 35
D·1/1·blos 9, de config estática a entidades por API, con r2-token forzado
D·2el IRC pasa a dynamic-config-provider y se suprime lo estático (catalogos ESTATICOS: 0)
D·3el gate
① el catalogo se crea POR API                            200
② el IRC crea namespace y tabla en el RECIEN nacido      200  ⇐ SIN reiniciarlo
   los bytes van al prefijo NUEVO
③ vende credencial TEMPORAL en el catalogo nuevo         s3.session-token ✅
④ el VECINO no la ve (list 404 · load 404)

⇒ Verificado además que los 9 existentes siguen vendiendo temporal y que C·1·b sigue verde.

⏭️ Falta el último metro, y es del control plane: ① exponer el servidor Gravitino con auth (hoy ClusterIP y authorization.enable sin cablear ⇒ Vercel no lo alcanza, y no debe sin auth) · ② que tenant-provisioning.ts llame a POST /api/metalakes/carbon/catalogs además de a Lakekeeper (escritura doble deliberada durante la transición). Ambos van con la migración del SQL Editor.


3 · La ingesta — el viraje, y lo que queda

Entregable: ingesta-como-query-approach.md.

3·1 · 🏁 Lo hecho

  • El brazo PG retirado: PostgresBackend (INSERT/UPSERT/COPY) + writeDatasetRowsPg
    • el brazo postgres de write() en junction.ts. −808 líneas, check:dataset-rows-seam 9 → 0.
  • T·1·0physicalFromLogical resuelve por coordenada (schema_id), no por ubicación. Medido: 8/8 tablas invisibles → 0/8.
  • T·1·a compensateIfPhantom (la duda no borra) · T·1·b reconcileDropInIndex.

3·2 · ⭐⭐⭐ El viraje: había dos carriles y el canary midió el equivocado

① POR LA PUERTA   runQuery → Junction → Spark → la cara
   la puerta manda al motor  namespace FÍSICO + nombre LÓGICO  (junction.ts:1918)
   🏁 el carril del producto: W0-W5 · P·3 · P·5 · N·5 medidos EN PRODUCCIÓN
② MOTOR EXTERNO   Spark configurado a mano contra /api/iceberg
   ⛔ el del canary T·1, y donde estaba su 404

La Fase A es runQuery("CREATE TABLE dst AS SELECT … FROM fuente"), no un Job de Spark. Coordenada, materialización, firma, ledger y reconciliación ya están. Lo que falta es que la fuente sea nombrable en el SELECT — y eso es la Fase B.

3·3 · Lo que dijo el estándar (Databricks · Snowflake)

  • La fuente es un objeto del catálogo con grants (CONNECTION en UC).
  • La credencial vive en el objeto, no en el plan.
  • La ingesta es una sentencia sobre ese objeto.
  • ⭐⭐⭐ Federar ≠ ingerir: ninguno hace CDC con un CTAS ⇒ del tubo muere la mitad de snapshot, no la de CDC.
  • 🏁 T·3 contestado: el secreto no necesita broker, es un securable.
  • Obsoleto: el destino elegido en el ALTA (17 de 26 fuentes acaban en un main.default que nadie eligió — el modelo pedía la decisión en el sitio equivocado).

3·4 · ⏭️ Siguiente

T·1·e — el canary de la ingesta por la puerta, con la forma de p3-canary-plan-manda.ts / p5-canary-alter-add-column.ts: npx dotenv -e .env.local -- npx tsx, contra producción, sin cluster ni preview.


4 · Gobernanza — la malla, y la frontera

Encuadre: index-gobernanza-mapa.md §4·bis a §5·nonies.

4·1 · ⭐⭐ La malla censada (scripts/governance/m0-censo-de-la-malla.ts)

⛔ primitivas que NO EXISTEN (construir) ..... 11
🟡 primitivas VACÍAS (poblar / cablear) ......  6
Hallazgo
⭐⭐⭐La política ya está construida: policies + policy_attachments (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
⭐⭐⭐Cinco vocabularios de «etiqueta» y ninguno gobierna (object_tags 10, 3 de MLflow, 2 del canvas, 1 de Gmail)
⭐⭐La conexión y el secreto no son securables: user_credentials (26) y api_connections (1), ninguna concedible
SHARING no estaba ni como concepto — añadidos como conceptos 19-21

4·2 · ⭐⭐⭐ La frontera (§5·octies)

⑤ SUPERFICIE AGÉNTICA (nuestra, única)   espacio de acción = EXISTE ∩ PUEDE
④ APLICACIÓN — el único que dice NO       la puerta · la cara
③ AUTORIDAD — INDEX                       retícula · políticas · etiquetas · contratos · ledger
② CATÁLOGO — Gravitino/Lakekeeper         qué existe · dónde · CAS   ⛔ define, NO aplica
① BYTES — R2
   AL LADO: DESCRIPTIVO (OpenMetadata) ← alimentado por OpenLineage

INDEX define · LA PUERTA aplica · EL CATÁLOGO registra · OM describe.

Gravitino NO APLICA (su propia doc: «won't check the privileges when Gravitino receives the requests»): administra y delega. ⇒ No opaca a Index en lo que Index hace de verdad — sólo en el vocabulario.

4·3 · ⭐⭐⭐ Vending vs política de fila — el estándar (§5·nonies)

  • Quién firma: NADIE. El catálogo VENDE. El remote signing es extensión del cliente.
  • La fricción es estructural: Iceberg es un formato de ficheros; quien recibe el fichero lo lee entero. Firmar da grano de fichero, no de fila.
  • Todos lo resuelven cortando: UC «tables with row filters or column masks are not supported with credential vending». Snowflake, Polaris, Dremio, Trino: motor de confianza.
  • ⭐ UC anunció Preview de grano fino para motores externos (first and only cross-engine ABAC, por etiquetas). Es la frontera del sector hoy.

El patrón a copiar, y ya tenemos las dos mitades:

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)

⭐⭐ El motor de confianza no hay que construirlo — lo tenemos. Falta la política (concepto 7) y las etiquetas (6).

4·4 · Absorber vs construir (§5·quater, §5·bis, §5·septies)

  • Se absorbe lo que DESCRIBE, se deriva lo que DECIDE. Criterio: si apagar esa pieza deja pasar una operación que antes se rechazaba, no era descriptiva.
  • 🏁 OpenLineage absorbido (L·1 verde): jar en el driver, ni pod ni cuota.
  • Atlas descartado por entero: no cabe (JanusGraph+HBase+Solr+Kafka+ZK), no aplica nada, y sería un segundo modelo de tipos y de permisos. Su modelo —proceso como entidad, propagación por el linaje— se adopta (conceptos 22-23).
  • La capa agéntica NO se construye sobre OM: el espacio de acción no se puede proyectar. Su MCP no se expone.
  • Activos heterogéneos: Gravitino ya gobierna TABLE · TOPIC · FILESET · MODEL. ⚠️ Pero nuestro punto de aplicación cubre UN tipo de activo ⇒ el primer activo no-tabla fuerza la decisión rama A/B.

5 · ⏭️ El orden sugerido para continuar

1 · B·0        el JWT de R2 a mano — 30 min de curl, sin cluster        ← DESBLOQUEA el cutover
2 · T·1·e      el canary de la ingesta POR LA PUERTA                    ← sin cluster
3 · el modelo en INDEX, en este orden:
     a) tags + tag_assignments sobre securables (y columnas)            ← el multiplicador
     b) connection + secret como SECURABLES                             ← lo exige el estándar
     c) extender `policies.type` con row_filter / column_mask           ← ⭐ NO construir: extender
4 · L·2        levantar OpenMetadata DECLARADO en infra/gke             ← node pool aparte
5 · C·3/C·4    re-registro y CAS — sólo tras B·4

⚠️ 1 y 2 no necesitan el cluster. 4 necesita un segundo nodo (cabe: quedan 8 CPU de cuota).


6 · Los artefactos nuevos de esta sesión

Manifiestos (infra/gke/)

FicheroQué
c0a-irc-postgres.yaml⭐ El IRC sobre Postgres: initContainers que componen libs/ (driver) y conf/ (los 9 catálogos)
c2-vending-gate.yamlEl gate del vending, con Spark contra el IRC
l1-openlineage-gate.yamlEl gate del linaje: listener, columnLineage y symlinks
t1-ingesta-ctas.yaml(previo) el canary de la ingesta — carril ②, ver §3·2

Sondas (scripts/)

ScriptContesta
warehouse/t10-resolucion-coordenada.ts¿resuelve la cara los nombres que ella misma creó?
warehouse/c2b-que-vende-lakekeeper.ts⭐ ¿qué vende Lakekeeper? Mira credenciales sin enseñarlas
governance/m0-censo-de-la-malla.ts⭐ la malla objeto a objeto: construir vs cablear

Código tocado

lib/workers/polling/dataset-writer.ts (−808) · lib/compute/junction.ts · lib/governance/catalog-names.ts (T·1·0) · lib/governance/table-materialise.ts (T·1·a/b) · scripts/check-dataset-rows-seam.ts (9→0) · tests: table-reconcile.test.ts (nuevo), catalog-names.test.ts, junction-pg-write.test.ts.


7 · 🪤 Las trampas medidas — para no repetirlas

kubectl get endpoints MIENTE en k8s 1.33+ (<none>): usar get endpointslices
HTTP 000 con el pod Ready: el Service apunta a targetPort: http y reescribir el Deployment perdió el NOMBRE del puertoPORTS <unset>. Preguntar por dentro (localhost) desempata
curl: (23) ERROR on write = permisos (uid 100 sobre volumen de root), no red
⛔⛔Un token MALFORMADO se rechaza igual que uno CADUCADO. B·0 dio 400 a las cuatro pruebas y los tres controles negativos —que sólo miraban «¿no fue 200?»— salieron verdes. Leer el <Code>: InvalidArgument (400, forma) ≠ AccessDenied (403, permiso)
⚠️La credencial de R2 caducada devuelve SignatureDoesNotMatch, no ExpiredToken — R2 deriva el secreto del JWT ⇒ un JWT muerto se ve como una firma que no cuadra
⚠️El ejemplo publicado por Cloudflare no pasa: el claim actions da 400 en toda forma. Un ejemplo de doc no es un control
⛔⛔Un loadTable en 200 puede estar sirviendo la llave LARGA. El síntoma es MUDO: el cliente funciona igual. Sólo el log del IRC lo decía (Generate credential: s3-secret-key). Que la respuesta sea 200 no dice qué credencial lleva dentro
⛔⛔La fuente que copias puede no haber tenido nunca el valor bueno. El Secret irc-catalogs dice s3-secret-key; el volteo a r2-token lo hace un sed del initContainer sobre el conf ⇒ el valor bueno sólo existía en memoria del pod. Copiar «la configuración» copió el valor pre-volteo
El IRC cachea el wrapper del catálogo por NOMBRE (expireAfterAccess, 1 h): un catálogo nuevo aparece al instante, uno modificado tarda hasta una hora — y reusar el nombre de una sonda anterior sirve la config vieja
⚠️Clonar una entidad ≠ reenviar lo que devuelve el GET: trae propiedades DERIVADAS (in-use) que el POST rechaza con 400 sin mensaje. Y Gravitino no borra un catálogo «en uso» (409): hay que deshabilitarlo antes
⛔⛔kubectl port-forward deploy/… durante un rollout se engancha al pod que aún TERMINA ⇒ se mide la config VIEJA. Dio un «There are no credential provider» con la clave ya puesta en el pod nuevo. rollout status en verde no garantiza que el viejo haya muerto
⚠️Los 9 catálogos-de-inquilino están VACÍOS (C·3 congelado) ⇒ una sonda de vending tiene que crearse su tabla. Y no vale el catálogo lakehouse: apunta a s3://lakehouse/warehouse, el prefijo de producción. El área de sondas es _probe-gravitino
⛔⛔La imagen del IRC NO trae unzip. Un barrido unzip … 2>/dev/null sobre los 174 jars dio cero y la lectura natural era «no hay providers registrados» — lo contrario de la verdad (hay 9). Censar con el mecanismo del propio consumidor (ServiceLoader), no con el que uno tenga a mano
El .conf del IRC lleva credenciales en claro — no volcarlo
⚠️rewrite_config.py borra y reescribe el conf ⇒ montarlo read-only revienta; pero conserva las claves que no conoce
⚠️dotenv-cli se traga los flags posicionales (-e de tsx)
⚠️PowerShell rompe las comillas anidadas en kubectl run --command -- sh -c '…' con JSON ⇒ usar un manifiesto de Pod + ConfigMap
⚠️Los previews de Vercel están detrás de Vercel Authentication ⇒ un motor externo recibe HTML
⚠️vercel env pull NO devuelve los valores de variables sensibles (vienen vacías)

8 · ⭐ Las reglas nuevas (sustrato-07 §13·23, nº 58-79)

Las que más se van a necesitar:

  • 58 Antes de canarizar un camino, mirar por cuál entra el producto.
  • 59 Una hipótesis que explica el síntoma no es la causa hasta que se mide.
  • 64 «No existe» y «existe vacía» son dos planes distintos, y se leen igual.
  • 66 Se absorbe lo que describe; se deriva lo que decide.
  • 70 El espacio de acción no se puede proyectar.
  • 71 Si apagarlo te deja sin nada, lo pusiste en el sitio equivocado.
  • 72 Un securable sin puerta es una fila.
  • 73 Administrar no es aplicar, y casi todo el mercado administra.
  • 76 Un endpoints[] es un folleto muy bien escrito — declarar no es servir.
  • 77 «Eso habría que quitarlo de todas formas» es la coartada más cómoda para un rodeo.
  • 78 El grano fino exige un motor de confianza — nadie lo aplica en el catálogo.

9 · Comandos que se corren

# en frío
npm run check:dataset-rows-seam            # ✅ 9 → 0 en dataset-writer
npx vitest run lib/compute lib/governance  # 494 verdes

# con .env.local
npx dotenv -e .env.local -- npx tsx scripts/warehouse/c2b-que-vende-lakekeeper.ts
npx dotenv -e .env.local -- npx tsx scripts/governance/m0-censo-de-la-malla.ts
npx dotenv -e .env.local -- npx tsx scripts/warehouse/t10-resolucion-coordenada.ts

# reactivar el cluster (hoy suspendido)
kubectl patch sparkconnectserver carbon-connect -n spark --type=merge \
  -p '{"spec":{"clusterOperation":{"stopped":false}}}'
kubectl scale deploy/spark-bridge -n spark --replicas=1
kubectl scale deploy/gravitino-irc -n catalog --replicas=1

⚠️ 4 fallos de test PREEXISTENTES en el árbol (read-client, namespace-resolution, projection-sql) y 20 errores de tsc (2 en studio workers + scripts sueltos): verificados como anteriores a esta sesión.