Published

La tenencia, DERIVADA — un solo sitio donde dice de quién es

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

La tenencia, DERIVADA — un solo sitio donde dice de quién es

Approach abierto: 2026-08-13. Propone, no describe. Regla de la casa: cada
hecho lleva el comando que lo demuestra
; lo que no lo lleva va marcado como
pendiente (⏳) u opinión.


1 · El problema, en una frase

«De quién es esto» está escrito CINCO veces, en cinco vocabularios, y ninguno
manda sobre los demás.

① tenant_warehouses.warehouse_name    carbon-{env}-w-{hex}
② tenant_warehouses.key_prefix        {env}/w_{hex}
③ el prefijo de la cara               w_{hex}
④ el namespace opaco                  {env}.w_{hex}
⑤ el nombre del warehouse en Lakekeeper

⚠️ Y no es teórico: el 2026-08-13, la primera pasada de I·1 se rompió mandando ② donde la cara esperaba ③.

1·1 · La causa raíz, medida

// lib/storage/tenant-warehouse.ts:47
function envToken(env: NodeJS.ProcessEnv): string { return env.LAKEHOUSE_ENV }

Se almacenan valores DERIVADOS cuya entrada es una propiedad del PROCESO, no del inquilino. Una fila compuesta por un despliegue con LAKEHOUSE_ENV=test queda mal para siempre en cuanto a ese inquilino lo sirve prod. Y como ① y ② se componen en momentos distintos, divergen dentro de la misma fila.

1·2 · El censo (2026-08-13)

8 workspaces · 8 filas en tenant_warehouses · todas `active`

ws          datasets   warehouse_name        key_prefix       dedicated
7b500f4d        123    carbon-PROD-w-…       TEST/w_…         ⛔ false
cccc70a5 · ec14e3cc · 0936227f     0         carbon-PROD-w-…  TEST/w_…   ✅ true
5b3dba28 · cbe70f74 · 17c5cc10 · 1548b5ae  0  carbon-PROD-w-…  PROD/w_…  ✅ true
Hallazgo
⛔⛔El aislamiento está encendido donde no hay nada. Los 7 inquilinos con CERO datasets tienen dedicated_storage: true; el único con 123 lo tiene en false
Cuatro filas se contradicen a sí mismas: el nombre dice prod, el prefijo dice test
El namespace, 6ª codificación, tiene SEIS formas en ese único workspace: main.default · main.test · main.my_first_project · karma.test · prod.w_7b50… · sin estampar

1·3 · Dónde NO está el problema, para no arreglar lo que funciona

  • No en el modelo. project → warehouse → namespace → table es el de Lakekeeper y es isomorfo al realm → catalog → namespace → table de Polaris. Tenemos la forma correcta.
  • No en la aplicación. La credencial la acuña la cara, acotada por tabla y comprobando pertenencia. Ahí vamos por delante del catálogo, que contra R2 no puede.
  • Está en la DERIVACIÓN, que es exactamente el §2 del plano gobernado: lo que no deriva es una declaración.

1·4 · Y por eso cambiar de catálogo no lo arregla

Las cinco codificaciones son nuestras. Migrar a Polaris dejaría las mismas cinco apuntando a otro sustrato, más el trabajo de mover la frontera.


2 · El estándar: la unidad de tenencia es lo que SOSTIENE LA CREDENCIAL

UnidadQué la hace frontera
Unity Catalogel catálogoexternal location + storage credential como securables de primera clase
Apache Polarisel catálogoallowedLocations + vending acotado a ellas
Lakekeeperel warehouseel storage-profile
Snowflakela accountexternal volume

El patrón que comparten los cuatro: UN objeto, y todo lo demás es una
REFERENCIA a él.
Un solo sitio donde cambiarlo, y ninguna forma de que dos fuentes
discrepen. Nosotros tenemos ese objeto y lo usamos como etiqueta.

⭐⭐ Y de ahí sale el principio de diseño de este trabajo:

Ninguna de las cinco codificaciones lleva información propia. Las cinco son
función pura de (workspaceId, env). ⇒ no hay nada que guardar: guardarlas es
lo que permite que se contradigan.


3 · ⛔ El orden: ni limpiar primero, ni construir primero

Limpiar primero es hacer la sincronización manual una vez más, sin trinquete que la sostenga — y se tuerce otra vez mientras se construye. Construir primero sobre filas que se contradicen es taparlas.

Se DERIVA, y la derivación es la limpieza. Es el patrón de F·2·0: planDerivacion
calcula alta y baja, y por eso es una proyección y no un volcado (regla 56).
El censo de morralla no se escribe a mano: lo emite el derivador en sombra.


4 · El plan — con su gate

QuéGate
🏁 D·1La derivación, PURAlib/storage/tenant-coordinates.tsHECHO 2026-08-13 · 14 tests · §8
🏁 D·2El censo, GENERADO por ellaHECHO 2026-08-13 · scripts/storage/d2-censo-de-tenencia.ts · §7·ter
🏁 D·3⚠️ Dónde están los bytesHECHO 2026-08-13 — y la respuesta no era ninguno de los dos (§7·quater)
🏁 D·4La reparaciónHECHO 2026-08-13 — 4 campos · D·2 verifica 8/8 coherentes (§7·quinquies)
🏁 D·5·aUn solo composer — los 5 de lib + las 8 sondas, redirigidosHECHO 2026-08-13 · check:tenencia-derivada0 (§7·octies)
🏁 D·5·benv entra · las derivadas vuelven GENERADASAPLICADO 2026-08-13 (§7·nonies · §7·decies)
🏁 D·6El trinquetecheck:tenencia-derivadaHECHO 2026-08-13 · verde, congelado en 13 sitios (§7·septies)
D·7⛔⛔ Mudar al inquilino que tiene el dato (7b500f4d, 123 datasets, hoy en el compartido)dedicated_storage: true y los bytes bajo su prefijo
D·8El namespace, la 6ª codificación — deja de ser portador de tenencia; la cara ya prefiere el prefijoNadie lee el inquilino del namespace salvo como respaldo declarado

⚠️ La distinción que hace SEGURO el plan

Cambiar key_prefix a una fila que tiene bytes detrás NO es una corrección: es una
mudanza de datos.

7 inquilinos con 0 datasets   →  reparar la cadena es GRATIS (no hay nada debajo)
1 inquilino con 123 datasets  →  es D·7, y es una migración con su ventana

D·4 sólo repara filas sin bytes. Meter las dos cosas en la misma fase es cómo un arreglo de metadatos se convierte en una pérdida de datos.


5 · Las decisiones de diseño, y por qué

5·1 · ⭐ El env deja de vivir dentro de las cadenas

Hoy {env} va incrustado en warehouse_name y key_prefix. Es lo peor de las dos opciones: ni es identidad del inquilino (así que un despliegue distinto lo cambia), ni es configuración (porque queda congelado en la fila).

Dos salidas, y hay que elegir una:

QuéConsecuencia
AEl env es configuración: nunca se almacena, se compone al usarUna fila vale en cualquier despliegue. ⚠️ Exige que LAKEHOUSE_ENV sea correcto en todos los procesos — hoy no lo es (I·1 midió que Carbon Jobs corre código de hace 4 días)
BEl env es identidad del inquilino: una columna propia, autoritativaExplícito y auditable; a cambio, un inquilino queda atado a su entorno para siempre

Propuesta: A, con el matiz que lo hace seguro — el derivador falla en voz alta si LAKEHOUSE_ENV falta, como ya hace envToken. Un prefijo compuesto con el entorno equivocado es peor que no componerlo.

5·2 · Qué se queda en Index, y por qué

Se aplica el invariante del pilar 3 — «guarda lo que el catálogo no puede saber»:

SE QUEDA          warehouse_id     ← lo asigna Lakekeeper: no es derivable
                  dedicated_storage · bucket · jurisdiction · state
SE VA (derivado)  warehouse_name · key_prefix

