📇 PIEZA · INDEX-CATALOG — el plano de control
| Versión | v2.0 — 🏁 los 24 módulos leídos: la imagen está completa |
| Estado | ⚠️ Cinco pilares construidos · dos de ellos APAGADOS · uno vacío |
| Última medición | 2026-08-12 |
⚠️ ALCANCE DE ESTE ESTUDIO, dicho antes de nada. Está medido: las 15 tablas
con sus filas · el listado de módulos y sus 4.668 líneas · las cabeceras de los cinco
pilares ·PolicyTypeySECURABLE_HIERARCHYleídos del fuente · los 8 recursos de
la Management API y su 404 en producción · los interruptores en el código · y las
cifras de C2 y C3 contra la base.🏁 v2.0 — LOS 24 MÓDULOS LEÍDOS. Ya no queda alcance por declarar: las 4.668
líneas delib/governance/están estudiadas, y cada afirmación de esta pieza sale
del fuente o de una medición contra la base.
Qué es
El tercer vértice del tridente. Los otros dos son de otros: R2 tiene los
bytes y Lakekeeper tiene los punteros. Index tiene todo lo demás — y «todo
lo demás» es lo que convierte un lago de ficheros en un catálogo gobernado.
R2 los bytes Parquet ← no sabe de nadie
Lakekeeper los punteros Iceberg ← el CAS del commit
⭐ INDEX quién existe · quién puede · qué se escribió · quién tocó · bajo qué regla
El invariante que lo define
Index guarda lo que el catálogo NO PUEDE saber.
Un catálogo Iceberg sabe qué tablas hay y dónde están sus ficheros. No sabe de inquilinos, ni de roles, ni de por qué se escribió algo, ni de quién lo leyó, ni de qué regla de retención rige. Eso es Index.
Qué NO es
- ⛔ No es la base de datos que lo aloja. Index Catalog son 15 tablas y 6,5 MB
— el 1,21 % del Postgres donde vive.
brain_embeddings,chat_sessionsy los 27.042objectsdel plano ontológico no son Index. - No es el catálogo de registro. El commit lo serializa Lakekeeper; Index nunca serializa escrituras (invariante nº4).
- No es un servicio. Es un esquema + 4.668 líneas en
lib/governance/que consultan la puerta, la cara y el escritor. - No aplica por sí mismo. Es la fuente; quien impide son la cara y Junction.
Los cinco pilares, y su estado real
| Pilar | Qué gobierna | Estado | |
|---|---|---|---|
| C1 | POLICY — política tipada, adjuntable y heredable | La regla que rige un securable | ⚠️ construido y VACÍO: 0 políticas, 0 adjuntos |
| C2 | ACCESS EVENTS — los hechos de uso | Quién tocó qué, y si se le negó | ✅ VIVO: 470 eventos en 24 h · 456 denegaciones |
| C3 | PRIVILEGE — vocabulario y cadena de roles | Quién puede qué | 🏁 248 filas de grant · aplicando (GRANTS) |
| C4 | MANAGEMENT API — el plano de control | Administrar todo lo anterior por API | ⛔ APAGADO: /api/management/v1/* → 404 |
| C5 | LA CARA — cómo Index habla con Lakekeeper | El punto de aplicación | ✅ viva, 1 de 4 carriles (ICEBERG-FACE) |
Las cifras de C3, cada una con su unidad
privilege_grants 248 filas ← los GRANTS (privilegio × securable × catalog role)
principal_grants 413 filas ← la VISTA aplanada (principal × rol × grant)
de ellas, de usuario 45
sujetos `user` distintos 12
⚠️ Son unidades distintas y no se suman. (Corrección: una versión anterior de esta
pieza decía «380 grants», que era 368 + 12 — el conteo de la vista antes de la
derivación más el número de sujetos. Dos magnitudes diferentes sumadas como si
fueran la misma.)
C2, medido de verdad — no por la existencia de su variable
total 4.558 · últimas 24 h: 470 · últimos 7 días: 4.474
más reciente: 2026-08-12 11:14
por desenlace: ok = 4.102 · ⭐ denied = 456
por superficie: iceberg-rest = 3.338 · storage-vending = 1.137 · plan-auditor = 83
⭐⭐ Tres cosas que sólo se ven con este detalle:
- 456 denegaciones registradas. La gobernanza no sólo se declara: deja rastro de lo que impidió.
- Emite desde TRES superficies, no una: la cara Iceberg, el vending de
almacenamiento (1.137 — la acuñación de credencial también deja asiento) y el
propio auditor del plan (83, en
shadow: mide y anota). - ⭐ La tabla ya tiene columna
policy_id. El cruce «qué se usó / bajo qué política» está pre-cableado — esperando a que C1 tenga filas.
⚠️ (Y la lección de método: la versión anterior daba C2 por «encendido» porque
ENABLE_ACCESS_EVENTS existía en Vercel. Es el mismo error que esta arquitectura
ya tiene catalogado — available = cableado ≠ funcionando. Lo que lo prueba son los
470 eventos de hoy, no la variable.)
⭐⭐ Y el patrón que comparten todos: puro + I/O, separados
policy.ts (PURO: resolvedor de herencia) · policy-queries.ts (Postgres)
privilege.ts (PURO: vocabulario + decisión) · privilege-queries.ts (Postgres)
membership-grants.ts (PURO: la proyección) · …-queries.ts (Postgres)
«No toca la base de datos, no importa el cliente, no hace I/O. El resolvedor es una función de (cadena de securables, adjuntos) → políticas efectivas, y por eso se puede probar exhaustivamente sin infraestructura.»
⇒ La decisión de gobernanza es código puro y probado; la persistencia es un detalle. Es la propiedad más fuerte de la pieza — y lo que haría barato mudar el almacén.
C1 · La política — construida, heredable, y sin una sola fila
Lo que colapsó, y por qué importa. Antes de C1 la «política» estaba esparcida en cinco superficies que no compartían forma, no se heredaban y no aplicaban nada:
retention_policies (0) · egress_policies (0) · credential_egress_policies (0)
file_access_grants (2, «y su propia cabecera dice que no aplica»)
tier-policy.ts (una constante en un fichero que sólo lee un componente de UI)
⇒ La forma de Apache Polaris las colapsa en UNA primitiva: política tipada +
adjunta a un securable + resolución que hereda por la cadena
workspace › catalog › schema › dataset.
⛔ Pero los siete tipos que existen NO son de acceso
export type PolicyType =
| 'retention' | 'snapshot_expiry' | 'compaction' | 'orphan_cleanup'
| 'egress' | 'tier_expectation' | 'custom'
Todos son de mantenimiento y ciclo de vida. ⛔ No hay row_filter ni
column_mask. La primitiva de política existe, hereda y está probada —
y la política de datos no está entre sus tipos.
⭐ Y la semántica de combinación ya está resuelta (override — gana el más
específico — «no existen dos ventanas de retención vigentes a la vez»), que es
justo la decisión que una política de celda tendría que tomar al revés: las de
celda se ACUMULAN, no se sobreescriben.
C4 · El plano de control existe entero — y devuelve 404
management-api.ts calca la Management API del estándar y declara ocho recursos:
principal-roles · principal-role-members · catalog-roles · catalog-role-bindings
grants · policies · policy-attachments · usage
curl https://app.paladio.io/api/management/v1/grants # → 404
curl https://app.paladio.io/api/management/v1/policies # → 404
Porque ENABLE_INDEX_MANAGEMENT_API no está puesto: la ruta contesta
«plano de control no habilitado».
⇒ ⛔ Hoy la única forma de administrar Index es SQL directo o un script. Y eso es exactamente lo que bloquea «que el cliente defina sus roles» — la superficie del cliente no es un desarrollo pendiente: es un interruptor apagado sobre una API que ya está escrita.
⏭️ ACTUALIZACIÓN · 2026-08-15 (tarde) — el interruptor sigue apagado, y ya no bloquea
C4 continúa inerte a propósito, pero dejó de ser el cuello: se añadieron dos
superficies que administran Index sin encenderla.·
/api/governance/[...path]— los mismos recursos y la misma lógica (comparte
MANAGEMENT_RESOURCES,decidePrivilege,assertGrantabley el evento de acceso),
pero autenticada por el token del realm de la persona en vez de por un bearer de
servicio. Encender C4 y llamarla con un token de servicio habría costado menos código
y habría roto lo más caro de arreglar: el evento de acceso diría que quien concedió
fuecarbon-app, no la persona.· El SQL Editor —
CREATE ROLE,GRANT … ON … TO ROLE,GRANT ROLE … TO 'persona',
SHOW ROLES. Interceptado antes del motor y contestado por Index.
Verg6-gobernanza-sql-approach.md.⇒ «Que el cliente defina sus roles» ya no espera a un interruptor: espera a un
deploy. Y C4 se queda apagada porque su público es máquina-a-máquina, que es otro
frente y otro perfil de riesgo.
Los siete módulos que faltaban por estudiar
(Estudio hecho el 2026-08-12. 1.096 líneas leídas.)
⭐⭐⭐ El trío del VENDING — y reencuadra un hallazgo anterior
storage-vending.ts · el motivo. Hasta que existió, «cada motor llevaba una llave
de R2 estática y de todo el bucket en su entorno: leía los bytes de todos los
inquilinos, se autorizara lo que se autorizara arriba».
⭐ «Toda la gobernanza que construimos vivía por encima de una puerta trasera
abierta.»
Y por qué lo hacemos nosotros y no Lakekeeper: su vending contra R2 está roto
—lakekeeper#1630: la Temp-Credentials API de R2 no soporta sus actions—. Lo que no
está roto es la API de R2 llamada directamente.
⭐ «Esto es el vending de la spec, hecho por nosotros mientras el catálogo no pueda.
El día que pueda, se retira este módulo y el motor no se entera: lo que viaja por
el cable es idéntico.»
credential-vending.ts · la máquina, como superficie propia. Antes acuñar era «un
efecto secundario de loadTable»; ahora es el patrón token vending machine de AWS —
«credencial para el inquilino T, sobre P, para hacer O» — y loadTable es un cliente
más. Es la primera vez que TABLE_WRITE_DATA decide algo.
⭐⭐⭐ Y aquí está el hallazgo que reencuadra la cara
La spec de Iceberg REST no tiene forma de declarar intención de escritura. Un
escritor pideloadTableigual que un lector, escribe sus ficheros con la
credencial que reciba, y sólo DESPUÉS commitea. ⇒ Si la credencial se acuña mirando
la operación,loadTablees siempre lectura y ningún motor puede escribir jamás
por la puerta.Y eso es literalmente lo que pasaba: un
INSERTde Spark moría con403 al cerrar el Parquet— después de que la puerta hubiera dicho que sí.⭐⭐ «El único escritor que funcionaba,
warehouse-writer, lo lograba SALTÁNDOSE
LA CARA.»
⇒ ICEBERG-FACE medía el síntoma; esto es la causa. La ingesta no
esquiva la cara por descuido de configuración: era la única forma de que funcionara,
y mode: 'auto' —donde el modo lo decide el PRIVILEGIO, no la operación— es lo que
lo arregla. «El mínimo privilegio no se relaja: se calcula bien.»
Dos cosas que el módulo dice en voz alta en vez de disimular
| ⚠️ La asimetría del alcance | lectura → acotada a la TABLA (su prefijo sale del metadata-location) · escritura → acotada al INQUILINO. Y es inevitable, no perezoso: al escribir, la tabla puede no existir todavía — su ruta la asigna el catálogo al crearla |
⚠️ scopeKind: 'shared' | Una organización sin espacio propio recibe el prefijo COMPARTIDO. «Estrictamente mejor que la llave de bucket de hoy y estrictamente peor que un espacio propio. Por eso el resultado lo DICE, en vez de disimularlo: una credencial de respaldo que se ve igual que una buena es cómo un estado transitorio se vuelve permanente sin que nadie lo decida» |
r2-local-sign.ts · acuñar sin llamar a nadie.
API de R2 firmado LOCAL
latencia 100-300 ms ≈ 1 ms (en proceso)
cuota consume NO consume
acota por prefijo sí sí
acota por acciones no 🔴 tampoco (medido)
⭐ El presupuesto es la razón de fondo: la REST API de Cloudflare admite 1.200 peticiones cada 5 minutos POR CUENTA, compartidas por todo. «Poner eso en el camino de cada escritura es atar el rendimiento de la plataforma a una cuota ajena.»
🔴 Y la promesa que no se cumplió: la doc de R2 anuncia actions —la lista
explícita de operaciones S3— como exclusiva del firmado local, y era lo que lo
acercaba a una session policy de AWS. Medido: R2 la rechaza en las cinco variantes
probadas (InvalidArgument: X-Amz-Security-Token). Queda el preset scope, y basta.
⚠️ Y una consecuencia que hay que no olvidar: el accessKeyId es el del token
padre. No es una fuga —sin sessionToken da SignatureDoesNotMatch— pero significa
que en los logs de R2 todos los accesos se ven iguales.
⇒ La atribución por inquilino vive en
access_eventsy en ningún otro sitio.
Eso explica los 1.137 eventos destorage-vendingmedidos arriba: no son un
extra, son la única traza que existe.
Los otros cuatro
write-claim.ts · por qué las transacciones se quedaban colgadas. ratify busca el
snapshot cuyo carbon.operation-id sea el id de la txn del ledger; la cara firmaba
con un uuid propio. Nunca casaban ⇒ «las transacciones se quedan open para
siempre, que es el estado medido: 20 colgadas, la más vieja del 01-08».
⚠️ Y no se propaga la cabecera, que era lo obvio, porque se midió y no funciona: «Spark fija las cabeceras del catálogo al CREAR la sesión, no por petición, y la sesión es caliente y compartida por inquilino (recrearla cuesta el 47× de sesión fría)». ⇒ el pool de sesiones de COMPUTE es lo que impide propagar la firma. Dos piezas que parecían independientes están atadas.
schema-reconcile.ts · Index guarda su PROPIA copia del esquema (datasets.schema,
column_count, dataset_schema_versions) «y de ella comen los pickers de datos, el
linaje, la UI del Warehouse y la ontología». Medido: esa copia sólo se escribía al
nacer la tabla o desde los caminos de ingesta — ninguno es el editor SQL.
⇒ Sin esto, un
ALTER TABLE … ADD COLUMNdejaría Iceberg con 8 columnas e Index
diciendo 7, y todo el producto enseñaría un esquema rancio sin que nada falle.
Un verbo sin su reconciliación no es un verbo nuevo: es una mentira nueva.
lossless-json.ts · el bug de 64 bits, con su medida.
Lakekeeper manda : current-snapshot-id = 2624199754595337768
Node JSON.parse : current-snapshot-id = 2624199754595337700 ← destruido
No lanza. No avisa. Los últimos dígitos simplemente cambian. Y el síntoma aparecía
tres capas más abajo y sin nombrar la causa: CommitFailedException: CatalogCommitConflicts — un conflicto de concurrencia donde no había concurrencia.
policy-queries.ts · lo único de C1 que habla con Postgres, y su regla: «la cadena
se RESUELVE contra la BD; no se deriva de nombres ni se asume».
Y los cinco últimos — la imagen ya está completa
(1.074 líneas más. Con esto, los 24 módulos leídos.)
access-event.ts · la telemetría que no puede tumbar el producto.
R1 — EMITIR NUNCA PUEDE ROMPER LA LECTURA. «Una telemetría que tumba el camino
caliente se apaga en la primera incidencia, y entonces no hay telemetría.»
Por eso nada lanza hacia fuera: recordAccessEvent es síncrono, no devuelve
promesa que esperar, y «su peor caso es descontar un evento en un contador».
buffer acotado BUFFER_LIMIT = 500 ← al llenarse DESCARTA y CUENTA (`dropped`)
vaciado FLUSH_AT = 100 ← dispara sin esperar al siguiente tick
⭐ «Perder telemetría es aceptable; crecer sin límite en el proceso no.»
⚠️ Y una guarda que su propia prueba destapó: sin el cerrojo de «vaciado en
curso», BUFFER_LIMIT era código muerto — flush hace el splice antes del
primer await, o sea de forma síncrona, así que el buffer se vaciaba al tocar el
umbral y jamás llegaba al tope.
Y tres decisiones de contenido que valen más que la mecánica:
| ⭐ R4 · el literal NUNCA se guarda | Un WHERE email = 'ana@x.com' metería PII en la tabla de auditoría. Se guarda el hash de la FORMA — suficiente para «qué se lee», «quién lee» y «qué no toca nadie» |
| ⭐ R5 | «El intento denegado es el evento más valioso, y casi nadie lo guarda.» ⇒ los 456 denied medidos |
⚠️ null = «no se sabe», NUNCA 0 | «Tres de nuestros cuatro motores no pueden reportar filas ni bytes.» Un 0 fingido convertiría «no medido» en «no leyó nada» |
catalog-tenant.ts · cómo un motor declara en nombre de quién pregunta. La cara
sacaba el inquilino del nombre del namespace, y eso estaba roto: los namespaces
vivos son main.default y main.test —«el canary opaco nunca se encendió»— así que
toda operación de namespace devolvía 403. Y hay un caso que ninguna forma de
nombre arregla: listNamespaces (el SHOW SCHEMAS de cualquier motor) no lleva
namespace.
⭐ La solución no es nuestra: es de la spec. El prefix de Iceberg REST — el motor
pide /v1/config?warehouse=w_<ws>, la cara le devuelve el prefix, y desde ahí
todas sus rutas lo llevan. Lakekeeper lo usa para su warehouse-id; nosotros para
el inquilino. ⇒ el inquilino viaja en TODAS las operaciones, listNamespaces
incluida.
catalog-names.ts · y aquí Unity nos corrige un detalle de diseño. Hasta el
2026-08-10 el nombre lógico sólo existía en Index y la cara exponía ds_<uuid> como
nombre de tabla. Funcionaba porque duck-server lo resolvía con su propio AST — «y
dejó de funcionar en cuanto entró un segundo motor»: Spark recibía
SELECT * FROM diets y contestaba TABLE_OR_VIEW_NOT_FOUND.
⭐⭐ «Unity Catalog — el cliente pide la tabla POR NOMBRE y el catálogo resuelve
su ubicación; el directorio físico lleva un nombre generado al azar para evitar
colisiones. Es exactamente nuestrods_<uuid>… sólo que ellos lo ponen en la
UBICACIÓN y nosotros lo pusimos en el NOMBRE.»
Y el argumento que lo cierra: traducir en la capa de arriba obliga a repetirlo una vez por motor y otra por superficie.
table-materialise.ts · por qué materializa la CARA y no la puerta. El approach lo
planteó al revés, y al hacerlo apareció el motivo: la puerta no tiene el esquema —
está dentro del DDL, así que habría que parsear SQL en la puerta, «que es lo que
este repo evita en todas partes: el destino se DECLARA, no se parsea».
La cara lo recibe ya estructurado, en el body del createTable. Cero parseo.
motor → createTable {name:"ventas", schema:{…}}
cara → resuelve el schema de Index desde el namespace (main.test → schema `test`)
→ CREA el dataset en Index ⇒ uuid
→ reescribe el body con name: ds_<uuid>
→ reenvía a Lakekeeper
management-api.ts · y el invariante que justifica que existan DOS APIs.
⭐⭐⭐ «La superficie de Index es una SPEC ABIERTA, nunca una API propietaria.
Index puede ser el punto único PORQUE habla Iceberg REST; el día que hable sólo
lo suyo, es lock-in con otro nombre.»
El plano de control de Index era Next, es decir una API propietaria — justo lo que ese invariante prohíbe. C4 lo calca de la Management API de Polaris, y fija la separación como invariante:
la API de DATOS nunca muta gobernanza
la API de CONTROL nunca sirve datos
«No es estética: son dos superficies con perfiles de riesgo, de latencia y de
auditoría distintos. Mezclarlas es cómo un GET acaba pudiendo conceder un
permiso.»
⭐⭐⭐ De qué DEPENDE Index para existir
Y aquí está el punto de inflexión de esta pieza:
┌──────────────────────────────────────┐
INDEX CATALOG ──▶│ el CATÁLOGO DE REGISTRO (hoy Lakekeeper) │
(la autoridad) │ · el nombre → puntero de metadata │
│ · el CAS del commit ← la ATOMICIDAD │
└──────────────────────────────────────┘
Index no puede existir solo. Sabe quién puede y bajo qué regla, pero no sabe dónde están los ficheros ni puede serializar un commit. Esa mitad la pone el catálogo de registro — y por eso la conversación de Lakekeeper ↔ Gravitino es una conversación sobre Index, no sobre una pieza de infraestructura ajena.
⚠️ Y las dos mitades viven separadas por un océano: Index en us-east-1
(Supabase), el catálogo en Railway, el cómputo en europe-west1
(mapa §8·quater·bis).
Frente a Unity Catalog / Snowflake Horizon — qué tenemos y qué falta
| Concepto | UC / Horizon | Index hoy |
|---|---|---|
| Securables con jerarquía | ✅ | ✅ workspace › catalog › schema › dataset |
| Privilegios con herencia e implicaciones | ✅ | ✅ 40 privilegios · cadena de roles · aplicando |
| Propiedad de primera clase | ✅ | ⛔ created_by es «quién lo tecleó» |
| Política como objeto versionado con dueño | ✅ | ⚠️ objeto sí, 0 filas, y sin tipos de acceso |
| Etiquetas / ABAC | ✅ (GA 2026) | ⛔ no existe — y el hueco ya está reservado en el contrato de la puerta |
| Filtro de fila · máscara de columna | ✅ | ⛔ |
| Linaje | ✅ | 🟡 parcial (sourceDatasetIds en la procedencia) · [sin medir en esta versión] |
| Auditoría de acceso | ✅ | ✅ C2 vivo: 470 eventos/24 h, 456 denegaciones, 3 superficies, y policy_id ya pre-cableado |
| API de administración | ✅ | ⚠️ escrita y apagada |
| Contrato / calidad del activo | 🟡 | ⛔ |
⭐ La distancia con Unity no es de arquitectura: el eje de securables, la
herencia, las implicaciones y la auditoría ya están, y son los correctos. La
distancia son tres piezas concretas —etiquetas, política de celda, contrato— y
un interruptor.
Lo que falta — con su gate
| Qué | Gate | |
|---|---|---|
| ⛔ | Encender C4 (ENABLE_INDEX_MANAGEMENT_API) | /api/management/v1/grants deja de dar 404 · y un cliente concede un rol sin que intervenga nadie |
| ⛔ | Tipos de política de ACCESO (row_filter, column_mask, projection) | C1 admite un tipo cuya combinación acumula en vez de sobreescribir |
| ⛔ | Etiquetas | El sustantivo sobre el que se escribe la política. Sin ellas, cada política es por objeto |
| 🟡 | La propiedad como eje | Una columna que signifique dueño, y que sea de un rol |
| ⚠️ | La locación: Index y el catálogo de registro, juntos y cerca del cómputo | Cierra §4·4 del plano de escritura — commit y asiento en una transacción |
Historial
| Versión | Fecha | |
|---|---|---|
| v2.0 | 2026-08-12 | 🏁 Los 5 módulos restantes ⇒ los 24 leídos. access-event (R1: emitir no puede romper la lectura · el literal nunca se guarda · null ≠ 0) · catalog-tenant (el prefix de la spec, porque listNamespaces no lleva namespace) · catalog-names (Unity pone el uuid en la UBICACIÓN; nosotros lo pusimos en el NOMBRE) · table-materialise (materializa la cara porque la puerta no tiene el esquema) · management-api («el día que hable sólo lo suyo, es lock-in con otro nombre») |
| v1.2 | 2026-08-12 | ⭐⭐⭐ Estudiados los 7 módulos pendientes. El trío del vending reencuadra ICEBERG-FACE: la ingesta no esquiva la cara por descuido — con la spec REST no se puede declarar intención de escritura, así que era la única forma de que escribiera. Y write-claim ata el pool de sesiones de COMPUTE con la firma del ledger |
| v1.1 | 2026-08-12 | Corrección: las cifras de C3 sumaban dos unidades distintas (368+12); las reales son 248 filas de grant · 413 en la vista · 45 de usuario · 12 sujetos. Y C2 se daba por encendido por la existencia de su variable — medido de verdad: 470 eventos/24 h y 456 denegaciones desde 3 superficies |
| v1.0 | 2026-08-12 | Primera imagen completa. Cinco pilares: uno aplicando, uno con datos, uno vacío, uno apagado, uno parcial. Y la constatación de que Index depende del catálogo de registro para existir — por eso Lakekeeper↔Gravitino es una decisión sobre Index |