Published

El paradigma de nombres — approach de construcción

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

El paradigma de nombres — approach de construcción

Fecha de la medición: 2026-08-11 (noche). Nace de una pregunta del owner —«¿por
qué el paradigma de namespacing es difuso?»
— que resultó tener una respuesta
estructural, no cosmética.

Manda sustrato-05.md sobre los hechos. Sucede a
index-n0-n2-approach.md, que cerró N·0 y N·1 y cuyo
trabajo sigue APAGADO
; esto es lo que falta para que sirva.

Regla de la casa: cada hecho lleva el comando que lo demuestra. Lo que no lo lleva
va marcado como propuesta (§7) o decisión del owner (§6).


0 · El problema, en una frase

El namespace físico es una COPIA del nombre editable. De ahí salen todos los
síntomas: el nombre decide dónde viven los bytes, y el aislamiento real —el
warehouse— es invisible desde el nombre.

Dos ejes que deberían ser independientes, hoy cruzados:

EJE A · CÓMO SE LLAMA      catalog . schema . table      ← editable por el usuario
EJE B · DÓNDE VIVEN BYTES  warehouse → key-prefix        ← inmutable, del inquilino

hoy:   namespace físico  =  {catalog.slug}.{schema.slug}   ⚠️ A metido dentro de B

1 · Lo medido el 2026-08-11 — con su comando

HechoComando
main.default es un namespace Iceberg REAL con 99 tablas; Index sólo estampa 84npx tsx scripts/warehouse/ns-fisicos.ts
main.my_first_project está estampado en Index y NO existe físicamente — un fantasma vivoídem + ns-censo.ts
25 tablas en namespaces que Index desconoce (datasets, bench, test.*)ns-fisicos.ts
9 catálogos en Index, 8 llamados main (uno por workspace) + karma vacíons-censo.ts
Crear catálogo y esquema es un INSERT en Postgres y NADA másCREATE TABLE test.test1.xNoSuchNamespaceExceptionnpx tsx scripts/governance/n3-namespace-nuevo.ts
La coordenada catalog.schema se TIRA: rewriteDottedFromRefs colapsa FROM a.b.cFROM c y se resuelve por nombre dentro del workspacenpm run check:plan-diferencial (§«los 4 empates que esconden una pérdida»)
Los 8 warehouses por inquilino existen y están active, con fila en Index y warehouse_idn2-que-es-w-uuid.ts
⛔ …pero dedicated_storage=false y jurisdiction=default en los 8isDedicated() falso ⇒ la cara sirve el compartido a todosídem
_ensure_namespace del writer crea el namespace y sus padres (create_namespace(parts[:i]))services/ml-runner/app/lakehouse/writer.py:908-918
LAKEHOUSE_ENV = prod en Railway (workers), ausente en Vercelrailway variables --service "Carbon Jobs" · vercel env ls production
⚠️ Divergencia de token: el workspace activo está provisionado como carbon-**test**-w-… mientras los workers dicen prodn2-que-es-w-uuid.ts
N catálogos en una sesión cuestan ~1.062 ms cada uno, UNA vez, perezoso, sin impuesto por query y sin degradar a los ya registradosnpx tsx scripts/bridge/n1-coste-n-catalogos.ts

2 · Las tres cadenas que se confunden

w_<uuid> no es un warehouse. Son tres cosas, en tres espacios de direcciones:

CadenaQué esQuién la usa
w_<hex>la dirección del INQUILINO en NUESTRO espaciomotor ↔ cara
carbon-<env>-w-<hex>el warehouse REAL de Lakekeeper (tenantWarehouseName)cara → Lakekeeper
<env>/w_<hex>el key-prefix en R2 (tenantKeyPrefix)Lakekeeper → R2

Y la cara traduce, sustituyendo el prefijo del motor por el del catálogo destino:

prefijoDestino = tenantUpstreamPrefix(workspaceId) ?? upstreamPrefix()   // hoy: siempre el 2º

3 · El norte: nombre ≠ ubicación, que es lo que hace la referencia

Y no por la razón que parece. Unity Catalog no mete catalog.schema en la ruta: guarda las tablas gestionadas en <managed-location>/tables/<table_id UUID>, y los motores resuelven por nombre a través del catálogo, nunca por ruta. UC es limpio porque SEPARÓ el nombre de la ubicación. Nosotros somos los que los tenemos pegados.

