Published

M5 · La metadata desde Index — approach

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

M5 · La metadata desde Index — approach

Entregable de planificación (2026-08-01). Convierte el milestone M5 de
carbon-sql-milestones.md §M5 en un plan ejecutable.
Nace de medir —no de leer— el coste real del plano de metadata actual, la
tenencia sobre datos vivos y la deriva entre lo que guarda Index y lo que tiene el
lago; y de cotejar el diseño con lo que documenta Unity Catalog, que es el sistema
al que Index se parece por construcción.

Cruza con: carbon-sql-m3-approach.md (la gramática y las
relaciones sobre las que esto se apoya) · carbon-sql-m4-approach.md
(las dos colas que M4 dejó aquí) · warehouse-index.md (§5.2:
«Index sabe quién debería poder ver qué, y no lo impide») ·
junction.md (la puerta).


⚠️ 0 · Lo primero, porque cambia la naturaleza del milestone

M5 estaba escrito como higiene: «ninguna llamada al motor por metadata» + «el filtrado por privilegios tiene test». Medido, una de las tres cosas que da por supuestas es falsa y otra es una indisponibilidad viva.

Lo que el plan suponíaLo medido hoy
Cortar duckColumnshigiene: no le preguntes al motor lo que sabe el catálogouna sola consulta a information_schema.columns retiene 65,7 s el ÚNICO lock del motor
Filtrar por privilegioscambio de producto arriesgado — M3 lo aplazó por eso (§7.3)delta = 0 objetos. 1 workspace · 1 creador · 106 objetos. Hoy no cambia lo que ve nadie
«Index puede contestar»una copia que quizá derivó⚠️ deriva estructural 0 (los nombres coinciden al 100 %) pero deriva de VOCABULARIO 50/50 en la muestra

0.1 · La indisponibilidad, con su número

buildInfoSchemaRows enriquece la relación llamando a duckColumns/columnsDuckEngine.columns_for. Y columns_for hace un SELECT * FROM <tabla> LIMIT 0 por tabla —el catálogo Iceberg REST es lazy, no aparece en information_schema hasta que se la toca— sobre _run_locked, que es el mismo threading.Lock que serializa todas las queries del servicio (duck.py:762, call-sites :1054, :1061, :1146).

Medido contra el duck-server de producción (scripts/duckdb/m5-columns-cost.ts):

lote    1 tablas →   1.298 ms ·   9 columnas
lote    5 tablas →   2.858 ms ·  41 columnas      (≈ 572 ms/tabla)
lote   20 tablas →  11.858 ms · 148 columnas      (≈ 593 ms/tabla)
lote   92 tablas →  65.656 ms · 830 columnas      (≈ 713 ms/tabla)

Lineal, ~700 ms por tabla, sin tope de tablas por debajo de MAX_TABLES = 200. Y el lock de espera de los demás es de 20 s (lock_timeout_s), mientras el timeout de query es de 120 s — o sea, la llamada de 65 s no se corta, y todo el que llegue detrás recibe engine_busy tras esperar sus 20 s.

Cualquier usuario autenticado que teclee SELECT * FROM information_schema.columns
deja al motor —a TODOS los tenants— sin servicio durante más de un minuto.
No es
una escalada de privilegios como H·A/H·B de M3; es una denegación de servicio por
diseño, y crece con el número de tablas del que pregunta.

Y lo que compra ese minuto, Databricks lo publica como NULL. Los tres campos que justifican el enriquecimiento:

CampoQué dice Databricks de élQué devuelve el motor hoy
character_maximum_length«Always NULL, reserved for future use»null en las 830 columnas — 0 aciertos
numeric_precision«For base-2 integral numeric types, FLOAT and DOUBLE, the number of supported bits. For DECIMAL the number of digits, NULL otherwise»329 valores, todos derivables del tipo (INTEGER→32, BIGINT→64, DECIMAL(38,9)→38)
numeric_scale«For integral numeric types 0, for DECIMAL the digits to the right»ídem

Ni un solo dato de los 65 s es irreducible. character_maximum_length no lo rellena ni Databricks; numeric_precision/scale son una función pura del tipo. Lo único que Index no sabe hoy es la precisión de sus 51 columnas DECIMAL(38,9) — y eso lo sabe el catálogo (§2.4), no hace falta el motor.

0.2 · Consecuencia sobre el orden

M5 deja de ser el milestone «de limpieza» del final. La fase que corta el motor del plano de metadata cierra una indisponibilidad medida y no depende de ninguna decisión de producto — va primera y se puede desplegar sola.


1 · Lo que hace Unity Catalog — y las cinco lecciones transferibles

Medido en documentación oficial el 2026-08-01. Index se parece a Unity Catalog por construcción (warehouse-index.md §1), así que aquí el cotejo no es inspiración: es leer el manual del sistema que ya elegimos imitar.

FuenteLa frase normativaQué nos dice
information_schema · UC«you see only the objects (catalogs, schemas, tables, columns, etc.) that you have privileges to access» · «The rows returned are limited to the relations the user is privileged to interact with»El filtrado es propiedad de la relación. Nosotros ya tenemos el sitio: una función (leerWarehouse), gracias a F4a
information_schema · UC«Unlike other system tables, the information schema does not require an explicit SELECT grant. Access is governed automatically by your Unity Catalog privileges»La metadata no se concede: es un espejo de lo que ya puedes. No hay un permiso «ver el catálogo» que administrar aparte
Privilegios · UC«To read from a table, a user needs SELECT on the table, USE CATALOG on the parent catalog, and USE SCHEMA on the parent schema»La visibilidad es una cadena por nivel de la jerarquía, no un booleano. Nuestra jerarquía existe (catalogsschemasdatasets) y no tiene grants en ninguno de los tres niveles
BROWSE · UC«Discover objects, view their metadata, and request access to them without needing USE CATALOG or USE SCHEMA» · «See that an object exists, view its name, description, and tags»Ver ≠ leer. Es un privilegio propio. Hoy nosotros los confundimos: si sale en SHOW TABLES, se puede SELECT
COLUMNS · UCCHARACTER_MAXIMUM_LENGTH «Always NULL, reserved for future use»; NUMERIC_PRECISION derivada del tipoLo que cuesta y no aporta se publica como NULL, declarado. El principio Cloudflare aplicado a una columna
Identificadores · UC«All identifiers except column and tag names are stored as lowercase» + «avoid LOWER(), compare directly»Ya lo hacemos en el catálogo de resolución (catalogoDesde), no en las relaciones. Es una incoherencia pequeña y barata

