Approach técnico · I·2 + I·3b — la puerta de escritura de Index y el carrier de procedencia
Documento de ejecución (2026-07-30). El QUÉ está en warehouse-index.md §5.3 y §7; el porqué del ORDEN, en index-handoff.md §7. Esto es el cómo: los call-sites exactos, las fases, la guarda de cada una y lo que mide.
Por qué I·2 e I·3b van juntos y no seguidos. «Quién escribe la gobernanza» y «dónde la escribe» son la misma pregunta. Mover el carrier sin una puerta obliga a tocar cada productor dos veces; poner la puerta sin mover el carrier la deja escribiendo en la columna que se va a retirar.
La ventana, dicha por adelantado. El censo mide 0 filas con procedencia en producción ⇒ este movimiento no necesita backfill. Es la única vez que va a ser gratis. Cada derivación que nazca a partir de hoy lo encarece.
0 · Lo que el reconocimiento corrigió
0.1 · «194 puntos de acceso a datasets» es cierto y es el número equivocado
Medido hoy: 202 from('datasets') en app/+lib/+scripts/. Pero de ésos:
| ~132 | LECTURAS | no le tocan a la puerta de escritura |
| 70 | escrituras (.insert/.update/.upsert) | la mayoría son UPDATE de estadística — y ésos los retira W·1, no I·2 |
| 21 | NACIMIENTOS | ⭐ éste es el número de I·2 |
La puerta no tiene que interceptar 194 sitios. Tiene que interceptar el NACIMIENTO, y son 21 — de los que 7 ya pasan por el chokepoint. El trabajo real son 14.
Encuadrar I·2 como «migrar 194 accesos» la hace parecer inabordable y por eso lleva pendiente. Encuadrarla como «14 nacimientos» la hace un entregable.
0.2 · El chokepoint ya existe, ya está duplicado, y las copias YA han driftado
createDatasetArtifact (dataset-output-materialiser.ts:709) hace las dos inserciones —satélite + fila de datasets— y estampa schema_id y tier. Cinco sitios copian su cuerpo en vez de llamarla, y al copiarlo perdieron campos:
| Nacimiento fuera del chokepoint | file_id | procedencia | tier | schema_id |
|---|---|---|---|---|
transformPaths.ts:555 (transform) | sí | ✅ | ❌ | ❌ |
transformPaths.ts:983 (join) | sí | ✅ | ❌ | ❌ |
transformPaths.ts:1637 (split) | sí | ❌ | ❌ | ❌ |
runtime.ts:380 (transform legacy) | sí | ❌ | ❌ | ❌ |
checkpoint-ephemeral.ts:159 (checkpoint) | NULL | ❌ | ❌ | ❌ |
🔴 Esto no es deuda estética: es un defecto vivo. El materialiser propaga el tier con
inheritedTier([...])porque «derivar limpia o transforma, no cambia la capa». Las tres derivaciones detransformPathsno lo hacen: untransform/join/splitsobre una tablagold_produce un output sin tier. La regla del medallón se cumple por un camino y se incumple por el otro, y la causa es exactamente que hay dos cuerpos donde debería haber uno.(No verificado: si algún paso posterior re-estampa el tier de esos outputs. El grep de
tierentransformPaths.tsda cero.)
Y los otros nueve nacimientos entran por supabase.from('datasets').insert() directo: datasets/ingest:137 · datasets/parse:361 · datasets/route:386 y :435 · notebooks/…/upload:197 · sql-editor/execute:406 · fileBackedDatasetUpload:175 y :434 · dataset-writer:1513.
0.3 · Los nacimientos SIN satélite no son un accidente: son el paradigma nuevo
I·3a cerró el lector para los objetos con file_id NULL, y el censo contó 5 hoy. Pero mirando quién los produce, la cifra va a subir por diseño — el árbol de Files deja de ser el sitio donde nacen las cosas:
| Camino | Nace con file_id |
|---|---|
sql-editor/execute:406 — CREATE VIEW | file_id: null explícito |
datasets/route:390 — creación Warehouse-native | file_id: null explícito |
notebooks/…/upload:201 | file_id: null explícito |
checkpoint-ephemeral:159 | file_id NULL en el SQL |
fileBackedDatasetUpload:182,439 | fileRecord?.id ?? null |
Ése es el argumento definitivo de I·3b, y no es «el contrato D1 lo declara canónico». Es que el carrier de hoy vive en una tabla que los objetos nuevos ya no tienen.
project_files.metadatano es un carrier discutible: es un carrier que se está quedando sin filas.
Y hay un caso que lo enseña entero: una VIEW tiene procedencia trivialmente derivable —las tablas de su propio view_definition— y hoy nace con metadata vacía. gold_order_risk_episodes (2026-07-11) es esa fila.
1 · El estado de partida
| I·3a · el lector | ✅ HECHO (f3e243b) — resolveCarrier(canonical, satellite), canónico gana. Inerte para los 101 satelitados |
| El censo | ✅ scripts/dataspaces/i3-carrier-census.ts — 0/101 con procedencia, 5 sin carrier alcanzable |
| La puerta de LECTURA | ✅ loadItemForConsumption es única y valida tenencia |
| La puerta de ESCRITURA | ⛔ no existe. 21 nacimientos, 7 por el chokepoint |
2 · Las fases
Ordenadas por «lo que reduce superficie sin depender de ninguna decisión» primero. Ninguna requiere la siguiente.
G·0 · El ratchet, ANTES que nada (coste: muy bajo · el que sostiene a los demás)
scripts/check-dataset-births.ts: falla si aparece un INSERT INTO datasets o un from('datasets').insert( fuera de una allowlist explícita, con conteo por fichero para que sólo pueda bajar — exactamente la forma de check-dataset-rows-seam y check-namespace-resolution, y cableado al job guards que ya es bloqueante.
Por qué va primero: sin él, cada nacimiento que se migre abajo puede reaparecer en el PR siguiente y nadie se entera. Es la lección de N·-1, ya pagada una vez.
⚠️ Y la lección de N·0 que hay que no repetir: el ratchet no debe contar prosa. La primera versión del de namespace contaba menciones en JSDoc y daba un falso positivo. Quitar comentarios y descontar la definición. Y verificar que caza, inyectando una regresión real — una guarda que no se ha visto fallar no es una guarda.
I·2a · Unificar los cinco cuerpos duplicados (coste: bajo · riesgo: bajo · reversible)
Los cinco nacimientos de §0.2 pasan a llamar a createDatasetArtifact. No es un refactor cosmético: arregla el tier perdido de las tres derivaciones de transformPaths.
Lo que hay que resolver al hacerlo, y es la única fricción real: checkpoint-ephemeral nace con file_id NULL y createDatasetArtifact siempre crea satélite. Hace falta admitir el nacimiento sin satélite en la firma (parentId: null ya existe; falta satellite: false), que es justo lo que I·3b necesita después. Se hace aquí, no se difiere.
Cómo se sabe que está bien: un output de transform/join/split sobre un source gold_ nace con tier='gold'. Hoy nace sin tier. Es una aserción, no una impresión.
I·2b · La puerta (coste: medio)
createDatasetArtifact se promueve a createGovernedObject y se le da lo que hoy no tiene y la puerta de lectura sí:
- Validación de tenencia — el simétrico de la que
runQueryya hace por tabla (junction.ts, el rechazo'cross-workspace'). - La coordenada obligatoria —
schema_idexplícito o el trigger; nunca «lo que caiga». - El carrier en un solo sitio — es lo que hace posible I·3b sin tocar 14 productores.
Los nueve nacimientos por supabase.insert() se migran de uno en uno, cada uno bajando el conteo del ratchet. dataset-writer:1513 (el polling) es el más caliente: va el último.
⭐ I·3b · Mover la procedencia a datasets.metadata (coste: bajo · la ventana)
Con la puerta puesta, es un cambio en createGovernedObject: la procedencia se escribe en datasets.metadata en vez de en el satélite. El lector ya la prefiere (I·3a).
Sin backfill, y eso está medido, no supuesto: 0 de 101 filas llevan procedencia.
⚠️ Sólo se mueve la PROCEDENCIA. project_files.metadata lleva config viva en las 101 filas —transaction_policy, formatos primario/adicionales, media_sync, schema_label, item_count— y su lector es otro (fileBackedDatasetUpload.ts:548-557). Mover eso es otro entregable y no es éste. Confundirlos convierte una fase barata en una migración.
I·3c · Sembrar la procedencia que hoy se tira (coste: bajo · el que hace visible el pilar)
Con el carrier en su sitio, tres productores pueden empezar a declarar lo que ya saben y hoy descartan:
CREATE VIEW→sourceDatasetIds= las tablas de su propioview_definition. El planificador del SQL Editor ya las extrae (scanTableRefs, de H5). Cero trabajo de análisis nuevo.checkpoint-ephemeral→ deriva de un dataset que lee en la línea de arriba (SELECT project_id, name, schema … FROM datasets WHERE id = $1) y no lo apunta.transformPaths:1637(split) yruntime.ts:380→ ya calculan su source; sólo les falta elmergeUpstreamProvenanceque sus hermanos sí hacen.
Es lo que convierte el 0/101 en un número distinto de cero por primera vez — y el censo lo mide sin tocar nada.
3 · Qué MIDE cada fase (no qué hace)
| Fase | Reversible | Qué mide |
|---|---|---|
| G·0 ratchet | sí | cuántos nacimientos hay fuera de la puerta (hoy: 14) |
| I·2a unificar | sí | si un derivado de un gold_ hereda el tier ← hoy NO por tres caminos |
| I·2b la puerta | sí, por sitio | cuántos nacimientos quedan sin validar tenencia |
| I·3b el carrier | sí (el lector acepta las dos) | nada cambia: es el movimiento inerte que abre I·3c |
| I·3c sembrar | sí | i3-carrier-census deja de decir 0 |
El criterio de parada, dicho por adelantado: si al unificar los cinco cuerpos (I·2a) aparece que alguno diverge por un motivo deliberado y documentado —y no por copia envejecida—, la unificación se para ahí y ese sitio se queda fuera con su porqué escrito. Una puerta con una excepción explicada es mejor que una puerta que rompe un caso.
4 · Lo que NO se toca en este tramo
- Las ~50 escrituras de estadística (
row_count,size_bytes,status). Son W·1, y su autoridad ya la reclama el reconciliador de N·2c. Meterlas aquí mezcla dos planes. project_filescomo árbol de autoría.datasets.project_id/file_idno son legacy (20261249revocó esa etiqueta y la BD viva lo confirma:project_ides NOT NULL). I·3b mueve un bloque de un JSONB, no retira el satélite.- La config file-backed. §I·3b, aviso.
- El CAS y el catálogo. Nada de este tramo los roza.
5 · Decisiones antes de empezar
- ¿
createGovernedObjectvalida tenencia, o sólo registra? Recomendación: valida. Una puerta que no puede negar no es una puerta — es la misma frase que warehouse-index.md §5.2 usa contra las siete piezas declaradas-y-no-aplicadas, y sería irónico repetirla aquí. - ¿Se admite el nacimiento sin satélite en la firma? Recomendación: sí, en I·2a. Ya hay cinco caminos que lo hacen (§0.3) y forzarles satélite sería mover el producto hacia atrás.
- ¿I·3c siembra también los datasets ya existentes? Recomendación: no. Sembrar hacia atrás es inventar linaje que no se observó. Los 28 derivados de hoy nacieron sin procedencia y deben seguir diciendo que no la tienen: un
nullhonesto vale más que un grafo verosímil.
Cruza con: warehouse-index.md (qué es Index · §5.3 la asimetría) · index-handoff.md (el estado y el orden) · index-frontier.md (el diseño de la frontera) · warehouse-scaffolding-retirement.md (el plan W, con el que I·2 NO se solapa).