Published

Index — el pilar 3 del Warehouse

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

Index — el pilar 3 del Warehouse

EMPIEZA POR EL HANDOFF: index-handoff.md — el recorrido completo, el estado, y las siete premisas que resultaron falsas al medirlas. Este documento es el QUÉ; el handoff es el DÓNDE ESTAMOS.

Entregable canónico (2026-07-29). El Warehouse de Carbon tiene tres pilares. Dos son estándar abierto y no son nuestros: los bytes (Parquet en R2) y el formato + catálogo (Iceberg + Lakekeeper). El tercero es software propietario, vive en Postgres, y hasta hoy no tenía nombre: se le llamaba «control-plane», «el tridente», «metadata en PG» o «gobernanza», según el documento.

Se llama Index. Es nuestro Unity Catalog: la base de datos de gobernanza, linaje y metadata de primer nivel. Este documento fija qué es, qué contiene, qué no puede contener nunca, y en qué estado real está — medido contra el código, no contra la intención.

Su complemento es warehouse-scaffolding-retirement.md, que trata lo contrario: lo que hoy está en Postgres y debe salir.


1 · La definición

Index es la base de datos de gobernanza del Warehouse: el registro de todo lo que el catálogo Iceberg no puede saber sobre un dato, y de todo lo que hace que un dato sea un producto y no un montón de ficheros Parquet.

                    Superficies  ──▶  JUNCTION  (la puerta única)
        ┌─────────────────────────────────┼─────────────────────────────────┐
        ▼                                 ▼                                 ▼
  ①  BYTES                       ②  FORMATO + CATÁLOGO            ③  INDEX
     Parquet en R2                   Iceberg + Lakekeeper            Postgres
     (abierto, no nuestro)           (la cintura estrecha)           (propietario)
                                     qué tablas, qué snapshot,       de quién es, de dónde
                                     qué esquema físico              viene, quién puede verlo,
                                                                     de qué calidad es

Lo que Index NO es. No es Junction (Junction es la puerta que consulta a los tres pilares; Index es uno de ellos). No es el catálogo (Lakekeeper es la autoridad sobre el hecho físico; Index nunca decide si un snapshot existe). No es el dato (las filas no son gobernanza).


2 · El invariante — el único test que decide qué entra

Index guarda lo que el catálogo no puede saber. Lo que el catálogo sabe, se le pregunta — no se copia.

Dos corolarios, y son duros:

  1. Todo hecho derivable del catálogo que viva en Index es andamio, por definición, por muy portante que sea hoy. No importa que funcione: importa que obliga a cada escritor nuevo a acordarse de reportarlo, y por eso cada motor nuevo cuesta una migración.
  2. Todo lo que el catálogo no puede saber es de Index, y se queda. No es deuda pendiente de migrar. Unity Catalog es una base de datos; el nuestro también debe serlo.

Por qué el catálogo no puede saberlo. Lakekeeper es un catálogo Iceberg: namespaces, tablas, y pares clave-valor por tabla (TABLE_WRITE_PROPERTIES, writer.py:316) y por commit (snapshot_properties, writer.py:119-121 — ya usamos ese slot para carbon.operation-id). El slot existe. Lo que no existe es poder consultarlo relacionalmente, recorrer el grafo de linaje, o cruzarlo con usuarios, proyectos y permisos. La carencia no es de almacenamiento: es de consulta.

La diferencia real con Databricks no es cuántas capas hay, sino dónde están. Las suyas están en el camino: no puedes escribir una tabla de Unity sin pasar por Unity. Las nuestras están al lado: el motor commitea en Iceberg y después alguien tiene que acordarse de actualizar Postgres. Esa es exactamente la frontera entre §3 (lo que se queda) y §4 (lo que sale).


3 · Qué contiene Index — tres anillos

Inventario derivado del censo de las 233 migraciones de supabase/migrations/ + los consumidores en código.

Anillo 1 · El modelo de objetos (los átomos del Warehouse)

