Published

Junction — el Furnace de Carbon

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 — el Furnace de Carbon

Entregable de arquitectura (2026-07-10). Qué ES Junction, con exactitud, y la arquitectura jerárquica, limpia y reutilizable que crece de la semilla que ya existe (~75% construido) para que toda superficie de la plataforma —SQL Editor, pipelines, jupyter, dashboards, sync, SDK— lea y escriba por la MISMA pieza. Síntesis de un workflow (8 mapeadores del código real + 2 de referencia SOTA + síntesis + crítica adversarial de elegancia), con grounding verificado sobre el contrato real.

Cruza con: compute-integration.md (spec previa del Compute Gateway, eje READ) · f4-governed-dml-approach.md (eje WRITE/DML) · warehouse-narrow-waist.md (la cintura) · INFRA.md (estado real). Memoria: router-junction, warehouse-narrow-waist, facade-contract-review.


0 · Qué ES Junction (la definición exacta)

Junction es la puerta única y agnóstica de motor de Carbon: el punto donde cualquier superficie declara QUÉ quiere —un item que consumir, o una query que ejecutar— con un contexto de gobernanza, y recibe datos, sin conocer NUNCA qué motor ejecutó ni dónde viven los bytes. Su responsabilidad única es desacoplar plan ↔ ejecución: resolver identidad → puntero → modo → frescura → gobernanza contra el catálogo, seleccionar qué motor plural ejecuta (DuckDB / Karma / pyiceberg / PG), despachar, y unificar el resultado en una frontera de salida estable. Es el equivalente-Furnace de Foundry.

Junction NO es un fichero nuevo que envuelve lo que hay. Junction ES la semilla que ya existe, evolucionada. Vive (F1, 2026-07-10 — reubicado a su hogar canónico lib/compute/) en dos ficheros complementarios:

  • lib/compute/contract.ts — LA PIEDRA: solo tipos, cero runtime; "todo lo demás DERIVA de este contrato". Fija ItemRef, Intent, DatasetHandle, WriteRequest, CommitResult, FreshnessVerdict, y (v1.2) GovernanceContext/CatalogBinding/QueryRequest/RunQuery + los ejes Substrate/Engine.
  • lib/compute/junction.ts — el COMPOSER PURO: "cero motor nuevo", compone read-router + write-router + read-client + iceberg-native-write + freshness. Dos entradas peer: loadItemForConsumption(ref, intent, principal, opts) (items) y runQuery(sql, principal, opts) (live-SQL — F1 fija la firma, inerte hasta F2).

Formalizar Junction fue hacer crecer esta puerta (§8, F1 hecho: reubicación + contrato v1.2), no crear un lib/compute/gateway.ts que la envuelva. Hay exactamente una puerta; nunca un router delante de otro router.

Qué NO es (la regla de frontera: CATÁLOGO ≠ GATEWAY ≠ MOTOR)

  1. NO es el CATÁLOGO. La cintura estrecha real es el catálogo/formato: Lakekeeper (Iceberg REST) + la coordenada lógica → FQN física. El catálogo responde qué tablas existen, qué snapshot, qué esquema. Junction lo consulta (resolveIdentity, item-consumption.ts:109-166, resuelve identidad LÓGICA→descriptor, nunca un path). El único commit atómico —el CAS de Lakekeeper sobre metadata.json— es del catálogo, no de Junction.
  2. NO es un MOTOR. DuckDB, Karma, pyiceberg y PG son motores plurales detrás de la cintura. Junction nunca ejecuta un plan físico ni lleva lógica de datos propia. ("DuckDB no es un 3er sink: es el motor detrás del sink iceberg", port-registry.ts:238.) Trampa capital: meter ejecución dentro del router. Furnace/Trino mantienen el gateway vacío de ejecución.
  3. NO es STORAGE. R2 (bytes Parquet) queda detrás; Junction no ve paths ni llaves (contract :16).
  4. NO es el DatasetWriter legacy. El brazo PG de write() lanza a propósito (item-consumption.ts:605-611, "Frontera del Paso 6 — no reimplementamos DatasetWriter aquí").
  5. La selección de motor es POLÍTICA del gateway (capacidad/coste/latencia), no propiedad del catálogo. Y el WRITE no es un sistema aparte: es la cara sink del MISMO contrato (handle.read() y handle.write() son métodos del mismo DatasetHandle, contract :276-288).

1 · El modelo elegante — colapsado a lo REAL

