tawasuyu

sergio/takana

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

TOML 1569Shell 203Rust 109Markdown 993728 Definiciones

⎇ b05badee231d3f5f3a8ad808397aa9ece272dbc4 docs/runbooks/stage1-vm-boot.md Markdown · 486 líneas · 31.5 KB Historia crudo

selfhost-verify: variante (b) ✓ REPRODUCIBLE in-VM con make+busybox swaps 26b10bd75 · sergio ·

1# Runbook — validar Stage 1 booteando en QEMU
2
3> Operativo, no diseño. Valida end-to-end que el rootfs que produce
4> `hammer bootstrap stage1` ([SDD 11](../11-bootstrap.md)) **bootea con arje-zero como PID 1**
5> ([SDD 12](../12-init-real.md)), levanta `hammerd` + getty bajo supervisión, y que el **`CRASHED`
6> real** funciona.
7>
8> **Esto es un procedimiento, no un transcript verificado.** La lógica de ensamblado/hash/seed está
9> cubierta por unit tests (y la seed se validó contra `card_core::Card`), pero el cross-compile y el
10> boot reales **no** se han corrido todavía — ese es justo el punto de este runbook. La primera
11> corrida puede destapar ajustes (link, `/dev/console`, getty/tty); §7 los anticipa.
12
13## 0. Lo que valida / lo que no
14
15- **Valida:** que el árbol sellado por `stage1` arranca como sistema: arje-zero monta los pseudo-FS,
16  carga la seed, incarna `hammerd` y la getty, y al matar `hammerd` lo reinicia con backoff (el
17  `CRASHED` que la Fase 5 difirió).
18- **No valida (aún):** bus único (B.2), overlay del store (I4), atestación (A1/A2), kernel
19  from-source. Aquí el kernel es prestado del host; el overlay no se monta.
20
21## 1. Prerrequisitos
22
23- **Lab de dev** montado: `./scripts/bootstrap-devfs.sh` (deja `.dev-fs/alpine` + `.dev-fs/tools/zig`).
24- **Red**: el build de `hammerd` y `arje-zero` clona repos git (hammer, tawasuyu) y corre
25  `cargo vendor` en el fetch. El build hermético del sandbox es `--offline`, pero el fetch necesita red.
26- **Herramientas host**: `qemu-system-x86_64`, un kernel x86_64 (`/boot/vmlinuz-*`), `cpio`, `gzip`.
27- **Espacio/tiempo**: construir `arje-zero` vendorea las deps del **monorepo tawasuyu entero** — es
28  pesado y lento la primera vez (se cachea después).
29- **El binario `hammer`**: `cargo build --release -p hammer-cli` ⇒ `./target/release/hammer`.
30
31> El store debe vivir en la raíz del repo (`./store`) para que `BuildConfig` resuelva
32> `.dev-fs/alpine` como hermano (`store/..` = repo root). El toolchain de Stage 1 **no** es el de
33> `.dev-fs`: es la semilla de Stage 0 (paso 2), que `stage1` enchufa como `zig_dir` del sandbox.
34
35## 2. Stage 0 — ingerir la semilla (zig)
36
37```sh
38export HAMMER=./target/release/hammer
39export STORE=./store
40
41"$HAMMER" --store "$STORE" bootstrap stage0 \
42  --url https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz \
43  --sha256 70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 \
44  --version 0.16.0
45# → imprime el seed_hash (b3:...). Guardalo:
46SEED="b3:..."   # pegá el hash impreso
47```
48
49Idempotente: re-correr no re-descarga. La semilla queda sellada como `seed-zig` en el store y anota
50su línea en `bootstrap.json`.
51
52## 3. Stage 1 — construir y sellar el rootfs
53
54```sh
55"$HAMMER" --store "$STORE" bootstrap stage1 --seed-hash "$SEED" --recipes recipes
56# construye musl + busybox + hammerd + arje-zero, ensambla el rootfs FHS y lo sella.
57# → imprime el rootfs_hash (b3:...).
58
59# El árbol sellado (read-only) del rootfs:
60ROOTFS_DIR=$(ls -d "$STORE"/*-stage1-rootfs)
61echo "$ROOTFS_DIR"
62ls "$ROOTFS_DIR"/ente/seed.card.json "$ROOTFS_DIR"/sbin/init "$ROOTFS_DIR"/usr/bin/{hammerd,arje-zero}
63```
64
65Necesita red (git clone + `cargo vendor`). Si un build Cargo falla en el link (`-lgcc_s`) o por
66static-PIE, ver §7 — el `zig cc` del lab debería resolver `-lgcc_s` (bitácora M2 del plan arje↔hammer).
67
68## 4. Empaquetar el rootfs como initramfs
69
70El árbol del store ya trae `/sbin/init`→`/usr/bin/arje-zero` y `/ente/seed.card.json`. Se empaqueta
71como cpio `newc` + gzip (formato initramfs):
72
73```sh
74( cd "$ROOTFS_DIR" && find . -print0 | cpio --null -o --format=newc ) | gzip -9 > /tmp/stage1.cpio.gz
75ls -lh /tmp/stage1.cpio.gz
76```
77
78`cpio newc` preserva symlinks y hardlinks. El symlink `/sbin/init`→`/usr/bin/arje-zero` es absoluto:
79en un initramfs resuelve dentro de su propia raíz. (Si el kernel no auto-monta `devtmpfs`, ver §7 por
80el nodo `/dev/console`.)
81
82## 5. Bootear en QEMU
83
84```sh
85qemu-system-x86_64 -m 512 -nographic \
86  -kernel /boot/vmlinuz-linux \
87  -initrd /tmp/stage1.cpio.gz \
88  -append "console=ttyS0 rdinit=/sbin/init"
89```
90
91- `rdinit=/sbin/init` — el default de un initramfs es `/init`; lo sobreescribimos a `/sbin/init`
92  (→arje-zero). Alternativa directa: `rdinit=/usr/bin/arje-zero`.
93- `console=ttyS0` + `-nographic` — consola serie, donde arje escribe y la getty se engancha.
94- Salir de QEMU: `Ctrl-A` luego `X`.
95
96## 6. Criterios de éxito (qué observar)
97
981. **PID 1**: en kmsg/consola, arje-zero loguea *"despierta como PID 1"*.
992. **Pseudo-FS**: monta `/proc /sys /dev` sin error fatal (best-effort; un `EBUSY` se ignora).
1003. **Seed**: carga `/ente/seed.card.json` sin *"card inválida"* (ya validada contra `card_core::Card`).
1014. **hammerd**: arranca como Ente — su tracing aparece y/o crea `/run/agent.sock`.
1025. **getty**: aparece un prompt de shell (`/bin/sh`) en `ttyS0`.
1036. **CRASHED real** — la prueba clave. En el shell:
104   ```sh
105   kill "$(pidof hammerd)"
106   ```
107   arje registra el `on_death(ExitStatus)` y **reinicia hammerd tras el backoff** (200 ms → ×2…).
108   Eso es el `CRASHED` que la Fase 5 dejó diferido, ahora manejado por el init propio.
109
110## 7. Troubleshooting (síntoma → causa → fix)
111
112| Síntoma | Causa probable | Fix |
113|---|---|---|
114| `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`) |
115| 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` |
116| 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) |
117| 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 |
118| getty sin prompt | device `console` no engancha tty | probar `argv` con `ttyS0` en vez de `console`; revisar que `/dev/console` existe |
119| `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` |
120| `cargo vendor` / git clone falla | el fetch necesita red | correr Stage 1 con red; el sandbox de build sigue siendo `--offline` |
121
122## 8. Bitácora — primera corrida real (2026-06-11)
123
124Corrida en el host de desarrollo (Artix). **Resultado: el userland C builda end-to-end; el camino
125Rust está bloqueado por una decisión de toolchain.** Hallazgos, en orden:
126
127| # | Etapa | Resultado |
128|---|---|---|
129| 1 | Lab (`bootstrap-devfs.sh`) | ✅ Alpine + zig 0.16.0 + 43 build-tools |
130| 2 | Stage 0 (ingerir zig) | ✅ `seed_hash` sellado, idempotente |
131| 3 | **musl 1.2.5** | ✅ cross-compila con zig cc y se sella — **el cross-compile C hermético funciona** |
132| 4 | **busybox 1.36.1** | ✅ tras tres fixes (abajo); compila y **linkea** con zig cc, se sella |
133| 5 | **hammerd** (Cargo) | ❌ bloqueado: triple de toolchain (abajo) |
134| 6 | arje-zero (Cargo) | ⏸ no alcanzado (mismo bloqueo; además vendorea el monorepo entero) |
135
136**Fixes aplicados a `recipes/busybox.toml`** (incompatibilidades GNU-toolchain ↔ zig, esperadas):
137
1381. **`gcc` hardcodeado.** El Kbuild de busybox fija `CC = gcc` / `HOSTCC = gcc` con `=` (ignora el
139   env `CC` del sandbox). Fix: pasar `CC='zig cc' HOSTCC='zig cc'` en **línea de comando** de make.
1402. **Flags GNU-ld que zig cc rechaza.** El link pasa `-Wl,--warn-common`, `-Wl,-Map,…`,
141   `-Wl,--verbose`; `zig cc` valida los `-Wl,` y aborta. Fix: quitarlos de `scripts/trylink` con sed.
1423. (`gcc` para herramientas host como `fixdep` y `gcc-version.sh` lo cubre el mismo `HOSTCC`.)
143
144**Bloqueo del camino Rust — decisión de toolchain (plan C.2):**
145
146- El vendoring (Lote 6) corre OK en el fetch (host, con red). El `cargo build` corre en el sandbox.
147- Se añadió `rust cargo` al rootfs (apk) — pero el rust de Alpine tiene host triple
148  **`x86_64-alpine-linux-musl`**, mientras `BuildSys::Cargo` pide **`--target x86_64-unknown-linux-musl`**.
149  Alpine no trae ese std y no hay rustup → `error[E0463]: can't find crate for core`.
150- **Opciones (a decidir):** (A) construir nativo sin `--target` (el sandbox ya es x86_64 musl) —
151  cambio en `BuildSys::Cargo`; (B) recetas Cargo con `target = "x86_64-alpine-linux-musl"` — leakage
152  de Alpine; (C) rustup en el sandbox + `rustup target add` — pierde hermeticidad/pin. Es la misma
153  tensión de "pin de toolchain entra al hash" que el plan C.2 dejó abierta.
154
155### Stage 1 completo + intento de boot
156
157Tras los fixes, **los 4 componentes buildan y el rootfs se sella** (musl+busybox+hammerd+arje-zero,
15829 MB; `/sbin/init`→arje-zero, `/ente/seed.card.json`, loader musl). Se empaquetó como initramfs
159(`cpio newc`+gzip, 11 MB) y se booteó en QEMU. La cadena de hallazgos del boot:
160
1611. **AVX.** El CPU default de QEMU (`qemu64`) no tiene AVX, pero los binarios (zig/rust nativos)
162   emiten `vxorps` → `SIGILL` (`invalid_op`) en el loader → init muere. Fix: `-cpu Broadwell`
163   (AVX2 bajo TCG; sin `/dev/kvm` no hay `-cpu host`). Para un distro real: compilar a un baseline
164   x86-64 genérico, no al CPU del builder.
1652. **musl dinámico.** El rust de Alpine produce binarios **dinámicos** (`DT_NEEDED
166   libc.musl-x86_64.so.1`), no crt-static. El loader corre pero no resuelve `memcpy/memset/log2/…`:
167   nuestro `libc.so` (musl 1.2.5 shared con zig cc) tiene 1586 dyn-syms pero **no exporta** esos
168   símbolos. → `Error relocating /sbin/init: memcpy: symbol not found` → init muere.
169
170**Conclusión.** El bootstrap **C** (musl+busybox+zig cc) y el **build Rust nativo** (hammerd y
171arje-zero compilan y se sellan) están **validados** end-to-end; el kernel arranca el initramfs y
172ejecuta arje-zero como PID 1. El último bloqueo del boot es el **linking dinámico de musl**: el fix
173correcto y golden-path es **crt-static** — compilar los componentes Rust con
174`-C target-feature=+crt-static -C relocation-model=static` para binarios **autocontenidos** (sin
175`libc.so`, sin loader, sin soname, como busybox). Requiere rebuild de hammerd/arje-zero (el de
176arje-zero vendorea el monorepo: caro en tiempo y disco). Es la realización plena de la "vía de oro"
177estática de hammer y cierra de paso el problema del soname de Alpine.
178
179**Actualización (Opción A implementada).** `BuildSys::Cargo` ahora construye **nativo** sin `--target`
180cuando el target es el del sandbox (`SANDBOX_NATIVE_TARGET`), con un wrapper `.hammer-zig-cc` que
181(a) resuelve el `zig cc` de dos palabras para cargo/cc-rs y (b) **sanea el triple** que cc-rs pasa
182(`--target=x86_64-alpine-linux-musl` → `x86_64-linux-musl`, que zig sí entiende). Resultado:
183**hammerd compila nativo y se sella ✓** — el camino Rust funciona. **arje-zero queda bloqueado** por
184otra razón: **tawasuyu no commitea `Cargo.lock`** (`git ls-files Cargo.lock` vacío), así que el
185`git archive` no lo trae y `cargo vendor --locked` no puede crearlo. Decisión pendiente: commitear
186el lock en tawasuyu (reproducible, plan C.2 #5) vs relajar `--locked` en el vendoring de hammer.
187
188**Cierres posteriores.** (1) Vendoring `--locked` **condicional** al lock committeado → arje-zero
189vendorea sin `--locked` (genera el lock) y **se sella**: los 4 componentes buildan. (2) Boot:
190AVX → `-cpu Broadwell`; y el linking **dinámico** de musl (Alpine rust es dinámico por defecto →
191`DT_NEEDED libc.musl-x86_64.so.1`; nuestro `libc.so` no exporta `memcpy/…`) → fix **crt-static**.
192(3) Iteración del crt-static (cada paso destapó el siguiente; hammerd valida barato):
193`+crt-static [-C relocation-model=static]` en **RUSTFLAGS** rompe los proc-macros (dylibs PIC) en
194build nativo → **solución:** `cargo rustc --release … -- <flags>`, que aplica los flags sólo a la
195crate top-level. musl vuelve a `--disable-shared` (no se necesita loader).
196
197### ✅ BOOT LOGRADO (2026-06-11)
198
199Con `cargo rustc -- +crt-static -C relocation-model=static`, `arje-zero` y `hammerd` salen
200**`statically linked` (ET_EXEC, sin `DT_NEEDED`, sin interpreter)**, el rootfs se sella, y el boot en
201QEMU (`-cpu Broadwell`) **levanta el sistema**:
202
203```
204arje_zero: ente-zero despierta como PID 1
205arje_zero::seed: Tarjeta Semilla cargada y validada  path=/ente/seed.card.json
206arje_zero: Ente #0 entra al bucle primordial  label=hammer-stage1
207arje_zero::graph::lifecycle: instanciando genesis  count=2
208  Ente encarnado  label=hammerd        pid=Some(Pid(59))
209  Ente encarnado  label=console-getty  pid=Some(Pid(60))
210~ #                                          (shell en consola)
211hammerd: arranque  agent_sock=/run/agent.sock …
212hammerd: watcher fanotify activo
213hammerd::bus: escuchando  sock=/run/agent.sock
214```
215
216**arje-zero es PID 1**, carga+valida la seed y **supervisa hammerd + getty**; hammerd corre con su
217watcher fanotify y su `agent.sock`. **Stage 1 bootea end-to-end.** Comando reproducible:
218`qemu-system-x86_64 -m 512 -no-reboot -nographic -cpu Broadwell -kernel <vmlinuz> -initrd
219stage1.cpio.gz -append "console=ttyS0 rdinit=/sbin/init"`.
220
221### ✅ `CRASHED` real demostrado (lo que la Fase 5 difirió)
222
223Desde la shell de la consola, `kill $(pidof hammerd)` y arje-zero lo supervisa de verdad:
224
225```
226kill $(pidof hammerd)
227arje_zero::graph::lifecycle: Ente disuelto      label=hammerd  status=Killed(SIGTERM)
228arje_zero::graph::lifecycle: Restart programado label=hammerd  delay_ms=200
229arje_zero::graph::lifecycle: Ente encarnado     label=hammerd  pid=Some(Pid(64))
230hammerd: arranque  agent_sock=/run/agent.sock …
231```
232
233Detecta la muerte **con su `ExitStatus`** (`Killed(SIGTERM)`), programa el restart con **backoff**
234(200 ms) y **re-encarna** hammerd (pid 59→64). Es la supervisión real del init propio — el `CRASHED`
235que [ADR 0007](../adr/0007-arje-como-init-propio.md) prometía y la Fase 5 había diferido. Exponerlo
236a la capa de IA por `agent.sock` es B.2 (bus único).
237
238## 8b. Stage 2 — verificación de reproducibilidad (pre-rebuild-in-rootfs)
239
240El `stage2` de hammer ancla el **content-hash** (`ArtifactHash::of_tree`, los bytes reales) del
241rootfs y lo compara con un rebuild. El rebuild **dentro** del rootfs usando *sólo* las herramientas
242de Stage 1 (auto-alojamiento pleno) necesita un **builder rootfs** — el Stage 1 mínimo es un
243*runtime* (no trae zig/make/cargo), así que ese paso es un hito aparte. Lo que **sí** se verificó
244acá: la **bit-reproducibilidad del build** (dos builds independientes del mismo componente →
245`diff -r`):
246
247| Componente | Reproducible | Notas |
248|---|---|---|
249| **musl** 1.2.5 | ✅ byte-idéntico | el build C con zig cc es determinista de fábrica |
250| **busybox** 1.36.1 | ✅ tras dos fixes | (ver abajo) |
251| **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 |
252| **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 |
253
254**La verificación cazó dos no-determinismos reales en busybox** (justo lo que [SDD 09 §2](../09-trust-model.md) pide):
255
2561. **Config interactiva.** Tras editar `.config`, el compile corre `silentoldconfig`, que ante
257   opciones NEW (WERROR, etc.) **prompea leyendo stdin** → el set de applets salía distinto por
258   corrida (build no-determinista *y* flaky). Fix: `yes '' | make oldconfig` (acepta defaults,
259   determinista; busybox 1.36.1 no tiene `olddefconfig`).
2602. **Headers del kernel.** Con el config determinista, los applets de `console-tools` exigen
261   `linux/kd.h` (UAPI) que zig **no** bundlea → `linux-headers` en el lab (`bootstrap-devfs.sh`);
262   zig cc nativo los encuentra en `/usr/include`.
263
264Con ambos, `musl` y `busybox` reconstruyen **bit-idéntico**. Sumados `hammerd` y `arje-zero` (este
265último tras el fix de `codegen-units`, nota de cierre §8c), **los 4 componentes reconstruyen byte-a-byte**
266— cada uno verificado con dos builds independientes del host. Lo único que le queda a Stage 2 es el
267rebuild *dentro* de un **builder rootfs** (un Stage 1 que incluya el toolchain) — el auto-alojamiento
268pleno, un hito propio (el Stage 1 mínimo es runtime, sin compilador).
269
270## 8c. Builder rootfs — el rebuild in-rootfs (auto-alojamiento, SDD 11 §7)
271
272El **ensamblado** del builder ya está implementado (`hammer bootstrap builder`, [SDD 11 §7.4](../11-bootstrap.md);
273variante a, toolchain desde Alpine). Produce, sobre el Stage 1 rootfs, una imagen con el toolchain
274adentro lista para reconstruirse a sí misma. Receta operacional:
275
276**1. Binario `hammer` estático (musl).** El builder lo bootea como PID-algo en la VM, así que debe
277ser autocontenido (como hammerd/arje-zero, crt-static — §7):
278
279```sh
280cargo build --release --target x86_64-unknown-linux-musl -p hammer-cli
281# (o cargo rustc -- -C target-feature=+crt-static, según el camino de oro del boot)
282```
283
284**2. Ensamblar el builder** anclando la referencia `of_tree(stage1)` (la que imprimió `stage2`):
285
286```sh
287hammer --store store bootstrap builder \
288    --stage1 b3:<STAGE1> --seed-hash b3:<SEED> \
289    --hammer-bin target/x86_64-unknown-linux-musl/release/hammer \
290    --toolchain .dev-fs/alpine --toolchain-tag alpine-3.23.4 \
291    --ref-content b3:<OF_TREE_STAGE1> \
292    --work-cache work \
293    --out work/builder-rootfs
294```
295
296`--work-cache work` embebe sólo `work/repos` (mirrors git) y `work/tarballs` (descargas verificadas)
297en `/work` ⇒ rebuild **offline y determinista** en la VM (de ahí `fetch` rematerializa los árboles
298fuente). **No** copia `work/sources` (los árboles ya materializados): es regenerable y pesa varios GB.
299Sin `--work-cache`, el rebuild fetchea por red (la seed card trae `networking: full`).
300
301**3. Empaquetar como initramfs y bootear** (igual que §4/§7, `-cpu Broadwell` por AVX). El script
302`scripts/boot-builder-vm.sh` encapsula esto (kernel, MEM, KVM configurables):
303
304```sh
305# --owner=root:root es OBLIGATORIO: si los ficheros viajan con el uid del que armó el builder
306# (p. ej. 1000), ese uid no está mapeado en el userns de bwrap (mapea 0→0) y el copy-up de overlay
307# del sandbox falla con EACCES (`Can't mkdir /opt/zig: Permission denied`).
308( cd work/builder-rootfs && find . -print0 | cpio --null -o -H newc --owner=root:root | gzip -1 ) > work/builder.cpio.gz
309./scripts/boot-builder-vm.sh        # o el qemu crudo:
310qemu-system-x86_64 -m 6144 -no-reboot -nographic -cpu Broadwell \
311    -kernel /boot/vmlinuz-linux -initrd work/builder.cpio.gz -append "console=ttyS0 rdinit=/sbin/init"
312```
313
314> **RAM.** El initramfs se desempaqueta a un tmpfs (~1.2 GB) y el rebuild Rust necesita varios GB
315> más, TODO en RAM. Con host ~7 GB y sin KVM va justo y lento (TCG). Sube `MEM` o monta un disco
316> para `/work` si el rebuild muere por OOM.
317
318**✅ Boot del builder confirmado (2026-06-11).** El initramfs (410 MB gz, 26 776 entradas) **bootea
319end-to-end**: arje-zero despierta como **PID 1**, carga+valida la seed card, instancia los 2 genesis
320(**hammerd** pid 60 + **console-getty** pid 61), hammerd levanta su watcher fanotify y el `agent.sock`,
321y la getty deja la shell `~ #` en consola. Idéntico al boot de Stage 1 (§7) pero con el toolchain
322adentro — listo para el rebuild.
323
324**4. Dentro de la VM**, desde la shell de la consola, correr el driver embebido:
325
326```sh
327rebuild-stage1
328# >> rebuild stage1 (toolchain in-rootfs, semilla b3:3ce721ec…)
329# >> stage1' = b3:…
330# ✓ REPRODUCIBLE: stage1' == stage1 (auto-alojado bit a bit)     ← of_tree(stage1')==REF_CONTENT
331```
332
333`rebuild-stage1` apunta `HAMMER_ROOTFS=/toolchain`, corre `hammer bootstrap stage1` con la semilla y
334las recetas de adentro, y compara con `stage2 --verify $REF_CONTENT`. **✓ REPRODUCIBLE** cierra el
335auto-alojamiento end-to-end de la variante (a); **✗ DIVERGENTE** caza un no-determinismo nuevo
336([SDD 09 §2](../09-trust-model.md)). La variante (b) — el toolchain construido por hammer desde
337fuente — reemplaza `/toolchain` Alpine pieza a pieza, con este mismo lazo verificando cada paso.
338
339### ✅ Rebuild in-rootfs ejecutado (2026-06-11): 2/4 componentes sellados en la VM
340
341Corrida real (QEMU TCG, `-m 6144`, sin KVM). El driver atravesó, en orden, una cadena de muros
342reales — cada uno destapado **sólo** por correr el rebuild *dentro* de la VM (justo lo que Stage 2
343existe para cazar) y arreglado:
344
3451. **Toolchain incompleto.** Traía compiladores (cargo/make/zig) pero no la orquestación del lab:
346   `bwrap` (anida el sandbox), `git` (`git archive` del mirror), `curl` (tarballs). En el host las
347   da el sistema; en la VM el toolchain es el único userland ⇒ `apk add bubblewrap git curl`.
3482. **Binarios dinámicos vs userland estático.** El toolchain es Alpine dinámico; el Stage 1 es musl
349   estático (sin loader en `/lib`) ⇒ no corrían. Fix: **shim del loader** (`cp` del `ld-musl` a
350   `/lib` + `LD_LIBRARY_PATH`/`PATH`) en `rebuild-stage1`.
3513. **overlay es módulo.** `CONFIG_OVERLAY_FS=m` y el initramfs arranca sin módulos ⇒ `bwrap
352   --tmp-overlay` daba `ENODEV`. Fix: `insmod /toolchain/lib/overlay.ko` (+ `kmod`; `overlay.ko` del
353   kernel destino inyectado en el builder).
3544. **Ownership del initramfs.** El copy-up de overlay daba `EACCES` (`Can't mkdir /opt/zig`): los
355   ficheros viajaban con uid 1000, sin mapear en el userns de bwrap (mapea 0→0). Fix: empaquetar con
356   `cpio --owner=root:root`.
357
358Con eso, **musl** y **busybox** se reconstruyeron y **sellaron dentro de la VM** con el toolchain de
359adentro (configure→compile→install→seal; ~35–38 min/componente bajo TCG). El mecanismo de
360auto-alojamiento queda **probado sobre builds reales**, no sólo en diseño.
361
362**Muro pendiente — deps Rust offline.** `hammerd` abortó en `cargo vendor` (`failed to sync`): el
363`/work` embebido lleva los **mirrors git de las fuentes** (`repos/`) pero no las **dependencias
364crates.io**, y la VM no tiene red. Los componentes C sellan (tarballs autocontenidos); los Rust
365(`hammerd`, `arje-zero`) necesitan, para offline, **o** red en la VM (qemu `-netdev user` + NIC +
366`/etc/resolv.conf`, los repos privados ya están mirroreados) **o** las crates vendoreadas en `/work`.
367Sumado al coste de los builds Rust bajo TCG (horas, RAM justa ⇒ riesgo de OOM en `arje-zero`), el
368veredicto pleno **4/4** se completa en un host con **KVM + más RAM** (y, idealmente, un builder
369booteado desde **disco** en vez de initramfs puro, que además evita la fricción ramfs/ownership).
370
371### Rust-offline cruzado + un no-determinismo cazado: el CPU del builder (2026-06-11)
372
373**El muro Rust-offline cayó.** Se dio red a la VM (NIC e1000 qemu user-mode + `insmod e1000.ko` +
374config estática de `eth0` 10.0.2.15 + DNS de slirp + CA bundle de Alpine copiado a la ruta estándar,
375commits `09034a5`/`fd20e38`). Con eso `cargo vendor` resolvió **crates.io entero** (DNS+TLS), y
376**`hammerd` se compiló, instaló y selló dentro de la VM** (74 m 35 s bajo TCG). Para no pagar el
377vendoring del monorepo de arje-zero bajo TCG, esta corrida **preseed-eó** musl/busybox/arje-zero en
378el store del builder y dejó **sólo hammerd** para reconstruir in-VM (driver `work/drive-rebuild.py`).
379
380**Veredicto: `✗ DIVERGENTE`** — y es justo lo que Stage 2 existe para cazar.
381`of_tree(stage1)=b3:94a4f1…` (host) vs `of_tree(stage1')=b3:ab615b…` (VM). El **seal hash** del rootfs
382sí coincidió (`b3:f11ca6e5…`), pero el seal es `of_inputs` (recipe + `(name,hash)` de componentes):
383**mismo key ≠ mismos bytes**. Lo único reconstruido in-VM era `hammerd`, así que la divergencia está
384ahí.
385
386**Diagnóstico (experimento de control en el host, sin VM):**
387
3881. **Rebuild de `hammerd` en el host → byte-idéntico** al cacheado (`cmp` limpio, dos veces). La
389   receta es determinista host-a-host; el cache **no** está stale.
3902. ⇒ la divergencia es **del entorno de build de la VM**. La única entrada de codegen no fijada es el
391   **CPU del builder**: `zig cc` (que compila el C/asm de los build-scripts: blake3, curve25519-dalek)
392   **default a `-mcpu=native`** y hornea la ISA de quien compila.
3933. **Evidencia ISA:** el `hammerd` del host trae `sha256rnds2` (SHA-NI) ×32 y 3530 instrucciones
394   AVX2 (`ymm`). El host tiene `sha_ni`+`avx2`; **`-cpu Broadwell` (el de la VM bajo TCG) NO tiene
395   SHA-NI** ⇒ codegen distinto ⇒ bytes distintos ⇒ `of_tree` distinto. Es la misma raíz que el SIGILL
396   de AVX del §7 (binarios al CPU del builder, no a un baseline).
397
398**Fix — `-mcpu=baseline` en TODOS los sitios `zig cc`** (codegen al baseline x86-64 genérico,
399independiente del CPU): el `CC`/`CXX` del sandbox (`sandbox.rs`), los dos wrappers Cargo
400(`.hammer-zig-cc`, `lib.rs`) y los `CC`/`HOSTCC` de la receta de busybox. Las rutas SIMD que el
401runtime sí usa (blake3/sha2) son asm con dispatch en runtime: siguen presentes y funcionando. Bonus:
402cierra de paso el AVX/SIGILL del §7 (el binario corre en qemu64 sin `-cpu Broadwell`).
403
404**Validado en el host:** con baseline, `hammerd` cambia de bytes (1 672 448 vs 1 677 584 — confirma
405que el default NO era baseline) y **los 3 componentes rebuildan limpio** (`stage1 rc=0`). Nueva
406referencia **CPU-independiente**: `of_tree(stage1-baseline)=b3:4408e44e2ec51c3769deffcd64dd1f6c2010d414418937c3fa7930a0ded3f845`
407(con musl `57b66a2e`+busybox `56664d70`+arje `bd0f8475`+hammerd `d08fd273`, todos baseline salvo arje
408que quedó preseed nativo). Nota de fondo (plan C.2): el flag de CPU **no** entra hoy en `of_inputs`,
409así que un cambio de toolchain/flags da cache-hit con bytes viejos — la misma tensión "pin de
410toolchain al hash" que conviene cerrar para que Stage 2 no dependa de borrar el store a mano (por eso
411el refresh de abajo borró los artefactos viejos a mano antes de reconstruir).
412
413**Store refrescado a baseline 4/4 + builder listo (2026-06-11).** Se reconstruyeron los **4**
414componentes con el toolchain baseline (incluido `arje-zero`, que re-vendorea el monorepo) en un store
415limpio, y se promovió a `./store` (el nativo quedó en `store-native-bak`). `arje-zero` baseline
416**difiere** del nativo (char 25) ⇒ baseline aplicado. Referencia **CPU-independiente full-4/4**:
417`of_tree(stage1)=b3:198f209f5e2c9d3a37411823b2ea42a09074d4e482f044278958b1b86f232dbb`
418(rootfs key `b3:2bd7c91b…`; nótese que el key `of_inputs` es **idéntico** al del preseed-nativo —
419el cambio de bytes de arje no movió su key: la misma escotilla C.2). El builder se re-ensambló con
420ese toolchain + el `hammer` baseline + `--ref-content 198f209f…` (embebida en
421`/etc/hammer/rebuild.env`) y se repackó **`work/builder.cpio.gz`** (415 MB, 27 027 entradas,
422`--owner=root:root`). **Listo para bootear:** `scripts/boot-builder-vm.sh` (o `scripts/drive-rebuild.py`
423no-interactivo) → adentro `rebuild-stage1` debe reproducir **`198f209f…`** ⇒ **✓ REPRODUCIBLE**
424cerraría el auto-alojamiento **4/4** bit a bit. Bajo TCG es lento y la RAM va justa; el veredicto
425pleno gana con **KVM + más RAM** (y boot desde disco).
426
427**No-determinismo #3 cazado — `arje-zero` y `codegen-units` (2026-06-12).** El rebuild in-VM dio
428`✗ DIVERGENTE` (`of_tree` host `984e002f…` ≠ VM `94b93585…`) pese a que los 4 componentes sellaban
429bajo el mismo key `of_inputs`. La caza (bisección por componente en el host): `musl`, `busybox` y
430`hammerd` reconstruyen **byte-idéntico** entre dos builds del host, y la ensambladura del rootfs
431(`of_tree`) es determinista e independiente del entorno. El culpable era **`arje-zero`**: dos builds
432del **mismo** host daban binarios distintos (Δ ~9.6 KB repartidos por `.text` −8064, `.rodata` −896,
433`.eh_frame`, `.gcc_except_table`, `.data.rel.ro`, `.got`) — la firma del **codegen paralelo** de
434rustc. Raíz: el workspace de hammer pinea `[profile.release] codegen-units = 1` (por eso `hammerd`
435reproducía), pero el monorepo tawasuyu **no** declara perfil → cargo usa el default `codegen-units = 16`.
436Con 16 unidades el reparto del crate en N objetos varía build-a-build incluso con entradas idénticas,
437y la salida cambia. **Fix:** el sandbox impone `CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1` para todas las
438crates Cargo (`crates/hammer-build/src/sandbox.rs`) — el lab elimina el no-determinismo en vez de
439confiar en que cada repo upstream lo declare ([SDD 09 §2](../09-trust-model.md)). Verificado: dos
440builds cu=1 de `arje-zero` → **byte-idénticos**, y el `of_tree(stage1)` queda estable. Nueva referencia
441**reproducible 4/4** (cu=1): `of_tree(stage1)=b3:0039b2b90fddf62221ad5b804a34df93ef57dc533cb210512cf53752876afdff`
442(reemplaza la `198f209f…` de cu=16, que ya era no-reproducible). El store del host se refrescó a este
443baseline y `EXPECT_REF` en `selfhost-verify.sh` se actualizó.
444
445**✓ REPRODUCIBLE — verificado end-to-end in-VM (2026-06-12).** `KVM=1 MEM=24576 ./scripts/selfhost-verify.sh`
446en el host `libre` (cachyos, `/dev/kvm` + swap 32G): la VM reconstruyó los **4/4** con el toolchain de
447adentro (arje-zero cu=1 compiló en **27m32s** in-VM), selló el rootfs key `2bd7c91b…` y su
448`of_tree(stage1') = b3:0039b2b9…` **igualó la referencia** ⇒ `✓ REPRODUCIBLE: stage1' == stage1
449(auto-alojado bit a bit)`, `DRIVER_RC=0`. **El auto-alojamiento bit-a-bit de Stage 1 queda cerrado y
450confirmado en el bucle completo** (host↔VM), no sólo host↔host.
451
452**✓ REPRODUCIBLE con swaps de la variante (b) — verificado end-to-end in-VM (2026-06-13).**
453`KVM=1 MEM=24576 SWAP_MAKE=1 SWAP_BUSYBOX=1 ./scripts/selfhost-verify.sh` en `libre`: el builder se
454ensambla pisando el `/toolchain` Alpine con el **make** (`b3:fbad44ac…`) y el **busybox**
455(`b3:56664d70…`) que hammer compiló desde fuente, y la VM reconstruye los **4/4** con ese toolchain
456hammerizado — incluido busybox compilándose a sí mismo con hammer-busybox de shell. Sellaron in-VM:
457`musl 57b66a2e…`, `busybox 56664d70…`, `hammerd d08fd273…`, `arje-zero bd0f8475…` (cu=1), y el
458`of_tree(stage1')` **igualó la referencia** `9adefb82…` ⇒ `✓ REPRODUCIBLE: stage1' == stage1`,
459`DRIVER_RC=0` (`RESULTADO: ✓ REPRODUCIBLE`, elapsed **3261 s** ≈ 54 min con `arje-zero` cu=1 in-VM).
460**La procedencia del builder deja de ser "todo Alpine": make+busybox son ya de hammer, auditados, y
461el auto-alojamiento sigue siendo bit-a-bit.** Siguiente pieza de la variante (b): linux-headers →
462bwrap → rust/llvm (cada una swapeada y re-verificada con este mismo comando).
463
464**Tarea reproducible end-to-end: `scripts/selfhost-verify.sh`.** Un solo comando hace TODO el pipeline
465(stage0 → stage1 baseline → stage2 ref → inyectar `overlay.ko`/`e1000.ko` del kernel local → ensamblar
466builder → empaquetar → bootear + driver no-interactivo → veredicto), pensado para correr en una
467máquina con **KVM + RAM holgada** (laptop):
468
469```sh
470./scripts/bootstrap-devfs.sh          # una vez: el lab (.dev-fs/alpine + zig)
471KVM=1 MEM=10240 ./scripts/selfhost-verify.sh
472# PRESEED=hammerd → preseed C+arje y reconstruye sólo hammerd in-VM (barato si la RAM va justa)
473```
474
475Cross-check de reproducibilidad **entre máquinas**: el script compara su `of_tree(stage1)` contra
476`EXPECT_REF` (la referencia conocida-buena `0039b2b9…` del host de dev, baseline cu=1). Si difieren, o
477cambió un pin (toolchain/recipes/seed) o hay un no-determinismo nuevo. En el host de dev (Artix, 7.6 GB, **sin KVM**)
478el rebuild **4/4** in-VM colgó por presión de RAM bajo TCG a los ~17 min del `make` de musl — el muro
479de hardware que esta tarea traslada a un host capaz.
480
481## 9. Cross-check opcional — `arje-packager`
482
483arje trae su propio empaquetador (`03_ukupacha/arje/init/arje-packager`):
484`arje-packager --seed <CARD.json> --out <initramfs.cpio.gz> [--bin LABEL=PATH]…`. Útil para comparar
485el initramfs **canónico de arje** contra el que sale del rootfs de hammer (§4). No reemplaza esta
486validación: aquí probamos el rootfs que **hammer** sella, no el que arje arma.

