gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

⎇ 97ceb7241181ba5e2b586b6bbc1c490c334422f4 docs/adr/0014-distribucion-multiorigen.md Markdown · 203 lines · 12.8 KB History raw

takana etapa 5b: los 59 docs de diseño, runbooks y ADR 97ceb7241 · Sergio ·

1# ADR 0014 — Distribución multi-origen: el origen no necesita confianza
2
3- **Estado:** ACEPTADO — implementado en `crates/hammer-build/src/fetch.rs`,
4  `crates/hammer-core/src/repo.rs` y `crates/hammer-cli/src/main.rs` (`RepoSource`).
5- **Fecha:** 2026-09-01
6- **Frontera:** `bases_de_mirror` / `rotar_bases`, `PackageEntry::digest`, `repo::verify_digest`,
7  `RepoSource::{parse,read_index,materialize}`.
8- **Continúa:** [ADR 0013](0013-mirror-de-fuentes.md) (mirror de fuentes) y
9  [SDD 19 §3](../19-lanzamiento-publico.md) (infraestructura pública).
10
11## Contexto
12
13**Todavía no hay usuarios.** Ése es exactamente el momento de decidir esto: el protocolo de
14distribución toca el formato del índice y el comportamiento de clientes ya instalados en máquinas
15ajenas. Cambiarlo después de la primera descarga pública significa romper a alguien o arrastrar dos
16formatos para siempre; cambiarlo hoy no cuesta nada.
17
18La pregunta que lo dispara es la correcta: *no quiero apoyarme en un solo servidorcito.* Y hoy nos
19apoyamos exactamente en eso, en tres capas a la vez:
20
21| capa | qué sirve | orígenes soportados **antes** de este ADR |
22|---|---|---|
23| fuentes tarball | `{sha256}.tar` | **1** (`TAKANA_MIRROR`) |
24| fuentes git | `{commit}.bundle` | **1** (`TAKANA_MIRROR_GIT`) |
25| repo de paquetes | `index.json` + `.swm` | **1** (`--repo <URL>`) |
26
27El ADR 0013 existe precisamente para no depender de 79 servidores ajenos — y lo resolvió creando una
28dependencia de **uno nuestro**. Es el mismo punto único de fallo con nuestro nombre encima.
29
30### La medida: qué habría que servir, y cuánto pesa
31
32| qué | tamaño | naturaleza |
33|---|---:|---|
34| tarballs de fuentes (`work/tarballs`, 393 objetos) | 2,9 G | inmutable, direccionado por `sha256` |
35| bundles git de fuentes (571 commits) | ~2,3 G | inmutable, direccionado por `commit` |
36| artefactos / paquetes | 36 G en el hub; **~51 G** el espejo publicable tras el split de debug ([SDD 23](../23-plan-rehasheo.md)) | inmutable, direccionado por BLAKE3 |
37| imágenes de instalación | unos pocos G por release | inmutable |
38| **índice firmado del repo** | **KB** | **mutable, y lo único que debe ser fresco y auténtico** |
39
40Es decir: **el 99,99% de los bytes es inmutable y verificable por contenido, y el 0,01% restante es
41lo único que necesita confianza.** Esa asimetría es la decisión entera.
42
43## Decisión
44
45### 1. La partición: raíz chica y firmada, cuerpo grande y sin confianza
46
47- **Raíz** — el `index.json` firmado. Debe ser fresca y auténtica. Es de kilobytes, así que puede
48  vivir en sitios que no escalan pero sí se controlan: el propio git (que ya se espeja a gitea **y**
49  a GitHub, ver `scripts/espejo-setup.sh`), o cualquier host.
50- **Cuerpo** — tarballs, bundles, `.swm`, artefactos, imágenes. Decenas de gigas, y **cero
51  necesidad de confianza**: el nombre del objeto ES su verificación, o su hash está anclado en la
52  raíz firmada.
53
54⇒ El cuerpo puede servirse desde **cualquier host, incluso uno hostil**. Eso convierte «conseguir
55espejos» de un problema de infraestructura y de contratos a un problema de a quién pedírselo.
56
57### 2. Todo camino de descarga acepta una LISTA de orígenes, no uno
58
59`TAKANA_MIRROR` y `TAKANA_MIRROR_GIT` pasan a ser listas separadas por comas. `--repo` acepta
60`https://a,https://b`. Añadir orígenes **no re-hashea nada**: la URL nunca estuvo en `hash_inputs`
61(ADR 0013 §1), y para el repo el `digest` no es un input de build sino un campo del índice.
62
63### 3. Las fuentes ROTAN el orden; el repo lo respeta
64
65Distinto a propósito, y la razón importa:
66
67- **Fuentes** — es la granja moliendo el corpus sin nadie mirando, cientos de objetos por tanda. Si
68  se probara siempre en orden, el segundo origen no se tocaría jamás hasta la emergencia: sería *un
69  espejo que nadie prueba*, la lección literal del ADR 0013 §2 un nivel más arriba. Se rota con una
70  semilla **determinista tomada del propio hash del objeto**: el mismo objeto sale siempre del mismo
71  origen (un fallo se reproduce y se depura), pero objetos distintos se reparten entre todos, así
72  que **una tanda del corpus ejercita la lista entera**. Tras el elegido se recorren los demás.
73- **Repo** — es una persona instalando un paquete. El orden es una preferencia suya (el espejo más
74  cercano primero) y la previsibilidad vale más que el reparto; además son un puñado de ficheros, no
75  una tanda. Se prueban en el orden que escribió.
76
77### 4. Ausencia ⇒ seguir. Contenido distinto ⇒ ABORTAR
78
79La distinción que hace que una lista de orígenes sea una mejora y no un agujero:
80
81- Un origen que **no tiene** el objeto (404, host caído, DNS) es normal: se pasa al siguiente en
82  silencio. Un espejo puede ir por detrás.
83- Un origen que devuelve **otro contenido** del que la raíz firmada declara es manipulación o
84  corrupción. **Se aborta ahí mismo**, no se cae al siguiente origen.
85
86Caer al siguiente sería la tentación natural («total, del bueno lo bajo igual») y es justo el patrón
87que este repo ya pagó caro: *un ausente falla ruidosamente; un vacío llega hasta el final diciendo
88que todo fue bien* (CLAUDE.md §3). El paquete acabaría instalado desde el espejo bueno y **nadie se
89enteraría nunca de que uno de los espejos miente**.
90
91### 5. El índice firmado ancla el `digest` de cada `.swm` — el hueco que había
92
93Antes de este ADR la cadena estaba **rota en el último eslabón**, y no era evidente. La firma del
94índice cubre la lista de entradas: nombre, versión, `file`, deps. Pero `file` es una **ruta, no un
95contenido**: quien sirviera los bytes podía devolver otro `.swm` bajo el mismo nombre y la firma del
96índice seguía casando perfectamente.
97
98Había red aguas abajo — `apply` reconstruye desde fuente y compara contra `expected_hash` — pero no
99alcanza: `expected_hash` es **opcional**, y el `.swm` se lee mucho antes de llegar ahí (el gate de
100colisiones de ficheros de `install` ya decide con su contenido). El comentario de `download.rs`
101decía que el `patch_url` «se cubre indirectamente»; *indirectamente* no es una cadena de confianza.
102
103Se cierra con `PackageEntry::digest` (BLAKE3, `b3:…`), sellado al publicar y verificado antes de
104escribir un byte. La cadena queda: **clave raíz → firma del índice → `digest` → bytes**.
105
106> **CORRECCIÓN (mismo día).** Este párrafo decía que el `digest` usaba «la misma función que sella
107> artefactos», o sea `of_inputs(&[bytes])` — length-prefijado. **Estaba mal, y la razón importa.**
108> Con una sola entrada el length-prefijado no desambigua ninguna concatenación: sólo hace que
109> `b3:<hex>` **deje de ser el hash que obtiene un tercero** con `b3sum` sobre el mismo archivo.
110>
111> Lo destapó escribir la contraparte de este ADR para churay (`02_ruway/churay/SDD-DISTRIBUCION.md`
112> §5 en tawasuyu): churay direcciona sus blobs con `blake3(bytes)` pelado y takana iba a hacerlo
113> length-prefijado, **bajo el mismo prefijo `b3:`** ⇒ el mismo archivo con dos hex distintos, y un
114> CAS compartido que no falla ruidosamente sino que busca nombres distintos para los mismos bytes.
115>
116> Ahora usa `ArtifactHash::of_bytes` — que **ya existía en takana** y ya era blake3 pelado; es la
117> convención de `of_file` y la del `expected_hash` de un `.swm`, así que `of_inputs` era además la
118> pieza fuera de sitio *dentro* del propio repo. Coste del cambio: una línea, porque ningún índice
119> publicado llevaba todavía el campo. Es el argumento de este ADR aplicado a sí mismo — decidir
120> antes de que haya usuarios. Hay guardián: `digest_es_blake3_pelado_y_no_length_prefijado`.
121
122El campo es `Option` con `skip_serializing_if`, así que **un índice firmado antes de este cambio
123serializa idéntico y su firma sigue siendo válida** (hay test). Un índice sin digests no bloquea,
124pero **dice cuántos paquetes no verificó**: un índice viejo servido desde un espejo ajeno no es
125seguro, y el silencio lo haría parecer verificado.
126
127### 6. Cloudflare R2 como primer origen público — instancia, no arquitectura
128
129El criterio no es el precio del almacenamiento (51 G no cuestan nada en ningún sitio): es el
130**egress**, que es lo que quiebra a los proyectos que reparten binarios. R2 no cobra salida.
131
132Órdenes de magnitud, **a verificar el día de contratar** — no son cotizaciones:
133
134| origen | almacenamiento | egress |
135|---|---|---|
136| Storage Box BX11 (ya pagado) | €3,20/mes por 1 TiB | sin cargo, pero no es un CDN |
137| Cloudflare R2 | ~US$0,015/GB-mes ⇒ **<US$1/mes por 51 G** | **0** |
138| Backblaze B2 + Cloudflare | ~US$6/TB-mes | 0 vía Bandwidth Alliance |
139| Hetzner Object Storage | ~€6/mes (1 TB + 1 TB) | incluido hasta el TB |
140
141**Lo que decide este ADR no es «usamos R2»: es que R2 sea una entrada más de una lista.** Si mañana
142cierra la cuenta, se añade otra base a la lista y no cambia una línea de código ni se re-hashea un
143artefacto. La condición de no depender de un servidorcito no se cumple contratando un proveedor
144grande — se cumple pudiendo perder cualquiera de ellos sin enterarse.
145
146Regla que se deriva: **nunca menos de dos orígenes en dos proveedores distintos**. Hoy el repo, el
147respaldo y el mirror de fuentes están los tres en Hetzner y en la misma cuenta — riesgo que el ADR
1480013 ya dejó anotado sin resolver, y que R2 resuelve por ser de otra casa.
149
150### 7. ⛔ Lo que NUNCA sale hacia el CDN
151
152La **clave raíz de firma**, que es offline y es lo único no delegable de todo el lanzamiento
153([SDD 19 §3.2](../19-lanzamiento-publico.md), [SDD 20](../20-catalogo-publicable-y-completa.md)). Un
154CDN sirve bytes públicos; firmar es lo contrario de publicar. Si la clave estuviera en el sitio desde
155donde se sirve, los N espejos dejarían de ser «orígenes sin confianza» y pasarían a ser N copias de
156la autoridad.
157
158## Verificado de punta a punta, no por inspección
159
160Dos servidores HTTP locales con el **mismo `index.json`** (mismo nombre de paquete, mismo `file`,
161mismo `digest`) y `.swm` distintos: el bueno y una copia con **un solo byte cambiado** — el mismo
162tamaño, el mismo nombre de fichero. Es exactamente lo que puede hacer un espejo que no controlamos.
163
164| escenario | resultado | qué prueba |
165|---|---|---|
166| 1. sólo el origen bueno | pasa el digest, baja, carga el `.swm`, y muere después en el `config_edit` (el paquete sintético toca un `/etc/network.conf` que no existe) | la descarga y la verificación se cruzan de verdad |
167| 2. sólo el origen manipulado | `Error: digest de 'demo' no coincide … el origen entregó un .swm distinto del que se firmó`, exit 1 | el byte cambiado se detecta |
168| 3. origen **muerto** primero (:9999), bueno detrás | llega al mismo punto que (1), y reporta `bajados 1 .swm de http://127.0.0.1:8801` | **la lista hace failover ante ausencia** |
169| 4. origen **manipulado** primero, bueno detrás | aborta con el mismo error que (2), exit 1 | **NO cae al siguiente ante contenido distinto** — §4 |
170
171(3) y (4) son el par que importa: sin el segundo, la lista sería un mecanismo para tapar espejos
172mentirosos en vez de para sobrevivir a espejos caídos.
173
174Además, el mensaje de (3) nombra **el origen que sirvió de verdad**, no la lista entera. Decir
175«bajado de \<lista\>» cuando sólo uno respondió es una media verdad que esconde justo el dato con el
176que se depura un espejo caído — el mismo error de diagnóstico que el ADR 0013 documenta en el
177poblador de bundles.
178
179## Consecuencias
180
181- **Cero re-hasheos.** La URL no está en `hash_inputs` y `digest` no es un input de build.
182- **Cero roturas de firmas existentes.** Verificado por test: sin `digest`, `PackageEntry` serializa
183  idéntico a antes.
184- Un espejo nuevo pasa a ser un `rsync` plano sin índice, sin coordinación y sin confianza. La
185  barrera de entrada para que un tercero nos espeje es más baja que la de una distro clásica, y eso
186  es una consecuencia directa del direccionamiento por contenido, no un favor que pedimos.
187- Aparece deuda de vigilancia (ver abajo): más orígenes = más sitios que pueden pudrirse en silencio.
188
189## Lo que este ADR NO decide
190
191- **El vigía de los espejos.** `scripts/fuentes/fuentes-vigia.sh` vigila **upstream**, no nuestros
192  orígenes. Con una lista de N, hace falta comprobar que **cada uno** tiene lo que dice tener — si no,
193  volvemos a la métrica que envejece hacia el optimismo. Es el trabajo pendiente más claro que deja
194  este documento.
195- **Qué origen es la verdad al publicar** (cómo se propaga un release de uno a los demás, y qué pasa
196  si la propagación se corta a media tanda: un espejo con índice nuevo y `.swm` viejo).
197- **Las imágenes de instalación**: son ficheros grandes de un solo tiro y el reparto natural es
198  torrent con webseed, que no comparte mecánica con esto.
199- **La rotación de claves de firma** y el procedimiento ante filtración — sigue siendo de
200  [SDD 19 §3.2](../19-lanzamiento-publico.md), y sin él la firma sigue siendo teatro por muchos
201  orígenes que haya.
202- **La concentración en `github.com`** (64% del corpus como fuente). Este ADR no la toca; sigue
203  pendiente desde el 0013.