Corrección clave de la crítica de elegancia: no presentamos "3 capas + 2 ejes" como si ya las tuviéramos. Hoy el artefacto real es ~1.5 elementos (un contrato + un composer + un flag de substrato). Las 3 capas de Furnace son el NORTE, no un andamiaje a construir por fe. El modelo honesto es:

UNA frontera · DOS caras · UNA cintura · gobernanza como contexto · motores plurales detrás.

   Superficies (sql-editor · pipelines · jupyter · dashboards · sync · sdk)
                              │  declaran: (item | query) + Principal
   ┌──────────────────────────────────────────────────────────────────┐
   │  LA PUERTA  =  contract (tipos) + composer (loadItemForConsumption) │  ← Junction
   │  · DOS caras: read/stream/aggregate  ·  write   (mismo DatasetHandle)│
   │  · UN GovernanceContext cruza la puerta (hoy disperso → unificar)    │
   └───────────────┬───────────────────────────────────┬────────────────┘
        resuelve por│CATÁLOGO (cintura)     selecciona   │MOTOR (política)
                    ▼                                     ▼
   ┌────────────────────────────┐        ┌───────────────────────────────┐
   │  Lakekeeper (Iceberg REST) │        │  EngineAdapter (plural):        │
   │  nombre lógico → FQN física│◀───────│  DuckDB · Karma · pyiceberg · PG│
   │  (UNA definición, §3)      │  liga  │  (substrato ⊥ motor, §4)        │
   └────────────────────────────┘        └───────────────┬───────────────┘
                                                          ▼   ejecuta detrás
                                                   R2 (Parquet Iceberg)

Lo que YA es elegante y real (la "construida-una-vez"): un contrato solo-tipos del que todo deriva + un composer puro sin motor propio; read/stream/aggregate/write como métodos del MISMO handle (la lección Trino PageSink: lectura y escritura son dos caras de una SPI); y la asimetría codificada en el seam — fail-SAFE en read (cae a PG, read-router.ts:81-88), fail-LOUD en write (nunca cae tras un commit, write-router.ts:143-149) → anti-split-brain por construcción, no por convención.

Lo que falta para que sea Junction-grade (las extensiones de §2–§5, cada una con trigger objetivo, no capas huecas ya presentes).


2 · Las DOS formas de entrada (el prerequisito, no un lujo diferible)

La puerta acepta hoy una forma de entrada: ItemRef (identidad lógica) + intent read/write/stream. Con eso, ~12 readers estructurados y el writer nativo ya enchufan (§7). Pero las superficies live-SQL —sql-editor, dashboards, pipeline-preview, jupyter— no producen un ItemRef; producen SQL de usuario. El contrato solo transporta ops-de-fila tipadas (PageQuery/AggQuery/WriteRequest), no "aquí hay una query, enrútala a un motor gobernado". Sin una segunda forma de entrada, esas superficies NO comparten la misma pieza — comparten un vecindario.

Por eso la capa-1 de Furnace (plan) NO es diferible como se creía: el mínimo viable de esa capa es el prerequisito de que live-SQL use Junction. La forma elegante:

Añadir un intent query como ciudadano de primera del MISMO DatasetHandle/puerta (antes de crear cualquier gateway.ts):

// La MISMA puerta, una entrada más — no una puerta nueva.
type Intent = 'read' | 'write' | 'stream' | 'query';
interface QueryRequest {
  sql: string;                 // SQL de usuario (dialecto del motor, texto tipado)
  catalog: CatalogBinding;     // FQNs físicas ya resueltas (pre-cualificación gobernada)
  session: GovernanceContext;  // tenant, tier, límites, read-only, purpose
}
// handle.query(q: QueryRequest): Promise<Page>   // devuelve {columns, rows|arrow}

Esto NO es Substrait ni un IR serializado (§10): es el envelope tipado que ya existe en germen —Node pre-cualifica FQN física + workspace ANTES de bajar al motor (buildDuckReadPlan, duck-plan.ts)— promovido a ciudadano del contrato. Es la "resolución del lado servidor" de Spark Connect: un contrato/plan tipado y versionado, no strings sueltas. El IR relacional portable (capa-1 producto) se difiere; el patrón (plan-como-frontera) se adopta ya.


3 · La cintura estrecha — UNA definición, no N copias