stage1-vm-boot.md

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), levanta hammerd + getty bajo supervisión, y que el CRASHED real 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

1. Prerrequisitos

El store debe vivir en la raíz del repo (./store) para que BuildConfig resuelva .dev-fs/alpine como hermano (store/.. = repo root). El toolchain de Stage 1 no es el de .dev-fs: es la semilla de Stage 0 (paso 2), que stage1 enchufa como zig_dir del 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"

6. Criterios de éxito (qué observar)

  1. PID 1: en kmsg/consola, arje-zero loguea "despierta como PID 1".
  2. Pseudo-FS: monta /proc /sys /dev sin error fatal (best-effort; un EBUSY se ignora).
  3. Seed: carga /ente/seed.card.json sin "card inválida" (ya validada contra card_core::Card).
  4. hammerd: arranca como Ente — su tracing aparece y/o crea /run/agent.sock.
  5. getty: aparece un prompt de shell (/bin/sh) en ttyS0.
  6. CRASHED real — la prueba clave. En el shell:
    kill "$(pidof hammerd)"
    
    arje registra el on_death(ExitStatus) y reinicia hammerd tras el backoff (200 ms → ×2…). Eso es el CRASHED que la Fase 5 dejó diferido, ahora manejado por el init propio.

7. Troubleshooting (síntoma → causa → fix)