Las cinco lecciones, y qué implican aquí

  1. La visibilidad es una cadena por nivel, no un booleano. Un objeto es visible si lo son su catálogo, su esquema y él. Hoy nuestra «cadena» tiene un solo eslabón: created_by = yo.
  2. ⭐ Ver no es leer. BROWSE existe porque el descubrimiento tiene valor sin el dato — es lo que permite «veo que existe gold_finance y pido acceso». Nosotros no tenemos esa distinción, y es exactamente la que hace falta cuando el filtro pasa de dueño a workspace: al ver más objetos, hay que poder ver sin poder leer.
  3. La metadata es un espejo, no un objeto concedible. Corolario duro y útil: el filtro de la relación debe DERIVARSE del mismo predicado que autoriza la lectura. Si son dos reglas distintas, tarde o temprano divergen — es la lección de M2 con los parsers y la de F4a con los dos catálogos, un piso más arriba.
  4. Lo que cuesta y no aporta se publica como NULL, declarado. No es dejadez: es contrato. Databricks lo escribe en su doc y nosotros ya tenemos el sitio donde escribirlo (la superficie generada de F5).
  5. El vocabulario de tipos es del catálogo, no del motor. Databricks publica STRING/BIGINT/TIMESTAMP en information_schema aunque por debajo haya Parquet con otros nombres. Nuestra deriva medida (§2.3) no es un bug de datos: es que no hemos decidido qué vocabulario publica Carbon SQL.

2 · Lo medido antes de diseñar

Tres scripts nuevos, todos de sólo lectura, contra la BD y el duck-server reales.

2.1 · La tenencia — el delta es CERO, y la ventana se cierra sola

scripts/dataspaces/m5-metadata-census.ts:

datasets totales        : 106
workspaces con datasets : 1          creadores distintos : 1
sin workspace_id        : 0          sin created_by      : 0

EL DELTA de `created_by` → `workspace_id`:   GANA 0 · PIERDE 0
roles en workspace_members: owner=3 · viewer=3 · admin=6

Lo que esto decide. M3 aplazó el cambio de sujeto con un motivo correcto —«cambia lo que la gente ve» (§7.3)—. Medido, hoy no cambia nada para nadie: hay un solo creador y un solo workspace con objetos. Es el momento más barato que va a existir, y cada creador nuevo lo encarece: el día que dos personas creen tablas en el mismo workspace, subir el filtro les cambia el árbol de golpe.

Y hay 12 miembros repartidos en otros workspaces (3 owner, 3 viewer, 6 admin) — o sea, la multi-tenencia existe en el producto; lo que no tiene todavía es datasets. El viewer es la prueba de que la lección nº2 hace falta: un rol que puede view pero no write ya está definido, y hoy el catálogo no lo distingue de un owner.

2.2 · El coste del motor — §0.1

2.3 · La deriva Index ↔ lago — de VOCABULARIO, no de estructura

scripts/duckdb/m5-column-drift.ts sobre 15 tablas:

tablas comparadas          : 15
tablas CON deriva          : 15 (100%)
columnas que Index no sabe : 0
columnas que Index inventa : 0
tipos que NO coinciden     : 50

traducciones observadas (Index → lago):
  STRING    → VARCHAR                      35
  TIMESTAMP → TIMESTAMP WITH TIME ZONE      9
  LONG      → BIGINT                        6

La buena noticia es la primera línea de los ceros: el conjunto de nombres de columna coincide al 100 %. La copia de Index no ha perdido ni inventado una sola columna, así que servir information_schema.columns desde Index no pierde estructura.

La mala es que hay tres vocabularios a la vez, y el censo lo enseña entero:

QuiénVocabulario
Index (datasets.schema, 630 columnas)STRING 167 · INTEGER 167 · CHARACTER VARYING 95 · NUMERIC 38 · TEXT 28 · LONG 8 · JSONB 5 · OBJECT 2 … — el tipo del SISTEMA ORIGEN, tal cual lo reportó el adaptador que lo sincronizó
El lago (lo que ve el motor, 830 columnas)VARCHAR 397 · INTEGER 167 · BIGINT 100 · TIMESTAMP WITH TIME ZONE 51 · DECIMAL(38,9) 51 · …
Databricks (lo que publicaría UC)STRING · BIGINT · TIMESTAMP · DECIMAL(38,9)

Index dice CHARACTER VARYING y TEXT para lo mismo que llama STRING; dice TIMESTAMP donde el lago tiene TIMESTAMP WITH TIME ZONE; dice NUMERIC/DECIMAL sin precisión donde el lago tiene DECIMAL(38,9). No es que la copia haya derivado: es que nunca hubo un vocabulario. Y —dato que ordena la decisión— el de Index está más cerca del de Databricks que el del motor: STRING, TIMESTAMP y DECIMAL ya son los nombres correctos; lo que hay que normalizar es LONGBIGINT, CHARACTER VARYING/TEXTSTRING y la variante TIMESTAMP WITHOUT TIME ZONE.

Y dos huecos que ninguna consulta enseña hoy:

  • 14 de 106 objetos (13 %) no tienen esquema en Index → hoy no salen en information_schema.columns, y nadie lo dice.
  • La única vista tiene 0 columnas en Index → una vista nunca aparece en information_schema.columns. Databricks sí las lista. Es la cola schema-id que M4 mandó aquí, vista desde el otro lado.

2.4 · La tercera vía existe, y ya está construida

El plan planteaba dos fuentes para la metadata de columnas: el motor (hoy) o la copia de Index. Hay una tercera, que es la única que satisface el invariante de Index («lo que el catálogo sabe, se le pregunta — no se copia»), y ya tiene primitiva:

/lakehouse/table-status (N·2b, lakehouse.py:566) hace catalog.load_table() con pyiceberg y hoy devuelve columns = list(table.schema().column_names)sólo los nombres. El objeto table.schema() que ya tiene en la mano lleva los tipos Iceberg completos, decimal(38,9) incluido. Es una llamada REST al catálogo, sin escanear datos y sin tocar el lock de DuckDB.

Ampliar esa primitiva para que devuelva (nombre, tipo, obligatoriedad) en vez de
sólo nombres es un cambio de tres líneas en ml-runner, y convierte «pregúntale al
motor» en «pregúntale al catálogo» sin copiar nada.

2.5 · ⚠️ Hay un TERCER camino a la misma metadata, y no filtra nada

La lección de Trino #14000 —«not all tables from system.metadata are access control aware»— que M3 citó para H·B, vuelve a aplicar, y esta vez fuera del SQL Editor:

app/api/datasets/route.ts:258   supabaseAdmin.from('datasets').select(…)
                                 ← sin .eq('workspace_id'), sin .eq('created_by')

El listado del árbol del Warehouse corre con supabaseAdmin (service-role, que salta RLS) y no aplica ningún filtro de tenencia. El guard de entrada es requirePermission('write'), que verifica el rol dentro del workspace propio y no acota la consulta. El parámetro ?scope= se lee (:86) y no se usa en ninguna parte.

Alcance real, dicho con precisión: hoy es latente, no explotable con consecuencias — hay un único workspace con datasets (§2.1), así que no hay dato ajeno que devolver. Pero es el mismo patrón que M3 cerró en el SQL Editor, en la superficie de al lado, y se activa solo el día que un segundo workspace cree su primera tabla.

Esto no es de Carbon SQL y no debe secuestrar M5. Pero sí fija un requisito del
diseño: el predicado de visibilidad tiene que ser una función exportada y
reutilizable
, no un .eq() escrito dentro de leerWarehouse. Si M5 lo deja
encerrado, el tercer camino nunca lo usará — que es literalmente cómo se llega a
Trino #14000.

✅ CERRADO el 2026-08-01 — y con dos correcciones a lo de arriba

El requisito se cumplió: visibility.ts exporta filtroDeTenencia(principal) (la forma EMPUJADA de puedeVer) y lo consumen los dos caminosleerWarehouse y las tres ramas de lectura de app/api/datasets/route.ts (el listado, ?datasetId= y ?fileId=: acotar sólo el listado deja sin cubrir el camino que de verdad se enumera, porque el id lo elige el cliente). El ?scope= muerto, borrado. Regresión en app/api/datasets/route.test.ts (un segundo workspace construido a mano; verificada en la dirección de fallo: 4 de 8 sondas caen si se quita el predicado).

Dos cosas que este párrafo daba por ciertas y no lo eran, medidas antes de tocar nada:

  1. El cliente no es service-role. La línea de import es supabaseRLS as supabaseAdmin — un alias con el nombre del cliente equivocado. Y RLS está activa en datasets (relrowsecurity = true, política datasets_workspace). Ningún caller de Clerk llegó a ver dato ajeno: el agujero era menos grave de lo escrito. Lección: un nombre importado bajo alias miente igual que un comentario rancio, y no lo caza el compilador.

  2. Y aun así el arreglo NO era redundante, que es lo que se supondría de un filtro que duplica a RLS. La política concede workspace_id IN (user_workspace_ids()) = cualquiera de mis workspaces; la app quiere el activo. Un usuario con dos workspaces veía los dos mezclados en el listado. O sea: el fallo real no era el que estaba escrito, y sólo apareció al medirlo.

Sigue abierto, y es otro cambio: un caller con API key no tiene JWT de Clerk → supabaseRLS cae a la clave anónima → la política (TO authenticated) no le concede nada → lista vacía. Falla cerrado, así que no es un agujero, pero es un camino roto; el arreglo diseñado ya existe (dbForAuth(ctx)) y sólo es seguro porque el filtro de tenencia ya está puesto.


3 · El diseño

3.1 · La tesis

La metadata es una relación del catálogo, servida a un PRINCIPAL bajo una cadena de
visibilidad declarada, con un vocabulario de tipos propio. El motor no participa en
el camino de lectura — sólo al CREAR, y lo que dice se persiste.

3.2 · Las cuatro piezas

        ┌──────────────────────────────────────────────────────────┐
        │ ① EL SUJETO — `Principal`, no `userId`                    │
        │   {userId, workspaceIds[], role} — lo que ya construye    │
        │   requireWorkspace(); deja de ser un string               │
        └───────────────────────┬──────────────────────────────────┘
        ┌──────────────────────────────────────────────────────────┐
        │ ② LA CADENA DE VISIBILIDAD — un predicado, UN sitio       │
        │   puedeVer(objeto, principal)  ← BROWSE                   │
        │   puedeLeer(objeto, principal) ← SELECT                   │
        │   exportadas: las consume `leerWarehouse` Y quien quiera  │
        └───────────────────────┬──────────────────────────────────┘
        ┌──────────────────────────────────────────────────────────┐
        │ ③ EL VOCABULARIO — `lib/warehouse/types.ts`               │
        │   tipo canónico Carbon (nombres Databricks) + precisión   │
        │   DERIVADA. Una función pura, con su tabla de traducción  │
        └───────────────────────┬──────────────────────────────────┘
        ┌──────────────────────────────────────────────────────────┐
        │ ④ LAS RELACIONES (F4a, ya existen) — filtradas DENTRO     │
        │   tables · views · schemata · columns                     │
        │   sin una sola llamada al motor                           │
        └──────────────────────────────────────────────────────────┘

① El sujeto. leerWarehouse(userId) pasa a leerWarehouse(principal). El Principal no se inventa: es lo que requireWorkspace() ya devuelve (userId, workspaceId, role). El cambio de firma es lo que impide que el filtro vuelva a ser un .eq() implícito.

② La cadena. Dos predicados exportados, no uno:

Qué habilitaRegla v1 (lo que HOY se puede aplicar de verdad)
puedeVer (BROWSE)salir en SHOW/information_schema, con nombre, coordenada, tier y descripciónmiembro del workspace del objeto
puedeLeer (SELECT)resolver en un FROM, ver filas, ver row_countmiembro + rol con view — que es el predicado que runQuery ya aplica

