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 unCREATEautorizado en el
catálogo no puede distinguir usuarios.Regla, la de siempre: cada hecho lleva el comando que lo demuestra.
Manda
sustrato-03.mddonde 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 NoSuchTableException | loadTable de una tabla que no existe |
403 ForbiddenException | permisos insuficientes |
404 en createTable | el 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
suNoSuchTableException. 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 datasets ↔ project_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
real — datasets es una tabla central y «seguro que no pasa nada» no es una
medición:
| Qué se comprobó | Resultado | |
|---|---|---|
| RLS | la única política es datasets_workspace | filtra por workspace_id IN (user_workspace_ids()) — no menciona project_id |
datasets_derive_schema | qué hace sin proyecto | ya lo contemplaba: IF NEW.project_id IS NOT NULL THEN … y cae a main/default |
datasets_name_unique | por qué resuelve | (workspace_id, schema_id, lower(name)) — tampoco toca project_id |
| índices y constraints | los dos índices que lo citan | btree (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
| Fase | Qué entrega | Gate | |
|---|---|---|---|
| 🏁 W4·0 | «no está» deja de ser «no puedes» — HECHO Y DESPLEGADO | unknown-table → 404 NoSuchTableException | ✅ w4-0-gate-no-esta.ts salida 0 contra producción: 200 / 404 / 403, las tres distintas. Ver §4·bis |
| 🏁 W4·1a | ⭐ Un dataset puede nacer SIN PROYECTO — HECHO | migració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·1b | La puerta acepta el verbo — HECHO | camino propio: sin writeTarget (el destino no existe) y sin plan de tablas | ✅ gate ① · y CREATE OR REPLACE sigue prohibido (⑥) |
| 🏁 W4·2 | ⭐ La cara MATERIALIZA (D4 original, H3) — HECHO | al 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·3 | ⛔ La compensación (D-W4·3) — HECHA | si 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·4 | CTAS (D-W4·4) — HECHO | la 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álogo | la 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 TABLE | el 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 destructivo | CREATE OR REPLACE sobre una tabla existente sigue fuera |
| Las 55 huérfanas | W4 evita crear nuevas; adoptar las que hay es decisión del owner (W5·c) |
6 · Documentos
| Doc | Para qué |
|---|---|
⭐ sustrato-03.md | la foto completa. Manda sobre esto |
w-write-path-approach.md | W0-W5 · esta spec desarrolla y corrige su §D4 |
w3-ledger-idempotencia.md | W3 y W5 · el censo de la deriva que justifica D-W4·3 |