Published

⚙️ 2d.1 — Dirigir el write de pipeline.output al tridente (approach)

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

⚙️ 2d.1 — Dirigir el write de pipeline.output al tridente (approach)

Qué es 2d.1 (REFRAME 2026-07-04 — leer PRIMERO). El objetivo NO es construir un motor de compute. El motor es Karma (query Iceberg sobre DataFusion) — y aún no ha llegado. 2d.1 solo tiene que dirigir el WRITE de pipeline.output al tridente del Data Space: R2 (bytes Parquet) + Lakekeeper (punteros/índices Iceberg) + PG (metadata + gobernanza del item). Y eso la facade write() YA lo hace (verificado E2E). El compute sigue corriendo en PG por ahora (Karma lo subsume después); solo cambiamos DÓNDE aterrizan sus filas: de INSERT INTO dataset_rows a facade.write({replace}).

NO sobre-extender. Toda la maquinaria de "recuperar atomicidad con un write-intent ledger elaborado", "full-rebuild-first del delta nativo", "precisión del committed_at watermark", "compute→stream cursor optimizado" es sobre-ingeniería para un motor intermedio que Karma reemplaza. Se documenta abajo como CONTEXTO/Karma-era, NO como trabajo de 2d.1. Lo mínimo de 2d.1 = el brazo iceberg: (§4) + subir maxMode (§5) + los 2 bug-fixes reales de nativo (§6 E3/E4). El patrón exacto ya existe: el manual-table route (compute/metadata PG → facade.write post-commit).

Fundado en 5 auditorías (2026-07-04). Los P0 ya están cerrados (2d-pipeline-output-flip.md §3). Companions: 2d-pipeline-output-flip.md · paso6-write-approach.md · port-migration.md.


0.5 El camino MÍNIMO (lo que 2d.1 SÍ hace)

  1. Brazo iceberg: en el write dispatch (§4): las filas computadas por el SQL de pipeline → facade.write({kind:'rows', writeMode:'replace', sourceRefs}) → tridente. Reusa el patrón manual-table (write nativo post-commit de la metadata PG). El compute PG se lee simple (cursor o memoria acotada — no hace falta optimizar; Karma lo reemplaza).
  2. Subir maxMode pg→iceberg_native (writer-flags.ts:109-112, defaultMode sigue pg) → flip opt-in por-dataset (canary).
  3. Fix 2 bugs reales de nativo (§6): E3 recount SELECT count(*) → usar el count del CommitResult; E4 cross-join pre-flight → handle.rowCount.
  4. replace-first, canary por-dataset (Tier A: join/split/transform-snapshot/1er-deploy). Sin delta nativo (solo replace — simple).

Atomicidad SIN ledger elaborado: facade.write() (→ withNativeControlPlane) YA es open→commit atómico y fail-loud para el write nativo (snapshot Iceberg all-or-nothing). La metadata PG (row_count/watermark) se estampa después, como en manual-table; si el proceso muere entre medias, un re-deploy (replace, idempotente) lo reconcilia. No construimos el write-intent ledger con estado open revivido + índice-único + barredor — eso es Karma-era.

Lo de abajo (§1–§9) es el análisis de espectro completo de las 5 auditorías. Sirve de CONTEXTO y para saber qué NO hacer ahora. El trabajo real de 2d.1 es §0.5 + §4 + §6(E3/E4). Las piezas B (ledger elaborado), D (full-rebuild-first/watermark preciso) y la optimización de A (cursor) son diferidas a Karma o innecesarias — marcadas como tal.


0. Correcciones que la auditoría de 2d.1 añadió

