# 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.rs` y `crates/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](0013-mirror-de-fuentes.md) (mirror de fuentes) y [SDD 19 §3](../19-lanzamiento-publico.md) (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 `) | 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](../23-plan-rehasheo.md)) | 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.json` firmado. 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, ver `scripts/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 `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:` **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: | 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 ⇒ **» 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_inputs` y `digest` no es un input de build. - **Cero roturas de firmas existentes.** Verificado por test: sin `digest`, `PackageEntry` serializa idéntico a antes. - Un espejo nuevo pasa a ser un `rsync` plano 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.sh` vigila **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 `.swm` viejo). - **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](../19-lanzamiento-publico.md), 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.