Published

Junction F4 — absorber el SQL Editor tras la puerta (runQuery)

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

Junction F4 — absorber el SQL Editor tras la puerta (runQuery)

Entregable de F4 (2026-07-28). Companion de junction.md · junction-f2-engine-adapter.md · junction-f3-equivalence.md · f4-governed-dml-approach.md.

F4 es el momento en que Junction deja de ser inerte: runQuery pasa de stub a entrada real, SqlEngineAdapter gana su primer consumidor, y el mayor bypass de la puerta se cierra. Es también la fase con más superficie de riesgo de todo el arco — por eso este doc empieza corrigiendo el encuadre.


0 · ⚠️ El encuadre heredado está MAL DIMENSIONADO

Los docs previos describen F4 así: "su duckQuery a pelo pasa por runQuery" (junction.md §8, handoff §7). Al leer app/api/sql-editor/execute/route.ts entero (1159 líneas), eso resulta ser una de siete ramas, y no la mayor.

La route no es un envoltorio de duckQuery: es un router de sentencias con su propia taxonomía, y decide el destino ANTES de que exista un motor:

#RamaQué hace hoy¿Va bajo la puerta?
1mode === 'external' (:893)Proxy a la BD conectada DEL USUARIONO — no es el Warehouse. Fuera de Junction por definición.
2ALTER TABLE … RENAME (:931)Renombra el dataset + estampa tierNO — es el CATÁLOGO, no un motor
3CREATE/DROP VIEW (:958,:972)Persiste/borra la fila-vista en datasetsNO — catálogo
4DESCRIBE [EXTENDED|HISTORY] (:988)Responde del plano de metadataNO — catálogo
5SHOW SCHEMAS/TABLES/VIEWS (:1004)ÍdemNO — catálogo
6information_schema.columns (:1038)Ejecuta SQL en POSTGRES (executeReadOnlyQuery, :1052)⚠️ ver §1
7buildDuckReadPlanduckQuery (:1092,:1109)El path de MOTORESTO es lo que absorbe runQuery

La consecuencia de diseño es importante y a favor: las ramas 2–5 son operaciones de catálogo, y la regla de frontera de junction.md §0 dice literalmente que el catálogo NO es la puerta. Intentar meterlas por runQuery sería violar la propia arquitectura. F4 no debe absorberlas.

Reencuadre: F4 no es "mover la route detrás de la puerta". Es extraer su rama de motor y dejar el resto donde está — reconociendo que la route seguirá siendo un router de catálogo. Eso es más pequeño de lo que sugería el plan en superficie, pero con más aristas de las que enumeraba.


1 · Hallazgo: queda un path SQL sobre POSTGRES en el SQL Editor

duckdbengine.md y sql-editor-duckdb-migration.md afirman que tras F3-RETIRO «DuckDB = motor de lectura ÚNICO». No es exacto: la rama information_schema.columns (route.ts:1038-1059) sigue ejecutando SQL contra Postgres vía executeReadOnlyQuery, sirviendo las filas como CTE desde jsonb_to_recordset.

No es un descuido: information_schema es una tabla virtual del plano de metadata, no una tabla del Warehouse — la propia rama rechaza explícitamente unirla con tablas reales (:1046). Pero desmiente la afirmación "motor único" tal y como está escrita, y F4 tiene que decidir si esa virtual-table se sirve por el catálogo (coherente con las ramas 2–5) o se materializa como CTE en DuckDB (coherente con "un solo motor"). Sin decidirlo, la ambigüedad se hereda.


2 · Los 4 huecos REALES entre la route y el contrato (lo que hay que resolver)

Ninguno es teórico: los cuatro rompen algo concreto si se ignoran.

2.1 · Hay DOS clasificadores read/write, y el de Node es el débil

isReadOnly (Node, route.ts:104)classify_statement (duck-server, duck.py:187)
MétodostartsWith('SELECT'/'WITH'/'EXPLAIN') + regex de verbos en `^;`
RobustezIngenuo — no entiende comentarios anidados, ni EXPLAIN (ANALYZE) DELETE, ni CTE data-modifyingRed-team: 63 bypasses → 0, regresión permanente en tests/test_redteam.py
DefaultPermite si empieza por SELECTDeny-by-default: verbo desconocido ⇒ write

runQuery debe derivar op de un clasificador. El del servidor es estrictamente superior, pero vive en Python detrás de la red: si Node necesita clasificar antes de elegir motor (y lo necesita: op es un campo de QueryRequest), hace falta o bien un port TS del clasificador con el mismo corpus de red-team, o bien aceptar que Node pre-clasifica de forma conservadora y el servidor sigue siendo el enforcement point (defensa en profundidad). Recomendado: lo segundo — Node clasifica para enrutar, duck-server para autorizar; nunca se retira el guard del servidor. Lo que hay que retirar es isReadOnly como gate de seguridad: degradarlo a hint de enrutado y que el rechazo lo dé el servidor.

2.2 · La tenencia se resuelve por USUARIO, pero se firma por WORKSPACE

buildCatalog(userId) filtra created_by = userId (:245) — el catálogo es per-usuario. Pero duckQuery firma un JWT de workspace, y ese workspace sale de plan.workspaceId, que buildDuckReadPlan fija con la PRIMERA tabla base que resuelve (duck-plan.ts:115, workspaceId ?? entry.workspace_id).

⇒ Una query que una tablas de dos workspaces del mismo usuario se firma con el token de uno y lee ambos. El deny-by-default de F4.2 cubre la escritura, no esto.

Esto es exactamente lo que CatalogBinding fue diseñado para arreglar: CatalogBindingEntry lleva datasetId + fqn + gobernanza POR TABLA, y la query se autoriza sii todas lo están (contract.ts §CatalogBinding). F4 debe construir el binding con verificación por tabla y rechazar el cruce de workspaces (o firmar por tabla, cuando remote-signing aterrice).