0014-distribucion-multiorigen.md

ADR 0014 — Distribución multi-origen: el origen no necesita confianza

Contexto

Todavía no hay usuarios. Ése es exactamente el momento de decidir esto: el protocolo de distribución toca el formato del índice y el comportamiento de clientes ya instalados en máquinas ajenas. Cambiarlo después de la primera descarga pública significa romper a alguien o arrastrar dos formatos para siempre; cambiarlo hoy no cuesta nada.

La pregunta que lo dispara es la correcta: no quiero apoyarme en un solo servidorcito. Y hoy nos apoyamos exactamente en eso, en tres capas a la vez:

capaqué sirveorígenes soportados antes de este ADR
fuentes tarball{sha256}.tar1 (TAKANA_MIRROR)
fuentes git{commit}.bundle1 (TAKANA_MIRROR_GIT)
repo de paquetesindex.json + .swm1 (--repo <URL>)

El ADR 0013 existe precisamente para no depender de 79 servidores ajenos — y lo resolvió creando una dependencia de uno nuestro. Es el mismo punto único de fallo con nuestro nombre encima.

La medida: qué habría que servir, y cuánto pesa

quétamañonaturaleza
tarballs de fuentes (work/tarballs, 393 objetos)2,9 Ginmutable, direccionado por sha256
bundles git de fuentes (571 commits)~2,3 Ginmutable, direccionado por commit
artefactos / paquetes36 G en el hub; ~51 G el espejo publicable tras el split de debug (SDD 23)inmutable, direccionado por BLAKE3
imágenes de instalaciónunos pocos G por releaseinmutable
índice firmado del repoKBmutable, y lo único que debe ser fresco y auténtico