La cintura de Carbon es el catálogo/formato, y su núcleo es la fórmula que traduce identidad lógica → tabla física: {catalog.slug}.{schema.slug}.ds_{uuid sin guiones}. Esa fórmula está DUPLICADA entre dos lenguajes — lib/warehouse/query/fqn.ts (TS) ≡ services/ml-runner/app/lakehouse/writer.py:416-419 (Python), "confirmada 1:1" a mano.

⚠️ Al verificarlo en F3 (2026-07-28), la promesa YA se había roto. No en el nombre de la tabla, sino en el default de namespace: fqn.ts (el path DuckDB) hardcodeaba 'datasets' sin leer LAKEHOUSE_NAMESPACE, que 11 sitios del resto del stack sí respetan. Con ese env cambiado, DuckDB y ml-runner resolvían tablas físicas DISTINTAS para el mismo dataset — en silencio. Latente sólo porque el env valía datasets. "Mantenida a mano" no es una garantía: es una promesa que ya se rompió una vez.

Fix (✅ HECHO en F3.2): una única resolución defaultIcebergNamespace() consumida por los 8 ficheros que antes derivaban la suya, + un test de conformidad (fqn-conformance.test.ts) que falla si TS y Python vuelven a divergir — en el default, en la fórmula del nombre y en el namespace legacy. La condición para que motores plurales escriban la MISMA tabla física con garantía deja de ser una convención y pasa a ser un fallo de CI.


4 · La abstracción de motor — separar substratomotor

Hoy el mecanismo interno es un closure resolve*() → run<T>({ postgres, iceberg }) (read-router.ts:38-64, write-router.ts:53-75) y CommitResult.sink: 'postgres'|'iceberg' (contract :266-273). El nombre iceberg hace doble trabajo: es a la vez el FORMATO del catálogo Y la identidad del ejecutor (ml-runner). En cuanto DuckDB lea/escriba Iceberg, iceberg deja de nombrar un motor y el discriminante binario miente — la capa catálogo y la capa motor están mezcladas en el mismo enum.

Fix (barato hoy, migración cara después de crecer): separar los dos ejes en el tipo desde ya:

  • substrato/formato: 'pg' | 'iceberg' (dónde viven los bytes).
  • motor: 'mlrunner' | 'duckdb' | 'karma' | 'pg' (quién computa).

Y promover un contrato EngineAdapter que cada motor implementa (los 3 ya emiten {columns, rows} → ~80% del cable alineado):

interface EngineAdapter {
  capabilities(): EngineCapabilities;                              // qué features SQL cubre
  query(sql: string, binding: CatalogBinding, session): QueryResult;   // {columns, rows|arrow, plan?}
  write(dml: WriteOp, binding, session): CommitResult;             // CTAS/INSERT/UPDATE/DELETE/MERGE
}

Así 'iceberg'≡ml-runner deja de estar hard-bindeado: DuckDB, Karma y pyiceberg son adaptadores detrás de la misma frontera; añadir un motor = un adaptador, no tocar run<T> en cada call-site. La primitiva reusable de escritura ya existe y es el modelo: withNativeControlPlane(opts, ingest) (iceberg-native-write.ts:155) — un control-plane, N data-planes, donde el closure ingest es la cintura de motor (el DML de DuckDB de F4 es el 4º data-plane).


5 · Gobernanza — UN solo contexto, no fragmentos

El reclamo "un único PDP/PEP (loadItemForConsumption)" tiene hoy dos agujeros:

  1. La superficie live-SQL más importante —sql-editor— alcanza DuckDB FUERA de la puerta (duck-direct, route.ts:1087) tras F3-RETIRO. Un PDP cuyo mayor consumidor lo bypasea no es un punto de control.
  2. El contexto de gobernanza no es un objeto: está esparcido en Principal (con purpose inerte, contract :96) + FreshnessVerdict + ProvenanceBlock + la columna tier. Y CredentialSlot (:151-156) está reservado pero inerte.

Fix (parte del camino crítico, no decisión diferida): unificar en un GovernanceContext que cruce la puerta y llegue —cerrado— a cada motor (que queda ciego, ejecutor tonto):

interface GovernanceContext {
  principal: Principal;        // tenant, user, purpose (ACTIVAR)
  credential: CredentialSlot;  // proxied hoy → remote-signing (ACTIVAR cuando aterrice)
  freshness: FreshnessVerdict; // read-your-writes / eventual
  provenance: ProvenanceBlock; // lineage propagado (ACTIVAR captura en el path DML)
  tier: DatasetTier | null;
  writeTarget?: string;        // destino autorizado fuera-de-banda (deny-by-default, F4.2)
}

