🚪 PIEZA · JUNCTION — la puerta
| Versión | v1.0 — las cinco capas trazadas |
| Estado | 🏁 Viva, y con dos entradas de alcance MUY desigual |
| Última medición | 2026-08-12 |
Trazada por capas: 1 qué es · 2 lectura · 3 escritura · 4 plano de control ·
5 el contrato.
Qué es
Un COMPOSITOR PURO. No tiene motor propio, no ve rutas ni llaves, no lleva
lógica de datos: orquesta piezas que ya existen y devuelve un objeto
auto-descrito con los métodos ya enlazados.
Lo dice su propia cabecera y es la clave para entenderla: «composer puro sobre las superficies que YA existen — cero motor nuevo».
resolveDatasetRowSource (read-router) → modo + frescura
resolveDatasetRowSink (write-router) → sink + clamp de capacidad + fail-loud
read-client → lectura Iceberg (page / stream / aggregate)
iceberg-native-write → escritura nativa + plano de control
freshness → veredicto de consistencia
datasets.schema + project_files.metadata → descriptor + gobernanza
Qué NO es
- No es el motor. Elige y despacha; ejecutar es de COMPUTE.
- No es la cara. La cara pregunta «¿puede este MOTOR?»; Junction pregunta «¿puede esta PERSONA?» y resuelve la coordenada.
- No es un gateway nuevo. Es la facade evolucionada — no envuelve a nadie.
- No serializa escrituras. El CAS es del catálogo, siempre.
Capa 1 · Las dos entradas, y su alcance real
① loadItemForConsumption(ref, intent, principal, opts) → DatasetHandle
la puerta ORIGINAL: leer / stream / agregar / escribir un dataset por referencia
② runQuery(sql, principal, opts) → QueryResult
la 2ª entrada: SQL de usuario en vivo
2.538 líneas · 14 exports · 510 líneas más de contrato (contract.ts, donde vive
la firma y los tipos de rechazo).
⚠️ Y el alcance es MUY desigual — medido
| Entrada | Consumidores de producción |
|---|---|
⭐ loadItemForConsumption | 11 — app/api/datasets · model/objects (×2) · graphs/explorer (×2) · dataspace/item/sample · pipelines/[id]/manual-table · table-export · pipelines/source-resolver · sql-source-hydration · kuzu-engine |
runQuery | 1 — sólo app/api/sql-editor/execute |
(+5 scripts de gate: facade-smoke, f2-canary, pipeline-output-canary,
door-write-parity, n1-opaque-namespace-gate.)
⭐⭐ La facade es la puerta ANCHA —11 superficies del producto entran por ella— y
runQueryes la ESTRECHA, con un solo consumidor.Y eso reordena una intuición: todo el trabajo reciente de gobernanza —los verbos,
el plan que manda, los grants de usuario, el auditor— vive en el camino de
runQuery, es decir, en la entrada que usa una sola superficie. Las once
que entran por la facade no pasan por nada de eso.
⛔ Lo que esta capa ya destapa
El brazo PG de LECTURA sigue cableado contra una tabla que no existe
interface PgSource { table: 'dataset_rows'; branchId?: string }
const BASE_PG: PgSource = { table: 'dataset_rows' };
…
postgres: () => pgReadPage(datasetId, eff), // ← rama viva en el dispatch de modo
dataset_rows se borró el 2026-07-31 (migración 20261262). El despacho por modo
conserva su rama postgres, y pgReadPage / pgReadStream / pgAggregate siguen
compilando y consultando esa tabla.
⭐ La mitad de branches SÍ está bien retirada, y se nota la diferencia:
// D·3 · El overlay de filas por branch se retiró con el plano: `overlay.active` es
// siempre false … la herencia deja de ser «transparente cuando no hay override»
// para ser la única forma.
const branchSrc: PgSource | null = null;
Ahí la retirada es estructural —el valor es null por construcción, y el
compilador lo sabe—. En el dispatch de modo, en cambio, la rama depende de lo que
devuelva el router en tiempo de ejecución.
⇒ Es el mismo patrón que en INGESTION con el sink pg: el plano
legacy se demolió, y quedaron los enchufes. Hoy no muerden porque nadie los
resuelve; el día que alguien lo haga, el error dirá «relation does not exist», que
no se parece a la causa.
Y la cabecera describe un mundo que ya no existe
Sigue anunciando «lectura completa … con fallback PG genérico (dataset_rows)» y
«los substrings por columna se empujan a PG como data->>col ILIKE». Eso ya no
puede pasar. Un comentario que describe una capacidad retirada no es ruido: es la
primera cosa que lee quien viene a entender la pieza.
Capa 2 · El camino de LECTURA
① clasificar classifyForRouting(sql) → read | write
deny-by-default: lo que no abre con verbo de lectura es escritura
② exigir sujeto principal.userId + principal.workspaceId, o se rechaza
③ namespace namespaceDeLectura(workspaceId) ← el opaco `{env}.w_{ws}`
④ CATÁLOGO buildWarehouseCatalog({userId, workspaceId})
└─ leerWarehouse → filtroDeTenencia() → workspace_id = el tuyo
⇒ sólo entran objetos de TU inquilino, antes de mirar el SQL
⑤ relaciones inline información de catálogo (`information_schema`) como CTE literal
⑥ PLAN buildDuckReadPlan(sql, catalog, { dialect })
pre-cualifica FQN · expande vistas a CTEs · sensible al DIALECTO
⑦ TENENCIA por tabla resolveCatalogRef(catalog, token) + régimen de coordenada (ESTRICTA)
⑧ coherencia una query = un workspace · y ese workspace = el del principal
⑨ PRIVILEGIO user-authz → TABLE_READ_DATA por dataset ⭐ `enforce` desde hoy
⑩ contexto GovernanceContext
⑪ motor elegirMotorDeLectura(false) → spark
⑫ auditor en `shadow`: mide el plan y no impide
Las tres propiedades que lo sostienen
| ⭐ El catálogo se filtra ANTES de mirar el SQL | La tenencia no es un WHERE añadido al final: es el universo con el que se resuelven los nombres. Un nombre de otro inquilino no «se deniega»: no existe |
| ⭐ La coherencia se comprueba DOS veces | Que todas las tablas sean de un workspace, y que ese workspace sea el del principal. La segunda es redundante por construcción — y está ahí porque «"por construcción" es lo que este pilar ha aprendido a no dar por bueno» |
| ⭐ El plan es sensible al DIALECTO | buildDuckReadPlan(…, { dialect }) — el mismo planificador emite para DuckDB o para Spark |
⚠️ Y una deuda de nombre que despista: la función se sigue llamando
buildDuckReadPlan y vive en duck-plan.ts, pero planifica para Spark, que
es el único motor elegible. El dialect por defecto sigue siendo 'duckdb'. Es
inofensivo hoy y es exactamente la clase de resto que hizo falso el engine del
ledger (§capa 3).
Capa 3 · El camino de ESCRITURA
El CREATE sale por su propia puerta, y antes que todo
Un CREATE TABLE no declara destino —no existe todavía— y no tiene FROM, así
que ni podría declararlo ni saldría del plan de lectura. Camino corto: verificar
tenencia → privilegio TABLE_CREATE → despachar. Quien lo materializa es la CARA,
que es la única que tiene el esquema sin parsear el DDL.
⛔ CREATE OR REPLACE se ataja ahí mismo: no crea, REEMPLAZA — y sobre una tabla
con datos eso es destructivo y sin vuelta atrás. Se comprueba arriba del todo
porque este camino sale antes que los demás: «sin esta línea, abrir el CREATE
habría abierto también el replace, en silencio».
Los tres cerrojos del DML
| Cerrojo | Estado | |
|---|---|---|
| ① | El destino se DECLARA, no se parsea (opts.writeTarget) — «la diferencia entre autorizar lo que el usuario dijo que escribe y autorizar lo que su texto resulte que escriba» | ⚠️ su mitad de refuerzo «duck-server lo impone server-side» quedó atrás: desde W2 escribe Spark, no duck-server |
| ② | El destino se resuelve por el MISMO catálogo del principal y debe vivir en el mismo workspace que las tablas leídas | ✅ |
| ③ | El interruptor es el sink del puerto sql-editor.dml — mientras resuelva a pg, la escritura de motor está apagada. Es el mismo canary por dataset que gobierna al resto de escritores, no un flag nuevo | ✅ |
⭐ Y una tercera vía que no es «parsear el texto»: preguntárselo al MOTOR. Con
PLAN_MANDA encendido —y lo está en producción— el destino sale de
destinoDelPlan(): el plan nombra el destino y además dice qué privilegio
exige. Un writeTarget declarado sigue ganando; el plan lo hace opcional, que es
lo que permitió entrar a los verbos que ninguna regex supo leer (ALTER TABLE … ADD COLUMN, INSERT OVERWRITE).
La lista de verbos
const DML_VERBS_PERMITIDOS = new Set(['DELETE', 'MERGE', 'UPDATE']);
Tres. Y es una prohibición global que existe porque no había a quién conceder por objeto — es el «sucedáneo» que GRANTS nombra. Con los grants ya aplicando, su condición de retirada está cumplida y la lista sigue ahí.
El plano de control del DML
withDmlControlPlane abre una dataset_transactions antes de ejecutar, con su
metadata, y la cierra después:
{ sink: 'iceberg_native',
engine: target.engine, // ⭐ el motor que escribió
writer: 'sql-editor.dml',
write_target: target.fqn,
attributable: esVerificable(target.engine) }
⭐⭐ Y el comentario de engine es la mejor lección de la pieza:
Era el literal
'duckdb'— cierto hasta W2 y falso desde entonces: la puerta elegía
Spark y el ledger seguía apuntando DuckDB. No es un adorno: es la columna con
la que se responde «¿qué motor produjo este dato?» en una auditoría, y un censo
del ledger la habría leído como verdad.
⚠️ Un dato falso en el ledger no falla: MIENTE. Y miente exactamente a quien va a auditar.
Lo que estas dos capas dejan ver
| ✅ | La tenencia se aplica por CONSTRUCCIÓN del universo, no por filtro posterior. Es la propiedad más fuerte del camino de lectura |
| ✅ | El destino de escritura nunca sale del texto — se declara o se le pregunta al plan |
| ⚠️ | Restos de la era DuckDB en tres sitios: el nombre buildDuckReadPlan, el dialecto por defecto 'duckdb', y la mitad del cerrojo ① que apela a duck-server |
| ⚠️ | DML_VERBS_PERMITIDOS sigue en 3 con su condición de retirada ya cumplida |
Capa 4 · El plano de control — firma, auditor y atribución
Tres mecanismos independientes que contestan tres preguntas distintas:
LA FIRMA «¿esta escritura es MÍA?» → dentro del propio commit Iceberg
EL AUDITOR «¿va el motor a leer algo que la puerta NO vio?»
LA ATRIBUCIÓN «¿aterrizó la operación X?» → se lo pregunta al CATÁLOGO
⭐⭐ La firma — y por qué la pone la CARA, no el motor
lib/governance/commit-signature.ts · clave carbon.operation-id
S5·1 demostró que un commit puede llevar su procedencia dentro: las opciones
snapshot-property.* viajan en el mismo commit que los datos, así que el dato y
su marca no se pueden desincronizar. Pero eso fue con DataFrameWriterV2, y el SQL
Editor manda SQL: el puente hace spark.sql(...), donde no hay .option().
⛔ Se midieron tres vías el 2026-08-10 y ninguna firma:
SET spark.sql.catalog.<cat>.snapshot-property.… → SUCCEEDED, summary LIMPIO
…su variante con .write. → SUCCEEDED, summary LIMPIO
SET spark.wap.id → SUCCEEDED, summary LIMPIO
(La API que sí serviría, CommitMetadata.withCommitProperties, es Java/Scala — y el
puente es Python sobre Spark Connect, que no expone la JVM.)
⇒ Pero el commit PASA POR LA CARA (updateTable es operación gobernada), así que
la firma se pone ahí. Y sale mejor que en el motor:
- ⭐ vale para cualquier motor —DuckDB, Karma, PyIceberg— sin tocarlos;
- ⭐ la cara es el único sitio que sabe quién pregunta (ya lo usa para
access_eventsy para acuñar la credencial); - sigue siendo el MISMO commit: no se añade uno segundo, se modifica el que ya viaja — la propiedad de S5·1 se conserva entera.
⚠️ Y el módulo no parsea ni serializa, a propósito. Recibe el objeto ya parseado y
lo devuelve mutado, para que el llamante use parseLossless — «la única combinación
que conserva los snapshot-id de 64 bits». Si hiciera el round-trip con JSON.parse
reintroduciría el bug que costó un bloqueante de escritura entero: …768 → …700
en silencio, y CatalogCommitConflicts sin que exista concurrencia alguna.
⭐⭐⭐ El auditor — un parser differential, con su nombre de la industria
lib/compute/plan-auditor.ts · off | shadow | enforce — hoy en shadow
La puerta decide sobre la lista de tablas que produce un escáner de texto
(scanTableRefs); el motor ejecuta lo que produce su propio parser.
Dos parsers distintos decidiendo sobre la misma entrada es una vulnerabilidad catalogada: parser differential. Y no es una analogía nuestra — es el mecanismo de los 1.207 bypasses de WAF de WAFFLED (2026) y de CVE-2026-52747 en ModSecurity. P·3 midió que en nuestro caso los dos parsers discrepan en 7 sitios.
Y el encuadre lo da la industria: se autoriza en el analizador, sobre nombres resueltos y después de expandir las vistas — PostgreSQL comprueba privilegios tras la reescritura («debido a la reescritura se acceden otras tablas que las de la consulta original»), y Trino igual.
⇒ Por eso el auditor pregunta por el SQL EJECUTADO, no por el que escribió el
usuario, y compara contra vioLaPuerta (las tablas base + el destino). En enforce,
un objeto no-vista se deniega; en shadow, se anota.
La atribución — la respuesta la da el CATÁLOGO
lib/lakehouse/attribution-client.ts — la primitiva de ratify.
«El camino feliz nunca fue el problema: el problema es cuando el proceso muere
ENTRE el commit y su registro.»
Antes, el snapshot_id se leía con table.current_snapshot() justo después de
commitear ⇒ sólo existía en la memoria de un proceso que ya no está. Y «el último
snapshot de la tabla» no es la respuesta: bajo concurrencia puede ser el de otro
escritor.
⇒ Se le pregunta al catálogo, buscando carbon.operation-id en el summary. Es la
única autoridad sobre el hecho — y es lo que cierra el lazo con la firma: firmar y
atribuir son la misma decisión vista desde los dos extremos.
Lo que esta capa deja ver
| ⭐⭐ | Firmar en la cara fue mejor que firmar en el motor, y salió de un callejón: las tres vías del motor no firmaban. La restricción produjo el mejor diseño, no un apaño |
| ⭐ | El auditor sabe su propio nombre técnico —parser differential— y sus CVE. Un riesgo con nombre se puede buscar; uno sin nombre se discute |
| ⚠️ | 7 discrepancias medidas entre los dos parsers, y el auditor en shadow ⇒ hoy se anotan, no se impiden |
| ⚠️ | La firma depende de una clave literal (carbon.operation-id) compartida entre quien firma y ratify: «cambiarla aquí rompería la verificación sin que nada falle» |
| ⛔ | Y el lazo se cierra sólo si alguien corre ratify — que INGESTION midió que no lo llama nadie: 5 txn open |
Capa 5 · El contrato — la piedra
lib/compute/contract.ts · 510 líneas · CERO runtime: sólo tipos.
El lenguaje COMÚN con el que cada building block de Carbon —pipelines,
notebooks, ontología, dashboards, grafo, exports, SDK, agentes— habla con el
tridente:R2 (bytes Parquet) + Lakekeeper (punteros Iceberg) + Postgres (metadata y gobernanza)
⭐ Y la promesa, que es lo que lo hace un contrato y no una interfaz:
Un puerto pide un item por su identidad LÓGICA y recibe un
DatasetHandle
auto-descrito con los métodos ya enlazados. Nunca vuelve a orquestar a mano
schema-fetch + puntero + frescura + seam + decode; nunca ve un path de R2 ni una
llave; nunca lleva lógica de datos propia.
⭐ «Sólo tipos» es la decisión estructural: todo lo demás —la facade, el veneer de servicio, el vending— deriva de este fichero. La forma no puede divergir de la implementación porque la implementación se compila contra la forma.
La versión es de la FORMA, no del código
export type ContractVersion = 'v1.3'
- v1.1 —
branchId(Lazy-COW) - v1.2 — la 2ª entrada
runQuery+GovernanceContext+ los ejesSubstrate/Engine - v1.3 — ⭐ la paginación es del RESULTADO:
offset/limitsobre un orden declarado y anclado a un snapshot, en vez del keyset sobre un__row_indexestampado en la fila
«Todo cambio de FORMA de la puerta sube esta versión.» Y tiene ancla en
runtime (DOOR_CONTRACT_VERSION), para que la versión declarada y la desplegada
no puedan discrepar en silencio.
Los cuatro ejes que el contrato separa
Intent read | write | stream ← qué quiero hacer
Substrate pg | iceberg ← dónde vive el dato
Engine duckdb|karma|mlrunner|pg|spark ← quién lo ejecuta
WriteMode replace | upsert | append ← cómo se escribe
⭐⭐ Que Substrate y Engine sean ejes SEPARADOS es la pieza que permite el
cómputo plural: el sustrato es la cintura estrecha (Iceberg), el motor es
intercambiable detrás. Confundirlos —«DuckDB» como si fuera un formato— es lo que
ataría el dato a quien lo lee.
Lo que el handle lleva encima
DatasetDescriptor schema · PK · identidad · FreshnessVerdict
GovernanceBlock service · tags · classification · schemaVersionId
└─ ProvenanceBlock origin · sourceDatasetIds
counterparty · edcTransferId · agreementId
CredentialSlot hoy 'proxied' — ninguna llave llega al cómputo
⭐ La procedencia lleva la frontera del dataspace incorporada (counterparty,
edcTransferId, agreementId): cuando un item entra por un conector EDC, quién lo
cedió y bajo qué acuerdo viaja con el item, no en una tabla aparte.
⚠️ Y tags / classification existen en el contrato desde el día 1 — y no hay
quien los produzca. Es el hueco de TAGS visto desde el otro lado: el
sitio donde ponerlos ya está reservado.
Los rechazos son un tipo cerrado
export type QueryRejection =
| 'unresolved-table' | 'not-readable' | 'no-tables'
| 'cross-workspace' | 'no-workspace' | 'write-not-enabled'
| 'verb-not-allowed' | 'not-authorized' | 'plan-failed'
Nueve motivos, todos de GOBERNANZA o RESOLUCIÓN — el motor nunca los ve. Y la distinción que los ordena: la acción del usuario es distinta (corregir el nombre · no cruzar inquilinos · pedir un rol · corregir el SQL), y por eso son códigos y no mensajes.
⭐ El par que mejor lo ilustra: not-readable («lo ves y no puedes leerlo») vs
not-authorized («falta un privilegio»). Son dos respuestas distintas — pedir
acceso vs pedir un rol.
El slot de credencial, reservado desde el día 1
Hoy 'proxied': ninguna llave se filtra al cómputo. Y el destino declarado es
remote-signing, no vended-credentials — que se probó ROTO sobre R2.
⇒ Es la misma conclusión a la que llegó el mapa de gobernanza por otro camino (§8·7): la palanca del vending ya está prevista en el contrato.
🏁 La pieza, cerrada
un COMPOSITOR PURO · 2 entradas de alcance 11 vs 1 · 2.538 líneas + 510 de contrato
lectura: el catálogo se filtra ANTES del SQL — la tenencia construye el universo
escritura: el destino se declara o lo dice el PLAN · 3 verbos · 3 cerrojos
control: la firma la pone la CARA · el auditor es un parser differential · ratify no corre
contrato: sólo tipos, versionado por FORMA, con Substrate ⊥ Engine
Las cinco deudas, por daño
| Deuda | Por qué duele | |
|---|---|---|
| ⛔⛔ | 11 superficies entran por la facade y no pasan por la gobernanza de runQuery | El trabajo de verbos, plan, grants y auditor cubre una de doce entradas |
| ⛔ | ratify no lo corre nadie | La firma y la atribución están hechas y el lazo no se cierra |
| ⚠️ | El auditor en shadow con 7 discrepancias medidas | El parser differential está identificado y no se impide |
| ⚠️ | DML_VERBS_PERMITIDOS en 3, con su condición de retirada cumplida | Una prohibición global que ya podría ser un permiso por objeto |
| ⚠️ | Restos de la era DuckDB: buildDuckReadPlan, dialecto por defecto, el brazo PG, la cabecera | Ninguno muerde hoy; todos mienten al que viene a leer |
Historial
| Versión | Fecha | |
|---|---|---|
| v1.0 | 2026-08-12 | Capa 5: el contrato — sólo tipos, versionado por FORMA con ancla en runtime, y con Substrate ⊥ Engine, que es lo que permite el cómputo plural. El slot de credencial ya declara remote-signing, no vending |
| v0.4 | 2026-08-12 | Capa 4: firmar en la cara salió mejor que en el motor (las tres vías del motor no firman), el auditor tiene nombre de la industria —parser differential— con 7 discrepancias medidas, y la atribución se la pregunta al catálogo |
| v0.3 | 2026-08-12 | Capas 2-3: la tenencia se aplica construyendo el universo, el destino nunca sale del texto, y restos de la era DuckDB en tres sitios |
| v0.1 | 2026-08-12 | Capa 1: compositor puro con dos entradas de alcance desigual (11 consumidores vs 1), y el brazo PG residual apuntando a una tabla borrada |