5·3 · Lo que NO se toca

  • El paradigma de nombres (ds_<hex>, nombre ≠ ubicación, resolución en la cara). Está cerrado, con trinquete, y es lo que ningún catálogo da nativo.
  • El vending en la puerta. Es donde vamos por delante del estándar.

6 · Riesgos

  1. D·7 mueve datos de un cliente. Necesita ventana, verificación de bytes y vuelta atrás. No es una fase de metadatos.
  2. ⚠️ LAKEHOUSE_ENV tiene que ser correcto en todos los procesos si se elige A. Medido hoy: el worker de ingesta corre código de hace cuatro días, así que la uniformidad del entorno no se puede suponer — hay que comprobarla.
  3. ⚠️ Retirar columnas rompe a quien las lea. D·5 va después de D·4, no antes.
  4. 🟡 Esto no arregla la política ni el aplicador. Es la frontera del inquilino, y sólo eso.

7·bis · 🏁 D·1 — HECHO (2026-08-13)

lib/storage/tenant-coordinates.ts · 14 tests puros.

La decisión del §5·1, contestada por el estándar: B

La convención de la industria dice las dos cosas a la vez, y juntas deciden:

  • el entorno va en el nombre del recurso (tipo · workload · entorno · región);
  • pero «un nombre debe estar formado sólo por propiedades INMUTABLES» — lo que puede cambiar (ubicación, tier) no va en el nombre, porque los nombres no se renombran.

⇒ Para un prefijo con bytes dentro, el entorno es inmutable: no se cambia, se muda. Luego pertenece al nombre, y por tanto tiene que ser un dato del inquilino, no algo que cada proceso recalcule.

Y eso descarta la opción A, que era la propuesta inicial: derivar el env de process.env permite que dos procesos compongan nombres distintos para el mismo recurso físico — que es literalmente el fallo medido. LAKEHOUSE_ENV queda sólo como defecto al CREAR un inquilino nuevo; nunca como entrada de la derivación.

Lo que el módulo fija

coordenadasDeInquilino(workspaceId, env){
  warehouseName    carbon-{env}-w-{hex}
  keyPrefix        {env}/w_{hex}
  facePrefix       w_{hex}            ← sin entorno: la cara ya sabe en cuál está
  opaqueNamespace  {env}.w_{hex}
}

Puro: sin I/O, sin process.env, sin fechas. Y el control negativo que lo sostiene: con LAKEHOUSE_ENV puesto y env vacío, LANZA en vez de usarlo — ese respaldo es el bug.

Además lleva lectores inversos y divergencias(), que no es simetría por gusto: permite decir en qué difiere, y otro-entorno vs otro-inquilino piden reparaciones opuestas (una puede ser una mudanza; la otra es integridad).

⭐ Verificado contra el dato real (sólo lectura)

Se probaron las dos hipótesis de entorno por fila, en vez de suponer una:

ws          datasets   hip. `prod`   hip. `test`   veredicto
7b500f4d        123      1 div.        1 div.      ⛔ NINGUNA la explica
17c5cc10 · 1548b5ae · 5b3dba28 · cbe70f74   0 div.  2 div.   ✅ coherente en prod
cccc70a5 · ec14e3cc · 0936227f              1 div.  1 div.   ⛔ NINGUNA la explica

4 filas coherentes · 4 que NINGUNA hipótesis explica

El derivador reproduce, generada, la contradicción que se había encontrado a mano — que es lo que lo convierte en oráculo y no en otra opinión.

⭐⭐ Y afina D·3: son exactamente 4 filas, y una de ellas es la del inquilino con los 123 datasets ⇒ su reparación no es de metadatos, es la mudanza de D·7.


7·ter · 🏁 D·2 — EL CENSO, GENERADO (2026-08-13)

scripts/storage/d2-censo-de-tenencia.ts — sólo lee. El inventario de la limpieza lo emite el derivador: si lo redactara una persona sería una sexta copia del mismo hecho, que es justo el problema que este trabajo cierra.