PiezaPor qué es de Index
catalogs · schemasLa jerarquía UC de 3 niveles. Y no son decorativos: {catalog.slug}.{schema.slug} es el namespace físico que se estampa al nacer la tabla. Index posee la traducción nombre-humano → namespace.
datasets (identidad)El registro del átomo Table/View: name, schema_id (la coordenada, NOT NULL desde D2), view_definition (NOT NULL ⇒ VIEW), kind, credential_id, description, metadata.
datasets.tierMedallón bronze/silver/gold. Es un juicio sobre el dato, ortogonal a su forma. Ningún catálogo de formato puede tenerlo. SSOT del tipo: lib/datasets/tier.ts.
datasets.project_id · file_idCANÓNICOS, no legacy. 20261249_datasets_project_files_first_class.sql:4-9 revoca expresamente la etiqueta «PUENTE/LEGACY … se retira en D5» que D1 les había puesto: «No hay D5 para estas columnas; no se retiran». Project & files es ortogonal a la coordenada, no su predecesor.
datasets_coordinate_uniqueEl sello: UNIQUE global sobre (workspace_id, schema_id, lower(name)), Table ∪ View. Es la integridad del espacio de nombres gobernado.
volumesEl átomo no-tabular. ⚠️ Existe y está inerte: cero consumidores en código; lo que el árbol muestra como «Volumes» son filas de datasets filtradas por service.
lib/warehouse/object-model.ts · qualified-name.tsLos 5 átomos como unión discriminada + la derivación única de la coordenada. Es el contrato de Index en TypeScript.

Anillo 2 · La gobernanza sobre los átomos

PiezaPor qué es de Index
dataset_schema_versions + schema_dependenciesIceberg tiene evolución de esquema; no tiene la narrativa: quién, por qué (sync/parse/sdk/edit), ni qué 7 clases de consumidor se pinnearon a qué versión con qué follow_mode.
El carrier de procedencia (ProvenanceBlock)contract.ts:140-151: origin, sourceDatasetIds, y la frontera del dataspace — counterparty, edcTransferId, agreementId. Hidratado por hydrateGovernance (junction.ts:266-276), propagado por mergeUpstreamProvenance (:284-310). ⚠️ Ver §5: tiene dos fugas medidas.
dataspace_assets · dataspace_transfersEl respaldo relacional de esa frontera: qué publicamos como Asset EDC, y bajo qué negociación/acuerdo/transfer entró un dato.
La ontología de definición: object_types, object_type_datasets, object_type_properties, link_typesLa capa semántica y su amarre a datasets. object_type_datasets es literalmente una arista de linaje dataset→tipo.
El linaje: lib/lineage/relationIndex.ts + ml_model_training_data + transform_paths⚠️ Ver §5: son cinco almacenes desconectados, no una pieza.
Tenencia y permisos: workspaces, workspace_members, user_workspace_ids() (RLS), requirePermission (3 acciones × 6 roles), workspace_group*, file_access_grants, egress_policiesUnity Catalog tiene exactamente esto. Un catálogo Iceberg no tiene concepto de grupo. ⚠️ Ver §5: la mitad no aplica nada.
workspace_branches, pull_requestsVersionado lógico gobernado que cruza N datasets, con PRs y protección de main. Iceberg tiene branches de tabla; esto es de otro orden.

Anillo 3 · La intención (la mitad del ledger que se queda)

dataset_transactions hace dos trabajos, y sólo uno es de Index:

  • «Qué íbamos a hacer», apuntado ANTES de tocar el datoid (que es el operation_id que viaja dentro del commit Iceberg), dataset_id, type, created_at, metadata.{sink, engine, writer, write_target, attributable}. Se queda, y es imprescindible: escribimos contra servicios remotos (ml-runner, duck-server); sin un «voy a» durable y local, tras un crash no hay forma de preguntar qué pasó.
  • «Qué le pasó a la tabla»status, committed_at, row_count, size_bytes. Lo sabe el catálogo mejor que él. Andamio (§4).

4 · Qué NO puede contener Index nunca

Sale por el invariante de §2. Todo esto es una copia de algo que Lakekeeper ya sabe:

PiezaEl hecho que copia
datasets.row_count, column_count, size_bytesEl total-records / total-files-size del summary del snapshot. Triplicado: el mismo número se escribe además en dataset_transactions.row_count y en iceberg_sync_log.rows.
datasets.current_transaction_id, last_committed_at, status, last_sync_at, sync_errorEl snapshot vigente y «qué le pasó a la tabla».
iceberg_sync_log (+ has_identity)El corazón del andamio: una fila que cada escritor tiene que insertar para que el sistema no se rompa solo. has_identity es un hecho del esquema de la tabla Iceberg copiado a PG — y la copia ya miente por diseño (junction.ts la estampa true literal, no verificado).
iceberg_freshness() / caught_upCompara dos tablas de Postgres para decidir si un lector va a Iceberg. Tiene sentido mientras ambos substratos tengan dato; para un dataset nativo, no.
iceberg_sync_cursor, lakehouse_read_flags / read_parity / write_flags, lakehouse_sync_partitionsMaquinaria de la pasarela: existen porque hay un espejo que seguir y dos substratos a la vez.

