645 líneas. Los ADR entran porque en este repo SON documentos vivos, no registros inmutables: el 0013 tiene 5 commits, el 0009 dos. Eso se comprobó antes de decidir, no se asumió por convención general. EXCLUIDOS por ser REGISTRO o generado: docs/evidencia/ (6), el HANDOFF de la noche de KDE (1) y docs/state/ (24, se regenera solo). Reescribir un comando dentro de una evidencia la falsifica. Y el ADR 0016 se excluye de todo barrido, con un aviso adentro para el próximo que barra: habla SOBRE el renombre, así que necesita seguir diciendo 'hammer'. El barrido se lo llevó puesto y lo dejó titulado 'Renombre del sistema: takana → takana'; revertido. Congelados, verificados uno por uno con controles: /opt/hammer, /var/lib/hammer, /usr/bin/hammer, /mnt/vvv/hammer, la URL de gitea, hammer-farm.service, hammer-live-install.sh, BRIEFING-hammer.md, hammerd, hammer-recover y HAMMER_LIVE.
13 KiB
ADR 0014 — Distribución multi-origen: el origen no necesita confianza
- Estado: ACEPTADO — implementado en
crates/hammer-build/src/fetch.rs,crates/hammer-core/src/repo.rsycrates/hammer-cli/src/main.rs(RepoSource). - Fecha: 2026-09-01
- Frontera:
bases_de_mirror/rotar_bases,PackageEntry::digest,repo::verify_digest,RepoSource::{parse,read_index,materialize}. - Continúa: ADR 0013 (mirror de fuentes) y SDD 19 §3 (infraestructura pública).
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:
| capa | qué sirve | orígenes soportados antes de este ADR |
|---|---|---|
| fuentes tarball | {sha256}.tar |
1 (TAKANA_MIRROR) |
| fuentes git | {commit}.bundle |
1 (TAKANA_MIRROR_GIT) |
| repo de paquetes | index.json + .swm |
1 (--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ño | naturaleza |
|---|---|---|
tarballs de fuentes (work/tarballs, 393 objetos) |
2,9 G | inmutable, direccionado por sha256 |
| bundles git de fuentes (571 commits) | ~2,3 G | inmutable, direccionado por commit |
| artefactos / paquetes | 36 G en el hub; ~51 G el espejo publicable tras el split de debug (SDD 23) | inmutable, direccionado por BLAKE3 |
| imágenes de instalación | unos pocos G por release | inmutable |
| índice firmado del repo | KB | mutable, 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
- Raíz — el
index.jsonfirmado. Debe ser fresca y auténtica. Es de kilobytes, así que puede vivir en sitios que no escalan pero sí se controlan: el propio git (que ya se espeja a gitea y a GitHub, verscripts/espejo-setup.sh), o cualquier host. - Cuerpo — tarballs, bundles,
.swm, artefactos, imágenes. Decenas de gigas, y cero necesidad de confianza: el nombre del objeto ES su verificación, o su hash está anclado en la raíz firmada.
⇒ 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:
- Fuentes — es la granja moliendo el corpus sin nadie mirando, cientos de objetos por tanda. Si se probara siempre en orden, el segundo origen no se tocaría jamás hasta la emergencia: sería un espejo que nadie prueba, la lección literal del ADR 0013 §2 un nivel más arriba. Se rota con una semilla determinista tomada del propio hash del objeto: el mismo objeto sale siempre del mismo origen (un fallo se reproduce y se depura), pero objetos distintos se reparten entre todos, así que una tanda del corpus ejercita la lista entera. Tras el elegido se recorren los demás.
- Repo — es una persona instalando un paquete. El orden es una preferencia suya (el espejo más cercano primero) y la previsibilidad vale más que el reparto; además son un puñado de ficheros, no una tanda. Se prueban en el orden que escribió.
4. Ausencia ⇒ seguir. Contenido distinto ⇒ ABORTAR
La distinción que hace que una lista de orígenes sea una mejora y no un agujero:
- Un origen que no tiene el objeto (404, host caído, DNS) es normal: se pasa al siguiente en silencio. Un espejo puede ir por detrás.
- Un origen que devuelve otro contenido del que la raíz firmada declara es manipulación o corrupción. Se aborta ahí mismo, no se cae al siguiente origen.
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
digestusaba «la misma función que sella artefactos», o seaof_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 queb3:<hex>deje de ser el hash que obtiene un tercero conb3sumsobre 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 conblake3(bytes)pelado y takana iba a hacerlo length-prefijado, bajo el mismo prefijob3:⇒ 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 deof_filey la delexpected_hashde un.swm, así queof_inputsera 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:
| origen | almacenamiento | egress |
|---|---|---|
| Storage Box BX11 (ya pagado) | €3,20/mes por 1 TiB | sin cargo, pero no es un CDN |
| Cloudflare R2 | ~US$0,015/GB-mes ⇒ <US$1/mes por 51 G | 0 |
| Backblaze B2 + Cloudflare | ~US$6/TB-mes | 0 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.
| escenario | resultado | qué prueba |
|---|---|---|
| 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 |
| 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 |
| 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 |
| 4. origen manipulado primero, bueno detrás | aborta con el mismo error que (2), exit 1 | NO 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
- Cero re-hasheos. La URL no está en
hash_inputsydigestno es un input de build. - Cero roturas de firmas existentes. Verificado por test: sin
digest,PackageEntryserializa idéntico a antes. - Un espejo nuevo pasa a ser un
rsyncplano sin índice, sin coordinación y sin confianza. La barrera de entrada para que un tercero nos espeje es más baja que la de una distro clásica, y eso es una consecuencia directa del direccionamiento por contenido, no un favor que pedimos. - Aparece deuda de vigilancia (ver abajo): más orígenes = más sitios que pueden pudrirse en silencio.
Lo que este ADR NO decide
- El vigía de los espejos.
scripts/fuentes/fuentes-vigia.shvigila upstream, no nuestros orígenes. Con una lista de N, hace falta comprobar que cada uno tiene lo que dice tener — si no, volvemos a la métrica que envejece hacia el optimismo. Es el trabajo pendiente más claro que deja este documento. - Qué origen es la verdad al publicar (cómo se propaga un release de uno a los demás, y qué pasa
si la propagación se corta a media tanda: un espejo con índice nuevo y
.swmviejo). - Las imágenes de instalación: son ficheros grandes de un solo tiro y el reparto natural es torrent con webseed, que no comparte mecánica con esto.
- La rotación de claves de firma y el procedimiento ante filtración — sigue siendo de SDD 19 §3.2, y sin él la firma sigue siendo teatro por muchos orígenes que haya.
- La concentración en
github.com(64% del corpus como fuente). Este ADR no la toca; sigue pendiente desde el 0013.