Y prohibir el path duck-direct del sql-editor —enrutarlo por la puerta (intent query)— en el MISMO cambio que introduce ese intent. Activar los 3 slots inertes (purpose + CredentialSlot + captura de provenance) es lo que convierte el "único PDP/PEP" de aspiración en realidad.


6 · Mapeo Furnace (el norte) ↔ Carbon (hoy)

Capa FurnaceQué esCarbon HOYTrigger para construir
1 · Plan unificado (Calcite en Foundry)Parse/valida/optimiza engine-independentAusente. Cada superficie autora su SQL (duck-plan.ts, jsonbCast, sourceCte).El intent query (§2) YA (prerequisito live-SQL); el IR portable (Substrait/DataFusion) diferido hasta una query multi-motor real (§10).
2 · Abstracción de motorTras planificar, decide qué motor ejecutaHECHO (F2, 2026-07-28) = SubstrateEngine cableados + EngineAdapter/SqlEngineAdapter + registry (lib/compute/engines/). iceberg≡ml-runner des-bindeado: el motor viaja explícito en el resuelto. Inerte (el consumidor es runQuery, F4).— (hecho; los run<T> binarios siguen intactos por inert-first)
3 · Selección automáticaMejor motor por carga/compat/rendimientoCruda = pickPortMode (flags, precedencia dataset>ws>global>default). F2 le dejó el INPUT preparado (capabilities() por motor + available ⊥ capacidad + ReaderPort/WriterPort.engine), pero la POLÍTICA no existe y no se finge. features va vacío a propósito.Capability-check por motor (lista blanca) + shadow como red, tras el harness diferencial (§8·F3) — que es justo lo que puebla features.
Cintura (transversal)Catálogo/formatoReal = Lakekeeper + FQN — pero duplicada (§3).Unificar la fórmula FQN (prerequisito).
Gobernanza (transversal)Contexto que cruza todoFragmentada + con bypass (§5).GovernanceContext único + cerrar el duck-direct.

Motores plurales (≈ Spark/Trino de Foundry): DuckDB (interactivo, compute+DML — el "Trino-ish") · Karma (read nativo con pruning — el acelerador) · pyiceberg/ml-runner (writer bulk — el "Spark-ish") · PG (legacy, en retiro).


7 · Reutilización — cómo enchufa CADA superficie a la MISMA pieza

La pieza compartida = el contrato DatasetHandle + la puerta loadItemForConsumption. Cada superficie llama IGUAL; el motor es invisible. Estado real (del inventario):

SuperficieREAD hoyWRITE hoyObjetivo bajo Junction
sql-editorBypasa la puerta — duck-direct (route.ts:1087, duck-client.ts) tras F3-RETIROF4 (writer sql-editor.dml, inerte)intent query por la puerta (contexto gobernado + motor plural). Chokepoint natural.
pipelinesPor la puerta (pipeline.source, source-resolver.ts:57)Único uso real de facade.write() (native-output-write.ts:80, canary)Ya enchufado; extender al eje motor plural.
jupyterPor la puerta vía sdk.rows (sdk/v1/datasets/[…]/rows/route.ts:81)idemYa reutiliza; solo binding.
dashboardsPor la puerta vía hydrate, pero motor PG-sobre-JSONB (route.ts:102) — diverge del DuckDB del sql-editorReunificar tras la puerta (motor DuckDB) → cierra la divergencia.
sync/pollingBypasa facade.write() (dataset-writer.tsresolveDatasetRowSink directo)Enrutar por la puerta (intent write).
sdkPor la puerta (rows)Por la puertaYa reutiliza.

Se PROMUEVE a la frontera única (hoy duplicado entre duck-plan.ts/fqn.ts/read-router/sourceCte): selección de motor + contexto de gobernanza + veredicto de frescura + resolución de FQN física. Se QUEDA por-superficie: la autoría de la query (las live-SQL producen SQL; las estructuradas producen read()/write()). Una sola puerta, motor invisible, cero re-iteración por superficie.


8 · Plan semilla → pieza (inert-first, UNA puerta)

