Con el volumen `takana-store` montado, la cosecha completa del worker corrió: **1369 → 1575
artefactos, 0 vacíos**, exactamente los 206 que faltaban. 12,4 G por la red para 39 G lógicos
(`speedup 3.14`, el ahorro de `-H`); `/store` queda en 73,7 G de 97,9.
Anotado: el enlace con el worker va a 11 MB/s y no a los 90 de gioser↔caja, porque el LXC vive en el
Proxmox de gioser, fuera de la red de Hetzner.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
`git ls-remote` andaba y `git clone` moría con `invalid index-pack output`, así que parecía un fallo
de red. El informe COMPLETO —no la última línea— decía qué y dónde: sha1dc leyendo un `uint32_t`
desalineado, abortado por el runtime ubsan que `-fsanitize=undefined` en CFLAGS había enlazado.
Arreglado por los dos lados: el sanitizador vuelve a ser sólo de enlace, y
`-DSHA1DC_FORCE_ALIGNED_ACCESS` quita el UB en la fuente. `git` es raíz de `perfil.base`, así que
esto arregla la distro entera y no sólo al hub.
De paso queda anotado que la clave del gitea es `~/.ssh/tawasuyu`, no la de la granja.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
Encontrado intentando que la caja de producción clonara el repo para ser un hub de verdad
(SDD 28 §6.11). `git ls-remote` funciona; `git clone` —de cualquier repo, incluso `--depth 1`— muere:
fatal: fetch-pack: invalid index-pack output
El informe completo, que la traza corta escondía:
panic: load of misaligned address 0x… for type 'const uint32_t',
which requires 4 byte alignment
in sha1_compression_states → sha1_process → SHA1DCUpdate → git_SHA1DCUpdate
→ git_hash_update → unpack_entry_data → cmd_index_pack
Dos cosas, y las dos son de fondo:
1. **`-fsanitize=undefined` estaba en CFLAGS**, no sólo en LDFLAGS. El motivo original era legítimo
—las `libz.a` etc. materializadas traen referencias `__ubsan_handle_*` que bajo `-static` no se
resuelven solas, y el flag AL ENLAZAR trae el runtime de zig— pero en CFLAGS **instrumenta el
código de git**. Un arreglo de ENLACE se había vuelto una mina en RUNTIME, y justo en la ruta de
hash, que es por donde pasa todo lo que git recibe. Ahora va sólo en LDFLAGS.
2. **`-DSHA1DC_FORCE_ALIGNED_ACCESS`**, que es la causa real. `sha1collisiondetection` —el backend
SHA1 por defecto, el que detecta SHAttered— lee palabras de 32 bits SIN alinear. En x86 eso
funciona y por eso nadie lo nota nunca; según el estándar es UB, y basta con que el runtime ubsan
esté enlazado para que aborte. El define hace que lea byte a byte: quita el UB en la FUENTE en vez
de esconderlo. Sin él, cualquier build que arrastre el runtime vuelve a romper `clone`.
Probado: `git clone --depth 1` del propio repo desde la caja ⇒ **877 recetas, commit 543676e1**.
Antes fallaba con y sin `--depth`.
Radio cero: `git` no es dep de ninguna receta (medido). Pero SÍ es raíz de `perfil.base`, así que
esto arregla la distro entera, no sólo el hub.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
Decisión del usuario (2026-09-11): **`terapeuta.ec` y `andino.ec` MUEREN**. Los 279 M de
`/var/www/terapeuta` se van con la caja y no se archivan. Los otros fósiles (aura, sigma, kosmofono,
gitea-redir, api.gioser, mail.sigma) también mueren: ni DNS ni ficheros ni dueño.
`summa`/`api.summa`/`dev.summa` no se mudan porque ya viven en otra máquina — sólo hay que sacar sus
bloques del Caddyfile al apagar gioser.
De las 19 entradas, **13 no se mudan**; la superficie real son 6 sitios y el que pesa es el gitea.
Que quede escrito ES la puerta: el día que se borre gioser, `terapeuta.ec` no se va a poder
recuperar, y la diferencia entre "lo decidimos" y "se nos pasó" es ese párrafo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
La tabla del §6 le ponía costo «bajo» a la IA local porque `rimay`/`iniy` parecían alcanzar. Se
miró antes de empezar y no alcanzan: los backends de `pluma-llm` son todos de nube, y
`rimay-verbo-fastembed` descarga onnxruntime (glibc) y el modelo de HuggingFace en el primer
arranque. Queda escrito, porque es lo que evita que alguien vuelva a estimarlo en «bajo».
Y de paso junta dos filas que el plan llevaba separadas: el §6.7 y la mitad semántica del §6.3 no
eran dos problemas — era que el corpus no tenía con qué correr un modelo. Un solo muro.
Queda documentado el hallazgo caro, con las dos líneas de cmake enfrentadas: SOURCE_DATE_EPOCH
—que exportamos para que los builds REPRODUZCAN— apagaba INS_ENB y con él las seis perillas de ISA
de ggml, sellando un motor de inferencia con CERO instrucciones vectoriales que corría igual.
Tabla 0/0/0 vs 42.356/1.298/86, medida sobre artefactos sellados.
Y las dos trampas del arnés que van a volver cuando se pinee el modelo de producción: `/health`
contesta 503 mientras carga, y un GGUF sin tipo de pooling hace que el endpoint compatible con
OpenAI conteste 400 — un error que parece del cliente.
⚠ Escrito también lo que esto NO es: el motor no es la función. Falta el modelo (fuente pineada,
como el perfil de PGO), los verbos del host y quién levanta el servidor. Y `llama-cpp` no se
declara en ningún perfil todavía: una imagen no crece 199 M por una función que aún no existe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqBhowvFe3aiieGCKgvxwa
El más grave: el paso del repo va con `--delete`, y el `/opt/takana` de la caja llegó por
`rsync --exclude=.git`. Una corrida real desde ahí habría BORRADO `.git` del Storage Box — el
historial entero — en silencio, porque rsync haría exactamente lo que se le pidió.
La regla que sale y que vale más allá de este script: **un `--delete` convierte «respaldar» en
«sincronizar», y sincronizar desde un origen incompleto no sube menos: BORRA.** Cualquier respaldo
con `--delete` necesita una guarda de completitud del ORIGEN, no sólo del destino.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
Encontrado corriendo el respaldo desde la caja de producción (SDD 28, puerta 7).
El paso [2/3] sube el repo **con `--delete`** —correcto: para eso está git—. Pero el `/opt/takana` de
la caja llegó por `rsync --exclude=.git`: es una COPIA, no un clon. Una corrida real desde ahí no
habría subido menos cosas: **habría borrado del respaldo todo lo que le falta al origen, empezando
por `.git`** — el historial entero. Y en silencio, porque rsync haría exactamente lo que se le pidió.
Ahora falla ruidosamente si la raíz no tiene `.git`, con `REPO_INCOMPLETO=1` como escotilla para el
caso deliberado. Probado en los tres sentidos: en gioser (con `.git`) pasa; en un árbol sin `.git`
aborta; con la escotilla pasa igual.
**Y el store tampoco estaba donde el script creía.** Usaba `$RAIZ/store` cableado — en gioser es un
bind-mount dentro del repo, pero en una caja takana instalada es una partición en `/store`, así que
el paso [3/3] moría con `change_dir "/opt/takana/store" failed`. Peor: rsync devuelve 23, que está en
la lista de reintentables, así que el bucle lo reintentaba — exactamente el cuadro que la cabecera de
este script ya documenta («un error reintentable que se repite 40 veces no es un corte de red: es
algo estructural»). Ahora `STORE` es env y hay una guarda ANTES del bucle que aborta si no existe.
Con eso el `--seco` completa los tres pasos desde la caja y lee la ocupación del box (111 G de 1 T).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
La IA local de la barra lateral (§6.7) y la mitad semántica del archivo personal (§6.3) figuraban
como dos pendientes distintos. Son uno: el corpus no tenía con qué correr un modelo. `pluma-llm`
sólo trae backends de NUBE y `rimay-verbo-fastembed` DESCARGA onnxruntime (glibc) y el modelo de
HuggingFace en el primer arranque. `recipes/llama-cpp.toml` (b10901, estática, 199 M) derriba el
muro entero: el mismo binario sirve `/v1/chat/completions` y `/v1/embeddings`.
⚠ Lo que este commit deja medido, y es lo que no se podía deducir: el PRIMER sello llevaba sólo
`-DGGML_NATIVE=OFF` —leído en `ggml/CMakeLists.txt:141`, que con NATIVE=OFF debería ENCENDER las
perillas explícitas de ISA— y salió con CERO instrucciones vectoriales. La causa está 36 líneas
más arriba: `if (CMAKE_CROSSCOMPILING OR DEFINED ENV{SOURCE_DATE_EPOCH})` apaga
`GGML_NATIVE_DEFAULT`, y el sandbox de takana exporta `SOURCE_DATE_EPOCH=1` (`sandbox.rs:453`)
justamente para que los builds REPRODUZCAN. O sea: la variable que nos da reproducibilidad apagaba
todas las instrucciones vectoriales del motor de inferencia — y no falló nada. El artefacto selló,
`--version` contestaba, y el binario era x86-64 pelado: 0 `%ymm`, 0 `vfmadd`, 0 `roundps` en
1.634.770 líneas de `objdump -d`. Ahora las seis van declaradas una por una y el `install` LAS
COMPRUEBA en el binario: 42.356 `%ymm` / 1.298 `vfmadd` / 86 `roundps`.
`strip_debug = true` porque zig cc emite debug_info por defecto y son 16 binarios estáticos:
1,8 G → 199 M.
Guardián `scripts/test-llama-cpp.py`, sobre el artefacto VIGENTE (resuelto por `takana hash`, no
por `ls store/*`) y con control negativo vivo (`--sin-modelo`, que TIENE que fallar): ISA,
hermético (0 NEEDED), una pasada de inferencia REAL con `stories260K` pineado por sha256, y el
endpoint de embeddings — vector de verdad, el mismo texto da el mismo vector, dos textos distintos
dan vectores distintos, y no son ceros. Y `verificar-repro.sh`: REPRODUCE, 0 no-determinismos.
⚠ Esto es el MOTOR, no la función. Un motor sin modelo no contesta nada: el modelo de producción
es fuente pineada aparte, como el perfil de PGO de firefox, y es su propia unidad de trabajo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqBhowvFe3aiieGCKgvxwa
**Puerta 8 (fósiles), sondeada por DNS Y por HTTP**: de las 19 entradas del Caddyfile, gioser sólo
sirve **6** de verdad (las dos landings, el gitea x2, sergio x2). Las otras 13 NO hay que mudarlas:
8 no tienen DNS, 2 dan 502, y **3 ya viven en otra máquina** (`summa`, `api.summa`, `dev.summa` →
154.197.1.2) aunque el bloque de Caddy siga en gioser. O sea que la superficie real a mudar son 6
sitios, y el que pesa es el gitea porque aloja el `origin` de takana.
⚠ `terapeuta.ec` es el único fósil CON DATOS (279 M en /var/www) y sin DNS: hay que decidir
explícitamente si se archiva o muere con la caja.
**Puerta 7**: la caja alcanza el Storage Box y refresca el manifiesto — 3140 artefactos, con 2
VACÍOS detectados y excluidos (`gnome-desktop` ×2). Para llegar ahí hubo que arreglar tres bloqueos
de la misma familia —*el instrumental asume que el hub es gioser*—: la raíz cableada, el binario de
desarrollo, y el rsync sin zstd que hacía que el respaldo no se ejecutara en absoluto.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
Corriendo el respaldo desde la caja de producción (SDD 28, puerta 7):
==> [1/3] estado (el grafo: qué había construido y con qué hash)
unknown compress name: zstd
!! estado: error 4 que NO es de red — no reintento
El script exige `--compress-choice=zstd` (medido: el doble de rendimiento efectivo, porque el 79 %
del store son secciones `.debug_*` y comprimen como texto) y **`recipes/rsync.toml` lo construía con
`--disable-zstd`**. El comentario de la receta decía por qué: «deps externas quitadas (no en
catálogo)». Ya no es cierto — `zstd` tiene receta y está sellada.
Dos arreglos, y los dos hacen falta:
1. **La receta enciende zstd** (dep `zstd`, fuera el `--disable-zstd`). Control en los dos sentidos:
el rsync nuevo lista `zstd zlibx zlib none`, el anterior `zlibx zlib none`. Radio cero: `rsync` no
es dep de ninguna receta (medido), así que no re-hashea nada más.
2. **El script DEGRADA en vez de morir.** Detecta el soporte (`rsync --version` → «Compress list»)
y cae a zlib avisando. Un respaldo que no corre por un algoritmo de compresión es peor que un
respaldo lento — y el fallo era especialmente malo porque el error 4 se clasifica como "no de red"
y el script no reintenta: el respaldo simplemente no se hace.
Y de paso queda cubierto el caso de un hub que todavía no reconstruyó su rsync.
**También la raíz cableada**: el script hacía `cd /mnt/vvv/takana` por defecto —la ruta de gioser—
así que desde cualquier otro hub moría con `No such file or directory`. Ahora se deriva de la
ubicación del script, como el resto de `scripts/`; el override por `RAIZ` se conserva. Control: en
gioser sigue resolviendo a `/mnt/vvv/takana`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
Desde la caja, sin tocar el latido de gioser: llevada la clave de la granja y el `.fleet` (los dos
fuera de git a propósito), la caja alcanza al worker `PruebasIA`, `estado-granja.sh` corre allá y lee
bien el avance KDE, y —lo que cierra el lazo— se cosechó un artefacto REAL del worker
(`speedtest-go`, 12 M) con el transporte de la granja: llegó con su `.hammer/recipe.toml` y **su
binario CORRE en la caja**. Worker construye → hub cosecha → binario ejecuta.
Lo que falta es CAPACIDAD: 206 artefactos del worker que la caja no tiene, ~23 G a la media del
corpus, contra 3,7 G libres (94 % de ocupación). Forzarlo repetiría el llenado que dejó 1367
artefactos vacíos.
Las tres salidas quedan escritas: enganchar `harkaq-cosecha` (= el cutover, gioser deja de construir
en ese instante y hoy está moliendo KDE), un volumen nuevo para la caja (~€5/mes, no interrumpe
nada), o podar (deshace la mudanza). Es decisión del usuario, no de ingeniería.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
gioser: 7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605
caja: 7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605
`base 61/61 · cli 84/84 · mirada 41/41 · servidor 96/96 · falta 0`, `wanted 3` (chrony, cronie,
logrotate: la deuda declarada), grafo CIERRA, topo-sort OK, en las dos máquinas. Es la prueba de que
un segundo hub está bien montado — no que "funcione".
Costó tres intentos y los dos fallos son la parte que vale:
1. **`unhashable 875`**, todas, con el lab bien y `takana hash` andando a mano. Los scripts asumen un
árbol de DESARROLLO (`HAMMER = ROOT/target/release/takana`); en una caja instalada el binario es
`/usr/bin/takana` y `target/` ni existe. No falla ruidosamente: sale el corpus entero como
`unhashable`, que se lee como "este corpus no se puede hashear".
2. **Dos nodos divergentes** (`aichat`, `zola`): `sealed` en gioser, `never` en la caja. No era deriva
ni pérdida — **tampoco están en disco en gioser**: son los 2 sellados avalados por los MANIFIESTOS
(`work/farm-sellados.txt`, `work/respaldo-sellados.txt`). Y `work/` está gitignored, así que un hub
recién sembrado no los tiene y degrada esos nodos en silencio.
Misma familia que `.fleet`: un fichero fuera de git del que depende una medición compartida.
**Sembrar un hub no es clonar el repo: es repo + store + lab + manifiestos.**
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
**Un hub no es repo+store: es repo+store+LAB.** Con python3 ya vivo, `takana hash` en la caja seguía
fallando por el apk db del lab: el toolchain entra en el ArtifactHash, así que una máquina sin lab no
puede ni preguntar cuál es el hash vigente de una receta — no puede computar el grafo. Sembrado el
lab, el hash testigo coincide byte a byte en las dos máquinas (`zlib` → `b3:dc363f26…`).
**Y la primera copia del store murió con el disco lleno, dejando 1367 de 1368 artefactos VACÍOS.**
La puerta 2 corrida EN EL DESTINO lo cazó en el acto — para eso está.
La causa, medida sobre el store de gioser:
du -sh 60 G (hardlinks contados UNA vez)
du -sh --count-links 85 G (hardlinks EXPANDIDOS)
.dmerge 11 G (la caché que hardlinkea al store)
`rsync` sin `-H` NO preserva hardlinks: los expande en copias enteras. O sea que «el store son 60 G»
—lo que dice `du` y lo que uno planifica— son 85 G al copiarlo, más lo que `.dmerge` expanda. La
partición de 68,7 G no tenía ninguna chance.
La copia correcta es `rsync -aH --exclude='.dmerge'`. Y la lección general: **`du -sh` sobre un árbol
con hardlinks no dice cuánto ocupa COPIARLO**; para dimensionar hay que medir con `--count-links`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
La distro comprime TODO con zstd: la imagen del lab (`lab-image.tar.zst`), el respaldo al Storage
Box, el `dd` remoto de una instalación. Y la imagen no traía el binario.
Medido sembrando el lab en la caja de producción: el `tar` del rootfs es el de busybox y contesta
`tar: unrecognized option: zstd`, así que hubo que extraer por tubería desde el hub
(`zstd -dc … | ssh caja 'tar -xf -'`). Un hub que se instala solo no puede depender de que otro hub
le descomprima las cosas.
La receta ya existía y está sellada (`b3:1ceb6215…`): sólo faltaba declararla.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
Elegidas las salidas 1 y 2 del §6.4; hecha la 1, que resultó más barata de lo que parecía y desbloquea
la 2 en vez de competir con ella.
`musl-shared` publica `/usr/lib/libc.so` + `/lib/ld-musl-x86_64.so.1`. Como variante y no tocando la
canónica: `musl` es Stage 1 y su `of_tree` es el baseline del selfhost. Control: `takana hash
recipes/musl.toml` sigue en `b3:ce952f72…`. Y `musl` no es dep de nadie ⇒ cero re-hasheo del corpus.
En la caja: `sh: python3: not found` → `Python 3.12.10`. De 23 inertes quedó **1**, `sqlite3`, que
pedía `libz.so.1`; `zlib-shared` ya estaba sellado y sólo faltaba declararla en `base`.
La salida 2 (python/perl estáticos) sigue viva pero deja de ser urgente: con el cargador publicado el
sistema ya no cuelga del rootfs del lab.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
La imagen del perfil servidor traía 23 binarios de 726 que no podían ejecutarse:
$ python3 -c "print(1)"
sh: python3: not found <- y `command -v python3` decía /usr/bin/python3
No faltaban: eran ELF dinámicos pidiendo `/lib/ld-musl-x86_64.so.1`, y **ningún artefacto del corpus
publicaba ese fichero**. Funcionaban en el hub sólo porque el rootfs Alpine del LAB lo presta — la
misma fuga que documentan `zlib-shared` y `expat-shared`, un piso más abajo, y peor: el lab no entra
en `hash_inputs`, así que la dependencia era invisible para el store.
Los 23 son la suite binutils entera + perl, python3, sqlite3, flex y nft. Entre ellos, TODO el
instrumental del proyecto, que es Python.
**Variante y no `--enable-shared` en la canónica**: `musl` es componente de Stage 1 y su `of_tree` es
el baseline de `selfhost-verify`. Re-hashearlo obliga a rehacer ese baseline a propósito, y eso es su
propia unidad de trabajo. Con el patrón `*-shared` (ya hay 23 en el corpus) la canónica no se mueve —
control: `takana hash recipes/musl.toml` sigue dando `b3:ce952f72…`, el mismo sellado de Stage 1. Y
además `musl` no es dep de NADIE (medido: sólo de sí misma; el enlace estático lo resuelve el musl de
zig), así que esto suma sin mover un solo ArtifactHash del corpus.
**⚠ `compiler = "gcc"` y no `zig-cc`, medido.** Con zig cc el `libc.so` sale con 1586 símbolos
dinámicos contra los 1654 de Alpine, y los 68 que faltan son EXACTAMENTE los que musl implementa en
ensamblador x86_64 (`memset memcpy memmove memcmp strlen` y toda la familia matemática) más los
`__stack_chk_*`. No es que no se compilen —en el `libc.a` canónico están—: zig los resuelve con su
propio musl y los deja `FUNC LOCAL HIDDEN` de tamaño 0, fuera de la tabla dinámica. El síntoma es un
cargador que arranca, reloca y muere con `memset: symbol not found`, que se lee como "el binario está
roto" y no como "a la libc le faltan símbolos". Con gcc: **1651 símbolos**, `memset` y `ceil`
presentes, y `python3 3.12.10` CORRE con el cargador del corpus.
De paso: el comentario de `musl.toml` decía que el artefacto aportaba «el loader, para uso dinámico
futuro». Era falso —`--disable-shared` no construye ninguno— y es justo la etiqueta que hizo que
nadie buscara el agujero. Corregido (los comentarios no entran en `hash_inputs`; verificado).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
La mitad del navegador del §6.5, deliberadamente asimétrica: sólo lee. Pregunta `focus.state` (nuevo
en puriy-costura, commit 38815b5f3) y pinta tres estados — `foco`, nada, y `?` cuando nadie escribió
el estado. Ese tercero es el que importa: si «no sé» se redondeara a «apagado», la insignia afirmaría
que no hay foco sin haberlo mirado.
No hay verbo para apagarlo, y es la propiedad y no un pendiente: si el navegador pudiera levantar el
foco, valdría lo que vale un bloqueador de extensión. En tawasuyu hay un test que lo fija; acá el
guardián mide el EFECTO — tras una sesión entera, el fichero de estado quedó igual.
`scripts/test-atuq-foco.py` corre los tres estados sobre el path de PRODUCCIÓN (`/etc/takana/focus`),
no la escotilla de pruebas: una escotilla mide el código, no el contrato con la imagen. Verde sobre
el artefacto vigente (atuq 5d1afc50, puriy-costura 0de6b4ca), y verificado rompiéndolo — con una
extensión parcheada que pinta «foco» siempre (en una COPIA del artefacto, vía ATUQ_DIR), falla
nombrando la insignia.
**Puerta 2 ✅**: 1362 artefactos reales, 0 vacíos. (El primer conteo dio «3 vacíos» y eran
`.mirror-tmp`, `.bootstrap-tmp` y `.divergen` — directorios de trabajo, no artefactos: el chequeo
tiene que acotar por el patrón `<hash>-<nombre>`, no listar el directorio.)
**El disco**: la imagen ocupaba 7 G de 76,3 y los otros 69 eran inalcanzables. Arreglado; `/store`
en la caja pasó de 487 M a **68,7 G**. Con eso el store de 55 G entra en disco local y **la mudanza
ya no depende de desenganchar el volumen de gioser** — que era la parte que paraba la granja.
**Puerta 3 🚧 BLOQUEADA, y no por configuración.** Al ir a computar los grafos:
$ python3 -c "print(1)"
sh: python3: not found <- y `command -v python3` decía /usr/bin/python3
Está y no arranca: es un ELF DINÁMICO que pide `/lib/ld-musl-x86_64.so.1`, y **en la imagen no hay
ningún cargador dinámico**. Tampoco en el store: el artefacto `musl` publica sólo lo estático
(`libc.a`, `crt*.o`, headers) — no hay `libc.so` ni `ld-musl` en NINGÚN artefacto del corpus. Lo que
hace que esto funcione en gioser es el rootfs Alpine del LAB.
Contado sobre la imagen: **23 binarios inertes de 726**. La suite binutils entera, más perl, python3,
sqlite3, flex y nft.
Frena la mudanza porque TODO el instrumental del proyecto es Python (build-state, targets, yupana,
hydrate-profile, drenar, triaje): un servidor takana no puede correr ni una de sus herramientas.
Las tres salidas —publicar el cargador desde musl (toca Stage 1 y mueve el baseline del selfhost),
python/perl estáticos (frente propio), o aceptar que el instrumental vive en el hub (contradice
apagar gioser)— son decisión de ADR. Queda escrito, medido, y sin tocar nada de Stage 1 de rebote.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
Una imagen se escribe con `dd` sobre un disco casi siempre más grande que ella. La del perfil
servidor son 7 G; la caja hcloud tiene 76,3 G. **Sobraban 69 G que nadie podía usar**, y la caja
arrancaba perfecta con el disco a un décimo — el fallo que no falla, otra vez.
Dos cambios que van juntos:
1. **El store pasa a ser la ÚLTIMA partición** (p1 bios, p2 `/`, p3 `/var/lib/hammer`, p4 `/store`).
Sólo la última puede extenderse sin mover datos, y el store es justamente la que crece con el uso.
Con él en el medio, la partición extensible era la de ESTADO, de 512 M, que no le sirve a nadie.
El cambio es seguro porque nada referencia números de partición: `root=PARTLABEL=hammer-root` y
el wrapper monta por etiqueta con `findfs`.
2. **El wrapper de `/sbin/init` la extiende en el primer arranque**, antes de montarla:
`sfdisk -N <n> ', +'` → `partx -u` → `e2fsck -pf` → `resize2fs`. Idempotente: si ya llega al
final, los dos últimos no hacen nada. Guardado tras `-x /sbin/sfdisk` para no romper un rootfs
que no lo traiga, y el aviso va a `/dev/kmsg`, que es donde se lee.
Probado en QEMU volcando la imagen de 7 G en un disco de 20 G: el arranque imprime
`init: store: /dev/sda4 extendido al final de /dev/sda` y `/store` queda en **13,3 G** con 12,6 G
libres, sin tocar nada a mano.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
La fila de la tabla se leía como «bloquear sitios». El cortafuegos NO sabe de sitios: su política de
egress tiene UNA dimensión, qué cgroup sale, y ninguna de destino (leído en UnidadRed, no supuesto).
El foco que estas piezas dan es «el navegador no sale», con todo lo local andando — promesa más
honesta, además: una lista de dominios se esquiva con un espejo; un cgroup sin egress no.
Medido con nuestro propio nft, en el LXC donde somos root:
linea-base exit=0 · control exit=0 · foco exit=1
Tres medidas y no una: la línea base porque un harness roto se lee igual que un foco que funciona, y
el control porque un reglaset que niega de más tampoco se distingue. `--broken-rules` abre egress al
cgroup en foco y el guardián falla nombrándolo (sale 1). Verificado en los dos sentidos.
Restricciones reales que salieron de medir: crear un cgroup pide root (EPERM incluso en un userns con
CAP_ALL), y `nft -c` no es sintaxis — resuelve el path del cgroup contra la máquina viva.
⚠ Y lo que rompí en el camino, documentado: `/sys/fs/cgroup` está montado SHARED, así que desmontar
la copia de un --rbind se propaga al montaje real. Dejé al worker sin cgroup2 dos veces, en silencio.
Remontado y jerarquía intacta. Ahora todo va en un mount namespace propio (--propagation private),
sin `umount -l` antes de un rm -rf, y con /proc/mounts consultado antes de borrar.
Tres falsos positivos más que cazó el harness, todos con cara de éxito: un gmp viejo tomado por
`ls store/*-gmp` (sin .so ⇒ nada conectaba, y el lab del hub lo tapaba), /sbin fuera del PATH del
chroot (sin `ip`, loopback caído), y un COMENTARIO que se ejecutó — backticks dentro de un heredoc
sin comillas corrieron `ip`/`ifconfig` en el host y pegaron su salida en el guión generado.
Entra por el §6.5 del SDD 26 —el «modo foco» de atuq lo sostiene el cortafuegos del sistema y no
una extensión que el navegador puede apagar— pero `nftables` estaba `wanted` en el grafo desde
antes: la cadena sirve igual al servidor de producción del SDD 28.
Selladas y MEDIDAS con el consumidor, no por presencia:
nft --version → nftables v1.1.6, corriendo desde nuestro artefacto
nft -c -f / nft -f → aceptan Y APLICAN en un netns privado (bwrap --unshare-net + CAP_NET_ADMIN)
socket cgroupv2 level 1 "<cgroup>" → la expresión EXACTA que genera cortafuegos-core: aplica
Dos hallazgos del camino, los dos en los comentarios de la receta:
1. nftables NO reproduce de fábrica: `MAKE_STAMP` es `$(shell date +%s)` y `nftversion.h` lo mete
byte a byte en el binario — la familia del BuildID de waterfox. No mira SOURCE_DATE_EPOCH. Se
fija en 0, que además es lo correcto: el sello sólo sirve para comparar qué nft creó una tabla,
y dos builds de la misma versión SON el mismo nft.
2. su `config.status` trae un bashismo (`for ((i = 56; ...))`) que busybox rechaza con
«bad for loop variable». Se arregla en el parche y no con CONFIG_SHELL=bash: el lab no entra en
`hash_inputs`, así que apoyarse en su bash sería una dependencia invisible.
Y una medición que condiciona al guardián que viene: `nft -c` NO es un chequeo de sintaxis — resuelve
el path del cgroup contra la máquina viva y falla con «cgroupv2 path fails» si no existe. O sea que
verificar una política del cortafuegos exige crear los cgroups, y eso pide root.
Cerrado: clave de release estable, 88/88 del perfil publicados con hash anclado, servidos por la
caja, y el control de firma en los dos sentidos (confianza buena ⇒ instala; confianza vacía ⇒
aborta).
El lazo destapó dos cosas que sólo se ven cerrándolo:
- **`strip_debug` no viajaba en el `.swm`** y ES entrada de hash ⇒ 40 recetas del corpus (18 de las
88 de este perfil) no se podían instalar. Nadie lo sabía porque nunca se había hecho el viaje
completo receta → paquete → `install --require-signed`.
- **El loopback nunca se levantaba.** `lo` DOWN, y el síntoma era un timeout de 30 s con caddy
escuchando: un cuelgue que parece del servidor, no un "connection refused".
Y deja el límite escrito, que es lo que más vale: **la caja sirve, verifica y descarga, pero NO
puede reproducir**. `install` construye desde fuente y eso exige el LAB ENTERO en el cliente — no es
que falte un compilador: el lab entra en el ArtifactHash, así que sin él no se puede ni calcular el
hash a comparar. Es el SDD 27 §4 visto desde el otro lado: para cualquiera que no sea un hub de
build, reproducir no es una opción, y el default tiene que ser hidratar artefactos firmados.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
`build-repo.sh` generaba una clave nueva en cada corrida si no se le pasaba `KEY=`. Un índice firmado
con una clave que nadie conoce y que cambia cada vez no lo verifica nadie: es decoración. El SDD 19
§3.2 lo dice sin vueltas — la firma sin gestión de claves es teatro.
- `trust/release.ed25519.pub` — la clave PÚBLICA, en el repo, que es donde tiene que estar para que
un cliente pueda verificar. La privada vive en `~/.config/takana/keys/release.ed25519` (0600),
fuera del repo, mismo trato que las credenciales del Storage Box.
- `trust/README.md` dice también **lo que esto NO es**: no hay clave raíz fuera de línea, ni
rotación, ni procedimiento de filtración. Cierra el agujero de la clave efímera, NO el §3.2.
- `scripts/repo-perfil.sh` publica la clausura de un perfil y **aborta si no encuentra la clave**, en
vez de inventar una. El conjunto sale de `yupana.membresia()` sobre `targets.toml`, la misma fuente
que usa la imagen ⇒ el repo y la imagen no pueden divergir: son la misma lista.
- Y trae su propia guarda: tras firmar, VERIFICA el índice contra `trust/` y falla si no ancla.
Medido: 88/88 del perfil `servidor` con `expected_hash` anclado, índice firmado que verifica.
Control en los dos sentidos contra el repo servido por la caja:
--trust ./trust => "release: trusted (by release)" ⇒ instala
--trust <dir vacío> => "release: unknown-key ... clave no confiada" ⇒ ABORTA
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
En un sistema con systemd u OpenRC el init levanta `lo`. Con arje-zero no lo hacía **nadie**.
Medido en la caja de producción el 2026-09-10:
ip -o link show lo => lo: <LOOPBACK> DOWN (y CERO direcciones)
caddy escuchando en 0.0.0.0:80
curl http://127.0.0.1/index.json => timeout de 30 s
No «connection refused», que se diagnostica en un minuto: un CUELGUE, que parece del servidor. Se
descubrió intentando que la caja instalara un paquete de su propio repo — o sea que el primer
servicio que habló con 127.0.0.1 fue también el primero en chocarse.
Cualquier cosa que use loopback fallaba igual: un backend detrás de un proxy, un socket TCP entre
demonios, un repo local. Va en `netup` porque es quien configura la red, y no es fatal: si ya estaba
levantado los errores son EEXIST y no deben tumbar el arranque.
Probado en un netns, en los dos estados: antes `lo: <LOOPBACK>` con 0 direcciones; después
`lo: <LOOPBACK,UP,LOWER_UP>` con `inet 127.0.0.1/8 scope host`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
Encontrado instalando `takana` desde su propio repo (SDD 28 §5), que es la primera vez que se hace
el viaje completo receta → `.tkn` → `install --require-signed` sobre un paquete con `strip_debug`.
Error: expected_hash no coincide:
declarado = b3:941d1857e6e93ebbfbad8240a1c30405263e5ff8fc881a923aee312160563eea
obtenido = b3:2727050ebdbd7817b53e79ffe9796569e9364454ed35474b51e881fcc6048766
`why-differs` sobre los dos artefactos lo nombró: las recetas selladas diferían en `version`,
`license` y **`strip_debug`**. Los dos binarios pesaban EXACTAMENTE lo mismo (3.374.768 bytes) y sólo
divergían en `.shstrtab` — la firma de un `strip` que corrió una vez y la otra no.
`swm_bridge` re-inyecta con cuidado `flags`, `phases`, `zig_version`, `strip_components`, `patches`,
`deps`, `evidence` y `slots` («fidelidad de reconstrucción», dice su comentario) y **se olvidaba de
`strip_debug`**, que ni siquiera existía en `SwmBuild` ⇒ no viajaba en el `.swm` en absoluto. Como
ENTRA EN `hash_inputs` (`recipe.rs:572`, con el comentario «cambia el CONTENIDO del artefacto, así
que TIENE que entrar»), el receptor reconstruía con `None` y sellaba en otra dirección.
**Alcance medido: 40 recetas del corpus lo usan, 18 de las 88 del perfil `servidor`.** Para todas,
el `expected_hash` anclado por `pack --build` no coincidía NUNCA y `install --require-signed`
abortaba. O sea: una quinta parte del repo no se podía instalar, y nadie lo sabía porque nunca se
había cerrado el lazo.
Por qué no lo cazó nadie antes: `tree` —sin `strip_debug`— da cache-hit y funciona perfecto. El bug
sólo aparece en las recetas que lo declaran, y el dogfood previo no las tocaba.
Test de regresión con su control: una receta CON `strip_debug` lo lleva en el `.swm`, y una SIN él no
gana un `false` inventado. 221 tests de takana-core en verde.
Verificado end-to-end tras el arreglo: `install takana --repo http://<caja> --require-signed` da
cache-hit en `b3:941d1857…` —el mismo hash anclado— e hidrata los 3 ficheros.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
Encontrado con `ps` sobre la máquina, no por un test: al cerrar el sandbox quedaban
vivos el daemon y su `bwrap` interno, sosteniendo overlays ya borrados. Es correcto que
el daemon se quede —su torrent de prueba no tiene enjambre, así que nunca termina y
nunca está «sin nada»—, lo que faltaba era despedirlo y MEDIR que se fue.
- el guión de adentro le pide `puriy-costura-torrent stop` (el verbo del producto);
- la huella del socket se anota adentro y ANTES del stop: el daemon lo borra al irse;
- el guardián censa daemons antes/después con `ps -C` (nunca `pkill -f`) y falla
nombrando el PID; el `finally` barre lo propio para que un test rojo no deje basura;
- tope de 180 s al sandbox, como seguro contra un cuelgue.
Comprobado en los dos sentidos con una copia rota fuera del repo: sin el `stop` el
guardián daba ✓ igual, y con la aserción nueva sale ✗ nombrando el PID. La hipótesis
de que se COLGARÍA era falsa: el `bwrap` externo vuelve; el que espera es el interno.
Verde: positivo, control negativo y rotura a propósito.
El corpus construía 869 recetas y no la suya. `takana` sólo existía como `cargo build --release`
sobre un clon del repo, y eso bloqueaba lo de arriba de todo del SDD 28: que un servidor takana se
sirva sus propios paquetes **a su propio host**. El host necesita `takana` instalado para consumir
el repo, y no podía instalarlo DESDE el repo porque no estaba en el repo.
**El muro, que nadie había tocado porque takana nunca se había empaquetado:** `takana-cli` declara
DOS `[[bin]]` sobre el mismo `main.rs` (`takana` y `hammer`, la compatibilidad de la etapa 2 del
ADR 0016). El camino Cargo del corpus invoca `cargo rustc … -- -C target-feature=+crt-static`, y
`cargo rustc` con argumentos extra **sólo admite UN target**:
error: extra arguments to `rustc` can only be passed to one target, consider filtering
the package by passing, e.g., `--lib` or `--bin NAME` to specify a single target
O sea: la decisión de emitir dos binarios —tomada para que el alias sobreviviera a `cargo clean` y a
la siembra de la granja, que excluye `/target`— hacía que el crate no se pudiera empaquetar. Se
resuelve con `--bin takana` y reponiendo `hammer` como ENLACE en la fase de instalación: en el árbol
de build un symlink no sobrevive, pero en el ARTEFACTO sellado es permanente y no duplica los megas.
Y ese alias tapa un agujero real: **`arje-zero` todavía invoca el binario por el nombre viejo** — en
el primer arranque de la caja de producción se leyó `hammer no corre, sin menú de arranque`. Viene
pineado desde tawasuyu, así que no se arregla desde este repo; mientras `hammer` esté en el
artefacto, el menú de arranque por grafo del ADR 0010 tiene qué ejecutar. Cuando la etapa 6 retire
el alias, hay que subir arje-zero ANTES.
Sellado `b3:941d1857…`, 3,3 M con el split de debug. Verificado: ELF estático, `takana --version` y
`hammer --version` dan los dos `takana 0.0.1`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
El §6.9 apunta ahora a tawasuyu `3bd1440d` (`b3:27c7b73a`), que arregla un daemon que
quedaba VIVO PARA SIEMPRE cuando su sesión de torrent no arrancaba: el bucle hacía
`let Ok(g) = gestor() else { continue }` y el `continue` saltaba la evaluación de
«¿sobro?».
Y lo que queda escrito en el §6.9 es CÓMO se encontró, porque es lo reutilizable: no lo
encontró ningún test sino mirar `ps` después de las pruebas —un daemon con 23 minutos y
`idle_seconds = 300`—. Los tests cubrían la decisión como función PURA, y esa función
estaba bien: el que no llegaba a llamarla era el bucle. **Una decisión correcta que
nadie toma se ve igual que una que no existe.**
Los dos guardianes vuelven a pasar sobre los artefactos vigentes (`atuq b3:d1a444ad`,
`puriy-costura b3:cad5c855`, `puriy-costura-torrent b3:27c7b73a`): el positivo con
`TOMADO prueba-atuq.bin` y el socket que nadie arrancó, y el control negativo nombrando
lo que falta.