Published

Sub-approach · I·2a — unificar los cinco cuerpos duplicados del chokepoint

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

Sub-approach · I·2a — unificar los cinco cuerpos duplicados del chokepoint

Documento de ejecución (2026-07-30). El tramo está en index-i2-i3-approach.md §2; esto es el detalle de la fase. G·0 ya está puesto (d95f02b): el trinquete check:dataset-births cuenta 14 nacimientos fuera de la puerta y sólo puede bajar. I·2a retira cinco de ellos.

Lo que el reconocimiento cambió: I·2a deja de ser un refactor y pasa a ser un ARREGLO con evidencia.


0 · La medición que reordena la fase

El approach decía que las tres derivaciones de transformPaths no propagan el tier y lo llamaba «defecto vivo». Medido, es más fuerte que eso: ya ha ocurrido en producción.

  🔴 Transform path (1)   tier=null   ← bronze_clientes   tier=bronze
  🔴 Transform path (2)   tier=null   ← bronze_clientes   tier=bronze

De 15 derivados con source resoluble, 2 perdieron el tier del padre. Y la superficie de exposición no es marginal: hay 12 tablas bronze_ en producción, así que cualquier transform/join/split sobre una de ellas reproduce el fallo hoy.

La regla del medallón se cumple por un camino y se incumple por el otro. medallion-tiers.md dice que derivar limpia o transforma, no cambia la capa; el materialiser lo respeta con inheritedTier([...]) y los cuatro cuerpos copiados no. La causa no es un olvido: es que hay dos cuerpos donde debería haber uno, y uno envejeció sin el otro. Eso es exactamente lo que I·2 viene a impedir, y aquí hay una factura ya pagada que lo demuestra.

(Calibración, por consistencia con cómo se descartó la compactación: aquí la incidencia no es cero. Son 2 filas reales, no un mecanismo posible.)


1 · Lo que el reconocimiento verificó que NO cambia de comportamiento

Unificar significa que cuatro sitios empiezan a insertar columnas que hoy omiten. Antes de tocar nada, se midió si eso mueve algo:

Columna que el chokepoint añadeRiesgo aparenteMedido en la BD vivaVeredicto
status = 'active'los cuerpos copiados no la ponen106 de 106 datasets están active✅ no es un cambio: ya lo es por defecto
schema_idel chokepoint lo pasa explícito, los copiados lo omiten0 datasets sin schema_id — el trigger datasets_derive_schema cubre a todos✅ pasar null es idéntico a omitirlo
tier2 derivados lo perdieron🔴 es el arreglo

Sin esta medición, la fase parecería tres cambios de comportamiento y sólo es uno. Es la diferencia entre un PR que se puede razonar y uno que hay que canariar.


2 · Los cinco sitios, y qué necesita cada uno

SitiosatéliteprocedenciatierQué le falta para llamar al chokepoint
transformPaths.ts:555 transformel tier del source
transformPaths.ts:983 join✅ (los dos lados)el tier de los dos lados — y hoy sólo recibe el izquierdo
transformPaths.ts:1637 splittier + procedencia
runtime.ts:380 transform legacytier + procedencia
checkpoint-ephemeral.ts:159NOnacimiento sin satélite + 3 columnas propias

2.1 · Los dos bloqueantes reales (y son los únicos)

B1 · SourceDatasetRow no lleva tier. La interfaz (transformPaths.ts:105-111) declara id, workspace_id, project_id, file_id, schema, y los tres SELECT que la pueblan (:203, :672, :1262) tampoco lo piden. No se puede heredar lo que no se ha leído. Es una línea en la interfaz y una palabra en tres consultas.

B2 · El join propaga la procedencia de los dos lados pero sólo recibe el row del izquierdo. createJoinOutputDataset(client, tp, leftDs, spec, …) calcula mergeUpstreamProvenance([left, right]) con los ids de tp, pero para el tier hacen falta los rows. Hay que pasarle rightDs, que el llamante ya tiene cargado (:678-679). La asimetría es en sí misma evidencia del drift: la procedencia se actualizó a dos fuentes y la firma se quedó en una.

B3 · checkpoint-ephemeral no encaja en la firma actual — nace con file_id NULL y estampa tres columnas que no son del chokepoint (kind, checkpoint_owner_pipeline_id, checkpoint_deployment_id). Por eso la fase se parte.


3 · La fase se parte en dos, y el corte no es arbitrario

I·2a.1 — los cuatro cuerpos homogéneos.HECHO. Tres de transformPaths + runtime.ts. Todos crean satélite, todos tienen su source a mano. Es donde vive el arreglo del tier. Retiró 4 de los 14.

I·2a.2 — checkpoint-ephemeral.HECHO. Amplió la firma del chokepoint con el nacimiento sin satélite. Retiró 1 más: 10 → 9.

Por qué partirlo y no hacerlo del tirón: a.1 no toca la firma del chokepoint — es un cambio de llamantes, reversible fichero a fichero. a.2 la toca, y una firma nueva es una decisión de diseño que merece su propio diff. Mezclarlos hace que un revert del diseño arrastre el arreglo del tier, que es lo que de verdad importa.

Lo que NO se hace aquí, y hay que decirlo: las tres columnas de checkpoint no se meten en DatasetArtifactInput. Un chokepoint que acepta campos de un solo llamante deja de ser un chokepoint. Se resuelve en a.2 con un UPDATE posterior dentro de la misma transacción, o declarando checkpoint como un kind de primera clase — decisión de §6.

3.1 · Lo que a.2 destapó, y no estaba en el plan: el nacimiento sin satélite OBLIGA a mover el carrier

Al implementarlo aparece una consecuencia que el plan no había enunciado, y es de las buenas:

input.metadata viaja hoy en project_files.metadata. Si no hay satélite, no hay dónde ponerlo. La única columna que queda es datasets.metadata — la que 20261239 creó, el contrato D1 declara CANÓNICA, y el lector ya prefiere desde I·3a.

O sea: I·2a.2 estrena el carrier canónico sin que nadie lo planificara como tal, y llega por donde tenía que llegar — un objeto que nace fuera del árbol de Files. Es la primera mitad de I·3b, entregada por la vía natural en vez de por una migración.

Con una regla explícita, porque es donde el pilar 3 ya tropezó una vez: el carrier va a uno de los dos sitios, nunca a los dos. Duplicarlo reintroduce el patrón de row_count —dos copias del mismo hecho y nadie sabe cuál manda— justo en la columna que I·3b quiere convertir en autoridad.

Y el tipo dejó de mentir. createDatasetArtifact devolvía fileId: string; ahora es string | null, y tsc sacó 7 sitios que asumían lo contrario (runtime.ts + dos harnesses). Se propagó el null en vez de castear: un as string habría escondido exactamente la posibilidad que esta fase introduce.


4 · La guarda, y qué mide cada mitad

AntesDespuésCómo se comprueba
I·2a.1trinquete a 1410npm run check:dataset-births — las entradas de transformPaths/runtime desaparecieron de la allowlist, no bajaron de conteo
I·2a.2109idem, checkpoint-ephemeral fuera

La aserción que prueba que la fase sirvió de algo —y no que el código quedó más bonito:

un transform / join / split sobre bronze_clientes produce un output con tier='bronze'. Hoy produce tier=null, y hay dos filas en producción que lo demuestran.

Y se fija con un GATE, no con un test unitarioscripts/dataspaces/i2a-tier-inheritance-gate.ts. La razón importa: inheritedTier es puro y ya estaba bien; lo que se rompió fue que nadie la llamaba. Un test que ejercite la función no habría visto nada, porque el fallo vivía en la ausencia de la llamada — y el tipo tampoco ayuda, ya que tier es opcional en DatasetArtifactInput y borrar la línea compila. La única guarda que ve el fallo real es medirlo sobre los datos.

El gate lleva BASELINE = 2 (la deuda histórica, que no se corrige hacia atrás) y falla si sube. Verificado que caza: bajado a 1, sale con exit 1.

4.1 · Lo que la iteración midió al hacerse

  • El trinquete bajó 14 → 10, y las dos entradas migradas desaparecieron de la allowlist. Bajar un conteo es ambiguo; que la entrada se vaya no lo es.
  • tsc 0 · 1229/1229 en las tres suites bloqueantes de CI (lib/compute, lib/warehouse, lib/pipelines).
  • El gate confirma los 2 casos históricos y ninguno nuevo. Uno de ellos es un join (bronze_clientes + censo_poblacional), que es justo el que exigía pasar el lado derecho (B2): sin ese cambio, la mitad del arreglo no habría llegado al caso que lo motivó.
  • Una vía de regresión que no estaba en el plan y se cerró: client.query<SourceDatasetRow>(…) es un cast, no una comprobación. Si un SELECT deja de pedir tier, TypeScript no se entera, el campo llega undefined y tierOrNull(undefined) devuelve null — que es un valor legítimo (= sin capa). O sea: la regresión sería exactamente el mismo fallo silencioso que esta fase arregla. Se cierra con SOURCE_DATASET_COLUMNS, una lista única pegada al tipo.

5 · Riesgos, sin adornos

  • transformPaths es camino caliente. El Apply de un pipeline pasa por ahí. Mitigación real: a.1 no cambia ninguna consulta de materialización — sólo sustituye dos INSERT por una llamada que hace exactamente esos dos INSERT, más tres columnas que ya se medió que no cambian nada (§1).
  • Las transacciones. Los cuatro sitios corren dentro de un PoolClient con BEGIN/SAVEPOINT propio. createDatasetArtifact recibe el client, así que hereda la transacción — no abre una nueva. Verificado en la firma; es la misma que ya usan el materialiser y el aterrizaje EDC.
  • No hay ciclo de imports. dataset-output-materialiser no importa transformPaths ni runtime (comprobado). La dependencia va en un solo sentido.
  • Los 2 derivados que ya perdieron el tier NO se corrigen hacia atrás. Igual que en I·3c: sembrar hacia atrás es inventar estado que no se observó. Si el owner los quiere tierados, es un UPDATE deliberado, no una migración silenciosa.

Criterio de parada: si al unificar aparece que algún sitio omite una columna a propósito y documentado —y no por copia envejecida—, se para ahí, ese sitio se queda fuera con su porqué escrito en la allowlist, y el trinquete baja menos. Una puerta con una excepción explicada es mejor que una puerta que rompe un caso.


6 · Decisiones

  1. ¿Las tres columnas de checkpoint entran en la firma del chokepoint? Recomendación: no. UPDATE posterior en la misma transacción en a.2. Un chokepoint con campos de un solo llamante se convierte en un cajón.
  2. ¿Se corrige el tier de los 2 derivados existentes? Recomendación: no automáticamente (§5).
  3. ¿split y runtime empiezan a propagar procedencia en a.1? Recomendación: — es una línea (mergeUpstreamProvenance, que sus hermanos ya llaman), y es la mitad barata de I·3c. Pero se dice en el commit: cambia lo que se escribe, no sólo quién lo escribe.

Cruza con: index-i2-i3-approach.md (el tramo) · warehouse-index.md §5.3 (la asimetría lectura/escritura) · medallion-tiers.md (la regla del tier) · index-handoff.md (el orden).