2.3 · QueryResult no puede transportar lo que el editor necesita

La route devuelve hoy un envelope más rico que QueryResult:

Campo de la respuesta¿En QueryResult?Quién lo consume
columns tipadas, data, totalRowsla grilla
engine✅ (F2 lo añadió)observabilidad
tablesResolved[] (:1134)la UI (qué dataset resolvió cada nombre)
errorType + line (:1146-1147)los markers de Monaco — sin esto el editor pierde el subrayado del error
explain: booleanla UI

runQuery no puede devolver menos sin regresión de producto. Opciones: (a) extender QueryResult con bindings? y diagnostics?; (b) que runQuery lance un error tipado que conserve DuckQueryError.info. Recomendado: ambasbindings deriva naturalmente del CatalogBinding que ya se construye (coste cero) y el error tipado es propagación, no invención.

2.4 · El write arm está BLOQUEADO por el hueco de control-plane

Hoy la route rechaza toda escritura en modo interno (:1018). Habilitar DML por runQuery no es un refactor: es un cambio de producto. Y está bloqueado por lo documentado en junction-f2-engine-adapter.md §2·ter — el adapter duckdb commitea en el catálogo pero no cierra el control-plane (dataset_transactions / row_count / sync-log), así que iceberg_freshness seguiría diciendo "no caught up" tras una escritura correcta → lectores STRICT a PG indefinidamente.

F4 se parte en dos, y solo la primera mitad es desbloqueable hoy.


3 · Plan de F4

F4a · el READ path bajo la puerta — ✅ HECHO (2026-07-28)

  • runQuery dejó de lanzar: clasifica (hint) → CatalogBindingtenencia POR TABLAGovernanceContextgetSqlEngine('duckdb')adapter.runQuery. QueryResult ganó bindings/explain; el stripping de columnas __ vive ya detrás de la puerta. La route sustituyó SOLO su rama 7; las de catálogo se quedaron. El resolutor de catálogo salió a lib/warehouse/query/catalog.ts (una definición, compartida).
  • Corrección de diseño durante la implementación: el binding NO puede derivarse de un escaneo de tokens propio — no ve las bases dentro del cuerpo de una VISTA, así que la gobernanza validaría un conjunto de tablas distinto del que el motor lee (y un SELECT * FROM mi_vista autorizaría tablas que nadie miró, incluidas las de otro workspace). buildDuckReadPlan expone ahora baseTables, recogidas dentro de la misma traversal que produce el SQL.
  • Verificación: scripts/duckdb/f4a-door-parity.ts corre el MISMO SQL por las dos rutas y las compara con el comparador de F3 → 11/11 idénticas + 5/5 rechazos correctos, en vivo. tsc 0 · 16 tests de gobernanza + 144 de compute/warehouse/lakehouse.
  • Dos fallos que cazó ese harness (y por eso existe): WITH … INSERT se clasificaba como lectura (el verbo no está ni al inicio ni tras ;) → el clasificador busca ahora a profundidad de paréntesis 0 y retira literales; y unresolved-table nunca disparaba porque el plan fallaba antes → missingToken se propaga, también desde dentro de una vista.

F4b · el WRITE path — ✅ HECHO (2026-07-29), ya no bloqueado. Se cableó DuckDB como 4º data-plane (532bb52, 0a40cdb) con canary por dataset. La matriz de verbos se midió después (D·0, duckdb-conformance.md §2.bis): sobre una tabla nativa DuckDB sólo puede DELETE y MERGEINSERT/UPDATE los rehúsa la extensión por el SortOrder, y esa protección es accidental, no nuestra. El texto original de abajo queda como registro del prerequisito tal como se planteó:

  • Prerequisito duro: exportar/adelgazar withNativeControlPlane y cablear DuckDB como 4º data-plane (f4-governed-dml-approach.md §2.3), para que un DML actualice dataset_transactions/row_count. Sin esto, cualquier escritura por la puerta produce deriva de frescura.
  • Empezar por CTAS/replace (idempotente); DML mutante con commit-uuid + read-back después.
  • Detrás de flag, canary por dataset.

Fuera de alcance de F4 (explícito): las ramas de catálogo 2–5, el modo external, y la migración de dashboards (eso es F6).


4 · Las 3 decisiones — ✅ CERRADAS POR EL OWNER (2026-07-28)

  1. information_schema se QUEDA en el plano de metadata (§1) — es una tabla virtual del catálogo, no del Warehouse; coherente con las ramas DESCRIBE/SHOW. Acción derivada: corregir la afirmación "DuckDB = motor de lectura ÚNICO" en duckdbengine.md y sql-editor-duckdb-migration.md, que hoy es inexacta.
  2. runQuery RECHAZA la query cross-workspace (§2.2) — si el CatalogBinding resuelve tablas de más de un workspace, error explícito. Es para lo que se diseñó la gobernanza POR TABLA. Riesgo asumido: puede romper alguna query que hoy funciona por accidente; el error debe nombrar los workspaces implicados para que sea diagnosticable.
  3. Se entrega F4a sola (read). (Superado: F4b se cableó el 2026-07-29 — ver arriba.)

⚠️ Coordinación: app/api/sql-editor/execute/route.ts tiene WIP tuyo sin commitear (derivación de tier en CREATE VIEW, ramas 3). F4a toca la rama 7 y el gate de :1018 — no colisiona, pero hay que commitear o rebasar antes de editar el fichero.


Doc vivo. F4 = el fin de la inercia de Junction. Después: F5 (write bajo la puerta + retirar el throw PG) → F6 (dashboards y pipeline-preview al mismo gateway).