Es decir: el 99,99% de los bytes es inmutable y verificable por contenido, y el 0,01% restante es lo único que necesita confianza. Esa asimetría es la decisión entera.

Decisión

1. La partición: raíz chica y firmada, cuerpo grande y sin confianza

⇒ El cuerpo puede servirse desde cualquier host, incluso uno hostil. Eso convierte «conseguir espejos» de un problema de infraestructura y de contratos a un problema de a quién pedírselo.

2. Todo camino de descarga acepta una LISTA de orígenes, no uno

TAKANA_MIRROR y TAKANA_MIRROR_GIT pasan a ser listas separadas por comas. --repo acepta https://a,https://b. Añadir orígenes no re-hashea nada: la URL nunca estuvo en hash_inputs (ADR 0013 §1), y para el repo el digest no es un input de build sino un campo del índice.

3. Las fuentes ROTAN el orden; el repo lo respeta

Distinto a propósito, y la razón importa:

4. Ausencia ⇒ seguir. Contenido distinto ⇒ ABORTAR

La distinción que hace que una lista de orígenes sea una mejora y no un agujero:

Caer al siguiente sería la tentación natural («total, del bueno lo bajo igual») y es justo el patrón que este repo ya pagó caro: un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue bien (CLAUDE.md §3). El paquete acabaría instalado desde el espejo bueno y nadie se enteraría nunca de que uno de los espejos miente.