#Suposición previaRealidad auditada
E1"el CommitResult trae el committed_at real → derivar high de ahí" (§4 del doc de espectro)FALSO. IcebergIngestResult (ingest-client.ts:23-29) y NativeWriteResult (iceberg-native-write.ts:217-223) devuelven transactionId/rows/snapshotId/sizeBytes/identifierNO committed_at. El committed_at lo sella el control-plane PG con now local (iceberg-native-write.ts:146) y se PIERDE. Hay que añadir committedAt al return (o releerlo de dataset_transactions).
E2"el advisory-lock P0.4 serializa el ciclo nativo"FALSO. lockDatasetForWrite es pg_advisory_xact_lock xact-scoped (dataset-build-lock.ts): se libera al COMMIT/ROLLBACK de SU tx. Al partir la tx (compute-tx-A cierra antes del write externo), el lock se suelta durante la ingesta a R2 → dos builds concurrentes solapan. Solución: serializar por el ledger (unique partial index WHERE status='open') que abarca el ciclo largo. Refina P0.4.
E3(no visto)BLOCKER: transformPaths.ts:372-379 hace SELECT count(*) FROM dataset_rows del target tras el write → sobre un nativo drenado = 0datasets.row_count=0 → rompe model.schema.infer + freshness. El count debe salir del CommitResult.
E4(no visto)BLOCKER latente: el pre-flight de cross-join (transformPaths.ts:687-692) cuenta dataset_rows de las FUENTES → nativas drenadas = 0 → desactiva el guard JOIN_MAX_OUTPUT_ROWS (deja pasar un cross-join explosivo). Contar sobre handle.rowCount.
E5(no visto)checkpoint-ephemeral.ts:177-180 clona PG→PG; sobre un estructural nativo el SELECT FROM dataset_rows devuelve 0 sin lanzar → redirect a snapshot VACÍA en silencio (no cae al fallback estructural). Necesita un guard antes de flipear un dataset fuente de checkpoint transient.

Además: NativeWriteResult no carga skipped/preserved (solo el total rows) → los merges Tier-B calculan esos conteos en el productor (Node), no ml-runner.


1. Las cinco piezas del motor

compute PG (cursor, rollbackable)                    [PIEZA A]
   → AsyncIterable<Row> de filas de negocio SIN identidad
        → facade.write({kind:'rows', writeMode:'replace', sourceRefs})   [PIEZA C]
             → ml-runner estampa __row_index/__row_id/__created_at → snapshot Iceberg
   envuelto en el WRITE-INTENT LEDGER (open→commit, tx partida)          [PIEZA B]
        gobernado por maxMode + canary + full-rebuild-first + watermark  [PIEZA D]
   con los BORDES cerrados (recount, cross-join, checkpoint, …)          [PIEZA E]

2. PIEZA A — compute→stream (el primitivo técnico)

El SELECT de negocio YA está aislado en cada productor — solo hay que despojar el envoltorio INSERT … to_jsonb(…) , row_number():

  • estrategias deploy: el CTE __final__ / __combined__ / __filtered__ (dataset-write-strategies/{snapshot-replace:137-143, append-new:82-88, append-always:59-61}.ts) vía buildOutputProjection (projection-sql.ts).
  • transform: finalSelect de combineSourceWithChain (sourceCte.ts:135-150, usado en transformPaths.ts:347).
  • join: compiled.sql (transformPaths.ts:764). split: matchedSql/unmatchedSql (dos streams).
  • model-node: ya es windowed (readSql, model-node-materialiser.ts:204-213) — caso especial (ver §7).

Mecanismo: cursor PG server-side vía pg-query-stream (ya es dependencia; patrón de referencia con cleanup en postgresql-adapter.ts:807-889; alternativa DECLARE/FETCH en cdc/snapshot.ts:43-62). Memory-bound a 1 batch. Se ejecuta WITH ${ctes} SELECT <cols> FROM __final__ como cursor sobre el mismo FROM ${sourceTable} que ya resuelve resolvePipelineSource (P0.2). El AsyncIterable<Row> alimenta directo facade.write({rows}) (que ya lo consume vía buildNdjsonBody, memory-bound, con backpressure de red). columns = outputSchema es pass-through (misma forma que NativeWriteColumns).

Reusar: pg-query-stream, el patrón flatten() batch→row de DatasetWriter.writeDatasetIcebergNative (dataset-writer.ts:1003-1018), la proyección __final__. Construir: el adaptador cursor→AsyncIterable sobre el SELECT compilado del pipeline.

⚠️ Decisión abierta clave (tx larga): el cursor mantiene la tx PG del compute abierta mientras ml-runner ingesta a R2 (segundos–minutos). Dos opciones (ver §8):

  • (a) cursor directo — una tx PG viva toda la ingesta (simple, pero retiene conexión + arriesga statement_timeout).
  • (b) SELECT→tempCREATE TEMP TABLE __out ON COMMIT DROP AS <select>, COMMIT del compute, luego stream de la temp con una tx corta de lectura. Desacopla compute-tx de ingest, pero duplica escritura.

3. PIEZA B — write-intent ledger + atomicidad