insumo: 8 filas de tenencia · 123 datasets · 13 warehouses en Lakekeeper
hipótesis de entorno probadas: prod · test · dev

⭐⭐⭐ El hallazgo: la fila no se contradice — apunta a DOS warehouses REALES

El censo mira también al revés, y ahí aparece lo que explica todo:

warehouses del sustrato que NINGUNA fila de Index reclama:
   carbon-test-w-cccc70a5…      (sería de cccc70a5, env 'test')
   carbon-test-w-7b500f4d…      (sería de 7b500f4d, env 'test')
   carbon-test-w-ec14e3cc…      (sería de ec14e3cc, env 'test')
   carbon-test-w-0936227f…      (sería de 0936227f, env 'test')

Cuatro, y son exactamente los cuatro inquilinos cuya fila no cuadraba.

⛔ Cada uno de esos inquilinos tiene DOS warehouses en Lakekeeper…-prod-… y
…-test-…— y su fila de Index se quedó con un campo de cada uno:
warehouse_name nombra al de prod, key_prefix ubica al de test.

No es una cadena rancia: es un aprovisionamiento duplicado. «Reparar la
cadena» habría apuntado la fila a un warehouse sin comprobar en cuál están los
bytes
— y eso no es una corrección, es perder datos de vista.

⚠️ Y por eso el censo no toca esos cuatro: pueden tener bytes, y este censo no mira dentro del bucket.

Lo demás que salió

① está corroborado por el sustrato: Lakekeeper conoce los 4 carbon-prod-w-… que Index nombra. La divergencia está aislada en ②
④: 7 de 123 datasets llevan el namespace de su inquilino. Los otros 116 (main.default 84 · sin estampar 20 · main.test 9 · karma.test 2 · main.my_first_project 1) son todos mudanza — el namespace es parte de la ubicación física
1 de las 4 divergencias es MUDANZA (la de 7b500f4d, con 123 datasets); 3 son reparables sin tocar bytes
③ no se censa aquí y se dice: no tiene copia almacenada, se computa ⇒ su superficie es código, y la cubre el trinquete de D·6

Lo que esto le hace a D·3

Deja de ser «¿cuál era el entorno de esta fila?» y pasa a ser una pregunta con respuesta física:

¿En cuál de los dos warehouses —test/w_… o prod/w_…— están los bytes?

Y trae una decisión que no estaba en el plan: qué hacer con el warehouse que sobre. Dejarlo es deriva; soltarlo sin mirar dentro es pérdida.


7·quater · 🏁 D·3 — LOS BYTES NO ESTÁN EN NINGUNO DE LOS DOS (2026-08-13)

scripts/storage/d3-donde-estan-los-bytes.ts — sólo lee.

⭐⭐⭐ La respuesta autoritativa: se la da el catálogo, no el listado

7b500f4d (123 datasets) → s3://lakehouse/warehouse/019f1a5f-…/metadata/…

warehouse/ — una TERCERA raíz, el espacio del warehouse compartido, indexado por el uuid que Lakekeeper asigna. Ni test/w_…, ni prod/w_….

key_prefix = test/w_7b50… no está equivocado sólo en el entorno: está
equivocado entero.
Los 17 objetos que sí hay bajo ese prefijo (26 KB, 3 parquet,
del 2026-08-09) no son los datos del inquilino: son el residuo del ensayo de
aislamiento. Los 123 datasets nunca se movieron de ahí.

Y por eso el plan miraba el mapa del bucket antes de comparar dos prefijos. Comparar sólo test/ y prod/ habría concluido «hay bytes en test» sobre 17 ficheros de prueba, y habría apuntado el control-plane a ellos.

El mapa del bucket, que cierra el caso

raíces: _probe-gravitino/ · _probe-polaris/ · edc-test/ · test/ · warehouse/

prod/ NO EXISTE. Las cuatro filas que dicen prod/w_… apuntan a una raíz que nunca se creó — y dedicated_storage: true en siete inquilinos es, literalmente, una afirmación sin nada detrás: ninguno tiene un solo byte.

Y un huérfano en la otra dirección