5. El índice firmado ancla el digest de cada .swm — el hueco que había

Antes de este ADR la cadena estaba rota en el último eslabón, y no era evidente. La firma del índice cubre la lista de entradas: nombre, versión, file, deps. Pero file es una ruta, no un contenido: quien sirviera los bytes podía devolver otro .swm bajo el mismo nombre y la firma del índice seguía casando perfectamente.

Había red aguas abajo — apply reconstruye desde fuente y compara contra expected_hash — pero no alcanza: expected_hash es opcional, y el .swm se lee mucho antes de llegar ahí (el gate de colisiones de ficheros de install ya decide con su contenido). El comentario de download.rs decía que el patch_url «se cubre indirectamente»; indirectamente no es una cadena de confianza.

Se cierra con PackageEntry::digest (BLAKE3, b3:…), sellado al publicar y verificado antes de escribir un byte. La cadena queda: clave raíz → firma del índice → digest → bytes.

CORRECCIÓN (mismo día). Este párrafo decía que el digest usaba «la misma función que sella artefactos», o sea of_inputs(&[bytes]) — length-prefijado. Estaba mal, y la razón importa. Con una sola entrada el length-prefijado no desambigua ninguna concatenación: sólo hace que b3:<hex> deje de ser el hash que obtiene un tercero con b3sum sobre el mismo archivo.

