1# ADR 0020 — Las fases de build no abortan a la primera: `sh -c` sin `-e`
2
3- **Estado:** PROPUESTO — el hallazgo está MEDIDO y un caso está reparado; la decisión (`-ec` sí o
4 no) queda abierta y depende de un experimento que todavía no se corrió (§Precondiciones).
5- **Fecha:** 2026-09-18
6- **Frontera:** `crates/takana-build/src/sandbox.rs:332,491` (las dos invocaciones de `/bin/sh -c`),
7 `crates/takana-build/src/lib.rs:805` (`resolve_phases`, las fases generadas), y las 142 recetas
8 con fases escritas a mano.
9- **Relacionado:** [CLAUDE.md regla 3](../../CLAUDE.md) (un vacío llega hasta el final diciendo que
10 todo fue bien), [ADR 0009](0009-content-addressed-code.md) (direccionamiento por contenido).
11
12## Contexto
13
14Las fases de una receta se ejecutan así, en los dos sitios donde se arma la orden del sandbox:
15
16```rust
17args.push("/bin/sh".into());
18args.push("-c".into());
19```
20
21**Sin `-e`.** La consecuencia es una regla de shell, no un bug: el estado de salida de un `sh -c` es
22el del **ÚLTIMO comando**, y un fallo a mitad no aborta nada. Traducido a esta base de código:
23
24> Un comando que **no** sea el último de su fase puede fallar, y el artefacto sella igual.
25
26## Cómo se encontró (2026-09-18)
27
28Construyendo `zsh` — la única receta del corpus que faltaba por sellar. El build terminó en verde y
29el artefacto pasó sus dos guardianes (estático, con `zsh/regex`, con `=~`). Pero el log traía:
30
31```
32/bin/sh: makeinfo: not found
33make: *** [Makefile:255: install.info] Error 2
34 ⇒ INFO takana_build: sealed path=./store/b96fb6e8…-zsh
35```
36
37El artefacto sellado llevaba **`/usr/share/info` VACÍO** adentro. `makeinfo` no está en el lab, así
38que ese `make` fallaba en **todos** los builds de zsh, siempre, en silencio.
39
40## Lo que la medición SÍ dice, y lo que NO
41
42Se midieron 144 logs de build del worker; 88 llegaron a `sealed`. De ésos, **4** contienen líneas de
43fallo de alta señal (`make: *** … Error N`, `/bin/sh: …: not found`). Clasificados uno por uno:
44
45| caso | veredicto |
46|---|---|
47| `zsh` → `install.info` | **HUECO REAL, CONFIRMADO.** `/usr/share/info` vacío en el artefacto. Reparado en `3b36e9df` |
48| `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 |
49| `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 |
50
51⚠ **La primera versión de este hallazgo decía «las fases sellan con huecos silenciosos» a secas, y
52era demasiado amplia.** El caso de python la corrige y conviene dejarlo escrito: **cuando el comando
53que falla es el último, takana SÍ aborta.** El agujero es estrictamente el de los comandos **no
54finales**.
55
56## La exposición, medida
57
58- **943 recetas.** 214 bloques de fase explícitos; de ésos **201 son multi-comando** y 13 de un solo
59 comando. **142 recetas** tienen al menos una fase multi-comando: ésa es la superficie expuesta.
60- Las ~800 restantes **no están expuestas**: usan las fases que genera `resolve_phases`, que encadena
61 con `&&` (`autoreconf -fi && ./configure …`), y `&&` sí propaga el fallo.
62
63## Lo que hoy tapa el agujero, y por qué no alcanza
64
65En `zsh` el artefacto se salvó de verdad — pero no por el estado de salida, sino por **dos guardianes
66que el autor de la receta escribió a mano**: uno mira el ELF por `INTERP`, otro pregunta al binario
67si tiene sus módulos. Funcionaron. El problema es que son **específicos de zsh**: comprueban las dos
68cosas que su autor supo prever. Una receta sin guardianes propios no tiene ninguna red, y hoy son la
69minoría.
70
71Y hay una asimetría que conviene nombrar: los guardianes corren **después** del `make` precisamente
72*porque* el shell no aborta. Si mañana se pone `-e`, esas recetas dejan de sellar — no porque el
73artefacto sea peor, sino porque el fallo que hoy se tolera pasa a ser mortal.
74
75## Decisión — ABIERTA
76
77Las tres salidas, con lo que cada una cuesta:
78
79**a) `sh -ec` (abortar a la primera).** Correcto por defecto y alineado con la regla 3. Radio de
80explosión: hasta 142 recetas, **muchas de ellas ya selladas y en uso**. Un build que hoy pasa
81tolerando un fallo benigno (un `rm` de algo que no existe, un `install` opcional) pasaría a fallar.
82No re-hashea nada —el shell no entra en `ArtifactHash`— pero puede dejar el corpus sin reconstruir.
83
84**b) `-e` por receta** (`strict_phases = true`), migrando de a poco. Sin big bang, pero deja el
85default inseguro, que es lo que causó esto.
86
87**c) Dejarlo y exigir guardianes.** Es el statu quo, y el statu quo produjo un artefacto con un
88directorio vacío que nadie vio durante un día.
89
90**Recomendación: (a), pero no antes de la medición de abajo.** Cambiar el default a ciegas sobre un
91corpus de 1115 sellados es exactamente el reflejo que este repo evita.
92
93## Precondiciones — el experimento que falta
94
95Lo que hay hoy es un **cribado sobre logs**, no una medición del radio de explosión. El número que
96falta es: *¿cuántas de las 142 recetas expuestas dejan de sellar bajo `-ec`?* Y no se puede sacar
97leyendo: hay que reconstruirlas.
98
99 # sobre una muestra de las 142, con el lock tomado una sola vez (CLAUDE.md regla 1)
100 flock -o work/.farm-build.lock bash -c 'for r in <muestra>; do takana --store ./store build "$r"; done'
101
102Con `-ec` puesto y contando los que caen. Una muestra de 20 alcanza para decidir; el corpus entero es
103la validación. **Hasta que ese número exista, este ADR no se adopta.**
104
105## Consecuencias que se caen solas
106
107- Un `grep` de `Error [0-9]` sobre los logs de builds **sellados** es hoy una herramienta de auditoría
108 válida y barata. Cuatro de 88 no es ruido.
109- Cualquier receta nueva con fase multi-comando nace expuesta. Mientras (a) no se adopte, lo honesto
110 es que el último comando de una fase sea el que verifica — que es, sin haberlo dicho nunca, lo que
111 `zsh` ya hacía con su bucle de guardianes.
112- **Pedir algo que no puede funcionar y tolerar su fallo es peor que no pedirlo.** Es lo que se aplicó
113 en `3b36e9df`: `install.info` no se pide, en vez de pedirse y fallar en silencio.
0020-fases-de-build-sin-set-e.md
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 (un vacío llega hasta el final diciendo que
todo fue bien), ADR 0009 (direccionamiento por contenido).
Contexto
Las fases de una receta se ejecutan así, en los dos sitios donde se arma la orden del sandbox:
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.