cccc70a5 · 0 datasets en Index · 51 objetos y 10 parquet bajo test/w_cccc70a5… (2026-08-07)

Bytes sin fila. Es el reverso exacto de la fila sin bytes que dejó T·1: las dos mitades de la misma ausencia de reconciliación.

⭐⭐ La consecuencia de diseño, que no estaba en el plan

key_prefix sólo significa algo cuando dedicated_storage es cierto. Para un
inquilino en el compartido no tiene valor válido — y guardar un valor sin
significado es exactamente cómo derivó.

⇒ D·4 deja de ser «corregir cuatro cadenas» y pasa a ser: el prefijo se deriva sólo para quien tiene espacio propio, y para el resto no se guarda. La validez de un campo condicionada a otro campo, y nada lo sostenía.

Lo que esto hace FÁCIL

La mudanza que temíamos no existe: como los 123 datasets no están bajo ninguno de los dos prefijos, arreglar la fila no mueve un solo byte. Lo que queda es:

8 de 8 filas se pueden reparar sin tocar datos
🟡Decidir qué se hace con los dos residuos de test/ (17 y 51 objetos)
D·7 sigue en pie y crece: mudar al inquilino con dato es de verdad una migración desde warehouse/<uuid>/, no un cambio de prefijo

7·quinquies · 🏁 D·4 — REPARADO (2026-08-13)

scripts/storage/d4-reparar-tenencia.ts · ensayo por defecto, D4_APPLY=1 para escribir.

8 filas · 4 ya coherentes · 4 reparadas — TODAS el mismo campo:
   key_prefix:  test/w_<hex>  →  prod/w_<hex>

Ni un byte tocado, y no por prudencia: D·3 probó que nada usa esos prefijos —los 123 datasets viven en warehouse/<uuid>/— así que cambiarlos no mueve nada.

Lo que NO se tocó, y por qué

dedicated_storageSi un inquilino debe tener espacio propio es una decisión de producto (D·7), no una divergencia. Repararlo aquí habría sido colar una decisión dentro de un arreglo
⛔ los bytesBorrar es otra operación, con otra confirmación

El gate, y por qué no vale el auto-chequeo

D·4 relee y vuelve a derivar tras escribir —un script que informa de lo que INTENTÓ escribir no prueba nada—, pero el gate es D·2, que es otro camino y otro proceso:

✅ 8/8 coherentes en 'prod'

⚠️ D·2 sigue saliendo en rojo, y es correcto: ④ mantiene sus 116 datasets con el namespace de otro. Eso es D·8, y que el censo no se ponga verde antes de tiempo es exactamente lo que se le pide.

⚠️ Lo que queda vivo, listado para que no sea deriva

· 116 datasets con el namespace de otro inquilino   → D·8

(El residuo bajo test/w_… y los warehouses carbon-test-w-… los purgó D·4·b, §7·sexies.)


7·sexies · 🏁 D·4·b — LA PURGA DEL RESIDUO, Y UN ERROR MÍO QUE VALE MÁS QUE ELLA

scripts/storage/d4b-purgar-residuo.ts · ensayo por defecto, D4B_APPLY=1.

La regla que hace imposible el borrado ancho

El purgador DERIVA sus propios objetivos y no se fía de ninguna cadena
almacenada.
Los prefijos y los nombres salen de coordenadasDeInquilino sobre
los workspaces que existen — nunca de tenant_warehouses.key_prefix, que es
justo el campo que estaba mintiendo.

Y encima comprueba la FORMA antes de cada borrado: lo que no encaja exactamente en test/w_<32 hex>/ o carbon-test-w-<32 hex> se salta y se dice.

Resultado

A · bytes       ✅ 68 objetos borrados (51 + 17) · la raíz `test/` ya no existe
B · warehouses  🟡 2 de 4 retirados · los otros 2 esperan la purga de 7 días
dato real       ✅ INTACTO — s3://lakehouse/warehouse/019f1a5f-… sin cambios

⛔⛔ El error: fabriqué la deriva que este trabajo persigue