El nombreLa ubicación física
Unity Catalogcatalog.schema.table (metastore)<managed-location>/tables/<uuid>
Carbon HOYcatalog.schema.table (Index)main.default / ds_<uuid> — el nombre va dentro
Carbon, el nortecatalog.schema.table (Index){env}.w_{workspace} / ds_{uuid}

El principio ya está escrito en lib/lakehouse/opaque-namespace.ts:

El namespace físico debe espejar lo INMUTABLE (el tenant), no lo EDITABLE (la
taxonomía).

⭐ Y lo que esto RESUELVE, más allá de renombrar

Prohibir el renombrado —decisión legítima del owner— quita el síntoma, no el coste:

  1. La fórmula {catalog.slug}.{schema.slug} existe en dos lenguajes (TS y Python) y se mantiene a mano. Ya se rompió una vez (F3: DuckDB y ml-runner apuntando a tablas distintas).
  2. Dos sistemas se declaran autoridad del namespace (Index y Lakekeeper) — de ahí el pin defensivo de datasets.iceberg_namespace.
  3. ⭐⭐ OpenFGA hereda jerárquicamente sobre namespaces. Con el namespace espejando la taxonomía, la tenencia no es aplicable en el catálogo; con w_<workspace> sí. Es lo que hoy deja la gobernanza de usuario en 0 grants (§5 del sustrato).
  4. Los fantasmas dejan de ser posibles: el namespace de destino existe siempre (uno por inquilino), así que el hecho ② no puede repetirse.
  5. Crear un catálogo o un esquema vuelve a ser lo que debe ser: una fila en Index. No hay nada físico que crear ⇒ el hecho ⑤ desaparece en vez de arreglarse.

4 · Lo que ya está construido, y por qué no sirve todavía

EstadoLo que le falta
N·0 un solo resolvedor🏁 hecho (7dd8035) — 8 sitios migrados
N·1 romper el espejo🏁 hecho, gate 6/6 verde en vivo (se renombró un esquema y la lectura siguió)INERTE: LAKEHOUSE_OPAQUE_NS_WORKSPACES no existe en Vercel · LAKEHOUSE_ENV tampoco
_ensure_namespace🏁 crea el namespace y sus padres

⛔ Y el hueco que hace que encenderlo hoy NO resuelva el caso del owner

opaqueNamespaceFor tiene un solo llamante: iceberg-native-write.ts:132 — ingesta y pipelines. El CREATE TABLE del SQL Editor va por table-materialise.ts, que estampa normalizeNamespace(input.namespace), es decir el namespace que vino en el SQL.

⇒ Con N·1 encendido tal cual, CREATE TABLE test.test1.item desde el editor seguiría fallando igual. Es la razón por la que este documento existe en vez de un vercel env add.


5 · Las fases, con su gate

QuéGate
N·3medir si un catálogo/esquema nuevo funciona🏁 n3-namespace-nuevo.tsNO, y el punto de rotura es un INSERT sin efecto físico (hecho ⑤)
N·3·a⭐⭐ el token de entorno, decidido y ÚNICO🏁 CERRADAn3a-token-de-entorno.ts salida 0. Token = prod, declarado (no derivado). Ver §5·bis
N·3·bcubrir el SEGUNDO camino de nacimiento: opaqueNamespaceFor en table-materialise.tsn3-namespace-nuevo.ts en verde: CREATE + UPDATE + SELECT sobre test.test1
N·3·cencender el canary sobre UN workspace (LAKEHOUSE_OPAQUE_NS_WORKSPACES=<id>, nunca *)el mismo N·3, contra ese workspace, más el censo físico enseñando {env}.w_<hex> recién creado
N·3·d⚠️ el control negativo: un workspace SIN canary sigue naciendo en el espejo, sin regresióndos corridas de N·3, una por régimen
N·4retirar la fórmula del espejo + trinquete que impida reintroducirlacheck: propio, en frío
N·5la coordenada lógica pasa a GOBERNARSE🟡 mecanismo hecho, INERTE — y el gate encontró por qué. Ver §5·ter
N·6cambió de significado — ya no es naming (lo cerraron N·1/N·4), es aislamiento de almacenamiento🟡 mitad hecha: 7 de 8 inquilinos servidos por SU warehouse. El 8º exige mudanza. Ver §5·quater