Revive el estado open muerto de pipeline_build_transactions (CHECK ya lo admite; hoy el pipeline inserta committed inline en dataset-output-materialiser.ts:348 y transformPaths.ts:326). Máquina de estados (clon del withNativeControlPlane, iceberg-native-write.ts:125-231, adaptado a tx partida):

INTENT   [tx-PG-A corta]  INSERT pipeline_build_transactions status='open',
                          transaction_id=τ (pre-minted), row_count=0
COMPUTE  [rollbackable]   cursor PG → AsyncIterable<Row> (NO INSERT dataset_rows)
WRITE    [externo]        facade.write({replace}) → snapshot Iceberg all-or-nothing
COMMIT   [tx-PG-B corta]  UPDATE status='committed', row_count=N (del CommitResult),
                          committed_at=τ_real; + source_watermark = τ_real (SOLO AQUÍ);
                          + datasets.row_count/status='active'; + runExpectations

Dónde se parte la tx única de hoy (dataset-output-materialiser.ts — todo hoy en una tx por output, deploy-worker.ts:607-680):

  • Queda en tx-PG-A (compute, rollbackable): el compute que produce el stream. En replace-first NO hay DELETE/INSERT de dataset_rows. El UPDATE datasets.schema (:271) se difiere al COMMIT (un compute fallido no debe adelantar schema).
  • Va a tx-PG-B (post-write): pipeline_build_transactions→committed, source_watermark, row_count/status, expectations. El reconcileDatasetSchema (:380) desaparece (replace reescribe limpio; ver §6).

Serialización (refina P0.4, E2): el advisory-lock xact-scoped NO cubre el ciclo largo. Recomendado: unique partial index pipeline_build_transactions(dataset_id) WHERE status='open' → dos INTENT sobre el mismo dataset colisionan; el 2º espera/reintenta. Abarca INTENT→COMMIT naturalmente y se libera al marcar committed/aborted. El advisory-lock actual se conserva solo dentro de la tx-PG-B corta si hace falta ordenar los UPDATEs finales.

Idempotencia + recovery: replace es idempotente (ml-runner posee row_index → mismo snapshot lógico) → retry seguro tras commit parcial (write OK, tx-PG-B falló). append-desde-max NO lo es → replace-first. Construir un barredor (no existe): job que marca open AND created_at < now()-Taborted (benigno: replace lo regenera; con el índice-único, también libera la serialización).

Reusar: los pasos commit+stats de withNativeControlPlane (casi 1:1 con la tx-PG-B); dataset_transactions_all + windowAllowsDelta/watermarkLowerBound (incremental.ts) tal cual. Construir: el estado open separado, el barredor, committedAt de vuelta (E1), el índice-único de serialización.


4. PIEZA C — el brazo iceberg: + mapeo por estrategia

El contrato de estrategia se extiende con un productor de filas (compute sin write):

interface WriteStrategy {
  apply(ctx): Promise<WriteResult>;        // PG (hoy, intacto)
  computeRows(ctx): { rows: AsyncIterable<Row>; writeMode; columns };  // NUEVO
}

El despacho (dataset-output-materialiser.ts:330, transformPaths.ts:362, model-node-materialiser.ts:249) gana el brazo:

sink.run({
  postgres: () => strategy.apply(ctx),                       // intacto
  iceberg: async () => {
    const { rows, writeMode, columns } = strategy.computeRows(ctx);   // cursor
    const commit = await loadItemForConsumption({datasetId: producedDatasetId}, 'write',
        {writerId:'pipeline.output', workspaceId}, {writeMode})
      .then(h => h.write({kind:'rows', columns, rows, writeMode,
        sourceRefs:[{datasetId: output.sourceDatasetId}],
        metadata:{service:'pipeline', origin:'pipeline_output'}}));
    return commitToWriteResult(commit, ctx);
  },
});

sourceRefs propaga la gobernanza (Fase D, mergeUpstreamProvenance) → reemplaza el INSERT pipeline_build_transactions manual. Plantilla E2E de referencia: el manual-table route (app/api/pipelines/[id]/manual-table/route.ts:196-221, commit PG de metadata → write nativo post-COMMIT).

Ranking replace-first (Tier A = primer canary, mapean limpio; Tier B = 2d.3, leen el target):