SíntomaCausa probableFix
Kernel panic … no init found/sbin/init no ejecutaprobar 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 errorbinario no estático / falta loaderconfirmar 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_shueco del toolchain musl dinámicoel lab usa zig cc (empaqueta su compiler-rt) → no debería aparecer; verificar compiler = "zig-cc" en la receta
getty sin promptdevice console no engancha ttyprobar argv con ttyS0 en vez de console; revisar que /dev/console existe
No working init / sin /dev/consolekernel sin auto-mount de devtmpfscrear 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 fallael fetch necesita redcorrer 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:

#EtapaResultado
1Lab (bootstrap-devfs.sh)✅ Alpine + zig 0.16.0 + 43 build-tools
2Stage 0 (ingerir zig)✅ seed_hash sellado, idempotente
3musl 1.2.5✅ cross-compila con zig cc y se sella — el cross-compile C hermético funciona
4busybox 1.36.1✅ tras tres fixes (abajo); compila y linkea con zig cc, se sella
5hammerd (Cargo)❌ bloqueado: triple de toolchain (abajo)
6arje-zero (Cargo)⏸ no alcanzado (mismo bloqueo; además vendorea el monorepo entero)

Fixes aplicados a recipes/busybox.toml (incompatibilidades GNU-toolchain ↔ zig, esperadas):

  1. gcc hardcodeado. El Kbuild de busybox fija CC = gcc / HOSTCC = gcc con = (ignora el env CC del sandbox). Fix: pasar CC='zig cc' HOSTCC='zig cc' en línea de comando de make.
  2. Flags GNU-ld que zig cc rechaza. El link pasa -Wl,--warn-common, -Wl,-Map,…, -Wl,--verbose; zig cc valida los -Wl, y aborta. Fix: quitarlos de scripts/trylink con sed.
  3. (gcc para herramientas host como fixdep y gcc-version.sh lo cubre el mismo HOSTCC.)

