Published

W4 · CREATE TABLE desde el editor — mini-spec

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

W4 · CREATE TABLE desde el editor — mini-spec

Fecha: 2026-08-10. Desarrolla la fase W4 de
w-write-path-approach.md §D4.

Existe porque al medirlo el bloqueante resultó ser otro que el que el approach
nombra, y porque hay una decisión de gobernanza que ese plan no contempla: el
motor entra con una identidad de SERVICIO, así que un CREATE autorizado en el
catálogo no puede distinguir usuarios.

Regla, la de siempre: cada hecho lleva el comando que lo demuestra.

Manda sustrato-03.md donde discrepen.


1 · Lo MEDIDO — no muere donde dice el approach

El approach dice: «hoy createTable muere con unknown-table porque la cara exige que el dataset exista en Index». Medido contra el sistema vivo, por el puente:

CREATE TABLE main.default.__w4_probe_… (id INT) USING iceberg
   → Forbidden: lakekeeper-spark-editor no puede loadTable (unknown-table)

CREATE TABLE … USING iceberg AS SELECT 1 AS id          (CTAS)
   → Forbidden: … no puede loadTable (unknown-table)

CREATE TABLE main.__ns_inexistente.… (id INT)           (namespace que no existe)
   → Forbidden: … no puede loadTable (unknown-table)

Los tres mueren en loadTable, no en createTable. El cliente Iceberg comprueba si la tabla existe antes de crearla, y la cara contesta «no puedes» donde lo cierto es «no está». El createTable no llega a intentarse nunca.

⇒ El primer bloqueante de W4 no es la materialización: es un código de respuesta.


2 · Lo que dice el estándar

404 NoSuchTableExceptionloadTable de una tabla que no existe
403 ForbiddenExceptionpermisos insuficientes
404 en createTableel namespace no existe (la tabla, obviamente, tampoco)

La cara viola la spec de Iceberg REST: convierte «no existe» en «no puedes». Y no es un matiz de purismo — es exactamente lo que impide crear una tabla, porque un cliente que recibe 403 aborta, y uno que recibe 404 procede a crearla.

Y el repo ya tenía la regla escrita, en la resolución de nombres lógicos (route.ts, W‑nombres):

«un nombre que no resuelve se reenvía tal cual y muere en el catálogo destino con
su NoSuchTableException. Convertirlo en 403 aquí diría "no puedes" cuando lo
cierto es "no está"
»

La regla existe desde el 08-10 para el resolutor… y catalog-authz la contradice tres capas más abajo: reason: 'unknown-table' sale por el mismo return 403 que no-privilege. Un principio escrito en un comentario no es un gate (§12·10).


3 · La decisión que el approach no contempla — ⭐⭐ ¿QUIÉN autoriza el CREATE?

D4 dice «la cara autoriza CREATE en el SCHEMA padre». La autorización ya está modelada así y es correcta:

createTable: { requires: ['TABLE_CREATE'], scope: 'namespace', operation: 'ddl' }

Pero el principal que llega es de SERVICIO. Para Spark, quien pregunta es lakekeeper-spark-editor, no el usuario. ⇒ Autorizar el CREATE ahí sólo puede responder «¿puede el editor crear tablas en este inquilino?», nunca «¿puede ESTE usuario?». Todos los usuarios del workspace tendrían el mismo permiso.

Para leer y escribir eso no importa, porque la puerta ya validó tabla por tabla antes de llamar al motor. Para crear, la puerta no interviene: hoy rechaza el verbo.

⇒ D-W4·1 · El CREATE lo autoriza LA PUERTA, y lo materializa LA CARA

Cada uno hace aquello para lo que es el único sitio posible:

usuario:  CREATE TABLE ventas (id INT, …)
  puerta  → conoce al USUARIO ⇒ autoriza `TABLE_CREATE` sobre el SCHEMA destino
          → crea el dataset en Index          ⇒ obtiene su uuid
  motor   → CREATE TABLE <ns>.`ventas`        (nombre lógico, como todo lo demás)
  cara    → traduce `ventas` → `ds_<uuid>`    (ya lo hace: `catalog-names.ts`)
          → reenvía a Lakekeeper

Y esto reconcilia D2, que negaba TABLE_CREATE al editor. El motivo de D2 era: «darlo al motor abriría un segundo camino de creación — que es justo lo que produce la deriva Index↔físico ya medida». Ese argumento se sostiene sobre un mundo sin D4: lo que produce deriva es crear la tabla física sin que Index se entere. Aquí el dataset nace primero en Index y el motor sólo materializa lo que ya está registrado. ⇒ No hay segundo camino: hay uno, y empieza en Index.

⚠️ El privilegio del principal deja de ser la política y pasa a ser el conducto — que es la formulación que este repo ya usa: privilegio al conducto, política al objeto. La política de quién crea vive en Index y la aplica la puerta, contra el usuario.

⚠️ Y el conducto hay que cerrarlo por el otro extremo: el puente exige un JWT firmado con BRIDGE_JWT_SECRET, que sólo tiene la aplicación. Sin eso, dar TABLE_CREATE al principal sí sería un agujero — se podría crear saltándose la puerta. Es la misma dependencia que ya tiene toda la escritura, y sigue en la lista de secretos por rotar.

⚠️⚠️ 3·bis · LO MEDIDO AL IR A IMPLEMENTAR D-W4·1 — y corrige la decisión

Antes de escribir una línea de W4·1 se midieron sus dos premisas. Las dos son falsas.

H1 · No hay grants de usuario. Ninguno.

grants por principal_kind:   360 × service          (y 0 de todo lo demás)
usuarios distintos con grants: 0
TABLE_CREATE: 0 grants          ← ni siquiera los servicios lo tienen explícito

PrincipalKind incluye 'user' y el modelo lo soporta, pero principal_grants sólo está poblado para servicios. ⇒ Autorizar el CREATE con decidePrivilege(usuario, 'TABLE_CREATE') denegaría a todo el mundo: fail-closed correcto y función inútil.

⭐ Y el dato colateral explica el resto: los servicios tienen TABLE_CREATE por implicación de CATALOG_MANAGE_CONTENT, no concedido. El cierre transitivo funciona; lo que no existe es la capa de usuario.

La autorización efectiva de un usuario hoy es la PERTENENCIA AL WORKSPACE — es exactamente la que la puerta ya aplica para leer y escribir (buildWarehouseCatalog filtra por workspace, y luego se comprueba cross-workspace). W4 no puede inventar una granularidad que el sistema no tiene; la granularidad por usuario es el Horizonte 2 del sustrato (encender OpenFGA), no una fase de W4.

D-W4·1 se corrige: la puerta autoriza con el mismo criterio que ya usa para todo lo demás (el schema destino es de tu workspace), y se dice el límite en vez de fingir que se comprueba un privilegio que nadie tiene.

H2 · Un dataset no puede nacer sin proyecto

createDatasetArtifact es la puerta única de nacimiento, y exige:

workspaceId: string;  projectId: string;  createdBy: string;  name: string;
service: string;      schema: SchemaColumn[];  parentId: string | null;

projectId es obligatorio, y por defecto además crea un satélite en project_files. Un CREATE TABLE del SQL Editor no tiene proyecto ni carpeta.

Existe ya el modo sin satélite (satellite: false, I·2a.2) — pero projectId sigue siendo obligatorio. ⇒ W4·1 topa de frente con el linchpin conocido de la reorganización del Warehouse: desacoplar datasets de project_files.

⭐⭐ H3 · Y de ahí sale un diseño MEJOR — vuelve la D4 original

Si la puerta crea el dataset, necesita el esquema… que está dentro del DDL (CREATE TABLE ventas (id INT, nombre STRING)). Habría que parsear SQL en la puerta, que es justo lo que este repo evita en todas partes: el destino se DECLARA, no se parsea.