Lo destapó escribir la contraparte de este ADR para churay (02_ruway/churay/SDD-DISTRIBUCION.md §5 en tawasuyu): churay direcciona sus blobs con blake3(bytes) pelado y takana iba a hacerlo length-prefijado, bajo el mismo prefijo b3: ⇒ el mismo archivo con dos hex distintos, y un CAS compartido que no falla ruidosamente sino que busca nombres distintos para los mismos bytes.

Ahora usa ArtifactHash::of_bytes — que ya existía en takana y ya era blake3 pelado; es la convención de of_file y la del expected_hash de un .swm, así que of_inputs era además la pieza fuera de sitio dentro del propio repo. Coste del cambio: una línea, porque ningún índice publicado llevaba todavía el campo. Es el argumento de este ADR aplicado a sí mismo — decidir antes de que haya usuarios. Hay guardián: digest_es_blake3_pelado_y_no_length_prefijado.

El campo es Option con skip_serializing_if, así que un índice firmado antes de este cambio serializa idéntico y su firma sigue siendo válida (hay test). Un índice sin digests no bloquea, pero dice cuántos paquetes no verificó: un índice viejo servido desde un espejo ajeno no es seguro, y el silencio lo haría parecer verificado.

6. Cloudflare R2 como primer origen público — instancia, no arquitectura