Que en v1 las dos reglas casi coincidan no las hace la misma: separarlas es lo que permite que los grants por catálogo/esquema/objeto entren después como una fila más, sin volver a tocar los seis comandos. Es la lección nº2, y es la diferencia entre M5 y un .eq() cambiado.

⚠️ Y hay una consecuencia inmediata, no teórica: el catálogo de resolución (catalogoDesde) se deriva de la misma lectura (F4a). Si leerWarehouse empieza a devolver lo que se puede ver, un objeto BROWSE-only entraría en el catálogo que resuelve FROM. La relación devuelve el objeto con su marca, y catalogoDesde filtra por puedeLeer. Sin eso, «ver» concede «leer» en silencio — que es justo el fallo que este milestone existe para impedir.

③ El vocabulario. Una función pura tipoCanonico(raw): {tipo, precision, escala}, con su tabla de traducción medida en §2.3 y su política declarada:

Campov1Motivo
data_typeel canónico Carbon (nombres Databricks)un information_schema que publica el tipo del sistema origen no es un contrato: es un eco
numeric_precision · numeric_scalederivados del tipo canónico (regla Databricks: base-2 → bits; DECIMAL → dígitos)función pura, 0 llamadas
character_maximum_lengthNULL, declarado en la superficiees lo que hace Databricks; es lo que ya devuelve el motor en el 100 % de los casos
La precisión REAL de un DECIMALdel catálogo (§2.4), no del motorúnica metadata que Index no tiene y el catálogo sí

④ Las relaciones no cambian de forma: ya existen y ya derivan de una sola lectura. Ganan el filtro dentro y pierden el enriquecimiento.

3.3 · La regla que resuelve la cola de M4 — y una frontera que hay que escribir

M4 mandó aquí el schema-id de la vista con este motivo: «exige calcular el esquema de salida del cuerpo, que es preguntarle al motor». Eso choca de frente con el gate de M5 («ninguna llamada al motor por metadata»)… hasta que se le pone la frontera correcta:

Al motor se le pregunta al ESCRIBIR, una vez, y lo que dice se persiste. Nunca al
LEER.

No es una excepción de conveniencia: es lo que hace el Iceberg View Spec —el schema-id de una versión lo calcula el motor que la creó y viaja dentro de la versión inmutable— y es lo que separa un hecho derivado y sellado de una pregunta recurrente. Con esa frontera:

  • el schema-id de la vista se calcula en CREATE VIEW (una vez, ya hay motor en el camino) y se guarda en la versión → cae la cola de M4 sin violar el gate;
  • las 14 tablas sin esquema y la vista con 0 columnas dejan de ser invisibles;
  • y el gate de M5 se enuncia con precisión: cero llamadas al motor en el camino de LECTURA de metadata (que es donde está el minuto de bloqueo).

4 · El contrato de visibilidad v1 — qué se declara

Se publica en la superficie generada (F5 de M3), con la misma regla: lo que no está, está escrito.

v1Fuera de v1, y por qué
Sujetoel principal: usuario + sus workspaces + rolgrupos (workspace_group_permissions existe y no lo aplica nadie)
Ver (BROWSE)miembro del workspacepedir acceso a lo que ves (request access de UC)
Leer (SELECT)miembro + rol con viewSELECT por objeto — no existe table_grants
Catálogo / esquemaheredan del workspaceUSE CATALOG/USE SCHEMA: no existen catalog_grants ni schema_grants (las 3 pestañas Permissions son stubs)
Vistas (DEFINER)la vista se resuelve por sus ataduras (M4). La elevación se ACTIVA: leer a través de una vista lo que no ves directamenteconceder la vista sin conceder nada más: exige grants por objeto
information_schema4 relaciones, filtradas48 vistas de UC · system.information_schema global

5 · Fases, con lo que cada una BORRA

EntregableGate (medido)Borra
N0El instrumento de VISIBILIDAD. El harness de catálogo mide formas, no quién ve qué: hoy «el filtrado tiene test» sería una creencia. Sondas con dos principales de verdad conocida (mismo workspace / otro workspace / rol viewer) sobre los 6 comandos + las 4 relacionesreproduce el estado ACTUAL sin arreglar nada (línea base), y se verifica en las dos direcciones como el trinquete de F0(N0 no borra: hace medible lo que hay)
N1El motor fuera del camino de lectura. tipoCanonico + precisión derivada + character_maximum_length NULL declarado. buildInfoSchemaRows deja de llamar a nadieverde — §16: 65.656 ms → 222 ms (×296) · 0 columnas perdidas · 630/630 tipos describen la tabla físicametadata-client.ts de lib/ · el enriquecimiento entero · MAX_TABLES 200 → 10 (⚠️ corrección al plan: el endpoint NO se borra — §16)
N2El sujeto: de dueño a principal. leerWarehouse(principal) + los 7 created_by de handlers.tsy el sujeto pasa a salir de la SESIÓN (§17)verde — §17: delta en vivo 0 ganados · 0 perdidos, 630 filas idénticas por la función de producción · 6 sondas con dos principales, verificadas en la dirección de falloel filtro por created_by como tenencia · el userId del body como identidad · la incoherencia catálogo(dueño) ↔ Junction(workspace) ↔ índice único(workspace)
N3La cadena: ver ≠ leer. visibility.ts (puedeVer/puedeLeer/legibilidad); el catálogo REGISTRA lo BROWSE-only marcado y el plan lo rechaza con motivo tipado (not-readable)verde — §18: 10 sondas, el caso medido (14 objetos reales) · verificado en vivo: el mensaje pasa de filtrar una identidad física a un motivo declaradoel mensaje del motor filtrando ds_<uuid> ajenos · la ambigüedad de M4 sobre la elevación (⚠️ que se resuelve en NO activarla — §18)
N4Los huecos de metadata: el schema-id de la vista al crearla (§3.3) y las 14 tablas sin esquemaverde — §19: 0 huecos · la vista sellada como v3 con 17 columnas · information_schema.columns 630 → 647 filasla cola de M4 · y un defecto vivo que destapó: ninguna vista se podía LEER
N5F4b · information_schema joinable con tablas reales4/5 en vivo — §20 (joins entre relaciones, agregados libres, WITH del usuario) · el 5º verificado en frío: espera al despliegue de N4el rechazo declarado de F1 · el no-tables de una lectura sólo-metadata

