Published

🚪 PIEZA · JUNCTION — la puerta

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

🚪 PIEZA · JUNCTION — la puerta

Versiónv1.0las cinco capas trazadas
Estado🏁 Viva, y con dos entradas de alcance MUY desigual
Última medición2026-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

EntradaConsumidores de producción
loadItemForConsumption11app/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
runQuery1 — 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
runQuery es 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 SQLLa 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 vecesQue 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 DIALECTObuildDuckReadPlan(…, { 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

CerrojoEstado
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:

  1. ⭐ vale para cualquier motor —DuckDB, Karma, PyIceberg— sin tocarlos;
  2. ⭐ la cara es el único sitio que sabe quién pregunta (ya lo usa para access_events y para acuñar la credencial);
  3. 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 | enforcehoy 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.

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écnicoparser 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.1branchId (Lazy-COW)
  • v1.2 — la 2ª entrada runQuery + GovernanceContext + los ejes Substrate / Engine
  • v1.3 — ⭐ la paginación es del RESULTADO: offset/limit sobre un orden declarado y anclado a un snapshot, en vez del keyset sobre un __row_index estampado 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-credentialsque 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

DeudaPor qué duele
⛔⛔11 superficies entran por la facade y no pasan por la gobernanza de runQueryEl trabajo de verbos, plan, grants y auditor cubre una de doce entradas
ratify no lo corre nadieLa firma y la atribución están hechas y el lazo no se cierra
⚠️El auditor en shadow con 7 discrepancias medidasEl parser differential está identificado y no se impide
⚠️DML_VERBS_PERMITIDOS en 3, con su condición de retirada cumplidaUna 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 cabeceraNinguno muerde hoy; todos mienten al que viene a leer

Historial

VersiónFecha
v1.02026-08-12Capa 5: el contrato — sólo tipos, versionado por FORMA con ancla en runtime, y con SubstrateEngine, que es lo que permite el cómputo plural. El slot de credencial ya declara remote-signing, no vending
v0.42026-08-12Capa 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.32026-08-12Capas 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.12026-08-12Capa 1: compositor puro con dos entradas de alcance desigual (11 consumidores vs 1), y el brazo PG residual apuntando a una tabla borrada