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 trinquetecheck:dataset-birthscuenta 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ñade | Riesgo aparente | Medido en la BD viva | Veredicto |
|---|---|---|---|
status = 'active' | los cuerpos copiados no la ponen | 106 de 106 datasets están active | ✅ no es un cambio: ya lo es por defecto |
schema_id | el chokepoint lo pasa explícito, los copiados lo omiten | 0 datasets sin schema_id — el trigger datasets_derive_schema cubre a todos | ✅ pasar null es idéntico a omitirlo |
tier | — | 2 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
| Sitio | satélite | procedencia | tier | Qué le falta para llamar al chokepoint |
|---|---|---|---|---|
transformPaths.ts:555 transform | sí | ✅ | ❌ | el tier del source |
transformPaths.ts:983 join | sí | ✅ (los dos lados) | ❌ | el tier de los dos lados — y hoy sólo recibe el izquierdo |
transformPaths.ts:1637 split | sí | ❌ | ❌ | tier + procedencia |
runtime.ts:380 transform legacy | sí | ❌ | ❌ | tier + procedencia |
checkpoint-ephemeral.ts:159 | NO | ❌ | ❌ | nacimiento 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 sí 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.metadataviaja hoy enproject_files.metadata. Si no hay satélite, no hay dónde ponerlo. La única columna que queda esdatasets.metadata— la que20261239creó, 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
| Antes | Después | Cómo se comprueba | |
|---|---|---|---|
| I·2a.1 ✅ | trinquete a 14 | 10 ✅ | npm run check:dataset-births — las entradas de transformPaths/runtime desaparecieron de la allowlist, no bajaron de conteo |
| I·2a.2 ✅ | 10 | 9 ✅ | idem, 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/splitsobrebronze_clientesproduce un output contier='bronze'. Hoy producetier=null, y hay dos filas en producción que lo demuestran.
Y se fija con un GATE, no con un test unitario — scripts/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.
tsc0 · 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 unSELECTdeja de pedirtier, TypeScript no se entera, el campo llegaundefinedytierOrNull(undefined)devuelvenull— 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 conSOURCE_DATASET_COLUMNS, una lista única pegada al tipo.
5 · Riesgos, sin adornos
transformPathses 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 dosINSERTpor una llamada que hace exactamente esos dosINSERT, más tres columnas que ya se medió que no cambian nada (§1).- Las transacciones. Los cuatro sitios corren dentro de un
PoolClientconBEGIN/SAVEPOINTpropio.createDatasetArtifactrecibe elclient, 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-materialiserno importatransformPathsniruntime(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
UPDATEdeliberado, 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
- ¿Las tres columnas de
checkpointentran en la firma del chokepoint? Recomendación: no.UPDATEposterior en la misma transacción en a.2. Un chokepoint con campos de un solo llamante se convierte en un cajón. - ¿Se corrige el tier de los 2 derivados existentes? Recomendación: no automáticamente (§5).
- ¿
splityruntimeempiezan a propagar procedencia en a.1? Recomendación: sí — 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).