# ADR 0020 — Las fases de build no abortan a la primera: `sh -c` sin `-e` - **Estado:** PROPUESTO — hallazgo MEDIDO, un caso reparado y el experimento CORRIDO sobre muestra ligera (§El experimento). Falta la cola pesada antes de adoptar (§Lo que queda por medir). - **Fecha:** 2026-09-18 - **Frontera:** `crates/takana-build/src/sandbox.rs:332,491` (las dos invocaciones de `/bin/sh -c`), `crates/takana-build/src/lib.rs:805` (`resolve_phases`, las fases generadas), y las 142 recetas con fases escritas a mano. - **Relacionado:** [CLAUDE.md regla 3](../../CLAUDE.md) (un vacío llega hasta el final diciendo que todo fue bien), [ADR 0009](0009-content-addressed-code.md) (direccionamiento por contenido). ## Contexto Las fases de una receta se ejecutan así, en los dos sitios donde se arma la orden del sandbox: ```rust args.push("/bin/sh".into()); args.push("-c".into()); ``` **Sin `-e`.** La consecuencia es una regla de shell, no un bug: el estado de salida de un `sh -c` es el del **ÚLTIMO comando**, y un fallo a mitad no aborta nada. Traducido a esta base de código: > Un comando que **no** sea el último de su fase puede fallar, y el artefacto sella igual. ## Cómo se encontró (2026-09-18) Construyendo `zsh` — la única receta del corpus que faltaba por sellar. El build terminó en verde y el artefacto pasó sus dos guardianes (estático, con `zsh/regex`, con `=~`). Pero el log traía: ``` /bin/sh: makeinfo: not found make: *** [Makefile:255: install.info] Error 2 ⇒ INFO takana_build: sealed path=./store/b96fb6e8…-zsh ``` El artefacto sellado llevaba **`/usr/share/info` VACÍO** adentro. `makeinfo` no está en el lab, así que ese `make` fallaba en **todos** los builds de zsh, siempre, en silencio. ## Lo que la medición SÍ dice, y lo que NO Se midieron 144 logs de build del worker; 88 llegaron a `sealed`. De ésos, **4** contienen líneas de fallo de alta señal (`make: *** … Error N`, `/bin/sh: …: not found`). Clasificados uno por uno: | caso | veredicto | |---|---| | `zsh` → `install.info` | **HUECO REAL, CONFIRMADO.** `/usr/share/info` vacío en el artefacto. Reparado en `3b36e9df` | | `python3` → `libpython3.12.a` | **NO es un hueco.** takana lo detectó y abortó: `Error: build phase falló (exit 2): make -j"$(nproc)"` — el `make` era el ÚLTIMO comando de su fase | | `strace` → `all Error 2` (×2 logs) | **INCONCLUSO.** Son logs multi-receta (`reconstruccion-detalle.log` tiene 1194 `sealed`), así que el target que falla no se puede atribuir a un sellado concreto. No hay artefacto `-strace` en el store del worker para comprobarlo | ⚠ **La primera versión de este hallazgo decía «las fases sellan con huecos silenciosos» a secas, y era demasiado amplia.** El caso de python la corrige y conviene dejarlo escrito: **cuando el comando que falla es el último, takana SÍ aborta.** El agujero es estrictamente el de los comandos **no finales**. ## La exposición, medida - **943 recetas.** 214 bloques de fase explícitos; de ésos **201 son multi-comando** y 13 de un solo comando. **142 recetas** tienen al menos una fase multi-comando: ésa es la superficie expuesta. - Las ~800 restantes **no están expuestas**: usan las fases que genera `resolve_phases`, que encadena con `&&` (`autoreconf -fi && ./configure …`), y `&&` sí propaga el fallo. **Y el corpus YA resolvió esto a mano, en un tercio de los casos.** 53 de esas 142 recetas abren alguna fase con un `set -e` propio — `wtype` es una de ellas. Eso son dos datos, no uno: 1. **El default está mal y los autores ya lo sabían.** Nadie escribe `set -e` en 53 recetas por gusto; se escribe después de que algo selle mal. Es la misma lección que este ADR, pagada de a una. 2. **Achica el radio de explosión.** Esas 53 ya corren bajo `set -e` y no romperían con `-ec`: la migración no son 142 recetas, son **89** — las expuestas que todavía no se defienden solas. ## Lo que hoy tapa el agujero, y por qué no alcanza En `zsh` el artefacto se salvó de verdad — pero no por el estado de salida, sino por **dos guardianes que el autor de la receta escribió a mano**: uno mira el ELF por `INTERP`, otro pregunta al binario si tiene sus módulos. Funcionaron. El problema es que son **específicos de zsh**: comprueban las dos cosas que su autor supo prever. Una receta sin guardianes propios no tiene ninguna red, y hoy son la minoría. Y hay una asimetría que conviene nombrar: los guardianes corren **después** del `make` precisamente *porque* el shell no aborta. Si mañana se pone `-e`, esas recetas dejan de sellar — no porque el artefacto sea peor, sino porque el fallo que hoy se tolera pasa a ser mortal. ## Decisión — recomendada, pendiente de la cola pesada Las tres salidas, con lo que cada una cuesta: **a) `sh -ec` (abortar a la primera).** Correcto por defecto y alineado con la regla 3. Radio de explosión: hasta 142 recetas, **muchas de ellas ya selladas y en uso**. Un build que hoy pasa tolerando un fallo benigno (un `rm` de algo que no existe, un `install` opcional) pasaría a fallar. No re-hashea nada —el shell no entra en `ArtifactHash`— pero puede dejar el corpus sin reconstruir. **b) `-e` por receta** (`strict_phases = true`), migrando de a poco. Sin big bang, pero deja el default inseguro, que es lo que causó esto. **c) Dejarlo y exigir guardianes.** Es el statu quo, y el statu quo produjo un artefacto con un directorio vacío que nadie vio durante un día. **Recomendación: (a), y ya no a ciegas.** El experimento de abajo pone el coste en ~8% de las expuestas — unas 8 recetas a arreglar sobre 96. Eso es un radio acotado y pagable, no el salto al vacío que se temía. Lo que sí queda es hacer la lista COMPLETA antes de tocar `sandbox.rs`. ## El experimento, CORRIDO (2026-09-18) **Diseño: no sella nada, a propósito.** Construir una receta modificada le cambia el `ArtifactHash`, así que medirlo a lo bruto habría dejado una docena de duplicados basura en un store que se respalda. En vez de eso, a cada receta de la muestra se le inyecta `set -e` al principio de cada fase **y un `exit 77` al final de la última**. El veredicto se lee en el código que reporta takana: - **falló con 77** ⇒ todas las fases corrieron enteras bajo `set -e` ⇒ **sobrevive** a `-ec` - **falló con ≠77** ⇒ una orden intermedia falla y hoy se está tragando ⇒ **se rompe** con `-ec` Nada llega al store. Las copias viven en `recipes/exp-ec-*.toml` y se barren al terminar. **Muestra:** 12 de las expuestas sin `set -e` propio, excluyendo los pesos pesados (`clang18`, kernels, `mesa`, navegadores) porque no caben en el presupuesto de tiempo. **Sesgo declarado:** la muestra es de recetas ligeras. | resultado | n | |---|---| | **sobrevive** sin tocar nada | **10** | | **se rompe** | **1** (`e2fsprogs`) | | inconcluso por tiempo (>7 min) | 1 (`cargo-edit`) | ⚠ El recuento de expuestas sube de 89 a **96**: el 89 salía de restar 142−53 dando por hecho que las 53 con `set -e` estaban todas dentro de las 142, y no lo estaban. Contadas una por una son 96. ### El único que se rompe, mirado de cerca `e2fsprogs` muere en su `./configure`, que aborta con `configure: error: external uuid library not found` **pese a que la receta pasa `--disable-libuuid`**. Que hoy eso se traga está comprobado: la misma receta sin `set -e` y con `exit 77` **llega al 77**, o sea que la fase sigue entera después del configure fallido, y el `make` de después es el que decide el estado de salida. **Y el artefacto que sella está BIEN.** 149 ficheros, 21 M, con `e2fsck`, `mke2fs`, los cuatro `fsck.ext*`, los `mkfs.ext*` y `badblocks`. O sea que acá `-ec` **no atrapa un bug: rompe una receta que funciona.** Arreglarla es trabajo de receta —entender por qué configure se queja de algo que está desactivado— y no un fallo que estuviera escondido. Ése es justamente el coste que no se podía adivinar leyendo: **1 de 12, ~8%**. Sobre las 96 expuestas son **unas 8 recetas** que hay que tocar ANTES de cambiar el default. Es un número chico y acotado, que es lo que hacía falta para decidir. ## Lo que queda por medir El experimento de arriba cubre la muestra ligera. Falta la cola pesada, que se excluyó por tiempo: `clang18`, los kernels, `mesa`, los navegadores. Son pocas recetas pero son las más largas, y no hay razón para esperar que se comporten distinto — sólo tardan más en decirlo. Se corren igual: # reutilizar scripts/farm/exp-ec.sh (el mismo diseño: set -e + exit 77, no sella) # con el lock por receta, NO por tanda: una tanda pesada retendría el lock horas y pararía la cosecha La validación completa es pasar las 96. Con ~8% de rotura esperada eso son unas 8 recetas a arreglar, y conviene tener la lista ENTERA antes de tocar `sandbox.rs`: cambiar el default y arreglar las recetas a medida que saltan deja el corpus a medio construir mientras tanto. ## Consecuencias que se caen solas - Un `grep` de `Error [0-9]` sobre los logs de builds **sellados** es hoy una herramienta de auditoría válida y barata. Cuatro de 88 no es ruido. - Cualquier receta nueva con fase multi-comando nace expuesta. Mientras (a) no se adopte, lo honesto es que el último comando de una fase sea el que verifica — que es, sin haberlo dicho nunca, lo que `zsh` ya hacía con su bucle de guardianes. - **Pedir algo que no puede funcionar y tolerar su fallo es peor que no pedirlo.** Es lo que se aplicó en `3b36e9df`: `install.info` no se pide, en vez de pedirse y fallar en silencio.