Y no es gobernanza: dataset_rows, dataset_branch_rows, dataset_row_staging, sdk_file_staging, objects/object_links. Son el dato (o su materialización), no el registro sobre el dato.

La frontera más incómoda no está entre tablas — está dentro de dos. (a) datasets mezcla en la misma fila la coordenada gobernada y las estadísticas del snapshot: retirar el andamio aquí es cirugía de columnas, no un DROP TABLE. (b) project_files es a la vez el árbol de autoría (app) y el carrier de toda la procedencia (Index): el bloque más importante del pilar 3 no tiene tabla propia — vive en un JSONB de una tabla de la otra mitad del producto.


5 · El estado real — lo que Index declara y no aplica

Esta sección es la que decide si «nuestro Unity Catalog» es una afirmación o una aspiración. Todo lo de aquí está medido.

5.1 · Dos fugas en el carrier de procedencia — ✅ CERRADAS (3a0a7bc)

Arregladas en el LECTOR, no en los productores, porque las filas ya escritas en producción llevan la forma rota: había que recuperarlas, no sólo dejar de romper las nuevas. hydrateGovernance acepta las dos formas de carrier (lo anidado gana) y traduce los alias snake→camel; el aterrizaje EDC pasa a escribir la forma canónica y a poblar agreementId, que ya viajaba en la fila de dataspace_transfers sin que nadie lo pasara. Guarda: lib/compute/governance-carrier.test.ts (12 casos sobre las formas reales de los productores).

La causa de que nadie lo viera está cerrada también: el smoke escribía el carrier plano y en camelCase — la única forma que producción no usa. Ahora ejerce las dos reales.

🔴 CENSO (2026-07-30) — y corrige la frase de arriba. «Las filas ya escritas en producción llevan la forma rota: había que recuperarlas» es falso: no hay ninguna que recuperar. scripts/dataspaces/i3-carrier-census.ts sobre la BD viva: 106 datasets · 101 con satélite · 0 con procedencia, ni en la forma plana (EDC) ni en la anidada (derivación). Desglose por productor: 15 transform_path + 13 pipeline_output = 28 derivados, ninguno con sourceDatasetIds.

Y la causa no es que el productor escriba mal — es que no ha corrido nunca desde que existe: Fase D (mergeUpstreamProvenance, 965d6e7) aterrizó el 2026-07-03, y el derivado más reciente de producción es del 2026-06-18.

Dos consecuencias, y las dos importan:

  1. I·1 es correcto y su valor medido hoy es CERO filas. Protege escrituras futuras, que es exactamente para lo que sirve — pero no hay recuperación retroactiva que celebrar.
  2. La frase que justifica el pilar no es 1/3 verdad: en los datos es 0/3. El tipo la describe con precisión; el dato no existe.

El diagnóstico, que conviene no perder:

Fuga 1 — desajuste de forma. Los tres chokepoints de derivación anidan el bloque bajo la clave provenance: dataset-output-materialiser.ts:788, transformPaths.ts:540 y :968metadata: { …, provenance }. Pero hydrateGovernance aplana el nivel superior: junction.ts:267const provenance = { ...(metadata as ProvenanceBlock) }.

Consecuencia: para un dataset derivado, descriptor.governance.provenance.sourceDatasetIds es undefined — el dato está un nivel más abajo. La cadena counterparty/edcTransferId/agreementId sobrevive un solo salto: A(EDC) → B(pipeline) la conserva; B → C la pierde. El smoke que debería detectarlo (facade-smoke.ts:49) escribe metadata plano, así que nunca ejerce el caso real.

Fuga 2 — snake vs camel. El único productor real del path EDC escribe snake_case: lakehouse-landing.ts:74-78{ source: 'edc_consumer', edc_transfer_id, counterparty }. Los dos lectores esperan camelCase: contract.ts:147-148 y junction.ts:299. No hay normalización en ningún punto.

counterparty funciona por coincidencia de nombre. edcTransferId no llega nunca (la fila «Transfer» del Warehouse está muerta para datos reales). agreementId no lo escribe nadie en producción.

La frase que justifica el pilar —«se derivó de la contraparte Y bajo el acuerdo Z»— se creía 1/3 verdad. El censo de arriba la deja en 0/3: no hay ni una fila con procedencia en producción.

5.2 · El patrón: registro y auditoría, sin aplicación

Siete piezas de gobernanza existen, se leen en la UI, y no impiden nada:

PiezaLo que dice su propio código
file_access_grants20261121:8-11«does NOT yet change RLS enforcement — access is still workspace-level»
egress_policies20261252:5-7«does NOT enforce traffic … this is audit/governance only»
lib/datasets/tier-policy.tsDeclara qué tier espera cada consumidor. Único importador: un componente de UI. Cero call-sites server-side.
Pestañas Permissions del Warehouse (catalog/schema/table)Los tres niveles son stubs «Coming soon» (WarehouseTab.tsx:1393, :1495, :1759). No existe catalog_grants ni schema_grants.
volumesTabla creada, cero consumidores.
ml_model_training_dataLa arista dato→modelo con dataset_txn_id — el único linaje normalizado del sistema. Cero consumidores.
GovernanceBlock.tags · .classificationSlots leídos por junction.ts:271-272. Cero productores en todo el repo; la UI pinta fijo.

Lo que SÍ se aplica: RLS por workspace vía user_workspace_ids(), la matriz 3×6 de requirePermission, workspace_group_permissions, el RBAC de promoción de modelos, y la validación de tenencia por tabla de runQuery (junction.ts:1082-1156) — que rechaza una query cross-workspace.

Index hoy sabe quién debería poder ver qué, y no lo impide.

5.3 · Una puerta de lectura, ninguna de escritura

La asimetría es nítida y es el hallazgo estructural de esta iteración:

  • LECTURA gobernada: existe y es única. loadItemForConsumption hidrata descriptor + tier + procedencia + frescura + credencial, y valida tenencia.
  • ESCRITURA de gobernanza: no hay puerta. La tabla datasets tiene 194 puntos de acceso en app/+lib/+services/+scripts/, y la mutación del objeto gobernado (nombre, tier, coordenada, metadata) vive en ≥12 módulos.

El chokepoint más cercano que ya existe es createDatasetArtifact (dataset-output-materialiser.ts:702), pero sólo cubre derivación, pipelines, EDC y CDC. Ingest, parse, upload SDK, sync polling y el DDL del SQL Editor entran por su cuenta.

5.4 · El linaje son cinco almacenes, no una pieza

lib/lineage/relationIndex.ts:9-12 lo declara: «This is NOT the pipeline transform DAG — there are no dataset→dataset edges». Los cinco: relationIndex (derivado en caliente, sin tabla de aristas), transform_paths, ml_model_training_data (el único normalizado, sin consumidores), dataspace_transfers.target_dataset_id, y el ProvenanceBlock en JSONB (con las dos fugas de §5.1).

⚠️ Corrección de memoria: «Warehouse registrado como nodo de lineage» es falso. relationIndex no emite nodo ni arista warehouse; en el sidebar de Lineage la categoría Warehouse es un stub «Coming soon» (LineageSidebar.tsx:1233).


6 · La regla de nombrado (obligatoria)

«Index» es un nombre tomado en este stack, en cuatro capas a la vez. Se adopta igual, porque el término que sustituye —«control-plane»— está peor: la misma palabra nombra hoy el pilar que se queda (sentido A, INFRA.md:42) y withNativeControlPlane, la función que estampa las copias que hay que retirar (sentido B). Una sola palabra para las dos mitades de la distinción. Eso no es fealdad: impide enunciar la tesis.

Las colisiones, medidas, y la regla que las desactiva:

ColisiónVolumenRegla
__row_index (el cursor keyset)838 usos, 158 ficherosIndex (mayúscula, con artículo: «el Index») = el pilar. __row_index se escribe siempre con sus dos guiones bajos. Nunca «el índice de fila» a secas.
Índices de Postgres342 CREATE INDEX«índice» en minúscula siempre cualificado: «el índice datasets_coordinate_unique».
El índice de Karma (zonemap/bloom/Puffin)roadmap, hoy inerteSiempre «el índice de Karma», nunca «el índice» a secas.
621 ficheros index.tsbarrelsIndex nunca es un nombre de fichero ni de directorio. No existe lib/index/.

Dos frases del corpus vigente hay que reescribir, porque ya colisionan: junction-handoff.md:72 («el ledger PG … es el índice de procedencia por fila» → «el registro de procedencia por fila») y compute-integration.md:158 (donde «índice» significa el de Karma).


7 · El plan