N1 va primera y se despliega sola. Cierra la indisponibilidad de §0.1 y no depende de ninguna decisión de producto.


6 · Riesgos

RiesgoMitigación
Cambiar el vocabulario de tipos rompe a un consumidor de information_schema.columnsmedir los consumidores antes (la UI pinta el objeto genérico; el harness fija la forma). El tipo canónico se publica en la superficie con su tabla de traducción
«Ver ≠ leer» se queda en dos funciones que devuelven lo mismoel test de N3 exige un caso donde difieran; si no se puede construir, la separación no está hecha
El delta de tenencia deja de ser 0 mientras se implementael censo es un script: se vuelve a correr en el gate de N2, no se confía en la medición de hoy
Servir columnas desde Index publica una copia que puede derivar§2.3 mide 0 deriva estructural hoy. El censo de deriva se convierte en trinquete: si un día Index y el lago dejan de coincidir en nombres, salta
N1 «arregla» el minuto quitando un dato que alguien usabalos tres campos están medidos: character_maximum_length es NULL en el 100 % de los casos y los otros dos son derivables. Nada que perder, y está contado
M5 se come el listado sin filtro de §2.5no entra en M5. Lo que M5 entrega es el predicado exportado para que ese arreglo sea una línea

7 · Decisiones del owner

  1. ¿N1 se adelanta y se despliega sola? Recomendación: . Es una indisponibilidad medida, alcanzable por cualquier usuario autenticado, y su arreglo no toca producto ni permisos.
  2. ⭐ ¿Qué vocabulario publica Carbon SQL en data_type? Recomendación: el canónico estilo Databricks (STRING/BIGINT/TIMESTAMP/DECIMAL(p,s)), normalizado desde lo que guarda Index. Es paridad, y es el que ya está más cerca. (La alternativa —publicar el tipo físico del lago— acopla el contrato al motor, que es justo lo que este pilar deshizo.)
  3. ¿Se activa la elevación de DEFINER en M5? Recomendación: , y es el motivo de que puedeVer/puedeLeer sean dos. Con un solo predicado, una vista DEFINER es indistinguible de un agujero.
  4. ¿row_count/size_bytes siguen saliendo de la copia de Index? Son extensiones nuestras a information_schema.tables (Databricks no las tiene). Derivarlas del catálogo son 92 llamadas REST. Recomendación: se quedan en Index en v1 y se declaran como «último valor conocido» en la superficie — es honesto y es gratis; derivarlas es del plan de retirada del andamio, no de Carbon SQL.
  5. ¿N5 (F4b) entra en M5 o se vuelve a diferir? Recomendación: que entre, pero la última. Ya se difirió una vez «porque M5 reconstruye el contrato»; cuando N1–N4 estén, ese motivo se ha consumido y volver a diferirlo sería convertir «decisión» en «resto».

16 · N1 · lo entregado (2026-08-01)

El resultado, en tres números

① information_schema.columns  →  65.656 ms  ⇒  222 ms      (×296)
② columnas PERDIDAS                              0
③ tipos publicados que NO describen la tabla física   0 / 630

El gate (scripts/duckdb/m5-n1-gate.ts) cronometra la función de producción y contrasta su salida columna a columna contra el motor. Los 226 valores de precisión que antes venían de la red coinciden todos con los derivados.

⭐ Por qué esto no publica una copia: el mapeo ya existía

La pregunta que decidía N1 —¿puede Index decir la verdad sobre el tipo?— tenía una respuesta mejor que «sí, aproximadamente»: la traducción no hay que inventarla, porque es la misma función que escribió el tipo físico.

raw de Index ──resolveIcebergType──▶ token Iceberg ──ICEBERG_A_CARBON──▶ tipo Carbon
              (lib/types/iceberg-types.ts:                              (types.ts)
               lo que el worker aplicó ANTES
               de que ml-runner creara la tabla)

Publicar el resultado no es estimar: es reproducir una derivación que ya ocurrió. De ahí que el contraste dé 630/630 y no «casi todas». Y explica por qué la deriva medida en §2.3 era de vocabulario y no de estructura: los dos extremos siempre dijeron lo mismo con distintas palabras.

La precisión de los DECIMAL —el único dato que parecía exclusivo del motor— sale del mismo sitio: el writer manda todo decimal a decimal(38,9) (parquet_source.py:45, types.py:55). Publicar 38/9 describe la tabla real.

Las tres decisiones que se declaran en la superficie

  1. data_type publica el vocabulario Carbon (nombres Databricks). Antes publicaba el tipo del sistema origen, que es un eco del adaptador: character varying, text y String eran tres nombres para la misma columna física.
  2. character_maximum_length = NULL por contrato (Databricks: «Always NULL»).
  3. numeric_scale de FLOAT/DOUBLE = NULL, regla de Databricks y de ANSI. DuckDB devuelve 0; son 11 columnas y la divergencia se publica, no se esconde.

Y una unificación que no estaba en el plan y había que hacer: DESCRIBE y SHOW COLUMNS publicaban el tipo crudo mientras la relación publicaba el canónico. Eran dos piezas que debían coincidir — el pecado de siempre — así que las tres pasan por nombreTipoCanonico.

⚠️ Corrección al plan: el endpoint /columns NO se borra

La fila de N1 decía «borra … el endpoint /columns y columns_for». Al ejecutarlo se vio el problema: §6 de este mismo documento promete un trinquete de deriva («si un día Index y el lago dejan de coincidir en nombres, salta»), y su única verdad de referencia posible es preguntarle al motor. Borrarlo dejaba la promesa sin instrumento.

Lo entregado en su lugar, que quita la indisponibilidad sin quitar la medida:

lib/warehouse/query/metadata-client.tsborrado — cero referencias desde producción
El cuerpo del clientemovido al harness: scripts/duckdb/duck-columns-probe.ts
MAX_TABLES en duck-server200 → 10, declarado como superficie de diagnóstico. 10 tablas ≈ 7 s, por debajo del lock_timeout de 20 s: ya no puede tumbar el servicio ni llamándolo a mano

Es el mismo movimiento que F0 hizo con los reconocedores: lo que hay que medir no puede ser privado, pero tampoco tiene por qué estar en el camino caliente.

🔁 Y el gate falló antes de acertar — otra vez, y por lo mismo

La primera pasada dio tipo publicado ≠ tipo físico: 1 y señalaba bronze_customers.customer_id. La tentación era mirar el código de producción. El error era del instrumento: hay dos bronze_customersmain.test con 99.441 filas y main.default con 30, el hallazgo de M4— y el gate indexaba por nombre de tabla, así que comparaba las columnas de una contra el esquema de la otra. Con la coordenada en la clave: 630/630.

Van cuatro instrumentos seguidos en este pilar que fallan antes de acertar, y los cuatro se descubrieron mirando el caso concreto que señalaban, no razonando sobre el diseño.

Los gates

Gate de N1 contra producción222 ms · 0 perdidas · 630/630 tipos · 226/226 precisiones
vitest lib/warehouse + lib/compute361/361 (+26 de types.test.ts, +2 de relations.test.ts)
El trinquete del vocabularioverificado en la dirección de fallo: quitando una fila de ICEBERG_A_CARBON, el test nombra al culpable (Long → LONG (token long))
pytest duck-server206/206
Matriz de catálogo64/64 · 0 defectos
tsc --noEmit0

Pendiente: desplegar (Vercel para el corte en Node; Railway para el tope de duck-server, que es defensa en profundidad — el camino de producción ya no pasa por ahí).

Y una rancidez que se publicaba y ha caído de paso: la superficie generada declaraba «verbos de escritura: DELETE · MERGE — pendiente de RE-MEDIR (M6)». M6 cerró, y UPDATE entró por evidencia.


17 · N2 · lo entregado (2026-08-01)

⚠️ Lo que se encontró al abrir la route, y cambia el tamaño de N2

N2 estaba escrito como «subir el filtro de created_by a workspace». Al ir a hacerlo apareció que el filtro no era una frontera de tenencia: era un parámetro.

route.ts:132   const { userId, query, mode } = body;     ← el sujeto, del CUERPO
route.ts:*     grep de auth/clerk/requireWorkspace  →  0 resultados

La route no tenía ni una llamada de autenticación. Clerk (proxy.ts) garantiza que haya una sesión, pero la identidad con la que se construía el catálogo era la que el cliente escribiera. Y ese catálogo no gobierna sólo metadata —como H·A/H·B de M3—: gobierna la resolución de FROM, o sea el dato.

La cadena entera dependía de un valor del cliente:

body.userId → buildWarehouseCatalog(userId) → leerWarehouse → .eq('created_by', userId)
            → plan de lectura → binding → JWT de workspace → el motor lee

runQuery valida tenencia por tabla, pero lo que valida es que las tablas entre sí sean del mismo workspace — no que el que pregunta pertenezca a él, porque hasta N2 no había un «el que pregunta» que no viniera del body.

Y un segundo desajuste, que no es de seguridad sino de corrección: la unicidad de nombre se comprobaba en la app contra created_by (handlers.ts, dos sitios) mientras el índice de la BD es datasets_coordinate_unique sobre (workspace_id, schema_id, lower(name)). Tres ámbitos para la misma pregunta —dueño, workspace, coordenada— es la misma deuda que dos parsers, un piso más arriba.

Lo entregado

Pieza
catalog-sql/principal.tsPrincipalWarehouse {userId, workspaceId} + principalDeSesion() (envuelve requireWorkspace(), no reimplementa nada) + principalDeUsuario() para scripts sin sesión
leerWarehouse(principal).eq('workspace_id', …). Autoría y tenencia se separan: userId sobrevive donde toca —created_by al crear, autor en la versión de una vista— y nunca como filtro
buildWarehouseCatalog(principal) · los 6 handlersmisma firma, mismo eje
runQueryexige principal.workspaceId y falla ruidosamente sin él. Degradar (resolver el workspace desde el usuario) reconstruiría el agujero por dentro
runQuery, ademáscomprueba que el workspace derivado de las TABLAS es el del principal. Tras N2 eso es cierto por construcción — y «por construcción» es justo lo que este pilar ha aprendido a no dar por bueno
La routeel sujeto sale de la sesión; el userId del cuerpo se sigue aceptando y se contrasta: si no casa, 403 principal_mismatch. Ignorarlo en silencio sería dejar la puerta entornada

El gate

Delta en vivo, re-medido (no se confió en la medición de la mañana)0 ganados · 0 perdidos · 1 workspace · 1 creador
La función de PRODUCCIÓN contra la BD real630 filas · 92 tablas — el mismo universo exacto que con el filtro viejo
principal.test.ts6 sondas con DOS principales, sobre las 4 superficies: SHOW, information_schema.columns, resolución de FROM, y el caso que un test ingenuo no coge — dos objetos que se llaman igual en workspaces distintos (sin filtro, B resolvería ventas al de A y leería otro tenant sin error)
Verificado en la dirección de fallocon el filtro viejo restaurado, 2 sondas en rojo; sin filtro ninguno, todas
vitest lib/warehouse + lib/compute369/369
Matriz de catálogo · tsc64/64 · 0

Lo que N2 NO hace, y hay que decirlo: el rol no se mira todavía. Un viewer y un owner del mismo workspace ven exactamente lo mismo — que es lo correcto para ver, y está por decidir para leer. Eso es N3 (puedeVer / puedeLeer), y el sitio donde se aplica ya está: una función.

Pendiente: la route no se ha podido ejercitar en vivo (hace falta una sesión Clerk real), así que el 403 de principal_mismatch y el camino de sesión están fijados por tipos y por tests, no medidos contra producción. Se verifica al desplegar.


18 · N3 · lo entregado (2026-08-01)

El riesgo que esta fase tenía que esquivar — y se midió antes de escribir nada