Estrategia / nodoTierwriteModeTarget-read a rutar por facade
joinAreplace— (el más limpio, canary #1)
split (×2 ramas)Areplace
transform-snapshotAreplace
snapshot_replace 1er deployAreplacetarget vacío → replace puro
append_alwaysAappend(MAX(row_index) lo disuelve ml-runner)
model-nodeA*replace— (*necesita col __source_row_key, §7)
append_newBappend__existing_pks__append-new.ts:77-80
snapshot_replace preservanteBreplace__preserved_candidates__snapshot-replace.ts:120-123
transform-deltaB (diferido)full-rebuild-firstoffset + deltaPredicate → §5

CommitResult→WriteResult: Tier A → rowsWritten=commit.rows, skipped=0, preserved=0. Tier B → el merge en Node calcula skipped/preserved. upsert de PyIceberg ≠ snapshot_replace preservante (M6) → Tier B se hace leyendo el target por facade + merge en el productor (traducir __new_deduped__/__preserved__/__filtered__ a Node) + write({replace}) del combinado (idempotente).

Confirmar (construir): la semántica del conteo append de ml-runner (¿total o batch?).


5. PIEZA D — maxMode, full-rebuild-first, watermark, canary

Subir el techo (writer-flags.ts:109-112): maxMode:'pg'→'iceberg_native', defaultMode sigue pg → flip opt-in por-dataset. Sin el brazo iceberg, el primer flag revienta el deploy (fail-loud write-router.ts:189-192) — por eso el brazo (§4) es prerrequisito de subir el techo.

Full-rebuild-first (E-M3, decidido): clampar readMode→snapshot cuando el sink es iceberg, en DOS sitios (dataset-output-materialiser.ts:290-307 + transformPaths.ts:293-310). Requiere mover resolveDatasetRowSink ARRIBA del bloque delta (hoy se resuelve en :327/:361, después). Helper compartido sinkForcesFullRebuild(sink) en incremental.ts (la regla "iceberg ⇒ snapshot" en un solo lugar; NO tocar windowAllowsDelta, que es pura y sirve a downstreams PG legítimos). Evita el mixed-mode (deltaPredicate sobre drenado lee 0 en silencio).

Watermark (E1): hoy high=buildStart wall-clock (dataset-output-materialiser.ts:290,302,355). Añadir committedAt al return de NativeWriteResult (iceberg-native-write.ts:217-223 + interfaz :44-46); el productor usa high = commit.committedAt al escribir el watermark en la tx-PG-B. Así la ventana (low, high] del downstream y el sello de la txn son el MISMO reloj → sin skew. Importa incluso con full-rebuild-first (downstreams que lean este output en delta).

Canary por-dataset: extender CUTOVER_WRITERS (lakehouse-cutover.ts:44) — segmentado (pipeline.output no lo tocan sync.*/ingest.api; no mezclar en la misma lista). Disparador del flip = re-deploy (no connector sync). Secuencia: checkset pipeline.output iceberg_native --dataset <ds> → re-deploy → verify (checksum) → canary --smoke. Primero hojas del DAG, replace-pura, sin downstream, ≤1M filas. Revert = clear/set pg (≤10s TTL). NO drenar dataset_rows hasta paridad sostenida (Fase 5).

Observabilidad: loguear la decisión de sink (sink.sink/mode/clamped) en el log de estrategia (dataset-output-materialiser.ts:358-370); añadir warn al fallback NO-EXEC del read-seam (read-router.ts:206-213). El canary existente ya muestrea iceberg_sync_log status='succeeded' → captará outputs nativos de pipeline automáticamente.


6. PIEZA E — bordes + checklist de completitud

BordeSeveridadFix
transform-path recount SELECT count(*) FROM dataset_rows (transformPaths.ts:372-379)🔴 BLOCKEREl count SIEMPRE del sink.run() result (dataset-output ya lo hace; unificar los 5 productores).
cross-join pre-flight count sobre fuentes (transformPaths.ts:687-692)🔴 BLOCKER latenteContar sobre handle.rowCount de la facade (nativo → 0 desactiva JOIN_MAX_OUTPUT_ROWS).
checkpoint-ephemeral clona PG→PG (:177-180) → redirect→vacío silencioso sobre nativo🟡 GUARDif source nativo → skip clone, redirect al durable (el snapshot Iceberg ya es inmutable). Diferible si ningún dataset con checkpoint transient se flipea en canary.
native→native cap 1M (source-resolver.ts:79-84)🟡 REGLA canaryNo flipear outputs >1M fuente de otro pipeline hasta compute nativo. Válvula: PIPELINE_SOURCE_MAX_HYDRATE_ROWS.
schema-reconcile (schema-reconcile.ts:66-77)🟢 2d.3Replace lo obvia (no-op). En merge-preservante: proyectar expectedKeys sobre las filas preservadas en el merge del productor.
syncDatasetToObjects lee su source paginado (object-type-output-materialiser.ts:359-368)🟡 consumidor no catalogadoUn dataset nativo que respalde un object_type → resolver su source por facade. Fuera del write-flip, pero es un reader de nativos no listado.
regenerar EXEMPT_BASELINE (check-dataset-rows-seam.ts)🟢 PROCESOAl quitar el recount + añadir brazos iceberg los counts bajan → SEAM_BASELINE_REPORT=1 como parte de 2d.1.

Fuera de scope confirmado: model-prepass stamping (control-plane, agnóstico al substrato), object_type write (substrato objects), model-node row_count (contador en memoria), split/join recount (cuentan el propio INSERT, no el target).


7. Secuenciación (camino MÍNIMO — el motor es Karma)

2d.1a · FIX de bordes (inert — PG hoy, cero cambio de comportamiento)   ✅ HECHO (2026-07-04)
     ├─ ✅ E3: transform-path recount decoplado — snapshot usa rowsWritten (sin leer
     │       dataset_rows, native-ready: el count vendrá del CommitResult); delta
     │       mantiene el recount (PG-only, native=full-rebuild=snapshot)
     ├─ ✅ E4: cross-join pre-flight cuenta datasets.row_count (source-agnostic) en vez
     │       de count(*) FROM dataset_rows (que da 0 para una fuente nativa → guard off)
     └─ (opcional, diferido) logging de sink + warn NO-EXEC (observabilidad)

2d.1b · EL BRAZO (inert — defaultMode sigue pg, ningún flag flipeado)   ◑ PARCIAL (2026-07-04)
     ├─ ✅ helper `lib/pipelines/native-output-write.ts` (writeOutputToTrident): corre el
     │      compute PG (read acotado en memoria, cap PIPELINE_OUTPUT_NATIVE_MAX_ROWS; Karma
     │      streamea) → facade.write({replace, sourceRefs}) → tridente. Count del CommitResult.
     ├─ ✅ brazo iceberg: en TRANSFORM (applyTransformPath): sink.run({postgres, iceberg}),
     │      computeSql = WITH ${ctes} SELECT to_jsonb(finalSelect.*) AS data; delta → throw
     │      (full-rebuild-first: nativo = snapshot).
     ├─ ✅ subir pipeline.output.maxMode pg→iceberg_native (defaultMode sigue pg → canary opt-in)
     Atomicidad interina SIN ledger nuevo: withNativeControlPlane (snapshot all-or-nothing) +
     re-apply idempotente (replace). committedAt preciso + tx partida = Karma-era.

2d.1c · APPLY-PATH COMPLETO (inert)   ✅ HECHO (2026-07-04)
     ├─ ✅ brazo iceberg en JOIN (applyJoinPath): computeSql WITH leftCte,rightCte SELECT
     │      to_jsonb(compiled.sql). Full replace (sin delta). Fuentes linchpin-routed.
     └─ ✅ brazo iceberg en SPLIT (applySplitPath, DUAL-TARGET): ambas ramas iceberg → DOS
            writes nativos (matched + unmatched, una snapshot cada uno); ambas pg → el
            statement combinado; mixto → throw (flipear el par junto).

2d.1d · DEPLOY SOURCE-RESPECTING (inert)   ◑ PARCIAL (2026-07-04)
     ├─ ✅ deploy-strategies SOURCE read → linchpin: `WriteContext.sourceTable` resuelto por
     │      `resolvePipelineSource` en el materialiser + las 3 estrategias leen
     │      `FROM ${sourceTable}` (append-always/new/snapshot-replace). Un upstream nativo se
     │      hidrata; delta fuerza PG. SQL byte-idéntico hoy (sourceTable='dataset_rows').
     └─ ⏳ el WRITE arm nativo del deploy + model-node = TIER B / contrato (2d.3+):
            · deploy: computeRows por estrategia + brazo; snapshot_replace/append_new son
              merge/append (Tier B, target-read por facade); solo pure-replace es Tier A.
            · model-node: col `__source_row_key` explícita (correlación) + brazo.
            Hasta cablearse, un flip de un output de deploy/model lanza fail-loud
            (write-router:189) — SEGURO porque defaultMode=pg.

NOTA: la suite lib/workers/pipelines/dataset-write-strategies tiene 8 fallos PRE-EXISTENTES
en main (projection-sql `::int` casts + behavior downstream), independientes de esta
migración — verificado idéntico en el commit limpio a9e72b4. El source-routing es neutral.

2d.2 · PRIMER CANARY (Tier A replace-pura, hoja del DAG, ≤1M, sin checkpoint transient)
     └─ join → transform-snapshot → split → snapshot_replace-1er-deploy
        (check→flip dataset-scoped→re-deploy→verify checksum→canary smoke; revert ≤10s)

DIFERIDO A KARMA / innecesario ahora (NO construir en 2d.1):
     ├─ write-intent ledger elaborado (estado `open` revivido + unique-index + barredor)
     │    → facade.write ya es open→commit atómico; la metadata PG sigue post-write (manual-table)
     ├─ committedAt preciso + full-rebuild-first del delta nativo (solo replace, sin delta nativo)
     ├─ cursor pg-query-stream optimizado (basta un read simple; Karma reemplaza el compute)
     └─ Tier B merge-preservante (append_new / snapshot_replace preservante) → cuando toque

Clave: el motor de compute es Karma (no llegado); 2d.1 NO lo construye. Solo dirige el write al tridente vía facade.write() (que ya existe). 2d.1a/b son inert-but-ready (verificables con typecheck+seam+vitest sin infra); 2d.2 es el primer flip real (necesita ml-runner+R2+Lakekeeper+canary). La atomicidad la da withNativeControlPlane (snapshot Iceberg all-or-nothing) + el re-deploy idempotente (replace), sin ledger nuevo.


8. Decisiones abiertas (con recomendación)

DecisiónOpcionesRecomendación
Duración de la tx del cursor (PIEZA A)(a) cursor directo (tx viva toda la ingesta) · (b) SELECT→temp ON COMMIT DROP + stream de la temp(b) para outputs grandes (desacopla compute-tx del write externo, no retiene conexión durante R2); (a) para outputs pequeños (más simple). Empezar con (a) en el canary de hojas pequeñas; medir antes de generalizar.
Serialización del ciclo largo (E2)(1) una tx larga (lock xact vivo) · (2) session-lock + finally · (3) unique partial index WHERE status='open'(3) — abarca el ciclo, se libera con el ledger, sobrevive la partición de tx, y el barredor lo desbloquea en crash.
committedAt real (E1)(a) añadir al return de NativeWriteResult · (b) releer de dataset_transactions(a) — un campo, cero roundtrip.
row_index de model-nodecol __source_row_key explícita vs modo "row_index provisto" en ml-runnerCol explícita (__source_row_key) en el stream — no depende de un cambio de ml-runner; model-node NO va en el primer canary.
Alcance de la primera tanda2d.1a solo · 2d.1a+2d.1b · hasta 2d.2 primer canary(a decidir con el usuario) — recomiendo 2d.1a primero (fundamentos + bordes, todo inert, verificable sin infra), luego 2d.1b, luego 2d.2 con infra.

9. Anti-patrones (que este approach evita)

  • ❌ Correr el write nativo dentro del SAVEPOINT del deploy → retiene conexión + lock durante la ingesta a R2.
  • ❌ Confiar en el advisory-lock xact-scoped para el ciclo largo (E2) → se suelta durante el write externo.
  • ❌ Avanzar el watermark antes del COMMIT del ledger, o con buildStart local (E1) → delta saltado.
  • ❌ Estampar row_count desde SELECT count(*) FROM dataset_rows sobre un nativo drenado (E3) → 0.
  • ❌ Flipear un output >1M fuente de otro pipeline antes del compute nativo → hard-fail en el cap 1M.
  • append/merge nativo antes que replace-first → no idempotente tras commit parcial.
  • ❌ Flipear un dataset fuente de checkpoint transient sin el guard (E5) → redirect→vacío silencioso.

Approach implementation-ready, fundado en 5 auditorías (2026-07-04). El motor sigue a los P0 (cerrados) y precede al primer canary (2d.2). Mantener al día conforme cada pieza aterrice.