N·3·a va primero y no se puede saltar. El resto son reversibles; el token no: datasets.iceberg_namespace se pinea al commitear, así que un token mal elegido es permanente para esa tabla y deshacerlo es copiar bytes.

⚠️ N·5 es el que más sorprende y el que más importa a largo plazo. Hoy el tercer nivel del nombre es decorativo en el camino de lectura. Con el namespace opaco, la coordenada deja de tener respaldo físico y pasa a depender enteramente de que Index la valide. Si N·5 no entra, catalog.schema es un adorno con aspecto de frontera — que es peor que no tenerlo.


5·bis · 🏁 N·3·a, cerrada — y la corrección que destapó

El token es prod, y por qué ése

Lo que dicen los referentes: el entorno es una FRONTERA, no un prefijo. Unity Catalog usa un catálogo por entorno, con su propia ubicación de almacenamiento y un ISOLATED binding que hace la producción inalcanzable desde dev «even if an identity is misconfigured». Lakekeeper ofrece Project → Warehouse para lo mismo.

⚠️ Y nuestro código ya está de acuerdo: carbon-<env>-w-<hex> + key-prefix <env>/w_<hex>. El token dentro del namespace es redundante con el warehouse

…salvo que hoy es portante, y por tres hechos que se combinan: los 8 inquilinos caen al warehouse compartido (⑧), dev y prod comparten el mismo control-plane (los scripts con .env.local leen los mismos 84 datasets que sirve producción) ⇒ los mismos UUID de workspace, y comparten warehouse y credencial. Sin token, un write de dev caería en el mismo namespace que producción.

prod en Vercel Production y Railway; test en local. Declarado TRANSITORIO: el norte es que el entorno sea el warehouse y el namespace quede en w_{workspace} a secas. Mientras el token viva en el nombre, el aislamiento entre entornos es una convención que una errata derrota.

⛔ La corrección: los warehouses no estaban todos vacíos

Este documento decía (⑦/⑧) que los 8 estaban vacíos. Falso, y por medir en un solo sitio: se miró el warehouse compartido y se concluyó sobre el sistema.

carbon-test-w-cccc70a5…   main.test 10 · test.p3_gate 1
carbon-test-w-7b500f4d…   main.my_first_project 1 · main.default 1 · main.test 1

⭐⭐ Y con ello main.my_first_project deja de ser un fantasma: existe — en el warehouse del inquilino. Lo que le pasa es peor y más interesante: es inalcanzable por ENRUTADO, porque isDedicated() es falso y la cara sirve el compartido. El dato está y la puerta no llega. 15 tablas en esa situación.

⚠️ Y el reconciliador dio un FALSO VERDE antes de acertar

provisionTenantWarehouse es idempotente por workspace, no por token: lleva un early-return —«si ya está activo, no se toca nada»— y devuelve la fila vieja sin mirar el token que se le pase. La primera corrida imprimió 🏁 reapuntado cuatro veces sin cambiar nada, y sólo se cazó porque se volvió a correr el gate.

⇒ Dos arreglos, y el segundo es el que vale: sacar la fila de active para que el provisionador vuelva a trabajar (estado intermedio seguro: con state != active, isDedicated es falso y la cara sigue sirviendo el compartido, que es lo que ya hacía), y que el script COMPRUEBE el resultado releyendo la fila en vez de fiarse de que la llamada no lanzara.

El estado tras reconciliar

② prod  8 inquilinos  ✅        ③ los 8 warehouses, 0 tablas ✅
④ control · sin token NO acuña ✅
🏁 COHERENTE — se puede acuñar con el token "prod".

⚠️ LAKEHOUSE_ENV=prod puesto en Vercel Production, pero una variable puesta no es una variable vigente (§12·37): entra en vigor con el próximo despliegue. No corre prisa — la acuñación sigue apagada hasta que exista LAKEHOUSE_OPAQUE_NS_WORKSPACES.


5·ter · 🟡 N·5 — el mecanismo está, y descubre que depende de P·4·bis

Lo medido primero, que es lo que ordenó el resto