La v1 borraba primero los warehouses y después los bytes, en dos bucles independientes. Dos warehouses rechazaron el borrado (409 WarehouseNotEmpty) y el segundo bucle borró sus bytes igualmente: catorce tablas del catálogo quedaron apuntando a ficheros que ya no existían.

⭐⭐ La regla que faltaba: una operación irreversible no se ejecuta si la
reversible que debía precederla ha FALLADO.
Intentar primero lo que puede negarse
no basta — hay que pararse cuando se niega. Ahora los bytes de un inquilino
sólo se borran si su warehouse se retiró del catálogo.

Reparado: se soltaron las 14 tablas huérfanas. Y el arreglo destapó dos cosas más:

  1. ⚠️ GET /namespaces devuelve sólo el primer nivel. Lakekeeper decía «no vacío» con los tres namespaces de arriba a cero tablas: estaban en main.default y main.test. Un listado que no recorre no dice "vacío": dice "no miré".
  2. WarehouseHasUnfinishedTasks es la ventana de soft-delete de 7 días, no un fallo. Una tabla soltada deja una tarea de purga pendiente, y hasta que corra el warehouse no se retira. Es el diseño funcionando — y explica por qué B queda a medias por calendario, no por error.

7·septies · 🏁 D·6 — EL TRINQUETE (2026-08-13)

npm run check:tenencia-derivada

⭐ Es un TRINQUETE, no una prohibición — y la diferencia es que se respeta

Hoy hay composición a mano en sitios reales. Exigir que desaparezca toda de golpe haría que el check naciera rojo, es decir ignorado. Lo que hace en su lugar: congela la superficie para que no crezca, y deja la lista a la vista para que D·5 la vacíe.

⛔ falla si   un fichero fuera de la deuda compone una de las formas
⛔ falla si   un fichero de la deuda compone MÁS veces que las registradas
✅ nunca      por componer MENOS — eso es progreso, y lo dice para bajar el número

⭐⭐ Lo que destapó al escribirlo: son CINCO composers, uno por codificación

lib/storage/tenant-warehouse.ts     2   warehouseName + keyPrefix
lib/storage/tenant-bucket.ts        1   warehouseName (el bucket por inquilino)
lib/lakehouse/opaque-namespace.ts   1   el segmento del namespace opaco
lib/governance/catalog-tenant.ts    1   el prefijo de la cara

Cinco codificaciones, cinco composers independientes, cada uno con su propia lectura del entorno. Ésa es la foto del mecanismo — el §1 lo describía; esto lo cuenta. Y dos de los cinco (tenant-bucket, catalog-tenant) no estaban en el censo original: aparecieron sólo al buscarlos con una herramienta.

Más 9 en scripts/ — sondas y gates. Cuentan igual: una sonda que compone su propia coordenada puede medir la equivocada.

El control negativo

Un trinquete verde sin control negativo es indistinguible de uno roto. Se probó metiendo un fichero que compone w_${ws.replace(/-/g,'')}:

⛔ NUEVOS — scripts/__tmp-control-negativo.ts:1  [facePrefix]
⛔ ROJO — la superficie de composición a mano ha CRECIDO.

