Toda la variante (b) del SDD 11 §7.2 vivía sólo en scripts/rust-frontier/README y notas dispersas; el SDD §7.2 y el runbook §8c quedaban en "Pendiente: linux-headers → bwrap → rust/llvm". Nuevo runbook docs/runbooks/ self-hosting-toolchain.md documenta el end-state: - las dos categorías de swap y los dos anclajes de of_tree (9adefb82 rustc Alpine / 7fa6cb4e hammer-rust auto-consistente); - tabla de las piezas from-source (make/busybox/coreutils/bwrap/linux-headers /rust/binutils + patch/m4/pkgconf) con hash, flag y si están en-camino; - las 3 corridas verify (5-swap, rust, capstone 6-swap) con comandos y resultados ✓ REPRODUCIBLE in-VM; - el mecanismo --swap (archivo/directorio/rust-overlay); - gotchas reusables (zig miscompila→gcc, musl 256 TLS keys, CARGO_BUILD_JOBS=1, zsh :u / word-splitting); - binutils inerte verificado por bisección. SDD §7.2 y runbook §8c ahora marcan variante (b) cerrada y enlazan el runbook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
32 KiB
Runbook — validar Stage 1 booteando en QEMU
Operativo, no diseño. Valida end-to-end que el rootfs que produce
hammer bootstrap stage1(SDD 11) bootea con arje-zero como PID 1 (SDD 12), levantahammerd+ getty bajo supervisión, y que elCRASHEDreal funciona.Esto es un procedimiento, no un transcript verificado. La lógica de ensamblado/hash/seed está cubierta por unit tests (y la seed se validó contra
card_core::Card), pero el cross-compile y el boot reales no se han corrido todavía — ese es justo el punto de este runbook. La primera corrida puede destapar ajustes (link,/dev/console, getty/tty); §7 los anticipa.
0. Lo que valida / lo que no
- Valida: que el árbol sellado por
stage1arranca como sistema: arje-zero monta los pseudo-FS, carga la seed, incarnahammerdy la getty, y al matarhammerdlo reinicia con backoff (elCRASHEDque la Fase 5 difirió). - No valida (aún): bus único (B.2), overlay del store (I4), atestación (A1/A2), kernel from-source. Aquí el kernel es prestado del host; el overlay no se monta.
1. Prerrequisitos
- Lab de dev montado:
./scripts/bootstrap-devfs.sh(deja.dev-fs/alpine+.dev-fs/tools/zig). - Red: el build de
hammerdyarje-zeroclona repos git (hammer, tawasuyu) y correcargo vendoren el fetch. El build hermético del sandbox es--offline, pero el fetch necesita red. - Herramientas host:
qemu-system-x86_64, un kernel x86_64 (/boot/vmlinuz-*),cpio,gzip. - Espacio/tiempo: construir
arje-zerovendorea las deps del monorepo tawasuyu entero — es pesado y lento la primera vez (se cachea después). - El binario
hammer:cargo build --release -p hammer-cli⇒./target/release/hammer.
El store debe vivir en la raíz del repo (
./store) para queBuildConfigresuelva.dev-fs/alpinecomo hermano (store/..= repo root). El toolchain de Stage 1 no es el de.dev-fs: es la semilla de Stage 0 (paso 2), questage1enchufa comozig_dirdel sandbox.
2. Stage 0 — ingerir la semilla (zig)
export HAMMER=./target/release/hammer
export STORE=./store
"$HAMMER" --store "$STORE" bootstrap stage0 \
--url https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz \
--sha256 70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 \
--version 0.16.0
# → imprime el seed_hash (b3:...). Guardalo:
SEED="b3:..." # pegá el hash impreso
Idempotente: re-correr no re-descarga. La semilla queda sellada como seed-zig en el store y anota
su línea en bootstrap.json.
3. Stage 1 — construir y sellar el rootfs
"$HAMMER" --store "$STORE" bootstrap stage1 --seed-hash "$SEED" --recipes recipes
# construye musl + busybox + hammerd + arje-zero, ensambla el rootfs FHS y lo sella.
# → imprime el rootfs_hash (b3:...).
# El árbol sellado (read-only) del rootfs:
ROOTFS_DIR=$(ls -d "$STORE"/*-stage1-rootfs)
echo "$ROOTFS_DIR"
ls "$ROOTFS_DIR"/ente/seed.card.json "$ROOTFS_DIR"/sbin/init "$ROOTFS_DIR"/usr/bin/{hammerd,arje-zero}
Necesita red (git clone + cargo vendor). Si un build Cargo falla en el link (-lgcc_s) o por
static-PIE, ver §7 — el zig cc del lab debería resolver -lgcc_s (bitácora M2 del plan arje↔hammer).
4. Empaquetar el rootfs como initramfs
El árbol del store ya trae /sbin/init→/usr/bin/arje-zero y /ente/seed.card.json. Se empaqueta
como cpio newc + gzip (formato initramfs):
( cd "$ROOTFS_DIR" && find . -print0 | cpio --null -o --format=newc ) | gzip -9 > /tmp/stage1.cpio.gz
ls -lh /tmp/stage1.cpio.gz
cpio newc preserva symlinks y hardlinks. El symlink /sbin/init→/usr/bin/arje-zero es absoluto:
en un initramfs resuelve dentro de su propia raíz. (Si el kernel no auto-monta devtmpfs, ver §7 por
el nodo /dev/console.)
5. Bootear en QEMU
qemu-system-x86_64 -m 512 -nographic \
-kernel /boot/vmlinuz-linux \
-initrd /tmp/stage1.cpio.gz \
-append "console=ttyS0 rdinit=/sbin/init"
rdinit=/sbin/init— el default de un initramfs es/init; lo sobreescribimos a/sbin/init(→arje-zero). Alternativa directa:rdinit=/usr/bin/arje-zero.console=ttyS0+-nographic— consola serie, donde arje escribe y la getty se engancha.- Salir de QEMU:
Ctrl-AluegoX.
6. Criterios de éxito (qué observar)
- PID 1: en kmsg/consola, arje-zero loguea "despierta como PID 1".
- Pseudo-FS: monta
/proc /sys /devsin error fatal (best-effort; unEBUSYse ignora). - Seed: carga
/ente/seed.card.jsonsin "card inválida" (ya validada contracard_core::Card). - hammerd: arranca como Ente — su tracing aparece y/o crea
/run/agent.sock. - getty: aparece un prompt de shell (
/bin/sh) enttyS0. - CRASHED real — la prueba clave. En el shell:
arje registra el
kill "$(pidof hammerd)"on_death(ExitStatus)y reinicia hammerd tras el backoff (200 ms → ×2…). Eso es elCRASHEDque la Fase 5 dejó diferido, ahora manejado por el init propio.
7. Troubleshooting (síntoma → causa → fix)
| Síntoma | Causa probable | Fix |
|---|---|---|
Kernel panic … no init found |
/sbin/init no ejecuta |
probar rdinit=/usr/bin/arje-zero; verificar que el symlink quedó en el cpio (cpio -tv) |
| arje cae a rescue shell "card inválida" | la seed no validó | confirmar que /ente/seed.card.json se empaquetó; comparar con el schema de seeds/arje-qemu.card.json |
hammerd/arje-zero: exec format error |
binario no estático / falta loader | confirmar link = "static"; un static-PIE pide /lib/ld-musl-x86_64.so.1 — para init-clásico desactivar PIE (-C relocation-model=static, bitácora M2) |
Build Cargo falla en -lgcc_s |
hueco del toolchain musl dinámico | el lab usa zig cc (empaqueta su compiler-rt) → no debería aparecer; verificar compiler = "zig-cc" en la receta |
| getty sin prompt | device console no engancha tty |
probar argv con ttyS0 en vez de console; revisar que /dev/console existe |
No working init / sin /dev/console |
kernel sin auto-mount de devtmpfs | crear el nodo en el cpio (mknod -m 600 dev/console c 5 1 antes de empaquetar) o usar un kernel con CONFIG_DEVTMPFS_MOUNT=y |
cargo vendor / git clone falla |
el fetch necesita red | correr Stage 1 con red; el sandbox de build sigue siendo --offline |
8. Bitácora — primera corrida real (2026-06-11)
Corrida en el host de desarrollo (Artix). Resultado: el userland C builda end-to-end; el camino Rust está bloqueado por una decisión de toolchain. Hallazgos, en orden:
| # | Etapa | Resultado |
|---|---|---|
| 1 | Lab (bootstrap-devfs.sh) |
✅ Alpine + zig 0.16.0 + 43 build-tools |
| 2 | Stage 0 (ingerir zig) | ✅ seed_hash sellado, idempotente |
| 3 | musl 1.2.5 | ✅ cross-compila con zig cc y se sella — el cross-compile C hermético funciona |
| 4 | busybox 1.36.1 | ✅ tras tres fixes (abajo); compila y linkea con zig cc, se sella |
| 5 | hammerd (Cargo) | ❌ bloqueado: triple de toolchain (abajo) |
| 6 | arje-zero (Cargo) | ⏸ no alcanzado (mismo bloqueo; además vendorea el monorepo entero) |
Fixes aplicados a recipes/busybox.toml (incompatibilidades GNU-toolchain ↔ zig, esperadas):
gcchardcodeado. El Kbuild de busybox fijaCC = gcc/HOSTCC = gcccon=(ignora el envCCdel sandbox). Fix: pasarCC='zig cc' HOSTCC='zig cc'en línea de comando de make.- Flags GNU-ld que zig cc rechaza. El link pasa
-Wl,--warn-common,-Wl,-Map,…,-Wl,--verbose;zig ccvalida los-Wl,y aborta. Fix: quitarlos descripts/trylinkcon sed. - (
gccpara herramientas host comofixdepygcc-version.shlo cubre el mismoHOSTCC.)
Bloqueo del camino Rust — decisión de toolchain (plan C.2):
- El vendoring (Lote 6) corre OK en el fetch (host, con red). El
cargo buildcorre en el sandbox. - Se añadió
rust cargoal rootfs (apk) — pero el rust de Alpine tiene host triplex86_64-alpine-linux-musl, mientrasBuildSys::Cargopide--target x86_64-unknown-linux-musl. Alpine no trae ese std y no hay rustup →error[E0463]: can't find crate for core. - Opciones (a decidir): (A) construir nativo sin
--target(el sandbox ya es x86_64 musl) — cambio enBuildSys::Cargo; (B) recetas Cargo contarget = "x86_64-alpine-linux-musl"— leakage de Alpine; (C) rustup en el sandbox +rustup target add— pierde hermeticidad/pin. Es la misma tensión de "pin de toolchain entra al hash" que el plan C.2 dejó abierta.
Stage 1 completo + intento de boot
Tras los fixes, los 4 componentes buildan y el rootfs se sella (musl+busybox+hammerd+arje-zero,
29 MB; /sbin/init→arje-zero, /ente/seed.card.json, loader musl). Se empaquetó como initramfs
(cpio newc+gzip, 11 MB) y se booteó en QEMU. La cadena de hallazgos del boot:
- AVX. El CPU default de QEMU (
qemu64) no tiene AVX, pero los binarios (zig/rust nativos) emitenvxorps→SIGILL(invalid_op) en el loader → init muere. Fix:-cpu Broadwell(AVX2 bajo TCG; sin/dev/kvmno hay-cpu host). Para un distro real: compilar a un baseline x86-64 genérico, no al CPU del builder. - musl dinámico. El rust de Alpine produce binarios dinámicos (
DT_NEEDED libc.musl-x86_64.so.1), no crt-static. El loader corre pero no resuelvememcpy/memset/log2/…: nuestrolibc.so(musl 1.2.5 shared con zig cc) tiene 1586 dyn-syms pero no exporta esos símbolos. →Error relocating /sbin/init: memcpy: symbol not found→ init muere.
Conclusión. El bootstrap C (musl+busybox+zig cc) y el build Rust nativo (hammerd y
arje-zero compilan y se sellan) están validados end-to-end; el kernel arranca el initramfs y
ejecuta arje-zero como PID 1. El último bloqueo del boot es el linking dinámico de musl: el fix
correcto y golden-path es crt-static — compilar los componentes Rust con
-C target-feature=+crt-static -C relocation-model=static para binarios autocontenidos (sin
libc.so, sin loader, sin soname, como busybox). Requiere rebuild de hammerd/arje-zero (el de
arje-zero vendorea el monorepo: caro en tiempo y disco). Es la realización plena de la "vía de oro"
estática de hammer y cierra de paso el problema del soname de Alpine.
Actualización (Opción A implementada). BuildSys::Cargo ahora construye nativo sin --target
cuando el target es el del sandbox (SANDBOX_NATIVE_TARGET), con un wrapper .hammer-zig-cc que
(a) resuelve el zig cc de dos palabras para cargo/cc-rs y (b) sanea el triple que cc-rs pasa
(--target=x86_64-alpine-linux-musl → x86_64-linux-musl, que zig sí entiende). Resultado:
hammerd compila nativo y se sella ✓ — el camino Rust funciona. arje-zero queda bloqueado por
otra razón: tawasuyu no commitea Cargo.lock (git ls-files Cargo.lock vacío), así que el
git archive no lo trae y cargo vendor --locked no puede crearlo. Decisión pendiente: commitear
el lock en tawasuyu (reproducible, plan C.2 #5) vs relajar --locked en el vendoring de hammer.
Cierres posteriores. (1) Vendoring --locked condicional al lock committeado → arje-zero
vendorea sin --locked (genera el lock) y se sella: los 4 componentes buildan. (2) Boot:
AVX → -cpu Broadwell; y el linking dinámico de musl (Alpine rust es dinámico por defecto →
DT_NEEDED libc.musl-x86_64.so.1; nuestro libc.so no exporta memcpy/…) → fix crt-static.
(3) Iteración del crt-static (cada paso destapó el siguiente; hammerd valida barato):
+crt-static [-C relocation-model=static] en RUSTFLAGS rompe los proc-macros (dylibs PIC) en
build nativo → solución: cargo rustc --release … -- <flags>, que aplica los flags sólo a la
crate top-level. musl vuelve a --disable-shared (no se necesita loader).
✅ BOOT LOGRADO (2026-06-11)
Con cargo rustc -- +crt-static -C relocation-model=static, arje-zero y hammerd salen
statically linked (ET_EXEC, sin DT_NEEDED, sin interpreter), el rootfs se sella, y el boot en
QEMU (-cpu Broadwell) levanta el sistema:
arje_zero: ente-zero despierta como PID 1
arje_zero::seed: Tarjeta Semilla cargada y validada path=/ente/seed.card.json
arje_zero: Ente #0 entra al bucle primordial label=hammer-stage1
arje_zero::graph::lifecycle: instanciando genesis count=2
Ente encarnado label=hammerd pid=Some(Pid(59))
Ente encarnado label=console-getty pid=Some(Pid(60))
~ # (shell en consola)
hammerd: arranque agent_sock=/run/agent.sock …
hammerd: watcher fanotify activo
hammerd::bus: escuchando sock=/run/agent.sock
arje-zero es PID 1, carga+valida la seed y supervisa hammerd + getty; hammerd corre con su
watcher fanotify y su agent.sock. Stage 1 bootea end-to-end. Comando reproducible:
qemu-system-x86_64 -m 512 -no-reboot -nographic -cpu Broadwell -kernel <vmlinuz> -initrd stage1.cpio.gz -append "console=ttyS0 rdinit=/sbin/init".
✅ CRASHED real demostrado (lo que la Fase 5 difirió)
Desde la shell de la consola, kill $(pidof hammerd) y arje-zero lo supervisa de verdad:
kill $(pidof hammerd)
arje_zero::graph::lifecycle: Ente disuelto label=hammerd status=Killed(SIGTERM)
arje_zero::graph::lifecycle: Restart programado label=hammerd delay_ms=200
arje_zero::graph::lifecycle: Ente encarnado label=hammerd pid=Some(Pid(64))
hammerd: arranque agent_sock=/run/agent.sock …
Detecta la muerte con su ExitStatus (Killed(SIGTERM)), programa el restart con backoff
(200 ms) y re-encarna hammerd (pid 59→64). Es la supervisión real del init propio — el CRASHED
que ADR 0007 prometía y la Fase 5 había diferido. Exponerlo
a la capa de IA por agent.sock es B.2 (bus único).
8b. Stage 2 — verificación de reproducibilidad (pre-rebuild-in-rootfs)
El stage2 de hammer ancla el content-hash (ArtifactHash::of_tree, los bytes reales) del
rootfs y lo compara con un rebuild. El rebuild dentro del rootfs usando sólo las herramientas
de Stage 1 (auto-alojamiento pleno) necesita un builder rootfs — el Stage 1 mínimo es un
runtime (no trae zig/make/cargo), así que ese paso es un hito aparte. Lo que sí se verificó
acá: la bit-reproducibilidad del build (dos builds independientes del mismo componente →
diff -r):
| Componente | Reproducible | Notas |
|---|---|---|
| musl 1.2.5 | ✅ byte-idéntico | el build C con zig cc es determinista de fábrica |
| busybox 1.36.1 | ✅ tras dos fixes | (ver abajo) |
| hammerd (Rust) | ✅ byte-idéntico | crt-static + cargo --locked + SOURCE_DATE_EPOCH + paths fijos /src + el workspace de hammer pinea codegen-units = 1 ⇒ rust reproducible |
| arje-zero (Rust) | ✅ tras fix codegen-units | dos builds del host divergían ~10 KB repartidos por .text/.rodata/.eh_frame: el workspace tawasuyu no fija [profile.release] → cargo usa el default codegen-units = 16 y el codegen paralelo de rustc no es reproducible. Fix: el sandbox impone CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 para todas las crates (ver nota de cierre §8c). Verificado: dos builds cu=1 → byte-idénticos |
La verificación cazó dos no-determinismos reales en busybox (justo lo que SDD 09 §2 pide):
- Config interactiva. Tras editar
.config, el compile corresilentoldconfig, que ante opciones NEW (WERROR, etc.) prompea leyendo stdin → el set de applets salía distinto por corrida (build no-determinista y flaky). Fix:yes '' | make oldconfig(acepta defaults, determinista; busybox 1.36.1 no tieneolddefconfig). - Headers del kernel. Con el config determinista, los applets de
console-toolsexigenlinux/kd.h(UAPI) que zig no bundlea →linux-headersen el lab (bootstrap-devfs.sh); zig cc nativo los encuentra en/usr/include.
Con ambos, musl y busybox reconstruyen bit-idéntico. Sumados hammerd y arje-zero (este
último tras el fix de codegen-units, nota de cierre §8c), los 4 componentes reconstruyen byte-a-byte
— cada uno verificado con dos builds independientes del host. Lo único que le queda a Stage 2 es el
rebuild dentro de un builder rootfs (un Stage 1 que incluya el toolchain) — el auto-alojamiento
pleno, un hito propio (el Stage 1 mínimo es runtime, sin compilador).
8c. Builder rootfs — el rebuild in-rootfs (auto-alojamiento, SDD 11 §7)
Variante (b) — auto-alojamiento puro — CERRADA. Este §8c cubre el mecanismo con toolchain Alpine (variante a). Para el toolchain construido por hammer desde fuente (make/busybox/coreutils/ bwrap/linux-headers + rust + binutils) y la verificación capstone de los 6 swaps juntos in-VM, ver self-hosting-toolchain.md.
El ensamblado del builder ya está implementado (hammer bootstrap builder, SDD 11 §7.4;
variante a, toolchain desde Alpine). Produce, sobre el Stage 1 rootfs, una imagen con el toolchain
adentro lista para reconstruirse a sí misma. Receta operacional:
1. Binario hammer estático (musl). El builder lo bootea como PID-algo en la VM, así que debe
ser autocontenido (como hammerd/arje-zero, crt-static — §7):
cargo build --release --target x86_64-unknown-linux-musl -p hammer-cli
# (o cargo rustc -- -C target-feature=+crt-static, según el camino de oro del boot)
2. Ensamblar el builder anclando la referencia of_tree(stage1) (la que imprimió stage2):
hammer --store store bootstrap builder \
--stage1 b3:<STAGE1> --seed-hash b3:<SEED> \
--hammer-bin target/x86_64-unknown-linux-musl/release/hammer \
--toolchain .dev-fs/alpine --toolchain-tag alpine-3.23.4 \
--ref-content b3:<OF_TREE_STAGE1> \
--work-cache work \
--out work/builder-rootfs
--work-cache work embebe sólo work/repos (mirrors git) y work/tarballs (descargas verificadas)
en /work ⇒ rebuild offline y determinista en la VM (de ahí fetch rematerializa los árboles
fuente). No copia work/sources (los árboles ya materializados): es regenerable y pesa varios GB.
Sin --work-cache, el rebuild fetchea por red (la seed card trae networking: full).
3. Empaquetar como initramfs y bootear (igual que §4/§7, -cpu Broadwell por AVX). El script
scripts/boot-builder-vm.sh encapsula esto (kernel, MEM, KVM configurables):
# --owner=root:root es OBLIGATORIO: si los ficheros viajan con el uid del que armó el builder
# (p. ej. 1000), ese uid no está mapeado en el userns de bwrap (mapea 0→0) y el copy-up de overlay
# del sandbox falla con EACCES (`Can't mkdir /opt/zig: Permission denied`).
( cd work/builder-rootfs && find . -print0 | cpio --null -o -H newc --owner=root:root | gzip -1 ) > work/builder.cpio.gz
./scripts/boot-builder-vm.sh # o el qemu crudo:
qemu-system-x86_64 -m 6144 -no-reboot -nographic -cpu Broadwell \
-kernel /boot/vmlinuz-linux -initrd work/builder.cpio.gz -append "console=ttyS0 rdinit=/sbin/init"
RAM. El initramfs se desempaqueta a un tmpfs (~1.2 GB) y el rebuild Rust necesita varios GB más, TODO en RAM. Con host ~7 GB y sin KVM va justo y lento (TCG). Sube
MEMo monta un disco para/worksi el rebuild muere por OOM.
✅ Boot del builder confirmado (2026-06-11). El initramfs (410 MB gz, 26 776 entradas) bootea
end-to-end: arje-zero despierta como PID 1, carga+valida la seed card, instancia los 2 genesis
(hammerd pid 60 + console-getty pid 61), hammerd levanta su watcher fanotify y el agent.sock,
y la getty deja la shell ~ # en consola. Idéntico al boot de Stage 1 (§7) pero con el toolchain
adentro — listo para el rebuild.
4. Dentro de la VM, desde la shell de la consola, correr el driver embebido:
rebuild-stage1
# >> rebuild stage1 (toolchain in-rootfs, semilla b3:3ce721ec…)
# >> stage1' = b3:…
# ✓ REPRODUCIBLE: stage1' == stage1 (auto-alojado bit a bit) ← of_tree(stage1')==REF_CONTENT
rebuild-stage1 apunta HAMMER_ROOTFS=/toolchain, corre hammer bootstrap stage1 con la semilla y
las recetas de adentro, y compara con stage2 --verify $REF_CONTENT. ✓ REPRODUCIBLE cierra el
auto-alojamiento end-to-end de la variante (a); ✗ DIVERGENTE caza un no-determinismo nuevo
(SDD 09 §2). La variante (b) — el toolchain construido por hammer desde
fuente — reemplaza /toolchain Alpine pieza a pieza, con este mismo lazo verificando cada paso.
✅ Rebuild in-rootfs ejecutado (2026-06-11): 2/4 componentes sellados en la VM
Corrida real (QEMU TCG, -m 6144, sin KVM). El driver atravesó, en orden, una cadena de muros
reales — cada uno destapado sólo por correr el rebuild dentro de la VM (justo lo que Stage 2
existe para cazar) y arreglado:
- Toolchain incompleto. Traía compiladores (cargo/make/zig) pero no la orquestación del lab:
bwrap(anida el sandbox),git(git archivedel mirror),curl(tarballs). En el host las da el sistema; en la VM el toolchain es el único userland ⇒apk add bubblewrap git curl. - Binarios dinámicos vs userland estático. El toolchain es Alpine dinámico; el Stage 1 es musl
estático (sin loader en
/lib) ⇒ no corrían. Fix: shim del loader (cpdelld-musla/lib+LD_LIBRARY_PATH/PATH) enrebuild-stage1. - overlay es módulo.
CONFIG_OVERLAY_FS=my el initramfs arranca sin módulos ⇒bwrap --tmp-overlaydabaENODEV. Fix:insmod /toolchain/lib/overlay.ko(+kmod;overlay.kodel kernel destino inyectado en el builder). - Ownership del initramfs. El copy-up de overlay daba
EACCES(Can't mkdir /opt/zig): los ficheros viajaban con uid 1000, sin mapear en el userns de bwrap (mapea 0→0). Fix: empaquetar concpio --owner=root:root.
Con eso, musl y busybox se reconstruyeron y sellaron dentro de la VM con el toolchain de adentro (configure→compile→install→seal; ~35–38 min/componente bajo TCG). El mecanismo de auto-alojamiento queda probado sobre builds reales, no sólo en diseño.
Muro pendiente — deps Rust offline. hammerd abortó en cargo vendor (failed to sync): el
/work embebido lleva los mirrors git de las fuentes (repos/) pero no las dependencias
crates.io, y la VM no tiene red. Los componentes C sellan (tarballs autocontenidos); los Rust
(hammerd, arje-zero) necesitan, para offline, o red en la VM (qemu -netdev user + NIC +
/etc/resolv.conf, los repos privados ya están mirroreados) o las crates vendoreadas en /work.
Sumado al coste de los builds Rust bajo TCG (horas, RAM justa ⇒ riesgo de OOM en arje-zero), el
veredicto pleno 4/4 se completa en un host con KVM + más RAM (y, idealmente, un builder
booteado desde disco en vez de initramfs puro, que además evita la fricción ramfs/ownership).
Rust-offline cruzado + un no-determinismo cazado: el CPU del builder (2026-06-11)
El muro Rust-offline cayó. Se dio red a la VM (NIC e1000 qemu user-mode + insmod e1000.ko +
config estática de eth0 10.0.2.15 + DNS de slirp + CA bundle de Alpine copiado a la ruta estándar,
commits 09034a5/fd20e38). Con eso cargo vendor resolvió crates.io entero (DNS+TLS), y
hammerd se compiló, instaló y selló dentro de la VM (74 m 35 s bajo TCG). Para no pagar el
vendoring del monorepo de arje-zero bajo TCG, esta corrida preseed-eó musl/busybox/arje-zero en
el store del builder y dejó sólo hammerd para reconstruir in-VM (driver work/drive-rebuild.py).
Veredicto: ✗ DIVERGENTE — y es justo lo que Stage 2 existe para cazar.
of_tree(stage1)=b3:94a4f1… (host) vs of_tree(stage1')=b3:ab615b… (VM). El seal hash del rootfs
sí coincidió (b3:f11ca6e5…), pero el seal es of_inputs (recipe + (name,hash) de componentes):
mismo key ≠ mismos bytes. Lo único reconstruido in-VM era hammerd, así que la divergencia está
ahí.
Diagnóstico (experimento de control en el host, sin VM):
- Rebuild de
hammerden el host → byte-idéntico al cacheado (cmplimpio, dos veces). La receta es determinista host-a-host; el cache no está stale. - ⇒ la divergencia es del entorno de build de la VM. La única entrada de codegen no fijada es el
CPU del builder:
zig cc(que compila el C/asm de los build-scripts: blake3, curve25519-dalek) default a-mcpu=nativey hornea la ISA de quien compila. - Evidencia ISA: el
hammerddel host traesha256rnds2(SHA-NI) ×32 y 3530 instrucciones AVX2 (ymm). El host tienesha_ni+avx2;-cpu Broadwell(el de la VM bajo TCG) NO tiene SHA-NI ⇒ codegen distinto ⇒ bytes distintos ⇒of_treedistinto. Es la misma raíz que el SIGILL de AVX del §7 (binarios al CPU del builder, no a un baseline).
Fix — -mcpu=baseline en TODOS los sitios zig cc (codegen al baseline x86-64 genérico,
independiente del CPU): el CC/CXX del sandbox (sandbox.rs), los dos wrappers Cargo
(.hammer-zig-cc, lib.rs) y los CC/HOSTCC de la receta de busybox. Las rutas SIMD que el
runtime sí usa (blake3/sha2) son asm con dispatch en runtime: siguen presentes y funcionando. Bonus:
cierra de paso el AVX/SIGILL del §7 (el binario corre en qemu64 sin -cpu Broadwell).
Validado en el host: con baseline, hammerd cambia de bytes (1 672 448 vs 1 677 584 — confirma
que el default NO era baseline) y los 3 componentes rebuildan limpio (stage1 rc=0). Nueva
referencia CPU-independiente: of_tree(stage1-baseline)=b3:4408e44e2ec51c3769deffcd64dd1f6c2010d414418937c3fa7930a0ded3f845
(con musl 57b66a2e+busybox 56664d70+arje bd0f8475+hammerd d08fd273, todos baseline salvo arje
que quedó preseed nativo). Nota de fondo (plan C.2): el flag de CPU no entra hoy en of_inputs,
así que un cambio de toolchain/flags da cache-hit con bytes viejos — la misma tensión "pin de
toolchain al hash" que conviene cerrar para que Stage 2 no dependa de borrar el store a mano (por eso
el refresh de abajo borró los artefactos viejos a mano antes de reconstruir).
Store refrescado a baseline 4/4 + builder listo (2026-06-11). Se reconstruyeron los 4
componentes con el toolchain baseline (incluido arje-zero, que re-vendorea el monorepo) en un store
limpio, y se promovió a ./store (el nativo quedó en store-native-bak). arje-zero baseline
difiere del nativo (char 25) ⇒ baseline aplicado. Referencia CPU-independiente full-4/4:
of_tree(stage1)=b3:198f209f5e2c9d3a37411823b2ea42a09074d4e482f044278958b1b86f232dbb
(rootfs key b3:2bd7c91b…; nótese que el key of_inputs es idéntico al del preseed-nativo —
el cambio de bytes de arje no movió su key: la misma escotilla C.2). El builder se re-ensambló con
ese toolchain + el hammer baseline + --ref-content 198f209f… (embebida en
/etc/hammer/rebuild.env) y se repackó work/builder.cpio.gz (415 MB, 27 027 entradas,
--owner=root:root). Listo para bootear: scripts/boot-builder-vm.sh (o scripts/drive-rebuild.py
no-interactivo) → adentro rebuild-stage1 debe reproducir 198f209f… ⇒ ✓ REPRODUCIBLE
cerraría el auto-alojamiento 4/4 bit a bit. Bajo TCG es lento y la RAM va justa; el veredicto
pleno gana con KVM + más RAM (y boot desde disco).
No-determinismo #3 cazado — arje-zero y codegen-units (2026-06-12). El rebuild in-VM dio
✗ DIVERGENTE (of_tree host 984e002f… ≠ VM 94b93585…) pese a que los 4 componentes sellaban
bajo el mismo key of_inputs. La caza (bisección por componente en el host): musl, busybox y
hammerd reconstruyen byte-idéntico entre dos builds del host, y la ensambladura del rootfs
(of_tree) es determinista e independiente del entorno. El culpable era arje-zero: dos builds
del mismo host daban binarios distintos (Δ ~9.6 KB repartidos por .text −8064, .rodata −896,
.eh_frame, .gcc_except_table, .data.rel.ro, .got) — la firma del codegen paralelo de
rustc. Raíz: el workspace de hammer pinea [profile.release] codegen-units = 1 (por eso hammerd
reproducía), pero el monorepo tawasuyu no declara perfil → cargo usa el default codegen-units = 16.
Con 16 unidades el reparto del crate en N objetos varía build-a-build incluso con entradas idénticas,
y la salida cambia. Fix: el sandbox impone CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 para todas las
crates Cargo (crates/hammer-build/src/sandbox.rs) — el lab elimina el no-determinismo en vez de
confiar en que cada repo upstream lo declare (SDD 09 §2). Verificado: dos
builds cu=1 de arje-zero → byte-idénticos, y el of_tree(stage1) queda estable. Nueva referencia
reproducible 4/4 (cu=1): of_tree(stage1)=b3:0039b2b90fddf62221ad5b804a34df93ef57dc533cb210512cf53752876afdff
(reemplaza la 198f209f… de cu=16, que ya era no-reproducible). El store del host se refrescó a este
baseline y EXPECT_REF en selfhost-verify.sh se actualizó.
✓ REPRODUCIBLE — verificado end-to-end in-VM (2026-06-12). KVM=1 MEM=24576 ./scripts/selfhost-verify.sh
en el host libre (cachyos, /dev/kvm + swap 32G): la VM reconstruyó los 4/4 con el toolchain de
adentro (arje-zero cu=1 compiló en 27m32s in-VM), selló el rootfs key 2bd7c91b… y su
of_tree(stage1') = b3:0039b2b9… igualó la referencia ⇒ ✓ REPRODUCIBLE: stage1' == stage1 (auto-alojado bit a bit), DRIVER_RC=0. El auto-alojamiento bit-a-bit de Stage 1 queda cerrado y
confirmado en el bucle completo (host↔VM), no sólo host↔host.
✓ REPRODUCIBLE con swaps de la variante (b) — verificado end-to-end in-VM (2026-06-13).
KVM=1 MEM=24576 SWAP_MAKE=1 SWAP_BUSYBOX=1 ./scripts/selfhost-verify.sh en libre: el builder se
ensambla pisando el /toolchain Alpine con el make (b3:fbad44ac…) y el busybox
(b3:56664d70…) que hammer compiló desde fuente, y la VM reconstruye los 4/4 con ese toolchain
hammerizado — incluido busybox compilándose a sí mismo con hammer-busybox de shell. Sellaron in-VM:
musl 57b66a2e…, busybox 56664d70…, hammerd d08fd273…, arje-zero bd0f8475… (cu=1), y el
of_tree(stage1') igualó la referencia 9adefb82… ⇒ ✓ REPRODUCIBLE: stage1' == stage1,
DRIVER_RC=0 (RESULTADO: ✓ REPRODUCIBLE, elapsed 3261 s ≈ 54 min con arje-zero cu=1 in-VM).
La procedencia del builder deja de ser "todo Alpine": make+busybox son ya de hammer, auditados, y
el auto-alojamiento sigue siendo bit-a-bit. Siguiente pieza de la variante (b): linux-headers →
bwrap → rust/llvm (cada una swapeada y re-verificada con este mismo comando).
Tarea reproducible end-to-end: scripts/selfhost-verify.sh. Un solo comando hace TODO el pipeline
(stage0 → stage1 baseline → stage2 ref → inyectar overlay.ko/e1000.ko del kernel local → ensamblar
builder → empaquetar → bootear + driver no-interactivo → veredicto), pensado para correr en una
máquina con KVM + RAM holgada (laptop):
./scripts/bootstrap-devfs.sh # una vez: el lab (.dev-fs/alpine + zig)
KVM=1 MEM=10240 ./scripts/selfhost-verify.sh
# PRESEED=hammerd → preseed C+arje y reconstruye sólo hammerd in-VM (barato si la RAM va justa)
Cross-check de reproducibilidad entre máquinas: el script compara su of_tree(stage1) contra
EXPECT_REF (la referencia conocida-buena 0039b2b9… del host de dev, baseline cu=1). Si difieren, o
cambió un pin (toolchain/recipes/seed) o hay un no-determinismo nuevo. En el host de dev (Artix, 7.6 GB, sin KVM)
el rebuild 4/4 in-VM colgó por presión de RAM bajo TCG a los ~17 min del make de musl — el muro
de hardware que esta tarea traslada a un host capaz.
9. Cross-check opcional — arje-packager
arje trae su propio empaquetador (03_ukupacha/arje/init/arje-packager):
arje-packager --seed <CARD.json> --out <initramfs.cpio.gz> [--bin LABEL=PATH]…. Útil para comparar
el initramfs canónico de arje contra el que sale del rootfs de hammer (§4). No reemplaza esta
validación: aquí probamos el rootfs que hammer sella, no el que arje arma.