§6 lo dejó por escrito: «ver ≠ leer se queda en dos funciones que devuelven lo mismo», con la mitigación «el test exige un caso donde DIFIERAN; si no se puede construir, la separación no está hecha». Así que primero se contó (scripts/dataspaces/m5-n3-discriminadores.ts):

106 objetos
① sin coordenada física (iceberg_namespace NULL) : 14
② sin esquema en Index                            : 14   ← los MISMOS 14
③ estados presentes                               : active 106  (no discrimina)
④ vistas con ataduras rotas                       : 0
⑤ roles                                            : owner 3 · viewer 3 · admin 6
                                                     (los seis tienen `view`: no discrimina)

OBJETOS QUE SE VEN Y NO SE PUEDEN LEER : 14 / 106

El caso existe y no hay que inventarlo. Son objetos heredados del plano PG demolido: la fila de catálogo quedó, la tabla nunca se escribió. Y de los 14, uno es la única vista del sistema — que sí es legible, porque una vista no necesita coordenada física. Mirar sólo el namespace se la habría llevado por delante; es el error fácil de esta fase y tiene su sonda.

El «antes», medido en vivo

SELECT count(*) FROM "Transform path (1) (b60865d3)"
  → Table with name ds_b60865d3b627444cac941159503f668e does not exist!
    Did you mean "ds_51685d44068c4d06a761582b0c3ad53e"?

Dos cosas mal, y la segunda no es de UX: el mensaje es incomprensible (el usuario tecleó un nombre lógico) y filtra identidades físicas de otros objetos. El «después», verificado en vivo tras el cambio:

reason  = not-readable
mensaje = "Transform path (1) (b60865d3)" existe en tu Warehouse pero no tiene datos
          materializados — nunca llegó a escribirse una tabla para él.

La forma: el objeto se REGISTRA marcado, no se esconde

La tentación era filtrar los no legibles fuera del catálogo. Se descartó: eso produce «ese nombre no existe», que es falso y además peor mensaje. La cadena queda así:

puedeVer (BROWSE)pertenencia al workspace. USE CATALOG/USE SCHEMA heredan: no existen catalog_grants ni schema_grants (las tres pestañas Permissions son stubs) — declarado, no omitido
legibilidad(obj)propiedad del objeto: ¿hay algo detrás? Separada a propósito de puedeLeer, para que el día que entren los grants se pueda distinguir «no puedes» de «no hay»
puedeLeer (SELECT)puedeVer y legible. Es el punto donde entrarán los grants por objeto: una condición más, y las seis superficies la heredan porque todas derivan de la misma lectura (F4a)
El rechazoocurre en el plan de lectura, con unreadableToken, y Junction lo tipa como not-readable — un motivo nuevo, no colapsado en unresolved-table. Colapsarlos mentiría: el objeto sí existe

Y el motivo se propaga desde dentro del cuerpo de una vista: una vista sobre una tabla sin materializar no es «una vista rota», y decirlo mandaría al usuario a arreglar lo que no es.

⚠️ La elevación de DEFINER NO se activa — y ahora con el motivo medido

La fila de N3 prometía activarla (venía de M4, que la aplazó «a M5, donde hay privilegios de verdad»). Al llegar aquí, la respuesta honesta es que no hay ningún caso al que aplicarla:

  • elevar = leer a través de la vista algo que no puedes leer directamente;
  • tras N2, la visibilidad es el workspace ⇒ ese caso sólo se da si la atadura apunta a otro workspace, y eso no es un privilegio que elevar: es tenencia, y debe seguir fallando;
  • lo único que niega una lectura dentro del workspace es N3 (no materializado), y ninguna elevación conjura datos que no existen.

Así que la elevación no queda inerte por falta de trabajo, sino porque no hay a quién elevar mientras no existan grants por objeto. Queda escrito en el sitio del código donde se notaría (duck-plan.ts, la rama de la atadura). Fingirla habría sido abrir un agujero para nadie — que es exactamente lo que M4 se negó a hacer, y sigue valiendo.

Los gates

visibility.test.ts10 sondas · el gate de la fase (①…⑥): sale en SHOW, se registra marcado, el plan lo rechaza con motivo, lo legible sigue funcionando, y el motivo cruza el cuerpo de una vista
Verificado en vivoel mensaje del «antes» y el del «después», los dos medidos contra producción
vitest lib/warehouse + lib/compute379/379
El resto no se moviógate de N1 verde otra vez (630 filas, 92 tablas, 0 perdidas) · matriz de catálogo 64/64 · tsc 0

Lo que N3 no hace: el rol sigue sin mirarse, porque medido, no discrimina — los seis tienen view. Restringir la lectura por rol es una decisión de producto, no una deuda técnica, y entra por puedeLeer el día que se tome.


19 · N4 · lo entregado (2026-08-01)

El hueco era UNA fila — y lo confirmó el censo antes de tocar nada

tablas LEGIBLES      : 92  ·  sin columnas:  0
tablas NO legibles   : 13  ·  sin columnas: 13   ← correcto: sin tabla no hay columnas (N3)
VISTAS               :  1  ·  sin columnas:  1   ← el hueco entero

Así que N4 no era «rellenar metadata»: era el schema-id de la vista, la cola que M4 mandó aquí con su motivo escrito («exige calcular el esquema de salida del cuerpo, que es preguntarle al motor»).

La frontera que lo desbloquea sin romper el gate de M5

Al motor se le pregunta al ESCRIBIR, una vez, y lo que dice se PERSISTE. Nunca al
LEER.

No es una excepción de conveniencia: es lo que hace el Iceberg View Spec, donde el schema-id lo calcula el motor que creó la versión y viaja dentro de la versión inmutable. Un hecho derivado y sellado no es una pregunta recurrente — y el gate de M5 es sobre el camino de lectura, que es donde estaba el minuto de bloqueo.

Entregado: migración 20261264 (aditiva y auto-verificada) con schema + schema_id en dataset_view_versions y view_schema_id en datasets; construirContrato sella el esquema; handleCreateView se lo pregunta al motor al crear; y el backfill de la única vista existente.