No se reescribe la plataforma: se promueve la semilla y se dejan las piezas construidas. Nada sirve tráfico autoritativo hasta que el harness diferencial (F3) lo valida.

  • F0 · Reconciliar deuda de nombres/docs (cero riesgo). El reader sql-editor.query quedó RANCIO (docs lo marcan facade-vía-hydrate; el código lee duck-direct desde F3-RETIRO) — fuente de confusión #1. Barrer docs+memoria a estado vigente antes de construir.
  • F1 · El intent query + GovernanceContext en el CONTRATO (no un router nuevo). Añadir la 4ª forma de entrada (§2) y el contexto único (§5) al consumption-contract + composer. La puerta sigue siendo una (opcional: reubicar contrato+composer a lib/compute/ como hogar canónico de Junction). Inerte: comportamiento idéntico.
  • F2 · Separar substrato ⊥ motor + EngineAdapter (§4). ✅ HECHO 2026-07-28 (inerte). Substrate(pg|iceberg) ⊥ Engine(duckdb|mlrunner|karma|pg) cableados; engine estampado en el resuelto de ambos routers; engine? declarable en el PortSpec; contrato EngineAdapter + extensión SqlEngineAdapter + registry total en lib/compute/engines/. run<T>({postgres, iceberg}) se dejó INTACTO (inert-first: los ~30 caller-closures no se tocan; el mapa keyed-por-motor no hacía falta para desbloquear runQuery). Corrección de premisa: los 3 motores NO emiten todos {columns, rows} de SQL — mlrunner no tiene endpoint SQL y pg no es un servicio, así que runQuery es una extensión declarada, no un método del contrato base (junction-f2-engine-adapter.md §2·bis).
  • F3 · Equivalence harness ANTES de servir tráfico autoritativo. ✅ HECHO 2026-07-28. Entregable: junction-f3-equivalence.md. El eje diferencial resultó NO ser el de la spec previa (§4 de compute-integration decía "PG↔Karma", pero el lado PG lo borró F3-RETIRO y Karma sigue inerte): es DuckDB ↔ ml-runner sobre la MISMA tabla física, que es justo lo que valida la tesis de motores plurales sobre una cintura estrecha. Construido: el comparador PURO lib/compute/equivalence.ts (sort-before-compare, tolerancia relativa, coerción del formato de cable, guard de no-determinismo, veredicto ternario MATCH/MISMATCH/UNCOMPARABLE), las 25 sondas de feature-probes.ts que DERIVAN la whitelist, y el harness e2e. Además cerró una divergencia VIVA de la cintura (§3): fqn.ts ignoraba LAKEHOUSE_NAMESPACE que 11 sitios sí respetan → DuckDB y ml-runner podían apuntar a tablas físicas distintas.
  • F4 · Absorber el SQL Editor tras la puerta — su duckQuery pasa por el intent query (gobernado + motor plural) en vez de duck-direct; cerrar el bypass en el mismo PR. Reunifica las 2 superficies live-SQL divergentes.
  • F5 · WRITE bajo la puerta — cablear el hermano adelgazado de withNativeControlPlane (f4 §2.3) como 4º data-plane, invocado por intent write, para sql-editor.dml. Empezar por CTAS/replace (idempotente); DML mutante con commit-uuid+read-back después (f4 F4.5). Retirar progresivamente el throw del brazo PG a medida que los writers rezagados (sync/cdc/manual/ingest) enrutan por la puerta.
  • F6 · Migrar dashboards + pipeline-preview del PG-sobre-JSONB al mismo gateway (motor DuckDB), retirando los shims duplicados (jsonbCast, sourceCte, extractores a la deriva). La frontera cubre toda superficie live-SQL.
  • Prerequisitos transversales (no fases, condiciones): unificar la fórmula FQN (§3) antes del 2º motor; unificar GovernanceContext (§5) en F1; el reconcile-from-catalog daemon (f4 §3.1) antes del canary de escritura.

9 · Principios de elegancia (por qué la iteración es limpia)

  1. La frontera es intención tipada o plan, NUNCA SQL-string opaca ni la API de un motor concreto → language-agnostic, forward-compatible.
  2. Doble frontera simétrica: intención tipada a la entrada, columnar {columns, rows}/Arrow a la salida — el centro es plural, los dos extremos estables.
  3. Read y write = dos caras de la MISMA SPI (Trino PageSink), no sistemas aparte — ya encarnado en DatasetHandle.
  4. Cada capa UNA responsabilidad + interfaz limpia. "Actualizar el motor no rompe queries" es la propiedad emergente de separar plan↔ejecución.
  5. Selección = POLÍTICA swappable, no mecanismo — se endurece (capacidad/coste/latencia) sin tocar el plan ni los motores.
  6. Asimetría codificada en el seam: fail-SAFE read / fail-LOUD write — anti-split-brain por construcción.
  7. Cintura en el CATÁLOGO/formato, no en un motor — UNA fórmula FQN cross-engine (§3) permite motores plurales sobre la misma tabla.
  8. Reúsa, no reinventa — el contrato es solo tipos del que todo deriva; el composer es puro, cero motor nuevo.
  9. Un control-plane, N data-planes por un solo closure — la pluralidad de motor vive en el plugin, no en la frontera.
  10. Gobernanza como contexto ÚNICO que cruza la puerta por un solo PDP/PEP; los motores quedan ciegos.
  11. inert-first + harness-diferencial-antes-de-servir — enviar antes de cobertura total sin arriesgar correctitud.
  12. Colapsar el modelo a lo real — no nombrar capas que aún están huecas; las capas Furnace son el norte con trigger, no andamiaje prematuro.