Si lo crea la cara, el esquema le llega ya estructurado en el body del createTable de Iceberg REST. Cero parseo.

usuario:  CREATE TABLE ventas (id INT, …)
  puerta  → AUTORIZA (workspace) y deja pasar el verbo · declara la intención
  motor   → createTable  {name: "ventas", schema: {…}}   → la cara
  cara    → crea el dataset en Index CON ESE ESQUEMA ⇒ uuid
          → reenvía a Lakekeeper como `ds_<uuid>`

Es la D4 original del approach, y la medición la reivindica: la cara materializa porque es la única que tiene el esquema sin parsear nada.

⚠️ Lo que la cara no tiene es quién es el usuario (entra un principal de servicio), y hace falta para created_by. ⇒ Se propaga con el mismo mecanismo que el operation-id de W3·c: la puerta declara la intención en una fila durable y la cara la consume. El patrón ya está construido y probado (write-claim.ts).

🏁 H2 · RESUELTO — datasets.project_id es nullable (decisión del owner, 08-10)

De las tres salidas posibles se eligió hacer el proyecto opcional, que es un paso real del desacople datasetsproject_files en vez de un rodeo:

-- 20261275_datasets_project_opcional.sql
ALTER TABLE public.datasets ALTER COLUMN project_id DROP NOT NULL;

Los cuatro sitios donde un NULL podía morder se midieron ANTES contra la base realdatasets es una tabla central y «seguro que no pasa nada» no es una medición:

Qué se comprobóResultado
RLSla única política es datasets_workspacefiltra por workspace_id IN (user_workspace_ids())no menciona project_id
datasets_derive_schemaqué hace sin proyectoya lo contemplaba: IF NEW.project_id IS NOT NULL THEN … y cae a main/default
datasets_name_uniquepor qué resuelve(workspace_id, schema_id, lower(name))tampoco toca project_id
índices y constraintslos dos índices que lo citanbtree (indexa NULLs) · cero constraints y cero FK sobre la columna

⇒ El cambio es de nulabilidad y nada más: ni datos, ni política, ni unicidad. Y la migración se auto-verifica —lee el estado resultante y aborta si no es el esperado—, porque un DROP NOT NULL sobre una columna que ya lo tenía quitado es un no-op silencioso. Aplicada: NO → YES.

⚠️ El cerrojo pasa al código, y con su diagnóstico: sin proyecto sólo se puede nacer sin satélite (un satélite vive en project_files, que sí lo exige). Se comprueba en assertTenancy, donde el error puede decir qué falta — dejarlo pasar reventaría tres pasos más abajo con un NOT NULL violation sobre otra tabla.

⚠️ No convierte ninguna fila: los 109 datasets conservan su proyecto; sólo los nuevos pueden nacer sin él.

D-W4·2 · El nombre físico lo pone el catálogo, y ya funciona

Nada nuevo: ds_<uuid> lo genera Index al crear el dataset y la traducción la hace catalog-names.ts, que ya está en producción y probada (b6-gate-nombres). W4 no inventa aquí — reusa.

D-W4·3 · La compensación NO es opcional, y ya hay 20 pruebas de por qué

El approach lo avisó: «un CREATE que registre el dataset y luego no cree la tabla física deja una fila fantasma». W5·c lo cuantificó: hay 20 fantasmas hoy, y 12 son artefactos de flujos que fallaron a medias.

⇒ Si la puerta crea el dataset y el motor falla, la puerta compensa: borra el dataset que acaba de crear. compensate.ts ya existe. Sin esto, cada CREATE fallido suma una fila fantasma — y W4 se convertiría en la principal fábrica de la deriva que W5 acaba de censar.

D-W4·4 · CTAS entra, y no por comodidad

