Sustrato · 07 — la arquitectura entera, tras el viraje
Fecha de la medición: 2026-08-12 · ⭐ actualizado el 2026-08-13 (§13).
Sucede asustrato-06
(08-11),05,04,03,
02y01, 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:
- 🏁 N·5 CERRADO y verificado en producción por el owner: la coordenada
direcciona; los nueve nombres duplicados dejan de ser ambiguos.- 🏁 El primer VERBO nuevo en producción —
ALTER TABLE … ADD COLUMN— y con su
reconciliación de Index, que es la mitad que hace que no mienta.- ⭐⭐⭐ 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.- ⭐⭐ 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.- ⭐ 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.- ⭐⭐⭐ 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
| Formato | Apache Iceberg · runtime iceberg-spark-runtime-4.1_2.13-1.11.0.jar |
| Almacenamiento | Cloudflare R2, bucket lakehouse, WEUR |
| Perímetro | warehouse-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
| Tipo | Iceberg REST (type=rest), en Railway |
| Warehouses | 9 — 1 compartido + 8 por inquilino · 7 de 8 ya se sirven del suyo |
| Endpoints declarados | 25 |
⛔ planTableScan | NO lo implementa — ninguno de plan / plan-tasks |
⭐ …/tables/{table}/credentials | SÍ: 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 %)
| Motor | Papel |
|---|---|
| ⭐ spark | el único ELEGIBLE: elegirMotorDeLectura devuelve spark en sus dos ramas |
| duckdb | disponible, no elegido en ninguna rama |
| karma · mlrunner · pg | vivos, 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_context— y dos defectos que lo invalidan:
- escribe los objetos en PG, sin pasar por la puerta;
- 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 uno | El modelo maduro concede sobre etiquetas |
| 🟡 | Karma con planificación propia | Quien planifica en cliente se salta la política |
| 🟡 | OpenMetadata como fuente de verdad | Degradado 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:
| Frente | Se atasca en | Y es lo mismo |
|---|---|---|
| Verbos | DML_VERBS_PERMITIDOS | Prohíbe globalmente porque no puede autorizar por objeto |
| Políticas de celda | no se pueden probar | Una política sin sujetos no tiene a quién aplicarse |
| Superficie del cliente | no existe | «Que el cliente defina sus roles» es administrar grants |
| Actions | tienen su propio permiso | Se 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 encadenados | n4-la-coordenada-desambigua.ts · verificado en producción por el owner |
| 🏁 | ⭐ ALTER TABLE … ADD COLUMN en producción, con reconciliación de Index | p5-canary-alter-add-column.ts → 4/4 |
| 🏁 | El plan del motor MANDA en el destino, con fallo cerrado | p3-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 servidor | f0-scan-planning.ts |
| 🏁 | F·1 · el vendado, contestado por el estándar | UC · Polaris · Dremio · Snowflake |
| 🏁 | Sesión de planificación — rompe la circularidad de orden | 9 tests, incluido lo que NO lleva |
| 🏁 | El copiado del editor en JSON, sin romperse con BIGINT | control 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
| 🏁 | HECHO 08-12 (§12): 12 sujetos derivados · aplicador cableado y apagado (USER_AUTHZ=off) ⇒ queda encenderlo |
| 🏁 | NO HACEN FALTA (§12): la herencia por la cadena ya lo es. Medido sobre los 123 datasets |
| ⛔ Política de celda | No hay modelo, ni etiquetas, ni aplicador |
| ⛔ Un solo modelo de tipos y permisos | Hoy 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:
- ⭐⭐⭐ 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?
- ⭐⭐ CUANDO EL MODELO DE AUTORIZACIÓN NO ALCANZA, CADA SUPERFICIE SE FABRICA EL
SUYO.
can_execute_action_typeno es una rareza: es el síntoma. Una pieza ausente explicaba cinco síntomas que parecían independientes. - ⭐⭐ 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.
- ⭐⭐ UN GUARDIÁN CON CONDICIÓN DE RETIRADA HAY QUE RELEERLA — Y DOS EN UNA SESIÓN
YA NO ES CASUALIDAD.
ALTEReINSERTbloqueados 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ó. - ⭐⭐ UN VERBO SIN SU RECONCILIACIÓN NO ES UN VERBO NUEVO: ES UNA MENTIRA NUEVA.
ADD COLUMNsin actualizar Index dejaría Iceberg con 8 columnas e Index diciendo 7, sin que nada falle. - ⭐⭐ 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. - ⭐ 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
READlo que el motor no decía. El veredicto se apoya en lo autoritativo, y se dice cuál es. - ⭐ 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.
- ⭐⭐⭐ 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 tieneowneryadmin. 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. - ⭐⭐ 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.
- ⭐⭐ 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. - ⭐ UNA COLUMNA CON NOMBRE DE DUEÑO NO ES EL DUEÑO.
datasets.created_bymezcla 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
| Doc | Para qué |
|---|---|
| ⭐ este | la foto completa. Manda sobre todo lo demás |
⭐⭐⭐ index-gobernanza-mapa.md | la 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.md | el approach VIVO del plano gobernado · F·0-F·7 · bitácora por sesión |
⭐⭐ escrituras-sustrato-debilidades.md | por qué un verbo cuesta tanto, y qué le falta a la escritura |
⭐⭐ parser-al-motor-spec.md · p1-inventario-verbos.md | el parser al motor · las 28 formas medidas |
⭐ errores-gobernados-approach.md | condición + SQLSTATE + parámetros · y por qué toca a OpenFGA |
⭐⭐ carbon-sql-contract-approach.md | el contrato y los corpus |
🧊 06 · 05 · 04 · 03 · 02 · 01 | congelados |
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_members | g1-derivar-grants-de-usuario.ts --apply → 24/24 |
| 🏁 | ⭐⭐ La separación de deberes, en vivo: sólo owner concede; admin administra el dato y no la retícula | g2-aplicador-de-usuario.ts → 17/17 |
| 🏁 | El aplicador para HUMANOS (user-authz.ts), cableado en lectura, escritura y CREATE · USER_AUTHZ off/shadow/enforce | tsc · 788 tests |
| 🏁 | ⭐⭐ Los future grants NO hacen falta: la herencia por la cadena ya lo es | g3-future-grants.ts → 5/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:
- ⛔
datasets.created_byno 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. - ⭐⭐⭐ El guardián del
CREATEllevaba su condición de retirada escrita y caducó — tercera vez (regla 49). - ⚠️ Dos sondas verdes que no habían medido el caso
viewer— el único inquilino con datos sólo tieneowneryadmin. Ver la regla 54.
13 · 2026-08-13 · LA INGESTA, Y LOS DOS CARRILES AL CATÁLOGO
13·1 · 🏁 El brazo PG de la ingesta, RETIRADO
dataset-writer.ts llevaba dentro un motor de escritura completo contra dataset_rows
—PostgresBackend (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·0 | physicalFromLogical resuelve por (workspace, schema_id, name) — la MISMA clave con la que materializeTable comprueba la colisión. La ubicación queda de respaldo | El que CREA y el que RESUELVE usan la misma clave |
| 🏁 T·1·a | compensateIfPhantom — 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·b | reconcileDropInIndex — soltar la tabla la suelta de Index | No 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 | |
|---|---|
| ✅ Confirma | la ingesta como sentencia es el modelo del sector — y entra por el catálogo gobernado, que es el viraje de §13·2 |
| ⭐⭐⭐ Corrige | federar ≠ 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·3 | el secreto no necesita un broker: es un securable que el motor consume por referencia. La retícula ya la tenemos; falta el objeto |
| ⛔ Deja obsoleto | la 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ó) |
| 🟡 Explica | por 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 bien | El 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 absorbe | JanusGraph+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 reordena | Polaris 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íamos | mergeUpstreamProvenance (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
| ⭐⭐⭐ OpenLineage | El 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 |
| ⭐⭐ OpenMetadata | El 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íamos | La 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 apagado | Las policies y roles de OM — igual que el OpenFGA de Lakekeeper. Encenderlos sería la segunda retícula |
| ⚠️ Lo que NO resuelve | Sigue 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 --conf —
io.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 upsó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}/plan | ⛔ | ✅ la diferencia real |
namespaces/{ns}/register | ✅ | ✅ ⇒ migrar es re-registrar |
⛔ transactions/commit | ✅ declarado | ⛔ no 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 pierde | El 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 toca | La 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 commit ⇒ sin 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ón | UC 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 | |
|---|---|---|
| Red | una llamada externa por vending | cero: se computa |
| Permiso | ⚠️⚠️ token «Admin Read & Write» de la CUENTA | el 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
-
⭐⭐⭐ 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.
-
⭐⭐ 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. -
⭐⭐ 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.
-
⭐ LA DUDA NO BORRA. Una compensación que se dispara con
!res.okconvierte un fallo transitorio en pérdida de gobierno. Se pregunta al catálogo y se actúa sobre un «no existe» comprobado. -
⭐⭐⭐ DOS COSAS QUE SE HACEN CON EL MISMO SQL NO SON LA MISMA COSA. Federar y ingerir se parecen —las dos acaban en un
SELECTsobre 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. -
⭐⭐ 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.defaultque 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. -
⭐⭐⭐ «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.
-
⭐⭐ 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.
-
⭐⭐⭐ 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.
-
⭐⭐⭐ DESCRIBIR PIDE TIPOS ABIERTOS; GOBERNAR PIDE TIPOS CERRADOS. Lo más vistoso de Atlas —el
typeDefextensible 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? -
⭐⭐⭐ 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.
-
⭐⭐⭐ 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.
-
⭐⭐⭐ 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.
-
⭐⭐ 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.
-
⭐⭐⭐ UN SECURABLE SIN PUERTA ES UNA FILA. Ampliar
securable_kinda 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. -
⭐⭐⭐ 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.
-
⭐⭐⭐ 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 fueHTTP 000con el podReady, la IP correcta y las labels correctas — que se lee como red o como servidor caído, cuando el servidor respondía perfectamente enlocalhost. Lo que desempata siempre es preguntar POR DENTRO. Corolario:kubectl get endpointsmiente en k8s 1.33+ (<none>, porque el recurso legacy ya no se puebla); lo autoritativo esget endpointslices. -
⭐⭐ 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 elmetadata.jsonde R2— sólo mejora 3×, porque R2 domina y no se mueve. La mejora sigue siendo real: lo que no es real es el titular. -
⭐⭐⭐ 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. -
⭐⭐⭐ «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.
-
⭐⭐⭐ 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.
-
⭐⭐ 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_tagsya teníaconfidenceysource, o sea que el vocabulario se pensó una vez… colgando del objeto equivocado. -
⭐⭐⭐ 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) yAccessDenied(403, el permiso) contestan preguntas distintas, y leer el<Code>en vez del «no-200» es lo que las separa. -
⭐⭐ 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.
-
⭐⭐ 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. -
⭐⭐⭐ LO QUE DESPACHA POR CLASE NO ATIENDE A TU NOMBRE. Gravitino traduce la credencial al cliente Iceberg con un
instanceofde la clase concreta, no con el tipo declarado: unaCredentialpropia 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. -
⭐⭐ 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 traeunzipy el2>/dev/nullse comió el error. La lectura natural —«no hay ninguno»— era lo contrario de la verdad (hay nueve). Rehecho con elServiceLoaderque 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. -
⭐⭐⭐ «DESPLEGADO» NO ES «SIRVIENDO LO DESPLEGADO». Un
loadTablecontestó «There are no credential provider» con la clave ya escrita en el conf del pod nuevo y elrollout statusen 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. -
⭐⭐ 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
checkAccessde Gravitino, que sin authz cableado dice que sí a todo ⇒ toda credencial saleread-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 unifajeno que hoy está entrue. -
⭐⭐⭐ 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 «elJdbcCatalogsepara porcatalog_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 credencialread-writesobre 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. -
⭐⭐⭐ ANTES DE EXTENDER, PREGUNTA CÓMO LO RESUELVE EL SECTOR. Íbamos a escribir un
IcebergConfigProviderpropio 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. -
⭐⭐⭐ UN 200 NO DICE QUÉ LLEVA DENTRO. Tras mover el catálogo al provider dinámico,
loadTableseguí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. -
⭐⭐ 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 ar2-tokenlo hacía unseddel 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. -
⭐⭐⭐ 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 rutaAcceptedy el pod sano (su health check es unGET /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.