n5-coordenada-gobernada.ts, contra el sistema vivo, con una tabla de test.test1:

① su propia coordenada        RESUELVE     ② OTRA coordenada       RESUELVE
③ una coordenada INVENTADA    RESUELVE     ④ el nombre pelado      RESUELVE

El tercer nivel del nombre es DECORATIVO. Y no era un bug: la caída al segmento pelado está documentada como deliberada en catalog.ts — hace que una FQN estilo Databricks cuyo catálogo/esquema no casen con los de Carbon siga resolviendo.

Pero el suelo se movió bajo esa decisión. Mientras el namespace físico espejaba la taxonomía, una coordenada equivocada fallaba de rebote: el namespace no existía. Con N·3 el namespace es opaco y todas las coordenadas de un workspace apuntan al mismo sitio ⇒ ya nada la comprueba. La permisividad dejó de ser una comodidad y pasó a ser el único motivo por el que catalog.schema no es una frontera.

Lo entregado

RegimenCoordenada = 'permisiva' | 'estricta' (NS_COORDENADA), permisiva por defecto para no romper nada al desplegar. En estricta, una referencia cualificada sólo casa por su clave exacta; el nombre pelado sigue resolviendo igual — la severidad es sobre lo que el usuario AFIRMA, no sobre lo que omite. 6 tests en frío.

⛔⛔ Y el hallazgo: en vivo, la estricta no cambia nada

Los tests en frío pasan y en vivo las cuatro siguen resolviendo. La diferencia entre «el régimen está roto» y «al régimen no le llega nada» la contesta ⑤ del gate:

antes  : SELECT * FROM noexiste.tampoco.n3_item_…
después: SELECT * FROM n3_item_…

rewriteDottedFromRefs destruye la coordenada en duck-plan.ts:214, ANTES de que resolveCatalogRef vea nada — y el propio código lo dice: «colapsa TODA coordenada al último segmento». El régimen estricto guarda una puerta por la que nadie pasa.

⭐⭐⭐ La conclusión, que es de arquitectura y no de esta fase

N·5 depende de P·4·bis. La coordenada no se pierde al resolver: se pierde al
REESCRIBIR TEXTO. Y lo que la conserva ya existe — es el lector del plan, que
devuelve referencias: string[][], coordenadas enteras. P·3 lo midió y lo escribió sin
saber que aquí haría falta: «los 4 empates esconden una pérdida: coinciden sólo porque
el viejo TIRÓ la coordenada; el lector la CONSERVA»
.

⇒ Los dos frentes de esta sesión —el parser del motor y el paradigma de nombres— no eran paralelos: son el mismo. Gobernar el tercer nivel del nombre exige dejar de tratar el SQL como texto, que es exactamente lo que P·4·bis retira.


5·quater · 🟡 N·6 — y por qué dejó de ser la fase que parecía

⭐ N·6 cambió de significado bajo nuestros pies

Se planteó como «adoptar el mapeo canónico: el catálogo del usuario ES un warehouse de Lakekeeper». Era una pregunta de nombres.

N·1 y N·4 ya resolvieron los nombres: el namespace físico es {env}.w_{workspace} y la taxonomía vive en Index. La tenencia ya está expresada en el namespace, y OpenFGA hereda jerárquicamente sobre namespaces.

⇒ Lo que queda de N·6 no es naming: es aislamiento de ALMACENAMIENTO — prefijo propio en R2, credencial propia, residencia. Que es exactamente lo que el código ya modelaba como opt-in con motivo declarado (dedicated_storage, jurisdiction). No era una fase pendiente: era una capacidad ya diseñada y sin encender.

⛔ Y no hay versión incremental — medido, no razonado

La cara resuelve el warehouse por inquilino y por petición, no por tabla:

prefijoDestino = tenantUpstreamPrefix(workspaceId) ?? upstreamPrefix()

⇒ En cuanto isDedicated es cierto, todas las peticiones de ese inquilino van a su warehouse, y lo que siga en el compartido deja de ser alcanzable. No es teoría: ya le pasó a 15 tablas en carbon-test-w-* — existen, y la puerta no llega.

El reparto, medido (n6-coste-de-la-mudanza.ts)

7 inquilinos → encender es GRATIS (0 tablas)
1 inquilino  → 123 tablas / 128.012 filas quedarían INALCANZABLES