Dos decisiones de precisión, dichas

  • Los tests se saltan. Un test que afirma carbon-prod-w-${HEX} está pineando la forma, que es lo contrario de dejarla derivar. Meterlos llenaría la deuda de ruido y escondería los sitios que sí importan.
  • La forma del prefijo de cara exige que la interpolación huela a workspace (replace( de guiones, o un nombre tipo hex/bare/ws). Sin eso marcaría w_${Date.now()} de un id de widget — y un trinquete con falsos positivos se desactiva a la semana.

7·octies · 🏁 D·5·a — UN SOLO COMPOSER (2026-08-13)

check:tenencia-derivada →  0 sitios en 0 ficheros   ✅

Trece sitios redirigidos: los 5 de lib y las 8 sondas. La deuda del trinquete queda vacía, y eso cambia lo que garantiza:

Con entradas, el trinquete sólo impedía que la deriva creciera. Con la lista a
cero, cualquier aparición es una violación.

La decomposición que lo hizo seguro

Cada composer legacy conserva su validación y su error propio —los llamantes cazan TenantWarehouseError, TenantBucketError, OpaqueNamespaceError— y delega sólo la forma:

export function tenantWarehouseName(workspaceId, env = process.env) {
  return coordenadasDeInquilino(hex(workspaceId), envToken(env)).warehouseName;
}

De dónde sale el entorno y cómo se da forma a la cadena son dos cosas
distintas.
D·5 unifica la segunda; la primera ya la había resuelto D·1 al negarse
a leer process.env. Fundirlas habría cambiado los tipos de error de cinco
módulos, que es una migración distinta disfrazada de refactor.

prefijoDeCara() — la consolidación que un comentario ya pedía

Dos sitios necesitaban w_<hex> sin saber el entorno: el prefijo de la cara y el segmento del namespace opaco. catalog-tenant.ts ya decía que su forma era la misma «a propósito: un solo vocabulario para el inquilino». Ahora también es el mismo código, que es lo que impide que dejen de serlo.

(Se exportó aparte en vez de exigir un env que no interviene: un parámetro que no se usa acaba usándose mal.)

Verde

262 tests (20 ficheros) en lib/storage · lib/lakehouse · lib/governance   ✅
suite amplia: 61/63 ficheros — los 2 rojos son los 4 fallos PREEXISTENTES de
              read-client / namespace-resolution (sustrato-07 §7), verificados
              por stash el 08-13
tsc: 26 errores, TODOS de la clase preexistente «scripts sin import comparten
     scope global y colisionan en main()». Ninguno en lo tocado.

⚠️ Y una nota honesta sobre ese 26: antes eran 25. El delta está en g2-gravitino-federacion-gate.ts, un fichero que este trabajo no toca; su error es de la misma clase, y aparece porque al cambiar el conjunto de ficheros TS reasigna a cuál de los colisionantes se lo achaca. Se dice en vez de dejar el número moverse sin explicación.


7·nonies · 🟡 D·5·b — ESCRITA Y SIN APLICAR (2026-08-13)

supabase/migrations/20261278_tenencia_env_derivada.sql + el cambio en lib/storage/tenant-warehouse.ts.

⭐ El hallazgo que define la migración

Si se sueltan las columnas hoy, se pierde el entorno del inquilino — porque hoy sólo existe incrustado dentro de ellas. No hay columna env.

⇒ La migración añade env primero. No es un desvío: es la decisión B del §5·1 —el entorno es una propiedad inmutable del inquilino— finalmente persistida. D·1 la fijó en el contrato del derivador; nada la había escrito en el dato.

Las dependencias, medidas antes de escribir el DROP

scripts/storage/d5-dependencias-de-columna.ts:

VISTAS que las mencionan .......... 0
FUNCIONES/RPC .................... 0
CONSTRAINT que cuelga ............ tenant_warehouses_key_prefix_key UNIQUE (key_prefix)
las 8 filas: name→env y prefix→env COINCIDEN en 'prod'   ⇒ backfill inequívoco

Y esa coincidencia es obra de D·4. Antes de repararlo, cuatro filas declaraban entornos distintos por los dos caminos, y el backfill habría sido una elección disfrazada de migración. Por eso la migración aborta si detecta discrepancia en vez de elegir: cuál tiene razón no lo decide un UPDATE, lo decide mirar dónde están los bytes (D·3).

⛔ Por qué NO se aplica todavía

loadTenantWarehouse selecciona las dos columnas por nombre. Soltarlas con la versión anterior desplegada rompe la ingesta y el SQL Editor en el acto — y el despliegue va por detrás (Carbon Jobs desde main, 142 commits atrás).

Esta migración viaja CON su despliegue, no antes. Es el mismo bloqueo de
release que I·1·b, y por el mismo motivo.

Dos decisiones, dichas

  • env sin DEFAULT, a propósito. Un DEFAULT 'prod' dejaría pasar altas que no declaran su entorno — el fallo silencioso que este trabajo cierra. Se falla ruidoso; el código lo pasa explícitamente, y LAKEHOUSE_ENV interviene sólo al crear.
  • El UNIQUE (key_prefix) cae con la columna, y no se pierde nada: su unicidad la garantiza ahora la PK de workspace_id, porque el prefijo es función de ella — dos inquilinos no pueden derivar el mismo.

⭐⭐ Y una consecuencia que cierra el círculo

D·5·b retira a D·2·①② y a D·4. El censo de divergencia y su reparador existen para comparar dos copias de un mismo hecho; sin columnas que guardar, un valor derivado no puede discrepar de sí mismo. Lo que sobrevive de D·2 es ④ (los namespaces) y ⑤ (el sustrato), que sí comparan contra otra cosa.

Lo que falta para cerrarla

· los lectores en scripts/ deben pasar por la puerta (`loadTenantWarehouse`)
  en vez de seleccionar columnas: n2 · p5-register-table-probe ·
  p3-born-in-tenant-gate · d2-censo · d4-reparar
· retirar `n3a-token-de-entorno.ts` y `n3a-reconciliar-token.ts` — existen para
  reconciliar el token INCRUSTADO en el nombre, y con `env` como columna se
  quedan sin motivo
· `workspaceFromKeyPrefix` — comprobar si le queda algún llamante

7·decies · ⛔→🏁 EL INCIDENTE, Y LA SALIDA QUE MEJORÓ EL DESTINO (2026-08-13)

20261278 se aplicó antes de desplegar su código. La versión desplegada selecciona las columnas por nombre, así que la cara empezó a devolver 502 en toda operación gobernada:

502 · "la cara no puede hablar con el catálogo:
       tenant_warehouses: column tenant_warehouses.warehouse_name does not exist"

⭐⭐ Se contrajo el esquema antes de migrar el código. El orden es
expand → migrar el código → contract, y aquí se saltó el paso de en medio.
La migración lo llevaba escrito en su cabecera —«no se aplica antes que el
código»
— y eso no impidió nada: una advertencia en un comentario no es un
gate (§12·10 del write-path).

Por qué no se arregló desplegando

HEAD va 142 commits por delante de lo desplegado. Desplegarlo no habría sido un hotfix: habría sido el release entero, y con producción caída no es el momento de estrenar 142 commits.

⭐⭐⭐ La salida: las columnas vuelven, pero GENERADAS

20261279 las devuelve como GENERATED ALWAYS ... STORED sobre (workspace_id, env):

warehouse_name  GENERATED ALWAYS AS ('carbon-' || env || '-w-' || replace(workspace_id::text,'-','')) STORED
key_prefix      GENERATED ALWAYS AS (env || '/w_' || replace(workspace_id::text,'-','')) STORED
El código viejo las lee y funciona, sin cambiar una línea
⭐⭐Nadie puede escribirlas. Control negativo: column "warehouse_name" can only be updated to DEFAULT
env sigue siendo la única fuente

No es un rollback: es el mismo destino, alcanzado sin romper a quien todavía no
se ha enterado.
Y llega más lejos que el plan original — donde éste se apoyaba en
un trinquete de CI para que nadie compusiera a mano, ahora Postgres lo impide por
construcción
. La deriva deja de ser improbable y pasa a ser imposible.

Restaurado y verificado: loadTable y listTables por la cara vuelven a 200, idénticos a antes de la migración.

Lo que queda de la contracción

Las dos columnas se sueltan de verdad con el release que lleve el código que ya no las mira. Mientras tanto son inertes y no pueden mentir, así que la urgencia desapareció.


8 · La frase que resume

Tenemos la forma correcta de la industria y la usamos como etiqueta. El arreglo
no es cambiar de catálogo ni de modelo: es que las cinco codificaciones dejen de
escribirse en paralelo y pasen a derivar de una. Y el orden no es limpiar-y-luego-
construir: la derivación, en sombra, ES el inventario de la limpieza.