ADR 0020: las fases corren con `sh -c` sin `-e`, y un fallo no final sella igual
Sale de construir `zsh`. El agujero es estrictamente el de los comandos NO FINALES de una fase: el estado de salida de `sh -c` es el del último comando, así que `make … install install.info` podía morir con `makeinfo: not found` y el artefacto sellaba con `/usr/share/info` VACÍO adentro. Medido, no supuesto: 144 logs del worker, 88 sellados, 4 con fallo de alta señal. Clasificados uno por uno da UN hueco real confirmado (zsh, ya reparado en 3b36e9df), uno que NO lo es (python3: ahí takana SÍ abortó, porque el `make` era el último comando de su fase) y uno inconcluso (strace, en logs multi-receta donde el target que falla no se puede atribuir). Ese caso de python corrige mi propio encuadre inicial, que decía «las fases sellan con huecos silenciosos» a secas y era demasiado amplio. Queda escrito en el ADR porque la versión amplia manda a buscar el bug donde no está. Exposición medida: de 943 recetas, 142 tienen fase multi-comando escrita a mano. Las ~800 restantes NO están expuestas — usan las fases de `resolve_phases`, que encadena con `&&` y sí propaga. La decisión (`-ec` o no) queda ABIERTA a propósito: falta el único número que importa, que es cuántas de esas 142 dejan de sellar bajo `-ec`, y eso no se saca leyendo sino reconstruyendo. Cambiar el default a ciegas sobre 1115 sellados es el reflejo que este repo evita.
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
1 archivos · +113 −0
diff --git a/docs/adr/0020-fases-de-build-sin-set-e.md b/docs/adr/0020-fases-de-build-sin-set-e.md
new file mode 100644
index 00000000..83e91439
--- /dev/null
+++ b/docs/adr/0020-fases-de-build-sin-set-e.md
@@ -0,0 +1,113 @@
+# ADR 0020 — Las fases de build no abortan a la primera: `sh -c` sin `-e`
+
+- **Estado:** PROPUESTO — el hallazgo está MEDIDO y un caso está reparado; la decisión (`-ec` sí o
+ no) queda abierta y depende de un experimento que todavía no se corrió (§Precondiciones).
+- **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.
+
+## 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 — ABIERTA
+
+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), pero no antes de la medición de abajo.** Cambiar el default a ciegas sobre un
+corpus de 1115 sellados es exactamente el reflejo que este repo evita.
+
+## Precondiciones — el experimento que falta
+
+Lo que hay hoy es un **cribado sobre logs**, no una medición del radio de explosión. El número que
+falta es: *¿cuántas de las 142 recetas expuestas dejan de sellar bajo `-ec`?* Y no se puede sacar
+leyendo: hay que reconstruirlas.
+
+ # sobre una muestra de las 142, con el lock tomado una sola vez (CLAUDE.md regla 1)
+ flock -o work/.farm-build.lock bash -c 'for r in <muestra>; do takana --store ./store build "$r"; done'
+
+Con `-ec` puesto y contando los que caen. Una muestra de 20 alcanza para decidir; el corpus entero es
+la validación. **Hasta que ese número exista, este ADR no se adopta.**
+
+## 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.