CREATE TABLE … AS SELECT es la forma en que un editor SQL crea tablas de verdad; la tabla vacía es el caso raro. Y no cambia el diseño: la puerta autoriza y crea el dataset igual, y el motor materializa con datos en vez de sin ellos.

⚠️ Lo que sí añade es que la fuente hay que resolverla, y el write-path sólo cualifica el destino — es exactamente el motivo por el que INSERT … SELECT está fuera de DML_VERBS_PERMITIDOS.

🏁 Medido y resuelto (08-10) — y la solución NO era «pasarlo por el plan»

① CTAS con fuente CUALIFICADA (main.default.censo_poblacional)  → ✅ SUCCEEDED
② CTAS con fuente PELADA      (censo_poblacional)               → ⛔ TABLE_OR_VIEW_NOT_FOUND
③ CTAS sin fuente             (SELECT 1 AS id)                  → ✅ SUCCEEDED

CTAS ya funcionaba —y el dataset nacía con el esquema derivado del SELECT, 4 columnas correctas—; lo único roto era el nombre pelado. Dárselo al plan de lectura parecía la respuesta obvia… y no lo es:

WITH … CREATE TABLE x AS SELECT …   →  ParseException
CREATE TABLE x AS WITH … SELECT …   →  SUCCEEDED

El plan antepone su prólogo, y delante de un CTAS no parsea: el WITH de un CTAS va DENTRO, después del AS. ⇒ Se planifica sólo el cuerpo (splitCtas) y se recompone.

⭐ Y partirlo trae una propiedad de regalo: el destino no pasa por el plan, así que no hay forma de que se lo cualifique por accidente — que es justo el fallo que costó W3·a·0 en el otro camino.

⚠️ Las tablas FUENTE se validan por tenencia, igual que en cualquier lectura. Sin eso, un CTAS sería el único camino de la puerta que lee sin comprobar de quién es lo que lee — y leer para copiar no es menos leer.


4 · Las fases, con su gate

FaseQué entregaGate
🏁 W4·0«no está» deja de ser «no puedes»HECHO Y DESPLEGADOunknown-table404 NoSuchTableExceptionw4-0-gate-no-esta.ts salida 0 contra producción: 200 / 404 / 403, las tres distintas. Ver §4·bis
🏁 W4·1aUn dataset puede nacer SIN PROYECTO — HECHOmigración 20261275 + projectId: string | null en la puerta de nacimiento✅ 28/28 en dataset-artifact-satellite.test.ts · migración auto-verificada NO → YES · y el negativo: con proyecto se sigue validando la tenencia
🏁 W4·1bLa puerta acepta el verbo — HECHOcamino propio: sin writeTarget (el destino no existe) y sin plan de tablas✅ gate ① · y CREATE OR REPLACE sigue prohibido (⑥)
🏁 W4·2La cara MATERIALIZA (D4 original, H3) — HECHOal recibir createTable, crea el dataset en Index con el esquema del body y reenvía como ds_<uuid>✅ gate ②③④ · dataset + tabla física + el nombre lógico resuelve
🏁 W4·3La compensación (D-W4·3) — HECHAsi el catálogo rechaza, se borra el dataset recién creado (y si el borrado falla, se registra el incidente con su id)cableada desde el primer día, no dejada para después — que es como nacen los fantasmas
🏁 W4·4CTAS (D-W4·4) — HECHOla puerta planifica sólo el CUERPO y recompone✅ gate ⑦ · CTAS con la fuente sin cualificar crea la tabla con el esquema heredado del SELECT

4·bis · 🏁 W4·0, CERRADO CONTRA PRODUCCIÓN (2026-08-10)

① tabla que EXISTE + privilegio        → 200
② tabla que NO EXISTE                  → 404 NoSuchTableException
③ tabla propia bajo prefijo AJENO      → 403 «la tabla no es del inquilino declarado»
🏁 salida 0

Y lo que de verdad prueba que sirvió: el CREATE avanzó de sitio.

