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". FijaItemRef,Intent,DatasetHandle,WriteRequest,CommitResult,FreshnessVerdict, y (v1.2)GovernanceContext/CatalogBinding/QueryRequest/RunQuery+ los ejesSubstrate/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) yrunQuery(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)
- 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 sobremetadata.json— es del catálogo, no de Junction. - 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. - NO es STORAGE. R2 (bytes Parquet) queda detrás; Junction no ve paths ni llaves (contract
:16). - NO es el
DatasetWriterlegacy. El brazo PG dewrite()lanza a propósito (item-consumption.ts:605-611, "Frontera del Paso 6 — no reimplementamos DatasetWriter aquí"). - 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()yhandle.write()son métodos del mismoDatasetHandle, 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 leerLAKEHOUSE_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íadatasets. "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 substrato ⊥ motor
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:
- 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. - El contexto de gobernanza no es un objeto: está esparcido en
Principal(conpurposeinerte, contract:96) +FreshnessVerdict+ProvenanceBlock+ la columnatier. YCredentialSlot(: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 Furnace | Qué es | Carbon HOY | Trigger para construir |
|---|---|---|---|
| 1 · Plan unificado (Calcite en Foundry) | Parse/valida/optimiza engine-independent | Ausente. 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 motor | Tras planificar, decide qué motor ejecuta | ✅ HECHO (F2, 2026-07-28) = Substrate ⊥ Engine 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ática | Mejor motor por carga/compat/rendimiento | Cruda = 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/formato | Real = Lakekeeper + FQN — pero duplicada (§3). | Unificar la fórmula FQN (prerequisito). |
| Gobernanza (transversal) | Contexto que cruza todo | Fragmentada + 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):
| Superficie | READ hoy | WRITE hoy | Objetivo bajo Junction |
|---|---|---|---|
| sql-editor | Bypasa la puerta — duck-direct (route.ts:1087, duck-client.ts) tras F3-RETIRO | F4 (writer sql-editor.dml, inerte) | intent query por la puerta (contexto gobernado + motor plural). Chokepoint natural. |
| pipelines | Por 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. |
| jupyter | Por la puerta vía sdk.rows (sdk/v1/datasets/[…]/rows/route.ts:81) | idem | Ya reutiliza; solo binding. |
| dashboards | Por la puerta vía hydrate, pero motor PG-sobre-JSONB (route.ts:102) — diverge del DuckDB del sql-editor | — | Reunificar tras la puerta (motor DuckDB) → cierra la divergencia. |
| sync/polling | — | Bypasa facade.write() (dataset-writer.ts → resolveDatasetRowSink directo) | Enrutar por la puerta (intent write). |
| sdk | Por la puerta (rows) | Por la puerta | Ya 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.queryquedó 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+GovernanceContexten el CONTRATO (no un router nuevo). Añadir la 4ª forma de entrada (§2) y el contexto único (§5) alconsumption-contract+ composer. La puerta sigue siendo una (opcional: reubicar contrato+composer alib/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;engineestampado en el resuelto de ambos routers;engine?declarable en el PortSpec; contratoEngineAdapter+ extensiónSqlEngineAdapter+ registry total enlib/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 desbloquearrunQuery). Corrección de premisa: los 3 motores NO emiten todos{columns, rows}de SQL —mlrunnerno tiene endpoint SQL ypgno es un servicio, así querunQueryes 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 defeature-probes.tsque DERIVAN la whitelist, y el harness e2e. Además cerró una divergencia VIVA de la cintura (§3):fqn.tsignorabaLAKEHOUSE_NAMESPACEque 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
duckQuerypasa por el intentquery(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 intentwrite, parasql-editor.dml. Empezar por CTAS/replace (idempotente); DML mutante con commit-uuid+read-back después (f4 F4.5). Retirar progresivamente elthrowdel 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)
- La frontera es intención tipada o plan, NUNCA SQL-string opaca ni la API de un motor concreto → language-agnostic, forward-compatible.
- 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. - Read y write = dos caras de la MISMA SPI (Trino PageSink), no sistemas aparte — ya encarnado en
DatasetHandle. - Cada capa UNA responsabilidad + interfaz limpia. "Actualizar el motor no rompe queries" es la propiedad emergente de separar plan↔ejecución.
- Selección = POLÍTICA swappable, no mecanismo — se endurece (capacidad/coste/latencia) sin tocar el plan ni los motores.
- Asimetría codificada en el seam: fail-SAFE read / fail-LOUD write — anti-split-brain por construcción.
- Cintura en el CATÁLOGO/formato, no en un motor — UNA fórmula FQN cross-engine (§3) permite motores plurales sobre la misma tabla.
- Reúsa, no reinventa — el contrato es solo tipos del que todo deriva; el composer es puro, cero motor nuevo.
- Un control-plane, N data-planes por un solo closure — la pluralidad de motor vive en el plugin, no en la frontera.
- Gobernanza como contexto ÚNICO que cruza la puerta por un solo PDP/PEP; los motores quedan ciegos.
- inert-first + harness-diferencial-antes-de-servir — enviar antes de cobertura total sin arriesgar correctitud.
- 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
- ✅ Home canónico — RESUELTO (F1, 2026-07-10):
lib/compute/contract.ts+lib/compute/junction.ts(reubicados desdelib/lakehouse/, 23 sitios de import actualizados, tsc 0).runQuerypeer +GovernanceContext(objeto nuevo) confirmados por el owner. - Vending / credencial per-workspace:
vendedroto sobre R2 (lakekeeper#1630) → god-key estática compartida hoy.CredentialSlotremote-signing reservado pero inerte. Prerequisito duro del aislamiento de tenants en storage. - Granularidad del capability-check: lista blanca de features SQL por motor (determinista) vs intentar-y-medir en shadow. Recomendado: lista blanca + shadow como red.
- 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.
- Wire Arrow: Flight SQL/ADBC completo vs Arrow IPC sobre el
POST /queryactual (paso intermedio barato). Afecta la frontera de salida. - Coordinación cross-engine (f4): lease cross-proceso real (recomendado) vs OCC de Lakekeeper + invariante single-writer. Hoy no hay lock cross-motor.
- Nodo 'Warehouse' como epicentro de lineage: la memoria lo afirma pero no está verificado en
lib/lineage/relationIndex.ts(PF_TYPES sin warehouse) ni enapp/api/data-lineage/route.ts— INCIERTO. Decidir si se cablea la arista write-side. - 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.