Published

📇 PIEZA · INDEX-CATALOG — el plano de control

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

📇 PIEZA · INDEX-CATALOG — el plano de control

Versiónv2.0 — 🏁 los 24 módulos leídos: la imagen está completa
Estado⚠️ Cinco pilares construidos · dos de ellos APAGADOS · uno vacío
Última medición2026-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 · PolicyType y SECURABLE_HIERARCHY leí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 de lib/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_sessions y los 27.042 objects del 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

PilarQué gobiernaEstado
C1POLICY — política tipada, adjuntable y heredableLa regla que rige un securable⚠️ construido y VACÍO: 0 políticas, 0 adjuntos
C2ACCESS EVENTS — los hechos de usoQuién tocó qué, y si se le negóVIVO: 470 eventos en 24 h · 456 denegaciones
C3PRIVILEGE — vocabulario y cadena de rolesQuién puede qué🏁 248 filas de grant · aplicando (GRANTS)
C4MANAGEMENT API — el plano de controlAdministrar todo lo anterior por APIAPAGADO: /api/management/v1/*404
C5LA CARA — cómo Index habla con LakekeeperEl 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:

  1. 456 denegaciones registradas. La gobernanza no sólo se declara: deja rastro de lo que impidió.
  2. 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).
  3. 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, assertGrantable y 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ó
fue carbon-app, no la persona.

· El SQL EditorCREATE ROLE, GRANT … ON … TO ROLE, GRANT ROLE … TO 'persona',
SHOW ROLES. Interceptado antes del motor y contestado por Index.
Ver g6-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á rotolakekeeper#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 pide loadTable igual 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
, loadTable es siempre lectura y ningún motor puede escribir jamás
por la puerta
.

Y eso es literalmente lo que pasaba: un INSERT de Spark moría con 403 al cerrar el Parquetdespué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 alcancelectura → 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_events y en ningún otro sitio.
Eso explica los 1.137 eventos de storage-vending medidos 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 COLUMN dejarí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 muertoflush 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 guardaUn 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 nuestro ds_<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

ConceptoUC / HorizonIndex hoy
Securables con jerarquíaworkspace › catalog › schema › dataset
Privilegios con herencia e implicaciones✅ 40 privilegios · cadena de roles · aplicando
Propiedad de primera clasecreated_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 accesoC2 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
EtiquetasEl sustantivo sobre el que se escribe la política. Sin ellas, cada política es por objeto
🟡La propiedad como ejeUna 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ómputoCierra §4·4 del plano de escritura — commit y asiento en una transacción

Historial

VersiónFecha
v2.02026-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.22026-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.12026-08-12Correcció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.02026-08-12Primera 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