10 · Capa-1 (plan unificado) — la decisión

Diferir Substrait como mainline; adoptar el PATRÓN ya, el PRODUCTO perezosamente.

  • Calcite = descartado como runtime (JVM en un stack Node/Rust/Python = impuesto operativo sin ganancia frente al planner SOTA que DuckDB ya trae). Vale solo como prior-art de diseño.
  • Substrait tiene fidelidad incompleta hoy (roundtrip DataFusion #16248: RecursiveQuery, EXISTS, GROUPING SETS/CUBE, casts, empty relation) y NO modela DML/DDL ni operaciones de catálogo. Consecuencia dura: el path DML/DDL/catálogo (SQL Editor→duck-server, F4) nunca debe pasar por Substrait — se mantiene ruta SQL-texto tipada.
  • DuckDB debe seguir parseando su propio SQL (su planner supera pasar por la cintura lossy de Substrait; su extensión substrait es community, no core).
  • El norte inmediato de la "capa-1" es el ENVELOPE TIPADO (el intent query, §2), no un IR serializado — el patrón "plan-como-frontera" de Spark Connect.
  • El PRODUCTO capa-1 (DataFusion como cerebro de planificación en Rust —ciudadano natural junto a Karma— + Substrait como formato-cable) se justifica solo cuando aparezca una query multi-motor real (empujar scan indexable→Karma + agregar en DuckDB). Señal objetiva para adoptarlo = el harness diferencial en verde, no adopción por fe.

11 · Decisiones abiertas

  1. Home canónico — RESUELTO (F1, 2026-07-10): lib/compute/contract.ts + lib/compute/junction.ts (reubicados desde lib/lakehouse/, 23 sitios de import actualizados, tsc 0). runQuery peer + GovernanceContext (objeto nuevo) confirmados por el owner.
  2. Vending / credencial per-workspace: vended roto sobre R2 (lakekeeper#1630) → god-key estática compartida hoy. CredentialSlot remote-signing reservado pero inerte. Prerequisito duro del aislamiento de tenants en storage.
  3. Granularidad del capability-check: lista blanca de features SQL por motor (determinista) vs intentar-y-medir en shadow. Recomendado: lista blanca + shadow como red.
  4. Gobernanza en el wire: tenancy como filtro de contexto de catálogo (resolver-scoped, hoy) vs credential vending con scope por tabla (estilo UC). El 2º es más SOTA pero exige el catálogo REST maduro.
  5. Wire Arrow: Flight SQL/ADBC completo vs Arrow IPC sobre el POST /query actual (paso intermedio barato). Afecta la frontera de salida.
  6. Coordinación cross-engine (f4): lease cross-proceso real (recomendado) vs OCC de Lakekeeper + invariante single-writer. Hoy no hay lock cross-motor.
  7. Nodo 'Warehouse' como epicentro de lineage: la memoria lo afirma pero no está verificado en lib/lineage/relationIndex.ts (PF_TYPES sin warehouse) ni en app/api/data-lineage/route.ts — INCIERTO. Decidir si se cablea la arista write-side.
  8. Reconcile-from-catalog daemon (f4 §3.1) — ratificado (catálogo=verdad/PG=proyección) pero no construido; fase de primera clase antes del canary de escritura.

Doc vivo. Fuente: workflow junction-architecture-deliverable (8 mapas + 2 SOTA + síntesis + crítica de elegancia), 2026-07-10, grounding verificado sobre consumption-contract.ts/item-consumption.ts. Junction = evolucionar la semilla (una puerta, dos caras, una cintura, gobernanza única, motores plurales) hacia el Furnace de Carbon — sin reescribir la plataforma.