El criterio no es el precio del almacenamiento (51 G no cuestan nada en ningún sitio): es el egress, que es lo que quiebra a los proyectos que reparten binarios. R2 no cobra salida.

Órdenes de magnitud, a verificar el día de contratar — no son cotizaciones:

origenalmacenamientoegress
Storage Box BX11 (ya pagado)€3,20/mes por 1 TiBsin cargo, pero no es un CDN
Cloudflare R2~US$0,015/GB-mes ⇒ <US$1/mes por 51 G0
Backblaze B2 + Cloudflare~US$6/TB-mes0 vía Bandwidth Alliance
Hetzner Object Storage~€6/mes (1 TB + 1 TB)incluido hasta el TB

Lo que decide este ADR no es «usamos R2»: es que R2 sea una entrada más de una lista. Si mañana cierra la cuenta, se añade otra base a la lista y no cambia una línea de código ni se re-hashea un artefacto. La condición de no depender de un servidorcito no se cumple contratando un proveedor grande — se cumple pudiendo perder cualquiera de ellos sin enterarse.

Regla que se deriva: nunca menos de dos orígenes en dos proveedores distintos. Hoy el repo, el respaldo y el mirror de fuentes están los tres en Hetzner y en la misma cuenta — riesgo que el ADR 0013 ya dejó anotado sin resolver, y que R2 resuelve por ser de otra casa.

7. ⛔ Lo que NUNCA sale hacia el CDN

La clave raíz de firma, que es offline y es lo único no delegable de todo el lanzamiento (SDD 19 §3.2, SDD 20). Un CDN sirve bytes públicos; firmar es lo contrario de publicar. Si la clave estuviera en el sitio desde donde se sirve, los N espejos dejarían de ser «orígenes sin confianza» y pasarían a ser N copias de la autoridad.

Verificado de punta a punta, no por inspección

Dos servidores HTTP locales con el mismo index.json (mismo nombre de paquete, mismo file, mismo digest) y .swm distintos: el bueno y una copia con un solo byte cambiado — el mismo tamaño, el mismo nombre de fichero. Es exactamente lo que puede hacer un espejo que no controlamos.

escenarioresultadoqué prueba
1. sólo el origen buenopasa el digest, baja, carga el .swm, y muere después en el config_edit (el paquete sintético toca un /etc/network.conf que no existe)la descarga y la verificación se cruzan de verdad
2. sólo el origen manipuladoError: digest de 'demo' no coincide … el origen entregó un .swm distinto del que se firmó, exit 1el byte cambiado se detecta
3. origen muerto primero (:9999), bueno detrásllega al mismo punto que (1), y reporta bajados 1 .swm de http://127.0.0.1:8801la lista hace failover ante ausencia
4. origen manipulado primero, bueno detrásaborta con el mismo error que (2), exit 1NO cae al siguiente ante contenido distinto — §4

(3) y (4) son el par que importa: sin el segundo, la lista sería un mecanismo para tapar espejos mentirosos en vez de para sobrevivir a espejos caídos.

Además, el mensaje de (3) nombra el origen que sirvió de verdad, no la lista entera. Decir «bajado de <lista>» cuando sólo uno respondió es una media verdad que esconde justo el dato con el que se depura un espejo caído — el mismo error de diagnóstico que el ADR 0013 documenta en el poblador de bundles.

Consecuencias

Lo que este ADR NO decide