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:
runQuerypasa de stub a entrada real,SqlEngineAdaptergana 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:
| # | Rama | Qué hace hoy | ¿Va bajo la puerta? |
|---|---|---|---|
| 1 | mode === 'external' (:893) | Proxy a la BD conectada DEL USUARIO | NO — no es el Warehouse. Fuera de Junction por definición. |
| 2 | ALTER TABLE … RENAME (:931) | Renombra el dataset + estampa tier | NO — es el CATÁLOGO, no un motor |
| 3 | CREATE/DROP VIEW (:958,:972) | Persiste/borra la fila-vista en datasets | NO — catálogo |
| 4 | DESCRIBE [EXTENDED|HISTORY] (:988) | Responde del plano de metadata | NO — catálogo |
| 5 | SHOW SCHEMAS/TABLES/VIEWS (:1004) | Ídem | NO — catálogo |
| 6 | information_schema.columns (:1038) | Ejecuta SQL en POSTGRES (executeReadOnlyQuery, :1052) | ⚠️ ver §1 |
| 7 | buildDuckReadPlan → duckQuery (:1092,:1109) | El path de MOTOR | ✅ ESTO 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étodo | startsWith('SELECT'/'WITH'/'EXPLAIN') + regex de verbos en `^ | ;` |
| Robustez | Ingenuo — no entiende comentarios anidados, ni EXPLAIN (ANALYZE) DELETE, ni CTE data-modifying | Red-team: 63 bypasses → 0, regresión permanente en tests/test_redteam.py |
| Default | Permite si empieza por SELECT | Deny-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 sí 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, totalRows | ✅ | la 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: boolean | ❌ | la 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: ambas — bindings 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)
runQuerydejó de lanzar: clasifica (hint) →CatalogBinding→ tenencia POR TABLA →GovernanceContext→getSqlEngine('duckdb')→adapter.runQuery.QueryResultganó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ó alib/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_vistaautorizaría tablas que nadie miró, incluidas las de otro workspace).buildDuckReadPlanexpone ahorabaseTables, recogidas dentro de la misma traversal que produce el SQL. - Verificación:
scripts/duckdb/f4a-door-parity.tscorre 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 … INSERTse 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; yunresolved-tablenunca disparaba porque el plan fallaba antes →missingTokense 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 MERGE — INSERT/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
withNativeControlPlaney cablear DuckDB como 4º data-plane (f4-governed-dml-approach.md §2.3), para que un DML actualicedataset_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)
- ✅
information_schemase QUEDA en el plano de metadata (§1) — es una tabla virtual del catálogo, no del Warehouse; coherente con las ramasDESCRIBE/SHOW. Acción derivada: corregir la afirmación "DuckDB = motor de lectura ÚNICO" enduckdbengine.mdysql-editor-duckdb-migration.md, que hoy es inexacta. - ✅
runQueryRECHAZA la query cross-workspace (§2.2) — si elCatalogBindingresuelve 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. - ✅ 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).