Bloqueo del camino Rust — decisión de toolchain (plan C.2):

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:

  1. AVX. El CPU default de QEMU (qemu64) no tiene AVX, pero los binarios (zig/rust nativos) emiten vxorps → SIGILL (invalid_op) en el loader → init muere. Fix: -cpu Broadwell (AVX2 bajo TCG; sin /dev/kvm no hay -cpu host). Para un distro real: compilar a un baseline x86-64 genérico, no al CPU del builder.
  2. 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 resuelve memcpy/memset/log2/…: nuestro libc.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):

ComponenteReproducibleNotas
musl 1.2.5✅ byte-idénticoel build C con zig cc es determinista de fábrica
busybox 1.36.1✅ tras dos fixes(ver abajo)
hammerd (Rust)✅ byte-idénticocrt-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-unitsdos 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):

  1. Config interactiva. Tras editar .config, el compile corre silentoldconfig, 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 tiene olddefconfig).
  2. Headers del kernel. Con el config determinista, los applets de console-tools exigen linux/kd.h (UAPI) que zig no bundlea → linux-headers en 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)

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 MEM o monta un disco para /work si 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:

  1. Toolchain incompleto. Traía compiladores (cargo/make/zig) pero no la orquestación del lab: bwrap (anida el sandbox), git (git archive del mirror), curl (tarballs). En el host las da el sistema; en la VM el toolchain es el único userland ⇒ apk add bubblewrap git curl.
  2. 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 (cp del ld-musl a /lib + LD_LIBRARY_PATH/PATH) en rebuild-stage1.
  3. overlay es módulo. CONFIG_OVERLAY_FS=m y el initramfs arranca sin módulos ⇒ bwrap --tmp-overlay daba ENODEV. Fix: insmod /toolchain/lib/overlay.ko (+ kmod; overlay.ko del kernel destino inyectado en el builder).
  4. 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 con cpio --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):

  1. Rebuild de hammerd en el host → byte-idéntico al cacheado (cmp limpio, dos veces). La receta es determinista host-a-host; el cache no está stale.
  2. ⇒ 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=native y hornea la ISA de quien compila.
  3. Evidencia ISA: el hammerd del host trae sha256rnds2 (SHA-NI) ×32 y 3530 instrucciones AVX2 (ymm). El host tiene sha_ni+avx2; -cpu Broadwell (el de la VM bajo TCG) NO tiene SHA-NI ⇒ codegen distinto ⇒ bytes distintos ⇒ of_tree distinto. 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.