🏁 N·6·a — hecho: la mitad que no cuesta

n6a-encender-warehouse-propio.ts --aplicar: 7 de 8 encendidos y verificados releyendo la fila (la lección del falso verde de N·3·a). El script se niega a tocar un inquilino con datos: un flag que parece un interruptor y es una mudanza no se aplica a ciegas.

Verificado: n2-que-es-w-uuid dice «la cara le sirve: SU warehouse» en 7 de 8, y n3e-regresion-espejo sigue verde para el que se quedó en el compartido.

⇒ Cada inquilino nuevo nace ya en el mapeo canónico —su warehouse, su prefijo, su credencial— sin mover un byte. Lo que no se haga hoy sólo cuesta más mañana.

Lo que queda, y es del owner

El inquilino con 123 tablas exige copiar bytes (Lakekeeper valida el key-prefix antes de leer la metadata ⇒ register_table no adopta cross-warehouse). Tres salidas, y ninguna es obviamente la buena:

copiar128.012 filas, una vez. Honesto y acotado
enrutar por TABLA en la carapermitiría estar a caballo — pero el warehouse es un concepto de CONEXIÓN, y hacerlo por tabla lo desnaturaliza
no hacerlo⭐ defendible: tras N·1/N·4 la tenencia ya está en el namespace. El warehouse propio sólo añade aislamiento de almacenamiento, que es una necesidad de cliente (residencia), no de arquitectura

6 · Las decisiones del owner (no de implementación)

La preguntaPor qué no la decide el código
El token de entornoVercel no lo tiene · Railway dice prod · el workspace activo está provisionado como test. ¿Cuál es EL token?Se acuña permanente. Elegir mal no se deshace sin mover bytes
La coordenada lógica¿test.test1.item con la coordenada equivocada falla o resuelve igual?Hacerla fallar rompe queries que hoy funcionan por accidente
dedicated_storage¿Se encienden los 8 warehouses por inquilino?Encenderlo dirige al inquilino a su warehouse vacío: la mudanza es copiar bytes (Lakekeeper valida el key-prefix antes de leer la metadata ⇒ register_table no adopta cross-warehouse)

7 · Los costes, declarados

⚠️ El token se acuña permanentemitigado por N·3·a (decidirlo antes) y por el canary por workspace, nunca global
⚠️ El canary por workspace mezcla dos regímenesa propósito: es lo que permite el control negativo de N·3·d. El coste es que el censo tiene dos formas a la vez durante la transición
⚠️ N·5 puede romper queries que hoy funcionanporque hoy funcionan por accidente (la coordenada se tira). Es una corrección, y se declara como tal
⚠️ LAKEHOUSE_ENV en Vercel desbloquea /api/organization/storagehoy esa ruta falla; ponerlo la arregla. No arma el provisioning: el webhook de Clerk encola y el worker ya tiene el token (hecho ⑩)

8 · Lo que este documento NO decide

Si catalog pasa a ser warehousees N·6. ⑫ retira el argumento del coste, pero la mudanza de los datos sigue siendo el obstáculo
Renombrarel owner decidió que no se permite. Este approach no depende de esa decisión: resuelve los otros cuatro costes igual
La UI del Warehousequé enseña y cómo, fuera de alcance

9 · Los comandos

# en frío / contra Index
npx dotenv -e .env.local -- npx tsx scripts/warehouse/ns-censo.ts        # taxonomía en Index
npx dotenv -e .env.local -- npx tsx scripts/warehouse/ns-fisicos.ts      # namespaces REALES
npx dotenv -e .env.local -- npx tsx scripts/warehouse/n2-que-es-w-uuid.ts # las 3 cadenas
npx dotenv -e .env.local -- npx tsx scripts/governance/n3-namespace-nuevo.ts  # ⭐ el gate

# contra el motor (port-forward al puente)
npx tsx scripts/bridge/n1-coste-n-catalogos.ts    # el coste de N catálogos

⚠️ ListNamespaces NO es recursivo: sin ?parent= sólo devuelve el primer nivel, y main sale con 0 tablas mientras main.default tiene 99. Leerlo sin recorrer los hijos concluye que el warehouse está casi vacío — falso, y es la trampa nº1 de este censo.