antes:  Forbidden: … no puede loadTable (unknown-table)        ← ni se intentaba
ahora:  Forbidden: … no puede createTable — falta TABLE_CREATE ← llega, y nombra lo que falta

⇒ El bloqueante pasó de ser invisible (el cliente abortaba en una comprobación de existencia) a ser accionable: el error dice exactamente qué privilegio falta, y ese privilegio es el que W0 le negó al editor a propósito — o sea que el siguiente paso es la decisión de gobernanza de §3, no un misterio.

⚠️ Regresión comprobada: w3-b-gate-puerta-escribe.ts sigue en verde tras el despliegue (el camino de escritura no se tocó).

4·ter · 🏁 W4 CERRADO CONTRA PRODUCCIÓN (2026-08-10)

① la puerta acepta el CREATE                       6.547 ms
② dataset en Index    esquema id:Integer, nombre:String · service=sql_editor.create_table
                      ns=main.default · project_id=NULL · file_id=NULL
③ tabla FÍSICA        main.default.ds_4b2c228c61c14f0181047e91b18846c9 · existe
④ el nombre LÓGICO resuelve   SELECT → 2 columnas
⑤ crear la MISMA otra vez → RECHAZADO  [TABLE_OR_VIEW_ALREADY_EXISTS]
⑥ CREATE OR REPLACE      → RECHAZADO  (verb-not-allowed)
🏁 salida 0

El ② es el que cierra el círculo de W4·1a: project_id=NULL. La tabla no vive en ningún árbol de Files — vive en su coordenada del Warehouse, que es lo que se decidió.

⚠️ Y el privilegio tuvo que entrar. lakekeeper-spark-editor no tenía TABLE_CREATE (W0/D2), así que el gate salió primero en rojo con falta TABLE_CREATE — el error nombrando exactamente lo que faltaba. Se concedió en la plantilla (tenant-template.ts) porque el argumento que lo negaba ya no aplica: decía que abriría «un segundo camino de creación que se saltaría el registro», y con la cara materializando no hay segundo camino, hay uno que empieza en Index.

⭐ Y de paso se tapó un hueco: lakekeeper-spark-editor no estaba en s5-0-principals-gate.ts, o sea que su forma no estaba fijada en ninguna parte — y es el principal que más ha cambiado (nació sin escritura en W0, ganó DML en W2, ahora crea). Ya está, con TABLE_DROP en su lista de negativos.

⚠️ Lo que el gate deja detrás, dicho: al limpiar borra el dataset, pero la tabla física se queda — soltarla exige TABLE_DROP, que el editor no tiene a propósito. Cada corrida deja una huérfana conocida y contable, que el censo W5·c verá. Es preferible a darle al editor el privilegio de soltar sólo para que un gate limpie.

El orden, y qué tapa a qué

W4·0 antes que todo →  hoy el `createTable` NO SE INTENTA: el cliente aborta en el
                       loadTable previo. Medir W4·1 sin esto mide el error de W4·0.
W4·3 antes de abrir →  sin compensación, cada CREATE fallido es una fila fantasma.

5 · Lo que W4 NO resuelve

Granularidad por usuario en el catálogola sigue sin haber: el principal es de servicio. W4 la recupera en la puerta, no en la cara. Un motor que entrara por otra vía no la tendría
DROP TABLEel editor no lo tiene (W0/D2) y W4 no se lo da. Borrar es otra decisión, con su propio documento (junction-replace-lineage.md)
El replace destructivoCREATE OR REPLACE sobre una tabla existente sigue fuera
Las 55 huérfanasW4 evita crear nuevas; adoptar las que hay es decisión del owner (W5·c)

6 · Documentos

DocPara qué
sustrato-03.mdla foto completa. Manda sobre esto
w-write-path-approach.mdW0-W5 · esta spec desarrolla y corrige su §D4
w3-ledger-idempotencia.mdW3 y W5 · el censo de la deriva que justifica D-W4·3