El schema-id comparte cuando la salida no cambia —dos versiones cuyo cuerpo se edita sin alterar lo que devuelve llevan el mismo schema-id—, que es la semántica del spec y tiene un uso concreto: un CREATE OR REPLACE que sólo toca el WHERE no debe invalidar a un consumidor pineado al esquema.

El backfill crea VERSIÓN, no hace UPDATE, porque el trigger de M4 lo impide: si la salida se sella con la versión, sellarla a posteriori es una versión. La vista quedó en v3, schema_id=3, 17 columnas — y el historial sigue contando la verdad (v1 nació sin atar, v2 sin esquema).

⛔ Y N4 destapó un DEFECTO VIVO: ninguna vista se podía LEER

Al pedirle al motor la forma del cuerpo saltó lo de verdad importante:

SELECT * FROM gold_order_risk_episodes   →  403 tabla_no_autorizada   (en producción)

La única vista del sistema no se podía consultar por la puerta. Y el diagnóstico costó cuatro mediciones porque las tres primeras hipótesis eran falsas:

HipótesisMediciónVeredicto
«el plan está mal»7 tablas base, SQL correcto❌ falsa
«el resolutor del repo falla»en frío, sobre el SQL y la rebanada exactos: 0 sin resolver❌ falsa
«el desplegado no maneja CTEs anidadas»sonda contra el duck-server real: 200 en las 3 formas❌ falsa
«el desplegado no es el repo»el SQL exacto + la rebanada exacta → 200❌ falsa

Lo que quedaba era mirar qué manda Junction de verdad, y ahí estaba:

entrada = body.logical_sql or sql      # routers/query.py

En enforce se resolvía el SQL lógico —el que tecleó el usuario—, donde una vista aparece por su NOMBRE. Y el nombre de una vista nunca está en la rebanada, porque la rebanada son sus tablas base. Tres consecuencias, y la tercera es la peor:

  1. toda lectura de una vista daba 403;
  2. de haber estado el nombre, se habría ejecutado el lógico resuelto — o sea sin la expansión, contra una tabla que no existe físicamente;
  3. como gobernanza estaba al revés: resolver el lógico deja las tablas de DENTRO del cuerpo de la vista sin comprobar, que es justo lo que la rebanada existe para comprobar (F4a).

El arreglo: en enforce se resuelve el SQL que se va a ejecutar; logical_sql conserva su papel en sombra, que es donde sirve (M2c: sin él se mediría un no-op). Cinco tests de regresión en test_m5_vistas.py, incluido el invariante que faltaba: una tabla no autorizada escondida dentro del cuerpo de una vista se delata.

Los gates

Huecos de metadata0information_schema.columns pasa de 630 a 647 filas
La vistav3 · schema_id=3 · 17 columnas · sale en information_schema y en DESCRIBE
Migraciónaplicada y auto-verificada contra la BD real
pytest duck-server211/211 (+5)
vitest · matriz · tsc392/392 · 64/64 · 0

Pendiente: el arreglo del 403 es de duck-server, así que la vista no se lee en producción hasta desplegar. Verificado en frío y por tests.


20 · N5 (F4b) · lo entregado (2026-08-01)

El mecanismo resultó más barato que el planeado — porque ya existía

M3 §12 midió una vía: mandar las filas como relación inline, que duck-server las inyecte en el cte_map del AST, y admitir un destino de un solo nivel en _split3. Todo eso era necesario antes de N4. Después, no:

  • buildDuckReadPlan ya sabe anteponer CTEs — es como expande una vista;
  • mergeViewCtes ya sabe fundirlas con un WITH del usuario (el bug que M2c mató era anteponer a ciegas, no esto);
  • rewriteDottedFromRefs ya colapsa nombres punteados a un identificador;
  • y desde N4 el motor gobierna el SQL que se ejecuta, donde una CTE es una CTE.

Una relación de metadata es, para el plan, una vista cuyo cuerpo es literal.
Cero código nuevo en el motor, cero cambio en _split3.

Lo entregado

inline-relation.ts materializa las relaciones que el SQL mencione como SELECT * FROM (VALUES …) AS _t(col, …), desde una sola lectura de Index —que aquí importa más que nunca: si tables y columns salieran de dos lecturas, un JOIN entre ellas podría no cuadrar consigo mismo—. El escapado de literales tiene su test: los nombres de tabla son datos del usuario, y un ' sin duplicar sería inyección por la puerta de atrás.

Tres piezas más: rewriteDottedFromRefs colapsa information_schema.<rel> al nombre entero citado (el prefijo no es una coordenada que se pueda ignorar); el rechazo declarado de F1 pasa a alMotor, que no es un 400 sino «esto lo contesta el motor»; y runQuery admite una lectura sin tablas base cuando hay relaciones inline — el punto que M3 §12 anotó como pendiente.

⚠️ Un hueco que N5 abría, cerrado en el mismo commit

Una lectura sólo-metadata llega con la rebanada vacía, y duck-server hacía if body.catalog_slice: — que en Python es falso con {}, así que se saltaba el resolutor entero. Pasa a is not None: una rebanada vacía significa «nada autorizado», no «no compruebes».

El gate — 4/5 en vivo, y el 5º explicado

✅ ② information_schema.tables ⋈ information_schema.columns
✅ ③ agregado libre (avg + group by) — lo que el evaluador cerrado NO hace
✅ ④ un WITH del usuario + la relación inline
✅ ⑤ una lectura normal sigue igual
❌ ① information_schema ⋈ una tabla REAL  → espera al despliegue de N4

① es el único que lleva rebanada no vacía, así que es el único que pasa por el resolutor del desplegado, que aún resuelve el lógico. Verificado en frío con el código actual sobre el SQL y la rebanada exactos: 0 sin resolver ⇒ en enforce daría 200. Y ②③④ ya demuestran lo que el rechazo de F1 quitaba: joins entre relaciones y agregados arbitrarios sobre la metadata.

Tamaño medido del literal: 86 KB para 647 columnas + 106 tablas. Tope declarado: TOPE_FILAS_INLINE = 20.000.

Pendiente: desplegar duck-server cierra ① y la lectura de vistas a la vez — es el mismo arreglo.


Fuentes