Ordenado por «lo que reduce superficie sin depender de ninguna decisión» primero.

  • I·0 · Nombrar (este entregable). La regla de §6 + el barrido de docs. Hecho.
  • I·1 · Cerrar las dos fugas del carrier (§5.1). ✅ HECHO (3a0a7bc). Cola CERRADA: el censo está hecho — 0 filas recuperables de 101 (§5.1). El arreglo protege escrituras futuras, no rescata pasado.
  • I·2 · Una puerta de escritura (§5.3). Promover createDatasetArtifact a chokepoint de mutación de Index y migrar los otros puntos de entrada. Es el simétrico de loadItemForConsumption y lo que convierte «Index» de nombre en frontera. ⚙️ Approach: index-i2-i3-approach.md — y corrige el tamaño: no son 194 accesos, son 21 nacimientos (7 ya por el chokepoint), y los 5 cuerpos duplicados ya han driftado (el tier no se propaga por tres de ellos).
  • I·3 · Decidir el carrier. project_files.metadata es el carrier histórico; datasets.metadata existe desde 20261239, está declarado CANÓNICO en el contrato D1, y el lector no lo miraba: un dataset con file_id NULL —permitido desde 20260631— perdía el carrier entero, no un campo.
    • I·3a · el LECTORHECHO. resolveCarrier(canonical, satellite) en junction.ts — canónico gana, satélite rellena. Inerte para los 101 satelitados (ninguno tiene las dos pobladas) y abre la puerta a los 5 sin satélite que hoy no podían tener gobernanza hagan lo que hagan sus productores. Uno de ellos es gold_order_risk_episodes, una VIEW nacida del DDL del SQL Editor el 2026-07-11 — la prueba de que el agujero no es histórico, sino la forma en que nacen los objetos nuevos del Warehouse.
    • I·3b · los PRODUCTORES. Apuntar la escritura de procedencia a datasets.metadata. El censo lo abarata a casi nada: no hay backfill que hacer (0 filas con procedencia). Va con I·2, y la ventana se cierra sola — cada derivación que nazca a partir de hoy encarece el movimiento. ⚠️ Sólo la procedencia: las 101 filas de project_files.metadata sí llevan config viva (media sets, formatos, transaction_policy), y eso no se mueve aquí.
    • El argumento definitivo no es el contrato D1, es dónde nacen los objetos. Cinco caminos de creación ya estampan file_id: null a propósito —CREATE VIEW del SQL Editor, la creación Warehouse-native, la subida desde notebooks, el checkpoint efímero y los file-backed—. project_files.metadata no es un carrier discutible: es un carrier que se está quedando sin filas.
  • I·4 · Aplicar o degradar (§5.2). Por cada una de las siete piezas declaradas-y-no-aplicadas: o se aplica, o se dice en la UI que es informativa. Un catálogo de gobernanza que no aplica no es un catálogo de gobernanza; es documentación con esquema.
  • I·5 · Consolidar el linaje (§5.4). Es la pieza menos consolidada del pilar y justo la que Unity Catalog sí tiene resuelta — la comparación que este documento usa a su favor.

Lo que NO está en este plan y va por su cuenta: retirar el andamio de §4. Ese es warehouse-scaffolding-retirement.md.

Cómo se LEVANTA la frontera — este documento la delimita; el diseño de cómo se completa la separación está en index-frontier.md. Su tesis: no se levanta moviendo columnas, sino partiendo el camino de control del camino de datos y rompiendo el espejo de nombres, para que la coordenada catalog.schema.objeto sea literalmente Index y la ubicación física un identificador opaco que nadie deriva.


8 · Decisiones para el owner

  1. ¿Se acepta que Index se queda en Postgres, y para siempre? Recomendación: sí, y decirlo en voz alta, porque hoy se trata como deuda pendiente de migrar. Unity Catalog es una base de datos. Lo que hay que sacar de ahí no es la gobernanza — son las copias del catálogo (§4).
  2. ¿datasets.schema es contrato o copia? Es el dudoso que más pesa. El contrato lo declara SSOT («el puerto nunca re-deriva schema») y la tabla Iceberg lleva su propio esquema — pero datasets.schema transporta isPrimaryKey, que el catálogo no sabe. No es derivación limpia: es una separación pendiente. Mientras no se decida, el UPDATE que escribe row_count escribe también schema, así que W·1 adelgaza el reporte obligatorio por escritor, no lo elimina.
  3. ¿I·4 aplica o degrada? Es la decisión que determina si Index es «nuestro Unity Catalog» o «nuestro registro de intenciones de gobernanza». Recomendación: elegir una pieza (los grants por artefacto son la más visible) y aplicarla de verdad; degradar el resto en la UI hasta que les toque.

Cruza con: warehouse-scaffolding-retirement.md (lo que sale) · junction.md (la puerta) · warehouse-object-model.md (los átomos) · medallion-tiers.md (el tier) · INFRA.md (la topología real).