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 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ 5e4014a6c240dc426fc8db6648956a0b5d23f4f1 docs/26-atuq-envoltorio-gecko.md Markdown · 4154 líneas · 289.0 KB Historia crudo

el latido no revierte: BORRA — y el primer intento de la tanda se evaporó entero 77eb0aa6b · Sergio ·

1# SDD 26 — `atuq`: el envoltorio Gecko de la distro
2
3Escrito 2026-09-05, a partir de la pregunta del usuario: *«ya habiendo compilado firefox y waterfox,
4qué tan complicado vs provechoso suena crear nuestro propio envoltorio, en vez de zen?»*, y de su
5aclaración de por qué la pregunta existe: **`puriy` quedó muy verde y es difícil sacarlo de ahí**.
6
7`atuq` es *zorro* en quechua. El nombre estaba libre —verificado con `grep -ri atuq` sobre
8`/mnt/vvv/takana` y `/mnt/vvv/tawasuyu`, cero coincidencias— y dice exactamente lo que la cosa es:
9otro fox, con marca propia y sin usar la de Mozilla (ver el §branding de `recipes/firefox.toml`).
10
11**Este documento no reemplaza a `puriy` ni lo cancela.** `puriy` sigue siendo el motor soberano; el
12§9 explica por qué `atuq` es su andamio y no su desvío.
13
14---
15
16## 1. La corrección que ordena todo: no hay envoltorio fuera del chrome
17
18La intuición natural —una carcasa Llimphi con nuestros widgets, y Gecko adentro pintando la página—
19**no es posible**. Gecko no tiene API de embebido en escritorio desde que murió XULRunner; GeckoView
20existe y es de Android. No hay forma soportada de meter el motor en una ventana de `mirada`.
21
22⇒ un envoltorio de Gecko es, necesariamente, **chrome-level**: JS/CSS/XHTML *dentro* del propio
23Firefox, más extensiones, más procesos externos que hablen con él.
24
25Y eso es exactamente lo que Zen es. Zen no toca el motor: su diferencia entera —workspaces, split
26view, glance, compact mode— vive en el chrome, más una herramienta (`surfer`) que reaplica sus
27parches sobre el árbol de Firefox y recompila. **Su costo no es el motor: es la cinta de correr del
28rebase cada cuatro semanas.** Copiar su método sería heredar su costo sin heredar su equipo.
29
30## 2. La decisión de arquitectura: artefacto DERIVADO, no fork de fuente
31
32Si `atuq` parchea el árbol, **cada iteración de UI cuesta un build de Gecko de cuatro horas**. Eso
33mata el proyecto en la primera semana de diseño, porque el diseño de chrome es iterativo por
34naturaleza.
35
36Pero nosotros no distribuimos un binario: sellamos artefactos. Entonces `atuq` es una receta cuyo
37`[deps]` incluye `firefox`, y cuyas fases copian el árbol instalado e inyectan encima:
38
39| Qué se inyecta | Mecanismo de Firefox | Sin recompilar |
40|---|---|---|
41| Marca, iconos, nombre | `distribution/`, `application.ini` | ✅ |
42| Prefs de fábrica | `defaults/pref/*.js` + autoconfig (`.cfg`) | ✅ |
43| Política de empresa | `distribution/policies.json` | ✅ |
44| Chrome propio (dientes, layout) | `userChrome.css` + re-empaque de `omni.ja` | ✅ |
45| Extensiones nuestras | `distribution/extensions/` | ✅ |
46
47**Ventajas que esto tiene sobre el método de Zen, y que son estructurales:**
48
491. **Iteración en segundos, no en horas.** El chrome se toca sin volver a compilar C++.
502. **No mueve el `ArtifactHash` de `firefox`.** El corpus no se invalida; `waterfox` y cualquier otro
51   fork siguen compartiendo el mismo artefacto base.
523. **Las CVE se heredan gratis.** Un fork de fuente te vuelve dueño del reloj de seguridad del
53   binario más atacado de la máquina — es lo que hundió a casi todos los forks de Firefox, y el
54   reproche histórico a Waterfox. Un derivado se reconstruye solo cuando sube `firefox`.
55
56**Los dos gotchas reales del camino, que son trabajo pero chico:**
57
58- **`omni.ja` es un zip ⇒ hay que re-empacarlo determinista** (mtimes fijos, orden estable) o el
59  artefacto deja de reproducir bit a bit. Es la misma disciplina que ya exige la imagen del lab.
60- **El `jarlog` del PGO ordena el `omni.ja` para el arranque** (§3.bis). Re-empacarlo a lo bruto
61  tira esa optimización a la basura sin que nadie lo note. El re-empaque preserva el orden.
62- **Hay que invalidar el startup cache** tras tocarlo (fichero `.purgecaches` junto al binario, o
63  bump del BuildID). Si no, los cambios no se ven y **parece que el overlay no agarró**: un falso
64  negativo de los caros, de la misma familia que el cache-hit que congela regresiones.
65
66### 2.bis Cuándo SÍ hay que parchear el árbol
67
68Cuando algo necesite tocar C++ del motor. Hoy conocemos un caso: `sct` en su forma fuerte (§6.1).
69La regla es: **nada baja al árbol antes de que exista la toolchain del §3**, porque hasta entonces
70cada intento cuesta cuatro horas. Y cuando baje, el vigía de parches (`scripts/vigia-parches.py`,
71commit `aa200a4`) es lo que convierte esas cuatro horas en un minuto para saber si el parche agarra.
72
73## 3. La puerta de las optimizaciones: no es un flag, es una toolchain
74
75El usuario decidió habilitar las optimizaciones. Lo que hay que saber antes de intentarlo:
76
77`recipes/firefox.toml` va con `compiler = "gcc"` por el **muro 3** (zig enlaza `libc++` estática para
78musl y el `configure` de Mozilla exige encontrar un `NEEDED …libc++`; es negativa de upstream, no una
79perilla). El problema es que **PGO, LTO y BOLT son cadena de clang en Gecko**:
80
81- `--enable-lto=cross` quiere clang + lld. El LTO de GCC sobre Gecko no está soportado upstream.
82- `--enable-profile-use` espera `-fprofile-instr-use` (clang), no `-fprofile-use` (gcc).
83- BOLT necesita `llvm-bolt`.
84
85Y **el corpus no tiene clang usable como compilador**. Verificado en las recetas, no supuesto:
86
87- `recipes/clang18.toml` compila `ninja -C build libclang.so clang-resource-headers` y su fase
88  `install` copia **sólo** `build/lib/libclang.so*` y las cabeceras `clang-c`. Está ahí porque
89  `bindgen` carga `libclang.so` en runtime. **No hay driver `clang`, ni `lld`, ni `libc++`.**
90- `llvm18` se selló con `-DLLVM_ENABLE_PROJECTS=""` — LLVM a secas.
91
92⇒ la primera versión de este documento concluyó que hacía falta **una receta `llvm-toolchain`
93(clang+lld+libc++ desde fuente)**. **Era caro de más, y se corrigió el mismo día mirando el lab en
94vez de suponerlo:**
95
96- `.dev-fs/alpine` **ya trae clang22 + llvm22 22.1.8** — la misma major que usa el APKBUILD de
97  Alpine para este mismo Firefox (`_llvmver=22`).
98- **`Compiler::Clang` ya existe en takana**, cableado de punta a punta (`parse_compiler` lo acepta;
99  `takana-build` pone `CC=clang`, `CXX=clang++`, `AR=llvm-ar`). Ninguna receta lo usaba.
100- Faltaba **sólo `ld.lld`**: `apk add lld` ⇒ dos paquetes, cero upgrades.
101
102Y los tres muros caen sin perder lo que gcc daba: el sondeo de linker se satisface con
103`--enable-linker=lld`, el `ar` lo pone takana solo, y el `NEEDED` de la stdlib de C++ existe porque
104clang++ de Alpine usa la `libstdc++` **compartida** — la prueba no es teórica: Alpine construye este
105Firefox con clang22 y **sin** `libcxx` en sus makedepends.
106
107**La huella del lab no se movió, y se midió antes de tocar nada.** `lld` no casa ningún prefijo de
108`TOOLCHAIN_PREFIXES` (`takana-core/src/lab.rs`), así que los 43 paquetes que entran en `hash_inputs`
109salieron idénticos ⇒ **los 837 artefactos sellados quedan intactos**. Eso abarata el cambio hoy y a
110la vez deja un agujero escrito: la versión de `lld` no es parte de la identidad del artefacto, y sólo
111expone a las recetas `compiler="clang"` — hoy, una. Cerrarlo cuesta re-hashear el corpus entero.
112
113**Hecho el 2026-09-05** (commit `cb3ecd5`): `firefox` va con `compiler = "clang"`,
114`--enable-linker=lld`, `--enable-lto=cross`, `--enable-packed-relative-relocs` y
115`--with-unsigned-addon-scopes=app,system`. Falta construirlo.
116
117**Riesgos de PGO, escritos antes de empezar:**
118
119- El PGO de Mozilla **corre el navegador** para juntar el perfil (`profileserver.py`, headless +
120  marionette). Dentro de un sandbox hermético, sin red y sin X11, eso hay que hacerlo andar.
121  Headless no necesita display, pero sí necesita que el árbol de perfil se genere adentro.
122- **El perfil resultante es un input del artefacto.** O entra en `hash_inputs`, o `atuq` deja de ser
123  determinista y no lo vamos a notar: sería otra forma del lab que no está en `hash_inputs`.
124- Ganancia esperable, para calibrar expectativas: PGO+LTO en Gecko son del orden de 10-25% en carga
125  de página y JS. **Hoy estamos ATRÁS de Zen en este eje, no adelante** — Zen hereda la
126  configuración de Mozilla tal cual. Esto no es una ventaja nuestra: es una deuda que se salda.
127
128### 3.bis El diff contra Alpine — y no es sólo PGO
129
130Medido el 2026-09-05 trayendo el `APKBUILD` y el `mozconfig` de `community/firefox` de aports y el
131`PKGBUILD` de Arch, y comparándolos línea a línea con el mozconfig de `recipes/firefox.toml`.
132**Alpine es nuestro propio upstream** —de ahí salen los once parches de musl— así que la comparación
133no es contra una distro lejana: es contra la que ya construye este mismo código.
134
135| | `recipes/firefox.toml` | Alpine `community/firefox` |
136|---|---|---|
137| LTO | **no** | `--enable-lto=cross` |
138| PGO | **no** | `--enable-profile-use=cross` + `merged.profdata` + `jarlog` |
139| Linker | GNU ld (via gcc) | `--enable-linker=lld` (lld 22) |
140| RELR | **no** | `--enable-packed-relative-relocs` |
141| Sandbox RLBox | **APAGADO** (`--without-wasm-sandboxed-libraries`) | `--with-wasi-sysroot` |
142| Extensiones sin firmar | **no declarado** | `--with-unsigned-addon-scopes=app,system` |
143| Allocator | `--disable-jemalloc` | `--disable-jemalloc` — **igual, no es desviación nuestra** |
144| Librerías | todo bundleado | `--with-system-{icu,nss,nspr,av1,vpx,webp,png,jpeg,zlib,ffi,libevent,pixman,pipewire}` |
145
146Arch hace lo mismo en el eje de velocidad («Do 3-tier PGO», `--enable-lto=cross`,
147`--enable-profile-use=cross`). **Ni Alpine ni Arch usan BOLT** (cero menciones en los dos ficheros)
148⇒ BOLT no es la desventaja, es el extra. Lo que nos separa del resto del mundo es **PGO + LTO**, del
149orden de 10-20% en carga de página y JS.
150
151**Tres lecturas que hay que sacar de esa tabla:**
152
1531. **RLBox apagado es una desventaja de SEGURIDAD, no de velocidad, y probablemente pesa más que el
154   PGO.** Es la jaula wasm alrededor de los parsers de fuentes y medios —graphite, ogg, expat,
155   woff2—, o sea exactamente el código que come entrada no confiable. Encenderlo pide `wasi-sdk` +
156   `wasi-compiler-rt` en el corpus: receta propia, no un flag.
1572. **`--with-unsigned-addon-scopes=app,system` nos falta y `atuq` lo NECESITA** para shipear sus
158   propias extensiones desde `distribution/extensions/` (§6.1, §7). Es un flag de `configure` ⇒
159   **una capacidad de `atuq` que no se resuelve en el overlay: exige tocar la base.**
1603. **Las librerías del sistema son diferencia a propósito, no deuda.** Alpine usa las suyas; nuestro
161   corpus *es* la fuente y bundlear es lo que mantiene la clausura cerrada.
162
163**Corolario que ordena el plan: todo esto entra en UN SOLO rebuild de `firefox`.** Cada rebuild son
164cuatro horas y re-sella la cola Gecko entera (waterfox incluido), así que LTO, PGO, RELR, lld, RLBox
165y el scope de addons se juntan en una sola pasada, no en seis.
166
167### 3.ter Los dos muros del PGO que las distros no tienen
168
169**El perfil se junta corriendo el navegador, y las dos distros lo hacen bajo `xvfb-run`** (Alpine:
170`xvfb-run -a -s "-screen 0 1920x1080x24" ./mach python build/pgo/profileserver.py`; Arch, idéntico).
171**Nosotros no tenemos X11 en el corpus** —es la decisión Wayland-only de toda la distro— así que ese
172camino no existe acá. La salida takana-nativa es correrlo bajo un **sway headless**
173(`WLR_BACKENDS=headless`), que ya tenemos del frente wlr/sway y arranca en segundos.
174
175**✅ EL MURO DEL DISPLAY ESTÁ DESPEJADO (2026-09-06).** No es una previsión: se corrió. `sway`
176headless con `WLR_BACKENDS=headless` y `WLR_RENDERER=pixman` arranca en **gioser**, que es un LXC
177**sin `/dev/dri`** —o sea sin GPU ninguna, todo software— y dibuja píxeles de verdad:
178`docs/evidencia/sway-headless-gioser-sin-gpu-2026-09-06.png` (sway 1.10 / wlroots 0.18.2 /
179foot 1.27.0, los tres binarios nuestros, salida `HEADLESS-1` a 1280x720). El ciclo entero es de
180~2 min con `scripts/wlr/sway-headless.sh`.
181
182Dos cosas que hubo que resolver y conviene tener escritas antes de repetirlo:
183· **Hidratar bajo el montaje del store.** `hydrate-profile.py` ENLAZA, y un hardlink no cruza
184  montajes: con `--into work/...` sale `EXDEV` porque el store vive en otro disco. Con
185  `--into store/.rootfs/sway` son 193/193 nodos, 41.677 ficheros y **4,9 G que no ocupan** (son
186  hardlinks al store).
187· **Inyectar el loader de musl.** `sway` salió DINÁMICO y pide `/lib/ld-musl-x86_64.so.1`, que el
188  cierre no trae; el error es `exec: /usr/bin/sway: not found`, que se lee como «falta el binario»
189  y lo que falta es su intérprete. Se resuelve con
190  `--ro-bind /usr/lib/musl/lib/libc.so /lib/ld-musl-x86_64.so.1` (en musl el `libc.so` ES el
191  loader). Es la misma familia que el resto de la noche: el mensaje nombra lo que buscó, no lo que
192  falta.
193
194**Y el perfil no es determinista.** Los contadores dependen del timing de la corrida, así que dos
195generaciones del profdata no dan los mismos bytes — a las distros no les importa porque no persiguen
196bit-repro; a nosotros nos rompe el invariante. La salida: **generar el perfil UNA vez y sellarlo como
197artefacto propio, consumido por hash** desde `firefox`. El build optimizado vuelve a ser determinista
198aunque su insumo no lo sea, y el perfil se regenera a propósito, no por accidente.
199
200### 3.quinquies El PGO, y los tres muros que el plan no tenía escritos (2026-09-07)
201
202El §3.ter daba dos muros: el display (X11 que no tenemos) y el no-determinismo del perfil. Los dos
203eran ciertos. Aparecieron **tres más**, y ninguno se ve sin construir:
204
2051. **`libclang_rt.profile.a` no existía.** El lab trae clang 22.1.8 pero **ninguna runtime de
206   compiler-rt** — su `lib/` del resource dir ni existe, exactamente como ya documentaba
207   `wasi-compiler-rt` para el caso wasm. El build instrumentado murió en el minuto 38:56 con
208   `ld.lld: cannot open …/libclang_rt.profile.a`. De ahí sale `recipes/compiler-rt-profile.toml`.
209
2102. **Los sandboxes de Firefox matan la corrida de perfilado.** Con ellos puestos los procesos hijo
211   mueren con **signal 11**, el que renderiza no llega a nacer, el servidor local registra **0 GET**
212   y el único `.profraw` que queda es el del padre arrancando — un perfil de nada, con todo en
213   verde. Es lo mismo que hace el `profileserver.py` de Mozilla. Sólo aplica a la corrida de
214   entrenamiento: el firefox que se distribuye lleva sus sandboxes intactos.
215
2163. **El worker no puede bajar del mirror de fuentes.** Tiene `scripts/fuentes/mirror-env.sh` desde
217   el 2026-09-01 pero no la clave del Storage Box, así que una receta pineada al mirror —y el perfil
218   es la primera— no se construye allá. Se sembró el artefacto sellado, que es content-addressed:
219   consigue lo mismo sin mover una credencial de máquina.
220
221**Cómo se verificó que el PGO llegó, y por qué NO como RLBox.** Con RLBox el artefacto delata la
222jaula (símbolos `w2c_*`, cero → 634). Acá el discriminante análogo serían las secciones
223`.text.hot`/`.text.unlikely` que clang emite al particionar por temperatura — y **no sirve**: con
224lld y ThinLTO el enlazador las fusiona, así que dan cero en el binario con PGO y sin él. La
225evidencia que sí vale está un nivel por debajo del `configure`: **189 invocaciones del compilador
226llevan `-fprofile-use`**, el configure encontró `llvm-profdata`, y no hay ni un aviso de perfil que
227no cuadre. `libxul.so` pasa de 226.078.752 a 227.802.816 bytes, consistente con inlining de caminos
228calientes. Es evidencia de build, no de artefacto, y conviene decirlo así.
229
230⚠⚠ **LEER PRIMERO: LOS PORCENTAJES DE LAS TRES TABLAS QUE SIGUEN ESTÁN DILUIDOS Y NO SON LO
231QUE PARECEN.** Miden el ciclo completo del proceso, del que **~16 s son arranque**. La corrección,
232con el control que faltaba, está más abajo en «EL CONTROL QUE FALTABA». Se dejan tal cual se
233publicaron, sin retocar, porque el error de método es el que enseña.
234
235**LA GANANCIA, MEDIDA (2026-09-07).** Mismo rootfs, mismo script, corridas INTERCALADAS A/B/A/B
236para que la deriva de carga afecte a las dos, mediana de 7, calentamiento descartado. Lo único que
237cambia entre variantes es un `--ro-bind` de `/usr/lib/firefox`.
238
239| página | sin PGO | con PGO | |
240|---|---|---|---|
241| DOM+layout+strings, **fuera** del corpus de entrenamiento | 23.540 ms | 21.327 ms | **−9,4 %** |
242| SunSpider `3d-raytrace`, **dentro** del corpus | 16.034 ms | 15.878 ms | −1,0 % |
243
244Los rangos no se solapan en ninguna de las dos (23.185–23.654 vs 21.030–21.650), así que las dos
245diferencias son reales y no ruido.
246
247⚠ **Y el resultado sale al revés de lo esperable, que es lo interesante.** La intuición dice que
248medir sobre el conjunto de ENTRENAMIENTO infla la ganancia; acá el corpus da el número MÁS BAJO. La
249razón es que `3d-raytrace` es aritmética pura en un bucle caliente, y ese bucle **no lo ejecuta el
250C++ de SpiderMonkey: lo ejecuta código máquina que el JIT genera en tiempo de ejecución**. El PGO
251optimiza el intérprete, el GC y el propio compilador JIT — no el código que el JIT emite. Un
252benchmark JIT-bound es casi ciego al PGO por construcción.
253
254Donde sí se ve es en el camino DOM/layout/arranque, que es C++ de principio a fin. Y eso es también
255lo que un usuario percibe: la medición cubre el ciclo COMPLETO del proceso (arrancar → renderizar →
256capturar → salir), no el rendimiento en régimen de una página ya cargada. De ahí que hasta la página
257de SunSpider tarde 16 s: casi todo es arranque.
258
259**Corolario para el corpus de entrenamiento — APLICADO Y MEDIDO (2026-09-08).** El corpus estaba
260sesgado hacia JS justo donde el PGO menos rinde, así que se le sumaron 10 páginas de maquetación
261propias (`scripts/pgo-corpus/`): flexbox, grid, tablas con los dos algoritmos de layout, texto en
262columnas, selectores contra 600 reglas, pintado, transforms, SVG, scroll pegajoso y layout
263thrashing. El perfil pasó de 5.885.254.799 ejecuciones registradas a **38.498.366.335 (6,5×)**.
264
265| | sin PGO | PGO v1 (36 pág.) | PGO v2 (46 pág.) |
266|---|---|---|---|
267| DOM/maquetación, fuera del corpus | 23.285 ms | 21.050 ms (−9,6 %) | **20.640 ms (−11,4 %)** |
268| SunSpider `3d-raytrace` | 16.003 ms | 15.846 ms (−1,0 %) | 15.857 ms (−0,9 %) |
269
270**La mejora en maquetación es real y no de medianas:** SEIS de las siete muestras de v1 son más
271lentas que TODAS las de v2; sólo una cae dentro del rango de v2. En SunSpider, en cambio, v1 y v2
272son **indistinguibles** —los valores se entrelazan por completo— que es justo lo esperable: ese
273camino lo ejecuta el JIT y el PGO no lo toca. Un corpus mejor no puede mejorar lo que el PGO no
274alcanza.
275
276⚠ Una de las siete corridas de v2 dio 120.029 ms contra ~20.600 de las otras seis: un atípico
277transitorio. **La mediana lo ignora por diseño**; una media lo habría convertido en «v2 es
278catastróficamente peor». Fue la razón de elegir mediana antes de ver un solo número.
279
280**EL JARLOG: PROBADO, MEDIDO, Y APARCADO PORQUE ROMPE `atuq` (2026-09-08).** Desde
281`d9833a58` el perfil trae también el `jarlog` y `firefox` lo consume con `--with-pgo-jarlog`. No
282hizo falta la extensión `Quitter` de Mozilla, que este documento daba por bloqueante: su
283`profileserver.py` sólo traduce `JARLOG_FILE` a `MOZ_JAR_LOG_FILE` en el entorno del navegador.
284
285| | sin PGO | v1 (36 pág.) | v2 (46 pág.) | v3 (= v2 + jarlog) |
286|---|---|---|---|---|
287| DOM/maquetación | 23.008 ms | 20.851 (−9,4 %) | 20.527 (−10,8 %) | 20.502 (−10,9 %) |
288| SunSpider | 16.006 ms | 15.859 (−0,9 %) | 15.869 (−0,9 %) | 15.838 (−1,0 %) |
289
290**El jarlog solo aporta −0,1 % y −0,2 %: indistinguible de cero.** Y el argumento no es que los
291rangos se solapen —que se solapan— sino que **la misma variante varía ~1 % entre corridas de días
292distintos** (v1 dio 20.851 hoy y 21.050 ayer). El suelo de ruido del banco es **diez veces** el
293efecto buscado.
294
295Eso NO es un fallo del jarlog: es que el banco corre con la **caché de página caliente**, y ahí
296reordenar `omni.ja` no ahorra ninguna lectura. Su terreno es el arranque EN FRÍO, que este
297instrumento no reproduce; medirlo pediría vaciar la caché entre corridas, que es una acción de
298sistema. **Lo que sí está probado es que la función existe**, y del lado del artefacto: `libxul` es
299IDÉNTICO byte a byte entre v2 y v3 y el `omni.ja` difiere en 26 bytes — el jarlog tocó exactamente
300lo que debía tocar y nada más. **Y sí costaba.** Al reconstruir `atuq` sobre ese firefox, murió:
301
302    zipfile.BadZipFile: Bad magic number for central directory   (rebrand.py)
303
304El jarlog convierte el `omni.ja` al **formato «jar optimizado» de Mozilla**, que mueve el directorio
305central al principio:
306
307    sin jarlog:  PK\003\004    ZIP estándar — `zipfile` lo abre, 5306 entradas
308    con jarlog:  \376\204#\0   `zipfile` lo rechaza
309
310Y `atuq` reempaqueta el `omni.ja` con `zipfile` para su branding. O sea que el jarlog **rompe el
311navegador propio de la distro**.
312
313**Aparcado, y la cuenta es asimétrica:** el coste está MEDIDO y el beneficio NO. Cambiar algo que
314funciona por una ganancia que no se pudo medir, rompiendo algo que sí funcionaba, es mal negocio.
315Se retoma cuando (1) se mida el arranque EN FRÍO, que es su terreno, y (2) `rebrand.py` sepa leer el
316jar optimizado o des-optimizarlo antes. El blob `92497cdd…` del mirror ya trae el jarlog, así que
317retomarlo es cambiar una línea y re-pinear el sha256 — no volver a perfilar.
318
319⚠ **Y la lección de método, que es la que más vale:** el jarlog parecía GRATIS. «Es lo que hace
320upstream y no cuesta nada» — lo escribí yo, en este documento, hace unas horas. El coste no apareció
321midiendo el jarlog sino **construyendo lo que dependía de él**. Una función que se declara gratuita
322sin haber reconstruido a sus consumidores no es gratuita: es no medida.
323
324⚠ **Dato que los datos delatan:** los dos valores atípicos del banco son 120.034 y 120.030 ms, o sea
325exactamente el `timeout 120` del arnés. No son ruido de carga: son corridas COLGADAS al arrancar en
326headless, 2 de 56 (~3,5 %). Sin perseguir, pero anotado.
327
328⚠ Y lo que este banco NO puede afirmar: las 10 páginas nuevas ejercitan los mismos subsistemas que
329la página de medición, así que el parentesco con lo que ahora se entrena es mayor que antes. El
330−11,4 % es real para ESTA carga; generalizarlo a «cualquier página» sería justo el error que el
331propio corpus de Mozilla cometía al revés.
332
333## EL CONTROL QUE FALTABA: el arranque eran 16 s, y sin restarlo ningún porcentaje significa nada (2026-09-08)
334
335Todo lo de arriba mide **el ciclo completo del proceso**: arrancar firefox, renderizar, capturar el
336PNG y salir. Nunca se midió cuánto de eso era la página. Faltaba el control más barato posible —una
337página VACÍA— y con él las cifras publicadas se leen distinto:
338
339| carga | total sin PGO | total con PGO | **trabajo** sin → con | mejora del TRABAJO |
340|---|---|---|---|---|
341| `vacia.html` (arranque puro) | 16.026 ms | 15.883 ms | — (ES la línea base) | **−0,9 %** |
342| DOM/maquetación | 23.307 ms | 20.627 ms | 7.281 → 4.744 ms | **−34,8 %** |
343| carga ajena (canvas/JSON/img) | 20.576 ms | 20.517 ms | 4.550 → 4.634 ms | **+1,8 % (nada)** |
344| SunSpider `3d-raytrace` | 15.992 ms | 15.877 ms | **−34 → −6 ms** | bajo el ruido |
345
346Las cuatro cargas y las dos variantes, **en una sola sesión intercalada**: restar el arranque de una
347corrida al total de otra no vale, porque la misma variante deriva ~1 % entre días.
348
349**Qué cambia, en concreto:**
350
3511. **El «−10,9 %» que publiqué es correcto como número y engañoso como afirmación.** Es real para el
352   ciclo completo, que es lo que un usuario espera al abrir el navegador. Pero se leía como
353   «firefox es un 11 % más rápido», y sobre el trabajo de la página la mejora es **−34,8 %**: el
354   PGO rinde TRES VECES más de lo que dije. El error subestimaba mi propio resultado.
355
3562. **La explicación que di del SunSpider era aire.** Escribí que el −1,0 % se explicaba porque un
357   benchmark JIT-bound es ciego al PGO. Mirá la fila: su trabajo mide **−34 ms**, negativo — la
358   página con el benchmark tardó MENOS que la vacía. `3d-raytrace` corre en decenas de ms y su
359   trabajo es más chico que la variación entre corridas. **Ahí no había nada que medir.** El −1,0 %
360   era el −0,9 % del arranque, y yo le colgué encima una teoría sobre el JIT.
361
362   La teoría puede ser cierta —el mecanismo es real y está bien descrito— pero **esta medición nunca
363   la probó**, y la presenté como si sí. Un número real más un mecanismo real no hacen una
364   explicación: hace falta que el número mida el mecanismo.
365
3663. **La carga ajena confirma que el PGO no es magia.** +1,8 % sobre su trabajo, o sea cero: mejora
367   lo que se le entrenó (maquetación, −34,8 %) y no lo que no (canvas/JSON, 0 %). Eso es exactamente
368   lo que un PGO honesto debe hacer, y es mejor evidencia de que funciona que el número grande.
369
3704. **El jarlog no se rehabilita.** Su −0,1 % diluido pasa a ~−0,5 % sin diluir; sigue muy por debajo
371   del ~1 % que la misma variante deriva entre días. La conclusión de aparcarlo no se mueve.
372
373⚠ **La lección, que es la misma de toda esta jornada con otro disfraz.** El instrumento funcionaba,
374las medianas eran correctas, las corridas estaban intercaladas, los rangos no se solapaban — todo el
375rigor estadístico estaba puesto **sobre una cantidad que no era la que yo creía estar midiendo**.
376Ninguna cantidad de repeticiones lo habría revelado: sólo lo revela un control, y el control costó
377siete corridas de una página vacía. **Antes de refinar la precisión de un número, hay que gastar una
378medición en averiguar qué mide.**
379
380Y el detalle que lo vuelve barato de recordar: el control salió NEGATIVO en una fila. Un valor
381imposible es la forma más limpia que tiene una medición de avisar que el modelo está mal, y por eso
382esa fila se deja publicada con su signo menos en lugar de redondearla a cero.
383
384**Y la pregunta que un insumo no determinista obliga a hacer, respondida: `firefox` SIGUE
385REPRODUCIENDO bit a bit** (`verificar-repro.sh`: REPRODUCEN 1, DERIVA 0, NO-DETERMINISMO 0). Era el
386riesgo real de esta unidad, no el rendimiento: si el `-fprofile-use` hubiera metido cualquier
387decisión dependiente del orden o del timing, habríamos cambiado velocidad por el invariante del
388proyecto. Congelar el perfil como fuente pineada era el argumento; esto es la medición.
389
390### 3.quater La cadena wasm, y las tres cosas que costó (2026-09-06)
391
392`--without-wasm-sandboxed-libraries` se cambió por `--with-wasi-sysroot=/usr/share/wasi-sysroot`,
393que es literalmente el flag de Alpine. Detrás hay cinco recetas nuestras, en este orden obligatorio:
394
395```
396wasi-libc-headers → wasi-compiler-rt → wasi-libc → wasi-libcxx → wasi-sdk → firefox
397```
398
399`wasi-libc-headers` existe **sólo** para romper el ciclo del bootstrap y pinea un commit VIEJO a
400propósito (triple `wasm32-wasi`), mientras `wasi-libc` pinea el nuevo (`wasm32-wasip1`, el que
401clang 22.1 usa de verdad). Es lo mismo que hace Alpine y no es un descuido.
402
403**Lo que costó, que es la parte reusable:**
404
4051. **`check-symbols` falla por sesgo de clang, no por un artefacto malo.** El lab trae clang 22.1.8
406   y el snapshot del árbol viene de uno anterior: sobra `#define __wasip1__ 1` y nada más. Se
407   reconcilia el snapshot; **no** se salta con `make no-check-symbols`, que se lleva por delante la
408   comparación de símbolos —la mitad que sí vale y que pasaba byte a byte.
409
4102. **Faltaba `wasi-libcxx`, y el hueco era visible en el grafo sin construir nada.** El configure
411   murió a los 6 s con `'cstring' file not found`, culpando a los headers de WASI, que estaban
412   perfectos: `<cstring>` es C++ y las librerías enjauladas no son todas C. El `wasi-sdk` de Alpine
413   declara `wasi-libc wasi-libcxx wasi-compiler-rt` —TRES— y la cadena tenía dos. **El `depends`
414   del paquete ajeno equivalente es una comprobación de completitud gratis**, antes de gastar CPU.
415
4163. **Un directorio VACÍO que sí importa.** Con `wasi-libcxx` sellado el configure volvió a morir
417   igual, y `cstring` estaba en el artefacto: clang no lo buscaba. Falta el `mkdir -p
418   include/c++/v1` que Alpine tiene en su `package()` y que parece ruido de empaquetado — es la
419   sonda por la que el driver decide que el sysroot tiene layout de libc++. Medido dentro del lab:
420   sin ese directorio, `'cstring' file not found`; con él, exit 0. Es el **reverso exacto de la
421   regla 3 del `CLAUDE.md`**: allá un directorio vacío es un artefacto mentiroso, acá es una
422   declaración dirigida al compilador.
423
424De ahí sale el criterio de guardián que ahora usan estas recetas: **«existe» no es «se encuentra»**.
425`wasi-libcxx` corre hoy la misma prueba que hace el configure de firefox, contra un sysroot
426fusionado con el de su dep, así que un fallo aparece en el segundo 20 de una receta chica —con la
427lista de búsqueda de clang impresa— y no en el segundo 6 de un build de cuatro horas que además
428culpa a otro. Y `firefox` exige RLBox **en el binario**: sobre `b3:352d7880` (el último con RLBox
429apagado) los patrones `w2c_`, `rlbox` y `wasm2c` dan cero los tres en `libxul.so`, así que
430cualquiera apareciendo prueba que la jaula entró. Es la tercera repetición de la lección de
431`MOZ_REQUIRE_SIGNING` y `MOZ_BUILD_DATE`: una bandera que el configure acepta y el artefacto ignora.
432
433**Evidencia de reproducibilidad cruzada (2026-09-06).** Las cinco recetas se construyeron por
434separado en las DOS máquinas —el hub (gioser, 4c) y el worker (`dev.gioser.net`, LXC 6c)— y los
435cinco artefactos salen **byte a byte idénticos**, comparando el árbol completo de cada uno:
436
437| receta | sha256 del árbol | |
438|---|---|---|
439| `wasi-libc-headers` | `0d34f9cb27530606…` | ✓ |
440| `wasi-compiler-rt` | `0c4fc6fdf08ef908…` | ✓ |
441| `wasi-libc` | `c13acdf896f2b78c…` | ✓ |
442| `wasi-libcxx` | `b1d9aeec08499063…` | ✓ |
443| `wasi-sdk` | `b49f7a74a5caed3f…` | ✓ |
444
445No es un trámite: **el lab NO entra en `hash_inputs`**, así que dos labs distintos pueden sellar
446bytes distintos en la misma dirección del store y nada lo detecta. Esto dice que para esta cadena
447los dos labs coinciden de verdad, y no sólo que coinciden los `ArtifactHash`.
448
449## 4. Lo que `atuq` NO promete
450
451Esta sección existe antes que la lista de features a propósito, con el mismo criterio que el modelo
452de adversario de `qullqa`: **lo que no se promete se escribe primero, porque una promesa falsa de
453privacidad es peor que no ofrecer nada.**
454
455**`atuq` no es Tor Browser y no va a tener «modo Tor».** Tor Browser no es «Firefox + SOCKS»: su
456valor son las contramedidas de huella (RFP, letterboxing, normalización de fuentes/canvas/timers) y
457sobre todo **un conjunto de anonimato donde todos se ven idénticos**. `atuq` es, por construcción, un
458binario único en el mundo: musl, Wayland-only, branding propio, extensiones propias. Alguien saliendo
459por Tor desde `atuq` es **más** identificable, no menos, y su conjunto de anonimato son diez
460personas. Para anonimato: Tor Browser, y lo decimos nosotros primero.
461
462Lo que sí se ofrece, y es honesto porque es otra cosa: **proxy por contenedor** (§6.8) — separación
463de tráfico, no anonimato.
464
465**`atuq` tampoco promete** anti-fingerprinting propio (es un programa de investigación con equipo
466full-time) ni bloqueo de anuncios propio (se shipea uBO y listo).
467
468## 5. Contexto verificado al escribir (2026-09-05)
469
470De `docs/state/build-state.json`, no de memoria:
471
472| Dato | Valor |
473|---|---|
474| Recetas / nodos | 838 / 840 |
475| Selladas | 837 |
476| `firefox` 154.0 | **sealed**, cola `corpus` |
477| `waterfox` 6.7.1.1 | **never** — primer build en curso |
478| Plataforma compartida | `gtk3`, `atk`, `nodejs`, `clang18`, `cbindgen`: todas sealed |
479
480**`atuq` no arranca hasta que `waterfox` selle.** Waterfox es la prueba de la tesis del reúso —«las
481diferencias reales con `firefox.toml` son TRES: la fuente, el branding y la versión»—. Si esa tesis
482falla, el precio de todo este documento cambia y hay que releerlo.
483
484## 6. Los diferenciadores, priorizados
485
486La columna «quién más lo tiene» es lo que evita que nos contemos un cuento.
487
488| # | Qué | Quién más lo tiene | Pieza que ya existe | Costo |
489|---|---|---|---|---|
490| 6.1 | **`sct` — transparencia de scripts** | **nadie** | `puriy-sct`, `puriy-sct-testigo` | medio |
491| 6.2 | **Descargas direccionadas por contenido** | nadie | store CAS de takana, `tejido` | bajo |
492| 6.3 | **Archivo personal + RAG local** | Rewind/Recall (nube, Windows); SingleFile (guarda, no busca) | `khipu`, `rag-motor`, `willay-rag` | medio |
493| 6.4 | **Historial y perfil sobre `qullqa`** | nadie | `qullqa-core`, `qullqa-pozo` | medio |
494| 6.5 | **Foco por `cortafuegos`, no por extensión** ✅ **cadena y navegador; falta quién lo aplica** | nadie (nadie es dueño del navegador *y* del sistema) | `cortafuegos`, `nftables` (nuevo en el corpus) | bajo |
495| 6.6 | **Medios por fuera del navegador** | extensiones sueltas; de fábrica no | `foreign-ytdlp`, `-platform`, `-dlna` | bajo |
496| 6.7 | **IA local en la barra lateral** ✅ **motor**; falta modelo y barra | Chrome/Edge son nube; Zen no tiene | `llama-cpp` (NUEVO en el corpus) — `rimay`/`iniy` NO servían, ver §6.7 | bajo |
497| 6.8 | **Proxy por contenedor** ✅ **v0.5** | nadie de fábrica | — (API de Firefox) | bajo |
498| 6.9 | **Torrent adentro** | **Vivaldi lo trae; Brave tuvo WebTorrent** | `shared/foreign-torrent` (SDD propio, sobre librqbit) | bajo |
499
500### 6.1 `sct` es la joya
501
502BLAKE3 a todo lo que se ejecuta, y **negarse a correr código que ningún testigo vio nunca**. Es lo
503más cerca que hay de «Certificate Transparency, pero para el JavaScript que te corre en la cara», y
504no lo tiene nadie: ni Zen, ni Brave, ni Tor Browser.
505
506En tawasuyu ya está escrito y —dice su README— la lógica es **agnóstica del transporte** y está
507certificada sin red; `puriy-sct-testigo` es sólo el cable HTTP.
508
509- **v1 — HECHA 2026-09-10, y con una corrección de este propio párrafo.** La extensión intercepta el
510  cuerpo de cada `<script src>` con `filterResponseData` y se lo pasa al **host** (§7), que hashea y
511  decide. Decía «consulta al testigo antes de dejarla pasar», y las dos mitades había que ajustarlas:
512  el cable del testigo es un POST con **postcard**, así que un JS no puede ser su cliente (§7.quater);
513  y v1 **observa y avisa, no bloquea** — que es lo que `puriy-sct` dice de su propia v1, y además
514  bloquear cada script contra un viaje entre procesos no es «más seguro», es un navegador que nadie
515  usa. Medida de punta a punta por `scripts/test-atuq-sct.py`, con servidor HTTP real, seis cargas de
516  página y dos controles.
517- **v2, forma fuerte:** gancho en el script loader de Gecko. Eso sí es parche de árbol ⇒ §2.bis ⇒
518  después de la toolchain del §3. Es también lo único que ve los scripts **inline** —
519  `filterResponseData` entrega el cuerpo de una PETICIÓN, y un inline viaja dentro del HTML— y lo único
520  que puede negarse a ejecutar.
521
522#### 6.1.bis Lo que costó armarla, que es lo que no se puede deducir (2026-09-10)
523
524Cinco cosas se midieron en vez de suponerse, y cada una tenía su forma de fallar en silencio:
525
526| Qué | Cómo falla si se supone mal |
527|---|---|
528| El manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/`, no en el appdir (`XRESysNativeManifests` es un `/usr/lib/mozilla` **compilado**) | el puerto se cierra «sin error y sin mensaje» |
529| **El manifiesto NO puede llevar argumentos** — `NativeMessaging.sys.mjs`: `command = manifest.path`, y los únicos argumentos son `[ruta-del-manifiesto, id]` | `puriy-costura` sin `--state` corre en memoria ⇒ cada arranque vuelve a «aprendiendo» y **nada alerta nunca**: la función existe, no falla, y no protege |
530| `filterResponseData` + el permiso `webRequestFilterResponse` **están** en nuestro `omni.ja` | se habría escrito la extensión contra una API que el build no tiene |
531| Los `inline` no se ven por esta vía | se habría prometido cobertura total teniendo la mitad |
532| **Una carga puede producir dos peticiones del mismo documento, y una llega con `tabId = -1`** | agrupando por `(tabId, documento)` esa carga contaba **dos visitas** |
533
534La última es la que más importa y la que menos se veía. Con las visitas infladas, un origen **se
535estabiliza antes de conocer su código real**, y entonces alerta por churn legítimo: el falso positivo
536que la spec de `puriy-sct` pide evitar por encima de todo. Se agrupa por documento —lo que hace que dos
537pestañas con la misma url cuenten UNA, que es el error seguro: tarda más en proteger, no alerta de
538más— y el guardián vigila el invariante «una carga, una visita», así que si vuelve, falla ruidoso.
539
540**Y el aviso se mide, no se supone.** La extensión relee la insignia con `getBadgeText` después de
541ponerla y lo dice, así que el guardián exige insignia vacía en las cinco cargas sin novedad y `"1"` en
542la del script cambiado. Es la única parte de toda la cadena que el usuario llega a ver: dejarla como
543«se llamó a la API» sería dejar sin medir justo el final.
544
545**Dos límites escritos donde no se pueden no leer** (el propio `fondo.js`, el `lib.rs` del host y los
546dos LEEME): se hashea el TEXTO ya decodificado, que vale para comparar dos cargas nuestras y **no** es
547comparable con el hash de los bytes servidos que publique un tercero; y el aviso es pasivo (insignia,
548no modal) porque un modal por despliegue entrena a cerrarlo sin leer.
549
550### 6.2 Una descarga con identidad
551
552Lo bajado no es un archivo en `~/Downloads`: es un objeto BLAKE3 en el store — deduplicado,
553verificable, y re-compartible por `tejido`. El torrent (6.9) alimenta lo mismo. **Ningún navegador
554trata una descarga como algo con identidad**; para todos es un blob con nombre, y por eso lo bajás
555dos veces y no lo sabés.
556
557Ésa, y no «tener torrent», es la razón por la que 6.9 vale: Vivaldi ya tiene torrent, pero termina en
558una carpeta.
559
560#### 6.2.bis Hecha (2026-09-10) — y el almacén NO es el del sistema, por una pérdida de datos medida
561
562`cas.ingest` en el host + la extensión `descargas@atuq.tawasuyu`, que observa las descargas
563TERMINADAS y le pasa la ruta. El objeto entra a un CAS BLAKE3 y el navegador contesta el hash, el
564tamaño, con qué nombre apareció la primera vez y **cuántas veces se bajó ese mismo contenido**.
565
566    1ª descarga  contenido A como `uno.bin`   ⇒ dedup=false, veces=1
567    2ª descarga  contenido A como `dos.bin`   ⇒ dedup=TRUE, veces=2, UN solo blob en disco
568
569**No es un almacén nuevo**: es `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y
570`tejido` — lo dice el propio código de tejido («esto NO es un almacén nuevo»). Un objeto que entra por
571acá queda donde los otros lo van a buscar, y el `<hex>` del CAS **es** el `expected_hash` de un `.swm`.
572
573**Tres cosas que este trabajo dejó, y ninguna es la feature:**
574
5751. **`arje-cas` gana ingesta en streaming.** `store` toma `&[u8]`, así que ingerir un fichero obliga a
576   leerlo entero: para los binarios de arje da igual, para lo que baja un navegador no —una ISO de
577   4 GiB serían 4 GiB de `Vec`—. `almacenar_fichero_en` hace UNA pasada (lee, hashea y escribe a la
578   vez, a un temporal con el PID, y recién entonces renombra al `<hex>`) y devuelve
579   `(hash, bytes, ya_estaba)`: ese `ya_estaba` **es** la deduplicación vista desde afuera.
5802. ⚠ **La raíz del CAS de descargas no es la del sistema, y es por una pérdida de datos medida.**
581   `arje_cas::gc` borra todo blob que no esté en el set `reachable` de su llamador, y el único
582   llamador que existe hoy —`arje-brain::introspect`, `IntrospectRequest::GcCas`— lo arma con la
583   cadena de audit + las raíces vivas del grafo + lo que le pase el caller. **Una descarga del usuario
584   no está en ninguno de esos conjuntos: el primer `GcCas` se la llevaría, en silencio.** Por eso van
585   a `<estado>/descargas-cas`: mismo formato (mover un objeto al CAS del sistema es un `rename`),
586   fuera del alcance del GC de otro. Lo que se pierde, dicho: `tejido` sirve el CAS por defecto, así
587   que compartir una descarga hoy exige apuntarlo a esta raíz (`ENTE_CAS_ROOT`, la perilla que su
588   propio código documenta) hasta que el índice de descargas sea una raíz que el GC respete.
5893. **No se toca el fichero del usuario.** Se copia al CAS y se deja donde estaba. Borrar o mover lo
590   que alguien acaba de bajar —aunque «ya esté en el CAS»— no es una decisión que le toque a un
591   navegador tomar por su cuenta, y el guardián lo comprueba byte a byte.
592
593El índice va en **JSON** y no en postcard a propósito: es la lista de lo que el usuario bajó y tiene
594que poder leerse con `cat` el día que el binario no arranque. El estado de `sct` es interno; esto es
595suyo.
596
597**Y ahí apareció el bug que valía más que la función: hay UN HOST POR PUERTO, no uno por perfil.**
598Con `sct` y `descargas` hablando hay dos procesos vivos a la vez, cada uno con su copia en memoria
599del estado, y cada uno escribía el fichero ENTERO al guardar. El de descargas **borraba el registro
600TOFU de `sct`** al salir; el de `sct` **revertía el índice de descargas** al valor que tenía cuando
601arrancó. Los dos contestaban bien: el daño estaba sólo en el disco.
602
603Se encontró porque el guardián lee el índice **en disco** en vez de creerle a la respuesta del host —
604la respuesta decía `veces=2` y el fichero decía 1. Aseverar sobre lo que el proceso contestó habría
605dejado pasar una pérdida de datos silenciosa. El arreglo es que cada proceso escriba **sólo la parte
606que tocó**, y lo que eso NO cubre queda dicho: dos procesos que toquen la MISMA parte se seguirían
607pisando, y ese día hace falta un dueño único del estado, no más banderas.
608
609⚠ **Y el test de ese arreglo también costó, con una lección propia:** la primera versión abría los dos
610hosts en SECUENCIA y **pasaba con el bug puesto**, porque el segundo cargaba de disco lo que el
611primero acababa de escribir. El bug necesita que las vidas se SOLAPEN. Se comprobó rompiendo el
612arreglo a propósito —con la rotura el test falla nombrando la causa, sin ella pasa—, que es la única
613forma de saber si un guardián sirve.
614
615`scripts/test-atuq-descargas.py` baja de verdad —`Content-Disposition: attachment`, no una llamada a
616la API— dos veces, y trae control negativo: con OTRO contenido la segunda vez, exige `dedup=false` y
617dos objetos. Sin él, una sonda que dijera «ya lo tenías» siempre se vería idéntica a una que funciona.
618
619⚠ Y corre sobre **sway headless**, no sobre `--headless`, porque en `--headless` una descarga
620terminada mata al navegador con SIGSEGV — con y sin nuestra extensión. Está medido y atribuido en
621el §6.10.bis.
622
623### 6.3 El archivo personal es la razón por la que alguien usaría `atuq`
624
625Cada página visitada se congela en el CAS y entra al DAG de `khipu`; después le preguntás a tu
626propio historial en lenguaje natural, **todo local**. Es la feature que más justifica el proyecto
627entero, y sale de piezas que ya están escritas.
628
629#### 6.3.bis La MITAD que se puede pagar hoy, y la que no (2026-09-10)
630
631El §6.3 promete dos cosas y conviene separarlas, porque una está hecha y la otra depende de
632infraestructura que la imagen todavía no lleva:
633
634| mitad | estado |
635|---|---|
636| **congelar cada página leída y poder encontrarla** | ✅ HECHA — `archive.add` / `archive.search` + la extensión `archivo@atuq.tawasuyu` |
637| **preguntarle al historial en lenguaje natural** | ❌ falta, pero **el muro cayó el 2026-09-11**: el motor de embeddings ya está en el corpus (§6.7, `llama-cpp` sirve `/v1/embeddings`, medido). Queda pinear el modelo y escribir el `rag-motor::RagMotor`, como `willay-rag` |
638
639Lo segundo no se «aproxima»: `willay-rag` —el motor del centro de eventos, que es el ejemplo a
640copiar— necesita un **daemon de embeddings** (`rimay-verbo`) y un **backend LLM real** (`pluma-llm`),
641y su `try_build()` devuelve `None` cuando falta cualquiera de los dos, dejando el panel «no
642disponible». Enseñar una búsqueda literal diciendo que es la semántica sería el peor cambio posible,
643así que el verbo se llama `search`, el aviso está arriba de la función en el código, y acá queda
644escrito qué falta exactamente.
645
646**Lo que sí quedó, y por qué vale:** la identidad de una página archivada es el **BLAKE3 de su HTML
647ya ejecutado** —lo que la persona vio, no lo que el servidor mandó—, no su URL. De ahí sale todo:
648volver a la misma página no la duplica (suma una visita), y **la misma URL con otro contenido es otra
649página**, o sea que el archivo tiene versiones. Un historial de direcciones guarda punteros, y los
650punteros se pudren; esto guarda lo leído.
651
652**Lo que NO se archiva es la parte que hay que mirar:** nada de una ventana privada, nada fuera del
653marco principal (un `<iframe>` de publicidad no es una página que alguien leyó) y nada sin texto
654visible (guardar el HTML de un visor de PDF llena el archivo de cosas que después no se encuentran).
655El descarte de lo privado se comprueba en el fondo y no en el guión de contenido porque `tabs.get()`
656es lo único que sabe si la pestaña lo es; y si no se puede saber, **no se archiva**.
657
658⚠ **El control negativo distingue quién protege, y la respuesta medida NO es la que uno supondría.**
659`scripts/test-atuq-archivo.py --negative-control` abre la página en una ventana privada, exige que no
660se archive nada, y dice por qué. Medido sobre `atuq b3:5cfe3221`:
661
662    ✓ en ventana privada no se archivó nada — lo impidió el NAVEGADOR
663      (la extensión no llegó a correr en la ventana privada)
664
665O sea que hoy **el que protege es Gecko**: las extensiones no corren en ventanas privadas salvo que se
666las habilite. Nuestro `if (emisor.tab.incognito) return` **nunca se ejecuta** con la configuración
667actual. Eso no lo vuelve código muerto: es exactamente lo que haría seguro habilitar
668`private_browsing` en `ExtensionSettings` el día que haga falta —y sin él, habilitarlo archivaría en
669silencio lo que alguien abrió en privado—. Lo que sí sería un error es haber escrito «la extensión no
670archiva lo privado» sin saber cuál de las dos cosas estaba pasando.
671
672**Dos techos con nombre**, no escondidos en un `truncate`: el índice guarda 4 KiB de texto por página
673—es JSON para poder leerse con `cat`, y uno sin techo deja de poder— y el corte va por CARÁCTER, no
674por byte, porque `truncate` sobre medio multibyte entra en pánico y en un archivo personal el texto
675con acentos es el caso normal.
676
677#### 6.3.ter La otra mitad: preguntarle al archivo por lo que DECÍA (2026-09-12)
678
679Hasta acá el archivo se buscaba **literal**: todas las palabras tienen que aparecer. Ahora también
680se le puede preguntar por el significado (`archive.ask`), y las dos conviven porque son dos
681preguntas: «la página que decía `EADDRINUSE`» es literal y no necesita ningún modelo.
682
683**El muro no era un daemon ni un LLM, y el propio código lo decía mal.** El comentario de la
684extensión afirmaba que el semántico «necesita un daemon de embeddings y un LLM de verdad», copiando
685lo que hace `willay-rag`. Medido: para ordenar por parecido **no hace falta ningún LLM** —eso es un
686coseno— y el «daemon de embeddings» resultó ser el mismo `llama-server` que ya levanta el chat, con
687otro modelo. La corrección quedó escrita donde estaba la afirmación.
688
689**Lo que se construyó, y dónde vive cada cosa** — que es la parte que las reglas del repo deciden,
690no el gusto:
691
692| pieza | dónde | por qué ahí |
693|---|---|---|
694| el protocolo de `llama-server` | `shared/foreign-llama` | **regla 4**: un dialecto ajeno entra por un puente. La primera versión vivía DENTRO de `puriy-costura::ia`, y cuando apareció el segundo consumidor —dos horas después— había dos clientes del mismo protocolo |
695| el `Provider` de embeddings | `rimay-verbo-llama` | **regla 10**: la familia `rimay-verbo-*` ya es la abstracción de embeddings de la suite. Es el primer backend suyo que no pide nube ni descarga nada al arrancar |
696| el índice y el coseno | `rimay-verbo-index::VectorIndex` | ya existían, con postcard y top-K. Escribir otro sería un segundo espacio vectorial sin dueño |
697| cuándo se embebe y qué se pregunta | `puriy-costura` | es lo nuestro |
698
699**Tres decisiones con su porqué:**
700
701- **los vectores se calculan al PREGUNTAR, no al archivar.** Archivar una página no puede quedarse
702  esperando a que cargue un modelo, ni fallar porque la imagen no lo traiga. El precio es que la
703  primera pregunta paga el atraso, y por eso la respuesta dice cuántas quedaron indexadas;
704- **es un SEGUNDO `llama-server`** (`--embedding --pooling mean`): llama.cpp carga un modelo por
705  proceso y el de chat no sabe embeber. Ese `--pooling` no es un gusto — sin él el endpoint contesta
706  un `400` que parece del cliente;
707- **sin umbral de puntaje.** Los cosenos de e5 dan ~0,9 entre frases sin relación: sus vectores viven
708  en un cono estrecho, así que cualquier umbral «razonable» deja pasar todo o corta todo. Lo que
709  significa algo es el **orden**, y de eso se ocupa el top-K.
710
711El guardián (`scripts/test-atuq-archivo-semantico.py`) archiva tres páginas con el navegador y
712después le pregunta al host **sin navegador**, por el cable de 4 bytes y con los paths de producción
713— así se comprueba de paso lo que el §6.3 promete: que el archivo quedó en disco. Pregunta **dos**
714cosas con ganadores distintos, a propósito: con una sola, un ranking constante —o uno que devuelva
715siempre la más reciente— pasaría sin haber entendido nada. Y las preguntas **no comparten palabras**
716con el texto de su página; si las compartieran, la búsqueda literal también acertaría y esto no
717probaría nada semántico.
718
719**MEDIDO en la jaula, sobre los artefactos sellados** (`atuq bfc14c92`, `puriy-costura 3f31233e`,
720`ia-modelo-embeddings 2c0c4258`):
721
722```
723ARCHIVADA … /gato.html   ARCHIVADA … /red.html   ARCHIVADA … /pan.html
724«¿dónde se echó a dormir el gato?»    0.6252 gato · 0.2471 red · 0.0973 pan
725«cómo bloqueo internet a un programa» 0.4039 red  · 0.1398 pan · 0.1066 gato
726por la barra lateral                  0.6252 /gato.html …
727```
728
729Y el control negativo: sin modelo, `no hay modelo de embeddings en /usr/share/takana/ia/embeddings.gguf:
730esta imagen no trae ninguno pineado` — **y ningún orden inventado**, que es el único fallo inaceptable
731en una búsqueda.
732
733⚠ **El guardián nació midiendo nada, y lo dijo el propio arnés.** La primera versión metía las tres
734páginas en `<iframe>` para archivarlas en una sola carga, y archivó **cero**: el §6.3 ignora a
735propósito lo que no es el marco principal —un iframe de publicidad no es una página que alguien
736leyó—. Encadenadas como navegación de verdad (cada página lleva a la siguiente con un `meta
737refresh`), las tres entran. De paso: las dos páginas de tránsito van **sin texto visible**, así el
738archivo las descarta y la evidencia no lista dos coincidencias sin título.
739
740#### 6.3.quinquies El modelo de embeddings es una DESCARGA OPCIONAL, no parte de la imagen (2026-09-12)
741
742Decisión del usuario, y la que corresponde a los números: el motor (199 M) y el modelo de chat
743(1,04 GiB) van en las cuatro imágenes de escritorio porque la barra lateral está en las cuatro; el
744modelo de embeddings **no**, porque son 610 MiB más para una función —preguntarle al archivo por
745significado— que no todo el mundo usa.
746
747**Y «opcional» no significa «lo armás vos».** El modelo ya es una receta del corpus, así que la
748maquinaria de la Etapa F lo convierte en paquete sin trabajo extra: `scripts/build-repo.sh` empaqueta
749**todas** las recetas, y el catálogo queda con su `expected_hash` anclado y sus deps declaradas
750(`llama-cpp`, `curl`, `busybox` — publicadas también, que si no `install` no puede reproducirlo).
751
752```
753takana install ia-modelo-embeddings --repo <dir|url>
754```
755
756Lo que hace que esto sea opcional **de verdad** es una sola línea: la receta **no está declarada en
757ningún perfil**. Eso es una decisión, no un olvido, y está escrito acá para que nadie la «arregle»
758agregándola.
759
760⚠ **Y la mitad que separa «opcional» de «invisible»**: cuando el modelo falta, el host ya no dice
761sólo «no está» — dice **cómo conseguirlo**. Con el modelo ausente, la ausencia es el caso NORMAL, no
762un error de instalación, y un mensaje sin siguiente paso deja al usuario mirando una función que no
763sabe que existe:
764
765```
766no hay modelo de embeddings en /usr/share/takana/ia/embeddings.gguf: es una descarga opcional
767— instalalo con `takana install ia-modelo-embeddings`
768```
769
770**El catálogo, firmado y verificado en los dos sentidos.** Publicar el paquete no alcanza: un
771catálogo sin firma deja a `install --require-signed` sin nada que comprobar, y una firma con clave
772efímera es decoración (SDD 19 §3.2). Se firmó con la **clave estable** del SDD 28 §5.1 —pública en
773`trust/`, privada fuera de git— y se comprobó como corresponde:
774
775| `--trust` | resultado |
776|---|---|
777| `./trust` | `release: trusted (by release) — 94 paquete(s)` |
778| un directorio vacío | `release: unknown-key … añadí su clave para confiar` |
779
780⚠ Esto es un **ensayo local**: `dist/repo` no está en git y la publicación de verdad es la caja del
781SDD 28 §5. Lo que queda demostrado acá es que el paquete y su cierre entran al catálogo firmado sin
782inventar nada nuevo.
783
784⚠ **Y tres cosas que sólo aparecieron al instalarlo de verdad, no al empaquetarlo:**
785
7861. **el cierre no estaba publicado**: faltaban `llama-cpp` y `cmake`. Un paquete cuyo cierre no está
787   en el catálogo existe y **no se instala**;
7882. **tres paquetes llevaban el ancla de sanidad por default** (`/usr/bin/<nombre>`), que en una
789   librería o en un modelo no existe — `install` habría fallado al verificar algo que nunca iba a
790   estar;
7913. **el instalador no podía instalar a otro filesystem**: `Invalid cross-device link`, justo en el
792   caso que su propia ayuda promete. Y no hace falta tener dos discos: `linkat` da EXDEV **entre dos
793   mounts del mismo dispositivo**, y el store de esta máquina es un bind-mount. Arreglado con una
794   copia ante EXDEV —y sólo ante EXDEV— en `takana-build::hydrate`, con su test cruzando a `/dev/shm`.
795
796#### 6.3.quater El modelo que elegimos NO SERVÍA, y el síntoma no se parecía a la causa (2026-09-12)
797
798Esto es la unidad de trabajo más caras de esta tanda y vale escribirla entera, porque la clase de
799fallo se repite: **un modelo de embeddings roto no falla — contesta vectores plausibles y ordena al
800azar.**
801
802La primera elección fue **multilingual-e5-small** (MIT, 118 M, el tamaño ideal, y lo que pedía el
803caso: español). Sellaba, cargaba, contestaba 384 dimensiones, el endpoint funcionaba y los 45 tests
804de la suite estaban en verde. Y la primera medición con el modelo DE VERDAD, de punta a punta, dio
805esto:
806
807```
808«¿dónde se echó a dormir el gato?»      0.9177 gato · 0.8986 red · 0.8965 pan   ✓
809«cómo bloqueo internet a un programa»   0.9337 gato · 0.9112 red · 0.9067 pan   ✗
810«qué lleva el pan»                      0.9233 gato · 0.9066 red · 0.9063 pan   ✗
811```
812
813Gana siempre la misma página. La caza, en el orden en que se hizo —cada paso descartando una
814hipótesis, no confirmándola—:
815
8161. **¿el batching?** No: los puntajes son idénticos pidiendo los pasajes juntos o de a uno;
8172. **¿la posición?** No: cambiando el orden de los pasajes los puntajes no se mueven. (Y esto mató
818   además una sospecha razonable: mi medición «buena» anterior tenía el pasaje correcto en la
819   posición 0, así que podía haber sido un falso positivo. No lo era, pero no se sabía);
8203. **¿nuestro código?** No: los mismos textos a mano contra el servidor dan lo mismo;
8214. **¿la cuantización nuestra?** No: el fp32 sin tocar falla igual;
8225. **¿la conversión de terceros?** No: dos conversos independientes fallan **idéntico** — y ese
823   «idéntico» era la pista que estaba a la vista desde el principio: `gato~perro` y
824   `gato~cortafuegos` daban **el mismo número a cuatro decimales**;
8256. **es el TOKENIZADOR**, y `/tokenize` lo dijo en una línea: «cortafuegos», «minino», «duerme» y
826   «tejado» van todos al **id 100 = `<unk>`**. Un vocabulario XLM-RoBERTa por esta ruta deja casi
827   todo en desconocido, y un texto que es todo `<unk>` embebe igual que cualquier otro.
828
829**Lo que se pineó en su lugar: Qwen3-Embedding-0.6B Q8_0**, el GGUF **oficial** de Qwen
830(Apache-2.0). Tokeniza español de verdad —`cortafuegos` → `cort|af|uegos`—, acierta **3/3** con
831márgenes anchos (0,649 contra 0,237 el segundo) y es de la misma familia que el modelo de chat, que
832ya estaba medido contestando en español. Cuesta **610 MiB**, cinco veces el e5 que no servía: es el
833precio de que funcione, y cambia la cuenta de la imagen (motor + chat + embeddings ≈ 1,85 GiB).
834
835Dos configuraciones que el número decidió, no el gusto: **`--pooling last`** (con `mean` también
836acierta 3/3, pero pegado: 0,574 contra 0,557) y **la instrucción delante de la consulta** (sin ella,
8372/3 — no es decorado).
838
839⚠ **Y la regla que queda, que ahora VIVE en la receta**: el guardián del `install` ya no mira sólo el
840mágico y el tamaño. Tokeniza dos frases en español sin palabras en común y exige que **no compartan
841tokens** (con el e5 roto compartían la mitad, todos `<unk>`), y después levanta el servidor de verdad
842y exige que **un gato se parezca más a un perro que a un cortafuegos**. Con esos dos chequeos, el e5
843no habría sellado nunca — y la caza de seis pasos no habría hecho falta.
844
845
846### 6.4 `qullqa` con su advertencia puesta — y los TRES bloqueos, medidos (2026-09-12)
847
848Almacenamiento con negación plausible y modelo de adversario **escrito** — ningún navegador tiene
849eso; el modo incógnito es teatro. ⚠ Con la advertencia que el propio SDD de `qullqa` ya anotó y que
850**hay que repetir acá, no esconder**: en una máquina con swap sin cifrar, el documento no aplica.
851Se promete lo que se puede probar.
852
853**Qué sería, concretamente:** el perfil de `atuq` —historial, cookies, caché, y el archivo personal
854del §6.3— vive DENTRO de un pozo de `qullqa`, montado mientras el navegador corre y desmontado al
855cerrar. Un registro de la máquina no encuentra un perfil de navegador que negar: no hay forma de la
856que sospechar. Es la promesa del dominio (`A2`: un control, una requisa, un funcionario mirando la
857laptop), no una nuestra.
858
859**Lo que se midió antes de escribir una línea de código, y bloquea:**
860
8611. **El kernel de la distro no trae FUSE.** `# CONFIG_FUSE_FS is not set` en el `.config` del
862   artefacto sellado (`linux-metal`, 6.16.12). Y `qullqa-vfs` proyecta el pozo **por FUSE**
863   (`fuser`, con `montar`/`montar_de_fondo`). Sin ese símbolo no hay pozo montable: es una línea en
864   `recipes/linux*.toml` y **un rebuild de kernel de ~35 min que mueve su ArtifactHash y con él las
865   imágenes** — o sea, una decisión, no un ajuste al pasar.
8662. **No hay binario que lo monte.** Los cuatro crates de `qullqa` son librerías y lo dicen («No
867   binary»). Hace falta algo que abra el pozo, lo monte, lance el navegador y lo desmonte al salir.
868   Eso es superficie nueva con nombre nuevo — la misma clase de decisión que fue `puriy-costura`.
8693. **La contraseña es UX, no criptografía.** El pozo se abre con una frase estirada con Argon2id.
870   Quién la pide, cuándo, y qué pasa si se cierra la sesión a la mitad es lo que decide si esto se
871   usa o se apaga al tercer día. `qullqa` no la contesta porque no es su pregunta.
872
873**Y la incógnita del helper ya está contestada, leyendo la fuente** (`fuser` 0.15.1,
874`src/mnt/fuse_pure.rs`; el workspace lo declara con `default-features = false`, así que no hay
875`libfuse` en tiempo de build):
876
877- `fuse_mount_pure` intenta **primero `fuse_mount_sys`** —el `mount(2)` directo sobre `/dev/fuse`—,
878  que **funciona como root y no necesita ningún helper**. Sólo si eso no se permite cae a
879  `fusermount3`;
880- desmontar hace `umount2(MNT_DETACH)` directo, con el helper también como respaldo;
881- **la excepción es `MountOption::AutoUnmount`**: esa opción fuerza el camino del helper, siempre.
882
883⇒ La imagen **no necesita una receta de `fuse3`** mientras quien monte corra como root y no pida
884`AutoUnmount`. Si algún día se pide, hace falta el helper — y conviene que esté escrito antes de que
885alguien lo agregue «porque es más prolijo desmontar así».
886
887⇒ Estado: **el §6.4 no está empezado, y ahora se sabe por qué**. Los tres bloqueos son nombrables y
888ninguno es del navegador.
889
890### 6.5 Foco por `cortafuegos` — la mitad del sistema, MEDIDA (2026-09-10)
891
892**La primera corrección es a este propio documento.** La fila de la tabla dice «foco por
893`cortafuegos`» y es fácil leerla como «bloquear sitios que distraen». **El cortafuegos de tawasuyu no
894sabe de sitios**: su política de egress (`cortafuegos-core::Politica`) tiene exactamente una
895dimensión —**qué cgroup puede salir**— y ninguna de destino; ni dominio, ni IP, ni puerto. Leído en
896la fuente (`UnidadRed { nombre, cgroup_path, concesion }`), no supuesto. Así que el foco que estas
897piezas permiten no es «Twitter no carga»: es **«el navegador no sale»**, con todo lo local
898—incluido el archivo personal del §6.3 y el CAS del §6.2— funcionando igual. Que es, además, una
899promesa más honesta: una lista de dominios se esquiva con un espejo, y un cgroup sin egress no.
900
901**Y la diferencia con una extensión no es de grado.** Un bloqueador de extensión lo apaga en dos
902clics la misma persona que quería no distraerse; un reglaset `nft` que aplicó root no lo toca el
903navegador ni sus extensiones. Por eso la fila decía «nadie lo tiene»: nadie es dueño del navegador
904*y* del sistema.
905
906**Lo que hizo falta traer.** `nftables` estaba en el grafo como `wanted` y nadie había puesto la
907cadena: entran `recipes/libmnl.toml`, `recipes/libnftnl.toml` y `recipes/nftables.toml` (1.1.6, la
908versión contra la que el SDD del cortafuegos validó `socket cgroupv2` en metal). Dos cosas venían de
909fábrica y están en los comentarios de la receta: nftables **no reproduce** —`MAKE_STAMP` es
910`$(shell date +%s)` horneado en el binario, la familia del `BuildID` de waterfox— y su
911`config.status` trae un bashismo que busybox rechaza. Las dos se arreglan en el parche, y el control
912existe: con el sello vivo, dos builds de la misma fuente **divergen** en `libnftables.so`
913(`why-differs`: `.data`, `.text`); con el parche, `verificar-repro.sh` da REPRODUCE.
914
915**La medición, que es la unidad de trabajo de verdad** (`scripts/test-foco-egress.sh`, lanzado con
916`scripts/foco-egress-remoto.sh`):
917
918```
919nft: nftables v1.1.6            ← el NUESTRO, musl, desde el artefacto sellado
920CONEXION linea-base exit=0      ← sin reglas: el harness mide algo
921CONEXION control    exit=0      ← con reglas: el cgroup permitido SÍ conecta
922CONEXION foco       exit=1      ← el cgroup del navegador NO
923```
924
925Tres medidas y no una: la **línea base** existe porque un harness roto («no conectó») se lee
926exactamente igual que un foco que funciona, y el **control** porque un reglaset que niega de más no
927se distingue de uno que niega bien. Con `--broken-rules` —que le abre egress también al cgroup en
928foco— el guardián falla diciendo `el cgroup EN FOCO conectó igual`, y sale 1.
929
930**Por qué no corre en el hub, y es una restricción real, no comodidad.** Crear un cgroup pide root:
931medido, `mkdir /sys/fs/cgroup/...` da EPERM **incluso dentro de un userns con `--cap-add ALL`**,
932porque el permiso se chequea contra la jerarquía real. Y `nft -c` **no es un chequeo de sintaxis**:
933resuelve el path del cgroup contra la máquina viva y falla con `cgroupv2 path fails` si no existe. O
934sea que ni siquiera *verificar* una política del cortafuegos se puede sin los cgroups puestos. Corre
935en el LXC prestado, y todo va dentro de `unshare -m --propagation private -n`.
936
937⚠ **Y ese `--propagation private` se pagó caro.** `/sys/fs/cgroup` está montado **shared**, así que
938desmontar la COPIA de un `--rbind` **se propaga al montaje real**: dos corridas dejaron al worker
939**sin `cgroup2` montado**, en silencio —los builds seguían— hasta que el guardián siguiente dijo «no
940hay cgroup2 montado». Se remontó y la jerarquía volvió intacta (los cgroups viven en el kernel, no en
941el montaje). Tres lecciones que quedan en el guión: los montajes van en un **mount namespace propio**
942y se van solos; **nunca `umount -l`** antes de un `rm -rf` (un desmontaje perezoso desaparece de
943`/proc/mounts` mientras el árbol sigue ahí, así que la guarda pasa y el borrado entra en el `/sys` de
944la máquina); y antes de borrar, **preguntarle a `/proc/mounts`**.
945
946⚠ **Tres falsos positivos más que cazó el propio harness, y todos se veían como éxito:**
947
9481. un `gmp` **viejo** empaquetado por `ls store/*-gmp` (sólo `libgmp.a`) ⇒ `libnftables.so` no
949   relocaba y **ninguna** conexión salía. En el hub no se había notado porque ahí el lab tiene su
950   propia libgmp y la tapaba: el artefacto parecía autosuficiente y no lo era. Ahora los artefactos
951   se resuelven por `takana hash` (vigente), y el guardián corre `nft --version` **antes** de todo;
9522. `/sbin` fuera del `PATH` del chroot ⇒ no había `ip`, el loopback quedaba caído y todo fallaba;
9533. y el mejor: **un comentario que se ejecutó**. El guión interior se escribía con un heredoc sin
954   comillas, y un comentario que mencionaba `` `ip` `` y `` `ifconfig` `` entre backticks los corrió
955   **en el host** y pegó la salida —con las IPs de la máquina— dentro del guión generado. Ahora el
956   heredoc va entrecomillado y los nombres entran por entorno.
957
958**Lo que falta para que esto sea un botón, y por qué no se inventó de paso.** Dos piezas, ninguna del
959navegador:
960
961- **quién pone a `atuq` en un cgroup.** Hoy nadie: los cgroups de la distro los crea arje para sus
962  servicios, y una app de escritorio la lanza el usuario. Hace falta delegación de un subárbol a la
963  sesión, que es decisión de arje (SDD 10), no de este documento;
964- **quién aplica la política.** `cortafuegos apply` pide root. Un demonio que lo haga a pedido del
965  navegador es superficie nueva: se decide, no se agrega de paso.
966
967Mientras eso no exista, lo que `atuq` puede tener —y tiene, §6.5.bis— es la mitad honesta: **ver** el
968estado del foco y **no poder apagarlo**.
969
970#### 6.5.bis La mitad del navegador: `focus.state`, y la asimetría a propósito (2026-09-10)
971
972La extensión `foco` **sólo lee**. Pregunta `focus.state` al host cada minuto —el estado lo cambia root
973por fuera, no hay evento al que suscribirse— y pinta tres cosas: `foco`, nada, o **`?`**. El tercer
974caso es el que importa: el host contesta `unknown` cuando nadie escribió el estado o cuando el
975contenido no se entiende, y **eso no se redondea a `off`**; una insignia que dijera «sin foco» sin
976haber mirado afirmaría un hecho que no tiene.
977
978**Y no hay verbo para apagarlo.** `focus.stop` cae en «verbo desconocido», igual que `focus.off` y
979`focus.start`. No es una omisión pendiente: es la propiedad entera. Si el navegador pudiera levantar
980el foco, esto valdría exactamente lo que vale un bloqueador de extensión —dos clics— que es el
981problema que el §6.5 venía a resolver. Encenderlo tampoco está: aplicar un reglaset pide root y este
982proceso corre como el usuario. Hay un test en tawasuyu que lo fija por nombre, y se puso rojo a
983propósito agregando un `focus.stop` «por comodidad».
984
985El estado vive en `/etc/takana/focus` (lo escribe root junto con el reglaset). `scripts/test-atuq-foco.py`
986corre **el path de producción, no la escotilla de pruebas** —una escotilla mide el código, no el
987contrato con la imagen— en tres sesiones del navegador, y en cada una comprueba tres cosas: qué dijo
988la extensión, qué pintó, y que el fichero **quedó igual** después de la sesión. Eso último es la
989promesa del §6.5 medida del lado del navegador. Verificado rompiéndolo: con una extensión parcheada
990que pinta «foco» pase lo que pase (dentro de una COPIA del artefacto, vía `ATUQ_DIR`), el guardián
991falla nombrando la insignia.
992
993```
994-- fichero de foco: on          → ESTADO on      · INSIGNIA "foco" · quedó on
995-- fichero de foco: off         → ESTADO off     · INSIGNIA ""     · quedó off
996-- fichero de foco: (no existe) → ESTADO unknown · INSIGNIA "?"    · siguió ausente
997```
998
999### 6.6 Medios fuera del navegador — HECHO (2026-09-10)
1000
1001Una navegación de PRIMER NIVEL a un medio (`Content-Type: video/*` o `audio/*`) se cancela y la URL se
1002la lleva el reproductor de la distro. `mpv` ya viaja en las cuatro imágenes de escritorio, así que
1003esto no agrega ni una receta: es cablear lo que ya estaba.
1004
1005**La regla es estrecha a propósito.** Un `<video>` embebido es parte de la página y sacarlo de ahí
1006rompería el sitio que lo puso; lo que se cambia es el caso en que el navegador no estaba pintando una
1007página sino haciendo de reproductor —seguiste un enlace a un `.mp4`—. Las reglas anchas en el camino
1008de cada petición son las que terminan rompiendo la web de alguien.
1009
1010**La seguridad del verbo es que el binario NO viene del cable**: es una constante del host
1011(`/usr/bin/mpv`). Si el programa viajara en el mensaje, esto dejaría de ser «abrir un vídeo» y sería
1012un lanzador de procesos arbitrarios manejado, al final de la cadena, por una página web. Sólo viaja la
1013URL, sólo `http(s)`, y detrás de un `--`.
1014
1015**Fail-open, y es la promesa que el control negativo prueba.** Cancelar es SÍNCRONO y la respuesta del
1016host llega después, así que no se puede saber en el momento si el reproductor arrancó. Se cancela sólo
1017si el puerto está vivo, y si el host contesta que no pudo, la URL **vuelve al navegador** (con una
1018marca para no entrar en bucle). Medido:
1019
1020    SIN REPRODUCTOR no hay reproductor en /usr/bin/mpv: esta imagen no lo trae
1021    VUELVE AL NAVEGADOR http://…/audio.wav?atuq-medios=no
1022
1023Quedarse sin vídeo Y sin pestaña es el único resultado inaceptable, y es el que se consigue si uno
1024confía en que salió bien.
1025
1026**⚠ Y el bug que esto destapó vale más que la función.** La primera corrida hizo todo bien —detectó el
1027medio, canceló, el host contestó con el pid— y después:
1028
1029    DESCONECTADO Native application tried to send a message of 546281442 bytes,
1030    which exceeds the limit of 1048576 bytes.
1031
1032**Un hijo hereda los descriptores del padre, y los del padre SON la tubería de native messaging.** El
1033reproductor escribía su salida ahí y el navegador la leía como un marco. Tres redirecciones, cada una
1034contra un fallo distinto: `stdin` a null (`mpv` lee stdin para sus comandos: heredándolo **se come los
1035mensajes del navegador**), `stdout` al **`stderr` del host** —no a `/dev/null`: apagarlo arreglaría el
1036bug y se llevaría puesto el diagnóstico, y Gecko manda ese `stderr` al log del navegador, que es donde
1037uno mira cuando «el vídeo no abrió»— y `stderr` heredado por lo mismo.
1038
1039El test que lo fija **arranca el binario, no la lib**, y ésa es la parte reutilizable: en proceso, el
1040`stdout` del host es el del arnés y la contaminación no se ve — el test habría pasado con el bug
1041puesto. Se comprobó rompiéndolo a propósito.
1042
1043⚠ Y una nota del arnés que ya apareció dos veces: la URL no se pasa por la línea de comandos, porque
1044una petición del ARRANQUE compite con la inicialización de la extensión. El guardián navega a una
1045página que redirige a los tres segundos — que además es lo que hace una persona: seguir un enlace.
1046
1047### 6.7 El motor de inferencia local — ENTRA AL CORPUS (2026-09-11)
1048
1049**La primera corrección es al propio §6.** La tabla de arriba le pone costo «bajo» a la IA local
1050porque las piezas parecían estar escritas en tawasuyu (`rimay`, `iniy`). Se fue a mirar antes de
1051empezar y **no alcanzan**, por dos razones independientes:
1052
1053| pieza | por qué no corre un modelo acá |
1054|---|---|
1055| `pluma-llm` | sus backends son `anthropic`, `cohere`, `claude-cli` y `mock`. **Todos de nube o de juguete** |
1056| `rimay-verbo-fastembed` | trae `fastembed` con la feature `ort-download-binaries`: **baja onnxruntime precompilado (glibc) y el modelo de HuggingFace en el primer arranque**. En un build hermético y en una distro musl eso no es lento, es imposible |
1057
1058Y eso además **junta dos pendientes que el plan llevaba separados**: el §6.7 (la barra lateral) y la
1059mitad semántica del §6.3 (preguntarle al archivo personal en lenguaje natural) no eran dos problemas.
1060Eran uno: **el corpus no tenía con qué correr un modelo.** El muro tiene forma de receta, y una sola
1061lo derriba entero — el mismo binario sirve `/v1/chat/completions` (el §6.7) y `/v1/embeddings` (lo
1062que `rag-motor` exige para el §6.3).
1063
1064`recipes/llama-cpp.toml` ⇒ **b10901, estática, 199 M**, 16 binarios sin un solo `NEEDED`.
1065`llama-server`, `llama-cli`, y de yapa `llama-quantize`/`llama-bench`/`llama-perplexity`, que son las
1066tres herramientas con las que se mide un modelo **antes** de pinearlo. **REPRODUCE** bit a bit
1067(`verificar-repro.sh`: 0 no-determinismos).
1068
1069#### ⚠ La variable que nos da reproducibilidad le apagó el AVX2 al motor, y el build salió 0
1070
1071Es lo único de esta unidad que no se podía deducir, y por eso va escrito acá con la línea leída.
1072
1073La receta nació con `-DGGML_NATIVE=OFF`, apoyada en `ggml/CMakeLists.txt:141`:
1074
1075```cmake
1076if (GGML_NATIVE OR NOT GGML_NATIVE_DEFAULT)
1077    set(INS_ENB OFF)     # ← con NATIVE=ON, ggml se apoya en -march=native y apaga las perillas
1078else()
1079    set(INS_ENB ON)      # ← con NATIVE=OFF *debería* encender SSE4.2/AVX/AVX2/BMI2/FMA/F16C
1080endif()
1081```
1082
1083Leído así, `GGML_NATIVE=OFF` era exactamente lo que queríamos: nada de `-march=native` (que sería un
1084artefacto distinto por máquina — la familia del `MAKE_STAMP` de nftables) y aun así un binario
1085vectorizado. **Y es falso en este sandbox**, por una línea 36 renglones más arriba:
1086
1087```cmake
1088if (CMAKE_CROSSCOMPILING OR DEFINED ENV{SOURCE_DATE_EPOCH})
1089    set(GGML_NATIVE_DEFAULT OFF)
1090```
1091
1092El sandbox de takana exporta `SOURCE_DATE_EPOCH=1` (`sandbox.rs:453`) **justamente para que los
1093builds reproduzcan**. Con eso `GGML_NATIVE_DEFAULT` queda OFF, `NOT GGML_NATIVE_DEFAULT` es
1094verdadero, e `INS_ENB` termina en OFF: las seis perillas apagadas. Dicho corto: **la variable que
1095existe para que el build reproduzca apagó todas las instrucciones vectoriales del motor de
1096inferencia.**
1097
1098⚠ **Y no falló nada.** El artefacto selló, `llama-cli --version` contestaba, el binario corría. Medido
1099sobre el artefacto sellado `b3:e3c62185`, no sobre el log:
1100
1101| | primer sello (`-DGGML_NATIVE=OFF` a secas) | con las seis declaradas |
1102|---|---|---|
1103| `%ymm` (AVX2) | **0** | 42.356 |
1104| `vfmadd` (FMA) | **0** | 1.298 |
1105| `roundps` (SSE4.2) | **0** | 86 |
1106
1107Un motor de inferencia sin AVX2 no está roto: está varias veces más lento, y **ninguna métrica del
1108repo lo dice**. Es la figura de siempre — un ausente falla ruidosamente; un desviado llega hasta el
1109final diciendo que todo fue bien. Por eso las seis van declaradas una por una, no se confía en el
1110default de nadie, y **el `install` las comprueba en el binario**: es el único punto donde este modo
1111de fallar se puede ver.
1112
1113⚠ Eso fija un **piso declarado: x86-64-v3**. Un CPU sin AVX2 (pre-2013) muere con SIGILL, no con un
1114mensaje. Medido antes de elegirlo: `momento`/gioser, el worker `dev.gioser.net` y el objetivo de
1115metal (TigerLake) traen los seis flags. Bajar el piso o publicar variantes es
1116`docs/plan-variantes-cpu.md`, no esta receta.
1117
1118De paso, `strip_debug = true`: zig cc emite `debug_info` por defecto y acá son 16 binarios
1119**estáticos**, o sea 16 copias de los símbolos de `libllama`+`libggml`+`libc++`. **1,8 G → 199 M**.
1120
1121#### El guardián, y lo que cada sonda afirma
1122
1123`scripts/test-llama-cpp.py`, sobre el artefacto **vigente** (resuelto por `takana hash`, nunca por
1124`ls store/*` — es la trampa del `gmp` viejo del §6.5), con escotilla `LLAMA_DIR` que grita al usarse:
1125
1126```
11271. ISA          42.356 %ymm · 1.298 vfmadd · 86 roundps      ← control negativo MEDIDO: 0/0/0
11282. hermético    llama-server y llama-cli: sin NEEDED
11293. inferencia   una pasada REAL con stories260K (1,1 MB, pineado por sha256) ⇒ 51 caracteres
11304. embeddings   vector de 64 dimensiones · mismo texto = mismo vector ·
1131                textos distintos = vectores distintos · no son ceros
1132```
1133
1134La sonda 4 es la que le importa al §6.3, y sus tres comprobaciones no son ceremonia: **«¿devuelve un
1135vector?» lo pasa un stub de ceros**, y «¿devuelve algo distinto?» lo pasa un generador de ruido. Hace
1136falta que el mismo texto dé lo mismo *y* que textos distintos den cosas distintas.
1137
1138El control negativo es vivo: `--sin-modelo` corre las mismas sondas contra un modelo inexistente y
1139**tiene que fallar** — sin eso, un arnés roto («no contestó») se lee igual que un motor que anda.
1140Y el censo después de correr: cero `llama-server` sueltos.
1141
1142⚠ **Dos trampas del arnés que van a volver cuando se pinee el modelo de producción:**
1143
11441. **`/health` contesta MIENTRAS carga**, con 503 y `{"status":"loading model"}`. Tomar «contestó»
1145   por «listo» manda la primera consulta contra un servidor a medio levantar, y el 503 se lee como
1146   «el endpoint no existe». Se espera el `ok`, no el 200.
11472. **`--pooling mean` no es un gusto.** Un GGUF declara su tipo de pooling en los metadatos y los
1148   modelos de embedding de verdad (e5, bge, nomic) lo traen; `stories260K` es un LM causal de juguete
1149   y no lo trae, así que llama.cpp cae en `none` y el endpoint compatible con OpenAI contesta
1150   **`400: Pooling type 'none' is not OAI compatible`** — no un vector vacío, no un 500: un 400 que
1151   parece un error del cliente.
1152
1153#### ⚠ Lo que esto NO es
1154
1155**Esto es el motor, no la función.** Un motor sin modelo no contesta nada, y `atuq` todavía no tiene
1156barra lateral de IA. Falta, y tiene forma:
1157
1158- **el modelo**, que es **fuente pineada por sha256**, no receta — el mismo patrón que el perfil de
1159  PGO de firefox. Son dos decisiones distintas y las dos cuestan disco en la imagen: uno de
1160  embeddings para el §6.3 (chico, ~100 MB) y uno de chat para el §6.7. `stories260K` es un modelo
1161  REAL pero de juguete: sirve para ejercitar la cadena, no para contestar;
1162- **los verbos en el host** (`puriy-costura`): un `ai.ask` y un `archive.search` semántico que hablen
1163  con `llama-server`. Hoy `archive.search` es LITERAL y por eso se llama `search`;
1164- **quién levanta el servidor.** Misma pregunta que dejó abierta el torrent del §6.9, y ahí ya hay
1165  respuesta escrita: un daemon propio, perezoso, con `setsid` para que sobreviva al navegador.
1166
1167`llama-cpp` **no está declarada en ningún perfil todavía**, y es a propósito: una imagen no debería
1168crecer 199 M por una función que aún no existe. Se declara cuando el §6.7 se pague entero.
1169
1170#### 6.7.bis La barra lateral YA PREGUNTA — y el modelo sigue siendo una decisión (2026-09-11)
1171
1172Lo que faltaba eran tres cosas; dos están hechas y la tercera es una decisión, no trabajo.
1173
1174**El verbo (`ai.ask`) y quién levanta el servidor.** Se resolvió **al revés que el torrent del §6.9**,
1175y la diferencia importa: allá el daemon sobrevive al navegador porque un torrent tiene trabajo que
1176sigue; acá el servidor **se muere con el navegador**, porque medio giga de modelo en RAM no es trabajo
1177pendiente, es peso. No hay servicio de IA en la imagen: el primer `ai.ask` levanta `llama-server` y el
1178host lo mata al salir.
1179
1180**El transporte es un socket UNIX**, no un puerto: `llama-server` acepta `--host <ruta>.sock`
1181(medido). No colisiona, no queda expuesto a la red y no hay que buscar puerto libre. El cliente HTTP
1182son treinta líneas porque el servidor contesta con `Content-Length` — medido, y si algún día
1183contestara `chunked` **falla diciéndolo** en vez de entregar medio cuerpo.
1184
1185**Lo que pasa si a `puriy-costura` lo matan con `SIGKILL`**: el servidor queda. La red natural sería
1186`PR_SET_PDEATHSIG`, pero pedirlo exige `Command::pre_exec`, que es `unsafe`, y la caja lleva
1187`#![forbid(unsafe_code)]` — **una propiedad de la caja no se negocia por una comodidad de ciclo de
1188vida**. El residuo se ACOTA en su lugar: si al preguntar ya hay un servidor vivo **con el mismo
1189modelo**, se adopta; así lo peor que queda es uno, y la sesión siguiente lo hereda. Si el que está
1190vivo tiene OTRO modelo, se niega nombrándolos: contestar con un modelo que no es el de la imagen sería
1191mentir sobre quién contestó.
1192
1193**La barra lateral** (`extensions/ia/`) es una caja de texto y el hilo de respuestas. La página no
1194habla native messaging: habla con el fondo, y el fondo con el host — si el puerto nativo viviera en la
1195página, cerrar la barra lateral se llevaría el proceso del host **y el modelo cargado** con ella.
1196`panel.html?q=…` abre el panel con la pregunta ya hecha, que es la puerta por la que entrará
1197«preguntar sobre la selección» y la que usa el guardián.
1198
1199`scripts/test-atuq-ia.py` mide la cadena entera con **inferencia de verdad** —el `llama-cpp` del
1200corpus contra el modelo pineado del guardián del motor, importado de allá para que el sha256 viva en
1201un solo sitio— y dentro de una jaula **`--unshare-net`**: no hubo red para consultar a nadie más. Tres
1202aserciones: que la respuesta venga del modelo de la imagen, que **no venga vacía**, y que al cerrarse
1203el navegador queden **cero `llama-server`**.
1204
1205⚠ Ese censo nació roto y el fallo es del género que este documento colecciona: `pgrep -c` **no existe
1206en busybox**, y el `|| echo 0` que parecía prudente convertía el error en un **cero**, o sea en un
1207verde. Con un motor vivo a propósito, el guardián pasaba igual. Ahora se cuenta leyendo `/proc` a
1208mano, y la rotura a propósito falla como debe (`quedaron 1 llama-server vivos`). De paso, el primer
1209intento de romperlo **tampoco rompía nada**: el motor de mentira moría al hacer `bind` porque el
1210socket estaba en `/salida`, que se comparte entre las dos corridas; con el socket en `/tmp` —tmpfs
1211nuevo por corrida— la rotura rompe.
1212
1213**El modelo de chat, PINEADO (2026-09-11).** `recipes/ia-modelo-chat.toml` ⇒ **Qwen2.5-1.5B-Instruct
1214Q4_K_M**, 1.117.320.736 bytes. Elegido por tres cosas y las tres discutibles cambiando un sha256:
1215**Apache-2.0** (lo que una distro puede shipear sin letra chica, al revés que Llama-3.2 o Gemma),
1216**habla español** —medido antes de pinearlo: se le preguntó qué es una distribución de GNU/Linux y
1217contestó dos frases correctas— y **corre en CPU**: 20,1 tokens/s en el hub sin GPU.
1218
1219El objeto pineado es un **tar que envuelve el `.gguf`**, publicado en el mirror de fuentes y servido
1220por sha256, porque takana extrae toda fuente con `tar` y un GGUF pelado no es un tar. Es el camino de
1221`firefox-pgo-profile`. La procedencia queda escrita en la receta con **el sha256 del GGUF de
1222upstream** (el `lfs.oid` que publica HuggingFace, verificado al bajarlo), no sólo el del tar nuestro.
1223Round-trip verificado como manda el ADR 0013: apartados el caché local y el artefacto, `takana build`
1224lo **bajó del mirror** y selló el mismo `ArtifactHash`.
1225
1226La receta trae guardián propio, porque el modo de fallo es callado: un fichero truncado o un HTML de
1227error renombrado a `.gguf` se instala igual y sella en verde. Se comprueban el mágico `GGUF` y el
1228tamaño.
1229
1230⚠ **Y el modelo de verdad destapó una carrera en el guardián del §6.7.bis.** El censo de motores
1231contaba **en el instante** del cierre: con el modelo de juguete daba 0 y con el de la imagen daba 1 —
1232y eso se lee como fuga cuando lo que pasa es que matar un proceso con un giga mapeado tarda ~1 s.
1233Ahora el censo **espera hasta 15 s** y anota cuánto tardó: `0 llama-server vivos (1s después)`. La
1234rotura a propósito sigue fallando, ahora con el tiempo a la vista.
1235
1236`scripts/test-atuq-ia.py --image-model` corre la cadena entera con el modelo de la imagen (llega como
1237**capa**, no copiado: mover 1 GiB a un tmpfs llenó `/tmp` y mató al guardián con un ENOSPC que no
1238tenía nada que ver con lo que mide). El default sigue siendo el modelo de juguete, que certifica la
1239misma cadena en un segundo.
1240
1241**Lo que queda decidir: qué imágenes lo llevan.** `llama-cpp` (199 M) y el modelo (1,04 GiB) suman
1242~1,25 GiB a cada perfil donde se declaren, y `atuq` está en cuatro escritorios. Sigue sin declararse
1243en ninguno: una imagen no crece 1,25 G por decisión de un commit.
1244
1245**Y falta el de embeddings** para la mitad semántica del §6.3 (multilingual-e5-small, decidido pero
1246no pineado todavía).
1247
1248#### 6.7.ter Una CAPTURA, y lo que encontró que ningún `dump` podía (2026-09-13)
1249
1250Los guardianes miden por el `dump`: qué dijo la extensión, qué contestó el host, qué quedó en disco.
1251Es lo correcto para un veredicto automático y **no puede ver lo único que el usuario ve**. Por eso
1252hay ahora `scripts/atuq-captura-ia.py`: el mismo arnés de sway headless de los otros guardianes, más
1253`grim` —que el cierre de `escritorio-sway` ya trae— y un PNG al final. No es un guardián: es
1254evidencia para una persona.
1255
1256La primera captura útil mostró el panel andando —título «IA local — atuq», el aviso «El modelo corre
1257en esta máquina. No sale nada a la red.», la pregunta y la respuesta del modelo— **y tres cosas
1258más**, ninguna visible en un log:
1259
12601. **La barra lateral se abría SOLA** en el primer arranque y se quedaba ocupando un tercio de la
1261   ventana. Gecko lo hace por defecto al instalar una extensión con `sidebar_action`, y para un
1262   navegador que la trae de fábrica eso significa **imponerle un panel a todo el mundo en cada
1263   instalación**. Un `"open_at_install": false` lo arregla; la captura del después lo confirma;
12642. **el diálogo «Close Firefox»** en la segunda corrida: el arnés mataba el navegador sin despedirse
1265   y el `.parentlock` quedaba. No es del producto, pero costaba una captura inútil por corrida;
12663. **dos barras de notificación vacías** en el arranque. Acá el atajo habría sido reportarlas como un
1267   fallo de marca —«las cadenas se perdieron al rebrandear»—, y era **falso**: instrumentando una
1268   COPIA del artefacto para volcar el DOM, resultaron ser `sandbox-content-disabled` («the security
1269   sandbox is disabled», que apaga **el arnés**) y `startup-restore-session-suggestion` (por el
1270   cierre abrupto de la corrida anterior). El texto está en el DOM y no en los píxeles: se comprobó
1271   además **quitando nuestro CSS**, con el mismo resultado ⇒ es el render por software de la jaula,
1272   no el producto.
1273
1274⇒ Y ésa es la lección de la unidad: **una captura muestra síntomas, el DOM dice de quién son**. Sin
1275el segundo paso, dos de los tres hallazgos habrían entrado al documento como bugs nuestros.
1276
1277### 6.8 Proxy por contenedor — HECHO (v0.5, 2026-09-06)
1278
1279**Es el primer diferenciador del §6 que se paga entero**, y se pudo pagar ahora porque es el único
1280de la lista que no pasa por el host del §7: es API de Firefox y nada más. Cada contenedor
1281—Personal, Trabajo, Banco, Compras— puede salir por su propio proxy.
1282
1283Tres piezas, en tres ficheros, y **ninguna alcanza sola**:
1284
1285| Pieza | Dónde | Qué aporta |
1286|---|---|---|
1287| `privacy.userContext.enabled` | `atuq.cfg` | prende los contenedores, que Firefox trae apagados |
1288| `Containers.Default` | `distribution/policies.json` | los CREA — es el único mecanismo que los pone en un perfil NUEVO |
1289| `3rdparty.Extensions` | `distribution/policies.json` | la config de fábrica, que la extensión lee como `storage.managed` |
1290| `proxy.onRequest` | `extensions/proxy/fondo.js` | lo único que ve el `cookieStoreId` de cada petición |
1291
1292**Las cuatro se comprobaron DENTRO del artefacto antes de escribir una línea**, que es la regla que
1293dejó el §2.sexies: `Containers` y `3rdparty` están en el `policies-schema.json` de `browser/omni.ja`,
1294`cookieStoreId` en el `schemas/proxy.json` de `omni.ja`, y `storage.managed` lee
1295`Services.policies.getExtensionPolicy(id)` en `ext-storage.js`. Ninguna de las cuatro se leyó de la
1296documentación de Mozilla: se leyeron de **nuestro** build, que es el que las tiene que traer.
1297
1298**Tres decisiones de diseño que valen más que el código:**
1299
13001. **La configuración se indexa por NOMBRE de contenedor, no por `cookieStoreId`.** El id
1301   (`firefox-container-3`) depende del ORDEN en que se crearon, así que la misma configuración
1302   aplicada a otro perfil apuntaría a otro contenedor. Un identificador que cambia de significado
1303   entre máquinas no sirve para configurar una distro.
13042. **Fail closed.** Un contenedor que TIENE proxy configurado y no se pudo honrar —entrada
1305   inválida, API que falla, mapa a medio construir— **no sale directo**: va a un destino cerrado y
1306   el navegador muestra el error. Salir directo sería una fuga silenciosa, y es la misma familia
1307   que el artefacto vacío de la regla 3: el fallo que llega hasta el final diciendo que todo fue
1308   bien. Un usuario que pidió que «Banco» no salga por su línea tiene que ver un error, no navegar.
13093. **`proxyDNS` viene PRENDIDO.** Sin él, Gecko resuelve el nombre por su cuenta antes de hablar
1310   con el proxy: la consulta DNS sale justo por la línea que se quería evitar. Es la fuga clásica de
1311   esta configuración, así que el default es el seguro y hay que apagarlo a mano.
1312
1313**Y lo que NO promete está en la propia página de opciones, arriba de todo y no en un pie**:
1314separación de tráfico, no anonimato; no toca la huella del navegador; para anonimato, Tor Browser.
1315Es el §4 cumplido en el sitio donde el usuario lo va a leer.
1316
1317#### Lo que quedó PROBADO, y con qué
1318
1319Corriendo el árbol de atuq en la misma jaula que usa `scripts/atuq-nested.sh`:
1320
1321| Afirmación | Prueba |
1322|---|---|
1323| La política CREA los cuatro contenedores | captura de `about:preferences#containers` con Personal/Trabajo/Banco/Compras y sus iconos, más el `containers.json` del perfil |
1324| Las dos extensiones se INSTALAN | `extensions.json` del perfil nombra `inicio@atuq.tawasuyu` y `proxy@atuq.tawasuyu` |
1325| El ruteo se arma contra los contenedores reales | `console.info: "atuq/proxy: 4 contenedor(es) enrutado(s)"` — o sea que leyó la config de fábrica por `storage.managed` Y la casó con los contenedores que creó la política |
1326| **Una petición hecha en «Banco» SALE por el proxy de «Banco»** | `scripts/test-atuq-ruteo.py`: dos pestañas piden la misma url; al destino llega `GET /directo` y al puerto del proxy un saludo **SOCKS5 (`\x05\x01\x00`)**, y la de «Banco» no aparece nunca en el destino. Con `--negative-control` (sin proxy configurado) las dos van directas y al proxy no llama nadie |
1327| Los guardianes del cruce política↔XPI FALLAN cuando deben | `scripts/test-atuq-politica.py`: 5 formas de romperlo, las 5 matan el build, y el control con la política intacta pasa |
1328| La capa sobrevive al cambio de BASE | rehecho sobre el `firefox` con RLBox (`b3:8116bdec`, 2026-09-06): `atuq` sella en 2 s como `b3:f2960991`, su `libxul.so` trae **634 símbolos que se LLAMAN `w2c_*`** donde la base anterior traía 0, y los cuatro contenedores y las dos extensiones siguen apareciendo en un arranque real |
1329
1330⚠ **Ese 634 hay que contarlo anclado, y la primera vez se contó mal.** `llvm-nm --defined-only \| grep -c w2c_` da **1547** sobre el mismo fichero, y los 913 de más son nombres C++ mangleados que contienen `w2c_` en el MEDIO (`_ZN5rlbox...PK16w2c_mem_capacity...`), no símbolos de la jaula. El patrón que cuenta lo que dice contar es `grep -c ' w2c_'` —o el equivalente `awk '{print $3}' \| grep -c '^w2c_'`—, y da 634, que es lo que mide el guardián de `recipes/firefox.toml`. La conclusión no cambia (la base anterior daba **0** con cualquiera de los dos patrones); lo que cambia es que un número sin su patrón no es una medición. Muestra de lo que sí es un símbolo de la jaula: `w2c_rlbox_0x5F_cxa_atexit_0`.
1331
1332**Y de paso quedó comprobado que la dep SÍ se sigue**, que era la promesa del §2 y no una que convenga
1333creer sin medir: en un catálogo de sonda, cambiar una bandera del `firefox` copiado mueve el hash de
1334`atuq` (`f2960991` → `60ade76a`), mientras que con el `firefox` idéntico al del corpus da el mismo
1335hash. O sea que un `atuq` viejo **no** puede quedarse tapando un motor nuevo — que es justo la forma
1336que tendría acá el cache-hit que congela regresiones.
1337
1338**Cómo se cerró lo que faltaba (2026-09-06).** Durante unas horas esta sección decía que probar el
1339paquete quedaba pendiente, con tres puertas cerradas. Las tres siguen cerradas y la prueba existe
1340igual, porque el camino era otro.
1341
1342Las tres puertas, que conviene NO volver a golpear:
1343
13441. **Desde la línea de comandos no hay flag** para el `userContextId` de la pestaña inicial. Y un
1345   `moz-extension://` tampoco se abre así: el manejador intenta resolverlo como fichero y muere con
1346   `NS_NOINTERFACE [nsIFileURL.file]`, abriendo la home en su lugar.
13472. **Marionette está en el artefacto y responde** —se le habló con un cliente propio de 60 líneas,
1348   el protocolo es `<longitud>:<json>`—, pero abrir una pestaña de contenedor pide contexto chrome,
1349   y eso exige `-remote-allow-system-access`; en la jaula no lo tomó ni por bandera, ni con
1350   `--remote-debugging-port`, ni por `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`, que es lo que
1351   `RemoteAgent.sys.mjs` lee en su constructor. Cabo suelto medido, no suposición.
13523. **Desde la extensión, `tabs.create({cookieStoreId})` exige el permiso `cookies`**
1353   (`ext-tabs-base.js:getUserContextIdForCookieStoreId`). Dárselo a la extensión del proxy para que
1354   pueda probarse a sí misma sería pagar con la superficie de ataque del producto una comodidad del
1355   test. Sigue sin dárselo.
1356
1357**La cuarta puerta estaba abierta: el fichero de sesión.** `sessionstore` guarda el `userContextId`
1358de cada pestaña y Gecko lo restaura, así que se fabrica a mano. El formato es `mozLz40\0` + tamaño +
1359un bloque LZ4, y **un bloque de sólo literales es LZ4 válido**: veinte líneas, sin depender de
1360ninguna librería. Con eso se abren dos pestañas —misma url, distinto contenedor— sin pedirle nada al
1361navegador.
1362
1363Y la medición no le pregunta nada tampoco: **se le ponen dos oídos en la red y se mira a cuál llama.**
1364El destino contesta un HTTP 200; el «proxy» no habla SOCKS, sólo anota quién lo saludó. Que al
1365puerto del proxy llegue un `\x05\x01\x00` es la prueba: ese saludo sólo aparece si Gecko decidió
1366hablar con un proxy para esa petición, y el único que se lo pudo indicar es nuestro
1367`proxy.onRequest` mirando el `cookieStoreId`.
1368
1369Tres detalles sin los cuales la prueba mide un silencio y se lee como un fallo:
1370`browser.sessionstore.restore_on_demand=false` (si no, las pestañas restauradas no piden nada hasta
1371que alguien las mira), `network.proxy.allow_hijacking_localhost=true` (Gecko saltea el proxy para
1372localhost, y la petición de «Banco» iría directa haciendo parecer culpable a la extensión), y
1373`triggeringPrincipal_base64: vQ==` en cada entrada de sesión (sin principal la pestaña se restaura
1374pero no navega).
1375
1376**Y la prueba trae su propio control negativo**, porque es lo que este repo se exige desde hoy: con
1377`--negative-control` no se configura el proxy y se exige el resultado CONTRARIO —las dos pestañas
1378directas, nadie llamando al proxy—. La única diferencia entre las dos corridas es una línea de
1379configuración y el observable se da vuelta entero. Sin ese modo, una prueba que se hubiera vuelto
1380ciega se vería idéntica a una que funciona.
1381
1382### 6.9 Torrent adentro — HECHO (2026-09-10), con un daemon propio, configurable y perezoso
1383
1384Vivaldi ya trae torrent, y termina en una carpeta. Lo que cambia acá es que **lo que baja entra al
1385CAS**: un objeto BLAKE3 deduplicado y verificable, con la misma identidad que una descarga del
1386navegador (§6.2) o un `.swm` de takana. Ésa, y no «tener torrent», es la razón por la que esto vale.
1387
1388    página de la extensión → fondo → connectNative → puriy-costura
1389      → /usr/bin/puriy-costura-torrent add   (que LEVANTA el daemon si no está)
1390      → daemon: librqbit, y al completarse, el CAS
1391
1392**Por qué un proceso aparte y no un verbo más del host — dos razones, y la primera decide:**
1393
13941. **Una descarga tiene que sobrevivir al navegador.** Gecko lanza un host de native messaging **por
1395   puerto** y lo mata cuando el puerto se cierra (medido este mismo día, §6.2.bis). Un cliente
1396   BitTorrent dentro de `puriy-costura` dejaría de bajar al cerrar la pestaña.
13972. **El precio.** `librqbit` + tokio + rustls son **226 crates**: ese host —952 K, compartido por las
1398   cinco extensiones— pasaría a ~20 MB, y cada iteración de cualquier función del navegador a un
1399   cuarto de hora.
1400
1401⚠ **Y que esa pila COMPILA para musl con zig-cc se midió antes de decidir**, con una receta
1402desechable: 226 crates y sella. Era una incógnita real —ninguna receta del corpus había construido
1403tokio+rustls desde tawasuyu; las que hay dicen «deps livianas (sin tokio/libp2p/axum)»— así que la
1404decisión no fue «no se puede», fue **«no ahí»**. Medir primero es lo que separa una decisión de
1405arquitectura de una excusa.
1406
1407**Perezoso en los dos sentidos**, que es lo que pidió el operador:
1408
1409| | qué significa |
1410|---|---|
1411| nadie lo arranca | **no hay servicio en la imagen**: el binario está y no corre. Lo levanta su propio cliente la primera vez que hay un torrent que tomar |
1412| se va solo | cuando no queda nada activo por `inactividad_seg` (300 s por defecto; `0` = nunca), sale. Sembrar cuenta como actividad: no se va mientras le devuelva piezas al enjambre |
1413
1414⚠ Se levanta con **`setsid`**: sin eso queda en el grupo de procesos del navegador y **se muere con
1415él**, que es exactamente lo que este proceso existe para no hacer. Si `setsid` faltara se arranca
1416igual —mejor bajar mientras el navegador esté abierto que no bajar— pero **avisando**, porque es una
1417degradación silenciosa de su única promesa.
1418
1419**Configurable**: un TOML opcional (`descargas`, `cas`, `indice`, `sembrar_tras_bajar`,
1420`inactividad_seg`, techos de velocidad). Sin fichero los defaults andan; un fichero **roto** sí es un
1421error, porque si un typo se comportara como «sin configuración» el usuario buscaría el problema en
1422cualquier otra parte. **El CAS por defecto es el del host**, y hay un test que falla si esas dos
1423raíces se separan: es lo que hace que un torrent y una descarga del mismo contenido sean **un objeto**.
1424
1425**Lo que el guardián NO hace, y por qué** (`scripts/test-atuq-torrent.py`):
1426
14271. **no usa un `magnet:` sino un `.torrent` servido por HTTP** — dar de alta un magnet BLOQUEA
1428   esperando la metadata del enjambre, y sin peers eso no llega nunca: se mediría un timeout y no la
1429   cadena. Con un `.torrent` la metadata viene en el fichero y el alta termina. El `.torrent` se arma
1430   en el propio guardián, en bencode a mano: un binario de prueba en el repo es una dependencia que
1431   nadie revisa;
14322. **no pasa por el despacho de protocolos de Gecko** — un clic en un `magnet:` abre el diálogo de
1433   «¿con qué lo abro?», que en headless no contesta nadie, y eso es una elección del usuario y no
1434   código nuestro. Se abre la página de la extensión, que es la que el handler abriría, descubriendo
1435   su URL base del `dump` de una primera corrida (el UUID lo asigna Gecko por perfil).
1436
1437Y el control negativo prueba lo que importa: **sin el cliente del daemon, el host falla diciéndolo y
1438nombrando lo que falta** — no finge haberlo tomado.
1439
1440⚠ **Y el bug que encontró MIRAR LA MÁQUINA, no un test.** Después de las pruebas, `ps` mostraba un
1441daemon con **23 minutos de vida** y `idle_seconds = 300`. El bucle hacía
1442`let Ok(g) = gestor() else { continue }`: si la sesión de torrent no se podía crear, el `continue`
1443saltaba la evaluación de «¿sobro?» y el daemon quedaba **vivo para siempre** — lo contrario de
1444perezoso, que es su única promesa. Los tests cubrían la decisión como función PURA, y esa función
1445estaba bien: **el que no llegaba a llamarla era el bucle**. Una decisión correcta que nadie toma se ve
1446igual que una que no existe. Arreglado (sin sesión = nada activo) y fijado con dos tests que corren el
1447PROCESO y el reloj de verdad, el segundo comprobado volviendo a poner el `continue`.
1448
1449⚠ **La pereza tiene un residuo, y el guardián lo daba por bueno (2026-09-10).** El daemon se va
1450«tras 300 s **sin nada**», y un torrent sin enjambre nunca termina ⇒ nunca está sin nada: se queda,
1451que es lo correcto para un cliente de torrent. Pero el guardián cerraba el sandbox y se iba, y
1452**quedaba vivo el daemon Y el `bwrap` interno**, sosteniendo abiertos overlays ya borrados —
1453encontrado con `ps` sobre la máquina, no por un test. La corrección tiene dos mitades y ninguna
1454sobra:
1455
14561. el guión de adentro **despide al daemon** con `puriy-costura-torrent stop` — el propio verbo del
1457   producto, así que de paso se ejerce;
14582. el guardián **cuenta los daemons antes y después** (`ps -C`, nunca `pkill -f`, que empareja la
1459   línea de comandos y se lleva la shell del que corre el test) y **falla nombrando el PID** si quedó
1460   alguno; y barre lo propio en el `finally`, para que un test rojo tampoco deje basura.
1461
1462Y el orden importa: la huella del socket se anota **adentro y antes** del `stop`, porque al irse el
1463daemon lo borra — mirarlo desde el hub después mediría el reloj, no el hecho. Comprobado en los dos
1464sentidos, quitando el `stop` en una copia fuera del repo: sin él el guardián decía ✓ igual y dejaba
1465un daemon suelto; con la aserción nueva sale
1466`✗ quedó un daemon vivo tras cerrar el sandbox: PID(s) [20157]`. La primera hipótesis —que sin `stop`
1467el guardián se COLGARÍA, porque `bwrap --unshare-pid` espera a su namespace— **era falsa y la
1468medición la descartó**: el `bwrap` externo vuelve, y el que se queda esperando es el interno. El tope
1469de 180 s quedó igual, pero por lo que es: seguro contra un cuelgue, no el que detecta la fuga.
1470
1471⚠ **Lo que sigue sin estar en ningún test automático: un transfer real entre peers.** Haría falta un
1472sembrador y una espera que volverían la suite una que nadie corre. Está dicho también en el test del
1473daemon, al pie, para que nadie lo lea como «probado de punta a punta».
1474
1475### 6.10 Lo que `atuq` NO PUEDE hacer hoy, y por qué (2026-09-07)
1476
1477Sale de un punto ciego que nombramos entre los dos frentes y que **ningún auditor de `readelf`
1478puede cubrir**. La jerarquía de lo que se ve:
1479
1480    NEEDED del ejecutable          → lo ve `test-atuq-rootfs.py` y lo ve `vigia-sonames.py`
1481    NEEDED de un .so dlopeado      → lo ven los dos, si el fichero está en el artefacto
1482    dlopen("libfoo.so.1") LITERAL  → NO LO VE NINGUNO
1483
1484Y un navegador vive del tercer escalón: Firefox sondea ffmpeg, VA-API, vulkan, libnotify y una
1485docena más **por nombre**, y cuando no están **no falla: apaga la función y sigue**. No hay línea
1486roja; hay una función que nadie ofrece y nadie reclama. Es la misma forma que ya nos costó caro con
1487OBS (`dlopen("libGL.so.1")` desde su glad).
1488
1489`scripts/test-atuq-rootfs.py --dlopen` busca esas cadenas y las cruza contra el rootfs. **Es un
1490heurístico y se declara como tal** —una cadena no prueba un `dlopen`, y su ausencia no prueba que no
1491lo haya—, así que no falla nunca: imprime un mapa triado. Lo que sí se puede afirmar es lo
1492contrario, que es lo útil: **si la cadena está y el fichero no, esa función no existe en esta
1493imagen.**
1494
1495De 85 cadenas con forma de soname, **47 no tenían quién las provea**. Triadas (y con dos filas
1496corregidas el mismo día — ver §6.11, que las midió en vez de deducirlas; hoy el mapa da **7 huecos**):
1497
1498| Qué se pierde | Estado |
1499|---|---|
1500| **Códecs del sistema (H.264/AAC)** | ❌ **ERA FALSO** — `recipes/ffmpeg.toml` existe, está sellada y ya viajaba en los cuatro escritorios por `mpv`. Corregido en §6.11: **reproduce** |
1501| **Notificaciones web** (`libnotify`) | ✅ **CERRADO el 2026-09-07**: `recipes/libnotify.toml`, 0.8.8, compartida. Es el primer hueco que este mapa encontró Y cerró |
1502| **Llavero** (`libsecret`) | hueco — pero **la receta ya existe** en `incoming-gnome` |
1503| **Sonidos del sistema** (`libcanberra`) | hueco — receta en `incoming-kde` |
1504| **WebGPU / vulkan** (`libvulkan`) | hueco — receta `vulkan-loader` en `incoming-kde` |
1505| **Vídeo por hardware** (VA-API) | hueco, pero **no por falta de receta**: `libva` está sellada y en las cuatro imágenes. Falta el driver — las tres mesa van con `-Dgallium-va=disabled` y `-Dvideo-codecs=` vacío ⇒ ni un `*_drv_video.so` |
1506| **Lectura en voz alta** (`libspeechd`) | hueco — sin receta |
1507| `libGL` | **decisión**: la distro es Wayland-only sin GLX y Firefox va por EGL |
1508| `libcurl` | **decisión**: sólo lo usa `pingsender`, que manda telemetría — apagada |
1509| Kerberos, menú global, ML | fuera de alcance |
1510| `libdl/libpthread/librt/libudev.so.0/libfreebl3` | ruido: musl los funde, o el soname es viejo |
1511
1512**El primero ya está cerrado.** `libnotify` no tenía receta y ahora la tiene: es la prueba de que el
1513mapa sirve para algo más que mirarlo. ⚠ Con una advertencia que va escrita en la propia receta:
1514`libnotify` no trae un daemon, manda `org.freedesktop.Notifications` por D-Bus ⇒ tenerla resuelve la
1515mitad —que firefox la encuentre— y la otra mitad es que en la imagen haya alguien escuchando ese
1516nombre. Nadie debería leer «notificaciones arregladas» y esperar un globo en un QEMU pelado.
1517
1518**⚠ CORRECCIÓN, mismo día: escribí que tres de los siete eran «baratos, promoción y no autoría»
1519—`libsecret`, `libcanberra`, `vulkan-loader`— y es FALSO.** Lo comprobé antes de declararlos en los
1520perfiles, y en los tres casos falta la otra mitad:
1521
1522| «barata» | por qué NO alcanza con declararla |
1523|---|---|
1524| `vulkan-loader` | **las tres recetas de mesa construyen con `-Dvulkan-drivers=` VACÍO** ⇒ el loader no encontraría un solo dispositivo. Un cargador sin ICD no es WebGPU |
1525| `libsecret` | no hay receta de `gnome-keyring` ni de ningún servicio de secretos: firefox hablaría a `org.freedesktop.secrets` y no habría nadie |
1526| `libcanberra` | no hay receta de `sound-theme-freedesktop`: la librería carga y no suena nada |
1527
1528Declararlas habría subido el número de paquetes de la imagen sin encender una sola función. **Una
1529librería es media función; la otra mitad es quien la atiende**, y eso vale para todas las de esta
1530tabla. Quedan como huecos, no como promociones pendientes.
1531
1532⚠ **Y esta frase también se cayó el mismo día.** Decía que de los otros cuatro «el más caro para el
1533usuario es `ffmpeg`: sin él, un sitio que sirva H.264 no reproduce» — con lo cual el hueco más caro
1534del mapa **no existía**. Ver §6.11: la receta estaba, faltaba declararla en el runner, y H.264 (y
1535AAC, VP9, Opus, AV1, MP3, FLAC) reproducen. De los cuatro «sin receta» quedan tres, y ninguno es un
1536códec: lectura en voz alta (`libspeechd`), el driver de VA-API, y `libpci` —que sólo lo usa
1537`glxtest`, o sea irrelevante sin GLX.
1538
1539**Y la misma vara aplicada a `libnotify`, que sí se declaró:** su otra mitad es un daemon que
1540escuche `org.freedesktop.Notifications`. Medido perfil por perfil:
1541
1542    escritorio-kde      plasma-workspace        ✓ completa
1543    escritorio-gnome    gnome-shell             ✓ completa
1544    escritorio-cosmic   cosmic-notifications    ✓ completa
1545    escritorio-sway     dunst 1.12.2            ✓ completa — CERRADA el 2026-09-07
1546
1547La de sway se cerró el mismo día con `recipes/incoming-wlr/dunst.toml`, y la trampa del homónimo
1548—**el nombre `mako` YA ESTÁ OCUPADO en el corpus** por el motor de plantillas de Python de Mesa— se
1549esquivó eligiendo dunst, que además habla D-Bus por GDBus y no por `sd-bus`, o sea sin receta nueva
1550de `basu`. La prueba no es que selle: `scripts/wlr/dunst-headless.sh` levanta sway headless y un bus
1551de sesión, manda un `notify-send` y **deja que el bus active el daemon solo** —el mismo camino que
1552recorre una página web en `atuq`—, y mide un diff de píxeles antes/después: 14.832 cambiados con el
1553`.service` puesto, 0 sin él. Evidencia en `docs/evidencia/dunst-sway-notificacion-2026-09-07.png`.
1554
1555Y la cadena se cerró hasta el final, que es donde vive la promesa: con `--via-atuq` el emisor no es
1556`notify-send` sino **una página web dentro del navegador**. Ahí se ejercitan las cinco piezas —
1557`new Notification(...)` → `libxul` haciendo `dlopen("libnotify.so.4")` (que no es NEEDED de ningún
1558ELF, o sea invisible para cualquier auditor) → D-Bus → activación → dunst dibujando—, y el veredicto
1559es el `onshow` del motor, no el diff de píxeles: con el `.service` puesto llega a `MOSTRADA #33`, sin
1560él el motor dice `ERROR al mostrar` y no se dibuja nada. Evidencia en
1561`docs/evidencia/atuq-notificacion-web-sway-2026-09-07.png`.
1562
1563Vale la pena decir qué prueba eso de más: que en el proceso de `atuq` **la GLib no se duplica**. El
1564navegador usa la cadena `-shared` (la que arrastra su GTK3) y `libnotify.so.4` enlaza esa misma, así
1565que el `dlopen` no repite GObject. El que sí lo repetía era `dunstify`, y por eso no se shipea.
1566
1567⚠ De paso apareció otra media función, y ésta toca a `libnotify` directamente: **`dunstify`
1568segfaultea hasta en `--help`** porque mezcla la GLib ESTÁTICA del corpus con la COMPARTIDA que
1569arrastra `libnotify.so.4` — dos copias de GObject en un proceso. No se shipea; el emisor es
1570`notify-send`, que viene dentro del artefacto de libnotify y enlaza toda la cadena compartida. Vale
1571como recordatorio de que en este corpus **quién enlaza qué GLib es parte del contrato**, no un
1572detalle del build.
1573
1574**Lo que esta sección cambia de fondo:** hasta hoy la pregunta era «¿arranca?», y la respuesta era
1575sí. La pregunta que faltaba era «¿y qué NO puede hacer?», que ninguna métrica del repo respondía
1576porque todas miran presencia y ésta mira ausencia declarada por el propio binario.
1577
1578#### 6.10.bis Una descarga terminada MATA a `atuq --headless` (2026-09-10)
1579
1580Medido al escribir el guardián del §6.2, y vale la pena por cómo se atribuyó, no por el bug:
1581
1582| corrida | qué pasa |
1583|---|---|
1584| `--headless`, con la extensión de descargas | baja el fichero, la extensión dice `TERMINADA`, y el proceso muere con **SIGSEGV (139)** |
1585| `--headless`, **sin** la extensión (control) | baja el fichero, y muere igual |
1586| `--headless`, con el panel de descargas apagado (`browser.download.alwaysOpenPanel=false`) | muere igual |
1587| **sway headless (compositor REAL, salida offscreen)** | baja, ingiere al CAS y **no se cae** |
1588
1589Las dos primeras filas son las que importan: **el control sin la extensión descarta que sea nuestro**,
1590y la última descarta que sea del producto. Es el modo `--headless` de Gecko, en esta jaula, cuando una
1591descarga completa. El fichero se baja bien en los cuatro casos — lo que muere es el proceso después.
1592
1593Sin el control, esto se lee de dos maneras y las dos son caras: «la extensión de descargas rompe el
1594navegador» (perseguir un bug que no existe) o «atuq no puede descargar» (reportar un bug de producto
1595que tampoco existe). Por eso `scripts/test-atuq-descargas.py` corre sobre sway y no sobre `--headless`,
1596y por eso dice en su cabecera POR QUÉ — un arnés distinto al de los demás guardianes, sin explicación,
1597es una invitación a «simplificarlo» de vuelta al que se cae.
1598
1599⚠ Lo que NO se midió: si el crash existe también en el `--headless` de una máquina sin jaula. No hace
1600falta para lo que se decidió acá, y afirmarlo sin medirlo sería justo lo que este documento no hace.
1601
1602#### 6.10.ter ⚠ En la IMAGEN BOOTEADA con COSMIC, `atuq` corre y NO PINTA VENTANA (2026-09-14)
1603
1604Primera vez que el navegador se prueba **dentro de una imagen de disco arrancada**, y el resultado no
1605es el de la jaula. La cadena entera funcionó —y esa parte también es medición—:
1606
16071. `escritorio-cosmic` hidratado con el cierre de HOY: **275 nodos**, 8,4 G, y adentro están
1608   `/usr/bin/atuq`, `/usr/bin/llama-server` y `/usr/share/takana/ia/modelo.gguf`. O sea que
1609   **declarar en el perfil sí pone las cosas en la imagen** — la otra mitad de la lección de `foot`;
16102. imagen EFI de 12 G construida y **arrancada en QEMU**: COSMIC pinta panel, dock y fondo en ~2 min
1611   (TCG, sin KVM);
16123. `atuq` **arranca**: sus extensiones inician y la del §6.5 sondea el foco cada minuto
1613   (`FOCO ESTADO unknown`), WebRender inicializa («Software WebRender», GL 3.2);
16144. **y la ventana nunca aparece.** Cinco minutos, dos capturas, y el escritorio sigue vacío.
1615   ⚠ **MATIZADO el mismo día, ver §6.10.sexies: es INTERMITENTE.** Repetido cinco veces sobre esta
1616   misma imagen, el navegador pintó en TRES (a +197…+204 s) y en dos no pintó —una de ellas con 15
1617   minutos de observación—. O sea que esto no era ni «nunca pinta» ni «sólo tardaba»: es un fallo que
1618   aparece a veces, y una corrida sola no lo puede decidir en ningún sentido.
1619
1620**Lo que ya se descartó**, para que nadie lo repita:
1621
1622- **no es el sandbox de Gecko**: relanzado con los cinco `MOZ_DISABLE_*_SANDBOX` puestos, idéntico;
1623- **no es que el proceso muera**: sigue vivo y ejecutando el JS de las extensiones;
1624- **no es el modelo ni la IA**: pasa con `about:blank`.
1625
1626**La pista que vale**: bajo **sway headless** el mismo artefacto pinta perfecto —hay capturas del
1627panel contestando (§6.7.ter)—. La diferencia entre los dos casos es el compositor (cosmic-comp con
1628llvmpipe contra sway con pixman) y el arranque real contra `bwrap`. Eso acota dónde mirar, y es
1629material para su propia unidad de trabajo, no para un parche apurado.
1630
1631⚠ **Y la lección del método**: sin arrancar la imagen, esto no se veía. Los quince guardianes están
1632en verde, el `vigia-imagen.py` del perfil da ✓ en iconos, cursores, fuentes, terminal y QML, y el
1633navegador **igual no se puede usar** en la imagen. «Sella», «hidrata» y «los tests pasan» son tres
1634cosas distintas de «arranca y se ve».
1635
1636Artefactos de la corrida, para quien siga: `/mnt/cosecha/takana-cosmic-qemu.img` (12 G) y las
1637capturas en `work/cosmic-{1..6}.png`.
1638
1639#### 6.10.quater El COMPOSITOR no es la causa: con `cosmic-comp` nesteado el navegador SÍ pinta (2026-09-14)
1640
1641El §6.10.ter dejó dos variables cambiando a la vez —el compositor (cosmic-comp+llvmpipe contra
1642sway+pixman) y el arranque real contra `bwrap`— y ninguna medición que las separe. `scripts/cosmic/
1643atuq-en-cosmic.sh` saca la primera de encima en ~2 min por vuelta, sin QEMU y sin imagen:
1644
1645    sway (headless, pixman)  →  cosmic-comp (winit, cliente de sway)  →  atuq
1646
1647sway es andamio: no se prueba nada de él, sólo le da a cosmic-comp una pantalla donde vivir, y `grim`
1648captura de SU lado, o sea que fotografía la ventana de cosmic-comp con lo que haya dentro. El
1649`--control` corre **ese mismo binario de `atuq`** —el del cierre `escritorio-cosmic` hidratado para la
1650imagen, no otro— directo sobre sway, sin tocar nada más.
1651
1652**El resultado, en píxeles:**
1653
1654    cosmic-comp   magenta de la página: 586331 px   bbox (205,169)-(1277,717)
1655    control sway  magenta de la página: 586331 px   bbox (205,169)-(1277,717)
1656
1657Las dos capturas coinciden **al píxel** en la región de la página, y no por casualidad ni porque el
1658navegador se haya escapado a sway: en la captura previa al lanzamiento, la ventana de cosmic-comp
1659ocupa exactamente `(2,81)-(1277,717)` = 1276×637 de su color de fondo, que es el mismo rectángulo que
1660sway le da a un cliente maximizado bajo su barra de título. Las dos corridas enteras difieren en 1130
1661píxeles, todos en la franja de las barras de título. Y el `MOZ_LOG` del widget lo confirma desde el
1662otro lado: en la corrida con cosmic el motor ve `mode output size 1276 x 637` (la ventana del
1663compositor nesteado) y en el control `1280 x 720` (la pantalla de sway).
1664
1665⇒ **`cosmic-comp` no es el que impide la ventana.** Con GL por software, sin GPU y hasta con el
1666navegador entero pintando dentro, la cadena funciona.
1667
1668**Y de paso, qué camino de buffers usa cuando funciona:** `WaylandBufferSHM::CreateWlBuffer()` en cada
1669cuadro — memoria compartida, no dmabuf. Es la línea de base contra la que comparar lo que haga en la
1670imagen: si allá el motor eligiera otro camino, se vería en la misma traza.
1671
1672Lo que queda como variable —y es donde sigue la caza— es el arranque de verdad: `cosmic-comp` sobre
1673**kms/DRM de virtio-gpu** en vez de winit, el PID1 de arje, el seat. Para eso está
1674`scripts/cosmic/atuq-en-imagen.py`, que arranca la imagen, maneja el serial, lanza el navegador con el
1675mismo `MOZ_LOG` y pide capturas por QMP — la corrida del §6.10.ter fue a mano y no dejó un solo log
1676que se pueda releer, que es exactamente lo que hace falta para comparar.
1677
1678#### 6.10.quinquies En la imagen, el navegador SÍ pinta — y la segunda pantalla no era (2026-09-14)
1679
1680`scripts/cosmic/atuq-en-imagen.py` arranca la imagen del §6.10.ter, maneja el serial, lanza el
1681navegador con `MOZ_LOG` del widget de Wayland y pide capturas por QMP. La corrida de aquel día fue a
1682mano y no dejó un log que se pueda releer; ésta deja todo en `work/atuq-imagen-*`.
1683
1684**La sospecha que se fue a probar** salía del propio serial: aquel arranque veía DOS dispositivos DRM
1685y éste uno solo.
1686
1687    == cosmic :: kernel 6.16.12 · drm: card0 card1 renderD128   ← §6.10.ter (sin `-vga none`)
1688    == cosmic :: kernel 6.16.12 · drm: card0 renderD128         ← con `-vga none`
1689
1690Sin `-vga none`, QEMU agrega una VGA estándar ADEMÁS del virtio-gpu, OVMF pinta su GOP ahí y el
1691kernel levanta `simpledrm` encima. Un compositor con dos tarjetas puede componer en la que el
1692`screendump` no muestra, y eso se vería exactamente como «la ventana no aparece».
1693
1694**Medido, con las dos configuraciones y el mismo lanzamiento** (perfil nuevo, `MOZ_ENABLE_WAYLAND=1`,
1695página de un solo color):
1696
1697| vídeo de QEMU | DRM en el guest | magenta en pantalla |
1698|---|---|---|
1699| `-vga none` | `card0` | **703 766 px** |
1700| `VGA=1` (como el §6.10.ter) | `card0 card1` | **703 766 px** |
1701
1702El mismo número en las dos, al píxel. La ventana aparece —panel de COSMIC, dock y el navegador con su
1703barra lateral—, `nsWindow::Create() Toplevel` sale en el `MOZ_LOG`, la superficie se mapea y
1704`NotifyOcclusionState() mIsFullyOccluded 0`. El camino de buffers es el mismo que bajo sway:
1705`WaylandBufferSHM`, 1280×696.
1706
1707⇒ **la segunda pantalla NO es la causa**, y el síntoma del §6.10.ter no se reproduce así. Eso deja una
1708sola diferencia entre aquella corrida y ésta, y es la que sigue: **cómo se lanzó el navegador**. Acá
1709va con perfil nuevo y las variables puestas; allá fue un `atuq` pelado, con su perfil por defecto —el
1710que trae la barra lateral y la IA— tecleado en el serial. Por eso el guion tiene `--como-usuario`:
1711lanza exactamente eso.
1712
1713⚠ **Y la trampa del método que costó una corrida entera**, escrita en el guion para que no se repita:
1714el terminal hace **eco** de lo que se le escribe, así que una marca de fin escrita literal aparece en
1715el serial ANTES de que el shell ejecute nada — el arnés la lee en el eco, da la orden por terminada y
1716manda la siguiente encima. Y `cmd & ; echo …` es **error de sintaxis** en ash: el navegador no se
1717lanzó y la corrida siguió como si todo fuera bien, con capturas de un escritorio vacío que se leían
1718igual que el fallo que se estaba investigando.
1719
1720#### 6.10.sexies El número: pinta a +197…+204 s — y **2 de 5 veces no pinta** (2026-09-14)
1721
1722El §6.10.quinquies dejó una sola variable viva —cómo se lanza el navegador— y `--as-user` la mide:
1723`atuq` pelado, con SU perfil por defecto (el que vive en la ext4 y hace su primer arranque entero),
1724tal como se tecleó en el serial aquella noche. Cinco corridas sobre la MISMA imagen:
1725
1726| # | lanzamiento | vídeo | observación | primera pintura |
1727|---|---|---|---|---|
1728| 1 | perfil nuevo en tmpfs | `-vga none` | 180 s | **+197 s** |
1729| 2 | perfil nuevo en tmpfs | `VGA=1` | 240 s | **+197 s** |
1730| 3 | perfil del usuario | `VGA=1` | 291 s | ✗ no pintó |
1731| 4 | perfil del usuario | `VGA=1` | 900 s | **+204 s** |
1732| 5 | perfil del usuario | `VGA=1` | 900 s | ✗ **no pintó en 15 min** |
1733
1734⇒ **el fallo es INTERMITENTE** —con diez corridas más, la tasa quedó en **3 de 13**, ver §6.10.septies—,
1735y las corridas 4 y 5 son el mismo comando sobre la misma imagen. No
1736es el compositor (§6.10.quater), no es la segunda pantalla (§6.10.quinquies) y **tampoco es sólo que
1737tarde**: cuando pinta, pinta siempre alrededor de los 200 s; cuando no, no pinta aunque se le den
1738quince minutos.
1739
1740⚠ **La lección de método, y me la comí yo hoy mismo.** Entre la corrida 3 y la 4 publiqué que «la
1741ventana SÍ aparece, tarda», y el argumento era el `MOZ_LOG`: en la 3, treinta segundos después de la
1742última captura, Gecko decía `mapped 1`, «marked as visible & has buffer» y commiteaba un
1743`WaylandBufferSHM` de 1280×696 — lo mismo que hace cuando pinta. La corrida 5 lo refutó: **commiteaba
1744cuadros desde ≤ +377 s y la pantalla estaba vacía a los 900 s.** O sea que *el cliente cree que está
1745visible* no es evidencia de que se vea; la evidencia es el píxel. Un log en verde compatible con una
1746pantalla vacía es exactamente el cuadro contra el que este documento viene advirtiendo, y aun así lo
1747usé para cerrar una pregunta. Ver [[la-etiqueta-no-es-el-hecho]].
1748
1749**Dónde queda el número, para que no viva en el scrollback de quien corrió la VM** —que es como se
1750perdió el de la primera corrida—: `scripts/cosmic/atuq-en-imagen.py` mide la primera pintura y
1751**acumula** cada corrida en `docs/state/primera-pintura.json` con sus condiciones (lanzamiento,
1752vídeo, RAM, vcpus, cadencia = resolución del número). Acumula y no pisa **porque el fenómeno es
1753intermitente**: un fichero de una sola medición convierte esto en «+204 s» o en «no pinta» según qué
1754corrida tocó última, que es la forma más cara de mentir con datos ciertos. Y `scripts/vigia-imagen.py`
1755—que mide artefactos en segundos y no arranca nada— lo LEE y lo informa como sexto dato:
1756
1757    == primera pintura en la imagen arrancada  (no lo mide este vigía)
1758       ⚠ pintaron 3 de 5 corridas · primera pintura +197…+204s
1759         ✓ +197s     perfil nuevo en tmpfs  virtio-gpu solo (-vga none)
1760         ✗ —         perfil del usuario     vga+virtio-gpu (dos DRM)
1761         ✓ +204s     perfil del usuario     vga+virtio-gpu (dos DRM)
1762         ✗ —         perfil del usuario     vga+virtio-gpu (dos DRM)
1763         ⚠ en las que NO pintó, el MOZ_LOG igual decía `mapped 1` + «has buffer» + …
1764
1765⚠ **Y una trampa para cualquier medición sobre la imagen: `cosmic-idle` ATENÚA la pantalla.** Entre
1766+526 s y +590 s sin una sola entrada, los 703 766 px de `(255,0,255)` pasan a 703 779 px de
1767`(117,0,117)` — la misma ventana al 46 % de brillo. Por eso el detector cuenta magenta **con
1768tolerancia** y no por color exacto: contar el color exacto lee el escritorio dormido como «desapareció
1769la ventana».
1770
1771**Lo que queda abierto**, y ahora con arnés para atacarlo: por qué a veces la superficie mapeada y con
1772buffer no llega a la pantalla. Lo próximo es mirarlo del lado del compositor —si el `xdg_toplevel`
1773recibe su `configure`, en qué workspace y en qué salida quedó la ventana— y correr N veces para tener
1774una tasa, no una anécdota.
1775
1776#### 6.10.septies La TASA: «3 de 13» — ⚠ **el número medía LA CÁMARA, no el producto (ver §6.10.octies)** (2026-09-14)
1777
1778Diez corridas seguidas, idénticas, con `scripts/cosmic/tasa-primera-pintura.sh`: `--as-user`, `VGA=1`,
1779ventana de 480 s, captura cada 30 s. Más las cinco de las secciones anteriores, todas sobre la misma
1780imagen y anotadas en `docs/state/primera-pintura.json`:
1781
1782| lanzamiento | pintaron | primera pintura |
1783|---|---|---|
1784| perfil del usuario (lo que hace un usuario) | **3 de 13** | 184, 199, 204 s |
1785| perfil nuevo en tmpfs | 2 de 2 | 197, 197 s |
1786
1787⚠ **ESTA TABLA NO SIGNIFICA LO QUE PARECE.** Las 13 del perfil del usuario salieron todas con `VGA=1`
1788y la de perfil nuevo casi ninguna ⇒ «perfil» y «vídeo» están confundidos; y las capturas miraban UNA
1789de las DOS salidas. El §6.10.octies lo mide: la ventana cae en cualquiera de las dos pantallas, así
1790que los ✗ son «no estaba en la que fotografié». Lo que sí sobrevive es el tiempo: cuando se la ve,
1791se la ve entre +184 y +312 s.
1792
1793**Dos cosas que el número dice y una anécdota no podía:**
1794
17951. **cuando pinta, pinta siempre en la misma ventana** —184…204 s— y nunca a los 300, 400 ni 900. O
1796   sea que «tardaba» era falso: no hay una cola larga, hay **dos regímenes**. O sale a los ~200 s o no
1797   sale;
17982. **la tasa con el perfil del usuario es ~1 de 4**, y con perfil nuevo en tmpfs no falló nunca (2 de
1799   2 — pocas corridas para afirmar que nunca falla, pero suficientes para que la diferencia valga como
1800   pista de dónde mirar: qué hace el primer arranque del perfil que el perfil ya hecho no hace).
1801
1802⚠ **Condición de la medida, que es parte del número:** las diez corridas salieron con la máquina
1803anfitriona a **load ~10** (la tanda de KDE de otro agente más una VM de otra sesión, en 4 cores). Eso
1804empuja hacia el ✗ y por eso el número es un PISO, no una constante del producto: la misma imagen en
1805una máquina ociosa puede fallar menos. Lo que no cambia con la carga es lo de arriba: las que pintan
1806pintan a ~200 s, no a los 400.
1807
1808⚠ Y una trampa del arnés, medida en las corridas 8–10: con el anfitrión así de cargado, el guest tarda
1809más de 420 s en llegar al shell del serial y los `esperar` del guion vencen —«nunca llegó ‹fin de la
1810ventana de observación›»—. **No las invalida** (se verificó una por una que el navegador se lanzó y
1811dejó su `MOZ_LOG` de 1200–5000 líneas), pero una corrida abortada de verdad se vería casi igual: por
1812eso el guion de la tasa cuenta las **abortadas aparte** en vez de sumarlas a los fallos.
1813
1814#### 6.10.octies La causa: **son DOS SALIDAS y la ventana cae en cualquiera** — y el «3 de 13» medía la cámara (2026-09-14)
1815
1816El §6.10.septies cerró con «con el perfil del usuario pinta 3 de 13, con perfil nuevo 2 de 2» y la
1817pregunta «qué hace el primer arranque del perfil». La respuesta es: **nada**. Dos cosas antes de la
1818medición que la contestó:
1819
18201. **el `MOZ_LOG` de las que pintan y las que no termina IDÉNTICO** — las dos commitean
1821   `WaylandBufferSHM` de 1280×696. O sea que el navegador dibuja igual en los dos casos;
18222. **el cruce de las 15 corridas está CONFUNDIDO**: las 13 «perfil del usuario» salieron TODAS con
1823   `VGA=1`, y de «perfil nuevo» hay una sola con dos tarjetas. Con esa tabla no se puede separar
1824   «perfil» de «vídeo»: comparar una columna que no varía es comparar con nada.
1825
1826**La medición que lo contesta:** `screendump` de QMP fotografía **un** dispositivo. Con `VGA=1` hay
1827dos (`vga0` estándar + `gpu0` virtio-gpu) y hasta ahora se fotografiaba uno. Se le puso `id=` a cada
1828uno y se capturan LOS DOS en cada toma. Resultado, en la misma corrida y el mismo instante:
1829
1830    +312s  ██ PRIMERA PINTURA — 704456 px de la página EN vga0
1831           gpu0 = 962 675 px de fondo azul + panel y dock  (el escritorio, SIN navegador)
1832           vga0 = 703 766 px magenta                       (la ventana, con la página)
1833
1834⇒ **`cosmic-comp` maneja las DOS salidas y la ventana cae en una u otra.** Lo que se publicó como
1835«pinta 3 de 13» era **cuántas veces la ventana cayó en la pantalla que yo fotografiaba**. No es una
1836tasa del producto: es una tasa de mi cámara. Y el §6.10.ter —«arranca y no pinta la ventana»— tiene
1837la misma explicación: aquel arranque también tenía dos tarjetas (`drm: card0 card1`), y la captura
1838miraba una.
1839
1840**Lo que queda en pie de todo lo anterior**, ahora sí sin confundido:
1841· el navegador arranca, mapea su superficie y commitea cuadros **siempre** (eso nunca falló);
1842· cuando la ventana está en la pantalla que se mira, se ve entre **+184 y +312 s**;
1843· el perfil del usuario **no** era la causa, y `cosmic-comp` nesteado tampoco (§6.10.quater).
1844
1845⚠ **La lección de método, que es la tercera vez en el mismo día:** con dos salidas, un ✗ de una
1846captura de una sola pantalla **no dice «la ventana no está»**, dice «no está en ESA». Igual que
1847`mapped 1` no probaba que se viera, una foto no prueba que no exista: prueba dónde no está. Por eso
1848`docs/state/primera-pintura.json` anota ahora **en qué pantalla** apareció y **cuántas fotografió**
1849cada corrida, y `scripts/vigia-imagen.py` **no cuenta las ciegas** — las nombra aparte:
1850
1851    ⚠ pintaron 6 de 6 corridas CONCLUYENTES · primera pintura +184…+312s
1852      ✓ +312s  vga0  perfil del usuario  vga+virtio-gpu (dos DRM)
1853      ⚠ 10 corridas NO se cuentan: con dos salidas fotografiaron UNA sola, y su ✗ sólo dice que
1854        la ventana no estaba en esa pantalla
1855
1856**Lo que sigue**, y ahora es otra pregunta: no «por qué no pinta» sino **por qué el escritorio abre la
1857ventana en la salida que no tiene el foco** —y si con una sola salida (`-vga none`, que es como se va
1858a usar la imagen de verdad) eso desaparece—. Es barato: la misma serie con una tarjeta. **Hecha: §6.10.nonies — y el hueco es real, 2 de 10.**
1859
1860#### 6.10.nonies Con UNA sola salida: **2 de 10** — y el hueco resulta ser real (2026-09-14)
1861
1862El §6.10.octies cerró pidiendo exactamente esta serie: las mismas diez corridas pero con `-vga none`,
1863una sola tarjeta (`drm: card0`), que es como se usa la imagen de verdad. Con una sola salida la
1864captura es COMPLETA, así que por primera vez **un ✗ significa «la ventana no está»** y no «no está en
1865la que fotografié».
1866
1867| vídeo | pintaron | qué dice el ✗ |
1868|---|---|---|
1869| `vga+virtio-gpu` (dos DRM) | 4 de 14 | nada: la cámara miraba una de dos pantallas |
1870| **`virtio-gpu` solo (`-vga none`)** | **2 de 10** | **concluyente** |
1871
1872Y como la captura ya no tiene punto ciego, se puede clasificar **el último cuadro de cada corrida** por
1873su color dominante, que separa dos fallos que hasta ahora eran el mismo ✗:
1874
1875| último cuadro | corridas | qué es |
1876|---|---|---|
1877| `#ff00ff` 68 % | 2 | ✓ la página, pintada |
1878| `#f9f9fb` 68 % | 1 | la **ventana está** —misma geometría que la que pinta— pero **en blanco** |
1879| `#214a87` 91 % | 7 | **sólo el escritorio**: fondo, panel y dock. **No hay ventana** |
1880
1881⇒ **el hueco del §6.10.ter existe y no era la cámara.** Las dos salidas explicaban el número inflado
1882de ✗ de las series con `VGA=1`, no el fenómeno.
1883
1884**Y el `MOZ_LOG` separa los dos regímenes con un número, no con una impresión:**
1885
1886| | líneas de `moz.moz_log` | último apunte del log |
1887|---|---|---|
1888| las 3 con ventana | 952 – 1505 | sigue escribiendo hasta que se apaga la VM |
1889| las 7 sin ventana | 573 – 727 | **se corta a los ~40 s del lanzamiento**, y quedan **6 minutos de silencio** |
1890
1891No es que dibuje invisible: **deja de trabajar**. Y no muere por un crash — `atuq.log` de una fallida
1892llega hasta `WebRender - OpenGL version new 3.2` / `Renderer: Software WebRender` sin una sola línea
1893de error. Las últimas líneas que escribe son justo el bucle de vsync emulado:
1894
1895    WaylandSurface::VSyncCallbackHandler() marked as visible & has buffer
1896    WaylandSurface::SetVSyncCallbackLocked(), enabled 1 mapped 1
1897    WaylandSurface::Commit() allowed [1] needs commit 1
1898      fire VSync callback aEmulated [0] cb.mEmulated [1]      ← y aquí se acaba
1899
1900Que es la forma que tiene Gecko de decir «commiteo a ciegas porque **nadie me devuelve frame
1901callbacks**». La hipótesis que queda —**no medida todavía**, y hay que decirlo así— es que
1902`cosmic-comp` nunca le da a esa superficie el estado de visible, así que el cliente se queda sin
1903callbacks y se detiene. Lo contesta el log del compositor, no el del navegador.
1904
1905⚠ **Condición de la medida:** anfitrión a **load ~15** (la tanda de KDE del otro agente más VMs de
1906otra sesión, en 4 cores) — más alta que el ~10 de la serie anterior. Las dos que pintaron lo hicieron
1907a **+175 s** y **+319 s**, en línea con los +184…+312 de antes. Y hay un orden sospechoso que la serie
1908no puede resolver sola: las 3 con ventana son las corridas 1-3 y las 7 sin ventana las 4-10, todas
1909seguidas. Eso puede ser la carga subiendo o puede ser estado que dejan las corridas entre sí; **con
1910diez corridas no se separa**.
1911
1912⚠ **Dos deudas del arnés que esta serie dejó a la vista**, ninguna barrida:
1913· la cuenta de «procesos atuq» **nunca volvió**: el eco del serial se come la salida, así que no se
1914  puede afirmar si el proceso seguía vivo en las fallidas (el `atuq.log` sin crash es evidencia
1915  indirecta, no la misma cosa);
1916· `tasa-primera-pintura.sh` quedó parametrizado por `VGA` pero **el nombre del log no**: la serie
1917  nueva sobrescribió los crudos de la anterior (se salvaron 9 de 10 en `work/serie-dos-tarjetas/`).
1918  Arreglado después de la serie — no durante: bash lee el guion a medida que lo ejecuta.
1919
1920#### 6.10.decies El log del compositor a `info` NO puede contestar — y por qué hizo falta una perilla (2026-09-14)
1921
1922La hipótesis del §6.10.nonies —«nadie le devuelve frame callbacks»— se contesta del lado de
1923`cosmic-comp`, así que el arnés ahora vuelca **`/var/log/cosmic/cosmic-session.log`**, que ya existía
1924desde el primer día y nunca se había mirado. Corrida fallida (91 % de azul de escritorio, sin
1925ventana), y el log entero:
1926
1927    92 líneas. Todas de 21:17:48 — los segundos del arranque. Ni una superficie, ni un toplevel,
1928    ni una salida. Termina en:
1929    ERROR cosmic_comp::xwayland: Failed to start Xwayland
1930          err=Custom { kind: AddrInUse, error: "Could not find a free socket for the XServer." }
1931
1932Dos cosas, y conviene no confundirlas:
1933
19341. **el ERROR de Xwayland es ruido aquí** — la distro es Wayland-only por decisión (§frente-gnome), y
1935   `atuq` es cliente Wayland nativo: que no haya X no le quita ni un frame. Es una pista falsa
1936   esperando a que alguien la agarre;
19372. **a `RUST_LOG=info` el compositor no dice NADA de ventanas.** El log no es que muestre algo raro:
1938   es que no habla del tema. Un log en silencio no es evidencia de nada — lo mismo imprime cuando
1939   todo anda.
1940
1941⇒ para responder hace falta subirle el nivel, y eso **no se puede hacer desde fuera**: `cosmic-comp`
1942lo lanza `cosmic-start` desde el getty, en el arranque, sin ambiente heredable. La perilla existe y
1943estaba a la vista: `cosmic-start` hace `. /etc/cosmic-mode` **antes** de
1944`export RUST_LOG="${RUST_LOG:-info}"`, así que ese fichero manda. Y la imagen se arranca sin
1945`-snapshot`, o sea que **lo que se escribe adentro persiste**. De ahí
1946`atuq-en-imagen.py --set-rust-log VALOR`: entra por el serial, escribe `/etc/cosmic-mode` y apaga; lo
1947usa el arranque SIGUIENTE. Con la cadena vacía lo quita, que es como se deja la imagen después de
1948medir — subirle el log a una imagen compartida y olvidárselo es dejar una trampa para el próximo.
1949
1950#### 6.10.undecies **La ventana SÍ está: mide 117×70 px** — y la hipótesis de los frame callbacks era falsa (2026-09-14)
1951
1952Como el compositor no habla por su log (§6.10.decies), se le preguntó por el **protocolo**:
1953`atuq-en-imagen.py --wayland-debug` lanza el navegador con `WAYLAND_DEBUG=1`, que registra cada
1954mensaje en los dos sentidos. Corrida fallida (91 % de azul de escritorio), y esto es lo que MANDA
1955`cosmic-comp`:
1956
1957    xdg_toplevel#58.configure_bounds(0, 0)          ← al mapear: todavía no sabe el tamaño
1958    xdg_toplevel#58.configure(0, 0, array[0])
1959    xdg_toplevel#58.configure_bounds(1280, 692)     ← después sí
1960    xdg_toplevel#58.configure(117, 70, array[16])   ← y la ventana queda de 117×70
1961    wl_surface#52.enter(wl_output#16)
1962    wl_callback#76.done(…)   × 43
1963
1964Y el píxel lo confirma, restando el cuadro de ANTES del de DESPUÉS: aparece un cluster en
1965**(496,115), de unos 286×86 con sombra**, con el blanco `#fafafb` y el acento cian `#63d0df` de la
1966decoración de COSMIC. Una ventanita.
1967
1968⇒ **`atuq` no deja de pintar: pinta una ventana de 117×70 px.** Con eso caen dos cosas que este mismo
1969documento daba por buenas hace dos secciones:
1970
1971| lo que decía | lo que mide el protocolo |
1972|---|---|
1973| «nadie le devuelve frame callbacks» (§6.10.nonies) | **43 `wl_callback.done`**. Los devuelve |
1974| «deja de trabajar a los ~40 s» | deja de **escribir**: con la ventana de 117×70 no hay casi nada que redibujar |
1975| «7 de 10 no tienen ventana» (§6.10.nonies) | **la tienen**. Mi contador de magenta no la ve porque es 0,7 % de la pantalla |
1976
1977**La tasa 2 de 10, entonces, tampoco mide lo que decía.** No es «pintó / no pintó»: es «la ventana
1978salió grande / salió de 117×70». El fenómeno es real y sigue estando —una ventana así es inusable—
1979pero el nombre estaba mal, y con el nombre mal la causa que se busca es otra.
1980
1981**De dónde sale 117×70, y qué es hipótesis.** El primer `configure` llega con `bounds(0,0)`: en ese
1982instante el compositor todavía no le puede decir al cliente de qué tamaño es la pantalla, y el
1983protocolo dice «elegí vos». La medida NO prueba quién eligió 117×70 —para eso hay que ver qué
1984tamaño de buffer commitea el cliente ANTES de ese configure—, pero el orden es compatible con una
1985**carrera**: `atuq` dimensiona su ventana antes de tener la geometría de la salida y cae en un
1986mínimo. En las corridas que salen bien la ventana mide 1073×549 (§6.10.quater), que es lo que se ve
1987cuando la geometría llegó primero. **Eso es lo próximo a medir, y se mide con el mismo arnés.**
1988
1989⚠ **Método, tercera vez en el mismo frente:** el primer vuelco del protocolo filtró por `' -> '`
1990creyendo que era «lo que manda el compositor». En `WAYLAND_DEBUG` la flecha marca los **pedidos del
1991cliente**; los eventos son las líneas SIN flecha. O sea que volví a leer al navegador —la mitad que
1992ya sabíamos que miente— con un filtro nuevo. Y las cuentas dieron cinco ceros porque los nombres
1993llevan `#id` en el medio (`wl_surface#30.commit`), así que `wl_surface.commit` no engancha: **un
1994grep que devuelve 0 puede ser una ausencia o puede ser un patrón mal escrito, y las dos se ven
1995igual.**
1996
1997#### 6.10.duodecies **El 117×70 lo pide el CLIENTE** — el compositor sólo obedece (2026-09-15)
1998
1999El §6.10.undecies dejó abierto quién elige el tamaño, y dijo cómo medirlo: mirar, **en orden y en los
2000dos sentidos**, dónde aparece por primera vez. `create_buffer` trae el tamaño (el `attach` no), y
2001`set_window_geometry` es lo que el cliente DECLARA querer. Numerado, sale así:
2002
2003    590  xdg_toplevel#58.configure_bounds(0, 0)                  ← compositor: «elegí vos»
2004    592  xdg_toplevel#58.configure(0, 0, array[0])
2005    629  → xdg_toplevel#58.set_min_size(117, 37)                 ← EL CLIENTE
2006    630  → xdg_toplevel#58.set_max_size(348, 16332)
2007    631  → xdg_surface#57.set_window_geometry(26, 23, 117, 70)   ← EL CLIENTE pide 117×70
2008    664  xdg_toplevel#58.configure_bounds(1280, 692)
2009    665  xdg_toplevel#58.configure(117, 70, array[16])           ← el compositor ACATA
2010
2011⇒ **`cosmic-comp` no impone nada.** Tiene 1280×692 para dar y devuelve exactamente lo que el cliente
2012pidió. La ventana de 117×70 la elige **`atuq`**.
2013
2014Y el `MOZ_LOG` de la misma corrida dice cómo:
2015
2016    nsWindow::Create()
2017      nsWindow::Create() Initial resize to 1 x 1     ← nace de 1×1
2018      nsWindow::Create() Toplevel
2019    nsWindowWayland::CreateNative()
2020
2021**Gecko crea la ventana de 1×1 y nunca la agranda.** El 117×70 no es un tamaño elegido: es lo que
2022queda cuando el chrome se mide a sí mismo sin que nadie le diga de qué tamaño tiene que ser. Lo
2023confirma el `set_max_size(348, 16332)`: ancho máximo 348 px y alto 16332 — las restricciones de una
2024caja que se dimensiona por su contenido, no las de una ventana de navegador. (Y el buffer que
2025commitea es de 157×110 para una geometría de 117×70: los ~20 px de margen son la sombra del CSD.)
2026
2027⇒ **el frente se mueve de COSMIC a Gecko.** No hay nada que arreglar en el compositor. Lo que falta
2028saber es por qué el `nsWindow` se queda en su tamaño mínimo, y la sospecha —**todavía sin medir**— es
2029que cuando dimensiona no tiene la geometría de la salida: el primer `configure` llega con
2030`bounds(0,0)`, o sea que en ese instante ni el compositor se la puede dar. Se comprueba ordenando en
2031la MISMA lista los eventos `wl_output` contra el `set_window_geometry`; con dos greps separados no se
2032puede, porque no se pueden intercalar.
2033
2034⚠ **Esa sospecha se midió al día siguiente y es FALSA —y además la pregunta estaba mal hecha: esa
2035ventana no era la del navegador (§6.10.terdecies).** Queda escrita porque el orden importa: era una
2036hipótesis razonable, y lo que la desmintió fue preguntarle al `ScreenManager` en vez de seguir
2037deduciendo del protocolo.
2038
2039#### 6.10.terdecies **La ventana de 117×70 es un DIÁLOGO: el navegador nunca abrió** (2026-09-15)
2040
2041Las últimas cuatro secciones discutieron por qué la ventana de `atuq` sale de 117×70. La respuesta es
2042que **no era la ventana de `atuq`**, y lo dice el mismo volcado de protocolo que ya se había leído
2043—dos líneas más abajo de donde paró la lectura—:
2044
2045    556  -> xdg_surface#57.get_toplevel(new id xdg_toplevel#58)
2046    558  -> xdg_toplevel#58.set_title("Open atuq in Troubleshoot Mode?")
2047    559  -> xdg_toplevel#58.set_app_id("atuq")
2048    629  -> xdg_toplevel#58.set_min_size(117, 37)
2049    630  -> xdg_toplevel#58.set_max_size(348, 16332)
2050    631  -> xdg_surface#57.set_window_geometry(26, 23, 117, 70)
2051
2052Es `chrome://browser/content/safeMode.xhtml`: el **diálogo de Modo de resolución de problemas**. Con
2053eso la pregunta cambia de «¿por qué la ventana sale chica?» a «¿por qué `atuq` arranca en modo de
2054resolución de problemas?», que tiene respuesta y tiene arreglo.
2055
2056**La cadena entera, y dónde se lee cada eslabón.** Nada de esto es deducción:
2057
20581. el perfil del usuario traía el contador de caídas alto — `prefs.js` con fecha 15-Sep 00:36, o sea
2059   **antes** de cualquier corrida de ese día:
2060
2061       user_pref("toolkit.startup.recent_crashes", 16);
2062
20632. el umbral, en **nuestro** build: `browser/omni.ja → defaults/preferences/firefox.js:831` dice
2064   `pref("toolkit.startup.max_resumed_crashes", 3)` ⇒ 16 > 3 ⇒ `automaticSafeModeNecessary`;
20653. quién abre el diálogo: `modules/BrowserGlue.sys.mjs:389`, en `_beforeUIStartup()`, cuyo propio
2066   comentario dice *«runs on startup, before the first command line handler is invoked (i.e. before
2067   the first window is opened)»*:
2068
2069       if (Services.appinfo.inSafeMode) {
2070         Services.ww.openWindow(null, "chrome://browser/content/safeMode.xhtml", "_blank",
2071                                "chrome,centerscreen,modal,resizable=no", null);
2072       }
2073
2074   **modal y antes de la primera ventana** ⇒ no hay un navegador detrás esperando: no hay navegador;
20754. hasta el `set_max_size(348, 16332)` sale de ahí: el título del diálogo se traduce con Fluent y la
2076   traducción trae el tamaño — `localization/en-US/browser/safeMode.ftl`,
2077   `troubleshoot-mode-window .style = max-width: 400px`;
20785. y por qué el proceso se MUERE solo (el «`[1]+ Done`» que imprimió el shell del serial): el único camino de
2079   ese diálogo que termina el programa es `onCancel()` de `safeMode.js`, que llama
2080   `appStartup.quit(appStartup.eForceQuit)`. El log trae `ATUQ-EXIT=0`: se va solo, sin caerse. Quién
2081   cancela el diálogo —Escape, el compositor, el cierre de la ventana— **no está medido**.
2082
2083**Cómo se leyó el perfil sin arrancar la imagen**, que es la única forma honesta de mirar el estado
2084*previo* —arrancarla lo cambia—: el disco es un raw con tabla de particiones y `debugfs` lee la
2085partición sin montarla y sin root:
2086
2087    debugfs -R "dump /root/.config/mozilla/firefox/<id>.default-default/prefs.js /tmp/prefs.js" \
2088      "/mnt/cosecha/takana-cosmic-qemu.img?offset=$((264192*512))"
2089
2090⚠ **Y esa lectura MIENTE si la VM se acaba de matar, con una forma peligrosa** (medido el 2026-09-15
2091por la otra sesión del frente): el ext4 del guest queda con el journal sucio, `debugfs` se planta con
2092«Block bitmap checksum does not match» y con `-c` abre pero lee el estado **PRE-journal** — ahí
2093`prefs.js` figura de **0 bytes** con la fecha de la corrida anterior. Un `prefs.js` vacío leído así se
2094ve idéntico a «el navegador borró el perfil», que es un diagnóstico mucho más grave y falso. La
2095lectura buena es **desde adentro**, después de que el mount replaye el journal: para eso están los
2096campos `recent_crashes_antes` / `_despues` del arnés. `debugfs` sirve para mirar el estado *previo*
2097con la imagen apagada, no para leer lo que dejó un kill.
2098
2099##### La sospecha del §6.10.duodecies era falsa: Gecko SÍ ve la pantalla, 1280×800
2100
2101Se midió agregándole `WidgetScreen:5` al `MOZ_LOG` —el módulo que registra qué monitores arma el
2102`ScreenManager`; que el módulo EXISTE en el `libxul` sellado se comprobó con `grep` antes de confiar
2103en la variable, porque un módulo que no está se ve igual que un módulo que no tiene nada que decir—.
2104La respuesta llega con hora:
2105
2106    17:12:29.031  D/WidgetScreen  New monitor 0 size [0,0 -> 1280 x 800] depth 24 scale 1.0 … refresh 75
2107    17:12:29.031  D/WidgetScreen    workarea [0, 0] -> [1280 x 800]
2108    17:12:31.377  D/WidgetScreen  (otra vez, ya en el proceso Parent)
2109    17:12:34.843  D/Widget        nsWindow::Create() Initial resize to 1 x 1     ← nace de 1×1
2110    17:12:35.290  D/WidgetScreen  GetScreenForWindow() [0] screen (x=0, y=0, w=1280, h=800)
2111
2112⇒ la pantalla está completa y a tiempo, **cinco segundos antes** de que la ventana se cree, y la
2113propia ventana la resuelve bien cuando pregunta. La carrera con `wl_output` queda **refutada** y el
2114`configure_bounds(0, 0)` del principio es ruido del protocolo, no información que a Gecko le falte.
2115Que el 117×70 resultara ser un diálogo con `max-width` no le quita valor: era la hipótesis viva y
2116hacía falta matarla con un instrumento.
2117
2118##### El control, en los DOS sentidos — y es el contador
2119
2120Dos corridas seguidas sobre la misma imagen, el mismo artefacto y el **mismo perfil**, cambiando una
2121sola cosa: `toolkit.startup.recent_crashes`, fijado en el `user.js` del perfil (que gana sobre
2122`prefs.js` en cada arranque). El mando es `atuq-en-imagen.py --crashes N`:
2123
2124| corrida | contador | qué pasó |
2125|---|---|---|
2126| **A** 17:31 | ausente (0) | ✓ **primera pintura +123 s**, 704 458 px de la página magenta |
2127| **B** 17:44 | fijado en **16** | ✗ nada en 300 s · `set_title("Open atuq in Troubleshoot Mode?")` · `ATUQ-EXIT=0` |
2128
2129⚠ **Y una honestidad que cambia la fuerza del argumento:** en la corrida A el `--crashes 0` fue un
2130**no-op** —el contador ya no estaba en `prefs.js`, y **por qué no estaba sigue sin explicación
2131medida** (ver más abajo: mostrar el diálogo NO lo limpia)—, así que A sola no prueba nada: prueba
2132tanto «lo arregló el 0» como «el perfil ya estaba limpio». **La que prueba la causa es B**, que lo pone de vuelta y rompe de vuelta. Una sola dirección
2133habría sido una correlación con suerte.
2134
2135##### ⚠⚠ La serie de `primera-pintura.json` está CONTAMINADA, y por el propio andamiaje
2136
2137El arnés mata la VM con el navegador vivo. Un arranque que no llega a terminar es, para Gecko, una
2138caída de arranque; pasados 3 el sujeto de la medición **deja de ser el navegador y pasa a ser el
2139diálogo**. Con 16 acumulados, todo lo medido desde el 14-Sep cae bajo sospecha: las tasas «3 de 13»
2140del §6.10.septies y «2 de 10» del §6.10.nonies no midieron intermitencia del producto sino **un
2141perfil que se degradaba corrida a corrida**. El instrumento degradaba al sujeto.
2142
2143**Y eso quedó MEDIDO el mismo día** (tres fases de 60 s en un mismo arranque, cada una matada con
2144`kill -9` **antes** de pintar — o sea antes del gancho que cierra la detección de caída de arranque):
2145
2146| fase | contador antes | después | qué la precedió |
2147|---|---|---|---|
2148| k1 | ausente | **ausente** | una corrida SANA, que terminó bien |
2149| k2 | ausente | **1** | k1, matada antes de pintar |
2150| k3 | 1 | **2** | k2, matada igual |
2151
2152Es un **tally**: uno por arranque interrumpido, sin techo. Y trae dos cosas que la serie sola no
2153distinguía:
2154
2155- **el incremento lo escribe el arranque SIGUIENTE, no el kill.** Se ve en que el «después» de k1
2156  sigue ausente —el navegador estaba vivo todavía— y el 1 aparece recién en el «después» de k2.
2157  Quien cuenta no es el que muere sino el que arranca y encuentra el anterior sin terminar; de ahí la
2158  consistencia interna de la serie, `antes(k3) = después(k2) = 1`;
2159- **un arranque que sigue a una corrida sana no suma** (k1). Eso separa «cualquier arranque
2160  incrementa» de «sólo después de uno interrumpido», que es la diferencia entre culpar al kill y
2161  culpar al arranque que no llegó a terminar.
2162
2163##### El borde, medido en los dos lados: **3 guardado no alcanza, 4 sí**
2164
2165Partiendo del perfil en 2, tres fases más en un arranque (las dos primeras matadas antes de pintar):
2166
2167| fase | antes | después | qué ventana abrió | `ATUQ-EXIT` |
2168|---|---|---|---|---|
2169| k1 | 2 | 3 | **el navegador** — `set_title("atuq")`, `set_min_size(638, 120)` | 137 (la matamos) |
2170| k2 | 3 | **4** | **el diálogo** — `set_title("Open atuq in Troubleshoot Mode?")`, `min_size(117, 37)` | 0 |
2171| k3 | 4 | **5** | el diálogo otra vez | 0 |
2172
2173⇒ **la comparación es DESPUÉS de sumar**: el arranque que encuentra 3 guardado lo sube a 4, compara
21744 > 3 y abre el diálogo; el que encuentra 2 pinta. Contando desde la última corrida que terminó bien,
2175eso son **cuatro arranques interrumpidos seguidos, y el quinto lanzamiento ya no abre el navegador**.
2176
2177Y la otra mitad del cálculo lo confirma sin ambigüedad: `toolkit.startup.last_success` vale
2178**1789494795 en las tres fases** —congelado en el arranque de la última corrida sana—. El contador
2179sube mientras `last_success` no se mueve; con las dos cifras juntas, un contador que no cambió deja
2180de ser ambiguo entre «no subió» y «subió y se limpió».
2181
2182⚠⚠ **Y eso REFUTA algo que este documento afirmaba dos secciones más arriba: mostrar el diálogo NO
2183limpia el contador.** k2 lo dejó en 4 y k3 en 5 — dos corridas consecutivas con diálogo y ninguna
2184limpieza. Entonces la desaparición del 16 entre las 17:12 y las 17:20 **no tiene explicación
2185medida**, y la atribución «Gecko lo borró al mostrarlo» se retira. Dos candidatas, las dos sin
2186comprobar: que el `prefs.js` se perdiera en el kill (cuando se lo leyó con `debugfs` figuraba de 0
2187bytes, y ese volcado no es fiable — es la lección del párrafo de `debugfs`), o que un arranque que
2188TERMINA sí lo limpie, que es lo que hace upstream.
2189
2190⚠ Hubo una tercera candidata, mía, y **está descartada con un cero que viene con control**: «el
2191`--crashes 0` fija el valor por DEFECTO y un pref igual al default no se persiste, así que el
2192“ausente” prueba el pin y no una limpieza». No: `toolkit.startup.recent_crashes` **no tiene default
2193en este build**, y por eso un 0 puesto a mano sí se escribe. Tres lugares del artefacto sellado lo
2194dicen y el cuarto es el que convierte a los otros en evidencia:
2195
2196    greprefs.js (omni de toolkit)                       → 0 líneas con «toolkit.startup»
2197    browser/omni.ja defaults/preferences/*.js           → 0 líneas con «recent_crashes»
2198    libxul.so, strings «sMirror_toolkit_startup*»       → 0     ⇒ no es pref de StaticPrefList
2199    libxul.so, strings «sMirror_browser_crashReporter*» → 2     ⇐ EL CONTROL, que TENÍA que dar positivo
2200
2201Los dos primeros ceros no valen solos, y el primero es el ejemplo de por qué: `greprefs.js` da cero
2202porque ahí no vive **ningún** `toolkit.startup.*`, no porque el pref no exista. Un `grep` que
2203devuelve 0 puede ser una ausencia o un sitio equivocado, y las dos se ven igual; lo único que las
2204separa es un patrón hermano que tenga que dar positivo en el mismo fichero. (Y una distinción que se
2205parece a otra y no lo es: el binario lee ese pref con un **0 de respaldo en el sitio del `GetInt`**,
2206que no es un default registrado y no evita que el valor se escriba.)
2207
2208##### La serie sana — y lo que refuta: **pintar no es TERMINAR**
2209
2210Cuatro arranques FRÍOS en serie, el perfil partiendo del 5 que dejó la corrida del umbral, con
2211`--until-paint` salvo la tercera (matada a los 60 s a propósito):
2212
2213| corrida | pin | antes | después | ventana | primera pintura |
2214|---|---|---|---|---|---|
2215| **S1** 18:27 | `--crashes 0` | 5 | 1 | el navegador | ✓ **+97 s** |
2216| **S2** 18:33 | — | 1 | 2 | el navegador | ✓ **+98 s** |
2217| **S3** 18:38 | — | 2 | 3 | (matada a los 60 s) | — |
2218| **S4** 18:50 | — | 3 | 4 | **el diálogo** (`min_size(117, 37)`, título Troubleshoot) | ✗ en 420 s |
2219
2220Tres cosas, y la tercera no la esperaba nadie:
2221
22221. **el número de la tasa, por fin con perfil sano: +97 s y +98 s**, dos corridas independientes y
2223   pegadas —contra los +123, +195, +197 y +204 de antes—. Con el anfitrión más descargado el mismo
2224   artefacto pinta en la mitad de tiempo, o sea que el número de esta serie es de **la máquina y la
2225   carga tanto como del producto**: se publica con sus condiciones o no se publica;
22262. **el umbral se reprodujo solo, en otra secuencia y sin buscarlo**: S4 arrancó con 3 guardado,
2227   sumó a 4 y abrió el diálogo. Es la segunda medición independiente del mismo borde, y esta vez la
2228   ventana se identificó por `min_size` y no por el título;
22293. ⚠⚠ **y una corrida que PINTA no limpia el contador.** S2 pintó y dejó el 2; S1 pintó y dejó 1.
2230   Más todavía: `toolkit.startup.last_success` vale **1789494795 en las cuatro corridas** —la marca
2231   del arranque de las 17:53— o sea que **el gancho de cierre no llegó a correr ni una sola vez**,
2232   incluso en las que pintaron. Pintar no es terminar el arranque.
2233
2234⇒ la pregunta de quién limpia el contador **no la cierra esta serie** —el §6.10.terdecies decía que
2235sí—, y lo que hacía falta era una corrida que dejara al navegador **seguir vivo** después de pintar.
2236
2237##### Lo que limpia el contador: **sobrevivir un rato después de pintar** (2026-09-15)
2238
2239El par aísla la variable: mismo pin, mismo perfil, misma imagen, y la única diferencia es cuánto vive
2240el navegador después del primer cuadro.
2241
2242| corrida | pin | antes | pintó | vida después de pintar | después |
2243|---|---|---|---|---|---|
2244| S1 18:27 | `--crashes 0` | 5 | +97 s | corta — `--until-paint` corta la observación | **1** |
2245| cierre-1 19:06 | `--crashes 0` | 4 | +120 s | **~360 s** (ventana de 480 s, sin `--until-paint`) | **ausente** |
2246
2247⇒ **pintar no alcanza; sobrevivir un rato después de pintar, sí.** Y explica A sin anomalía: A
2248también siguió viva minutos después del primer cuadro, porque los volcados por el serial tardan.
2249
2250**Y el sello viaja con la limpieza: es UN gancho, no dos.** El «antes» de la corrida siguiente trae
2251`last_success = 1789498722 = 18:58:42 UTC`, que es la hora de **arranque** de `cierre-1`: el mismo
2252arranque que limpió el contador escribió el sello. (Ese volcado sólo grepeaba el contador y se
2253resolvió en el instrumento en vez de dejarlo como nota: `last_success` va ahora en los DOS volcados y
2254se guarda en la medición, `last_success_antes` / `_despues`.)
2255
2256##### ⚠ El reloj es el del LANZAMIENTO, no el de la pintura — y el piso que sale de ahí
2257
2258Leyendo el perfil en CADA captura (`--watch-prefs`), la traza de una corrida arrancada a las
225919:12:17:
2260
2261| captura | `last_success` en disco | `recent_crashes` |
2262|---|---|---|
2263| +30 s · +64 s · +100 s | 18:58:42 (el de la corrida anterior) | 0 |
2264| **+137 s** ██ primera pintura | **19:12:17 (el suyo)** | **ausente** |
2265| +167…+433 s | 19:12:17 | ausente |
2266
2267⇒ el sello llega al disco **entre +100 s y +137 s desde el lanzamiento**. Y S1 —que parecía la
2268excepción— lo **acota por abajo de forma independiente**: sus propios ficheros dicen que vivió
2269**100 s** desde el lanzamiento (primer cuadro a +97 s, conversión a PNG tres segundos después) y no
2270selló. Dos corridas distintas, el mismo intervalo.
2271
2272**Si el reloj fuera el de la pintura, S1 tendría que haber sellado** —pintó a +97 s y siguió unos
2273segundos— y no lo hizo. Con el reloj del lanzamiento encaja todo sin excepciones: S1 pintó pronto y
2274murió a los 100 s; `cierre-1` pintó a +120 y vivió 480; A pintó a +195 y vivió los minutos de los
2275volcados; la de arriba selló a los ~+120 pintara cuando pintara.
2276
2277⚠ **Y la consecuencia es contraintuitiva, y es la que muerde: cuanto MÁS RÁPIDO pinta, más fácil es
2278envenenar el perfil**, porque `--until-paint` corta antes. La serie que se rompió a la cuarta se
2279rompió por eso — no por ser larga, por ser rápida. La regla está en el arnés y no en el runbook:
2280`PISO_SELLO = 180` (margen sobre la cota alta de 137), `--until-paint` no corta antes de ese piso y
2281dice por qué sigue esperando. El comentario lleva los dos números medidos al lado, porque **un piso
2282que se lee como elegido lo baja el próximo que lo vea**.
2283
2284⚠ **Y el piso se COMPRUEBA, no se confía.** Los 180 s son de ESTA máquina —TCG sin KVM y con la carga
2285que tenga el anfitrión, igual que los +97/+120/+137 de la pintura—, así que con el anfitrión cargado
2286el mismo arranque puede no llegar al gancho: la corrida sumaría al contador y envenenaría a la
2287siguiente **en silencio**, que es exactamente como empezó todo esto. Como el sello se lee antes y
2288después, el arnés lo dice en la cara:
2289
2290    ✓ el arranque SELLÓ el éxito (last_success 18:58:42 → 19:12:17): esta corrida no envenena la siguiente
2291    ⚠⚠ el sello NO se movió: este arranque no terminó ⇒ la corrida SUMA al contador. Subí PISO_SELLO
2292
2293Un piso que falla callado es un piso que no existe; éste avisa cuando no alcanzó.
2294
2295⚠ **Y el 16 sigue sin explicación, con una contradicción medida encima**: desapareció en una corrida
2296que fue diálogo + `ATUQ-EXIT=0`, y las fases k2/k3 del umbral fueron diálogo + `ATUQ-EXIT=0` también
2297y no limpiaron nada (quedaron en 4 y 5). Mismo par de condiciones, resultado opuesto ⇒ «el diálogo
2298que se cierra limpio limpia el contador» no se sostiene. Conviene no dejar que la respuesta buena
2299—la de la tabla de arriba— tape esta, que es de otra pregunta.
2300
2301**Y la consecuencia para el instrumento es lo más caro de la serie:** con `--until-paint`, **cada
2302corrida suma uno**, pinte o no pinte. Cuatro corridas envenenan cualquier perfil, así que una serie
2303larga se rompe sola a la cuarta — que es exactamente lo que había pasado ayer sin que nadie lo viera.
2304Dos formas de convivir con eso, las dos escritas en el arnés: **pinnear `--crashes 0` en cada corrida
2305de una serie** (S1 muestra que el pin funciona incluso partiendo de 5: resetea la base y el
2306incremento del arranque la deja en 1, lejos del borde), o dejar que el navegador llegue al gancho de
2307cierre, que cuesta más tiempo de VM del que cuesta medir la pintura. Y mientras tanto el arnés
2308**avisa** cuando el contador está en 3: la corrida siguiente va a abrir el diálogo y no el navegador.
2309
2310**Un discriminador que salió gratis y sirve para siempre:** el `ATUQ-EXIT` de una fase que matamos es
2311**137** (128+9) y el de una corrida que se fue sola por el diálogo es **0**. El código de salida
2312distingue «la mataron» de «se fue sola» sin mirar nada más — que es justo lo que el §6.10.terdecies
2313no podía distinguir de una captura.
2314
2315Qué se hizo con eso, en vez de borrar el fichero:
2316
2317- **el contador viaja con cada medición**: `recent_crashes_antes`, `recent_crashes_despues` y
2318  `recent_crashes_fijado` son campos de cada corrida de `docs/state/primera-pintura.json`. Antes y
2319  después porque el número cambia DENTRO de la corrida —lo escribe el arranque, no el kill— y porque
2320  sin el «después» no se puede encadenar una corrida con la siguiente, que es lo que convirtió esto
2321  en una serie legible;
2322- **el vigía las APARTA y lo dice**: `scripts/vigia-imagen.py` saca de la tasa las corridas con el
2323  contador > 3 y, para las 21 anteriores al campo, informa que **no se pueden clasificar** en vez de
2324  contarlas como buenas. Un denominador que se marca, no que se borra.
2325
2326##### Tres arreglos de instrumento que salieron de la misma corrida
2327
2328- **el código de salida se escribe**: el lanzamiento va en un subshell que agrega `ATUQ-EXIT=$?` al
2329  log. En la corrida de las 17:12 el navegador terminó a los ~25 s y el arnés siguió 420 s
2330  fotografiando un escritorio sin navegador para concluir «no pintó en 420 s» — cierto y sin ningún
2331  significado. «Se cayó» y «se fue solo» se ven igual en una captura y son frentes distintos;
2332- **se lee el log ENTERO**, no `tail -25`: con `WAYLAND_DEBUG=1` esas 25 líneas son protocolo, y los
2333  errores de JS del chrome nunca se habían leído. Ahora hay `head` y un `grep` de
2334  `JavaScript error|Uncaught|NS_ERROR|###!!!`; y como el disco de la VM es persistente, el log del
2335  arranque anterior se copia a `/var/log/cosmic-anterior/` antes de pisarlo;
2336- **el perfil se BUSCA y se imprime**: el `find` miraba sólo `/root` a 4 niveles y el perfil está a
2337  cinco (`/root/.config/mozilla/firefox/<id>.default-default`), así que la sonda salía muda con un
2338  aviso de una línea. Ahora va `find / -xdev -maxdepth 7` e imprime `$HOME`: si el perfil no está
2339  donde se supone, lo que hace falta es saber dónde está.
2340
2341Y la lista ordenada del protocolo tenía su propio agujero: **se ahogaba en los ~60 modos que anuncia
2342QEMU** (`wl_output.mode(0, …)`), así que el `head -70` cortaba antes del `set_window_geometry` —la
2343lista que existía para ordenar dos cosas se quedaba sin una de las dos—. Ahora excluye los modos no
2344actuales y trae `set_title`/`set_app_id`, que es lo que identificó la ventana.
2345
2346##### ⚠ Dos agentes, una imagen — y lo que nos salvó fue el lock de QEMU
2347
2348Mientras se medía esto, **dos sesiones distintas** corrieron `atuq-en-imagen.py` sobre el mismo
2349`takana-cosmic-qemu.img`. La segunda murió en el arranque:
2350
2351    qemu-system-x86_64: -drive file=…/takana-cosmic-qemu.img,format=raw,if=virtio:
2352      Failed to get "write" lock. Is another process using the image?
2353
2354Dos VMs escribiendo el mismo raw es corrupción silenciosa del sistema de ficheros del guest, y la
2355imagen **no** se abre con `snapshot=on` a propósito (las corridas se acumulan: de ahí salen los logs
2356del arranque anterior). Que el candado exista es de QEMU, no nuestro — el mismo patrón del ADR 0012
2357con el árbol de fuentes, pero con la mitigación ya puesta. Dos consecuencias:
2358
2359- **la imagen es de a uno, y quien la usa lo dice**;
2360- el arnés ahora **mira si QEMU murió** en el primer segundo y muestra `qemu.log`. Sin eso, el fallo
2361  se veía como diez minutos esperando una marca del serial que no iba a llegar nunca: el socket queda
2362  creado, el `connect()` funciona y no falla nada.
2363
2364##### Lo que queda, y una decisión de producto
2365
23661. **decidir si `atuq` debe traer el modo automático apagado.** `toolkit.startup.max_resumed_crashes
2367   = -1` lo desactiva. A favor: en una distro que se entrega, un diálogo modal en inglés sin
2368   navegador detrás es un arranque roto para el usuario, y el diálogo aparece por caídas que pueden
2369   no ser suyas. En contra: es una red de seguridad de upstream, y apagarla esconde caídas de
2370   arranque reales. **Es una decisión, no un arreglo que se mete de paso** — toca `recipes/atuq/` y
2371   por lo tanto re-sella el artefacto (~340 M de store y todas las sondas a repetir);
23722. **rehacer la serie de la tasa con perfil sano**: hoy hay UNA corrida limpia (+123 s) y el resto
2373   está marcado. «Cuánto tarda en pintar» vuelve a estar sin medir, y ahora se puede medir bien;
23743. ~~¿respeta el `sizemode=maximized` que el perfil recuerda?~~ **CONTESTADA el mismo día: sí.** Con
2375   perfil sano el navegador pintó a +195 s y el protocolo trae las dos cifras, cada una en su papel:
2376   `set_window_geometry(26, 23, 1332, 852)` —el tamaño normal que restaura del `xulstore`— y
2377   `configure(1280, 692)` acatado, que es el área de trabajo por el maximizado. Ninguna es 1152×720
2378   ⇒ `browser-init.js` no calculó nada, que es lo correcto en un perfil que ya recuerda.
2379
2380   **De ahí sale el discriminador barato entre las dos ventanas**, y vale para cualquier corrida
2381   futura: el navegador pide `set_min_size(638, 120)` —después (690, 172)— y
2382   `set_max_size(16332, 16332)`; el diálogo pide `set_min_size(117, 37)` y `set_max_size(348, 16332)`.
2383   Con mirar el `min_size` se sabe cuál abrió, **y hay que mirar ése y no el título**: `set_title`
2384   también las separa pero está TRADUCIDO, así que en una imagen en castellano el `grep` por
2385   «Troubleshoot Mode» deja de enganchar y su ausencia se lee como «no salió el diálogo» — la
2386   conclusión contraria a la verdadera. El `min_size` es la misma pregunta hecha a un número. (Y el
2387   `348` del diálogo sale de su propio Fluent: `troubleshoot-mode-window .style = max-width: 400px`.
2388   Una cadena de localización decidiendo geometría, que es por qué el 117×70 no se parecía al tamaño
2389   de nada.)
2390
2391   Dos cosas más de esa corrida: el `xulstore` quedó igual ⇒ **una corrida sana no ensucia lo que el
2392   perfil recuerda**; y `recent_crashes_despues` salió **ausente**, que en su momento se leyó como
2393   «la corrida que pinta limpia el contador» y **hay que no leerlo así** — esa corrida llevaba
2394   `--crashes 0` pinneado, o sea el valor por DEFECTO, y un pref igual al default no se persiste. El
2395   «ausente» puede ser eso y no una limpieza.
2396
2397### 6.11 Y ahora la pregunta que faltaba: ¿REPRODUCE? (2026-09-07)
2398
2399El §6.10 preguntó «¿qué NO puede hacer?» y contestó leyendo cadenas. Media pregunta. La otra mitad
2400—**¿se ve el vídeo?**— no la contesta ningún auditor de ELF, y al hacerla aparecieron **dos errores
2401del mapa y un bug real del navegador**.
2402
2403**Error 1: «no hay receta de ffmpeg».** La hay. `recipes/ffmpeg.toml`, 7.1, sellada, publica
2404`libavcodec.so.61` —uno de los once sonames que `libxul.so` sondea por `dlopen` literal— y **ya
2405viajaba en la clausura de los cuatro escritorios**, arrastrada por `mpv`. Comprobado con
2406`yupana.membresia`, que es la vista honesta por par `(cola, nombre)`, no leyendo el TOML:
2407
2408    escritorio-kde 308 nodos · escritorio-gnome 192 · escritorio-sway 203 · escritorio-cosmic 163
2409    ffmpeg ✓ en los cuatro · libva ✓ en los cuatro
2410
2411Lo que faltaba no era la receta: era **la lista de raíces de `scripts/atuq-nested.sh`**, escrita a
2412mano, que hidrataba un rootfs más pobre que la imagen. Otra vez el instrumento y no el artefacto —
2413la misma forma que `gcc-libs` el día anterior. Y la comprobación previa que sí se pudo hacer sin
2414correr nada: de los 82 símbolos con forma `av*_` que `libxul` dlsimboliza, **los 9 que nuestro
2415`libavcodec` no exporta son los de la API vieja** (`avcodec_decode_video2`, `av_lockmgr_register`,
2416`avcodec_register_all`…), que Firefox sólo pide para las versiones 53-57.
2417
2418**Error 2: «VA-API, sin receta».** También la hay, y también está en las cuatro imágenes. El hueco
2419es real pero por la OTRA mitad: las tres recetas de mesa construyen con `-Dgallium-va=disabled` y
2420`-Dvideo-codecs=` vacío ⇒ **no existe un solo `*_drv_video.so`**. Un cargador sin ICD no acelera
2421nada, exactamente como `vulkan-loader`. ⚠ Y con una trampa nueva: ahora que `libva` está en el
2422rootfs, **el mapa de `--dlopen` ya no la reporta**, porque mide presencia de fichero. La librería
2423presente SILENCIA el aviso sin encender la función. Queda escrito acá porque el guardián ya no
2424puede decirlo.
2425
2426**El bug real: AV1 no reproducía.** Gecko **se come el primer elemento de `LD_LIBRARY_PATH`** al
2427lanzar el proceso RDD, el que decodifica vídeo. El lanzador ponía `/usr/lib/atuq` una sola vez —o
2428sea, primero—, así que el RDD arrancaba sin él, no encontraba el ffvpx bundleado (donde vive dav1d)
2429y anotaba `FFVPX: Link result: NoProvidedLib`. Y ahí **no falla**: se cae al ffmpeg del sistema, que
2430cubre H.264/AAC/VP8/VP9/MP3/FLAC/Opus… pero **no AV1 por software**, porque el decodificador `av1`
2431de ffmpeg es sólo hwaccel y nuestra receta va sin dav1d. Resultado: un vídeo AV1 se quedaba en
2432`readyState=1` para siempre, sin un error, sin un NEEDED faltante y sin una cadena ausente.
2433
2434Tres corridas del MISMO artefacto que sólo cambian esa variable:
2435
2436    LD=/usr/lib/atuq:/usr/lib:/lib             → RDD NoProvidedLib   AV1 ✗
2437    LD=/usr/lib/atuq:/usr/lib/atuq:/usr/lib    → RDD Success         AV1 ✓
2438    LD=/relleno:/usr/lib/atuq:/usr/lib         → RDD Success         AV1 ✓   (el relleno se lo come)
2439
2440El arreglo es repetir el appdir en `recipes/atuq/bin/atuq`. Feo y correcto mientras no haya
2441`patchelf` en el corpus para grabar `RUNPATH=$ORIGIN`, que borraría la variable entera.
2442
2443**Lo que quedó medido**, con `scripts/test-atuq-codecs.sh`, headless, por el lanzador de verdad y
2444sin `LD_LIBRARY_PATH` de afuera:
2445
2446| Muestra | Veredicto |
2447|---|---|
2448| H.264 + AAC (mp4) | ✅ `t=3.00`, 75 cuadros |
2449| VP9 + Opus (webm) | ✅ `t=3.00` |
2450| AV1 (mp4) | ✅ `t=3.00` — sólo desde el arreglo del lanzador |
2451| MP3 | ✅ |
2452| FLAC | ✅ |
2453
2454El veredicto **no es «se creó el decodificador»**: es que `currentTime` AVANCE. La distinción no es
2455retórica — en el caso de AV1 el decodificador se creaba (`RemoteDecoderChild … codec: av1`) y no
2456entregaba un solo cuadro. El guardián corre con `--negative-control`, que saltea el lanzador y
2457**exige que AV1 falle**: probado en los dos sentidos, porque uno que no puede fallar y uno que nunca
2458falló se ven igual desde afuera.
2459
2460**Dos bugs de andamiaje que salieron de paso**, los dos en `atuq-nested.sh` y los dos escondidos
2461detrás de una caché: el bucle de hidratación salía 1 siempre (línea vacía final + `set -e`) y mataba
2462el script sin imprimir nada, y la comparación del sello nunca daba igual (un `\n` que `$(cat …)`
2463recorta de un lado y no del otro), así que el rootfs se rehidrataba entero en cada corrida. El
2464primero sólo se veía cuando cambiaban los hashes; lo destapó agregar `ffmpeg` a las raíces.
2465
2466### 4.g El chrome se programa SIN abrir `omni.ja` — y la vista dividida ya la trae el motor (2026-09-09)
2467
2468El pendiente escrito era «el chrome de verdad dentro de `omni.ja` (split view, dientes propios): el
2469re-empaque ya está resuelto, lo que falta es el contenido». Las dos mitades de esa frase eran falsas,
2470y las dos se midieron con `scripts/test-atuq-chrome.py` sobre el artefacto vigente:
2471
2472    VIVO el JS de atuq.cfg corre
2473    VENTANA con gBrowser
2474    PREF splitView.enabled=true
2475    WRAPPER tabs=2
2476    ACTIVA si
2477    BROWSERS 2
2478
2479**1. El zip no es el gancho del chrome; `atuq.cfg` sí.** Ese fichero ya corre con privilegios —de ahí
2480sale `nsIStyleSheetService`, y es la razón de `sandbox_enabled = false`—, así que desde ahí se observa
2481`browser-delayed-startup-finished` y se toca el `gBrowser` de CADA ventana que abra el navegador. La
2482sonda abre dos pestañas y las parte en dos desde `atuq.cfg`, sin tocar `omni.ja`.
2483
2484Y no es un atajo: es la vía CORRECTA, porque re-empacar `omni.ja` es exactamente lo que pelea con el
2485orden del `jarlog` del PGO —el motivo por el que el `jarlog` está aparcado (`c3413d29`)—. Dicho al
2486revés: **cada función del chrome que no entre al zip es una que no compite con la optimización de
2487arranque.** Eso cambia el orden de preferencia para todo lo que venga: primero prefs, después
2488extensión de sistema, después JS de chrome por autoconfig, y `omni.ja` sólo si no queda otra.
2489
2490**2. La vista dividida no hay que escribirla: Firefox 154 la trae y viene PRENDIDA.** En
2491`browser/omni.ja` están `tabbrowser/tabsplitview.js` (492 líneas, el custom element completo con
2492`addTabs`/`unsplitTabs`/`reverseTabs`), `opentabs-splitview.mjs` y `split-view-footer.js`; y
2493`defaults/preferences/firefox.js` trae `pref("browser.tabs.splitView.enabled", true)`. La sonda no se
2494queda en «el fichero está»: llama a `gBrowser.addTabSplitView([t1, t2])` y le pregunta al motor, que
2495contesta `activeSplitView` puesta y `splitViewBrowsers.length == 2`. **Funciona en nuestro build.**
2496
2497Es el mismo patrón que ya pagó dos veces en este SDD —los dientes (`sidebar.verticalTabs`), los
2498contenedores (`privacy.userContext.enabled`)—: la función existe en el motor y viene apagada o sin
2499usar, y el trabajo es prenderla y comprobarla, no escribirla. La forma de equivocarse es al revés:
2500declarar como deuda propia algo que upstream ya nos dio.
2501
2502⚠ **Lo que esto NO dice.** atuq todavía **no lleva JS de chrome propio**: lo medido es el GANCHO, con
2503la sonda inyectada por una capa de overlay que no viaja en el artefacto. Cuando haya una función que
2504lo justifique, va en `recipes/atuq/chrome/atuq.js` y la carga `atuq.cfg`. No se shipea un cargador
2505vacío para «tenerlo listo».
2506
2507El control negativo sale del propio motor y no de una idea nuestra: `addTabSplitView` documenta que
2508si TODAS las pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control` las fija
2509y exige justamente eso — sin él, una sonda que dijera «partió» pase lo que pase se vería idéntica a
2510una que funciona.
2511
2512### 2.ter Dónde vive la fuente de `atuq` — hizo falta un modo de `[source]` nuevo
2513
2514Escribir la receta destapó un muro que este documento no había visto: `[source]` era
2515**obligatoriamente** git o tarball, así que una receta cuyo contenido no viene de upstream sino de
2516nosotros no se podía ni expresar. Los rodeos eran todos peores — fetchear una fuente que después se
2517ignora **miente** sobre la identidad del artefacto, y colgar el overlay de un repo aparte obliga al
2518worker a tener acceso a un repo privado.
2519
2520Se agregó **`source.dir`** (commit `54fa4a9`): un árbol dentro del propio repo, hasheado por
2521**contenido** con `ArtifactHash::of_tree` —que ya existía para verificar bit-reproducibilidad en
2522Stage 2— y no por ruta. Es la misma disciplina que ya tenían los `patches`, que entran al hash por
2523bytes. Editar un CSS del overlay mueve el `ArtifactHash`; renombrar el directorio, no.
2524
2525Un `.swm` **no** puede llevar una receta derivada, y los cuatro sitios que lo tocan fallan
2526diciéndolo: el manifiesto es compartible y necesita un puntero resoluble desde fuera.
2527
2528**Hecho el 2026-09-05** (commits `aeebb19` y `6f6c32b`): `recipes/atuq.toml` + `recipes/atuq/`.
2529La **v0.2** (`b3:7eff4ba4`) ya trae el branding: `application.ini` (Vendor/Name/RemotingName/
2530CodeName, con el `ID` intacto para no salirse del ecosistema de complementos), `brand.ftl` dentro de
2531`browser/omni.ja`, los binarios renombrados, iconos propios y `.desktop`. El re-empaque preserva
2532orden, `STORED` y fechas del original, y se verificó contra el artefacto: **5306 entradas, mismo
2533orden, y una sola con contenido distinto**. El `BuildID` se deriva del contenido del overlay —no de
2534la fecha— porque es la clave con la que Gecko invalida su startup cache: sin eso, un cambio de
2535chrome arranca con la interfaz vieja y *parece* que el overlay no agarró.
2536
2537La capa de configuración de la v0.1 son cuatro ficheros —autoconfig, `atuq.cfg`, `chrome/atuq.css`,
2538`policies.json`— y el CSS se registra como `USER_SHEET` desde autoconfig porque `userChrome.css`
2539vive en el perfil y exigiría que el usuario prenda una pref: atuq tiene que verse como atuq en el
2540primer arranque. Falta construirlo: su dep `firefox` está en vuelo.
2541
2542### 2.quater Abrirlo en una pantalla: tres fallos que la clausura no podía ver
2543
2544El 2026-09-05, con `atuq` sellado y verificado, se hidrató y se abrió en el compositor de la sesión
2545(waypipe). **No arrancó**, y los tres motivos son la diferencia entre «sellado» y «usable»:
2546
25471. **EXDEV al hidratar.** `hydrate` proyecta con hardlinks y `linkat()` rechaza cruzar un punto de
2548   montaje aunque los dos lados sean el mismo filesystem. Acá el store es `/dev/sdb` bind-monteado
2549   y `work/` vive en `/dev/sdc`. El rootfs va bajo `/mnt/cosecha`, donde el volumen está montado
2550   entero. Se comprueba con `findmnt -T`, **nunca** con `stat -c %d`.
25512. **El lanzador no puede ser un symlink.** Ni el binario ni sus `.so` traen `RPATH`/`RUNPATH`
2552   (`readelf -d`), así que el motor no encontraba su propio `libnspr4.so` y moría en
2553   `XPCOMGlueLoad`. Es un script con `LD_LIBRARY_PATH` + `exec`, como Debian y Fedora. La
2554   alternativa limpia —`RPATH=$ORIGIN` con `patchelf`, que es lo que hace Alpine— espera a que
2555   `patchelf` exista como receta del corpus.
25563. **Un bug del corpus, no de atuq: `atk` se había tragado GObject.** Declaraba la variante
2557   ESTÁTICA de glib y produce un objeto compartido, así que `libatk-1.0.so` llevaba una copia
2558   entera del sistema de tipos. Dos GObject en un proceso ⇒ 45 `GLib-GObject-CRITICAL` y
2559   `Segmentation fault`. **El síntoma no nombra a atk**, y no se ve mirando el rootfs: había una
2560   sola `libgobject`. Se cazó preguntando quién DEFINE el símbolo:
2561   `nm -D --defined-only <cada .so> | grep " T g_type_register_static"` — deben salir uno, salieron
2562   dos.
2563
2564Radio medido antes de tocar (`yupana radio atk`): 4 transitivos, 3 sellados a deuda; GNOME fuera
2565porque usa gtk4. La cadena `atk → gtk3 → firefox → atuq` volvió a sellar 4/4, y **firefox tardó
2566~50 minutos, no las cuatro horas** que repetía este documento — el número venía del folclore de la
2567receta, no de una medición.
2568
2569**La lección para el plan: ninguna unidad de este frente está cerrada hasta que algo se ABRE en una
2570pantalla.** La clausura decía 100% con un navegador que no llegaba a pintar un píxel.
2571
2572### 2.quinquies La v0.3 y los tres mecanismos: dos muertos, uno vivo
2573
2574La página de inicio y la pestaña nueva parecían un `pref` y resultaron ser un frente. Lo que se
2575midió, en orden:
2576
2577| Mecanismo | Resultado |
2578|---|---|
2579| `distribution/extensions/` — el clásico de las distros | **No instala nada.** Firefox retiró el *sideloading*. El `extensions.json` del perfil ni lo mencionaba y el log no dijo una palabra: fallo perfectamente silencioso |
2580| `policies.json` → `ExtensionSettings` con `install_url` | **Se lee** (`browser.policies.applied=true` en el perfil) y falla nombrándose: `ERROR_SIGNEDSTATE_REQUIRED` |
2581| `xpinstall.signatures.required = false` | **No alcanza** — la exigencia viene COMPILADA |
2582
2583**El instrumento que destrabó el diagnóstico fue un testigo.** `defaultPref` no deja rastro en
2584`prefs.js` —sólo se guarda lo que difiere del default— así que un autoconfig que **no se ejecuta**
2585es indistinguible de uno que sí y no hace nada. `atuq.cfg` escribe ahora `atuq.autoconfig.ok` como
2586pref de **usuario**, legible desde fuera sin abrir el navegador. Salió `true` ⇒ el `.cfg` corría, y
2587por lo tanto la pref de firma se estaba ignorando.
2588
2589De ahí `MOZ_REQUIRE_SIGNING` vacío en `recipes/firefox.toml`. Dos detalles que costaron cada uno su
2590vuelta:
2591
25921. El default sale del **milestone** (`milestone.is_release_or_beta`), no del canal de actualización.
2593   El nuestro es `default` y la exigencia estaba activa igual.
25942. **Se desactiva con el valor VACÍO, no con cero.** `=0` muere con «takes 0 values»: es booleana y
2595   el cero es un valor que no acepta. Leído del parser de mozbuild, no deducido.
2596
2597**El precio, escrito:** este Firefox deja de exigir la firma de Mozilla para cualquier complemento.
2598La alternativa es firmar en AMO —cuenta y revisión de Mozilla por versión—, que es la dependencia
2599externa que esta distro existe para no tener. Y no es un desvío: **sin esto, el `sct` del §6.1
2600tampoco se podría shipear nunca**, así que la unidad 6 dependía de ésta sin que el plan lo dijera.
2601
2602**Método que conviene repetir:** la prueba del testigo se hizo **sin reconstruir**, montando el
2603`.cfg` modificado con `--ro-bind` por encima del artefacto. Editar el fichero en el rootfs habría
2604escrito sobre un **hardlink del store** y corrompido el artefacto sellado.
2605
2606### 2.sexies Lo que quedó PROBADO, y con qué
2607
2608Tres afirmaciones que este documento venía haciendo sin medir, y cómo se cerraron:
2609
2610| Afirmación | Prueba |
2611|---|---|
2612| El re-empaque del `omni.ja` es determinista | `why-differs` entre dos builds de `atuq`: **85 entradas idénticas, 0 divergen** |
2613| La capa de configuración se aplica | testigo `atuq.autoconfig.ok` escrito como pref de **usuario** en el perfil |
2614| El branding se ve | captura de la pantalla real con `grim` desde dentro de la jaula, y la página de inicio renderizada por el propio navegador con `--headless --screenshot` |
2615| La base también reproduce | `why-differs` entre dos builds de `firefox` en el worker: **56 entradas idénticas, 0 divergen** |
2616
2617Y el contraste que salió de la primera: **el derivado reproducía y la base no.** El `BuildID` de
2618`firefox` era la hora del build, así que dos construcciones con el mismo `ArtifactHash` daban bytes
2619distintos — y `build-state.json` no lo podía ver, porque el hash es input-addressed y no se mueve
2620por esto. Verde y mintiendo. Cerrado con `MOZ_BUILD_DATE` desde `SOURCE_DATE_EPOCH`, que es el
2621mecanismo de hermeticidad que el sandbox ya tenía — y **comprobado como se comprueba esto**:
2622apartando el artefacto como `.ref`, construyendo de nuevo y pasándole `why-differs`. Reproduce.
2623
2624**Dos guardianes nuevos en la fase `install` de `firefox`**, hermanos entre sí, porque los dos fallos
2625son de la misma familia —una bandera que el `configure` acepta y el artefacto ignora, muda y a 50
2626minutos de distancia—: uno comprueba `MOZ_REQUIRE_SIGNING: false` dentro del `omni.ja`, el otro que
2627el `BuildID` sea el que `SOURCE_DATE_EPOCH` obliga.
2628
2629**La regla que sale de todo el día:** cuando la duda es «¿llegó al artefacto?», la respuesta no está
2630en el log del build ni en la receta — está **dentro del artefacto**, y hay que ir a buscarla ahí.
2631
2632### 2.septies Los diecinueve guardianes, en una corrida — y el sello que se había quedado atrás (2026-09-15)
2633
2634Cada afirmación de este documento tiene quién la mida, y son **diecinueve guardianes**. Hasta hoy
2635ninguno corría solo: se corrían de a uno, el día que alguien tocaba esa parte. Ya se sabe cómo
2636termina eso —el de descargas estuvo ROJO dos días y nadie lo corrió (`29831e62`)—, así que la corrida
2637entera es ahora **un comando**: `scripts/test-atuq-suite.sh`.
2638
2639**Y lo primero que encontró fue un hueco que no era de ningún guardián, sino del ciclo.** Todo
2640`recipes/atuq/` es `source.dir`, así que el commit de la bóveda de esta mañana (unidad 12,
2641`6f0ba974`) movió el `ArtifactHash` — y **`atuq` quedó NO-SELLADO durante las nueve horas
2642siguientes**. En ese lapso ningún guardián podía medir nada: se niegan a correr contra un artefacto
2643que no sea el vigente, que es lo correcto. Pero un guardián que se niega **sólo grita cuando alguien
2644lo corre**, y nadie lo corría: el repo se veía sano, el commit estaba pusheado, y la única señal era
2645un `hash --check` que nadie tenía motivo para teclear. Por eso lo primero que hace el runner es mirar
2646el sello y NO correr nada si falta, diciendo el comando exacto para construirlo. Verificado con
2647control: contra un store vacío sale por ahí y devuelve 1.
2648
2649El re-sello costó **3,7 s** —`atuq` es derivado y el `firefox` vigente estaba en el store— y el ciclo
2650que manda es el de siempre: **editar todo → resellar UNA vez → medir**.
2651
2652**El cuadro, sobre `b3:e556024b` (gioser, en serie, 32,2 min):**
2653
2654| | |
2655|---|---|
2656| **17 en verde** | política, rootfs, inicio, chrome, nativo, foco, instalación, ruteo, archivo, descargas, medios, torrent, `sct`, códecs, IA, archivo semántico y notificaciones |
2657| **2 en ROJO** | `vigia-atuq-verbos` y `test-atuq-boveda-coherente`, **los dos por la misma causa**: los tres verbos de la bóveda (§7.quinquies) |
2658
2659Los caros son `sct` (242 s), el archivo semántico (238 s), la instalación (180 s, tres sesiones), la
2660IA (182 s, levanta `llama-server`) y las notificaciones (180 s, sway headless). Los cuatro primeros
2661salen en **5 s** y no tocan el navegador: para «¿rompí el cruce política↔XPI?» alcanza `--only`.
2662
2663**Y al día siguiente, el mismo comando: 19 en verde y 0 en rojo** (2026-09-16, 32,1 min). Los dos
2664rojos se apagaron subiendo el pin de `puriy-costura` (§7.quinquies.bis) — no se tocó ningún guardián.
2665Que la suite existiera antes del arreglo es lo que permite decir eso: el cuadro de ayer y el de hoy
2666son el mismo instrumento.
2667
2668⚠ **Lo que hay que cuidar ahora es el control del propio runner.** Mientras hubo un rojo conocido, el
2669número esperado distinguía solo «producto sano» de «runner que no corre nada». Con todo en verde ya
2670no, así que lo que sostiene la corrida son la **puerta del sello** (contra un store sin el artefacto
2671no corre nada y devuelve 1, verificado a propósito) y el **control interno de cada guardián**. El
2672runner no los reemplaza: los junta.
2673
2674O sea: **el commit de la bóveda no rompió nada más**. Los quince guardianes que no tienen nada que
2675ver con ella siguen verdes sobre el artefacto que la trae adentro, y eso es lo que hacía falta saber
2676antes de seguir — porque la unidad 12 tocó `atuq.cfg`, la política y el empaquetado de extensiones,
2677que es exactamente donde un cambio se lleva puesto algo de al lado sin decirlo.
2678
2679⚠ **El decimonoveno entró porque estaba nombrado como hueco, y eso envejece mal.** La primera
2680versión dejaba afuera al de notificaciones (`scripts/wlr/dunst-headless.sh --via-atuq`: una página
2681llama `new Notification(...)`, `libxul` hace `dlopen("libnotify.so.4")` —invisible para cualquier
2682auditor de ELF—, el bus activa `dunst` y dunst DIBUJA) con la excusa de que vive en el arnés de sway,
2683y lo escribía en la cabecera «para que su ausencia no se lea como cobertura». Dura tres días: al
2684cuarto, la lista es la cobertura y el párrafo no lo lee nadie. Se corrió aparte, salió verde en 180 s,
2685y entró. La lista pasó a llevar el COMANDO de cada guardián y no su nombre de fichero, que es lo que
2686lo tenía afuera.
2687
2688⚠ **Y el control del runner se corrigió solo en la primera corrida.** La cabecera anunciaba «17 en
2689verde y UN rojo» —predicción escrita antes de correr— y el cuadro dijo dos: el guardián de coherencia
2690de la bóveda lleva el mismo chequeo del cable adentro, que es justamente lo que el §7.quinquies le
2691pidió. El número quedó escrito en la cabecera **como control**: un cuadro entero en verde puede ser
2692un producto sano o un runner que no corre nada, y se ven igual.
2693
2694## 7. La costura: un host de native messaging en Rust
2695
2696Todo lo del §6 que no es CSS pasa por **un solo mecanismo**: un proceso Rust que habla native
2697messaging con la extensión de `atuq` y, del otro lado, con la suite (`agora` para identidad Ed25519,
2698`khipu`, el store, `foreign-*`, `cortafuegos`).
2699
2700Que sea **uno** y no siete es la decisión: cada feature del §6 pasa a ser un verbo de ese host, no un
2701proyecto nuevo. El binario vive en tawasuyu (es suite, no build system); `atuq` sólo lo declara como
2702dep y le deja el manifiesto de native messaging en su sitio. **El nombre del crate se decide en
2703tawasuyu y con su regla 10 puesta** — acá no se bautiza nada de allá.
2704
2705### 7.bis El camino EXISTE, y tres cosas que no eran obvias (2026-09-07)
2706
2707Antes de escribir un crate contra un mecanismo hay que saber si el mecanismo funciona en NUESTRO
2708build —propio, rebrandeado, con `MOZ_REQUIRE_SIGNING` vacío—, así que se puso una **sonda**:
2709extensión de diez líneas con permiso `nativeMessaging` y un host de tres que contesta `{"ok":true}`.
2710Vive en `scripts/test-atuq-nativo.py`, con control negativo:
2711
2712    positivo  MENSAJE {"ok":true}
2713    control   DESCONECTADO No such native application puente_atuq
2714
2715El control no sólo falla: **nombra la causa**, que es lo que confirma que la ruta del manifiesto es
2716la que se probó. Lo que hay que saber para escribir el host:
2717
2718| Qué | Medido |
2719|---|---|
2720| Dónde va el manifiesto | **`/usr/lib/mozilla/native-messaging-hosts/`**, NO el appdir. Sale de `XRESysNativeManifests`, un `/usr/lib/mozilla` compilado; el rebranding a `atuq` no lo mueve |
2721| Cómo se instala la extensión | por el **escaneo de `distribution/extensions/`**. `ExtensionSettings.install_url` con `file://` no instala nada, ni con `force_installed` ni con el XPI dentro del appdir, y **sin una línea de log** |
2722| Forma del host | un proceso **largo**: si escribe la respuesta y sale, Gecko cierra el puerto sin entregarla — `onDisconnect` «sin error y sin mensaje», el peor informe posible |
2723| Con qué diagnosticar | el error del puerto de `connectNative`. `sendNativeMessage` devuelve «An unexpected error occurred» para todo y no distingue nada |
2724
2725⚠ La tercera fila del cuadro corrige una lectura fácil de `policies.json`: los dos mecanismos
2726apuntan a los mismos ficheros y se tapan uno al otro, así que la política PARECE ser la que instala.
2727Fija y configura; instalar, no.
2728
2729### 7.ter Y esa fila ahora tiene guardián — porque el README decía lo contrario (2026-09-09)
2730
2731La fila «cómo se instala la extensión» era una nota al pie de una sonda de native messaging, y
2732mientras tanto **`recipes/atuq/README.md` afirmaba lo opuesto**: que el *sideloading* desde
2733`distribution/extensions/` «ya no funciona» y que instalaba `ExtensionSettings`. Dos documentos
2734nuestros, contradictorios, sobre el mecanismo del que cuelgan TODAS las extensiones de atuq —hoy
2735`inicio` y `proxy`, mañana la de `sct`—. Y ninguna observación del artefacto los distingue, porque
2736los dos apuntan a los mismos ficheros.
2737
2738`scripts/test-atuq-instalacion.py` lo rompe de a uno y mira qué pasa. Medido sobre
2739`atuq b3:fab2fbfb` (firefox 154 con PGO v2) y REPETIDO sobre `b3:9e16ac35` —el artefacto que esta
2740misma corrección creó, porque el README vive dentro de `source.dir` y editarlo re-hashea atuq—,
2741tres escenarios:
2742
2743| escenario | qué se rompe | motor (`homepageOverride`) | perfil |
2744|---|---|---|---|
2745| `as-is` | nada | `moz-extension://…/inicio.html` | `inicio` y `proxy` activas |
2746| `no-policy` | `policies.json` **sin** `ExtensionSettings` | **sigue siendo la nuestra** | **las dos siguen activas** |
2747| `policy-only` | los XPI mudados a `/usr/lib/atuq/xpi-politica/`, con `install_url` apuntando ahí | `about:home` | **ninguna de las dos existe** |
2748
2749⇒ **instala el escaneo de la carpeta; `install_url` con `file://` no instala nada.** El §7.bis
2750queda confirmado y el README corregido.
2751
2752Dos cosas que este guión trae y que valen más que el veredicto:
2753
27541. **El control es por construcción, no un modo aparte.** La sonda que le pregunta al motor vive en
2755   `distribution/extensions/` y **ninguna política la nombra**: si la sonda habla, el escaneo está
2756   vivo en esa corrida. Una corrida muda no se lee como «no instaló» — se declara ROTA y se guarda
2757   el log. Sin eso, un atuq que no arranca se vería idéntico a una política que no instala.
27582. **La segunda señal que probé NO discrimina, y queda dicho.** Esperaba que el `location` de
2759   `extensions.json` separara los mecanismos (`app-system-defaults` vs. `app-profile`). No: los dos
2760   XPI salen `app-profile` **también** cuando los instala la carpeta y no hay política que los
2761   mencione. Se imprime igual porque dice si están, pero como discriminador es cero — y una señal
2762   que uno cree que discrimina y no discrimina es peor que ninguna.
2763
2764**Por qué esto es un guardián y no un test más.** Mozilla lleva años retirando el *sideloading*, y
2765el día que la carpeta deje de instalar, atuq arranca **sin ninguna de sus extensiones** y sin una
2766línea de error: la página de inicio vuelve a ser la de Firefox y el proxy por contenedor
2767simplemente no enruta. Es el modo de fallo silencioso de siempre, y el que aparece cuando SUBE la
2768dep, no cuando alguien toca atuq.
2769
2770⚠ **Y por eso mismo hay que decir lo que todavía NO está: ninguno de los `test-atuq-*` corre en el
2771latido.** `cosecha-cron.sh` sólo ejecuta `vigia-sonames.py`; los de atuq se corren a mano. Meterlos
2772cuesta ~3 min por ciclo (tres arranques de navegador headless por escenario) y exige que el hub tenga
2773hidratado el rootfs de `scripts/atuq-nested.sh`, así que **es una decisión con precio y no un
2774`echo` más en el cron** — queda escrita acá y sin dar por hecho lo contrario. El día que se meta, el
2775sitio es el bloque de guardianes de `cosecha-cron.sh` (L341-352).
2776
2777### 7.quater El host, escrito — y por qué la extensión NO puede hablarle al testigo (2026-09-10)
2778
2779El §6.1 decía que `sct` v1 es «extensión con `webRequest` bloqueante que hashea cada respuesta de
2780script y **consulta al testigo** antes de dejarla pasar». La primera mitad es correcta; la segunda es
2781imposible tal como está escrita, y conviene decirlo porque parecía un atajo para saltarse esta
2782unidad: **el cable de `puriy-sct-testigo` es un POST con `postcard` en el body**, no JSON. Un JS no
2783puede ser su cliente. Y reimplementar el registro TOFU en la extensión sería fabricar un sustituto
2784paralelo del original —lo que la regla 10 de tawasuyu prohíbe— además de duplicar la única pieza que
2785ya estaba certificada. Entonces v1 pasa por el host, y el host es esta unidad.
2786
2787Se decidió con el operador, con el `grep` de la regla 10 hecho primero: **ningún término del dominio
2788significaba «host de native messaging»**, y los que suenan a mensajero están todos ocupados por
2789dominios ajenos y grandes (`chasqui` es un type broker, `chaka` el puente a COBOL, `paloma` el cliente
2790de correo, `tampu` el común de objetos). El nombre salió del título de este propio §7: **la costura**.
2791
2792| Pieza | Dónde | Por qué ahí |
2793|---|---|---|
2794| `puriy-costura` | `00_unanchay/puriy/` | regla 1: subcrate del dominio, no proliferación lateral. Precedentes: `puriy-sct-testigo`, `rimay/verbo-daemon`, `watuy-daemon` |
2795| `foreign-webext` | `shared/` | regla 4: el protocolo es AJENO. Lib pura sin binario, como los otros 37 puentes (medido: ninguno lleva `[[bin]]`) |
2796| `recipes/puriy-costura.toml` | takana | el artefacto: estático musl, pineado por commit, `--locked` |
2797
2798Verbos v1: `ping`, `sct.observe`, `sct.state`. Claves del cable en inglés (regla 7 de allá y regla 4
2799de acá: se tipean). `phase` es `learning`/`stable`/`not-evaluable`, y un `events[]` vacío es lo normal
2800— sólo hay evento cuando un origen **ya estable** ejecuta un hash que nadie vio nunca.
2801
2802**Los tres tests que hacen que el veredicto signifique algo** (de catorce): el mismo cambio de script
2803mientras el origen todavía aprende **no** alerta; rotar `?v=` con el mismo código **tampoco** (la
2804identidad del recurso es su contenido, y sin eso cada despliegue con cache-buster sería un aviso de
2805supply-chain: un detector que grita todos los días no se mira más); y el aprendizaje **sobrevive al
2806cierre del navegador**, sin lo cual cada arranque volvería a «aprendiendo» y ningún cambio sería nunca
2807un evento.
2808
2809**Los dos límites, escritos en el código y en los dos LEEME, no en una nota de sesión:**
2810
28111. **Qué bytes se hashean.** La extensión manda el TEXTO ya decodificado; se hashea su UTF-8. La
2812   detección vale —el TOFU compara lo mismo contra lo mismo—, pero nuestro hash **no es comparable**
2813   con el de los bytes servidos que publique un tercero, ni con el de la v2 (el gancho en el script
2814   loader). Un hash que uno cree comparable y no lo es sería peor que no tenerlo.
28152. **La semilla del log es provisional**: se deriva del directorio de estado, así que es
2816   determinística (el log se reanuda) pero **no es un secreto** ⇒ la bitácora es a prueba de
2817   REESCRITURA, no de SUPLANTACIÓN. La de verdad sale del perfil cuando el almacén del perfil exista.
2818
2819**Y lo que queda medido para la unidad 6, preguntándole al artefacto y no a la documentación de
2820Mozilla** (§2.sexies): `filterResponseData` y el permiso `webRequestFilterResponse` **están** en
2821nuestro build (`omni.ja`, `chrome/toolkit/content/extensions/schemas/web_request.json`), así que la
2822extensión va a poder leer el cuerpo de un script. Lo que NO va a poder es ver los scripts **inline**
2823por esa vía —`filterResponseData` entrega el documento, no cada `<script>`—, y eso está bien para v1:
2824el ataque que `sct` nombra es la sustitución en el CDN, que es exactamente el caso `<script src>`.
2825
2826### 7.quinquies El cable tiene DOS puntas en DOS repos — y hoy no coinciden (2026-09-15)
2827
2828La bóveda se commiteó esta mañana (unidad 12, `6f0ba974`): la extensión, la política, el permiso del
2829host, las preferencias, y un guardián que mira los cuatro. Los cuatro estaban bien. **La bóveda no
2830funciona igual**, y el motivo no está en ninguno de esos cuatro sitios.
2831
2832Cada función de `atuq` que no es CSS es un **verbo** del host nativo, y el host vive en OTRO REPO:
2833`recipes/puriy-costura.toml` lo pinea por commit de tawasuyu. O sea que el cable tiene dos puntas en
2834dos repos, y que `vault.match` sea un verbo o una cadena que nadie atiende lo decide un `commit =`
2835de un TOML que la extensión nunca menciona. Escribir la extensión y subir el pin son **dos unidades
2836de trabajo distintas**, y la primera se commitea sin la segunda sin que nada proteste.
2837
2838**Cómo se ve el fallo, que es por qué merece vigía.** No falla nada. El manifiesto está, la política
2839instala, `connectNative` **conecta** —el host existe y contesta—, la extensión manda `vault.match` y
2840recibe:
2841
2842    {"ok": false, "error": "verbo desconocido: vault.match"}
2843
2844`fondo.js` lee `r.ok !== true`, borra la insignia y se calla. El usuario ve un navegador sin bóveda;
2845un guardián de ficheros ve todo en orden. Es la misma familia que el §6.10 del subcomando sin driver:
2846sellado, con contenido, reproducible… y la función no está.
2847
2848**La medición, con su control al lado.** El artefacto vigente (`hash --check` sobre la receta dice
2849`b3:1eb2b692  SELLADO`) contra su propio binario:
2850
2851    strings del binario sellado   →  «vault»  0 veces
2852                                  →  «cas»    5 veces   ⇐ EL CONTROL
2853
2854El cero solo no probaba nada: un `grep` que devuelve cero puede ser una ausencia o un sitio
2855equivocado, y las dos se ven igual. Lo que lo convierte en evidencia es el hermano que TENÍA que dar
2856positivo en el mismo binario. Y desde la fuente sale lo mismo por otro camino: la receta pinea
2857`e19bb0e5`, que `git merge-base --is-ancestor` confirma **ANCESTRO** de `bc6903f9e`, el commit que
2858agregó los verbos. El host sellado es anterior a la bóveda por construcción.
2859
2860**Y no se podía deducir mirando la receta.** Las otras nueve extensiones conviven bien con ese mismo
2861pin viejo: 13 verbos sobre 8 extensiones, y los únicos tres sin dueño son los de la bóveda. «Cuándo
2862se tocó la receta por última vez» no contesta esta pregunta.
2863
2864| extensión | verbos | ¿el pin los atiende? |
2865|---|---|---|
2866| `archivo` | `archive.add`, `archive.ask`, `archive.search` | ✓ |
2867| **`boveda`** | **`vault.match`, `vault.fill`, `vault.save`** | **✗ los tres** |
2868| `descargas` | `cas.ingest` | ✓ |
2869| `foco` | `focus.state` | ✓ |
2870| `ia` | `ai.ask`, `archive.ask` | ✓ |
2871| `medios` | `media.open` | ✓ |
2872| `sct` | `sct.observe` | ✓ |
2873| `torrent` | `torrent.add` | ✓ |
2874| `inicio`, `proxy` | — | no le hablan al host, a propósito |
2875
2876Queda vigilado por `scripts/vigia-atuq-verbos.py`, que corre en un segundo sin construir nada y
2877pregunta `git grep '"<verbo>" =>' <commit> -- '*.rs'` **leyendo del objeto** —sin checkout: el árbol
2878de tawasuyu es compartido y siempre tiene ficheros en vuelo—. El mismo chequeo quedó además dentro
2879de `test-atuq-boveda-coherente.py`, que pasa a decir **cinco** lugares y no cuatro.
2880
2881⚠ **Y lo que costó más que el vigía: el extractor de verbos no veía la mitad.** Buscaba `verb: "…"`,
2882que es como lo escribe la bóveda; pero la extensión de IA arma el mensaje con `postMessage({ id,
2883verb: verbo, … })` —el verbo llega por VARIABLE— así que ese patrón encontraba **cero verbos en
2884`ia`** y la declaraba sana sin haber mirado nada. Cero verbos encontrados y cero verbos faltando son
2885el mismo cero con dos causas. De ahí salió un tercer control que no es ni positivo ni negativo sino
2886de **cobertura**: una extensión de la que no se extrae ningún verbo se REPORTA, y las dos que de
2887verdad no le hablan al host están nombradas en el código para que no se confundan con el extractor
2888mudo.
2889
2890#### Por qué el pin NO se subió en el mismo turno, y qué hace falta
2891
2892El candidato es `be8710cff` —el más nuevo que toca `puriy-costura`, y trae además «la aplicación de
2893la bóveda y el navegador ya pueden convivir», que pinear `bc6903f9e` se saltearía—. **No se puede
2894todavía, y el muro está medido**, no supuesto: el `Cargo.lock` de tawasuyu no cierra para
2895`puriy-costura`. Su entrada en el lock de `main` todavía lista las **once** deps viejas —sin
2896`pacha-boveda`, sin `pacha-cifrador`— y `pacha-boveda-daemon` no está en el lock en absoluto.
2897
2898Reproducido en un árbol LIMPIO del commit (`git archive <sha> | tar -x`, que es el procedimiento que
2899la propia receta dejó escrito y que evita que el lock describa «el escritorio de todos»):
2900
2901    cargo metadata --locked --offline
2902    error: cannot update the lock file … because --locked was passed to prevent this
2903
2904—y, como la receta avisaba, **no nombra al crate culpable**. La reparación es barata y quedó
2905comprobada en ese mismo árbol limpio: `cargo metadata --offline` cierra el lock con un diff de **37
2906líneas**, todas de contabilidad de deps por ruta, **sin mover una sola versión de registry**.
2907
2908**Pero ese fichero no se commiteó, y la razón es la regla 2 de `CLAUDE.md` vista desde el otro lado.**
2909En el clon compartido, `git status` dice `MM Cargo.lock`: otra sesión lo tiene **stageado Y
2910modificado** ahora mismo. Las tres versiones, medidas:
2911
2912| dónde | tamaño | deps de `puriy-costura` |
2913|---|---|---|
2914| `main` (`2c40330e4`) | 672 409 B | 11 — no cierra |
2915| el **índice** (stageado por otro) | 671 160 B | 11 — no cierra, y **no es el de `main`** |
2916| el **árbol** de esa sesión | 673 397 B | **18 — cierra** |
2917
2918O sea que la reparación ya existe, sin commitear, en el árbol de otro agente. Tocarla es pisarle el
2919trabajo, y el `--` no protege de eso. **Comprobado en un repo de juguete, porque la intuición es la
2920contraria:**
2921
2922```sh
2923# f.txt en estado MM: v2 en el índice (del otro), v3 en el árbol (mío)
2924git commit -m … -- f.txt
2925#   ⇒ commitea v3 (EL ÁRBOL, no lo stageado)
2926#   ⇒ y el índice queda en v3: lo que el otro tenía stageado ahí DESAPARECE
2927```
2928
2929El `--` acota **qué rutas** entran al commit y con eso aísla de lo que el otro dejó en OTRAS rutas
2930—que es lo que la regla 2 dice y sigue siendo cierto—; para la ruta que uno nombra, se lleva el
2931árbol y **borra el índice ajeno**. En un repo compartido, un fichero en `MM` que uno no tocó no se
2932commitea ni con pathspec: se avisa.
2933
2934⇒ **Pendiente, y no es de takana:** que tawasuyu publique el lock cerrado. Con eso, subir el pin a
2935`be8710cff`, reconstruir (2,4 G de vendoreo del workspace entero, ver la receta) y recién ahí escribir
2936el guardián de METAL de la bóveda —servidor, navegador real, login real, el diálogo de consentimiento
2937a la vista—, que es lo que el commit de la unidad 12 dejó anunciado.
2938
2939#### 7.quinquies.bis DESTRABADO (2026-09-16): el lock, el pin, y el mismo `strings` leyendo al revés
2940
2941Las tres cosas que el párrafo de arriba dejaba pendientes están hechas, y en ese orden porque cada
2942una destrababa a la siguiente.
2943
2944**1. El lock de tawasuyu, publicado** (`23a292863`). Regenerado en un árbol LIMPIO del `HEAD` de allá
2945—`git archive | tar -x`, que no toca el `.git` compartido— y verificado en los dos sentidos, que es
2946lo que lo convierte en evidencia: `cargo metadata --locked --offline` **moría antes** de regenerarlo
2947y **pasa después**. El diff son 215 líneas y **todas de contabilidad de deps por ruta: cero
2948`checksum` movidos, cero `source` movidos**, ninguna versión de registry tocada. Lo que aparece es
2949`llimphi-registro`, los cuatro crates de la bóveda y `umbral` dentro de `pacha-boveda`.
2950
2951⚠ **Commiteado con un índice TEMPORAL, no con `git commit`.** El `Cargo.lock` seguía en vuelo en el
2952clon compartido, y la regla 2 ya está medida: `git commit -- Cargo.lock` se lleva **el árbol** y deja
2953el índice ahí, o sea que borra lo que el otro agente tuviera stageado en ESA ruta. El camino que no
2954toca nada es plumbing:
2955
2956```sh
2957export GIT_INDEX_FILE=/tmp/…/tw.index      # un índice PROPIO: el compartido no se abre
2958git read-tree HEAD
2959blob=$(git hash-object -w <el lock nuevo>)
2960git update-index --cacheinfo 100644,$blob,Cargo.lock
2961git commit-tree $(git write-tree) -p HEAD -F msg     # ⇒ commit sin tocar índice ni árbol
2962git push origin <commit>:main
2963```
2964
2965Con su control puesto: `git diff-tree -r --name-only HEAD <tree>` tiene que decir **un solo fichero**.
2966
2967⚠ **Y la sorpresa que ordena el resto: el lock publicado resultó ser BYTE A BYTE el que la otra
2968sesión ya tenía en su árbol** —mismo blob `b2f0b9eb`—. No era una versión rival de la reparación: era
2969la suya, que llevaba horas sin commitear. O sea que lo que bloqueó a `atuq` tres semanas no fue un
2970problema técnico sino **un fichero correcto que nadie había publicado**, y del que ningún guardián de
2971ninguno de los dos repos podía hablar.
2972
2973**2. El pin, subido** a ese commit ⇒ `b3:7d63655a`. El artefacto se construyó **en el worker**: el
2974vendoreo son 2,4 G y el disco del hub estaba al 99% (lo llena `tawasuyu/target`, 148 G), así que
2975construir acá habría sido el cuadro de «disco lleno = ✗ falso». Bajar el artefacto son 2,3 M.
2976
2977**3. La misma medición que diagnosticó el problema, leída al revés.** El §7.quinquies lo cerró con
2978`strings` sobre el binario sellado: `vault` 0 veces, `cas` 5 —el control que tenía que dar positivo—.
2979Sobre el binario nuevo, con el mismo comando:
2980
2981| | antes (`e19bb0e5`) | ahora (`23a29286`) |
2982|---|---|---|
2983| `vault.` | **0** | **10** |
2984| `cas.` / `sct.` (control) | presentes | presentes |
2985
2986Y del lado del navegador, `scripts/vigia-atuq-verbos.py` pasa de **3 verbos sin dueño** a **cero**, con
2987su control positivo intacto (`sct.observe` sigue apareciendo ⇒ el grep mide).
2988
2989**Lo que queda de la unidad 12** es el guardián de METAL de la bóveda —servidor, navegador real,
2990login real, el diálogo de consentimiento a la vista—, que ahora sí se puede escribir porque hay con
2991qué correrlo. ⚠ **Esto último resultó FALSO y está corregido en el §7.sexies: faltaban el dueño de la
2992bóveda y el diálogo de consentimiento, y sin ellos el host contesta «cerrada».**
2993
2994### 7.sexies «Hay con qué correrlo» era falso: la bóveda del navegador contesta CERRADA (2026-09-18)
2995
2996El párrafo de arriba cerró el §7.quinquies.bis diciendo que el guardián de metal «ahora sí se puede
2997escribir porque hay con qué correrlo». **No se podía, y la primera pregunta del guardián lo dice en
2998veinte segundos.** Antes de escribirlo se le preguntó al artefacto sellado —el mismo binario que va
2999en las cuatro imágenes de escritorio— por marcos de 4 bytes, igual que le habla la extensión:
3000
3001```
3002← {"id":1,"ok":true,"verb":"ping","version":"0.1.0"}
3003← {"id":2,"ok":true,"verb":"vault.status","locked":true,"count":0}
3004← {"id":3,"ok":true,"verb":"vault.match","locked":true,"items":[]}
3005stderr: puriy-costura: sin bóveda (No such file or directory (os error 2)):
3006        los verbos vault.* dirán «cerrada»
3007```
3008
3009`ping` es el control: el host **está**, atiende y contesta. Lo que no hay es bóveda. Y el `ok:true`
3010de las otras dos es la parte que engaña: **«cerrada» es una respuesta exitosa**, no un error, así que
3011ningún reintento, ningún log y ninguna insignia distinguen esto de un usuario que todavía no
3012desbloqueó la suya.
3013
3014**Por qué faltaba, y por qué el §7.quinquies no podía verlo.** Ese capítulo midió lo que se podía
3015medir desde acá: que el commit pineado del host CONOCE los verbos (`strings` → `vault` 10 veces,
3016`vigia-atuq-verbos` → cero verbos sin dueño). Las dos mediciones eran correctas y siguen siéndolo.
3017Lo que ninguna preguntaba es **quién contesta del otro lado del socket**: el host no abre la bóveda
3018nunca, a propósito —`sled` toma un lock exclusivo y el proceso que lanza el navegador muere y revive
3019con cada pestaña—, así que le habla a un DUEÑO, y el dueño no estaba en el corpus. Es la misma forma
3020de fallo del §7.quinquies una capa más abajo: **saber el verbo no es poder contestarlo**.
3021
3022Las dos piezas ausentes, las dos con el mismo efecto de «se ve apagado y nada falla»:
3023
3024| pieza | qué es | qué pasa sin ella |
3025|---|---|---|
3026| `boveda` (`pacha-boveda-llimphi`) | la app de la bóveda, y el **dueño único** de la base: la abre para su ventana y levanta el socket del navegador en un hilo | `vault.*` contesta `locked:true`: insignia vacía, nada que ofrecer |
3027| `shuma-pregunta` | el diálogo de consentimiento que `PorDialogo` lanza como proceso | todo `vault.fill` se **deniega** —`Command::new` falla ⇒ «no»—, indistinguible de que el usuario haya dicho que no |
3028
3029⇒ Hoy, en las cuatro imágenes de escritorio, `atuq` instala la décima extensión y la función está
3030apagada de fábrica. Entran `recipes/boveda.toml` y `recipes/shuma-pregunta.toml`, las dos pineadas al
3031**mismo** `23a292863` que ya comparten `puriy-costura`, `shuma-*` y `pacha-*` —un pin distinto es
3032otro vendoreo de 2,4 G del workspace entero— y las dos con la forma de `mirada-greeter`, que es el
3033patrón de una llimphi GUI: `link = "dynamic"` porque winit/wgpu cargan EGL/Vulkan/wayland por
3034`dlopen`, y la inyección de `LOCKSTEP_XML_PATH` que evita el panic de `zbus-lockstep-macros` en el
3035subdir `xml/schemas/` de atspi (mismo `accesskit_winit` 0.33, mismo `atspi-common` 0.13 en el lock).
3036
3037⚠ **Y quedan DOS decisiones que no se toman de paso**, las dos de producto:
3038
30391. **en qué imágenes se declaran.** La función del navegador no existe sin las dos, así que o entran
3040   a las cuatro de escritorio o la bóveda de `atuq` se queda como una extensión que no ofrece nada.
3041   Es la misma pregunta abierta que el §6.7 dejó con `llama-cpp`, y se contesta con el tamaño medido
3042   al lado, no antes: **`boveda` son 22 M y `shuma-pregunta` 21 M**, o sea ~43 M por perfil, contra
3043   los ~1,25 GiB que pide el §6.7. ⚠ Pero hoy la respuesta es **NO, y no por el tamaño**: ninguna de
3044   las dos abre ventana en ninguna imagen (§7.septies), así que declararlas ahora sería agregar 43 M
3045   de binarios que no pueden pintar — y peor, dejar la bóveda «instalada» negando todo en silencio;
30462. **de dónde sale la raíz de las claves.** La app la saca de la seed de identidad del llavero del
3047   kernel, que en una máquina de desarrollo no está desbloqueada — y por eso existe `BOVEDA_TEST_ROOT`,
3048   que deriva la raíz de una cadena y abre **otra** bóveda, vacía, diciéndolo en pantalla. Es la
3049   escotilla con la que el guardián de metal va a poder sembrar una credencial sin tocar la de nadie.
3050
3051**Lo que queda de la unidad 12**, entonces, no es «escribir el guardián de metal»: es sellar estas
3052dos y recién ahí escribirlo. Lo que sí queda escrito desde hoy es la pregunta con la que ese guardián
3053tiene que empezar —`vault.status` contra el host— porque es la que separa «la bóveda dijo que no» de
3054«no hay bóveda», que se ven idénticas desde la extensión.
3055
3056#### 7.septies El diálogo NO ABRE, y el muro no es de la bóveda: **ninguna app llimphi de escritorio puede pintar en esta distro** (2026-09-18)
3057
3058Con `boveda` y `shuma-pregunta` sellados, la etapa B del guardián de metal falla, y falla en un sitio
3059que no tiene nada que ver con las contraseñas:
3060
3061```
3062thread 'main' panicked at 02_ruway/llimphi/llimphi-hal/src/lib.rs:1032:83:
3063index out of bounds: the len is 0 but the index is 0
3064```
3065
3066Esa línea es `caps.formats[0]` sobre las capacidades de la surface. O sea: **la surface no tiene NI UN
3067formato**. La cadena entera, medida eslabón por eslabón con `RUST_LOG=wgpu_hal=debug`:
3068
3069```
3070wgpu_hal::gles::egl: No (or unknown) windowing system ((None, Some(...))) present.
3071                     Using surfaceless platform
3072wgpu_hal::gles::egl: Trying native-render → No config found!
3073wgpu_hal::gles::egl: Trying presentation  → No config found!
3074wgpu_hal::gles::egl: Trying off-screen    → (ésta sí)
3075```
3076
3077El `None` de ese par es `desc.raw_display_handle`, y **no es un accidente del arnés: es lo que el
3078propio llimphi hace a propósito**, con su comentario al lado (`llimphi-hal/src/lib.rs`, camino de
3079escritorio `new_inner`):
3080
3081> «Sin display: este camino no tiene ventana todavía (la surface se crea después, contra esta misma
3082> instancia). Los caminos que SÍ la tienen —`new_for_raw_surface`, `recreate_for_window`— la pasan.»
3083
3084Sin display handle, wgpu abre el display EGL por la plataforma **surfaceless**, que por definición no
3085publica configs con `WINDOW_BIT`; sin config de ventana, `presentable` es falso; sin `presentable`,
3086`surface_capabilities` devuelve `None` y la lista de formatos sale vacía. Cada paso está en el log.
3087
3088**Por qué esto no se nota en la máquina de quien escribió llimphi, y sí acá.** Ese camino elige
3089`Backends::PRIMARY` —Vulkan/Metal/DX12— y **a Vulkan el display handle no le hace falta**. Sólo cae al
3090backend GL cuando no hay ninguno de los tres… que es exactamente el caso de esta distro:
3091
3092| | Vulkan | efecto |
3093|---|---|---|
3094| `recipes/mesa.toml` (iris, la de las imágenes) | `-Dvulkan-drivers=` **vacío** | no hay ICD |
3095| `recipes/mesa-swrast.toml` · `recipes/mesa-llvmpipe.toml` | `-Dvulkan-drivers=` **vacío** | no hay ICD |
3096| `vulkan-loader` | sólo en `incoming-kde`, y **ningún perfil lo declara** | y el loader sin driver no es un driver |
3097
3098⇒ **En ninguna imagen de takana hay un driver Vulkan.** Con lo cual toda app llimphi de escritorio
3099—no sólo el diálogo de la bóveda— toma el camino GL, y el camino GL sin display handle no puede abrir
3100ventana. Comprobado en TRES binarios distintos y con dos versiones de wgpu: `shuma-pregunta` (wgpu 29),
3101`boveda` (wgpu 29) y `llimphi-counter` (wgpu 27, pineado a otro repo).
3102
3103**Los controles, porque la conclusión es fuerte:**
3104
3105· pedir Vulkan explícito (`LLIMPHI_WGPU_BACKEND=vulkan`) ⇒ **`NoAdapter`**, y `ls /usr/share/vulkan/icd.d`
3106  y `ls /usr/lib/libvulkan*` no existen en ninguna capa. La premisa «no hay Vulkan» está medida, no
3107  supuesta;
3108· con **softpipe** el fallo es OTRO y anterior —`RequestDevice("Parent device is lost")`, con
3109  `indirect-validation error: ComputePipeline(… COMPUTE_SHADER …)` una línea antes, porque softpipe se
3110  queda en GL 3.3 y wgpu pide compute—. O sea que llegar hasta el panic de los formatos ya exige
3111  `llvmpipe` (GL 4.5), que es lo que el arnés monta: `mesa-llvmpipe` + `llvm18`;
3112· y el mismo arnés levanta `sway`, el socket wayland aparece y `WAYLAND_DISPLAY` está puesto — el
3113  compositor no es el que falta.
3114
3115⚠ **Lo que esto dice del producto, y es lo que importa:** la bóveda de `atuq` no está «casi lista». Su
3116consentimiento —`PorDialogo`, que lanza `shuma-pregunta`— **no puede pedir permiso en ninguna imagen
3117de hoy**, y como el fallo del lanzamiento se traduce a «no» (`Command::new` falla ⇒ `return false`),
3118lo que un usuario vería es una bóveda que niega todo, sin un error. Y no es sólo la bóveda: es
3119cualquier ventana llimphi que la distro quiera abrir.
3120
3121**Dónde se arregla, y dónde NO.** No se arregla en takana: ni en la receta, ni en el arnés, ni
3122poniendo otro mesa. Las dos salidas son de otro repo o de otra receta, y conviene decir cuál es cuál:
3123
31241. **llimphi** (tawasuyu): que el camino de escritorio pase el display handle cuando lo tiene, o que
3125   al caer al backend GL reconstruya la instancia con él. La función que hace falta **ya existe ahí
3126   al lado** (`instancia_con`, que usan `new_for_raw_surface` y `recreate_for_window`);
31272. **un driver Vulkan por software** en el corpus (lavapipe: `-Dvulkan-drivers=swrast`), que además
3128   destrabaría el muro de ScreenCast que el runbook de COSMIC documenta por otro camino. Es una
3129   receta nueva y una decisión de tamaño, no un arreglo de paso.
3130
3131La 1 es la correcta: un navegador que pide permiso no debería depender de que la máquina tenga
3132Vulkan. La 2 es la que, además, le sirve a otros frentes.
3133
3134##### El arreglo, escrito y empujado en llimphi (`eed3120b6`) — falta medirlo
3135
3136Se hizo la 1, que son dos ficheros y ninguna pieza nueva: **la función que faltaba ya existía al
3137lado**. `Hal::new_con_display` usa el mismo `instancia_con` que el camino layer-shell, y el llamador
3138de escritorio le pasa la `window` — que **está ahí, creada una línea antes** para hacerle la surface.
3139`new_inner` y el nuevo comparten cuerpo (`new_generico`) y sólo se distinguen en cómo arman la
3140instancia: duplicar la elección de backend y el fallback es cómo se termina con dos caminos que
3141divergen sin que nadie se entere.
3142
3143Controles antes de empujar, porque el arreglo no se puede probar hasta reconstruir: `cargo check -p
3144shuma-pregunta` pasa con el parche **y falla con una rotura a propósito en la línea tocada** (si no,
3145un check que no compila el fichero se ve igual que uno que sí).
3146
3147⚠ **Commiteado allá con índice temporal** (`GIT_INDEX_FILE` + `commit-tree` + `push <sha>:main`), que
3148es la única forma de publicar dos rutas en un clon compartido sin llevarse por delante los 181
3149ficheros que otra sesión tiene en vuelo. Control: `git diff-tree -r --name-only` nombra **dos**. Y el
3150push fue rechazado la primera vez porque el remoto se había movido: se rehízo el `commit-tree` sobre
3151el `FETCH_HEAD` nuevo, comprobando antes que los dos ficheros no habían cambiado allá.
3152
3153##### ⚠⚠ «SELLADA» en cero segundos: el latido REVIERTE la receta del worker (2026-09-18)
3154
3155Al reconstruir con el pin nuevo, el log del worker dijo `### boveda SELLADA` casi al instante. No
3156era. El worker había vuelto a la receta VIEJA y lo que «selló» fue un **acierto de caché sobre el
3157artefacto anterior**:
3158
3159```
3160hub:     recipes/boveda.toml  →  b3:8d1d7536   (pin eed3120b6)
3161worker:  recipes/boveda.toml  →  b3:59ffd74b   (pin 23a292863, el de antes)
3162```
3163
3164**Quién la revierte:** el latido (`cosecha-cron.sh`) siembra `rsync -az --delete` hub→worker **al
3165principio** de cada ciclo, y hace su `git pull --ff-only` **al final**. O sea que cada media hora el
3166worker vuelve al árbol de ESE hub, que puede estar hasta un ciclo atrasado — y un `rsync` manual de
3167una receta dura lo que tarde el siguiente latido. El cron, además, no corre en este hub: corre en el
3168otro, con su propio checkout.
3169
3170**Por qué es peligroso y no sólo molesto:** el modo de fallo no es un error, es un **éxito falso**.
3171Si el artefacto de la receta vieja ya está sellado, el build imprime `SELLADA` en cero segundos y el
3172operador lee exactamente lo que esperaba leer. Es la forma «un ausente falla ruidosamente; un vacío
3173llega hasta el final diciendo que todo fue bien» de la regla 3, un piso más abajo.
3174
3175**La mitigación, que es una línea y va DENTRO del mismo comando que toma el lock:** preguntarle al
3176worker el hash y compararlo con el del hub antes de construir.
3177
3178```sh
3179h=$(./target/release/takana --store ./store hash recipes/boveda.toml | sed s/^b3://)
3180[ "$h" = "$ESPERADO" ] || { echo "### RECETA REVERTIDA: el worker hashea ${h:0:12}"; exit 3; }
3181flock -o work/.farm-build.lock ./target/release/takana --store ./store build recipes/boveda.toml
3182```
3183
3184Con eso el segundo intento dijo `### receta verificada 8d1d75369548` antes de compilar nada.
3185
3186⚠ **El pin de `boveda` y `shuma-pregunta` ahora DIVERGE del de sus hermanas** (`eed3120b6` contra
3187`23a292863`), y eso cuesta **un árbol de fuentes propio: otro vendoreo de 2,4 G**, porque el árbol se
3188comparte por `<repo>-<sha>`. Es el precio de no mover las otras once recetas del monorepo en el mismo
3189turno; se paga hasta que suban.
3190
3191**Lo que queda escrito y corriendo mientras tanto** es `scripts/test-atuq-boveda-metal.py`, con sus
3192seis etapas declaradas y la A en verde: sin la app dueña, el host contesta `locked:true`. Es poco, y
3193es exactamente lo que se puede afirmar hoy — que es mejor que un guardián que no existe y que uno que
3194diera verde midiendo nada.
3195
3196#### 7.octies Con el navegador abierto la bóveda queda MUDA — el dueño atendía de a un cliente (2026-09-18)
3197
3198Con llimphi arreglado, el guardián de metal llegó hasta la etapa D —guardar una contraseña con
3199consentimiento real y encontrarla de nuevo— y **se plantó en la E**, que es la que usa el navegador.
3200El cuadro era éste, y ninguna de sus líneas es un error:
3201
3202```
3203BOVEDA CONECTADO puriy_costura        ← la extensión conecta con su host
3204BOVEDA-METAL EXTENSION cargada
3205BOVEDA-METAL MOSTRADA true            ← el botón de la extensión SÍ está para esa pestaña
3206BOVEDA-METAL INSIGNIA ""              ← …y la insignia está vacía
3207BOVEDA-METAL DISPARO api              ← se aprieta el botón
3208(nada más. Ni diálogo, ni relleno, ni una línea de `fondo.js`)
3209```
3210
3211La extensión no dice nada porque **no tiene nada que decir**: `vault.match` se manda y **no vuelve
3212nunca**. Sin respuesta no hay insignia, no hay log y no hay error — que desde el navegador es
3213indistinguible de «este sitio no tiene contraseñas guardadas».
3214
3215**La causa, medida y no deducida.** `pacha_boveda_daemon::Dueno::servir` llamaba a `atender_cliente`
3216en el hilo del `accept`, y `atender_cliente` no vuelve hasta que el cliente se va ⇒ **la primera
3217conexión se queda con el dueño mientras viva** y el resto espera en la cola del socket para siempre.
3218Y el caso normal es justamente ése: **Gecko lanza un `puriy-costura` por PUERTO** —uno por extensión,
3219ocho en `atuq`— y cada uno abre su conexión a la bóveda al arrancar (§7.quater ya había medido lo de
3220«un host por puerto»; lo que faltaba era ver qué le hace eso al dueño).
3221
3222El control, en los dos sentidos y sin navegador, dentro de la misma jaula:
3223
3224```
3225== un solo cliente            → {"verb":"vault.status","locked":false}   en milisegundos
3226== con otro host conectado    → colgado; lo mata el `timeout` a los 30 s
3227```
3228
3229**Arreglado allá** (`cf3540460`): un hilo por conexión. El argumento por el que se serializaba —dos
3230diálogos de consentimiento a la vez es cómo alguien autoriza el que no era— sigue en pie, y ahora lo
3231sostiene **el `Mutex` de la bóveda**, que quien atiende toma para responder y sostiene mientras
3232pregunta. Lo que deja de serializarse es lo que nunca debió: estar conectado.
3233
3234⚠ **Y el arreglo obvio estaba mal por una razón que no se ve leyendo:** clonar el `Dueno` para cada
3235hilo hace que el primero que termina **borre el socket** (lo hace en su `Drop`, y está bien que lo
3236haga: un socket huérfano deja al próximo cliente esperando en vez de decirle que no hay daemon). Por
3237eso atiende un `Atendedor` —bóveda y a quién preguntarle, sin la ruta—. Lo cazó el test nuevo en el
3238primer intento.
3239
3240**El test, y por qué el crate no podía ver su propio fallo.** `dos_clientes_vivos_a_la_vez_son_
3241atendidos`. Los siete que ya existían abrían un cliente, lo usaban y lo soltaban antes del siguiente
3242—y el propio arnés del test llama a `atender_cliente` en secuencia—, así que **nunca hubo dos
3243conexiones solapadas**: el test serializaba justo lo que producción no serializa nunca. El segundo
3244cliente se pregunta en un hilo con `recv_timeout` a propósito, porque el fallo ES colgarse y un test
3245que se cuelga no dice qué pasó. Probado en los dos sentidos: con el bucle viejo falla a los 5 s, con
3246el nuevo pasa, y los otros siete siguen verdes.
3247
3248##### ⚠ Y el lock de tawasuyu volvió a estar abierto — se cierra con UNA línea, no con mil
3249
3250Subir el pin a `cf3540460` dio el error de siempre: `cargo vendor --locked` muere con «cannot update
3251the lock file … because --locked was passed», **sin nombrar el crate**. Es el mismo modo de fallo que
3252bloqueó a `atuq` tres semanas (§7.quinquies.bis), y vuelve cada vez que alguien agrega una dep de
3253ruta sin actualizar el lock.
3254
3255Lo que importa para la próxima vez es **cuál de las dos operaciones se usa para cerrarlo**:
3256
3257| | diff | checksums movidos | sirve |
3258|---|---|---|---|
3259| `cargo metadata` sin `--locked` (mínima) | **1 línea** | **0** | ✅ |
3260| `cargo generate-lockfile` | 6.305 líneas | 750 | ❌ invalida el vendoreo de todos |
3261
3262La mínima agrega sólo la arista que falta. La otra re-resuelve el workspace entero y se ve igual de
3263«correcta» hasta que uno mira el diff. Las dos en un árbol LIMPIO (`git archive | tar -x`), con el
3264control de siempre: `cargo metadata --locked` falla antes y pasa después. Publicado en `b80f7567c`,
3265con índice temporal porque el `Cargo.lock` estaba `MM` en el clon compartido.
3266
3267#### 7.novies CERRADA: la bóveda entrega una contraseña, y sólo cuando alguien dice que sí (2026-09-18)
3268
3269Las seis etapas del guardián de metal, en verde, sobre artefactos vigentes:
3270
3271```
3272A ✓ sin la app dueña, el host contesta locked:true — y con ok:true, que es la trampa
3273B ✓ con la app, vault.status dice ABIERTA: el socket sube en 1-2 s
3274C ✓ el diálogo ABRE en el compositor y se lo CONTESTA: «1» ⇒ yes, «2» ⇒ no
3275D ✓ vault.save con consentimiento real guarda, y vault.match la encuentra SIN contraseña
3276E ✓ el navegador real, sobre una página servida por HTTP: la contraseña llega al campo
3277F ✓ contestando que NO, al campo no llega NADA
3278```
3279
3280**Lo que la E afirma, y por qué la F es la mitad que la hace valer.** La E es la promesa entera del
3281§6: un servidor de verdad sirve un formulario, `atuq` lo carga, se aprieta el botón de la extensión,
3282`vault.fill` abre el diálogo, alguien dice que sí y la contraseña aparece en el campo. Sola no
3283probaría nada: **una bóveda que entrega siempre se ve idéntica a una que entrega con permiso.** La F
3284corre lo mismo contestando que no y exige que al campo no llegue nada.
3285
3286Y lo que mide no es lo que el host contesta sino **lo que hay en el campo**: la página tiene un
3287guión que delata al servidor lo que le pongan. Es la lección del §7.quater —«el guardián leyó el
3288disco, no la respuesta»— aplicada al otro extremo.
3289
3290##### Tres fallos del ARNÉS, y los tres se veían como fallos del producto
3291
3292Ninguno era de la bóveda, y los tres habrían quedado escritos como «la bóveda no anda»:
3293
32941. **`wait` a secas esperaba también a la app de la bóveda**, que es un trabajo en segundo plano.
3295   La jaula no cerraba nunca, se comía su timeout y a la app la mataba un KILL — y con ella se iba
3296   lo recién guardado, porque `sled` no alcanzaba a volcar. La etapa siguiente no encontraba la
3297   credencial: **«la bóveda no guarda», que es el diagnóstico contrario al verdadero.** `wait
3298   "$contestador"`;
32992. **el diálogo no responde al teclado mientras la app de la bóveda está inicializando su GPU.**
3300   Con render por software en cuatro núcleos, dos llimphi arrancando a la vez se pisan: la ventana
3301   del diálogo entra en el árbol del compositor, toma el foco y se come doce teclas seguidas sin
3302   efecto; `vault.save` vuelve `denied` por timeout **con la ventana todavía abierta**. Se espera a
3303   que la app PINTE (aparezca en el árbol, 12-13 s), no sólo a que atienda el socket;
33043. **una sola tecla no alcanza y el borde se mueve**: con el mismo `wtype rc=0`, una corrida
3305   contestaba y otra volvía `denied`. El contestador insiste hasta que la ventana se va, que es la
3306   única señal que no depende de adivinar cuánto tarda en estar listo.
3307
3308⚠ Y uno más, del lado del chrome: **`WebExtensionPolicy` no es global en el scope de una ventana del
3309navegador** —la sonda moría con `ReferenceError` y eso se leía como «la extensión no está»—. Sale
3310del módulo `ExtensionParent`, que además la reexporta.
3311
3312##### Lo que costó llegar hasta acá, en fallos ajenos al navegador
3313
3314El guardián se escribió para medir una función y terminó destapando tres piezas rotas, **ninguna en
3315`atuq`**: que el dueño de la bóveda y el diálogo no estaban en el corpus (§7.sexies), que ninguna app
3316llimphi de escritorio podía pintar sin Vulkan (§7.septies), y que el dueño atendía de a un cliente
3317(§7.octies). Las tres se veían igual desde el navegador: una bóveda que no ofrece nada, sin un solo
3318error. Es el argumento entero a favor de medir en metal y no leer ficheros.
3319
3320#### 7.decies La bóveda estaba SELLADA y en ninguna imagen — el sexto lugar donde una extensión se enchufa (2026-09-21)
3321
3322El §7.novies cerró la función: las seis etapas en verde, el diálogo a la vista, el control de decir
3323que no. Lo que quedaba abierto era la **decisión 1 del §7.sexies** —«en qué imágenes se declaran»—
3324con su respuesta escrita como **NO**, y con el motivo: mientras ninguna app llimphi pudiera pintar
3325(§7.septies), declararlas era instalar 43 M de binarios que niegan todo sin un error.
3326
3327Ese motivo se cayó el 2026-09-18. La respuesta de hoy es **SÍ, en las cuatro**, y antes de tomarla
3328la pregunta se volvió a medir en vez de darla por sabida:
3329
3330```
3331atuq             sealed   perfiles=['escritorio-cosmic','escritorio-gnome','escritorio-kde','escritorio-sway']
3332puriy-costura    sealed   perfiles=['escritorio-cosmic','escritorio-gnome','escritorio-kde','escritorio-sway']
3333boveda           sealed   perfiles=[]
3334shuma-pregunta   sealed   perfiles=[]
3335```
3336
3337**`sealed` con `perfiles: []` es la lección de `foot` otra vez** —y no por falta de haberla
3338escrito: `targets.toml` la repetía **quince veces** antes de hoy—: una receta sellada que ningún
3339perfil declara NO ESTÁ EN NINGUNA IMAGEN, y la métrica de clausura no lo puede ver porque mide lo
3340declarado. Las dos entran a los cuatro perfiles de escritorio de
3341`docs/state/targets.toml`, **las dos o ninguna**: sin el dueño `vault.match` no ofrece nada, y sin
3342el diálogo `Command::new` falla y TODO `vault.fill` se deniega — media bóveda es una que niega todo
3343en silencio. Cuestan **~43 M por imagen** (22 M + 21 M medidos sobre los artefactos sellados), contra
3344los ~1,25 GiB que ya lleva el §6.7.
3345
3346##### El hueco que apareció al declararlas: estar en la imagen no es poder abrirla
3347
3348Con las dos raíces puestas, la app viaja en las cuatro imágenes y **sigue sin existir para quien la
3349usa**: `recipes/boveda.toml` instalaba `/usr/bin/boveda` y nada más, y los lanzadores de los cuatro
3350escritorios leen `/usr/share/applications`. A un binario que nadie lista sólo se llega escribiendo
3351`boveda` en una terminal. Es **la misma forma de fallo, una capa más arriba**: la función instalada,
3352apagada y sin un error — que es lo único que este capítulo entero viene persiguiendo.
3353
3354Entra `boveda.desktop` en la fase `install` de la receta. Tres cosas se midieron antes de escribirlo:
3355
3356- **el icono existe.** `Icon=dialog-password` es nombre del icon naming spec, y está en los tres
3357  temas que los perfiles declaran: `breeze-icons` (6 ficheros), `adwaita-icon-theme` (1),
3358  `cosmic-icons` (2). El cuarto perfil (sway) lleva sólo `hicolor`, que por diseño no trae iconos:
3359  ahí cae al genérico, que es degradarse y no romperse;
3360- **el fichero lo acepta el validador de verdad** —el `desktop-file-validate` del artefacto
3361  `desktop-file-utils`, con `atuq.desktop` de control, que pasa sin una observación—. Deja un hint
3362  sobre `Categories=Utility;Security;`: que `Security` se empareja con `Settings` o `System`. **Las
3363  dos formas que callan ese hint lo cambian por uno peor** —medido: `Utility;Security;System;` y
3364  `Utility;Security;Settings;` traen dos categorías principales ⇒ «application might appear more
3365  than once in the application menu»—. Se queda como está, que es además lo que usa KeePassXC;
3366- **el `app_id` está, y el `.desktop` no necesita `StartupWMClass`.** `llimphi_ui` arma la ventana
3367  con `with_name` —el `app_id` del xdg-toplevel en Wayland—: toma el `App::app_id()` que la app
3368  declare y, si no declara ninguna (el caso de `BovedaApp`), cae al **nombre del ejecutable**, que
3369  es el piso que llimphi se puso el día que 163 de sus 178 apps compartían el genérico del
3370  compositor. ⇒ `app_id = "boveda"`, el mismo basename que el `.desktop`, y el emparejamiento
3371  estándar ya funciona. Corroborado en metal: el guardián espera `'"app_id": *"boveda"'` en el
3372  árbol de sway y lo encuentra a los 12-13 s. ⚠ Lo que sí queda mal es el **título**: `App::title()`
3373  devuelve `"llimphi"` por defecto y `BovedaApp` no lo sobrescribe, así que la barra y el conmutador
3374  dicen «llimphi». Es de la app, y es su propia unidad.
3375
3376El `.desktop` mueve el hash de la receta: `b3:b0c6adc4` ⇒ `b3:3f1072cc`, reconstruida en el worker
3377con la guarda del §7.quinquies puesta (`### receta verificada 3f1072cc…` antes de compilar nada,
3378porque el latido revierte la receta del worker cada media hora y un acierto de caché sobre la receta
3379vieja imprime `SELLADA` en cero segundos). Y el artefacto se miró por dentro, que es la regla 3:
3380
3381```
3382/.hammer/recipe.toml
3383/usr/bin/boveda
3384/usr/share/applications/boveda.desktop     ← 22 M, y el validador del store lo acepta
3385```
3386
3387##### El SEXTO lugar, y su guardián
3388
3389`test-atuq-boveda-coherente.py` decía —y este documento con él— que una extensión de `atuq` está
3390enchufada en **cinco** lugares. Son **seis**, y el sexto es el que faltaba:
3391
3392| lugar | qué decide | cómo se ve cuando falta |
3393|---|---|---|
3394| `manifest.json` | el id que la extensión declara | — |
3395| `distribution/policies.json` | la política que la instala | navegador sin la función |
3396| `native-messaging/*.json` | el permiso para hablarle al host | `connectNative` falla en silencio |
3397| `atuq.cfg` | las preferencias que la acompañan | el gestor de Gecko se pelea por el campo |
3398| `recipes/puriy-costura.toml` | el COMMIT del host: si sus verbos existen | «verbo desconocido» ⇒ se calla |
3399| **`docs/state/targets.toml`** | **quién DECLARA al dueño y al diálogo en la imagen** | **`ok:true, locked:true`** |
3400
3401El sexto chequeo mira `targets.toml` —el manifiesto de objetivo, no `build-state.json`, que es su
3402derivado— y exige las dos raíces en los cuatro perfiles, con **control positivo**: `atuq` tiene que
3403estar ahí, porque un `in` que no encuentra puede ser una raíz ausente o un campo equivocado y las dos
3404se ven igual. El tercer control negativo (`--negative-control-perfil`) saca a `boveda` de una copia
3405en memoria y exige que esto lo vea; sin él, un chequeo que siempre dice que sí se vería idéntico a
3406uno que funciona. Probado en los dos sentidos: los cuatro perfiles en verde, y el control en rojo.
3407
3408##### Lo que queda abierto, dicho como lo que es
3409
34101. **quién levanta la app.** Hoy: la persona, desde el lanzador. Mientras no esté abierta, la bóveda
3411   del navegador contesta `locked:true` —correcto, y es lo que hace cualquier gestor de contraseñas—,
3412   pero nadie decidió todavía si debe autoarrancar con la sesión. No se decide de paso: autoarrancar
3413   la ata a que la identidad del llavero esté desbloqueada en el login (la decisión 2 del §7.sexies,
3414   que sigue abierta);
34152. **el único proveedor de GL de las cuatro imágenes es `iris` (Intel)** —`mesa-llvmpipe` no está en
3416   ningún perfil y `mesa-swrast` sólo en `escritorio-mirada`—, así que en metal sin GPU Intel el
3417   diálogo no pinta. ⚠ Pero **esto no es un hueco que abra la bóveda, ni una decisión pendiente**:
3418   el recorte iris-only es una decisión ESCRITA (SDD 14 §«Por qué iris-only»), el target declarado
3419   es una laptop Intel Iris Xe, y `llvmpipe`/`radeonsi` piden la cola de LLVM que ese recorte existe
3420   para no traer. El caso de la VM ya tiene camino y también está escrito: los runbooks de QEMU
3421   montan `mesa-llvmpipe` COMO CAPA por fuera de la imagen, que es exactamente lo que hace el
3422   guardián de metal. La bóveda hereda el alcance de hardware de la imagen y no le agrega nada: sin
3423   GL tampoco pinta el escritorio, que también es cliente de GL.
3424
3425#### 7.undecies La bóveda entró a las imágenes y NADIE puede abrirla — y lo que el navegador veía era una bóveda TEMPORAL (2026-09-21)
3426
3427El §7.decies dejó dos cosas abiertas y las dos resultaron la misma: **quién levanta la app** y **de
3428dónde sale la raíz de las claves** (la decisión 2 del §7.sexies). La cadena, medida eslabón por
3429eslabón hacia atrás desde la app:
3430
3431| eslabón | qué dice | medido con |
3432|---|---|---|
3433| `raiz_de_identidad()` | saca la seed del **llavero del kernel**, clave `pacha_llavero::SEED_IDENTIDAD` | el fuente de `pacha-boveda-llimphi` |
3434| quién la ESCRIBE | **dos lugares** en todo tawasuyu: `agora-cli identity unlock` y el onboarding de diseño, `churay-welcome-runner` (que la guarda bajo el mismo nombre) | `git grep` de los `guardar(…)` contra el llavero |
3435| `agora-cli` en las imágenes | receta sellada, **`perfiles: []`** — en ninguna | `build-state` |
3436| …y aunque se declarara | su pin es `9967b02c`, **del 2026-06-18**, donde no existen ni `SEED_IDENTIDAD` ni `desbloquear` | `git grep` sobre el pin, con HEAD de control (4 y 1 aciertos) |
3437| `churay-welcome` | **no tiene receta en takana**: no está en el corpus | `ls recipes/` |
3438
3439⇒ **Hoy, en ninguna imagen de takana hay un binario capaz de sembrar la identidad.** No es que el
3440usuario no la desbloqueó: es que no tiene con qué. La bóveda de la app abre `Err(boveda-cerrada)`
3441siempre, en las cuatro.
3442
3443##### Y lo que el navegador veía NO era «cerrada»: era una bóveda temporal que se traga contraseñas
3444
3445Acá está la parte que importa, y es la que ningún guardián de ficheros podía ver. Cuando `abrir()`
3446falla, la ventana se levanta igual sobre `bóveda_imposible()` —un `sled` en
3447`/tmp/boveda-sin-abrir-<pid>`— **y dice el problema en pantalla**, que está bien y se queda. Lo que
3448estaba mal es la línea siguiente:
3449
3450```rust
3451let compartida = Arc::new(Mutex::new(boveda));
3452atender_al_navegador(&compartida);      // ← sin preguntar si abrió
3453```
3454
3455El socket del navegador se levantaba **sobre esa bóveda temporal**. Y del lado del host, `acceso()`
3456prefiere al dueño si lo hay (`AccesoPrestado::Ajeno`), así que la extensión veía:
3457
3458```
3459vault.status  ⇒  {"ok":true,"locked":false,"count":0}      ← ABIERTA, no «cerrada»
3460vault.save    ⇒  guarda… en /tmp/boveda-sin-abrir-<pid>
3461```
3462
3463O sea: una persona apretaba el botón, el diálogo de consentimiento abría de verdad, decía que sí, y
3464la contraseña se guardaba **en un temporal que muere con el proceso** — y que ni el arranque
3465siguiente de la misma app volvería a mirar, porque el nombre lleva el pid.
3466
3467⚠ **Hasta dónde llega cada afirmación, porque no son todas del mismo tipo.** Que el socket se
3468levantaba sin preguntar si la bóveda abrió está MEDIDO: es el test de regresión, que falla con el
3469arreglo neutralizado. Que la extensión veía `locked:false` es LECTURA del host —`Costura::acceso()`
3470prefiere al dueño (`AccesoPrestado::Ajeno`) y `vault_status` contesta `locked:false` con el `count`
3471del dueño, sea cual sea la bóveda que ese dueño tenga—; medirlo de punta a punta pide el guardián de
3472metal, que no corre en esta máquina (no hay rootfs de sway ni store acá). La etapa que lo mediría es
3473una hermana de la B: lanzar la app SIN `BOVEDA_TEST_ROOT` y exigir `locked:true`. El comentario de
3474`bóveda_imposible` dice «nunca se escribe nada ahí»: es cierto para la ventana, y el navegador es el
3475OTRO cliente. Es la regla 3 del CLAUDE.md entera: **un ausente falla ruidosamente; esto llegaba hasta
3476el final diciendo que todo fue bien.**
3477
3478**Arreglado en tawasuyu** (`7917fbb96`): `atender_al_navegador` toma el problema y **no levanta el
3479socket si la bóveda no abrió**; la extensión ve `locked:true`, que es la verdad y lo que ya sabe
3480mostrar. La ventana sigue levantándose y diciendo por qué, que era lo bueno del diseño y no se toca.
3481Con test de regresión en los dos sentidos —sin bóveda no hay socket; con bóveda abierta el socket
3482aparece, que es el control sin el cual un `atender_al_navegador` vacío pasaría igual—, y probado
3483además AL REVÉS: con el `if` neutralizado a propósito el test falla con el mensaje que corresponde.
3484
3485##### ⚠⚠ Y el muro del `Cargo.lock`, por tercera vez — con una vuelta que NO estaba escrita
3486
3487Antes de poder subir el pin: `cargo metadata --locked` sobre el HEAD publicado de tawasuyu muere con
3488«cannot update the lock file», el mismo modo de fallo del §7.quinquies.bis y del §7.octies. Se
3489aplicó la operación MÍNIMA que el §7.octies dejó escrita —`cargo metadata` SIN `--locked`—, dio un
3490diff de **15 líneas de borrado y cero checksums movidos**, y **resultó byte a byte idéntico** al que
3491otra sesión ya tenía sin commitear en el árbol compartido. Todo encajaba con la vez anterior. Se
3492publicó, se subió el pin… y el build murió con **el mismo error de siempre** sobre el árbol ya
3493pineado.
3494
3495**La causa, y es la parte que hay que recordar: esa operación mínima sólo es correcta donde el
3496registro de cargo está COMPLETO.** Corrida en la jaula qorpa —cuyo registro local no tiene todos los
3497crates— `cargo metadata` falla una descarga, **termina con estado 0** y deja un lock al que le faltan
3498**dos miembros del workspace**. Las dos corridas, lado a lado:
3499
3500| dónde | diff del lock | qué hizo |
3501|---|---|---|
3502| jaula (registro incompleto) | **−15 líneas** | se llevó `cuentas-allichay` y `allichay` |
3503| worker (registro completo) | **+1 línea** | agregó `"llimphi-widget-panel"`, la que faltaba |
3504
3505O sea que **el diff equivocado se ve MÁS mínimo que el correcto** —quince líneas contra una, y cero
3506checksums movidos en los dos—, que es exactamente el criterio con el que el §7.octies enseñó a
3507distinguir el arreglo bueno del malo. Y el «byte a byte idéntico al de la otra sesión», que la vez
3508anterior fue la confirmación de que estaba bien, acá sólo significaba que la otra sesión lo había
3509calculado **en la misma jaula**. Dos mediciones que coinciden no son dos mediciones independientes si
3510comparten el mismo instrumento roto.
3511
3512Publicado el lock bueno en `6f0408d40`, y con él `cargo metadata --locked` pasa en el worker. El pin
3513de las dos recetas sube ahí —se mueven juntas porque comparten árbol de fuentes por `<repo>-<sha>`—
3514⇒ `boveda` `b3:5c43a827` (22 M) y `shuma-pregunta` `b3:cd4277c5` (21 M), selladas en el worker con
3515la guarda de receta puesta y **miradas por dentro**, que es la regla 3:
3516
3517```
3518/.hammer/recipe.toml
3519/usr/bin/boveda                            ← `strings` encuentra el aviso del arreglo: 1
3520/usr/share/applications/boveda.desktop     ← el lanzador del §7.decies sigue adentro
3521```
3522
3523El `strings` no es decoración: el artefacto es lo único que viaja a la imagen, y que el arreglo esté
3524en el COMMIT no dice que esté en el BINARIO — es la lección del §7.quinquies, donde el host conocía
3525los verbos y la función igual no existía.
3526
3527##### ⚠ La guarda de receta del §7.quinquies.bis NO alcanza para una TANDA
3528
3529Las dos recetas se construyeron en un solo comando, con la guarda puesta —hash del worker contra el
3530del hub— **una vez, al principio**. `shuma-pregunta` selló bien. Once minutos después, `boveda` dijo:
3531
3532```
3533INFO takana_build: caché: artefacto ya en el store hash=b3:3f1072cc… name=boveda
3534```
3535
3536que es el hash de la receta ANTERIOR: un acierto de caché en cero segundos sobre el artefacto viejo,
3537o sea el éxito falso que ese capítulo existe para evitar. La causa es la misma y el agujero es
3538nuevo: **el latido (`rsync --delete` hub→worker, cada 30 min) revierte la receta EN EL MEDIO de la
3539tanda**, y una guarda que corre al principio no puede ver lo que pasa diez minutos después.
3540Comprobado: `recipes/boveda.toml` en el worker había vuelto al pin `b80f7567c`, con mtime del ciclo
3541del latido.
3542
3543⇒ **la guarda va PEGADA a cada build, no una vez por tanda.** Y la cura de fondo es empujar la
3544receta a `origin/main` ANTES de construir: el latido siembra desde el checkout del otro hub, así que
3545mientras el commit no esté publicado, cada ciclo deshace lo que uno acaba de subir por `rsync`.
3546
3547ℹ Y un detalle que ahorra construcciones y que conviene tener medido: **los COMENTARIOS de una
3548receta no mueven su hash** —se hashean los campos declarados, no los bytes del fichero—. Comprobado
3549en los dos sentidos sobre `shuma-pregunta.toml`: con un comentario de más y sin él, el mismo
3550`b3:cd4277c5`. Lo que sí lo mueve es el contenido de una fase, que es por qué el `.desktop` del
3551§7.decies costó un build y corregir tres párrafos de comentario no costó ninguno.
3552
3553##### El llavero es el de SESIÓN, y en takana no hay quien lo cree
3554
3555Aun con un sembrador en la imagen quedaría una pregunta que conviene tener medida antes de
3556escribirla mal: `pacha-llavero` usa `KEY_SPEC_SESSION_KEYRING` (-3). Ese llavero **lo crea el
3557login**, y quien no lo tiene recibe uno propio en cuanto lo toca — o sea que dos procesos HERMANOS
3558pueden no compartirlo.
3559
3560Lo medido, y dónde:
3561
3562- **en el worker sí se comparte, y se ve por qué**: `/etc/pam.d/login` y `/etc/pam.d/sshd` traen
3563  `session optional pam_keyinit.so force revoke`, y dos procesos hermanos de la misma sesión ssh
3564  caen en el MISMO anillo — `grep "_ses: 1$" /proc/keys` da uno solo con la clave adentro, y el
3565  censo de `_ses` no crece al repetir;
3566- **en takana no lo crea nadie**: el único `pam.d` que este repo escribe es el de
3567  `scripts/mirada-usb.sh`, y es `pam_permit` en las cuatro líneas. Y más abajo todavía:
3568  `recipes/shadow.toml` compila con `--without-libpam`, así que **el `login` de consola no pasa por
3569  PAM** y `pam_keyinit` no tendría dónde correr aunque se configurara. El módulo SÍ está en la
3570  imagen (`lib64/security/pam_keyinit.so`, dentro de `linux-pam`, que está en las cuatro).
3571
3572⇒ de ahí se SIGUE —y esto es inferencia, no medición: ver el aviso de abajo— que el día que haya
3573sembrador el camino bueno sea el de la misma rama de procesos (desbloquear en la consola y lanzar el
3574compositor desde ahí, que hereda), y que el de «desbloquear en una terminal de adentro» no alcance,
3575porque la app del lanzador es hermana y no hija. **Es lo primero que habrá que medir** cuando exista
3576el sembrador, y se mide con el guardián de metal, que sí tiene dónde correr.
3577
3578⚠ **Lo que NO se pudo medir, dicho como tal:** la herencia hermano/hijo no se probó con una sonda
3579directa. `add_key` da `EPERM` en la jaula qorpa y `keyctl` da `ENOSYS` en el LXC del worker (seccomp
3580de contenedor, las dos), así que el control de la sonda no encendía. Lo de arriba es el censo de
3581`/proc/keys` más la semántica documentada del kernel — no una medición de la herencia.
3582
3583##### Entonces: ¿se quedan las dos recetas en las imágenes?
3584
3585**Sí, y el motivo cambió.** La respuesta era NO cuando la app no podía pintar (§7.septies): eso era
3586una bóveda que niega todo *sin un error*. Hoy la app pinta, dice en su ventana que no hay identidad
3587desbloqueada, y —con el arreglo de arriba— el navegador contesta `locked:true`, que es la verdad.
3588Son 43 M que muestran un estado explicable en vez de uno silencioso. Si mañana se prefiere sacarlas
3589hasta que exista el sembrador, son dos líneas por perfil; queda dicho para que sea una decisión y no
3590un olvido.
3591
3592**La unidad que queda**, con sus dos formas posibles y sin elegir de paso: un **sembrador de
3593identidad en la imagen** — o subir el pin de `agora-cli` a uno que tenga `identity unlock` (y es un
3594pin compartido por árbol de fuentes con `arje-zero` y `dominium-cli`), o traer `churay-welcome` al
3595corpus, que es el camino de diseño y es otra receta llimphi GUI como ésta. Y con cualquiera de los
3596dos, la pregunta del llavero de sesión de acá arriba.
3597
3598#### 7.duodecies El sembrador entra a las imágenes — y de paso: `agora-cli` cifraba la seed de TODO EL MUNDO con una palabra pública (2026-09-21)
3599
3600El §7.undecies cerró con una unidad y dos formas posibles: **un sembrador de identidad en la
3601imagen**, o subiendo el pin de `agora-cli` a uno que tenga `identity unlock`, o trayendo
3602`churay-welcome` al corpus. Ésta es esa unidad, y trajo un hallazgo que no estaba en ninguna de
3603las dos formas.
3604
3605##### Cuál de las dos, con lo medido y no con la intuición
3606
3607| | `agora-cli` | `churay-welcome-llimphi` |
3608|---|---|---|
3609| ¿existe el binario? | sí, receta sellada desde la etapa G | **sí** — `src/main.rs` sin `[[bin]]`, o sea que cargo lo descubre con el nombre del paquete |
3610| ¿siembra `SEED_IDENTIDAD`? | sí, `identity unlock` | sí, `Efecto::GenerarSeedYDesbloquear` por el runner |
3611| ¿pide la frase? | por terminal | en un campo de la ventana — **es el camino de diseño** |
3612| ¿qué MÁS decide? | nada | backend de IA, dotfiles, fondo de pantalla, chasqui, `first_run_done` |
3613
3614⇒ **entra `agora-cli`**, y el motivo no es que sea mejor: es que el wizard trae consigo la
3615experiencia de primer arranque entera, y eso es una decisión de producto que no se toma de paso
3616dentro de una unidad del navegador. `agora-cli` hace UNA cosa, no impone ningún flujo, y es la que
3617el propio tawasuyu nombra como «la que siembra el login» (comentario de `pacha-secretos`). Cuando
3618`churay-welcome` tenga receta, esto se revisa — y queda dicho acá para que sea una revisión y no un
3619descubrimiento.
3620
3621##### ⚠⚠ Y antes de poder declararlo: sin `AGORA_PASSPHRASE`, la frase era una constante pública
3622
3623Esto apareció leyendo el fuente para otra cosa, y es lo que convierte la unidad en obligatoria en
3624vez de conveniente. `Sesion::abrir()` resolvía la frase del keystore así:
3625
3626```rust
3627let passphrase = std::env::var("AGORA_PASSPHRASE").unwrap_or_else(|_| {
3628    eprintln!("agora-cli: usando passphrase de desarrollo \"agora-dev\". …");
3629    "agora-dev".to_string()          // ← y el comando sigue, y termina en ✓
3630});
3631```
3632
3633La cadena que eso toca, medida en el fuente y no supuesta:
3634
3635```
3636la frase  →  Argon2id  →  ChaCha20-Poly1305 que cifra la seed en el keystore  (agora-keystore)
3637la seed   →  SEED_IDENTIDAD en el llavero   →  la clave con la que `boveda` descifra su base
3638```
3639
3640O sea: **en una máquina de trabajo es una comodidad; en una imagen de escritorio es la bóveda de
3641todo el mundo cerrada con una palabra que está escrita en el fuente**. Y otra vez la forma de fallo
3642de este capítulo entero: no falla nada, el aviso va a stderr —donde nadie mira cuando el comando
3643dice ✓— y lo que se ve es un gestor de contraseñas que anda.
3644
3645**Arreglado en tawasuyu** (`fd08dc03a`), con la decisión separada de su ejecución para poder
3646probarla sin una terminal delante:
3647
3648```
3649variable  >  terminal (se pregunta, sin eco)  >  desarrollo (sólo si no hay a quién preguntarle)
3650```
3651
3652- **perezosa**: `identity list` no descifra nada, así que no pregunta; y una vez por proceso, así
3653  que un comando que abre y después guarda no pregunta dos veces;
3654- **doble al crear** (`identity new`, `wawa forjar-clave`): en la génesis no hay nada cifrado
3655  contra qué verificar, así que un error de tipeo no se nota hoy — se nota el día que la bóveda no
3656  abre, y para entonces la seed no se recupera;
3657- **la de desarrollo se queda** para el caso sin terminal (cron, pipes, CI), porque quitarla
3658  rompería a todos los llamadores no interactivos que ya existen. Lo que cambió es que ahora es
3659  una rama elegida y no el default de todos.
3660
3661Cuatro tests sobre `elegir_origen()`, y probado AL REVÉS, que es lo único que dice que el test
3662mide: con el brazo `Preguntar` borrado a propósito, falla con `left: Desarrollo / right:
3663Preguntar`, que es exactamente el comportamiento viejo.
3664
3665##### ⚠ El muro del `Cargo.lock`, por CUARTA vez — y esta vez la causa era otra
3666
3667`cargo vendor --locked` sobre el HEAD publicado murió con el «cannot update the lock file» de
3668siempre (§7.quinquies.bis, §7.octies, §7.undecies). Pero la causa **no** era la de las veces
3669anteriores: no había nada mal en el lock que yo había tocado —mi cambio agrega una sola arista,
3670`rpassword`, que ya era dependencia de workspace y por lo tanto ya estaba en el lock con su
3671checksum—. Lo que faltaba era `shuma-taller`, un miembro que otro agente había metido en un
3672`Cargo.toml` sin que el lock lo acompañara.
3673
3674Conviene tenerlo escrito así porque cambia el diagnóstico: **este muro no lo levanta quien toca el
3675lock, lo levanta cualquiera que toque un `Cargo.toml` del monorepo.** Va a volver.
3676
3677El cierre se hizo donde el §7.undecies dijo —**donde el registro de cargo está completo**—, y acá
3678salió gratis: el árbol ya estaba bajado en el worker por el build que acababa de fallar, así que
3679`cargo metadata` corrió ahí mismo. **+1 línea, cero checksums movidos**, que es la forma que tiene
3680el arreglo bueno; en la jaula, la misma operación devuelve 0 y borra miembros del workspace con un
3681diff que se ve más mínimo.
3682
3683##### ⚠ Y el `Cargo.lock` del árbol compartido traía otra vez el malo
3684
3685Al ir a commitear, `Cargo.lock` estaba en estado `MM`: el ÍNDICE con una versión sin
3686`llimphi-widget-panel` —la arista que el §7.undecies había agregado— y el ÁRBOL con una a la que
3687le faltaban `cuentas-allichay` y `allichay`, o sea **el lock que sale de cerrarlo en una jaula con
3688el registro incompleto, otra vez**. Ninguno de los dos se tocó: el commit se armó con `git
3689commit-tree` sobre el lock de HEAD, sin pasar por el índice ni por el árbol. Es la regla 2 del
3690CLAUDE.md llevada un paso más allá — cuando el fichero que necesitás es justo el que el otro agente
3691tiene a medias, el pathspec no alcanza y hay que salirse del índice.
3692
3693##### Lo que quedó, y cómo se comprobó
3694
3695**El pin**, con el rodeo del lock dentro: `9967b02c` (2026-06-18) → `fd08dc03a` (el arreglo) →
3696`da5fb8968` (el lock cerrado). Sellado en el worker con la guarda de receta **pegada al build**
3697—que es la lección del §7.undecies: el latido revierte la receta EN EL MEDIO de una tanda— ⇒
3698`agora-cli` `b3:46529e14`, **1,9 M**, ELF estático.
3699
3700⚠ **Y un pin distinto es un árbol de fuentes distinto.** No coincide con el de
3701`boveda`/`shuma-pregunta` (`6f0408d40`) ni con el de sus hermanas de monorepo (`9967b02c`:
3702`dominium-cli`, `cosmos-cli`, los `arje-*`), así que paga un vendoreo propio de ~2,4 G. Se paga a
3703propósito: mover `boveda` para que coincidan cuesta dos builds caros de llimphi GUI y arrastra 28
3704commits de upstream sin relación con esto. El día que el pin de `boveda` suba, que suba a éste.
3705
3706**Mirado por dentro, que es la regla 3** — el arreglo está en el COMMIT no dice que esté en el
3707BINARIO (§7.quinquies):
3708
3709```
3710frase NUEVA para el keystore de …   1      ← la doble pregunta de la génesis
3711repetila / las dos frases no coinciden   1 / 1
3712sin AGORA_PASSPHRASE y sin terminal      1  ← la rama de desarrollo, ahora acotada
3713id:default                               1  ← el valor de SEED_IDENTIDAD: SÍ siembra
3714agora-dev                                1  ← control: la frase vieja sigue, para el caso sin tty
3715```
3716
3717**Y probado como artefacto, de punta a punta, con su control** (en el worker, con el binario que
3718va a la imagen):
3719
3720```
3721identity new --name probador   ⇒  nueva identidad creada · id dcedffd4…
3722unlock                         ⇒  ✓ identidad dcedffd4… desbloqueada y cacheada en la sesión
3723/proc/keys                     ⇒  user  pacha:id:default: 32      ← la seed, sus 32 bytes
3724AGORA_PASSPHRASE=la-mala unlock ⇒ keystore: autenticación fallida  ← y NO re-siembra
3725```
3726
3727⚠ **Y de paso, una corrección al §7.undecies: el verbo NO es `agora-cli identity unlock`.** Es
3728`agora-cli unlock`, subcomando de primer nivel; `identity` no tiene `unlock`. Lo decía este
3729documento, lo decía la receta, y hasta el mensaje de error del propio binario dice «crea una con
3730`agora-cli identity new <nombre>`» cuando el nombre va por `--name`. Nada de eso falla en un
3731guardián: se descubre la primera vez que alguien lo escribe.
3732
3733##### El séptimo lugar del guardián de coherencia
3734
3735`test-atuq-boveda-coherente.py` pasa de SEIS lugares a SIETE. El séptimo pregunta lo que queda
3736debajo del sexto —el sexto dice si el DUEÑO está en la imagen; éste, si hay con qué abrirla— y lo
3737hace en los dos planos, porque fallan distinto:
3738
3739| plano | qué exige | cómo se ve cuando falla |
3740|---|---|---|
3741| `recipes/agora-cli.toml` | que el COMMIT pineado llame a `guardar(pacha_llavero::SEED_IDENTIDAD` | `unknown subcommand`, o un binario que hace otra cosa |
3742| `docs/state/targets.toml` | que algún perfil de escritorio lo declare | sellado ≠ instalado: `locked:true` para siempre |
3743
3744Con su control positivo (`SEED_IDENTIDAD: &str`, que cualquier commit del monorepo tiene que
3745traer) y su control negativo propio (`--negative-control-sembrador`, que lo saca de una copia en
3746memoria). **El control positivo pagó solo**: corrido contra el pin VIEJO no contesta «no siembra»
3747sino «CONTROL ROTO: `SEED_IDENTIDAD: &str` tampoco aparece en 9967b02c» — que es la verdad exacta
3748(ese commit es anterior a la constante) y no la acusación equivocada que un `grep` sin control
3749habría hecho. Los cuatro controles negativos, en verde.
3750
3751##### Lo que sigue abierto, y separado de lo que quedó medido
3752
37531. **la herencia del llavero, TODAVÍA sin medir**, y con una corrección sobre cómo NO medirla: el
3754   §7.undecies dedujo del censo de `/proc/keys` que en el worker los hermanos comparten anillo.
3755   Acá se vio que **`/proc/keys` leído como root lista claves que el proceso no necesariamente
3756   puede usar** —una segunda sesión ssh «ve» la clave de la primera—, así que ese fichero prueba
3757   que la clave EXISTE, no quién puede recuperarla. Lo que sí quedó medido y no lo estaba:
3758   `add_key` **funciona** en el LXC del worker (lo hace `agora-cli unlock`, arriba); lo que da
3759   `ENOSYS` ahí es `keyctl`. La pregunta que importa —si la app lanzada desde el menú, que es
3760   HERMANA de la terminal, ve lo que la terminal sembró— sigue pidiendo el guardián de metal y un
3761   lector, no un censo;
37622. **`pacha` y `pacha-secretos` tienen el mismo hueco, una capa al lado**, y esto es medido:
3763   los dos están declarados **sólo en `perfil.servidor`** y los dos sólo LEEN `SEED_IDENTIDAD`, así
3764   que el Secret Service de esa imagen corre en «modo memoria» —los secretos no persisten— sin
3765   sembrador que lo evite. Es menos silencioso que la bóveda, porque `pacha-secretos` publica
3766   `persistente` por D-Bus justamente porque «desde afuera es indistinguible»; pero es la misma
3767   unidad sin hacer. **No se declara `agora-cli` en `servidor` de paso**: es otro perfil, otro
3768   dueño y otra decisión;
37693. **el wizard**, que es el camino de diseño y hoy no tiene receta.
3770
3771#### 7.terdecies «No hay identidad» era también «no pude preguntar» — y en un sitio eso guardaba los dotfiles EN CLARO (2026-09-21)
3772
3773El §7.duodecies cerró con tres pendientes, y el segundo era `pacha`/`pacha-secretos` en
3774`perfil.servidor`: los dos LEEN `SEED_IDENTIDAD`, ninguno la siembra, y nadie en esa imagen puede
3775sembrarla. Esto es ir a contestar eso. La respuesta a la pregunta de origen cambió poco; lo que
3776apareció por el camino, mucho.
3777
3778##### El control positivo salió ROJO, y eso es lo que encontró el defecto
3779
3780El plan era medir con el daemon de verdad: sembrar la identidad, arrancar `pacha-secretos` y ver si
3781dice persistente. Cuatro sondas, con su control:
3782
3783| | sembrado | cómo arranca el daemon | esperado | salió |
3784|---|---|---|---|---|
3785| P0 control negativo | no | normal | modo memoria | modo memoria ✓ |
3786| **P1 control POSITIVO** | **sí, misma sesión, mismo uid** | normal | **persistente** | **modo memoria ✗** |
3787| P2 el caso del servidor | sí | `setsid` (otra sesión) | modo memoria | modo memoria |
3788| P3 otro uid | sí | `su nobody` | — | modo memoria |
3789
3790Con el control en rojo, P2 y P3 **no prueban nada**: los cuatro contestan igual, que es
3791indistinguible de un guardián que siempre dice lo mismo. Así que antes de escribir una sola
3792conclusión hubo que preguntar por qué, y la sonda de syscalls lo dijo en veinte segundos:
3793
3794```
3795add_key            ->    679788099  errno=0
3796keyctl SEARCH      ->           -1  errno=38 Function not implemented
3797```
3798
3799En el LXC del worker —seccomp de contenedor— **se puede sembrar y no se puede cosechar**. La seed
3800estaba en `/proc/keys`, sus 32 bytes, puesta por el `agora-cli` del §7.duodecies; y el Secret
3801Service arrancaba igual diciendo «SIN identidad agora desbloqueada». (En la jaula qorpa las dos dan
3802`EPERM`, que es por qué esto no se puede medir desde acá.)
3803
3804##### Los cinco lugares donde `Option` tenía dos estados y el llavero tres
3805
3806La causa no es el contenedor: es que el lector traduce mal. Estaba en **cinco** sitios, todos con la
3807misma forma —`.recuperar(…).ok().flatten()`, o un `_ =>`—, que convierte **«no se pudo preguntar»**
3808en **«no hay identidad desbloqueada»**:
3809
3810| dónde | qué se ve cuando el llavero falla | gravedad |
3811|---|---|---|
3812| `pacha-cli` (dotfiles) | **los dotfiles se guardan SIN CIFRAR**, sin una palabra, con la identidad del usuario desbloqueada | la peor: es pérdida de confidencialidad, no un mensaje feo |
3813| `pacha-secretos` | «SIN identidad agora desbloqueada — modo memoria» | los secretos no persisten y la culpa apunta al usuario |
3814| `pacha-cli` (`dotfiles pubkey`) | «identidad bloqueada: no hay seed» | acusa de no haber desbloqueado |
3815| `pacha-boveda-llimphi` · `pata-llimphi` | «La bóveda está cerrada. **Se abre al entrar a la sesión.**» | consejo falso: entrar a la sesión no lo arregla |
3816| `wawa-panel-llimphi` | «ninguna identidad desbloqueada» | — |
3817
3818**Y esto ya había pasado, con cicatriz escrita en el propio crate.** El doc de
3819`pacha_llavero::SEED_IDENTIDAD` cuenta que hasta el 2026-08-19 había dos nombres de clave que no se
3820encontraban, y que la consecuencia fue *«el Secret Service arrancaba SIEMPRE en modo memoria aunque
3821la identidad estuviera desbloqueada, y no persistía un solo secreto»* — semanas, sin que nadie lo
3822viera. Se arregló el nombre; **lo que lo hizo invisible seguía puesto**.
3823
3824**Arreglado en tawasuyu** (`4f2b5eac7`): `pacha-llavero` gana `SeedDeSesion` —`Hay` / `NoHay` /
3825`Fallo`— con un `motivo()` cuyos tres textos son distintos a propósito, y los cinco lectores pasan
3826por ahí. `pacha-secretos` gana además `reason` en su interfaz propia `net.tawasuyu.Secretos1`, al
3827lado de `persistent`, que existía exactamente por esto: «desde afuera es indistinguible». Test en
3828los dos sentidos —`los_tres_estados_no_se_colapsan`, con un llavero que siempre rompe y la
3829exigencia de que los textos difieran—, y probado al revés: con `Err(e) => Fallo(e)` neutralizado a
3830`NoHay`, falla en «un Err del llavero TIENE que llegar como fallo».
3831
3832##### La pregunta de origen: ¿entra `agora-cli` en `perfil.servidor`? **NO**
3833
3834Y no por lo que uno supondría. La premisa está medida:
3835
3836```
3837scripts/targets.py --service-paths servidor   ⇒  incluye recipes/pacha-secretos.toml
3838takana service-cards recipes/pacha-secretos.toml ⇒ Card de scope system, "lifecycle": "daemon"
3839```
3840
3841O sea que la Card entra en el `genesis` de la imagen y **arje arranca el daemon en el arranque**,
3842con `setuidgid pacha`. De ahí se sigue, sin necesidad de medir ningún llavero:
3843
38441. **en el arranque no hay nadie logueado**, así que no hay seed que sembrar todavía — un sembrador
3845   en la imagen no llega a tiempo, por construcción;
38462. `abrir_almacen()` se llama **una sola vez, en `main()`**, y el resultado queda en `Estado`: aunque
3847   el operador desbloquee después, el daemon ya decidió y no vuelve a mirar;
38483. y el `agora-cli unlock` de una sesión ssh siembra en **el llavero de ESA sesión**, que no es el
3849   del daemon.
3850
3851⇒ declarar `agora-cli` en `servidor` no cerraría nada y dejaría la función **pareciendo** cerrada,
3852que es peor que el hueco. En el escritorio (§7.duodecies) el sembrador sí tiene sentido porque la
3853persona está delante; acá no hay persona en el momento que importa. **La misma raíz, dos respuestas
3854distintas, y por eso no se copia la decisión de un perfil al otro.**
3855
3856Lo que SÍ lo cerraría, nombrado y no decidido —el propio `pacha-llavero` ya lo tiene previsto: *«la
3857elección de backend de desbloqueo (kernel hoy; mañana PAM/TPM/passphrase+Argon2/greeter) NO está
3858cementada: se cambia la impl de [`Llavero`]»*—:
3859
3860- un backend de llavero que un daemon pueda usar al arrancar (fichero con permisos recortados, TPM
3861  sellado), en vez del llavero de sesión, que es de sesión por diseño;
3862- o que el daemon **vuelva a mirar** en vez de decidir una vez, y que el operador lo destrabe
3863  después del arranque. Hoy ni siquiera eso alcanzaría, por el punto 3.
3864
3865Hasta entonces el Secret Service de `servidor` es efímero —que es lo que su propia receta ya decía—
3866y ahora **lo dice con el motivo correcto** y lo publica por D-Bus. Que es toda la diferencia entre
3867un hueco conocido y uno silencioso.
3868
3869##### Lo sellado, y el antes/después medido contra el artefacto
3870
3871Las dos recetas suben de `23a292863` a `cd9acd0d9` —`4f2b5eac7` más el cierre de su `Cargo.lock`, ver
3872abajo— y sellan en el worker con la guarda pegada a cada build: `pacha-secretos` `b3:e8038352` (4,6 M)
3873y `pacha` `b3:a9fb8c17` (8,5 M). Miradas por dentro, que es la regla 3 —el arreglo en el commit no
3874dice que esté en el binario—:
3875
3876```
3877pacha-secretos   «NO se pudo consultar» 1 · «no hay identidad … en esta sesión» 1 · Reason 1
3878                 control: «SIN identidad agora desbloqueada» (el texto viejo) → 0
3879pacha            «los dotfiles se guardan SIN cifrar» 1 · control: «identidad bloqueada: no hay seed» → 0
3880```
3881
3882Y corriendo el daemon sellado, que es la prueba que importa:
3883
3884```
3885antes:  WARN  SIN identidad agora desbloqueada — modo memoria, los secretos NO persisten
3886ahora:  WARN  modo memoria — los secretos NO persisten
3887              motivo=el llavero de sesión NO se pudo consultar
3888                     (keyring del kernel: Function not implemented (os error 38))
3889                     — esto no es «no hay identidad»
3890```
3891
3892⚠ **Hasta dónde llega esto.** En el LXC las DOS sondas dan ahora «fallo», porque ahí `keyctl` es
3893`ENOSYS` siempre: lo que quedó probado contra el artefacto es que el caso de error **deja de
3894disfrazarse de ausencia**. Que la rama `NoHay` siga diciendo «no hay» sólo lo cubre el test unitario
3895—con un llavero que rompe a propósito—, porque para ejercitarla hace falta una máquina donde el
3896llavero funcione, y ni la jaula ni el worker lo son.
3897
3898##### ℹ La guarda pegada al build volvió a salvar una tanda, y en vivo
3899
3900Las dos recetas se lanzaron en un solo comando, con la guarda por receta que el §7.undecies dejó
3901escrita. La primera selló; la segunda no llegó a construirse:
3902
3903```
3904GUARDA [recipes/pacha.toml]: esperaba b3:a9fb8c17… y hay b3:ea0c664f…
3905```
3906
3907`b3:ea0c664f` es el hash ANTERIOR: el latido (`rsync --delete` cada 30 min) había revertido la
3908receta **entre las dos construcciones de la misma tanda** — comprobado por el mtime de los dos
3909ficheros en el worker, idéntico y de ese ciclo. Sin la guarda eso habría sido un acierto de caché en
3910cero segundos sobre el artefacto viejo, o sea un éxito falso. Con ella, un error y un recopiado.
3911**Es la primera vez que esa guarda se dispara sola**, y confirma que el capítulo que la pidió no
3912estaba siendo paranoico.
3913
3914##### ⚠ Y otra vez el árbol compartido, ahora con un arreglo publicado DESHECHO en el índice
3915
3916Al ir a commitear, `pacha-boveda-llimphi/src/lib.rs` estaba `MM` — y las dos versiones, la del
3917índice y la del árbol, eran **anteriores al arreglo de `7917fbb96`**: la que levanta el socket del
3918navegador sin preguntar si la bóveda abrió (§7.undecies), con su comentario y todo borrado. Mis
3919cambios se habían escrito encima de ESA base sin que nada avisara.
3920
3921Se rehízo sobre HEAD y se commiteó con `commit-tree`, sin tocar índice ni árbol. **Lo que lo
3922delató no fue mirar el diff: fue que el test de regresión
3923`el_socket_del_navegador_sale_solo_si_la_boveda_abrio` estaba en la lista de los que tenían que
3924pasar.** Es el tercer episodio del mismo tipo en dos días (el `Cargo.lock` dos veces, esto una), y
3925la regla que sale es la misma que el §7.duodecies: cuando el fichero que necesitás es justo el que
3926otro tiene a medias, el pathspec no alcanza — hay que salirse del índice, y hay que tener un test
3927que grite si la base era la equivocada.
3928
3929##### ⚠ El `Cargo.lock`, quinta vez — y ahora la arista que faltaba era la MÍA de esa mañana
3930
3931`cargo vendor --locked` volvió a morir. Esta vez la arista ausente era `rpassword` en `agora-cli`,
3932publicada unas horas antes en `da5fb8968` (§7.duodecies): se la llevó `338d4a9b9 "merge de git en
3933«main» (import)"`, que resolvió el lock quedándose con el lado viejo. Bisecado y no adivinado:
3934
3935```
3936git log -S'"rpassword",' da5fb8968..origin/main -- Cargo.lock   ⇒   338d4a9b9
3937```
3938
3939El blob repuesto es **byte a byte** el que ya estaba publicado. Con esto la regla del §7.duodecies
3940queda confirmada del peor modo: **este muro no lo levanta quien toca el lock, lo levanta cualquiera
3941que toque un `Cargo.toml` del monorepo — o un merge que lo resuelva mal.** Y deja un aviso práctico:
3942`recipes/agora-cli.toml` está pineada a `da5fb8968`, que SÍ tiene la arista, así que su artefacto
3943sigue construyendo; quien la suba por encima de `338d4a9b9` sin el commit de reparación se come el
3944muro, y el mensaje de cargo no nombra ni `rpassword` ni el merge.
3945
3946ℹ Y una reparación de paso, porque costó media hora entender el síntoma: en el clon compartido de
3947tawasuyu, **cuatro directorios de `.git/objects` eran de `nobody`** (uid sin mapear, del 2026-09-18),
3948así que cualquier `fetch` cuyos objetos cayeran en `05/`, `a5/`, `b4/` o `b8/` moría con
3949*«insufficient permission for adding an object»* — intermitente, según el sha. Reparados copiando
3950los cuatro objetos a directorios nuevos; `git fsck` limpio.
3951
3952##### Lo que queda, y lo barato que sería
3953
3954`boveda` y `shuma-pregunta` siguen pineadas en `6f0408d40`, o sea **sin** este arreglo: la ventana
3955de la bóveda todavía dice «se abre al entrar a la sesión» cuando el llavero es el que falló. Subirlas
3956las arregla. No se hizo acá porque son dos builds de llimphi GUI y esto era la unidad del servidor;
3957queda dicho para que sea una decisión.
3958
3959⚠ **Este párrafo decía además que subirlas «colapsa dos árboles de fuentes en uno» y que así el
3960vendoreo de 2,4 G ya estaría pagado. Es FALSO, y se corrige en el §7.quaterdecies**, que fue a
3961hacerlo: el árbol de fuentes se nombra por RECETA, no por commit, así que compartir pin no comparte
3962nada. Ya estaba medido, y en CLAUDE.md §3 bis, con estas dos mismas recetas de ejemplo.
3963
3964#### 7.quaterdecies La cola del capítulo anterior — y dos premisas suyas que no eran ciertas (2026-09-22)
3965
3966El §7.terdecies cerró con dos pendientes escritos: subir `boveda`/`shuma-pregunta` al commit que
3967las arregla, y medir la herencia del llavero de SESIÓN entre procesos HERMANOS. Esto es ir a pagar
3968los dos. Los dos se pagaron, y los dos destaparon que la nota que los pedía tenía una premisa
3969equivocada adentro — una por pendiente.
3970
3971##### El pin sube, y el motivo que la nota le ponía no existe
3972
3973`boveda` y `shuma-pregunta` estaban en `6f0408d40`; el arreglo del llavero se publicó en
3974`4f2b5eac7` y `pacha`/`pacha-secretos` ya estaban en `cd9acd0d9`, que lo contiene y además cierra
3975el `Cargo.lock`. Se suben las dos a **`cd9acd0d9`**, no a `4f2b5eac7`: mismo arreglo y encima el
3976lock que `cargo vendor --locked` necesita.
3977
3978Lo que cambia de verdad es un texto, y por eso importa. `pacha-boveda-llimphi` es uno de los cinco
3979lectores que hacían `.ok().flatten()` sobre `SEED_IDENTIDAD` —`Option` tiene dos estados y el
3980llavero tiene tres— así que la ventana decía **«La bóveda está cerrada. Se abre al entrar a la
3981sesión»** en los dos casos: cuando no hay seed sembrada, que es verdad, y cuando el llavero FALLÓ,
3982que es mentira y encima le echa la culpa a quien mira. Un mensaje equivocado no tiene código de
3983salida: el artefacto sella, reproduce, entra a las cuatro imágenes y abre su ventana.
3984
3985⚠⚠ **Y la nota decía que subirlas «colapsa dos árboles de fuentes en uno», con lo que el vendoreo
3986de 2,4 G «ya estaría pagado». No es cierto, y las dos recetas lo repetían en tres párrafos cada una
3987como si fuera el motivo del pin propio.** El árbol de fuentes es
3988`work/sources/<NOMBRE DE RECETA>-<commit>` —`crates/takana-build/src/fetch.rs`, el `format!` de
3989`{}-{}`— y encima se BORRA y se rehace en cada build (`remove_dir_all` antes del `git archive`).
3990O sea que **cada receta paga su copia del monorepo y su `cargo vendor` siempre, con el pin que
3991sea**; lo único compartido es el `CARGO_HOME` del hub, porque `cargo vendor` corre fuera del
3992sandbox: se comparte la DESCARGA, no la copia.
3993
3994Lo incómodo es que esto **ya estaba medido, y en CLAUDE.md §3 bis, con estas dos recetas de
3995ejemplo**: «`boveda` y `shuma-pregunta` salen del mismo `b80f7567…` y ocupaban 2,7 G cada una».
3996La nota del §7.terdecies y los cabezales de las dos recetas afirmaban lo contrario al lado de la
3997medición que lo desmentía. Corregido en los tres sitios. La lección no es nueva —es
3998`la-etiqueta-no-es-el-hecho` otra vez— pero la forma sí: **una frase que justifica una decisión se
3999copia entre ficheros y se revisa menos que un número**, porque nadie la lee como una afirmación
4000medible. Ésta viajó por dos recetas y un SDD.
4001
4002⇒ Corolario práctico, que sí vale: el pin propio de estas dos recetas —vigente desde el 2026-09-18
4003porque necesitaban estar por encima de `eed3120b6`, donde llimphi aprende a pasarle el display
4004handle a wgpu (§7.septies)— **nunca costó un vendoreo extra**. Costaba exactamente lo mismo que
4005compartirlo. Lo que se gana subiéndolas es el arreglo, y nada más.
4006
4007    boveda           b3:5c43a827 → b3:1b7365bf
4008    shuma-pregunta   b3:cd4277c5 → b3:fcc745e8
4009
4010⏳ Los dos artefactos van al **worker**, como las tres veces anteriores: son llimphi GUI y el hub
4011es la jaula qorpa. La tanda se lanzó con la guarda por receta del §7.undecies **dentro** del
4012`flock`, que es donde la ventana existe de verdad — la del §7.terdecies se disparó justamente
4013porque el latido revirtió una receta entre dos builds de la misma tanda. Quedó encolada detrás de
4014la tanda de otro frente (once recetas del monorepo subiendo a `c7545339a`), que es lo que el lock
4015del ADR 0012 hace y para lo que está.
4016
4017###### ⚠⚠ Y el latido no revierte: BORRA — el primer intento se evaporó entero
4018
4019El §7.terdecies contó que el latido había revertido una receta entre dos builds. Falta la mitad:
4020su `rsync -az --delete` no sólo pisa lo que difiere, **borra todo lo que no esté en el árbol del
4021hub**. El primer lanzamiento de esta tanda puso el script y su log en `/opt/takana/` —que parece
4022el sitio natural, es donde está todo lo demás— y en el ciclo de las 10:30 desaparecieron los dos.
4023El `sh` que estaba ejecutando el script murió con él. Lo que quedó fue un `flock` esperando un lock
4024para nadie, y un `tail -F` sobre un fichero que ya no existía: **desde afuera se ve idéntico a una
4025tanda encolada y paciente**, que es como se leyó durante veinte minutos.
4026
4027Y la misma barrida revirtió la receta sembrada a mano, que era la otra mitad del error: seguir
4028sembrando recetas al worker por `rsync` propio es pelearse con el latido cada media hora. Las dos
4029correcciones son una sola frase: **lo efímero vive fuera de `/opt/takana` (acá, `/root`), y las
4030recetas las siembra el latido**. El relanzamiento espera a que el hash pedido APAREZCA en el worker
4031en vez de fallar, con su propio límite y su propio mensaje (`SIN SEMILLA`).
4032
4033ℹ Y el latido siembra al EMPEZAR el ciclo y se pone al día (`git pull --ff-only`) al terminarlo, así
4034que un commit recién empujado tarda **dos** ciclos en llegar al worker, no uno. Se acortó a cero
4035haciendo ese `pull` a mano en el clon del latido —`/opt/takana` del anfitrión, que no es el clon
4036donde se trabaja (`/work/sergio/takana`): son dos árboles distintos y esa distinción no estaba
4037escrita en ningún lado—.
4038
4039##### El OCTAVO lugar: nadie vigilaba que la receta no se quedara DETRÁS de su propio paquete
4040
4041`test-atuq-boveda-coherente.py` miraba siete lugares y los siete preguntaban lo mismo: ¿la pieza
4042ESTÁ? Ninguno preguntaba si la que está es la de hoy. Y ésa es exactamente la forma en que se rompió
4043esto: la receta pineada tres días atrás, el arreglo publicado en el otro repo, y nada que falle.
4044Es el cuerpo del §7.quinquies —el cable tiene dos puntas en dos repos y se desincronizan en
4045silencio— un piso más abajo: allá la punta que se quedaba atrás era el HOST y el síntoma era
4046«verbo desconocido»; acá es la receta de la app y el síntoma es un texto.
4047
4048La pregunta se hace en la forma que **no envejece**: no se busca el símbolo `SeedDeSesion`
4049—renombrarlo dejaría al guardián mintiendo en verde, que es el modo de falla de
4050`umbral-a-mano-envejece`— sino que se le pregunta a git si hay commits en `main` que TOQUEN el
4051paquete que la receta construye y que el pin no tenga:
4052
4053    boveda.toml          → shared/pacha/pacha-boveda-llimphi, shared/pacha/pacha-llavero
4054    shuma-pregunta.toml  → 02_ruway/shuma/shuma-pregunta
4055
4056Y no dice «subí el pin»: dice **cuántos y cuáles**, porque un pin viejo puede ser deliberado y
4057«estás atrás» sin decir de qué no sirve para decidir. El control POSITIVO es que el pin sea ancestro
4058de `main`: si no lo es, el `pin..main` da cero por una razón que no es «al día» —otra rama, un clon
4059sin `fetch`— y el verde sería falso. El control negativo (`--negative-control-deriva`) finge el pin
4060viejo, y es el único de los cinco cuyo «mal» ya ocurrió de verdad:
4061
4062```
4063✗ boveda.toml pineada (6f0408d40) está DETRÁS de 2 commit(s) que tocan su propio paquete
4064      · pacha-boveda-llimphi: 4f2b5eac7 fix(pacha): «el llavero falló» se contaba como…
4065      · pacha-llavero:        4f2b5eac7 fix(pacha): «el llavero falló» se contaba como…
4066```
4067
4068##### La herencia entre HERMANOS: sí, pero sólo si alguien abrió sesión
4069
4070El otro pendiente. El §7.terdecies lo dejó «sin medir» y con la razón bien puesta: `/proc/keys` como
4071root no lo mide, sólo dice que la clave existe. Ver que `add_key` funcionó no dice que el hermano
4072pueda ENCONTRARLA — y en el LXC del worker esas dos cosas se separan de la peor manera.
4073
4074La pregunta es del producto, no del kernel: en las cuatro imágenes la persona corre `agora-cli
4075identity unlock` en una terminal y después abre `boveda` desde el lanzador. Son dos procesos y
4076ninguno es hijo del otro: **hermanos**. Sonda nueva, `scripts/sonda-llavero-hermanos.{c,sh}`, con
4077los roles como `exec` aparte y no como forks —un fork heredaría por definición y la sonda
4078contestaría que sí sin haber medido nada—. Medido en el anfitrión (kernel 7.1.2, root):
4079
4080```
4081A  el ancestro NO tiene sesión propia   sembrada ✓ · el hermano NO la encuentra
4082B  el ancestro se une a una sesión      sembrada ✓ · el hermano la ENCUENTRA (mismo id)
4083```
4084
4085Los dos escenarios se corren **juntos y siempre**, porque el interesante es el contraste: el A
4086solo, en rojo, se lee como «los hermanos no comparten nunca», que es falso y habría cerrado el
4087flujo del escritorio por construcción. Lo que pasa en A es que pedir `@s` sin sesión previa hace
4088que el kernel le fabrique una **al proceso**, y la seed muere con quien la sembró.
4089
4090⇒ **El flujo del escritorio es posible, pero no gratis**: `agora-cli unlock` y `boveda` tienen que
4091colgar de un ancestro con llavero de SESIÓN. Si arje lanza cada app sin eso, la bóveda contesta
4092`locked:true` para siempre y no falla nada — la forma de fallo de todo este capítulo, una capa más
4093abajo todavía. Es un requisito sobre arje que nadie había escrito, y es lo que esta medición
4094agrega sobre «sí, los hermanos comparten».
4095
4096Tres controles, y los tres se dispararon de verdad. El del INSTRUMENTO es el que más vale: corrida
4097en el LXC del worker, la sonda **se niega a contestar** —`keyctl(KEYCTL_SEARCH)` da `ENOSYS`, se
4098puede sembrar y no cosechar— y así reproduce sola la medición del §7.terdecies en vez de citarla.
4099El POSITIVO es que el sembrador se busque su propia clave antes de salir. Y el NEGATIVO —un tercer
4100proceso que salta a una sesión nueva y no la encuentra— **sólo se corre donde el hermano SÍ
4101encontró**: si arriba salió «no», un «no» del control es el mismo «no» y no separa medir de no
4102encontrar nunca.
4103
4104ℹ El primer intento fue en Python con `ctypes` y no corre donde hace falta: el python del anfitrión
4105musl no trae `_ctypes` (falta `libffi.so.8`) y se cae **al importar**, antes de medir nada.
4106`keyutils`/`keyctl(1)` no están en el corpus. Queda el syscall pelado, que además es lo que hace
4107`pacha-llavero`.
4108
4109## 8. Plan, por unidades de trabajo
4110
4111Cada una cierra sola, se commitea y se pushea. El orden no es preferencia: cada una destraba a la
4112siguiente.
4113
4114| # | Unidad | Puerta que abre | Bloqueada por |
4115|---|---|---|---|
4116| 1 | **`waterfox` SELLA** ✅ 2026-09-06 — `b3:88b5a762`, 377 M, BuildID determinista **y REPRODUCE bit a bit**. Cuatro muros, **ninguno de la receta**: takana viejo en el worker, el watchdog barriendo el post-mortem, deps estáticas vs. el cairo bundleado, y una rotura de upstream sólo-Linux | valida la tesis del reúso | — |
4117| 2 | ~~`llvm-toolchain`~~ → **`apk add lld` + `compiler="clang"`** ✅ 2026-09-05 | LTO **y** el muro 3 | — |
4118| 3 | Construir el `firefox` con LTO (hash `b3:6f2a3b2f`) | el eje de velocidad **y** las extensiones de `atuq` | 2 ✅ |
4119| 3.a | **PGO ENCENDIDO** ✅ 2026-09-07 — perfil de 501.521 funciones recogido bajo headless, publicado en el mirror y pineado por sha256; 189 compilaciones con `-fprofile-use`. Sin `jarlog` todavía | la otra mitad de la ganancia | 3 ✅ |
4120| 3.b | **RLBox ENCENDIDO** ✅ 2026-09-06 — cadena wasm de CINCO recetas selladas + guardián que la exige en el binario; firefox sellado **y REPRODUCE bit a bit** (`verificar-repro.sh`: 0 no-determinismos) | cierra el hueco de seguridad del §3.bis | 2 ✅ |
4121| 4 | **`atuq`: receta derivada + overlay de chrome** ✅ v0.1 escrita | el andamio de todo lo demás | 3 |
4122| 4.b | Branding + re-empaque de `omni.ja` ✅ v0.2 | el chrome de verdad | — |
4123| 4.d | **Abrirlo en pantalla** — destapó EXDEV, el lanzador, el `atk` envenenado y la cadena GTK3 estática | «usable», no sólo «sellado» | — |
4124| 4.e | **v0.3: página de inicio y pestaña nueva** por extensión de sistema; exigió `MOZ_REQUIRE_SIGNING` vacío. **Verificado 2026-09-07** con `scripts/test-atuq-inicio.py`: se le pregunta al MOTOR (`browserSettings.homepageOverride` / `newTabPageOverride`) y da `moz-extension://…/inicio.html` en las dos, contra `about:home`/`about:newtab` en el control | y con eso queda destrabada la unidad 6 (`sct`) | rebuild de firefox |
4125| 4.f | **Los códecs, medidos** ✅ 2026-09-07 — H.264+AAC, VP9+Opus, AV1, MP3 y FLAC reproducen; arreglado el `LD_LIBRARY_PATH` que dejaba al RDD sin ffvpx (AV1 muerto en silencio) y nacido `scripts/test-atuq-codecs.sh` con control negativo | «se ve el vídeo», que ningún auditor de ELF podía responder | — |
4126| 4.c | `MOZ_BUILD_DATE` determinista ✅ — `BuildID=19700101000001`, con guardián en install y **`why-differs` 56/56 sobre dos builds reales** | el invariante de reproducibilidad | — |
4127| 4.g | **El chrome se programa desde `atuq.cfg`, sin abrir `omni.ja`** ✅ 2026-09-09 — y de paso: la **vista dividida ya la trae el motor y viene prendida** (fx 154), ejercitada de verdad con `gBrowser.addTabSplitView` ⇒ `activeSplitView` + 2 navegadores. Guardián `scripts/test-atuq-chrome.py` con control negativo del propio motor | retira dos pendientes que eran de upstream, y deja el camino del chrome que NO pelea con el `jarlog` | — |
4128| 4.h | **Quién instala las extensiones, medido** ✅ 2026-09-09 — tres escenarios en `scripts/test-atuq-instalacion.py`: instala el ESCANEO de `distribution/extensions/`, `install_url` con `file://` no instala nada. El README de la receta afirmaba lo contrario y quedó corregido | el mecanismo del que cuelga la extensión de `sct` deja de ser una nota al pie y pasa a estar vigilado | — |
4129| 5 | **Host de native messaging — HECHO 2026-09-10.** `puriy-costura` en tawasuyu (`ebe41e96`) + el cable `shared/foreign-webext`, 14 tests sin navegador; y del lado de acá `recipes/puriy-costura.toml` ⇒ `b3:3b633431`, ELF estático de 952 K **verificado como artefacto**: contesta un marco de 4 bytes y hace la promesa del §6.1 completa (4 visitas aprendiendo → `stable` → un script cambiado = un evento). El camino ya estaba comprobado en el §7.bis con sonda y control negativo | los verbos del §6 | 4 ✅ |
4130| 6 | **`sct` v1 — HECHA 2026-09-10.** Extensión `sct@atuq.tawasuyu` (`filterResponseData` → native messaging) + el manifiesto en `/usr/lib/mozilla/native-messaging-hosts/` + el lanzador que le pone el `--state` que el manifiesto no puede llevar. Guardián `scripts/test-atuq-sct.py`: servidor HTTP real, seis cargas, **una carga = una visita** vigilado, la insignia releída, y dos controles (la 5ª repite el mismo script y no alerta; sin manifiesto no hay veredicto) | el diferenciador que nadie tiene, andando | 5 ✅ |
4131| 7 | **Descargas al CAS — HECHA 2026-09-10.** `cas.ingest`/`cas.list` en el host + la extensión `descargas@atuq.tawasuyu`; ingesta en STREAMING (`arje-cas::almacenar_fichero_en`, nueva) para no cargar una ISO entera en RAM; raíz propia porque el `GcCas` de arje borraría las descargas en silencio; el fichero del usuario no se toca. Guardián `scripts/test-atuq-descargas.py` con descarga real y control negativo | 6.2, y alimenta 6.9 | 5 ✅ |
4132| 8 | **Archivo personal — la mitad de congelar y buscar, HECHA 2026-09-10.** `archive.add`/`archive.search` + la extensión `archivo@atuq.tawasuyu`: el HTML ya ejecutado al CAS, el índice buscable en JSON, y nada de ventanas privadas. Guardián `scripts/test-atuq-archivo.py`, cuyo control negativo además **dice quién** impidió archivar lo privado. **Falta la mitad semántica**: un motor `rag-motor::RagMotor` (como `willay-rag`), que exige daemon de embeddings + LLM real | 6.3 | 5 ✅, 7 ✅ |
4133| 9 | **Medios (6.6) ✅ y torrent (6.9) ✅ — 2026-09-10.** El medio lo abre `mpv`; el torrent lo toma `puriy-costura-torrent`, un daemon **propio, configurable y perezoso** que sobrevive al navegador y cosecha al CAS. Medido antes de decidir: la pila de librqbit compila para musl (226 crates), así que la decisión fue «no ahí» y no «no se puede». **Faltan** el foco (6.5) y la IA local (6.7), ésta bloqueada porque el corpus no tiene modelo ni embeddings | 6.5–6.7, 6.9 | 5 ✅ |
4134| 10 | **Foco (6.5) — 2026-09-10.** Entra al corpus la cadena `nftables` (libmnl + libnftnl + nft 1.1.6), que el grafo pedía y nadie había puesto, con dos arreglos de fábrica: el sello de tiempo que rompía la reproducción y un bashismo. Medido con root: el cgroup en foco no sale y el de al lado sí. Y del lado del navegador, la extensión `foco` MUESTRA el estado y **no tiene verbo para apagarlo**. **Falta** quién pone a `atuq` en un cgroup (delegación, decisión de arje) y quién aplica la política (root) | 6.5 | 6 ✅ |
4135| 11 | **Motor de inferencia local (6.7) — 2026-09-11.** Entra `recipes/llama-cpp.toml` (b10901, estática, 199 M, **REPRODUCE**), que resulta ser **un solo muro para dos pendientes**: el §6.7 y la mitad semántica del §6.3 no eran dos problemas, era que el corpus no tenía con qué correr un modelo (`pluma-llm` sólo tiene backends de nube; `rimay-verbo-fastembed` DESCARGA onnxruntime glibc + el modelo). ⚠ Y dejó medido lo que no se podía deducir: **`SOURCE_DATE_EPOCH` —la variable que nos da reproducibilidad— apagaba las SEIS perillas de ISA de ggml**, y el artefacto sellaba y corría con 0 `%ymm`. Ahora van declaradas y el `install` las comprueba. Guardián `scripts/test-llama-cpp.py` con control negativo vivo. **Faltan** el modelo (fuente pineada, no receta), los verbos del host y quién levanta el servidor | 6.7, y la mitad semántica de 6.3 | 5 ✅ |
4136| 12 | **La bóveda (SDD-BOVEDA §8) — a medias, 2026-09-15.** La décima extensión: ofrece la credencial del sitio abierto y no puede sacar una contraseña por su cuenta —`vault.match` contesta títulos y usuarios; la contraseña sale por `vault.fill`, que **pregunta en el escritorio**—. La dirección la pone el chrome, nunca la página. El gestor de Gecko se aparta como VALOR DE ARRANQUE y no como política. **Destrabada el 2026-09-16** (§7.quinquies.bis): el `Cargo.lock` de tawasuyu publicado —y resultó ser byte a byte el que la otra sesión ya tenía sin commitear—, el pin subido a `23a292863` ⇒ `b3:7d63655a`, construido en el worker (el vendoreo son 2,4 G y el hub estaba al 99%) y medido con el mismo `strings` que había diagnosticado el hueco: `vault` de **0 a 10**, con `cas`/`sct` de control. **Falta**, y no era lo que este renglón decía (§7.sexies, 2026-09-18): el host sellado contesta `vault.status → locked:true` porque **el dueño de la bóveda y el diálogo de consentimiento no estaban en el corpus** — la función está apagada de fábrica en las cuatro imágenes. Entran `recipes/boveda.toml` y `recipes/shuma-pregunta.toml`, **las dos SELLADAS el 2026-09-18** (`b3:59ffd74b` y `b3:99763eca`, construidas en el worker). ⚠ Y con eso apareció el muro de verdad, que no es de la bóveda: **ninguna app llimphi de escritorio abre ventana en esta distro** porque no hay Vulkan en ninguna imagen y el camino GL de llimphi arma la instancia sin display handle (§7.septies). **CERRADA el 2026-09-18** (§7.novies): el guardián de metal —`scripts/test-atuq-boveda-metal.py`— pasa sus **seis etapas**, con el navegador de verdad, el diálogo a la vista y el control de decir que NO. Para llegar hubo que arreglar **tres piezas ajenas al navegador**: el dueño y el diálogo no estaban en el corpus (§7.sexies), ninguna ventana llimphi podía pintar sin Vulkan (§7.septies, arreglado en llimphi) y el dueño atendía de a UN cliente, lo que con el navegador abierto dejaba la bóveda muda (§7.octies, arreglado en pacha con su test de regresión). **Y ENTRA A LAS IMÁGENES el 2026-09-21** (§7.decies): hasta ese día las dos estaban `sealed` con `perfiles: []` —o sea selladas y en ninguna imagen, la lección de `foot` que ese mismo fichero repetía quince veces—, así que la función seguía apagada de fábrica aunque anduviera. Se declaran en los cuatro perfiles de escritorio (~43 M por imagen) y la receta aprende a instalar `boveda.desktop`, porque estar en la imagen no es poder abrirla: sin entrada en `/usr/share/applications` a la app sólo se llega escribiendo su nombre en una terminal. El guardián de coherencia pasa de CINCO lugares a SEIS —el sexto es `targets.toml`— con su tercer control negativo. **Y la decisión 2 CONTESTADA el 2026-09-21** (§7.undecies), con la respuesta incómoda: **nadie puede abrirla**, porque ninguna imagen trae un binario capaz de sembrar `SEED_IDENTIDAD` — `agora-cli` está en `perfiles: []` y además pineado al 2026-06-18, donde el verbo `unlock` todavía no existe; `churay-welcome`, que es el camino de diseño, no tiene receta. Y al medirlo apareció lo que ningún guardián de ficheros veía: cuando la bóveda no abre, la app **igual le levantaba el socket al navegador** sobre una bóveda TEMPORAL ⇒ `vault.status` contestaba `locked:false` y un `vault.save` consentido guardaba en `/tmp/boveda-sin-abrir-<pid>`, que muere con el proceso. Arreglado en tawasuyu (`7917fbb96`) con test en los dos sentidos, y el pin de las dos recetas sube a `6f0408d40`, que trae además el `Cargo.lock` cerrado — con una vuelta nueva: la «operación mínima» del §7.octies, corrida en una jaula con el registro de cargo incompleto, devuelve 0 y BORRA dos miembros del workspace, y su diff se ve más mínimo que el correcto. **Y EL SEMBRADOR ENTRA el 2026-09-21** (§7.duodecies), que es lo que esa respuesta dejaba pendiente: de las dos formas posibles se elige `agora-cli` —el wizard `churay-welcome-llimphi` SÍ tiene binario, pero trae consigo backend de IA, dotfiles, fondo y chasqui, o sea la experiencia de primer arranque entera, y eso no se decide dentro de una unidad del navegador—. Y antes de poder declararlo apareció lo que lo volvía imposible: **sin `AGORA_PASSPHRASE`, `Sesion::abrir()` caía en la frase de desarrollo `"agora-dev"`** con un aviso por stderr y un ✓ en pantalla ⇒ en una imagen de escritorio, la seed de identidad de todo el mundo —que es la RAÍZ con la que la bóveda se descifra— cifrada con una palabra que está escrita en el fuente. Arreglado en tawasuyu (`fd08dc03a`): variable > terminal (se pregunta, sin eco, y DOS veces en la génesis) > desarrollo sólo si no hay a quién preguntarle, con la decisión en una función pura, cuatro tests y el control al revés. Pin `9967b02c` → `da5fb8968` (con el muro del `Cargo.lock` por cuarta vez, y esta vez la arista que faltaba era de OTRO agente: `shuma-taller`) ⇒ `b3:46529e14`, 1,9 M, declarado en los cuatro perfiles, **medido como artefacto**: `identity new` + `unlock` deja `user pacha:id:default: 32` en `/proc/keys`, y con la frase equivocada falla y no re-siembra. El guardián de coherencia pasa de SEIS lugares a SIETE, con su cuarto control negativo. **Y EL CASO `servidor` CONTESTADO el 2026-09-21** (§7.terdecies), con la respuesta CONTRARIA a la del escritorio: **`agora-cli` NO va en `perfil.servidor`**. La premisa está medida —la Card de `pacha-secretos` es `scope=system` y entra al `genesis`, o sea que arje arranca el daemon EN EL ARRANQUE— y de ahí se sigue que un sembrador no llega a tiempo por construcción: en el arranque no hay nadie logueado, `abrir_almacen()` decide una sola vez en `main()`, y un `unlock` de una sesión ssh siembra en el llavero de ESA sesión. Declararlo dejaría la función PARECIENDO cerrada, que es peor que el hueco. Y al ir a medirlo apareció el defecto que sí se arregló: los **cinco** lectores de la seed hacían `.ok().flatten()`, que convierte «el llavero no se pudo consultar» en «no hay identidad desbloqueada» — y en `pacha-cli` eso guardaba **los dotfiles SIN CIFRAR, en silencio**. Lo encontró un control positivo en ROJO (con la seed presente en `/proc/keys` el daemon decía que no había), cuya causa mide la sonda de syscalls: en el LXC `add_key` funciona y `keyctl(KEYCTL_SEARCH)` da `ENOSYS`. Arreglado en tawasuyu (`4f2b5eac7`) con `SeedDeSesion` —tres estados, tres textos— más `reason` en `net.tawasuyu.Secretos1`; pin de las dos recetas `23a292863` → `cd9acd0d9`. **Queda**: la herencia del llavero de SESIÓN entre procesos HERMANOS (sin medir — y `/proc/keys` como root no la mide, sólo dice que la clave existe), y subir `boveda`/`shuma-pregunta` a ese mismo commit, que las arregla y de paso colapsa dos árboles de fuentes en uno | el gestor de contraseñas de la suite dentro del navegador | 5 ✅ |
4137| 9.a | **Proxy por contenedor ✅ v0.5** — contenedores por política + extensión con `proxy.onRequest` | 6.8, y NO dependía de 5: es API de Firefox | — |
4138
4139Las unidades 2 y 4 son **paralelizables**: la toolchain no toca el chrome y el chrome no toca la
4140toolchain. Si hay dos frentes, van juntas.
4141
4142## 9. Por qué esto no abandona a `puriy`
4143
4144`puriy` es el motor DOM/CSS propio con JS real (QuickJS-NG sobre `wasmi`) cuyo límite declarado no es
4145el lenguaje sino **el wiring nativo**: DOM bindings completos, red, render. Eso no se acelera
4146escribiendo otro navegador; se acelera con tiempo.
4147
4148`atuq` no le compite porque no comparte una sola línea con él, y **le sirve** porque todo lo del §6
4149vive del lado Rust: el host del §7, el testigo de `sct`, las descargas al CAS, el archivo con RAG.
4150Son **agnósticos del motor**. Cuando `puriy` madure, se enchufan del mismo lado sin reescribirse.
4151
4152Dicho al revés, que es como conviene recordarlo: **`atuq` es el navegador que se puede usar mientras
4153`puriy` crece, y el andamio de las features que `puriy` va a heredar.** Si dentro de dos años `atuq`
4154se apaga, lo que se tira es una receta derivada y una hoja de CSS; todo lo demás sobrevive.

26-atuq-envoltorio-gecko.md

SDD 26 — atuq: el envoltorio Gecko de la distro

Escrito 2026-09-05, a partir de la pregunta del usuario: «ya habiendo compilado firefox y waterfox, qué tan complicado vs provechoso suena crear nuestro propio envoltorio, en vez de zen?», y de su aclaración de por qué la pregunta existe: puriy quedó muy verde y es difícil sacarlo de ahí.

atuq es zorro en quechua. El nombre estaba libre —verificado con grep -ri atuq sobre /mnt/vvv/takana y /mnt/vvv/tawasuyu, cero coincidencias— y dice exactamente lo que la cosa es: otro fox, con marca propia y sin usar la de Mozilla (ver el §branding de recipes/firefox.toml).

Este documento no reemplaza a puriy ni lo cancela. puriy sigue siendo el motor soberano; el §9 explica por qué atuq es su andamio y no su desvío.


1. La corrección que ordena todo: no hay envoltorio fuera del chrome

La intuición natural —una carcasa Llimphi con nuestros widgets, y Gecko adentro pintando la página— no es posible. Gecko no tiene API de embebido en escritorio desde que murió XULRunner; GeckoView existe y es de Android. No hay forma soportada de meter el motor en una ventana de mirada.

⇒ un envoltorio de Gecko es, necesariamente, chrome-level: JS/CSS/XHTML dentro del propio Firefox, más extensiones, más procesos externos que hablen con él.

Y eso es exactamente lo que Zen es. Zen no toca el motor: su diferencia entera —workspaces, split view, glance, compact mode— vive en el chrome, más una herramienta (surfer) que reaplica sus parches sobre el árbol de Firefox y recompila. Su costo no es el motor: es la cinta de correr del rebase cada cuatro semanas. Copiar su método sería heredar su costo sin heredar su equipo.

2. La decisión de arquitectura: artefacto DERIVADO, no fork de fuente

Si atuq parchea el árbol, cada iteración de UI cuesta un build de Gecko de cuatro horas. Eso mata el proyecto en la primera semana de diseño, porque el diseño de chrome es iterativo por naturaleza.

Pero nosotros no distribuimos un binario: sellamos artefactos. Entonces atuq es una receta cuyo [deps] incluye firefox, y cuyas fases copian el árbol instalado e inyectan encima:

Qué se inyectaMecanismo de FirefoxSin recompilar
Marca, iconos, nombredistribution/, application.ini✅
Prefs de fábricadefaults/pref/*.js + autoconfig (.cfg)✅
Política de empresadistribution/policies.json✅
Chrome propio (dientes, layout)userChrome.css + re-empaque de omni.ja✅
Extensiones nuestrasdistribution/extensions/✅

Ventajas que esto tiene sobre el método de Zen, y que son estructurales:

  1. Iteración en segundos, no en horas. El chrome se toca sin volver a compilar C++.
  2. No mueve el ArtifactHash de firefox. El corpus no se invalida; waterfox y cualquier otro fork siguen compartiendo el mismo artefacto base.
  3. Las CVE se heredan gratis. Un fork de fuente te vuelve dueño del reloj de seguridad del binario más atacado de la máquina — es lo que hundió a casi todos los forks de Firefox, y el reproche histórico a Waterfox. Un derivado se reconstruye solo cuando sube firefox.

Los dos gotchas reales del camino, que son trabajo pero chico:

2.bis Cuándo SÍ hay que parchear el árbol

Cuando algo necesite tocar C++ del motor. Hoy conocemos un caso: sct en su forma fuerte (§6.1). La regla es: nada baja al árbol antes de que exista la toolchain del §3, porque hasta entonces cada intento cuesta cuatro horas. Y cuando baje, el vigía de parches (scripts/vigia-parches.py, commit aa200a4) es lo que convierte esas cuatro horas en un minuto para saber si el parche agarra.

3. La puerta de las optimizaciones: no es un flag, es una toolchain

El usuario decidió habilitar las optimizaciones. Lo que hay que saber antes de intentarlo:

recipes/firefox.toml va con compiler = "gcc" por el muro 3 (zig enlaza libc++ estática para musl y el configure de Mozilla exige encontrar un NEEDED …libc++; es negativa de upstream, no una perilla). El problema es que PGO, LTO y BOLT son cadena de clang en Gecko:

Y el corpus no tiene clang usable como compilador. Verificado en las recetas, no supuesto:

⇒ la primera versión de este documento concluyó que hacía falta una receta llvm-toolchain (clang+lld+libc++ desde fuente). Era caro de más, y se corrigió el mismo día mirando el lab en vez de suponerlo:

Y los tres muros caen sin perder lo que gcc daba: el sondeo de linker se satisface con --enable-linker=lld, el ar lo pone takana solo, y el NEEDED de la stdlib de C++ existe porque clang++ de Alpine usa la libstdc++ compartida — la prueba no es teórica: Alpine construye este Firefox con clang22 y sin libcxx en sus makedepends.

La huella del lab no se movió, y se midió antes de tocar nada. lld no casa ningún prefijo de TOOLCHAIN_PREFIXES (takana-core/src/lab.rs), así que los 43 paquetes que entran en hash_inputs salieron idénticos ⇒ los 837 artefactos sellados quedan intactos. Eso abarata el cambio hoy y a la vez deja un agujero escrito: la versión de lld no es parte de la identidad del artefacto, y sólo expone a las recetas compiler="clang" — hoy, una. Cerrarlo cuesta re-hashear el corpus entero.

Hecho el 2026-09-05 (commit cb3ecd5): firefox va con compiler = "clang", --enable-linker=lld, --enable-lto=cross, --enable-packed-relative-relocs y --with-unsigned-addon-scopes=app,system. Falta construirlo.

Riesgos de PGO, escritos antes de empezar:

3.bis El diff contra Alpine — y no es sólo PGO

Medido el 2026-09-05 trayendo el APKBUILD y el mozconfig de community/firefox de aports y el PKGBUILD de Arch, y comparándolos línea a línea con el mozconfig de recipes/firefox.toml. Alpine es nuestro propio upstream —de ahí salen los once parches de musl— así que la comparación no es contra una distro lejana: es contra la que ya construye este mismo código.

recipes/firefox.tomlAlpine community/firefox
LTOno--enable-lto=cross
PGOno--enable-profile-use=cross + merged.profdata + jarlog
LinkerGNU ld (via gcc)--enable-linker=lld (lld 22)
RELRno--enable-packed-relative-relocs
Sandbox RLBoxAPAGADO (--without-wasm-sandboxed-libraries)--with-wasi-sysroot
Extensiones sin firmarno declarado--with-unsigned-addon-scopes=app,system
Allocator--disable-jemalloc--disable-jemalloc — igual, no es desviación nuestra
Libreríastodo bundleado--with-system-{icu,nss,nspr,av1,vpx,webp,png,jpeg,zlib,ffi,libevent,pixman,pipewire}

Arch hace lo mismo en el eje de velocidad («Do 3-tier PGO», --enable-lto=cross, --enable-profile-use=cross). Ni Alpine ni Arch usan BOLT (cero menciones en los dos ficheros) ⇒ BOLT no es la desventaja, es el extra. Lo que nos separa del resto del mundo es PGO + LTO, del orden de 10-20% en carga de página y JS.

Tres lecturas que hay que sacar de esa tabla:

  1. RLBox apagado es una desventaja de SEGURIDAD, no de velocidad, y probablemente pesa más que el PGO. Es la jaula wasm alrededor de los parsers de fuentes y medios —graphite, ogg, expat, woff2—, o sea exactamente el código que come entrada no confiable. Encenderlo pide wasi-sdk + wasi-compiler-rt en el corpus: receta propia, no un flag.
  2. --with-unsigned-addon-scopes=app,system nos falta y atuq lo NECESITA para shipear sus propias extensiones desde distribution/extensions/ (§6.1, §7). Es un flag de configure ⇒ una capacidad de atuq que no se resuelve en el overlay: exige tocar la base.
  3. Las librerías del sistema son diferencia a propósito, no deuda. Alpine usa las suyas; nuestro corpus es la fuente y bundlear es lo que mantiene la clausura cerrada.

Corolario que ordena el plan: todo esto entra en UN SOLO rebuild de firefox. Cada rebuild son cuatro horas y re-sella la cola Gecko entera (waterfox incluido), así que LTO, PGO, RELR, lld, RLBox y el scope de addons se juntan en una sola pasada, no en seis.

3.ter Los dos muros del PGO que las distros no tienen

El perfil se junta corriendo el navegador, y las dos distros lo hacen bajo xvfb-run (Alpine: xvfb-run -a -s "-screen 0 1920x1080x24" ./mach python build/pgo/profileserver.py; Arch, idéntico). Nosotros no tenemos X11 en el corpus —es la decisión Wayland-only de toda la distro— así que ese camino no existe acá. La salida takana-nativa es correrlo bajo un sway headless (WLR_BACKENDS=headless), que ya tenemos del frente wlr/sway y arranca en segundos.

✅ EL MURO DEL DISPLAY ESTÁ DESPEJADO (2026-09-06). No es una previsión: se corrió. sway headless con WLR_BACKENDS=headless y WLR_RENDERER=pixman arranca en gioser, que es un LXC sin /dev/dri —o sea sin GPU ninguna, todo software— y dibuja píxeles de verdad: docs/evidencia/sway-headless-gioser-sin-gpu-2026-09-06.png (sway 1.10 / wlroots 0.18.2 / foot 1.27.0, los tres binarios nuestros, salida HEADLESS-1 a 1280x720). El ciclo entero es de ~2 min con scripts/wlr/sway-headless.sh.

Dos cosas que hubo que resolver y conviene tener escritas antes de repetirlo: · Hidratar bajo el montaje del store. hydrate-profile.py ENLAZA, y un hardlink no cruza montajes: con --into work/... sale EXDEV porque el store vive en otro disco. Con --into store/.rootfs/sway son 193/193 nodos, 41.677 ficheros y 4,9 G que no ocupan (son hardlinks al store). · Inyectar el loader de musl. sway salió DINÁMICO y pide /lib/ld-musl-x86_64.so.1, que el cierre no trae; el error es exec: /usr/bin/sway: not found, que se lee como «falta el binario» y lo que falta es su intérprete. Se resuelve con --ro-bind /usr/lib/musl/lib/libc.so /lib/ld-musl-x86_64.so.1 (en musl el libc.so ES el loader). Es la misma familia que el resto de la noche: el mensaje nombra lo que buscó, no lo que falta.

Y el perfil no es determinista. Los contadores dependen del timing de la corrida, así que dos generaciones del profdata no dan los mismos bytes — a las distros no les importa porque no persiguen bit-repro; a nosotros nos rompe el invariante. La salida: generar el perfil UNA vez y sellarlo como artefacto propio, consumido por hash desde firefox. El build optimizado vuelve a ser determinista aunque su insumo no lo sea, y el perfil se regenera a propósito, no por accidente.

3.quinquies El PGO, y los tres muros que el plan no tenía escritos (2026-09-07)

El §3.ter daba dos muros: el display (X11 que no tenemos) y el no-determinismo del perfil. Los dos eran ciertos. Aparecieron tres más, y ninguno se ve sin construir:

  1. libclang_rt.profile.a no existía. El lab trae clang 22.1.8 pero ninguna runtime de compiler-rt — su lib/ del resource dir ni existe, exactamente como ya documentaba wasi-compiler-rt para el caso wasm. El build instrumentado murió en el minuto 38:56 con ld.lld: cannot open …/libclang_rt.profile.a. De ahí sale recipes/compiler-rt-profile.toml.

  2. Los sandboxes de Firefox matan la corrida de perfilado. Con ellos puestos los procesos hijo mueren con signal 11, el que renderiza no llega a nacer, el servidor local registra 0 GET y el único .profraw que queda es el del padre arrancando — un perfil de nada, con todo en verde. Es lo mismo que hace el profileserver.py de Mozilla. Sólo aplica a la corrida de entrenamiento: el firefox que se distribuye lleva sus sandboxes intactos.

  3. El worker no puede bajar del mirror de fuentes. Tiene scripts/fuentes/mirror-env.sh desde el 2026-09-01 pero no la clave del Storage Box, así que una receta pineada al mirror —y el perfil es la primera— no se construye allá. Se sembró el artefacto sellado, que es content-addressed: consigue lo mismo sin mover una credencial de máquina.

Cómo se verificó que el PGO llegó, y por qué NO como RLBox. Con RLBox el artefacto delata la jaula (símbolos w2c_*, cero → 634). Acá el discriminante análogo serían las secciones .text.hot/.text.unlikely que clang emite al particionar por temperatura — y no sirve: con lld y ThinLTO el enlazador las fusiona, así que dan cero en el binario con PGO y sin él. La evidencia que sí vale está un nivel por debajo del configure: 189 invocaciones del compilador llevan -fprofile-use, el configure encontró llvm-profdata, y no hay ni un aviso de perfil que no cuadre. libxul.so pasa de 226.078.752 a 227.802.816 bytes, consistente con inlining de caminos calientes. Es evidencia de build, no de artefacto, y conviene decirlo así.

⚠⚠ LEER PRIMERO: LOS PORCENTAJES DE LAS TRES TABLAS QUE SIGUEN ESTÁN DILUIDOS Y NO SON LO QUE PARECEN. Miden el ciclo completo del proceso, del que ~16 s son arranque. La corrección, con el control que faltaba, está más abajo en «EL CONTROL QUE FALTABA». Se dejan tal cual se publicaron, sin retocar, porque el error de método es el que enseña.

LA GANANCIA, MEDIDA (2026-09-07). Mismo rootfs, mismo script, corridas INTERCALADAS A/B/A/B para que la deriva de carga afecte a las dos, mediana de 7, calentamiento descartado. Lo único que cambia entre variantes es un --ro-bind de /usr/lib/firefox.

páginasin PGOcon PGO
DOM+layout+strings, fuera del corpus de entrenamiento23.540 ms21.327 ms−9,4 %
SunSpider 3d-raytrace, dentro del corpus16.034 ms15.878 ms−1,0 %

Los rangos no se solapan en ninguna de las dos (23.185–23.654 vs 21.030–21.650), así que las dos diferencias son reales y no ruido.

⚠ Y el resultado sale al revés de lo esperable, que es lo interesante. La intuición dice que medir sobre el conjunto de ENTRENAMIENTO infla la ganancia; acá el corpus da el número MÁS BAJO. La razón es que 3d-raytrace es aritmética pura en un bucle caliente, y ese bucle no lo ejecuta el C++ de SpiderMonkey: lo ejecuta código máquina que el JIT genera en tiempo de ejecución. El PGO optimiza el intérprete, el GC y el propio compilador JIT — no el código que el JIT emite. Un benchmark JIT-bound es casi ciego al PGO por construcción.

Donde sí se ve es en el camino DOM/layout/arranque, que es C++ de principio a fin. Y eso es también lo que un usuario percibe: la medición cubre el ciclo COMPLETO del proceso (arrancar → renderizar → capturar → salir), no el rendimiento en régimen de una página ya cargada. De ahí que hasta la página de SunSpider tarde 16 s: casi todo es arranque.

Corolario para el corpus de entrenamiento — APLICADO Y MEDIDO (2026-09-08). El corpus estaba sesgado hacia JS justo donde el PGO menos rinde, así que se le sumaron 10 páginas de maquetación propias (scripts/pgo-corpus/): flexbox, grid, tablas con los dos algoritmos de layout, texto en columnas, selectores contra 600 reglas, pintado, transforms, SVG, scroll pegajoso y layout thrashing. El perfil pasó de 5.885.254.799 ejecuciones registradas a 38.498.366.335 (6,5×).

sin PGOPGO v1 (36 pág.)PGO v2 (46 pág.)
DOM/maquetación, fuera del corpus23.285 ms21.050 ms (−9,6 %)20.640 ms (−11,4 %)
SunSpider 3d-raytrace16.003 ms15.846 ms (−1,0 %)15.857 ms (−0,9 %)

La mejora en maquetación es real y no de medianas: SEIS de las siete muestras de v1 son más lentas que TODAS las de v2; sólo una cae dentro del rango de v2. En SunSpider, en cambio, v1 y v2 son indistinguibles —los valores se entrelazan por completo— que es justo lo esperable: ese camino lo ejecuta el JIT y el PGO no lo toca. Un corpus mejor no puede mejorar lo que el PGO no alcanza.

⚠ Una de las siete corridas de v2 dio 120.029 ms contra ~20.600 de las otras seis: un atípico transitorio. La mediana lo ignora por diseño; una media lo habría convertido en «v2 es catastróficamente peor». Fue la razón de elegir mediana antes de ver un solo número.

EL JARLOG: PROBADO, MEDIDO, Y APARCADO PORQUE ROMPE atuq (2026-09-08). Desde d9833a58 el perfil trae también el jarlog y firefox lo consume con --with-pgo-jarlog. No hizo falta la extensión Quitter de Mozilla, que este documento daba por bloqueante: su profileserver.py sólo traduce JARLOG_FILE a MOZ_JAR_LOG_FILE en el entorno del navegador.

sin PGOv1 (36 pág.)v2 (46 pág.)v3 (= v2 + jarlog)
DOM/maquetación23.008 ms20.851 (−9,4 %)20.527 (−10,8 %)20.502 (−10,9 %)
SunSpider16.006 ms15.859 (−0,9 %)15.869 (−0,9 %)15.838 (−1,0 %)

El jarlog solo aporta −0,1 % y −0,2 %: indistinguible de cero. Y el argumento no es que los rangos se solapen —que se solapan— sino que la misma variante varía ~1 % entre corridas de días distintos (v1 dio 20.851 hoy y 21.050 ayer). El suelo de ruido del banco es diez veces el efecto buscado.

Eso NO es un fallo del jarlog: es que el banco corre con la caché de página caliente, y ahí reordenar omni.ja no ahorra ninguna lectura. Su terreno es el arranque EN FRÍO, que este instrumento no reproduce; medirlo pediría vaciar la caché entre corridas, que es una acción de sistema. Lo que sí está probado es que la función existe, y del lado del artefacto: libxul es IDÉNTICO byte a byte entre v2 y v3 y el omni.ja difiere en 26 bytes — el jarlog tocó exactamente lo que debía tocar y nada más. Y sí costaba. Al reconstruir atuq sobre ese firefox, murió:

zipfile.BadZipFile: Bad magic number for central directory   (rebrand.py)

El jarlog convierte el omni.ja al formato «jar optimizado» de Mozilla, que mueve el directorio central al principio:

sin jarlog:  PK\003\004    ZIP estándar — `zipfile` lo abre, 5306 entradas
con jarlog:  \376\204#\0   `zipfile` lo rechaza

Y atuq reempaqueta el omni.ja con zipfile para su branding. O sea que el jarlog rompe el navegador propio de la distro.

Aparcado, y la cuenta es asimétrica: el coste está MEDIDO y el beneficio NO. Cambiar algo que funciona por una ganancia que no se pudo medir, rompiendo algo que sí funcionaba, es mal negocio. Se retoma cuando (1) se mida el arranque EN FRÍO, que es su terreno, y (2) rebrand.py sepa leer el jar optimizado o des-optimizarlo antes. El blob 92497cdd… del mirror ya trae el jarlog, así que retomarlo es cambiar una línea y re-pinear el sha256 — no volver a perfilar.

⚠ Y la lección de método, que es la que más vale: el jarlog parecía GRATIS. «Es lo que hace upstream y no cuesta nada» — lo escribí yo, en este documento, hace unas horas. El coste no apareció midiendo el jarlog sino construyendo lo que dependía de él. Una función que se declara gratuita sin haber reconstruido a sus consumidores no es gratuita: es no medida.

⚠ Dato que los datos delatan: los dos valores atípicos del banco son 120.034 y 120.030 ms, o sea exactamente el timeout 120 del arnés. No son ruido de carga: son corridas COLGADAS al arrancar en headless, 2 de 56 (~3,5 %). Sin perseguir, pero anotado.

⚠ Y lo que este banco NO puede afirmar: las 10 páginas nuevas ejercitan los mismos subsistemas que la página de medición, así que el parentesco con lo que ahora se entrena es mayor que antes. El −11,4 % es real para ESTA carga; generalizarlo a «cualquier página» sería justo el error que el propio corpus de Mozilla cometía al revés.

EL CONTROL QUE FALTABA: el arranque eran 16 s, y sin restarlo ningún porcentaje significa nada (2026-09-08)

Todo lo de arriba mide el ciclo completo del proceso: arrancar firefox, renderizar, capturar el PNG y salir. Nunca se midió cuánto de eso era la página. Faltaba el control más barato posible —una página VACÍA— y con él las cifras publicadas se leen distinto:

cargatotal sin PGOtotal con PGOtrabajo sin → conmejora del TRABAJO
vacia.html (arranque puro)16.026 ms15.883 ms— (ES la línea base)−0,9 %
DOM/maquetación23.307 ms20.627 ms7.281 → 4.744 ms−34,8 %
carga ajena (canvas/JSON/img)20.576 ms20.517 ms4.550 → 4.634 ms+1,8 % (nada)
SunSpider 3d-raytrace15.992 ms15.877 ms−34 → −6 msbajo el ruido

Las cuatro cargas y las dos variantes, en una sola sesión intercalada: restar el arranque de una corrida al total de otra no vale, porque la misma variante deriva ~1 % entre días.

Qué cambia, en concreto:

  1. El «−10,9 %» que publiqué es correcto como número y engañoso como afirmación. Es real para el ciclo completo, que es lo que un usuario espera al abrir el navegador. Pero se leía como «firefox es un 11 % más rápido», y sobre el trabajo de la página la mejora es −34,8 %: el PGO rinde TRES VECES más de lo que dije. El error subestimaba mi propio resultado.

  2. La explicación que di del SunSpider era aire. Escribí que el −1,0 % se explicaba porque un benchmark JIT-bound es ciego al PGO. Mirá la fila: su trabajo mide −34 ms, negativo — la página con el benchmark tardó MENOS que la vacía. 3d-raytrace corre en decenas de ms y su trabajo es más chico que la variación entre corridas. Ahí no había nada que medir. El −1,0 % era el −0,9 % del arranque, y yo le colgué encima una teoría sobre el JIT.

    La teoría puede ser cierta —el mecanismo es real y está bien descrito— pero esta medición nunca la probó, y la presenté como si sí. Un número real más un mecanismo real no hacen una explicación: hace falta que el número mida el mecanismo.

  3. La carga ajena confirma que el PGO no es magia. +1,8 % sobre su trabajo, o sea cero: mejora lo que se le entrenó (maquetación, −34,8 %) y no lo que no (canvas/JSON, 0 %). Eso es exactamente lo que un PGO honesto debe hacer, y es mejor evidencia de que funciona que el número grande.

  4. El jarlog no se rehabilita. Su −0,1 % diluido pasa a ~−0,5 % sin diluir; sigue muy por debajo del ~1 % que la misma variante deriva entre días. La conclusión de aparcarlo no se mueve.

⚠ La lección, que es la misma de toda esta jornada con otro disfraz. El instrumento funcionaba, las medianas eran correctas, las corridas estaban intercaladas, los rangos no se solapaban — todo el rigor estadístico estaba puesto sobre una cantidad que no era la que yo creía estar midiendo. Ninguna cantidad de repeticiones lo habría revelado: sólo lo revela un control, y el control costó siete corridas de una página vacía. Antes de refinar la precisión de un número, hay que gastar una medición en averiguar qué mide.

Y el detalle que lo vuelve barato de recordar: el control salió NEGATIVO en una fila. Un valor imposible es la forma más limpia que tiene una medición de avisar que el modelo está mal, y por eso esa fila se deja publicada con su signo menos en lugar de redondearla a cero.

Y la pregunta que un insumo no determinista obliga a hacer, respondida: firefox SIGUE REPRODUCIENDO bit a bit (verificar-repro.sh: REPRODUCEN 1, DERIVA 0, NO-DETERMINISMO 0). Era el riesgo real de esta unidad, no el rendimiento: si el -fprofile-use hubiera metido cualquier decisión dependiente del orden o del timing, habríamos cambiado velocidad por el invariante del proyecto. Congelar el perfil como fuente pineada era el argumento; esto es la medición.

3.quater La cadena wasm, y las tres cosas que costó (2026-09-06)

--without-wasm-sandboxed-libraries se cambió por --with-wasi-sysroot=/usr/share/wasi-sysroot, que es literalmente el flag de Alpine. Detrás hay cinco recetas nuestras, en este orden obligatorio:

wasi-libc-headers → wasi-compiler-rt → wasi-libc → wasi-libcxx → wasi-sdk → firefox

wasi-libc-headers existe sólo para romper el ciclo del bootstrap y pinea un commit VIEJO a propósito (triple wasm32-wasi), mientras wasi-libc pinea el nuevo (wasm32-wasip1, el que clang 22.1 usa de verdad). Es lo mismo que hace Alpine y no es un descuido.

Lo que costó, que es la parte reusable:

  1. check-symbols falla por sesgo de clang, no por un artefacto malo. El lab trae clang 22.1.8 y el snapshot del árbol viene de uno anterior: sobra #define __wasip1__ 1 y nada más. Se reconcilia el snapshot; no se salta con make no-check-symbols, que se lleva por delante la comparación de símbolos —la mitad que sí vale y que pasaba byte a byte.

  2. Faltaba wasi-libcxx, y el hueco era visible en el grafo sin construir nada. El configure murió a los 6 s con 'cstring' file not found, culpando a los headers de WASI, que estaban perfectos: <cstring> es C++ y las librerías enjauladas no son todas C. El wasi-sdk de Alpine declara wasi-libc wasi-libcxx wasi-compiler-rt —TRES— y la cadena tenía dos. El depends del paquete ajeno equivalente es una comprobación de completitud gratis, antes de gastar CPU.

  3. Un directorio VACÍO que sí importa. Con wasi-libcxx sellado el configure volvió a morir igual, y cstring estaba en el artefacto: clang no lo buscaba. Falta el mkdir -p include/c++/v1 que Alpine tiene en su package() y que parece ruido de empaquetado — es la sonda por la que el driver decide que el sysroot tiene layout de libc++. Medido dentro del lab: sin ese directorio, 'cstring' file not found; con él, exit 0. Es el reverso exacto de la regla 3 del CLAUDE.md: allá un directorio vacío es un artefacto mentiroso, acá es una declaración dirigida al compilador.

De ahí sale el criterio de guardián que ahora usan estas recetas: «existe» no es «se encuentra». wasi-libcxx corre hoy la misma prueba que hace el configure de firefox, contra un sysroot fusionado con el de su dep, así que un fallo aparece en el segundo 20 de una receta chica —con la lista de búsqueda de clang impresa— y no en el segundo 6 de un build de cuatro horas que además culpa a otro. Y firefox exige RLBox en el binario: sobre b3:352d7880 (el último con RLBox apagado) los patrones w2c_, rlbox y wasm2c dan cero los tres en libxul.so, así que cualquiera apareciendo prueba que la jaula entró. Es la tercera repetición de la lección de MOZ_REQUIRE_SIGNING y MOZ_BUILD_DATE: una bandera que el configure acepta y el artefacto ignora.

Evidencia de reproducibilidad cruzada (2026-09-06). Las cinco recetas se construyeron por separado en las DOS máquinas —el hub (gioser, 4c) y el worker (dev.gioser.net, LXC 6c)— y los cinco artefactos salen byte a byte idénticos, comparando el árbol completo de cada uno:

recetasha256 del árbol
wasi-libc-headers0d34f9cb27530606…✓
wasi-compiler-rt0c4fc6fdf08ef908…✓
wasi-libcc13acdf896f2b78c…✓
wasi-libcxxb1d9aeec08499063…✓
wasi-sdkb49f7a74a5caed3f…✓

No es un trámite: el lab NO entra en hash_inputs, así que dos labs distintos pueden sellar bytes distintos en la misma dirección del store y nada lo detecta. Esto dice que para esta cadena los dos labs coinciden de verdad, y no sólo que coinciden los ArtifactHash.

4. Lo que atuq NO promete

Esta sección existe antes que la lista de features a propósito, con el mismo criterio que el modelo de adversario de qullqa: lo que no se promete se escribe primero, porque una promesa falsa de privacidad es peor que no ofrecer nada.

atuq no es Tor Browser y no va a tener «modo Tor». Tor Browser no es «Firefox + SOCKS»: su valor son las contramedidas de huella (RFP, letterboxing, normalización de fuentes/canvas/timers) y sobre todo un conjunto de anonimato donde todos se ven idénticos. atuq es, por construcción, un binario único en el mundo: musl, Wayland-only, branding propio, extensiones propias. Alguien saliendo por Tor desde atuq es más identificable, no menos, y su conjunto de anonimato son diez personas. Para anonimato: Tor Browser, y lo decimos nosotros primero.

Lo que sí se ofrece, y es honesto porque es otra cosa: proxy por contenedor (§6.8) — separación de tráfico, no anonimato.

atuq tampoco promete anti-fingerprinting propio (es un programa de investigación con equipo full-time) ni bloqueo de anuncios propio (se shipea uBO y listo).

5. Contexto verificado al escribir (2026-09-05)

De docs/state/build-state.json, no de memoria:

DatoValor
Recetas / nodos838 / 840
Selladas837
firefox 154.0sealed, cola corpus
waterfox 6.7.1.1never — primer build en curso
Plataforma compartidagtk3, atk, nodejs, clang18, cbindgen: todas sealed

atuq no arranca hasta que waterfox selle. Waterfox es la prueba de la tesis del reúso —«las diferencias reales con firefox.toml son TRES: la fuente, el branding y la versión»—. Si esa tesis falla, el precio de todo este documento cambia y hay que releerlo.

6. Los diferenciadores, priorizados

La columna «quién más lo tiene» es lo que evita que nos contemos un cuento.

#QuéQuién más lo tienePieza que ya existeCosto
6.1sct — transparencia de scriptsnadiepuriy-sct, puriy-sct-testigomedio
6.2Descargas direccionadas por contenidonadiestore CAS de takana, tejidobajo
6.3Archivo personal + RAG localRewind/Recall (nube, Windows); SingleFile (guarda, no busca)khipu, rag-motor, willay-ragmedio
6.4Historial y perfil sobre qullqanadiequllqa-core, qullqa-pozomedio
6.5Foco por cortafuegos, no por extensión ✅ cadena y navegador; falta quién lo aplicanadie (nadie es dueño del navegador y del sistema)cortafuegos, nftables (nuevo en el corpus)bajo
6.6Medios por fuera del navegadorextensiones sueltas; de fábrica noforeign-ytdlp, -platform, -dlnabajo
6.7IA local en la barra lateral ✅ motor; falta modelo y barraChrome/Edge son nube; Zen no tienellama-cpp (NUEVO en el corpus) — rimay/iniy NO servían, ver §6.7bajo
6.8Proxy por contenedor ✅ v0.5nadie de fábrica— (API de Firefox)bajo
6.9Torrent adentroVivaldi lo trae; Brave tuvo WebTorrentshared/foreign-torrent (SDD propio, sobre librqbit)bajo

6.1 sct es la joya

BLAKE3 a todo lo que se ejecuta, y negarse a correr código que ningún testigo vio nunca. Es lo más cerca que hay de «Certificate Transparency, pero para el JavaScript que te corre en la cara», y no lo tiene nadie: ni Zen, ni Brave, ni Tor Browser.

En tawasuyu ya está escrito y —dice su README— la lógica es agnóstica del transporte y está certificada sin red; puriy-sct-testigo es sólo el cable HTTP.

6.1.bis Lo que costó armarla, que es lo que no se puede deducir (2026-09-10)

Cinco cosas se midieron en vez de suponerse, y cada una tenía su forma de fallar en silencio:

QuéCómo falla si se supone mal
El manifiesto va en /usr/lib/mozilla/native-messaging-hosts/, no en el appdir (XRESysNativeManifests es un /usr/lib/mozilla compilado)el puerto se cierra «sin error y sin mensaje»
El manifiesto NO puede llevar argumentos — NativeMessaging.sys.mjs: command = manifest.path, y los únicos argumentos son [ruta-del-manifiesto, id]puriy-costura sin --state corre en memoria ⇒ cada arranque vuelve a «aprendiendo» y nada alerta nunca: la función existe, no falla, y no protege
filterResponseData + el permiso webRequestFilterResponse están en nuestro omni.jase habría escrito la extensión contra una API que el build no tiene
Los inline no se ven por esta víase habría prometido cobertura total teniendo la mitad
Una carga puede producir dos peticiones del mismo documento, y una llega con tabId = -1agrupando por (tabId, documento) esa carga contaba dos visitas

La última es la que más importa y la que menos se veía. Con las visitas infladas, un origen se estabiliza antes de conocer su código real, y entonces alerta por churn legítimo: el falso positivo que la spec de puriy-sct pide evitar por encima de todo. Se agrupa por documento —lo que hace que dos pestañas con la misma url cuenten UNA, que es el error seguro: tarda más en proteger, no alerta de más— y el guardián vigila el invariante «una carga, una visita», así que si vuelve, falla ruidoso.

Y el aviso se mide, no se supone. La extensión relee la insignia con getBadgeText después de ponerla y lo dice, así que el guardián exige insignia vacía en las cinco cargas sin novedad y "1" en la del script cambiado. Es la única parte de toda la cadena que el usuario llega a ver: dejarla como «se llamó a la API» sería dejar sin medir justo el final.

Dos límites escritos donde no se pueden no leer (el propio fondo.js, el lib.rs del host y los dos LEEME): se hashea el TEXTO ya decodificado, que vale para comparar dos cargas nuestras y no es comparable con el hash de los bytes servidos que publique un tercero; y el aviso es pasivo (insignia, no modal) porque un modal por despliegue entrena a cerrarlo sin leer.

6.2 Una descarga con identidad

Lo bajado no es un archivo en ~/Downloads: es un objeto BLAKE3 en el store — deduplicado, verificable, y re-compartible por tejido. El torrent (6.9) alimenta lo mismo. Ningún navegador trata una descarga como algo con identidad; para todos es un blob con nombre, y por eso lo bajás dos veces y no lo sabés.

Ésa, y no «tener torrent», es la razón por la que 6.9 vale: Vivaldi ya tiene torrent, pero termina en una carpeta.

6.2.bis Hecha (2026-09-10) — y el almacén NO es el del sistema, por una pérdida de datos medida

cas.ingest en el host + la extensión descargas@atuq.tawasuyu, que observa las descargas TERMINADAS y le pasa la ruta. El objeto entra a un CAS BLAKE3 y el navegador contesta el hash, el tamaño, con qué nombre apareció la primera vez y cuántas veces se bajó ese mismo contenido.

1ª descarga  contenido A como `uno.bin`   ⇒ dedup=false, veces=1
2ª descarga  contenido A como `dos.bin`   ⇒ dedup=TRUE, veces=2, UN solo blob en disco

No es un almacén nuevo: es arje-cas, el mismo formato y el mismo hash que usan arje, takana y tejido — lo dice el propio código de tejido («esto NO es un almacén nuevo»). Un objeto que entra por acá queda donde los otros lo van a buscar, y el <hex> del CAS es el expected_hash de un .swm.

Tres cosas que este trabajo dejó, y ninguna es la feature:

  1. arje-cas gana ingesta en streaming. store toma &[u8], así que ingerir un fichero obliga a leerlo entero: para los binarios de arje da igual, para lo que baja un navegador no —una ISO de 4 GiB serían 4 GiB de Vec—. almacenar_fichero_en hace UNA pasada (lee, hashea y escribe a la vez, a un temporal con el PID, y recién entonces renombra al <hex>) y devuelve (hash, bytes, ya_estaba): ese ya_estaba es la deduplicación vista desde afuera.
  2. ⚠ La raíz del CAS de descargas no es la del sistema, y es por una pérdida de datos medida. arje_cas::gc borra todo blob que no esté en el set reachable de su llamador, y el único llamador que existe hoy —arje-brain::introspect, IntrospectRequest::GcCas— lo arma con la cadena de audit + las raíces vivas del grafo + lo que le pase el caller. Una descarga del usuario no está en ninguno de esos conjuntos: el primer GcCas se la llevaría, en silencio. Por eso van a <estado>/descargas-cas: mismo formato (mover un objeto al CAS del sistema es un rename), fuera del alcance del GC de otro. Lo que se pierde, dicho: tejido sirve el CAS por defecto, así que compartir una descarga hoy exige apuntarlo a esta raíz (ENTE_CAS_ROOT, la perilla que su propio código documenta) hasta que el índice de descargas sea una raíz que el GC respete.
  3. No se toca el fichero del usuario. Se copia al CAS y se deja donde estaba. Borrar o mover lo que alguien acaba de bajar —aunque «ya esté en el CAS»— no es una decisión que le toque a un navegador tomar por su cuenta, y el guardián lo comprueba byte a byte.

El índice va en JSON y no en postcard a propósito: es la lista de lo que el usuario bajó y tiene que poder leerse con cat el día que el binario no arranque. El estado de sct es interno; esto es suyo.

Y ahí apareció el bug que valía más que la función: hay UN HOST POR PUERTO, no uno por perfil. Con sct y descargas hablando hay dos procesos vivos a la vez, cada uno con su copia en memoria del estado, y cada uno escribía el fichero ENTERO al guardar. El de descargas borraba el registro TOFU de sct al salir; el de sct revertía el índice de descargas al valor que tenía cuando arrancó. Los dos contestaban bien: el daño estaba sólo en el disco.

Se encontró porque el guardián lee el índice en disco en vez de creerle a la respuesta del host — la respuesta decía veces=2 y el fichero decía 1. Aseverar sobre lo que el proceso contestó habría dejado pasar una pérdida de datos silenciosa. El arreglo es que cada proceso escriba sólo la parte que tocó, y lo que eso NO cubre queda dicho: dos procesos que toquen la MISMA parte se seguirían pisando, y ese día hace falta un dueño único del estado, no más banderas.

⚠ Y el test de ese arreglo también costó, con una lección propia: la primera versión abría los dos hosts en SECUENCIA y pasaba con el bug puesto, porque el segundo cargaba de disco lo que el primero acababa de escribir. El bug necesita que las vidas se SOLAPEN. Se comprobó rompiendo el arreglo a propósito —con la rotura el test falla nombrando la causa, sin ella pasa—, que es la única forma de saber si un guardián sirve.

scripts/test-atuq-descargas.py baja de verdad —Content-Disposition: attachment, no una llamada a la API— dos veces, y trae control negativo: con OTRO contenido la segunda vez, exige dedup=false y dos objetos. Sin él, una sonda que dijera «ya lo tenías» siempre se vería idéntica a una que funciona.

⚠ Y corre sobre sway headless, no sobre --headless, porque en --headless una descarga terminada mata al navegador con SIGSEGV — con y sin nuestra extensión. Está medido y atribuido en el §6.10.bis.

6.3 El archivo personal es la razón por la que alguien usaría atuq

Cada página visitada se congela en el CAS y entra al DAG de khipu; después le preguntás a tu propio historial en lenguaje natural, todo local. Es la feature que más justifica el proyecto entero, y sale de piezas que ya están escritas.

6.3.bis La MITAD que se puede pagar hoy, y la que no (2026-09-10)

El §6.3 promete dos cosas y conviene separarlas, porque una está hecha y la otra depende de infraestructura que la imagen todavía no lleva:

mitadestado
congelar cada página leída y poder encontrarla✅ HECHA — archive.add / archive.search + la extensión archivo@atuq.tawasuyu
preguntarle al historial en lenguaje natural❌ falta, pero el muro cayó el 2026-09-11: el motor de embeddings ya está en el corpus (§6.7, llama-cpp sirve /v1/embeddings, medido). Queda pinear el modelo y escribir el rag-motor::RagMotor, como willay-rag

Lo segundo no se «aproxima»: willay-rag —el motor del centro de eventos, que es el ejemplo a copiar— necesita un daemon de embeddings (rimay-verbo) y un backend LLM real (pluma-llm), y su try_build() devuelve None cuando falta cualquiera de los dos, dejando el panel «no disponible». Enseñar una búsqueda literal diciendo que es la semántica sería el peor cambio posible, así que el verbo se llama search, el aviso está arriba de la función en el código, y acá queda escrito qué falta exactamente.

Lo que sí quedó, y por qué vale: la identidad de una página archivada es el BLAKE3 de su HTML ya ejecutado —lo que la persona vio, no lo que el servidor mandó—, no su URL. De ahí sale todo: volver a la misma página no la duplica (suma una visita), y la misma URL con otro contenido es otra página, o sea que el archivo tiene versiones. Un historial de direcciones guarda punteros, y los punteros se pudren; esto guarda lo leído.

Lo que NO se archiva es la parte que hay que mirar: nada de una ventana privada, nada fuera del marco principal (un <iframe> de publicidad no es una página que alguien leyó) y nada sin texto visible (guardar el HTML de un visor de PDF llena el archivo de cosas que después no se encuentran). El descarte de lo privado se comprueba en el fondo y no en el guión de contenido porque tabs.get() es lo único que sabe si la pestaña lo es; y si no se puede saber, no se archiva.

⚠ El control negativo distingue quién protege, y la respuesta medida NO es la que uno supondría. scripts/test-atuq-archivo.py --negative-control abre la página en una ventana privada, exige que no se archive nada, y dice por qué. Medido sobre atuq b3:5cfe3221:

✓ en ventana privada no se archivó nada — lo impidió el NAVEGADOR
  (la extensión no llegó a correr en la ventana privada)

O sea que hoy el que protege es Gecko: las extensiones no corren en ventanas privadas salvo que se las habilite. Nuestro if (emisor.tab.incognito) return nunca se ejecuta con la configuración actual. Eso no lo vuelve código muerto: es exactamente lo que haría seguro habilitar private_browsing en ExtensionSettings el día que haga falta —y sin él, habilitarlo archivaría en silencio lo que alguien abrió en privado—. Lo que sí sería un error es haber escrito «la extensión no archiva lo privado» sin saber cuál de las dos cosas estaba pasando.

Dos techos con nombre, no escondidos en un truncate: el índice guarda 4 KiB de texto por página —es JSON para poder leerse con cat, y uno sin techo deja de poder— y el corte va por CARÁCTER, no por byte, porque truncate sobre medio multibyte entra en pánico y en un archivo personal el texto con acentos es el caso normal.

6.3.ter La otra mitad: preguntarle al archivo por lo que DECÍA (2026-09-12)

Hasta acá el archivo se buscaba literal: todas las palabras tienen que aparecer. Ahora también se le puede preguntar por el significado (archive.ask), y las dos conviven porque son dos preguntas: «la página que decía EADDRINUSE» es literal y no necesita ningún modelo.

El muro no era un daemon ni un LLM, y el propio código lo decía mal. El comentario de la extensión afirmaba que el semántico «necesita un daemon de embeddings y un LLM de verdad», copiando lo que hace willay-rag. Medido: para ordenar por parecido no hace falta ningún LLM —eso es un coseno— y el «daemon de embeddings» resultó ser el mismo llama-server que ya levanta el chat, con otro modelo. La corrección quedó escrita donde estaba la afirmación.

Lo que se construyó, y dónde vive cada cosa — que es la parte que las reglas del repo deciden, no el gusto:

piezadóndepor qué ahí
el protocolo de llama-servershared/foreign-llamaregla 4: un dialecto ajeno entra por un puente. La primera versión vivía DENTRO de puriy-costura::ia, y cuando apareció el segundo consumidor —dos horas después— había dos clientes del mismo protocolo
el Provider de embeddingsrimay-verbo-llamaregla 10: la familia rimay-verbo-* ya es la abstracción de embeddings de la suite. Es el primer backend suyo que no pide nube ni descarga nada al arrancar
el índice y el cosenorimay-verbo-index::VectorIndexya existían, con postcard y top-K. Escribir otro sería un segundo espacio vectorial sin dueño
cuándo se embebe y qué se preguntapuriy-costuraes lo nuestro

Tres decisiones con su porqué:

El guardián (scripts/test-atuq-archivo-semantico.py) archiva tres páginas con el navegador y después le pregunta al host sin navegador, por el cable de 4 bytes y con los paths de producción — así se comprueba de paso lo que el §6.3 promete: que el archivo quedó en disco. Pregunta dos cosas con ganadores distintos, a propósito: con una sola, un ranking constante —o uno que devuelva siempre la más reciente— pasaría sin haber entendido nada. Y las preguntas no comparten palabras con el texto de su página; si las compartieran, la búsqueda literal también acertaría y esto no probaría nada semántico.

MEDIDO en la jaula, sobre los artefactos sellados (atuq bfc14c92, puriy-costura 3f31233e, ia-modelo-embeddings 2c0c4258):

ARCHIVADA … /gato.html   ARCHIVADA … /red.html   ARCHIVADA … /pan.html
«¿dónde se echó a dormir el gato?»    0.6252 gato · 0.2471 red · 0.0973 pan
«cómo bloqueo internet a un programa» 0.4039 red  · 0.1398 pan · 0.1066 gato
por la barra lateral                  0.6252 /gato.html …

Y el control negativo: sin modelo, no hay modelo de embeddings en /usr/share/takana/ia/embeddings.gguf: esta imagen no trae ninguno pineado — y ningún orden inventado, que es el único fallo inaceptable en una búsqueda.

⚠ El guardián nació midiendo nada, y lo dijo el propio arnés. La primera versión metía las tres páginas en <iframe> para archivarlas en una sola carga, y archivó cero: el §6.3 ignora a propósito lo que no es el marco principal —un iframe de publicidad no es una página que alguien leyó—. Encadenadas como navegación de verdad (cada página lleva a la siguiente con un meta refresh), las tres entran. De paso: las dos páginas de tránsito van sin texto visible, así el archivo las descarta y la evidencia no lista dos coincidencias sin título.

6.3.quinquies El modelo de embeddings es una DESCARGA OPCIONAL, no parte de la imagen (2026-09-12)

Decisión del usuario, y la que corresponde a los números: el motor (199 M) y el modelo de chat (1,04 GiB) van en las cuatro imágenes de escritorio porque la barra lateral está en las cuatro; el modelo de embeddings no, porque son 610 MiB más para una función —preguntarle al archivo por significado— que no todo el mundo usa.

Y «opcional» no significa «lo armás vos». El modelo ya es una receta del corpus, así que la maquinaria de la Etapa F lo convierte en paquete sin trabajo extra: scripts/build-repo.sh empaqueta todas las recetas, y el catálogo queda con su expected_hash anclado y sus deps declaradas (llama-cpp, curl, busybox — publicadas también, que si no install no puede reproducirlo).

takana install ia-modelo-embeddings --repo <dir|url>

Lo que hace que esto sea opcional de verdad es una sola línea: la receta no está declarada en ningún perfil. Eso es una decisión, no un olvido, y está escrito acá para que nadie la «arregle» agregándola.

⚠ Y la mitad que separa «opcional» de «invisible»: cuando el modelo falta, el host ya no dice sólo «no está» — dice cómo conseguirlo. Con el modelo ausente, la ausencia es el caso NORMAL, no un error de instalación, y un mensaje sin siguiente paso deja al usuario mirando una función que no sabe que existe:

no hay modelo de embeddings en /usr/share/takana/ia/embeddings.gguf: es una descarga opcional
— instalalo con `takana install ia-modelo-embeddings`

El catálogo, firmado y verificado en los dos sentidos. Publicar el paquete no alcanza: un catálogo sin firma deja a install --require-signed sin nada que comprobar, y una firma con clave efímera es decoración (SDD 19 §3.2). Se firmó con la clave estable del SDD 28 §5.1 —pública en trust/, privada fuera de git— y se comprobó como corresponde:

--trustresultado
./trustrelease: trusted (by release) — 94 paquete(s)
un directorio vacíorelease: unknown-key … añadí su clave para confiar

⚠ Esto es un ensayo local: dist/repo no está en git y la publicación de verdad es la caja del SDD 28 §5. Lo que queda demostrado acá es que el paquete y su cierre entran al catálogo firmado sin inventar nada nuevo.

⚠ Y tres cosas que sólo aparecieron al instalarlo de verdad, no al empaquetarlo:

  1. el cierre no estaba publicado: faltaban llama-cpp y cmake. Un paquete cuyo cierre no está en el catálogo existe y no se instala;
  2. tres paquetes llevaban el ancla de sanidad por default (/usr/bin/<nombre>), que en una librería o en un modelo no existe — install habría fallado al verificar algo que nunca iba a estar;
  3. el instalador no podía instalar a otro filesystem: Invalid cross-device link, justo en el caso que su propia ayuda promete. Y no hace falta tener dos discos: linkat da EXDEV entre dos mounts del mismo dispositivo, y el store de esta máquina es un bind-mount. Arreglado con una copia ante EXDEV —y sólo ante EXDEV— en takana-build::hydrate, con su test cruzando a /dev/shm.

6.3.quater El modelo que elegimos NO SERVÍA, y el síntoma no se parecía a la causa (2026-09-12)

Esto es la unidad de trabajo más caras de esta tanda y vale escribirla entera, porque la clase de fallo se repite: un modelo de embeddings roto no falla — contesta vectores plausibles y ordena al azar.

La primera elección fue multilingual-e5-small (MIT, 118 M, el tamaño ideal, y lo que pedía el caso: español). Sellaba, cargaba, contestaba 384 dimensiones, el endpoint funcionaba y los 45 tests de la suite estaban en verde. Y la primera medición con el modelo DE VERDAD, de punta a punta, dio esto:

«¿dónde se echó a dormir el gato?»      0.9177 gato · 0.8986 red · 0.8965 pan   ✓
«cómo bloqueo internet a un programa»   0.9337 gato · 0.9112 red · 0.9067 pan   ✗
«qué lleva el pan»                      0.9233 gato · 0.9066 red · 0.9063 pan   ✗

Gana siempre la misma página. La caza, en el orden en que se hizo —cada paso descartando una hipótesis, no confirmándola—:

  1. ¿el batching? No: los puntajes son idénticos pidiendo los pasajes juntos o de a uno;
  2. ¿la posición? No: cambiando el orden de los pasajes los puntajes no se mueven. (Y esto mató además una sospecha razonable: mi medición «buena» anterior tenía el pasaje correcto en la posición 0, así que podía haber sido un falso positivo. No lo era, pero no se sabía);
  3. ¿nuestro código? No: los mismos textos a mano contra el servidor dan lo mismo;
  4. ¿la cuantización nuestra? No: el fp32 sin tocar falla igual;
  5. ¿la conversión de terceros? No: dos conversos independientes fallan idéntico — y ese «idéntico» era la pista que estaba a la vista desde el principio: gato~perro y gato~cortafuegos daban el mismo número a cuatro decimales;
  6. es el TOKENIZADOR, y /tokenize lo dijo en una línea: «cortafuegos», «minino», «duerme» y «tejado» van todos al id 100 = <unk>. Un vocabulario XLM-RoBERTa por esta ruta deja casi todo en desconocido, y un texto que es todo <unk> embebe igual que cualquier otro.

Lo que se pineó en su lugar: Qwen3-Embedding-0.6B Q8_0, el GGUF oficial de Qwen (Apache-2.0). Tokeniza español de verdad —cortafuegos → cort|af|uegos—, acierta 3/3 con márgenes anchos (0,649 contra 0,237 el segundo) y es de la misma familia que el modelo de chat, que ya estaba medido contestando en español. Cuesta 610 MiB, cinco veces el e5 que no servía: es el precio de que funcione, y cambia la cuenta de la imagen (motor + chat + embeddings ≈ 1,85 GiB).

Dos configuraciones que el número decidió, no el gusto: --pooling last (con mean también acierta 3/3, pero pegado: 0,574 contra 0,557) y la instrucción delante de la consulta (sin ella, 2/3 — no es decorado).

⚠ Y la regla que queda, que ahora VIVE en la receta: el guardián del install ya no mira sólo el mágico y el tamaño. Tokeniza dos frases en español sin palabras en común y exige que no compartan tokens (con el e5 roto compartían la mitad, todos <unk>), y después levanta el servidor de verdad y exige que un gato se parezca más a un perro que a un cortafuegos. Con esos dos chequeos, el e5 no habría sellado nunca — y la caza de seis pasos no habría hecho falta.

6.4 qullqa con su advertencia puesta — y los TRES bloqueos, medidos (2026-09-12)

Almacenamiento con negación plausible y modelo de adversario escrito — ningún navegador tiene eso; el modo incógnito es teatro. ⚠ Con la advertencia que el propio SDD de qullqa ya anotó y que hay que repetir acá, no esconder: en una máquina con swap sin cifrar, el documento no aplica. Se promete lo que se puede probar.

Qué sería, concretamente: el perfil de atuq —historial, cookies, caché, y el archivo personal del §6.3— vive DENTRO de un pozo de qullqa, montado mientras el navegador corre y desmontado al cerrar. Un registro de la máquina no encuentra un perfil de navegador que negar: no hay forma de la que sospechar. Es la promesa del dominio (A2: un control, una requisa, un funcionario mirando la laptop), no una nuestra.

Lo que se midió antes de escribir una línea de código, y bloquea:

  1. El kernel de la distro no trae FUSE. # CONFIG_FUSE_FS is not set en el .config del artefacto sellado (linux-metal, 6.16.12). Y qullqa-vfs proyecta el pozo por FUSE (fuser, con montar/montar_de_fondo). Sin ese símbolo no hay pozo montable: es una línea en recipes/linux*.toml y un rebuild de kernel de ~35 min que mueve su ArtifactHash y con él las imágenes — o sea, una decisión, no un ajuste al pasar.
  2. No hay binario que lo monte. Los cuatro crates de qullqa son librerías y lo dicen («No binary»). Hace falta algo que abra el pozo, lo monte, lance el navegador y lo desmonte al salir. Eso es superficie nueva con nombre nuevo — la misma clase de decisión que fue puriy-costura.
  3. La contraseña es UX, no criptografía. El pozo se abre con una frase estirada con Argon2id. Quién la pide, cuándo, y qué pasa si se cierra la sesión a la mitad es lo que decide si esto se usa o se apaga al tercer día. qullqa no la contesta porque no es su pregunta.

Y la incógnita del helper ya está contestada, leyendo la fuente (fuser 0.15.1, src/mnt/fuse_pure.rs; el workspace lo declara con default-features = false, así que no hay libfuse en tiempo de build):

⇒ La imagen no necesita una receta de fuse3 mientras quien monte corra como root y no pida AutoUnmount. Si algún día se pide, hace falta el helper — y conviene que esté escrito antes de que alguien lo agregue «porque es más prolijo desmontar así».

⇒ Estado: el §6.4 no está empezado, y ahora se sabe por qué. Los tres bloqueos son nombrables y ninguno es del navegador.

6.5 Foco por cortafuegos — la mitad del sistema, MEDIDA (2026-09-10)

La primera corrección es a este propio documento. La fila de la tabla dice «foco por cortafuegos» y es fácil leerla como «bloquear sitios que distraen». El cortafuegos de tawasuyu no sabe de sitios: su política de egress (cortafuegos-core::Politica) tiene exactamente una dimensión —qué cgroup puede salir— y ninguna de destino; ni dominio, ni IP, ni puerto. Leído en la fuente (UnidadRed { nombre, cgroup_path, concesion }), no supuesto. Así que el foco que estas piezas permiten no es «Twitter no carga»: es «el navegador no sale», con todo lo local —incluido el archivo personal del §6.3 y el CAS del §6.2— funcionando igual. Que es, además, una promesa más honesta: una lista de dominios se esquiva con un espejo, y un cgroup sin egress no.

Y la diferencia con una extensión no es de grado. Un bloqueador de extensión lo apaga en dos clics la misma persona que quería no distraerse; un reglaset nft que aplicó root no lo toca el navegador ni sus extensiones. Por eso la fila decía «nadie lo tiene»: nadie es dueño del navegador y del sistema.

Lo que hizo falta traer. nftables estaba en el grafo como wanted y nadie había puesto la cadena: entran recipes/libmnl.toml, recipes/libnftnl.toml y recipes/nftables.toml (1.1.6, la versión contra la que el SDD del cortafuegos validó socket cgroupv2 en metal). Dos cosas venían de fábrica y están en los comentarios de la receta: nftables no reproduce —MAKE_STAMP es $(shell date +%s) horneado en el binario, la familia del BuildID de waterfox— y su config.status trae un bashismo que busybox rechaza. Las dos se arreglan en el parche, y el control existe: con el sello vivo, dos builds de la misma fuente divergen en libnftables.so (why-differs: .data, .text); con el parche, verificar-repro.sh da REPRODUCE.

La medición, que es la unidad de trabajo de verdad (scripts/test-foco-egress.sh, lanzado con scripts/foco-egress-remoto.sh):

nft: nftables v1.1.6            ← el NUESTRO, musl, desde el artefacto sellado
CONEXION linea-base exit=0      ← sin reglas: el harness mide algo
CONEXION control    exit=0      ← con reglas: el cgroup permitido SÍ conecta
CONEXION foco       exit=1      ← el cgroup del navegador NO

Tres medidas y no una: la línea base existe porque un harness roto («no conectó») se lee exactamente igual que un foco que funciona, y el control porque un reglaset que niega de más no se distingue de uno que niega bien. Con --broken-rules —que le abre egress también al cgroup en foco— el guardián falla diciendo el cgroup EN FOCO conectó igual, y sale 1.

Por qué no corre en el hub, y es una restricción real, no comodidad. Crear un cgroup pide root: medido, mkdir /sys/fs/cgroup/... da EPERM incluso dentro de un userns con --cap-add ALL, porque el permiso se chequea contra la jerarquía real. Y nft -c no es un chequeo de sintaxis: resuelve el path del cgroup contra la máquina viva y falla con cgroupv2 path fails si no existe. O sea que ni siquiera verificar una política del cortafuegos se puede sin los cgroups puestos. Corre en el LXC prestado, y todo va dentro de unshare -m --propagation private -n.

⚠ Y ese --propagation private se pagó caro. /sys/fs/cgroup está montado shared, así que desmontar la COPIA de un --rbind se propaga al montaje real: dos corridas dejaron al worker sin cgroup2 montado, en silencio —los builds seguían— hasta que el guardián siguiente dijo «no hay cgroup2 montado». Se remontó y la jerarquía volvió intacta (los cgroups viven en el kernel, no en el montaje). Tres lecciones que quedan en el guión: los montajes van en un mount namespace propio y se van solos; nunca umount -l antes de un rm -rf (un desmontaje perezoso desaparece de /proc/mounts mientras el árbol sigue ahí, así que la guarda pasa y el borrado entra en el /sys de la máquina); y antes de borrar, preguntarle a /proc/mounts.

⚠ Tres falsos positivos más que cazó el propio harness, y todos se veían como éxito:

  1. un gmp viejo empaquetado por ls store/*-gmp (sólo libgmp.a) ⇒ libnftables.so no relocaba y ninguna conexión salía. En el hub no se había notado porque ahí el lab tiene su propia libgmp y la tapaba: el artefacto parecía autosuficiente y no lo era. Ahora los artefactos se resuelven por takana hash (vigente), y el guardián corre nft --version antes de todo;
  2. /sbin fuera del PATH del chroot ⇒ no había ip, el loopback quedaba caído y todo fallaba;
  3. y el mejor: un comentario que se ejecutó. El guión interior se escribía con un heredoc sin comillas, y un comentario que mencionaba `ip` y `ifconfig` entre backticks los corrió en el host y pegó la salida —con las IPs de la máquina— dentro del guión generado. Ahora el heredoc va entrecomillado y los nombres entran por entorno.

Lo que falta para que esto sea un botón, y por qué no se inventó de paso. Dos piezas, ninguna del navegador:

Mientras eso no exista, lo que atuq puede tener —y tiene, §6.5.bis— es la mitad honesta: ver el estado del foco y no poder apagarlo.

6.5.bis La mitad del navegador: focus.state, y la asimetría a propósito (2026-09-10)

La extensión foco sólo lee. Pregunta focus.state al host cada minuto —el estado lo cambia root por fuera, no hay evento al que suscribirse— y pinta tres cosas: foco, nada, o ?. El tercer caso es el que importa: el host contesta unknown cuando nadie escribió el estado o cuando el contenido no se entiende, y eso no se redondea a off; una insignia que dijera «sin foco» sin haber mirado afirmaría un hecho que no tiene.

Y no hay verbo para apagarlo. focus.stop cae en «verbo desconocido», igual que focus.off y focus.start. No es una omisión pendiente: es la propiedad entera. Si el navegador pudiera levantar el foco, esto valdría exactamente lo que vale un bloqueador de extensión —dos clics— que es el problema que el §6.5 venía a resolver. Encenderlo tampoco está: aplicar un reglaset pide root y este proceso corre como el usuario. Hay un test en tawasuyu que lo fija por nombre, y se puso rojo a propósito agregando un focus.stop «por comodidad».

El estado vive en /etc/takana/focus (lo escribe root junto con el reglaset). scripts/test-atuq-foco.py corre el path de producción, no la escotilla de pruebas —una escotilla mide el código, no el contrato con la imagen— en tres sesiones del navegador, y en cada una comprueba tres cosas: qué dijo la extensión, qué pintó, y que el fichero quedó igual después de la sesión. Eso último es la promesa del §6.5 medida del lado del navegador. Verificado rompiéndolo: con una extensión parcheada que pinta «foco» pase lo que pase (dentro de una COPIA del artefacto, vía ATUQ_DIR), el guardián falla nombrando la insignia.

-- fichero de foco: on          → ESTADO on      · INSIGNIA "foco" · quedó on
-- fichero de foco: off         → ESTADO off     · INSIGNIA ""     · quedó off
-- fichero de foco: (no existe) → ESTADO unknown · INSIGNIA "?"    · siguió ausente

6.6 Medios fuera del navegador — HECHO (2026-09-10)

Una navegación de PRIMER NIVEL a un medio (Content-Type: video/* o audio/*) se cancela y la URL se la lleva el reproductor de la distro. mpv ya viaja en las cuatro imágenes de escritorio, así que esto no agrega ni una receta: es cablear lo que ya estaba.

La regla es estrecha a propósito. Un <video> embebido es parte de la página y sacarlo de ahí rompería el sitio que lo puso; lo que se cambia es el caso en que el navegador no estaba pintando una página sino haciendo de reproductor —seguiste un enlace a un .mp4—. Las reglas anchas en el camino de cada petición son las que terminan rompiendo la web de alguien.

La seguridad del verbo es que el binario NO viene del cable: es una constante del host (/usr/bin/mpv). Si el programa viajara en el mensaje, esto dejaría de ser «abrir un vídeo» y sería un lanzador de procesos arbitrarios manejado, al final de la cadena, por una página web. Sólo viaja la URL, sólo http(s), y detrás de un --.

Fail-open, y es la promesa que el control negativo prueba. Cancelar es SÍNCRONO y la respuesta del host llega después, así que no se puede saber en el momento si el reproductor arrancó. Se cancela sólo si el puerto está vivo, y si el host contesta que no pudo, la URL vuelve al navegador (con una marca para no entrar en bucle). Medido:

SIN REPRODUCTOR no hay reproductor en /usr/bin/mpv: esta imagen no lo trae
VUELVE AL NAVEGADOR http://…/audio.wav?atuq-medios=no

Quedarse sin vídeo Y sin pestaña es el único resultado inaceptable, y es el que se consigue si uno confía en que salió bien.

⚠ Y el bug que esto destapó vale más que la función. La primera corrida hizo todo bien —detectó el medio, canceló, el host contestó con el pid— y después:

DESCONECTADO Native application tried to send a message of 546281442 bytes,
which exceeds the limit of 1048576 bytes.

Un hijo hereda los descriptores del padre, y los del padre SON la tubería de native messaging. El reproductor escribía su salida ahí y el navegador la leía como un marco. Tres redirecciones, cada una contra un fallo distinto: stdin a null (mpv lee stdin para sus comandos: heredándolo se come los mensajes del navegador), stdout al stderr del host —no a /dev/null: apagarlo arreglaría el bug y se llevaría puesto el diagnóstico, y Gecko manda ese stderr al log del navegador, que es donde uno mira cuando «el vídeo no abrió»— y stderr heredado por lo mismo.

El test que lo fija arranca el binario, no la lib, y ésa es la parte reutilizable: en proceso, el stdout del host es el del arnés y la contaminación no se ve — el test habría pasado con el bug puesto. Se comprobó rompiéndolo a propósito.

⚠ Y una nota del arnés que ya apareció dos veces: la URL no se pasa por la línea de comandos, porque una petición del ARRANQUE compite con la inicialización de la extensión. El guardián navega a una página que redirige a los tres segundos — que además es lo que hace una persona: seguir un enlace.

6.7 El motor de inferencia local — ENTRA AL CORPUS (2026-09-11)

La primera corrección es al propio §6. La tabla de arriba le pone costo «bajo» a la IA local porque las piezas parecían estar escritas en tawasuyu (rimay, iniy). Se fue a mirar antes de empezar y no alcanzan, por dos razones independientes:

piezapor qué no corre un modelo acá
pluma-llmsus backends son anthropic, cohere, claude-cli y mock. Todos de nube o de juguete
rimay-verbo-fastembedtrae fastembed con la feature ort-download-binaries: baja onnxruntime precompilado (glibc) y el modelo de HuggingFace en el primer arranque. En un build hermético y en una distro musl eso no es lento, es imposible

Y eso además junta dos pendientes que el plan llevaba separados: el §6.7 (la barra lateral) y la mitad semántica del §6.3 (preguntarle al archivo personal en lenguaje natural) no eran dos problemas. Eran uno: el corpus no tenía con qué correr un modelo. El muro tiene forma de receta, y una sola lo derriba entero — el mismo binario sirve /v1/chat/completions (el §6.7) y /v1/embeddings (lo que rag-motor exige para el §6.3).

recipes/llama-cpp.toml ⇒ b10901, estática, 199 M, 16 binarios sin un solo NEEDED. llama-server, llama-cli, y de yapa llama-quantize/llama-bench/llama-perplexity, que son las tres herramientas con las que se mide un modelo antes de pinearlo. REPRODUCE bit a bit (verificar-repro.sh: 0 no-determinismos).

⚠ La variable que nos da reproducibilidad le apagó el AVX2 al motor, y el build salió 0

Es lo único de esta unidad que no se podía deducir, y por eso va escrito acá con la línea leída.

La receta nació con -DGGML_NATIVE=OFF, apoyada en ggml/CMakeLists.txt:141:

if (GGML_NATIVE OR NOT GGML_NATIVE_DEFAULT)
    set(INS_ENB OFF)     # ← con NATIVE=ON, ggml se apoya en -march=native y apaga las perillas
else()
    set(INS_ENB ON)      # ← con NATIVE=OFF *debería* encender SSE4.2/AVX/AVX2/BMI2/FMA/F16C
endif()

Leído así, GGML_NATIVE=OFF era exactamente lo que queríamos: nada de -march=native (que sería un artefacto distinto por máquina — la familia del MAKE_STAMP de nftables) y aun así un binario vectorizado. Y es falso en este sandbox, por una línea 36 renglones más arriba:

if (CMAKE_CROSSCOMPILING OR DEFINED ENV{SOURCE_DATE_EPOCH})
    set(GGML_NATIVE_DEFAULT OFF)

El sandbox de takana exporta SOURCE_DATE_EPOCH=1 (sandbox.rs:453) justamente para que los builds reproduzcan. Con eso GGML_NATIVE_DEFAULT queda OFF, NOT GGML_NATIVE_DEFAULT es verdadero, e INS_ENB termina en OFF: las seis perillas apagadas. Dicho corto: la variable que existe para que el build reproduzca apagó todas las instrucciones vectoriales del motor de inferencia.

⚠ Y no falló nada. El artefacto selló, llama-cli --version contestaba, el binario corría. Medido sobre el artefacto sellado b3:e3c62185, no sobre el log:

primer sello (-DGGML_NATIVE=OFF a secas)con las seis declaradas
%ymm (AVX2)042.356
vfmadd (FMA)01.298
roundps (SSE4.2)086

Un motor de inferencia sin AVX2 no está roto: está varias veces más lento, y ninguna métrica del repo lo dice. Es la figura de siempre — un ausente falla ruidosamente; un desviado llega hasta el final diciendo que todo fue bien. Por eso las seis van declaradas una por una, no se confía en el default de nadie, y el install las comprueba en el binario: es el único punto donde este modo de fallar se puede ver.

⚠ Eso fija un piso declarado: x86-64-v3. Un CPU sin AVX2 (pre-2013) muere con SIGILL, no con un mensaje. Medido antes de elegirlo: momento/gioser, el worker dev.gioser.net y el objetivo de metal (TigerLake) traen los seis flags. Bajar el piso o publicar variantes es docs/plan-variantes-cpu.md, no esta receta.

De paso, strip_debug = true: zig cc emite debug_info por defecto y acá son 16 binarios estáticos, o sea 16 copias de los símbolos de libllama+libggml+libc++. 1,8 G → 199 M.

El guardián, y lo que cada sonda afirma

scripts/test-llama-cpp.py, sobre el artefacto vigente (resuelto por takana hash, nunca por ls store/* — es la trampa del gmp viejo del §6.5), con escotilla LLAMA_DIR que grita al usarse:

1. ISA          42.356 %ymm · 1.298 vfmadd · 86 roundps      ← control negativo MEDIDO: 0/0/0
2. hermético    llama-server y llama-cli: sin NEEDED
3. inferencia   una pasada REAL con stories260K (1,1 MB, pineado por sha256) ⇒ 51 caracteres
4. embeddings   vector de 64 dimensiones · mismo texto = mismo vector ·
                textos distintos = vectores distintos · no son ceros

La sonda 4 es la que le importa al §6.3, y sus tres comprobaciones no son ceremonia: «¿devuelve un vector?» lo pasa un stub de ceros, y «¿devuelve algo distinto?» lo pasa un generador de ruido. Hace falta que el mismo texto dé lo mismo y que textos distintos den cosas distintas.

El control negativo es vivo: --sin-modelo corre las mismas sondas contra un modelo inexistente y tiene que fallar — sin eso, un arnés roto («no contestó») se lee igual que un motor que anda. Y el censo después de correr: cero llama-server sueltos.

⚠ Dos trampas del arnés que van a volver cuando se pinee el modelo de producción:

  1. /health contesta MIENTRAS carga, con 503 y {"status":"loading model"}. Tomar «contestó» por «listo» manda la primera consulta contra un servidor a medio levantar, y el 503 se lee como «el endpoint no existe». Se espera el ok, no el 200.
  2. --pooling mean no es un gusto. Un GGUF declara su tipo de pooling en los metadatos y los modelos de embedding de verdad (e5, bge, nomic) lo traen; stories260K es un LM causal de juguete y no lo trae, así que llama.cpp cae en none y el endpoint compatible con OpenAI contesta 400: Pooling type 'none' is not OAI compatible — no un vector vacío, no un 500: un 400 que parece un error del cliente.

⚠ Lo que esto NO es

Esto es el motor, no la función. Un motor sin modelo no contesta nada, y atuq todavía no tiene barra lateral de IA. Falta, y tiene forma:

llama-cpp no está declarada en ningún perfil todavía, y es a propósito: una imagen no debería crecer 199 M por una función que aún no existe. Se declara cuando el §6.7 se pague entero.

6.7.bis La barra lateral YA PREGUNTA — y el modelo sigue siendo una decisión (2026-09-11)

Lo que faltaba eran tres cosas; dos están hechas y la tercera es una decisión, no trabajo.

El verbo (ai.ask) y quién levanta el servidor. Se resolvió al revés que el torrent del §6.9, y la diferencia importa: allá el daemon sobrevive al navegador porque un torrent tiene trabajo que sigue; acá el servidor se muere con el navegador, porque medio giga de modelo en RAM no es trabajo pendiente, es peso. No hay servicio de IA en la imagen: el primer ai.ask levanta llama-server y el host lo mata al salir.

El transporte es un socket UNIX, no un puerto: llama-server acepta --host <ruta>.sock (medido). No colisiona, no queda expuesto a la red y no hay que buscar puerto libre. El cliente HTTP son treinta líneas porque el servidor contesta con Content-Length — medido, y si algún día contestara chunked falla diciéndolo en vez de entregar medio cuerpo.

Lo que pasa si a puriy-costura lo matan con SIGKILL: el servidor queda. La red natural sería PR_SET_PDEATHSIG, pero pedirlo exige Command::pre_exec, que es unsafe, y la caja lleva #![forbid(unsafe_code)] — una propiedad de la caja no se negocia por una comodidad de ciclo de vida. El residuo se ACOTA en su lugar: si al preguntar ya hay un servidor vivo con el mismo modelo, se adopta; así lo peor que queda es uno, y la sesión siguiente lo hereda. Si el que está vivo tiene OTRO modelo, se niega nombrándolos: contestar con un modelo que no es el de la imagen sería mentir sobre quién contestó.

La barra lateral (extensions/ia/) es una caja de texto y el hilo de respuestas. La página no habla native messaging: habla con el fondo, y el fondo con el host — si el puerto nativo viviera en la página, cerrar la barra lateral se llevaría el proceso del host y el modelo cargado con ella. panel.html?q=… abre el panel con la pregunta ya hecha, que es la puerta por la que entrará «preguntar sobre la selección» y la que usa el guardián.

scripts/test-atuq-ia.py mide la cadena entera con inferencia de verdad —el llama-cpp del corpus contra el modelo pineado del guardián del motor, importado de allá para que el sha256 viva en un solo sitio— y dentro de una jaula --unshare-net: no hubo red para consultar a nadie más. Tres aserciones: que la respuesta venga del modelo de la imagen, que no venga vacía, y que al cerrarse el navegador queden cero llama-server.

⚠ Ese censo nació roto y el fallo es del género que este documento colecciona: pgrep -c no existe en busybox, y el || echo 0 que parecía prudente convertía el error en un cero, o sea en un verde. Con un motor vivo a propósito, el guardián pasaba igual. Ahora se cuenta leyendo /proc a mano, y la rotura a propósito falla como debe (quedaron 1 llama-server vivos). De paso, el primer intento de romperlo tampoco rompía nada: el motor de mentira moría al hacer bind porque el socket estaba en /salida, que se comparte entre las dos corridas; con el socket en /tmp —tmpfs nuevo por corrida— la rotura rompe.

El modelo de chat, PINEADO (2026-09-11). recipes/ia-modelo-chat.toml ⇒ Qwen2.5-1.5B-Instruct Q4_K_M, 1.117.320.736 bytes. Elegido por tres cosas y las tres discutibles cambiando un sha256: Apache-2.0 (lo que una distro puede shipear sin letra chica, al revés que Llama-3.2 o Gemma), habla español —medido antes de pinearlo: se le preguntó qué es una distribución de GNU/Linux y contestó dos frases correctas— y corre en CPU: 20,1 tokens/s en el hub sin GPU.

El objeto pineado es un tar que envuelve el .gguf, publicado en el mirror de fuentes y servido por sha256, porque takana extrae toda fuente con tar y un GGUF pelado no es un tar. Es el camino de firefox-pgo-profile. La procedencia queda escrita en la receta con el sha256 del GGUF de upstream (el lfs.oid que publica HuggingFace, verificado al bajarlo), no sólo el del tar nuestro. Round-trip verificado como manda el ADR 0013: apartados el caché local y el artefacto, takana build lo bajó del mirror y selló el mismo ArtifactHash.

La receta trae guardián propio, porque el modo de fallo es callado: un fichero truncado o un HTML de error renombrado a .gguf se instala igual y sella en verde. Se comprueban el mágico GGUF y el tamaño.

⚠ Y el modelo de verdad destapó una carrera en el guardián del §6.7.bis. El censo de motores contaba en el instante del cierre: con el modelo de juguete daba 0 y con el de la imagen daba 1 — y eso se lee como fuga cuando lo que pasa es que matar un proceso con un giga mapeado tarda ~1 s. Ahora el censo espera hasta 15 s y anota cuánto tardó: 0 llama-server vivos (1s después). La rotura a propósito sigue fallando, ahora con el tiempo a la vista.

scripts/test-atuq-ia.py --image-model corre la cadena entera con el modelo de la imagen (llega como capa, no copiado: mover 1 GiB a un tmpfs llenó /tmp y mató al guardián con un ENOSPC que no tenía nada que ver con lo que mide). El default sigue siendo el modelo de juguete, que certifica la misma cadena en un segundo.

Lo que queda decidir: qué imágenes lo llevan. llama-cpp (199 M) y el modelo (1,04 GiB) suman ~1,25 GiB a cada perfil donde se declaren, y atuq está en cuatro escritorios. Sigue sin declararse en ninguno: una imagen no crece 1,25 G por decisión de un commit.

Y falta el de embeddings para la mitad semántica del §6.3 (multilingual-e5-small, decidido pero no pineado todavía).

6.7.ter Una CAPTURA, y lo que encontró que ningún dump podía (2026-09-13)

Los guardianes miden por el dump: qué dijo la extensión, qué contestó el host, qué quedó en disco. Es lo correcto para un veredicto automático y no puede ver lo único que el usuario ve. Por eso hay ahora scripts/atuq-captura-ia.py: el mismo arnés de sway headless de los otros guardianes, más grim —que el cierre de escritorio-sway ya trae— y un PNG al final. No es un guardián: es evidencia para una persona.

La primera captura útil mostró el panel andando —título «IA local — atuq», el aviso «El modelo corre en esta máquina. No sale nada a la red.», la pregunta y la respuesta del modelo— y tres cosas más, ninguna visible en un log:

  1. La barra lateral se abría SOLA en el primer arranque y se quedaba ocupando un tercio de la ventana. Gecko lo hace por defecto al instalar una extensión con sidebar_action, y para un navegador que la trae de fábrica eso significa imponerle un panel a todo el mundo en cada instalación. Un "open_at_install": false lo arregla; la captura del después lo confirma;
  2. el diálogo «Close Firefox» en la segunda corrida: el arnés mataba el navegador sin despedirse y el .parentlock quedaba. No es del producto, pero costaba una captura inútil por corrida;
  3. dos barras de notificación vacías en el arranque. Acá el atajo habría sido reportarlas como un fallo de marca —«las cadenas se perdieron al rebrandear»—, y era falso: instrumentando una COPIA del artefacto para volcar el DOM, resultaron ser sandbox-content-disabled («the security sandbox is disabled», que apaga el arnés) y startup-restore-session-suggestion (por el cierre abrupto de la corrida anterior). El texto está en el DOM y no en los píxeles: se comprobó además quitando nuestro CSS, con el mismo resultado ⇒ es el render por software de la jaula, no el producto.

⇒ Y ésa es la lección de la unidad: una captura muestra síntomas, el DOM dice de quién son. Sin el segundo paso, dos de los tres hallazgos habrían entrado al documento como bugs nuestros.

6.8 Proxy por contenedor — HECHO (v0.5, 2026-09-06)

Es el primer diferenciador del §6 que se paga entero, y se pudo pagar ahora porque es el único de la lista que no pasa por el host del §7: es API de Firefox y nada más. Cada contenedor —Personal, Trabajo, Banco, Compras— puede salir por su propio proxy.

Tres piezas, en tres ficheros, y ninguna alcanza sola:

PiezaDóndeQué aporta
privacy.userContext.enabledatuq.cfgprende los contenedores, que Firefox trae apagados
Containers.Defaultdistribution/policies.jsonlos CREA — es el único mecanismo que los pone en un perfil NUEVO
3rdparty.Extensionsdistribution/policies.jsonla config de fábrica, que la extensión lee como storage.managed
proxy.onRequestextensions/proxy/fondo.jslo único que ve el cookieStoreId de cada petición

Las cuatro se comprobaron DENTRO del artefacto antes de escribir una línea, que es la regla que dejó el §2.sexies: Containers y 3rdparty están en el policies-schema.json de browser/omni.ja, cookieStoreId en el schemas/proxy.json de omni.ja, y storage.managed lee Services.policies.getExtensionPolicy(id) en ext-storage.js. Ninguna de las cuatro se leyó de la documentación de Mozilla: se leyeron de nuestro build, que es el que las tiene que traer.

Tres decisiones de diseño que valen más que el código:

  1. La configuración se indexa por NOMBRE de contenedor, no por cookieStoreId. El id (firefox-container-3) depende del ORDEN en que se crearon, así que la misma configuración aplicada a otro perfil apuntaría a otro contenedor. Un identificador que cambia de significado entre máquinas no sirve para configurar una distro.
  2. Fail closed. Un contenedor que TIENE proxy configurado y no se pudo honrar —entrada inválida, API que falla, mapa a medio construir— no sale directo: va a un destino cerrado y el navegador muestra el error. Salir directo sería una fuga silenciosa, y es la misma familia que el artefacto vacío de la regla 3: el fallo que llega hasta el final diciendo que todo fue bien. Un usuario que pidió que «Banco» no salga por su línea tiene que ver un error, no navegar.
  3. proxyDNS viene PRENDIDO. Sin él, Gecko resuelve el nombre por su cuenta antes de hablar con el proxy: la consulta DNS sale justo por la línea que se quería evitar. Es la fuga clásica de esta configuración, así que el default es el seguro y hay que apagarlo a mano.

Y lo que NO promete está en la propia página de opciones, arriba de todo y no en un pie: separación de tráfico, no anonimato; no toca la huella del navegador; para anonimato, Tor Browser. Es el §4 cumplido en el sitio donde el usuario lo va a leer.

Lo que quedó PROBADO, y con qué

Corriendo el árbol de atuq en la misma jaula que usa scripts/atuq-nested.sh:

AfirmaciónPrueba
La política CREA los cuatro contenedorescaptura de about:preferences#containers con Personal/Trabajo/Banco/Compras y sus iconos, más el containers.json del perfil
Las dos extensiones se INSTALANextensions.json del perfil nombra inicio@atuq.tawasuyu y proxy@atuq.tawasuyu
El ruteo se arma contra los contenedores realesconsole.info: "atuq/proxy: 4 contenedor(es) enrutado(s)" — o sea que leyó la config de fábrica por storage.managed Y la casó con los contenedores que creó la política
Una petición hecha en «Banco» SALE por el proxy de «Banco»scripts/test-atuq-ruteo.py: dos pestañas piden la misma url; al destino llega GET /directo y al puerto del proxy un saludo SOCKS5 (\x05\x01\x00), y la de «Banco» no aparece nunca en el destino. Con --negative-control (sin proxy configurado) las dos van directas y al proxy no llama nadie
Los guardianes del cruce política↔XPI FALLAN cuando debenscripts/test-atuq-politica.py: 5 formas de romperlo, las 5 matan el build, y el control con la política intacta pasa
La capa sobrevive al cambio de BASErehecho sobre el firefox con RLBox (b3:8116bdec, 2026-09-06): atuq sella en 2 s como b3:f2960991, su libxul.so trae 634 símbolos que se LLAMAN w2c_* donde la base anterior traía 0, y los cuatro contenedores y las dos extensiones siguen apareciendo en un arranque real

⚠ Ese 634 hay que contarlo anclado, y la primera vez se contó mal. llvm-nm --defined-only \| grep -c w2c_ da 1547 sobre el mismo fichero, y los 913 de más son nombres C++ mangleados que contienen w2c_ en el MEDIO (_ZN5rlbox...PK16w2c_mem_capacity...), no símbolos de la jaula. El patrón que cuenta lo que dice contar es grep -c ' w2c_' —o el equivalente awk '{print $3}' \| grep -c '^w2c_'—, y da 634, que es lo que mide el guardián de recipes/firefox.toml. La conclusión no cambia (la base anterior daba 0 con cualquiera de los dos patrones); lo que cambia es que un número sin su patrón no es una medición. Muestra de lo que sí es un símbolo de la jaula: w2c_rlbox_0x5F_cxa_atexit_0.

Y de paso quedó comprobado que la dep SÍ se sigue, que era la promesa del §2 y no una que convenga creer sin medir: en un catálogo de sonda, cambiar una bandera del firefox copiado mueve el hash de atuq (f2960991 → 60ade76a), mientras que con el firefox idéntico al del corpus da el mismo hash. O sea que un atuq viejo no puede quedarse tapando un motor nuevo — que es justo la forma que tendría acá el cache-hit que congela regresiones.

Cómo se cerró lo que faltaba (2026-09-06). Durante unas horas esta sección decía que probar el paquete quedaba pendiente, con tres puertas cerradas. Las tres siguen cerradas y la prueba existe igual, porque el camino era otro.

Las tres puertas, que conviene NO volver a golpear:

  1. Desde la línea de comandos no hay flag para el userContextId de la pestaña inicial. Y un moz-extension:// tampoco se abre así: el manejador intenta resolverlo como fichero y muere con NS_NOINTERFACE [nsIFileURL.file], abriendo la home en su lugar.
  2. Marionette está en el artefacto y responde —se le habló con un cliente propio de 60 líneas, el protocolo es <longitud>:<json>—, pero abrir una pestaña de contenedor pide contexto chrome, y eso exige -remote-allow-system-access; en la jaula no lo tomó ni por bandera, ni con --remote-debugging-port, ni por MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1, que es lo que RemoteAgent.sys.mjs lee en su constructor. Cabo suelto medido, no suposición.
  3. Desde la extensión, tabs.create({cookieStoreId}) exige el permiso cookies (ext-tabs-base.js:getUserContextIdForCookieStoreId). Dárselo a la extensión del proxy para que pueda probarse a sí misma sería pagar con la superficie de ataque del producto una comodidad del test. Sigue sin dárselo.

La cuarta puerta estaba abierta: el fichero de sesión. sessionstore guarda el userContextId de cada pestaña y Gecko lo restaura, así que se fabrica a mano. El formato es mozLz40\0 + tamaño + un bloque LZ4, y un bloque de sólo literales es LZ4 válido: veinte líneas, sin depender de ninguna librería. Con eso se abren dos pestañas —misma url, distinto contenedor— sin pedirle nada al navegador.

Y la medición no le pregunta nada tampoco: se le ponen dos oídos en la red y se mira a cuál llama. El destino contesta un HTTP 200; el «proxy» no habla SOCKS, sólo anota quién lo saludó. Que al puerto del proxy llegue un \x05\x01\x00 es la prueba: ese saludo sólo aparece si Gecko decidió hablar con un proxy para esa petición, y el único que se lo pudo indicar es nuestro proxy.onRequest mirando el cookieStoreId.

Tres detalles sin los cuales la prueba mide un silencio y se lee como un fallo: browser.sessionstore.restore_on_demand=false (si no, las pestañas restauradas no piden nada hasta que alguien las mira), network.proxy.allow_hijacking_localhost=true (Gecko saltea el proxy para localhost, y la petición de «Banco» iría directa haciendo parecer culpable a la extensión), y triggeringPrincipal_base64: vQ== en cada entrada de sesión (sin principal la pestaña se restaura pero no navega).

Y la prueba trae su propio control negativo, porque es lo que este repo se exige desde hoy: con --negative-control no se configura el proxy y se exige el resultado CONTRARIO —las dos pestañas directas, nadie llamando al proxy—. La única diferencia entre las dos corridas es una línea de configuración y el observable se da vuelta entero. Sin ese modo, una prueba que se hubiera vuelto ciega se vería idéntica a una que funciona.

6.9 Torrent adentro — HECHO (2026-09-10), con un daemon propio, configurable y perezoso

Vivaldi ya trae torrent, y termina en una carpeta. Lo que cambia acá es que lo que baja entra al CAS: un objeto BLAKE3 deduplicado y verificable, con la misma identidad que una descarga del navegador (§6.2) o un .swm de takana. Ésa, y no «tener torrent», es la razón por la que esto vale.

página de la extensión → fondo → connectNative → puriy-costura
  → /usr/bin/puriy-costura-torrent add   (que LEVANTA el daemon si no está)
  → daemon: librqbit, y al completarse, el CAS

Por qué un proceso aparte y no un verbo más del host — dos razones, y la primera decide:

  1. Una descarga tiene que sobrevivir al navegador. Gecko lanza un host de native messaging por puerto y lo mata cuando el puerto se cierra (medido este mismo día, §6.2.bis). Un cliente BitTorrent dentro de puriy-costura dejaría de bajar al cerrar la pestaña.
  2. El precio. librqbit + tokio + rustls son 226 crates: ese host —952 K, compartido por las cinco extensiones— pasaría a ~20 MB, y cada iteración de cualquier función del navegador a un cuarto de hora.

⚠ Y que esa pila COMPILA para musl con zig-cc se midió antes de decidir, con una receta desechable: 226 crates y sella. Era una incógnita real —ninguna receta del corpus había construido tokio+rustls desde tawasuyu; las que hay dicen «deps livianas (sin tokio/libp2p/axum)»— así que la decisión no fue «no se puede», fue «no ahí». Medir primero es lo que separa una decisión de arquitectura de una excusa.

Perezoso en los dos sentidos, que es lo que pidió el operador:

qué significa
nadie lo arrancano hay servicio en la imagen: el binario está y no corre. Lo levanta su propio cliente la primera vez que hay un torrent que tomar
se va solocuando no queda nada activo por inactividad_seg (300 s por defecto; 0 = nunca), sale. Sembrar cuenta como actividad: no se va mientras le devuelva piezas al enjambre

⚠ Se levanta con setsid: sin eso queda en el grupo de procesos del navegador y se muere con él, que es exactamente lo que este proceso existe para no hacer. Si setsid faltara se arranca igual —mejor bajar mientras el navegador esté abierto que no bajar— pero avisando, porque es una degradación silenciosa de su única promesa.

Configurable: un TOML opcional (descargas, cas, indice, sembrar_tras_bajar, inactividad_seg, techos de velocidad). Sin fichero los defaults andan; un fichero roto sí es un error, porque si un typo se comportara como «sin configuración» el usuario buscaría el problema en cualquier otra parte. El CAS por defecto es el del host, y hay un test que falla si esas dos raíces se separan: es lo que hace que un torrent y una descarga del mismo contenido sean un objeto.

Lo que el guardián NO hace, y por qué (scripts/test-atuq-torrent.py):

  1. no usa un magnet: sino un .torrent servido por HTTP — dar de alta un magnet BLOQUEA esperando la metadata del enjambre, y sin peers eso no llega nunca: se mediría un timeout y no la cadena. Con un .torrent la metadata viene en el fichero y el alta termina. El .torrent se arma en el propio guardián, en bencode a mano: un binario de prueba en el repo es una dependencia que nadie revisa;
  2. no pasa por el despacho de protocolos de Gecko — un clic en un magnet: abre el diálogo de «¿con qué lo abro?», que en headless no contesta nadie, y eso es una elección del usuario y no código nuestro. Se abre la página de la extensión, que es la que el handler abriría, descubriendo su URL base del dump de una primera corrida (el UUID lo asigna Gecko por perfil).

Y el control negativo prueba lo que importa: sin el cliente del daemon, el host falla diciéndolo y nombrando lo que falta — no finge haberlo tomado.

⚠ Y el bug que encontró MIRAR LA MÁQUINA, no un test. Después de las pruebas, ps mostraba un daemon con 23 minutos de vida y idle_seconds = 300. El bucle hacía let Ok(g) = gestor() else { continue }: si la sesión de torrent no se podía crear, el continue saltaba la evaluación de «¿sobro?» y el daemon quedaba vivo para siempre — lo contrario de perezoso, que es su única promesa. Los tests cubrían la decisión como función PURA, y esa función estaba bien: el que no llegaba a llamarla era el bucle. Una decisión correcta que nadie toma se ve igual que una que no existe. Arreglado (sin sesión = nada activo) y fijado con dos tests que corren el PROCESO y el reloj de verdad, el segundo comprobado volviendo a poner el continue.

⚠ La pereza tiene un residuo, y el guardián lo daba por bueno (2026-09-10). El daemon se va «tras 300 s sin nada», y un torrent sin enjambre nunca termina ⇒ nunca está sin nada: se queda, que es lo correcto para un cliente de torrent. Pero el guardián cerraba el sandbox y se iba, y quedaba vivo el daemon Y el bwrap interno, sosteniendo abiertos overlays ya borrados — encontrado con ps sobre la máquina, no por un test. La corrección tiene dos mitades y ninguna sobra:

  1. el guión de adentro despide al daemon con puriy-costura-torrent stop — el propio verbo del producto, así que de paso se ejerce;
  2. el guardián cuenta los daemons antes y después (ps -C, nunca pkill -f, que empareja la línea de comandos y se lleva la shell del que corre el test) y falla nombrando el PID si quedó alguno; y barre lo propio en el finally, para que un test rojo tampoco deje basura.

Y el orden importa: la huella del socket se anota adentro y antes del stop, porque al irse el daemon lo borra — mirarlo desde el hub después mediría el reloj, no el hecho. Comprobado en los dos sentidos, quitando el stop en una copia fuera del repo: sin él el guardián decía ✓ igual y dejaba un daemon suelto; con la aserción nueva sale ✗ quedó un daemon vivo tras cerrar el sandbox: PID(s) [20157]. La primera hipótesis —que sin stop el guardián se COLGARÍA, porque bwrap --unshare-pid espera a su namespace— era falsa y la medición la descartó: el bwrap externo vuelve, y el que se queda esperando es el interno. El tope de 180 s quedó igual, pero por lo que es: seguro contra un cuelgue, no el que detecta la fuga.

⚠ Lo que sigue sin estar en ningún test automático: un transfer real entre peers. Haría falta un sembrador y una espera que volverían la suite una que nadie corre. Está dicho también en el test del daemon, al pie, para que nadie lo lea como «probado de punta a punta».

6.10 Lo que atuq NO PUEDE hacer hoy, y por qué (2026-09-07)

Sale de un punto ciego que nombramos entre los dos frentes y que ningún auditor de readelf puede cubrir. La jerarquía de lo que se ve:

NEEDED del ejecutable          → lo ve `test-atuq-rootfs.py` y lo ve `vigia-sonames.py`
NEEDED de un .so dlopeado      → lo ven los dos, si el fichero está en el artefacto
dlopen("libfoo.so.1") LITERAL  → NO LO VE NINGUNO

Y un navegador vive del tercer escalón: Firefox sondea ffmpeg, VA-API, vulkan, libnotify y una docena más por nombre, y cuando no están no falla: apaga la función y sigue. No hay línea roja; hay una función que nadie ofrece y nadie reclama. Es la misma forma que ya nos costó caro con OBS (dlopen("libGL.so.1") desde su glad).

scripts/test-atuq-rootfs.py --dlopen busca esas cadenas y las cruza contra el rootfs. Es un heurístico y se declara como tal —una cadena no prueba un dlopen, y su ausencia no prueba que no lo haya—, así que no falla nunca: imprime un mapa triado. Lo que sí se puede afirmar es lo contrario, que es lo útil: si la cadena está y el fichero no, esa función no existe en esta imagen.

De 85 cadenas con forma de soname, 47 no tenían quién las provea. Triadas (y con dos filas corregidas el mismo día — ver §6.11, que las midió en vez de deducirlas; hoy el mapa da 7 huecos):

Qué se pierdeEstado
Códecs del sistema (H.264/AAC)❌ ERA FALSO — recipes/ffmpeg.toml existe, está sellada y ya viajaba en los cuatro escritorios por mpv. Corregido en §6.11: reproduce
Notificaciones web (libnotify)✅ CERRADO el 2026-09-07: recipes/libnotify.toml, 0.8.8, compartida. Es el primer hueco que este mapa encontró Y cerró
Llavero (libsecret)hueco — pero la receta ya existe en incoming-gnome
Sonidos del sistema (libcanberra)hueco — receta en incoming-kde
WebGPU / vulkan (libvulkan)hueco — receta vulkan-loader en incoming-kde
Vídeo por hardware (VA-API)hueco, pero no por falta de receta: libva está sellada y en las cuatro imágenes. Falta el driver — las tres mesa van con -Dgallium-va=disabled y -Dvideo-codecs= vacío ⇒ ni un *_drv_video.so
Lectura en voz alta (libspeechd)hueco — sin receta
libGLdecisión: la distro es Wayland-only sin GLX y Firefox va por EGL
libcurldecisión: sólo lo usa pingsender, que manda telemetría — apagada
Kerberos, menú global, MLfuera de alcance
libdl/libpthread/librt/libudev.so.0/libfreebl3ruido: musl los funde, o el soname es viejo

El primero ya está cerrado. libnotify no tenía receta y ahora la tiene: es la prueba de que el mapa sirve para algo más que mirarlo. ⚠ Con una advertencia que va escrita en la propia receta: libnotify no trae un daemon, manda org.freedesktop.Notifications por D-Bus ⇒ tenerla resuelve la mitad —que firefox la encuentre— y la otra mitad es que en la imagen haya alguien escuchando ese nombre. Nadie debería leer «notificaciones arregladas» y esperar un globo en un QEMU pelado.

⚠ CORRECCIÓN, mismo día: escribí que tres de los siete eran «baratos, promoción y no autoría» —libsecret, libcanberra, vulkan-loader— y es FALSO. Lo comprobé antes de declararlos en los perfiles, y en los tres casos falta la otra mitad:

«barata»por qué NO alcanza con declararla
vulkan-loaderlas tres recetas de mesa construyen con -Dvulkan-drivers= VACÍO ⇒ el loader no encontraría un solo dispositivo. Un cargador sin ICD no es WebGPU
libsecretno hay receta de gnome-keyring ni de ningún servicio de secretos: firefox hablaría a org.freedesktop.secrets y no habría nadie
libcanberrano hay receta de sound-theme-freedesktop: la librería carga y no suena nada

Declararlas habría subido el número de paquetes de la imagen sin encender una sola función. Una librería es media función; la otra mitad es quien la atiende, y eso vale para todas las de esta tabla. Quedan como huecos, no como promociones pendientes.

⚠ Y esta frase también se cayó el mismo día. Decía que de los otros cuatro «el más caro para el usuario es ffmpeg: sin él, un sitio que sirva H.264 no reproduce» — con lo cual el hueco más caro del mapa no existía. Ver §6.11: la receta estaba, faltaba declararla en el runner, y H.264 (y AAC, VP9, Opus, AV1, MP3, FLAC) reproducen. De los cuatro «sin receta» quedan tres, y ninguno es un códec: lectura en voz alta (libspeechd), el driver de VA-API, y libpci —que sólo lo usa glxtest, o sea irrelevante sin GLX.

Y la misma vara aplicada a libnotify, que sí se declaró: su otra mitad es un daemon que escuche org.freedesktop.Notifications. Medido perfil por perfil:

escritorio-kde      plasma-workspace        ✓ completa
escritorio-gnome    gnome-shell             ✓ completa
escritorio-cosmic   cosmic-notifications    ✓ completa
escritorio-sway     dunst 1.12.2            ✓ completa — CERRADA el 2026-09-07

La de sway se cerró el mismo día con recipes/incoming-wlr/dunst.toml, y la trampa del homónimo —el nombre mako YA ESTÁ OCUPADO en el corpus por el motor de plantillas de Python de Mesa— se esquivó eligiendo dunst, que además habla D-Bus por GDBus y no por sd-bus, o sea sin receta nueva de basu. La prueba no es que selle: scripts/wlr/dunst-headless.sh levanta sway headless y un bus de sesión, manda un notify-send y deja que el bus active el daemon solo —el mismo camino que recorre una página web en atuq—, y mide un diff de píxeles antes/después: 14.832 cambiados con el .service puesto, 0 sin él. Evidencia en docs/evidencia/dunst-sway-notificacion-2026-09-07.png.

Y la cadena se cerró hasta el final, que es donde vive la promesa: con --via-atuq el emisor no es notify-send sino una página web dentro del navegador. Ahí se ejercitan las cinco piezas — new Notification(...) → libxul haciendo dlopen("libnotify.so.4") (que no es NEEDED de ningún ELF, o sea invisible para cualquier auditor) → D-Bus → activación → dunst dibujando—, y el veredicto es el onshow del motor, no el diff de píxeles: con el .service puesto llega a MOSTRADA #33, sin él el motor dice ERROR al mostrar y no se dibuja nada. Evidencia en docs/evidencia/atuq-notificacion-web-sway-2026-09-07.png.

Vale la pena decir qué prueba eso de más: que en el proceso de atuq la GLib no se duplica. El navegador usa la cadena -shared (la que arrastra su GTK3) y libnotify.so.4 enlaza esa misma, así que el dlopen no repite GObject. El que sí lo repetía era dunstify, y por eso no se shipea.

⚠ De paso apareció otra media función, y ésta toca a libnotify directamente: dunstify segfaultea hasta en --help porque mezcla la GLib ESTÁTICA del corpus con la COMPARTIDA que arrastra libnotify.so.4 — dos copias de GObject en un proceso. No se shipea; el emisor es notify-send, que viene dentro del artefacto de libnotify y enlaza toda la cadena compartida. Vale como recordatorio de que en este corpus quién enlaza qué GLib es parte del contrato, no un detalle del build.

Lo que esta sección cambia de fondo: hasta hoy la pregunta era «¿arranca?», y la respuesta era sí. La pregunta que faltaba era «¿y qué NO puede hacer?», que ninguna métrica del repo respondía porque todas miran presencia y ésta mira ausencia declarada por el propio binario.

6.10.bis Una descarga terminada MATA a atuq --headless (2026-09-10)

Medido al escribir el guardián del §6.2, y vale la pena por cómo se atribuyó, no por el bug:

corridaqué pasa
--headless, con la extensión de descargasbaja el fichero, la extensión dice TERMINADA, y el proceso muere con SIGSEGV (139)
--headless, sin la extensión (control)baja el fichero, y muere igual
--headless, con el panel de descargas apagado (browser.download.alwaysOpenPanel=false)muere igual
sway headless (compositor REAL, salida offscreen)baja, ingiere al CAS y no se cae

Las dos primeras filas son las que importan: el control sin la extensión descarta que sea nuestro, y la última descarta que sea del producto. Es el modo --headless de Gecko, en esta jaula, cuando una descarga completa. El fichero se baja bien en los cuatro casos — lo que muere es el proceso después.

Sin el control, esto se lee de dos maneras y las dos son caras: «la extensión de descargas rompe el navegador» (perseguir un bug que no existe) o «atuq no puede descargar» (reportar un bug de producto que tampoco existe). Por eso scripts/test-atuq-descargas.py corre sobre sway y no sobre --headless, y por eso dice en su cabecera POR QUÉ — un arnés distinto al de los demás guardianes, sin explicación, es una invitación a «simplificarlo» de vuelta al que se cae.

⚠ Lo que NO se midió: si el crash existe también en el --headless de una máquina sin jaula. No hace falta para lo que se decidió acá, y afirmarlo sin medirlo sería justo lo que este documento no hace.

6.10.ter ⚠ En la IMAGEN BOOTEADA con COSMIC, atuq corre y NO PINTA VENTANA (2026-09-14)

Primera vez que el navegador se prueba dentro de una imagen de disco arrancada, y el resultado no es el de la jaula. La cadena entera funcionó —y esa parte también es medición—:

  1. escritorio-cosmic hidratado con el cierre de HOY: 275 nodos, 8,4 G, y adentro están /usr/bin/atuq, /usr/bin/llama-server y /usr/share/takana/ia/modelo.gguf. O sea que declarar en el perfil sí pone las cosas en la imagen — la otra mitad de la lección de foot;
  2. imagen EFI de 12 G construida y arrancada en QEMU: COSMIC pinta panel, dock y fondo en ~2 min (TCG, sin KVM);
  3. atuq arranca: sus extensiones inician y la del §6.5 sondea el foco cada minuto (FOCO ESTADO unknown), WebRender inicializa («Software WebRender», GL 3.2);
  4. y la ventana nunca aparece. Cinco minutos, dos capturas, y el escritorio sigue vacío. ⚠ MATIZADO el mismo día, ver §6.10.sexies: es INTERMITENTE. Repetido cinco veces sobre esta misma imagen, el navegador pintó en TRES (a +197…+204 s) y en dos no pintó —una de ellas con 15 minutos de observación—. O sea que esto no era ni «nunca pinta» ni «sólo tardaba»: es un fallo que aparece a veces, y una corrida sola no lo puede decidir en ningún sentido.

Lo que ya se descartó, para que nadie lo repita:

La pista que vale: bajo sway headless el mismo artefacto pinta perfecto —hay capturas del panel contestando (§6.7.ter)—. La diferencia entre los dos casos es el compositor (cosmic-comp con llvmpipe contra sway con pixman) y el arranque real contra bwrap. Eso acota dónde mirar, y es material para su propia unidad de trabajo, no para un parche apurado.

⚠ Y la lección del método: sin arrancar la imagen, esto no se veía. Los quince guardianes están en verde, el vigia-imagen.py del perfil da ✓ en iconos, cursores, fuentes, terminal y QML, y el navegador igual no se puede usar en la imagen. «Sella», «hidrata» y «los tests pasan» son tres cosas distintas de «arranca y se ve».

Artefactos de la corrida, para quien siga: /mnt/cosecha/takana-cosmic-qemu.img (12 G) y las capturas en work/cosmic-{1..6}.png.

6.10.quater El COMPOSITOR no es la causa: con cosmic-comp nesteado el navegador SÍ pinta (2026-09-14)

El §6.10.ter dejó dos variables cambiando a la vez —el compositor (cosmic-comp+llvmpipe contra sway+pixman) y el arranque real contra bwrap— y ninguna medición que las separe. scripts/cosmic/ atuq-en-cosmic.sh saca la primera de encima en ~2 min por vuelta, sin QEMU y sin imagen:

sway (headless, pixman)  →  cosmic-comp (winit, cliente de sway)  →  atuq

sway es andamio: no se prueba nada de él, sólo le da a cosmic-comp una pantalla donde vivir, y grim captura de SU lado, o sea que fotografía la ventana de cosmic-comp con lo que haya dentro. El --control corre ese mismo binario de atuq —el del cierre escritorio-cosmic hidratado para la imagen, no otro— directo sobre sway, sin tocar nada más.

El resultado, en píxeles:

cosmic-comp   magenta de la página: 586331 px   bbox (205,169)-(1277,717)
control sway  magenta de la página: 586331 px   bbox (205,169)-(1277,717)

Las dos capturas coinciden al píxel en la región de la página, y no por casualidad ni porque el navegador se haya escapado a sway: en la captura previa al lanzamiento, la ventana de cosmic-comp ocupa exactamente (2,81)-(1277,717) = 1276×637 de su color de fondo, que es el mismo rectángulo que sway le da a un cliente maximizado bajo su barra de título. Las dos corridas enteras difieren en 1130 píxeles, todos en la franja de las barras de título. Y el MOZ_LOG del widget lo confirma desde el otro lado: en la corrida con cosmic el motor ve mode output size 1276 x 637 (la ventana del compositor nesteado) y en el control 1280 x 720 (la pantalla de sway).

⇒ cosmic-comp no es el que impide la ventana. Con GL por software, sin GPU y hasta con el navegador entero pintando dentro, la cadena funciona.

Y de paso, qué camino de buffers usa cuando funciona: WaylandBufferSHM::CreateWlBuffer() en cada cuadro — memoria compartida, no dmabuf. Es la línea de base contra la que comparar lo que haga en la imagen: si allá el motor eligiera otro camino, se vería en la misma traza.

Lo que queda como variable —y es donde sigue la caza— es el arranque de verdad: cosmic-comp sobre kms/DRM de virtio-gpu en vez de winit, el PID1 de arje, el seat. Para eso está scripts/cosmic/atuq-en-imagen.py, que arranca la imagen, maneja el serial, lanza el navegador con el mismo MOZ_LOG y pide capturas por QMP — la corrida del §6.10.ter fue a mano y no dejó un solo log que se pueda releer, que es exactamente lo que hace falta para comparar.

6.10.quinquies En la imagen, el navegador SÍ pinta — y la segunda pantalla no era (2026-09-14)

scripts/cosmic/atuq-en-imagen.py arranca la imagen del §6.10.ter, maneja el serial, lanza el navegador con MOZ_LOG del widget de Wayland y pide capturas por QMP. La corrida de aquel día fue a mano y no dejó un log que se pueda releer; ésta deja todo en work/atuq-imagen-*.

La sospecha que se fue a probar salía del propio serial: aquel arranque veía DOS dispositivos DRM y éste uno solo.

== cosmic :: kernel 6.16.12 · drm: card0 card1 renderD128   ← §6.10.ter (sin `-vga none`)
== cosmic :: kernel 6.16.12 · drm: card0 renderD128         ← con `-vga none`

Sin -vga none, QEMU agrega una VGA estándar ADEMÁS del virtio-gpu, OVMF pinta su GOP ahí y el kernel levanta simpledrm encima. Un compositor con dos tarjetas puede componer en la que el screendump no muestra, y eso se vería exactamente como «la ventana no aparece».

Medido, con las dos configuraciones y el mismo lanzamiento (perfil nuevo, MOZ_ENABLE_WAYLAND=1, página de un solo color):

vídeo de QEMUDRM en el guestmagenta en pantalla
-vga nonecard0703 766 px
VGA=1 (como el §6.10.ter)card0 card1703 766 px

El mismo número en las dos, al píxel. La ventana aparece —panel de COSMIC, dock y el navegador con su barra lateral—, nsWindow::Create() Toplevel sale en el MOZ_LOG, la superficie se mapea y NotifyOcclusionState() mIsFullyOccluded 0. El camino de buffers es el mismo que bajo sway: WaylandBufferSHM, 1280×696.

⇒ la segunda pantalla NO es la causa, y el síntoma del §6.10.ter no se reproduce así. Eso deja una sola diferencia entre aquella corrida y ésta, y es la que sigue: cómo se lanzó el navegador. Acá va con perfil nuevo y las variables puestas; allá fue un atuq pelado, con su perfil por defecto —el que trae la barra lateral y la IA— tecleado en el serial. Por eso el guion tiene --como-usuario: lanza exactamente eso.

⚠ Y la trampa del método que costó una corrida entera, escrita en el guion para que no se repita: el terminal hace eco de lo que se le escribe, así que una marca de fin escrita literal aparece en el serial ANTES de que el shell ejecute nada — el arnés la lee en el eco, da la orden por terminada y manda la siguiente encima. Y cmd & ; echo … es error de sintaxis en ash: el navegador no se lanzó y la corrida siguió como si todo fuera bien, con capturas de un escritorio vacío que se leían igual que el fallo que se estaba investigando.

6.10.sexies El número: pinta a +197…+204 s — y 2 de 5 veces no pinta (2026-09-14)

El §6.10.quinquies dejó una sola variable viva —cómo se lanza el navegador— y --as-user la mide: atuq pelado, con SU perfil por defecto (el que vive en la ext4 y hace su primer arranque entero), tal como se tecleó en el serial aquella noche. Cinco corridas sobre la MISMA imagen:

#lanzamientovídeoobservaciónprimera pintura
1perfil nuevo en tmpfs-vga none180 s+197 s
2perfil nuevo en tmpfsVGA=1240 s+197 s
3perfil del usuarioVGA=1291 s✗ no pintó
4perfil del usuarioVGA=1900 s+204 s
5perfil del usuarioVGA=1900 s✗ no pintó en 15 min

⇒ el fallo es INTERMITENTE —con diez corridas más, la tasa quedó en 3 de 13, ver §6.10.septies—, y las corridas 4 y 5 son el mismo comando sobre la misma imagen. No es el compositor (§6.10.quater), no es la segunda pantalla (§6.10.quinquies) y tampoco es sólo que tarde: cuando pinta, pinta siempre alrededor de los 200 s; cuando no, no pinta aunque se le den quince minutos.

⚠ La lección de método, y me la comí yo hoy mismo. Entre la corrida 3 y la 4 publiqué que «la ventana SÍ aparece, tarda», y el argumento era el MOZ_LOG: en la 3, treinta segundos después de la última captura, Gecko decía mapped 1, «marked as visible & has buffer» y commiteaba un WaylandBufferSHM de 1280×696 — lo mismo que hace cuando pinta. La corrida 5 lo refutó: commiteaba cuadros desde ≤ +377 s y la pantalla estaba vacía a los 900 s. O sea que el cliente cree que está visible no es evidencia de que se vea; la evidencia es el píxel. Un log en verde compatible con una pantalla vacía es exactamente el cuadro contra el que este documento viene advirtiendo, y aun así lo usé para cerrar una pregunta. Ver [[la-etiqueta-no-es-el-hecho]].

Dónde queda el número, para que no viva en el scrollback de quien corrió la VM —que es como se perdió el de la primera corrida—: scripts/cosmic/atuq-en-imagen.py mide la primera pintura y acumula cada corrida en docs/state/primera-pintura.json con sus condiciones (lanzamiento, vídeo, RAM, vcpus, cadencia = resolución del número). Acumula y no pisa porque el fenómeno es intermitente: un fichero de una sola medición convierte esto en «+204 s» o en «no pinta» según qué corrida tocó última, que es la forma más cara de mentir con datos ciertos. Y scripts/vigia-imagen.py —que mide artefactos en segundos y no arranca nada— lo LEE y lo informa como sexto dato:

== primera pintura en la imagen arrancada  (no lo mide este vigía)
   ⚠ pintaron 3 de 5 corridas · primera pintura +197…+204s
     ✓ +197s     perfil nuevo en tmpfs  virtio-gpu solo (-vga none)
     ✗ —         perfil del usuario     vga+virtio-gpu (dos DRM)
     ✓ +204s     perfil del usuario     vga+virtio-gpu (dos DRM)
     ✗ —         perfil del usuario     vga+virtio-gpu (dos DRM)
     ⚠ en las que NO pintó, el MOZ_LOG igual decía `mapped 1` + «has buffer» + …

⚠ Y una trampa para cualquier medición sobre la imagen: cosmic-idle ATENÚA la pantalla. Entre +526 s y +590 s sin una sola entrada, los 703 766 px de (255,0,255) pasan a 703 779 px de (117,0,117) — la misma ventana al 46 % de brillo. Por eso el detector cuenta magenta con tolerancia y no por color exacto: contar el color exacto lee el escritorio dormido como «desapareció la ventana».

Lo que queda abierto, y ahora con arnés para atacarlo: por qué a veces la superficie mapeada y con buffer no llega a la pantalla. Lo próximo es mirarlo del lado del compositor —si el xdg_toplevel recibe su configure, en qué workspace y en qué salida quedó la ventana— y correr N veces para tener una tasa, no una anécdota.

6.10.septies La TASA: «3 de 13» — ⚠ el número medía LA CÁMARA, no el producto (ver §6.10.octies) (2026-09-14)

Diez corridas seguidas, idénticas, con scripts/cosmic/tasa-primera-pintura.sh: --as-user, VGA=1, ventana de 480 s, captura cada 30 s. Más las cinco de las secciones anteriores, todas sobre la misma imagen y anotadas en docs/state/primera-pintura.json:

lanzamientopintaronprimera pintura
perfil del usuario (lo que hace un usuario)3 de 13184, 199, 204 s
perfil nuevo en tmpfs2 de 2197, 197 s

⚠ ESTA TABLA NO SIGNIFICA LO QUE PARECE. Las 13 del perfil del usuario salieron todas con VGA=1 y la de perfil nuevo casi ninguna ⇒ «perfil» y «vídeo» están confundidos; y las capturas miraban UNA de las DOS salidas. El §6.10.octies lo mide: la ventana cae en cualquiera de las dos pantallas, así que los ✗ son «no estaba en la que fotografié». Lo que sí sobrevive es el tiempo: cuando se la ve, se la ve entre +184 y +312 s.

Dos cosas que el número dice y una anécdota no podía:

  1. cuando pinta, pinta siempre en la misma ventana —184…204 s— y nunca a los 300, 400 ni 900. O sea que «tardaba» era falso: no hay una cola larga, hay dos regímenes. O sale a los ~200 s o no sale;
  2. la tasa con el perfil del usuario es ~1 de 4, y con perfil nuevo en tmpfs no falló nunca (2 de 2 — pocas corridas para afirmar que nunca falla, pero suficientes para que la diferencia valga como pista de dónde mirar: qué hace el primer arranque del perfil que el perfil ya hecho no hace).

⚠ Condición de la medida, que es parte del número: las diez corridas salieron con la máquina anfitriona a load ~10 (la tanda de KDE de otro agente más una VM de otra sesión, en 4 cores). Eso empuja hacia el ✗ y por eso el número es un PISO, no una constante del producto: la misma imagen en una máquina ociosa puede fallar menos. Lo que no cambia con la carga es lo de arriba: las que pintan pintan a ~200 s, no a los 400.

⚠ Y una trampa del arnés, medida en las corridas 8–10: con el anfitrión así de cargado, el guest tarda más de 420 s en llegar al shell del serial y los esperar del guion vencen —«nunca llegó ‹fin de la ventana de observación›»—. No las invalida (se verificó una por una que el navegador se lanzó y dejó su MOZ_LOG de 1200–5000 líneas), pero una corrida abortada de verdad se vería casi igual: por eso el guion de la tasa cuenta las abortadas aparte en vez de sumarlas a los fallos.

6.10.octies La causa: son DOS SALIDAS y la ventana cae en cualquiera — y el «3 de 13» medía la cámara (2026-09-14)

El §6.10.septies cerró con «con el perfil del usuario pinta 3 de 13, con perfil nuevo 2 de 2» y la pregunta «qué hace el primer arranque del perfil». La respuesta es: nada. Dos cosas antes de la medición que la contestó:

  1. el MOZ_LOG de las que pintan y las que no termina IDÉNTICO — las dos commitean WaylandBufferSHM de 1280×696. O sea que el navegador dibuja igual en los dos casos;
  2. el cruce de las 15 corridas está CONFUNDIDO: las 13 «perfil del usuario» salieron TODAS con VGA=1, y de «perfil nuevo» hay una sola con dos tarjetas. Con esa tabla no se puede separar «perfil» de «vídeo»: comparar una columna que no varía es comparar con nada.

La medición que lo contesta: screendump de QMP fotografía un dispositivo. Con VGA=1 hay dos (vga0 estándar + gpu0 virtio-gpu) y hasta ahora se fotografiaba uno. Se le puso id= a cada uno y se capturan LOS DOS en cada toma. Resultado, en la misma corrida y el mismo instante:

+312s  ██ PRIMERA PINTURA — 704456 px de la página EN vga0
       gpu0 = 962 675 px de fondo azul + panel y dock  (el escritorio, SIN navegador)
       vga0 = 703 766 px magenta                       (la ventana, con la página)

⇒ cosmic-comp maneja las DOS salidas y la ventana cae en una u otra. Lo que se publicó como «pinta 3 de 13» era cuántas veces la ventana cayó en la pantalla que yo fotografiaba. No es una tasa del producto: es una tasa de mi cámara. Y el §6.10.ter —«arranca y no pinta la ventana»— tiene la misma explicación: aquel arranque también tenía dos tarjetas (drm: card0 card1), y la captura miraba una.

Lo que queda en pie de todo lo anterior, ahora sí sin confundido: · el navegador arranca, mapea su superficie y commitea cuadros siempre (eso nunca falló); · cuando la ventana está en la pantalla que se mira, se ve entre +184 y +312 s; · el perfil del usuario no era la causa, y cosmic-comp nesteado tampoco (§6.10.quater).

⚠ La lección de método, que es la tercera vez en el mismo día: con dos salidas, un ✗ de una captura de una sola pantalla no dice «la ventana no está», dice «no está en ESA». Igual que mapped 1 no probaba que se viera, una foto no prueba que no exista: prueba dónde no está. Por eso docs/state/primera-pintura.json anota ahora en qué pantalla apareció y cuántas fotografió cada corrida, y scripts/vigia-imagen.py no cuenta las ciegas — las nombra aparte:

⚠ pintaron 6 de 6 corridas CONCLUYENTES · primera pintura +184…+312s
  ✓ +312s  vga0  perfil del usuario  vga+virtio-gpu (dos DRM)
  ⚠ 10 corridas NO se cuentan: con dos salidas fotografiaron UNA sola, y su ✗ sólo dice que
    la ventana no estaba en esa pantalla

Lo que sigue, y ahora es otra pregunta: no «por qué no pinta» sino por qué el escritorio abre la ventana en la salida que no tiene el foco —y si con una sola salida (-vga none, que es como se va a usar la imagen de verdad) eso desaparece—. Es barato: la misma serie con una tarjeta. Hecha: §6.10.nonies — y el hueco es real, 2 de 10.

6.10.nonies Con UNA sola salida: 2 de 10 — y el hueco resulta ser real (2026-09-14)

El §6.10.octies cerró pidiendo exactamente esta serie: las mismas diez corridas pero con -vga none, una sola tarjeta (drm: card0), que es como se usa la imagen de verdad. Con una sola salida la captura es COMPLETA, así que por primera vez un ✗ significa «la ventana no está» y no «no está en la que fotografié».

vídeopintaronqué dice el ✗
vga+virtio-gpu (dos DRM)4 de 14nada: la cámara miraba una de dos pantallas
virtio-gpu solo (-vga none)2 de 10concluyente

Y como la captura ya no tiene punto ciego, se puede clasificar el último cuadro de cada corrida por su color dominante, que separa dos fallos que hasta ahora eran el mismo ✗:

último cuadrocorridasqué es
#ff00ff 68 %2✓ la página, pintada
#f9f9fb 68 %1la ventana está —misma geometría que la que pinta— pero en blanco
#214a87 91 %7sólo el escritorio: fondo, panel y dock. No hay ventana

⇒ el hueco del §6.10.ter existe y no era la cámara. Las dos salidas explicaban el número inflado de ✗ de las series con VGA=1, no el fenómeno.

Y el MOZ_LOG separa los dos regímenes con un número, no con una impresión:

líneas de moz.moz_logúltimo apunte del log
las 3 con ventana952 – 1505sigue escribiendo hasta que se apaga la VM
las 7 sin ventana573 – 727se corta a los ~40 s del lanzamiento, y quedan 6 minutos de silencio

No es que dibuje invisible: deja de trabajar. Y no muere por un crash — atuq.log de una fallida llega hasta WebRender - OpenGL version new 3.2 / Renderer: Software WebRender sin una sola línea de error. Las últimas líneas que escribe son justo el bucle de vsync emulado:

WaylandSurface::VSyncCallbackHandler() marked as visible & has buffer
WaylandSurface::SetVSyncCallbackLocked(), enabled 1 mapped 1
WaylandSurface::Commit() allowed [1] needs commit 1
  fire VSync callback aEmulated [0] cb.mEmulated [1]      ← y aquí se acaba

Que es la forma que tiene Gecko de decir «commiteo a ciegas porque nadie me devuelve frame callbacks». La hipótesis que queda —no medida todavía, y hay que decirlo así— es que cosmic-comp nunca le da a esa superficie el estado de visible, así que el cliente se queda sin callbacks y se detiene. Lo contesta el log del compositor, no el del navegador.

⚠ Condición de la medida: anfitrión a load ~15 (la tanda de KDE del otro agente más VMs de otra sesión, en 4 cores) — más alta que el ~10 de la serie anterior. Las dos que pintaron lo hicieron a +175 s y +319 s, en línea con los +184…+312 de antes. Y hay un orden sospechoso que la serie no puede resolver sola: las 3 con ventana son las corridas 1-3 y las 7 sin ventana las 4-10, todas seguidas. Eso puede ser la carga subiendo o puede ser estado que dejan las corridas entre sí; con diez corridas no se separa.

⚠ Dos deudas del arnés que esta serie dejó a la vista, ninguna barrida: · la cuenta de «procesos atuq» nunca volvió: el eco del serial se come la salida, así que no se puede afirmar si el proceso seguía vivo en las fallidas (el atuq.log sin crash es evidencia indirecta, no la misma cosa); · tasa-primera-pintura.sh quedó parametrizado por VGA pero el nombre del log no: la serie nueva sobrescribió los crudos de la anterior (se salvaron 9 de 10 en work/serie-dos-tarjetas/). Arreglado después de la serie — no durante: bash lee el guion a medida que lo ejecuta.

6.10.decies El log del compositor a info NO puede contestar — y por qué hizo falta una perilla (2026-09-14)

La hipótesis del §6.10.nonies —«nadie le devuelve frame callbacks»— se contesta del lado de cosmic-comp, así que el arnés ahora vuelca /var/log/cosmic/cosmic-session.log, que ya existía desde el primer día y nunca se había mirado. Corrida fallida (91 % de azul de escritorio, sin ventana), y el log entero:

92 líneas. Todas de 21:17:48 — los segundos del arranque. Ni una superficie, ni un toplevel,
ni una salida. Termina en:
ERROR cosmic_comp::xwayland: Failed to start Xwayland
      err=Custom { kind: AddrInUse, error: "Could not find a free socket for the XServer." }

Dos cosas, y conviene no confundirlas:

  1. el ERROR de Xwayland es ruido aquí — la distro es Wayland-only por decisión (§frente-gnome), y atuq es cliente Wayland nativo: que no haya X no le quita ni un frame. Es una pista falsa esperando a que alguien la agarre;
  2. a RUST_LOG=info el compositor no dice NADA de ventanas. El log no es que muestre algo raro: es que no habla del tema. Un log en silencio no es evidencia de nada — lo mismo imprime cuando todo anda.

⇒ para responder hace falta subirle el nivel, y eso no se puede hacer desde fuera: cosmic-comp lo lanza cosmic-start desde el getty, en el arranque, sin ambiente heredable. La perilla existe y estaba a la vista: cosmic-start hace . /etc/cosmic-mode antes de export RUST_LOG="${RUST_LOG:-info}", así que ese fichero manda. Y la imagen se arranca sin -snapshot, o sea que lo que se escribe adentro persiste. De ahí atuq-en-imagen.py --set-rust-log VALOR: entra por el serial, escribe /etc/cosmic-mode y apaga; lo usa el arranque SIGUIENTE. Con la cadena vacía lo quita, que es como se deja la imagen después de medir — subirle el log a una imagen compartida y olvidárselo es dejar una trampa para el próximo.

6.10.undecies La ventana SÍ está: mide 117×70 px — y la hipótesis de los frame callbacks era falsa (2026-09-14)

Como el compositor no habla por su log (§6.10.decies), se le preguntó por el protocolo: atuq-en-imagen.py --wayland-debug lanza el navegador con WAYLAND_DEBUG=1, que registra cada mensaje en los dos sentidos. Corrida fallida (91 % de azul de escritorio), y esto es lo que MANDA cosmic-comp:

xdg_toplevel#58.configure_bounds(0, 0)          ← al mapear: todavía no sabe el tamaño
xdg_toplevel#58.configure(0, 0, array[0])
xdg_toplevel#58.configure_bounds(1280, 692)     ← después sí
xdg_toplevel#58.configure(117, 70, array[16])   ← y la ventana queda de 117×70
wl_surface#52.enter(wl_output#16)
wl_callback#76.done(…)   × 43

Y el píxel lo confirma, restando el cuadro de ANTES del de DESPUÉS: aparece un cluster en (496,115), de unos 286×86 con sombra, con el blanco #fafafb y el acento cian #63d0df de la decoración de COSMIC. Una ventanita.

⇒ atuq no deja de pintar: pinta una ventana de 117×70 px. Con eso caen dos cosas que este mismo documento daba por buenas hace dos secciones:

lo que decíalo que mide el protocolo
«nadie le devuelve frame callbacks» (§6.10.nonies)43 wl_callback.done. Los devuelve
«deja de trabajar a los ~40 s»deja de escribir: con la ventana de 117×70 no hay casi nada que redibujar
«7 de 10 no tienen ventana» (§6.10.nonies)la tienen. Mi contador de magenta no la ve porque es 0,7 % de la pantalla

La tasa 2 de 10, entonces, tampoco mide lo que decía. No es «pintó / no pintó»: es «la ventana salió grande / salió de 117×70». El fenómeno es real y sigue estando —una ventana así es inusable— pero el nombre estaba mal, y con el nombre mal la causa que se busca es otra.

De dónde sale 117×70, y qué es hipótesis. El primer configure llega con bounds(0,0): en ese instante el compositor todavía no le puede decir al cliente de qué tamaño es la pantalla, y el protocolo dice «elegí vos». La medida NO prueba quién eligió 117×70 —para eso hay que ver qué tamaño de buffer commitea el cliente ANTES de ese configure—, pero el orden es compatible con una carrera: atuq dimensiona su ventana antes de tener la geometría de la salida y cae en un mínimo. En las corridas que salen bien la ventana mide 1073×549 (§6.10.quater), que es lo que se ve cuando la geometría llegó primero. Eso es lo próximo a medir, y se mide con el mismo arnés.

⚠ Método, tercera vez en el mismo frente: el primer vuelco del protocolo filtró por ' -> ' creyendo que era «lo que manda el compositor». En WAYLAND_DEBUG la flecha marca los pedidos del cliente; los eventos son las líneas SIN flecha. O sea que volví a leer al navegador —la mitad que ya sabíamos que miente— con un filtro nuevo. Y las cuentas dieron cinco ceros porque los nombres llevan #id en el medio (wl_surface#30.commit), así que wl_surface.commit no engancha: un grep que devuelve 0 puede ser una ausencia o puede ser un patrón mal escrito, y las dos se ven igual.

6.10.duodecies El 117×70 lo pide el CLIENTE — el compositor sólo obedece (2026-09-15)

El §6.10.undecies dejó abierto quién elige el tamaño, y dijo cómo medirlo: mirar, en orden y en los dos sentidos, dónde aparece por primera vez. create_buffer trae el tamaño (el attach no), y set_window_geometry es lo que el cliente DECLARA querer. Numerado, sale así:

590  xdg_toplevel#58.configure_bounds(0, 0)                  ← compositor: «elegí vos»
592  xdg_toplevel#58.configure(0, 0, array[0])
629  → xdg_toplevel#58.set_min_size(117, 37)                 ← EL CLIENTE
630  → xdg_toplevel#58.set_max_size(348, 16332)
631  → xdg_surface#57.set_window_geometry(26, 23, 117, 70)   ← EL CLIENTE pide 117×70
664  xdg_toplevel#58.configure_bounds(1280, 692)
665  xdg_toplevel#58.configure(117, 70, array[16])           ← el compositor ACATA

⇒ cosmic-comp no impone nada. Tiene 1280×692 para dar y devuelve exactamente lo que el cliente pidió. La ventana de 117×70 la elige atuq.

Y el MOZ_LOG de la misma corrida dice cómo:

nsWindow::Create()
  nsWindow::Create() Initial resize to 1 x 1     ← nace de 1×1
  nsWindow::Create() Toplevel
nsWindowWayland::CreateNative()

Gecko crea la ventana de 1×1 y nunca la agranda. El 117×70 no es un tamaño elegido: es lo que queda cuando el chrome se mide a sí mismo sin que nadie le diga de qué tamaño tiene que ser. Lo confirma el set_max_size(348, 16332): ancho máximo 348 px y alto 16332 — las restricciones de una caja que se dimensiona por su contenido, no las de una ventana de navegador. (Y el buffer que commitea es de 157×110 para una geometría de 117×70: los ~20 px de margen son la sombra del CSD.)

⇒ el frente se mueve de COSMIC a Gecko. No hay nada que arreglar en el compositor. Lo que falta saber es por qué el nsWindow se queda en su tamaño mínimo, y la sospecha —todavía sin medir— es que cuando dimensiona no tiene la geometría de la salida: el primer configure llega con bounds(0,0), o sea que en ese instante ni el compositor se la puede dar. Se comprueba ordenando en la MISMA lista los eventos wl_output contra el set_window_geometry; con dos greps separados no se puede, porque no se pueden intercalar.

⚠ Esa sospecha se midió al día siguiente y es FALSA —y además la pregunta estaba mal hecha: esa ventana no era la del navegador (§6.10.terdecies). Queda escrita porque el orden importa: era una hipótesis razonable, y lo que la desmintió fue preguntarle al ScreenManager en vez de seguir deduciendo del protocolo.

6.10.terdecies La ventana de 117×70 es un DIÁLOGO: el navegador nunca abrió (2026-09-15)

Las últimas cuatro secciones discutieron por qué la ventana de atuq sale de 117×70. La respuesta es que no era la ventana de atuq, y lo dice el mismo volcado de protocolo que ya se había leído —dos líneas más abajo de donde paró la lectura—:

556  -> xdg_surface#57.get_toplevel(new id xdg_toplevel#58)
558  -> xdg_toplevel#58.set_title("Open atuq in Troubleshoot Mode?")
559  -> xdg_toplevel#58.set_app_id("atuq")
629  -> xdg_toplevel#58.set_min_size(117, 37)
630  -> xdg_toplevel#58.set_max_size(348, 16332)
631  -> xdg_surface#57.set_window_geometry(26, 23, 117, 70)

Es chrome://browser/content/safeMode.xhtml: el diálogo de Modo de resolución de problemas. Con eso la pregunta cambia de «¿por qué la ventana sale chica?» a «¿por qué atuq arranca en modo de resolución de problemas?», que tiene respuesta y tiene arreglo.

La cadena entera, y dónde se lee cada eslabón. Nada de esto es deducción:

  1. el perfil del usuario traía el contador de caídas alto — prefs.js con fecha 15-Sep 00:36, o sea antes de cualquier corrida de ese día:

    user_pref("toolkit.startup.recent_crashes", 16);
    
  2. el umbral, en nuestro build: browser/omni.ja → defaults/preferences/firefox.js:831 dice pref("toolkit.startup.max_resumed_crashes", 3) ⇒ 16 > 3 ⇒ automaticSafeModeNecessary;

  3. quién abre el diálogo: modules/BrowserGlue.sys.mjs:389, en _beforeUIStartup(), cuyo propio comentario dice «runs on startup, before the first command line handler is invoked (i.e. before the first window is opened)»:

    if (Services.appinfo.inSafeMode) {
      Services.ww.openWindow(null, "chrome://browser/content/safeMode.xhtml", "_blank",
                             "chrome,centerscreen,modal,resizable=no", null);
    }
    

    modal y antes de la primera ventana ⇒ no hay un navegador detrás esperando: no hay navegador;

  4. hasta el set_max_size(348, 16332) sale de ahí: el título del diálogo se traduce con Fluent y la traducción trae el tamaño — localization/en-US/browser/safeMode.ftl, troubleshoot-mode-window .style = max-width: 400px;

  5. y por qué el proceso se MUERE solo (el «[1]+ Done» que imprimió el shell del serial): el único camino de ese diálogo que termina el programa es onCancel() de safeMode.js, que llama appStartup.quit(appStartup.eForceQuit). El log trae ATUQ-EXIT=0: se va solo, sin caerse. Quién cancela el diálogo —Escape, el compositor, el cierre de la ventana— no está medido.

Cómo se leyó el perfil sin arrancar la imagen, que es la única forma honesta de mirar el estado previo —arrancarla lo cambia—: el disco es un raw con tabla de particiones y debugfs lee la partición sin montarla y sin root:

debugfs -R "dump /root/.config/mozilla/firefox/<id>.default-default/prefs.js /tmp/prefs.js" \
  "/mnt/cosecha/takana-cosmic-qemu.img?offset=$((264192*512))"

⚠ Y esa lectura MIENTE si la VM se acaba de matar, con una forma peligrosa (medido el 2026-09-15 por la otra sesión del frente): el ext4 del guest queda con el journal sucio, debugfs se planta con «Block bitmap checksum does not match» y con -c abre pero lee el estado PRE-journal — ahí prefs.js figura de 0 bytes con la fecha de la corrida anterior. Un prefs.js vacío leído así se ve idéntico a «el navegador borró el perfil», que es un diagnóstico mucho más grave y falso. La lectura buena es desde adentro, después de que el mount replaye el journal: para eso están los campos recent_crashes_antes / _despues del arnés. debugfs sirve para mirar el estado previo con la imagen apagada, no para leer lo que dejó un kill.

La sospecha del §6.10.duodecies era falsa: Gecko SÍ ve la pantalla, 1280×800

Se midió agregándole WidgetScreen:5 al MOZ_LOG —el módulo que registra qué monitores arma el ScreenManager; que el módulo EXISTE en el libxul sellado se comprobó con grep antes de confiar en la variable, porque un módulo que no está se ve igual que un módulo que no tiene nada que decir—. La respuesta llega con hora:

17:12:29.031  D/WidgetScreen  New monitor 0 size [0,0 -> 1280 x 800] depth 24 scale 1.0 … refresh 75
17:12:29.031  D/WidgetScreen    workarea [0, 0] -> [1280 x 800]
17:12:31.377  D/WidgetScreen  (otra vez, ya en el proceso Parent)
17:12:34.843  D/Widget        nsWindow::Create() Initial resize to 1 x 1     ← nace de 1×1
17:12:35.290  D/WidgetScreen  GetScreenForWindow() [0] screen (x=0, y=0, w=1280, h=800)

⇒ la pantalla está completa y a tiempo, cinco segundos antes de que la ventana se cree, y la propia ventana la resuelve bien cuando pregunta. La carrera con wl_output queda refutada y el configure_bounds(0, 0) del principio es ruido del protocolo, no información que a Gecko le falte. Que el 117×70 resultara ser un diálogo con max-width no le quita valor: era la hipótesis viva y hacía falta matarla con un instrumento.

El control, en los DOS sentidos — y es el contador

Dos corridas seguidas sobre la misma imagen, el mismo artefacto y el mismo perfil, cambiando una sola cosa: toolkit.startup.recent_crashes, fijado en el user.js del perfil (que gana sobre prefs.js en cada arranque). El mando es atuq-en-imagen.py --crashes N:

corridacontadorqué pasó
A 17:31ausente (0)✓ primera pintura +123 s, 704 458 px de la página magenta
B 17:44fijado en 16✗ nada en 300 s · set_title("Open atuq in Troubleshoot Mode?") · ATUQ-EXIT=0

⚠ Y una honestidad que cambia la fuerza del argumento: en la corrida A el --crashes 0 fue un no-op —el contador ya no estaba en prefs.js, y por qué no estaba sigue sin explicación medida (ver más abajo: mostrar el diálogo NO lo limpia)—, así que A sola no prueba nada: prueba tanto «lo arregló el 0» como «el perfil ya estaba limpio». La que prueba la causa es B, que lo pone de vuelta y rompe de vuelta. Una sola dirección habría sido una correlación con suerte.

⚠⚠ La serie de primera-pintura.json está CONTAMINADA, y por el propio andamiaje

El arnés mata la VM con el navegador vivo. Un arranque que no llega a terminar es, para Gecko, una caída de arranque; pasados 3 el sujeto de la medición deja de ser el navegador y pasa a ser el diálogo. Con 16 acumulados, todo lo medido desde el 14-Sep cae bajo sospecha: las tasas «3 de 13» del §6.10.septies y «2 de 10» del §6.10.nonies no midieron intermitencia del producto sino un perfil que se degradaba corrida a corrida. El instrumento degradaba al sujeto.

Y eso quedó MEDIDO el mismo día (tres fases de 60 s en un mismo arranque, cada una matada con kill -9 antes de pintar — o sea antes del gancho que cierra la detección de caída de arranque):

fasecontador antesdespuésqué la precedió
k1ausenteausenteuna corrida SANA, que terminó bien
k2ausente1k1, matada antes de pintar
k312k2, matada igual

Es un tally: uno por arranque interrumpido, sin techo. Y trae dos cosas que la serie sola no distinguía:

El borde, medido en los dos lados: 3 guardado no alcanza, 4 sí

Partiendo del perfil en 2, tres fases más en un arranque (las dos primeras matadas antes de pintar):

faseantesdespuésqué ventana abrióATUQ-EXIT
k123el navegador — set_title("atuq"), set_min_size(638, 120)137 (la matamos)
k234el diálogo — set_title("Open atuq in Troubleshoot Mode?"), min_size(117, 37)0
k345el diálogo otra vez0

⇒ la comparación es DESPUÉS de sumar: el arranque que encuentra 3 guardado lo sube a 4, compara 4 > 3 y abre el diálogo; el que encuentra 2 pinta. Contando desde la última corrida que terminó bien, eso son cuatro arranques interrumpidos seguidos, y el quinto lanzamiento ya no abre el navegador.

Y la otra mitad del cálculo lo confirma sin ambigüedad: toolkit.startup.last_success vale 1789494795 en las tres fases —congelado en el arranque de la última corrida sana—. El contador sube mientras last_success no se mueve; con las dos cifras juntas, un contador que no cambió deja de ser ambiguo entre «no subió» y «subió y se limpió».

⚠⚠ Y eso REFUTA algo que este documento afirmaba dos secciones más arriba: mostrar el diálogo NO limpia el contador. k2 lo dejó en 4 y k3 en 5 — dos corridas consecutivas con diálogo y ninguna limpieza. Entonces la desaparición del 16 entre las 17:12 y las 17:20 no tiene explicación medida, y la atribución «Gecko lo borró al mostrarlo» se retira. Dos candidatas, las dos sin comprobar: que el prefs.js se perdiera en el kill (cuando se lo leyó con debugfs figuraba de 0 bytes, y ese volcado no es fiable — es la lección del párrafo de debugfs), o que un arranque que TERMINA sí lo limpie, que es lo que hace upstream.

⚠ Hubo una tercera candidata, mía, y está descartada con un cero que viene con control: «el --crashes 0 fija el valor por DEFECTO y un pref igual al default no se persiste, así que el “ausente” prueba el pin y no una limpieza». No: toolkit.startup.recent_crashes no tiene default en este build, y por eso un 0 puesto a mano sí se escribe. Tres lugares del artefacto sellado lo dicen y el cuarto es el que convierte a los otros en evidencia:

greprefs.js (omni de toolkit)                       → 0 líneas con «toolkit.startup»
browser/omni.ja defaults/preferences/*.js           → 0 líneas con «recent_crashes»
libxul.so, strings «sMirror_toolkit_startup*»       → 0     ⇒ no es pref de StaticPrefList
libxul.so, strings «sMirror_browser_crashReporter*» → 2     ⇐ EL CONTROL, que TENÍA que dar positivo

Los dos primeros ceros no valen solos, y el primero es el ejemplo de por qué: greprefs.js da cero porque ahí no vive ningún toolkit.startup.*, no porque el pref no exista. Un grep que devuelve 0 puede ser una ausencia o un sitio equivocado, y las dos se ven igual; lo único que las separa es un patrón hermano que tenga que dar positivo en el mismo fichero. (Y una distinción que se parece a otra y no lo es: el binario lee ese pref con un 0 de respaldo en el sitio del GetInt, que no es un default registrado y no evita que el valor se escriba.)

La serie sana — y lo que refuta: pintar no es TERMINAR

Cuatro arranques FRÍOS en serie, el perfil partiendo del 5 que dejó la corrida del umbral, con --until-paint salvo la tercera (matada a los 60 s a propósito):

corridapinantesdespuésventanaprimera pintura
S1 18:27--crashes 051el navegador✓ +97 s
S2 18:33—12el navegador✓ +98 s
S3 18:38—23(matada a los 60 s)—
S4 18:50—34el diálogo (min_size(117, 37), título Troubleshoot)✗ en 420 s

Tres cosas, y la tercera no la esperaba nadie:

  1. el número de la tasa, por fin con perfil sano: +97 s y +98 s, dos corridas independientes y pegadas —contra los +123, +195, +197 y +204 de antes—. Con el anfitrión más descargado el mismo artefacto pinta en la mitad de tiempo, o sea que el número de esta serie es de la máquina y la carga tanto como del producto: se publica con sus condiciones o no se publica;
  2. el umbral se reprodujo solo, en otra secuencia y sin buscarlo: S4 arrancó con 3 guardado, sumó a 4 y abrió el diálogo. Es la segunda medición independiente del mismo borde, y esta vez la ventana se identificó por min_size y no por el título;
  3. ⚠⚠ y una corrida que PINTA no limpia el contador. S2 pintó y dejó el 2; S1 pintó y dejó 1. Más todavía: toolkit.startup.last_success vale 1789494795 en las cuatro corridas —la marca del arranque de las 17:53— o sea que el gancho de cierre no llegó a correr ni una sola vez, incluso en las que pintaron. Pintar no es terminar el arranque.

⇒ la pregunta de quién limpia el contador no la cierra esta serie —el §6.10.terdecies decía que sí—, y lo que hacía falta era una corrida que dejara al navegador seguir vivo después de pintar.

Lo que limpia el contador: sobrevivir un rato después de pintar (2026-09-15)

El par aísla la variable: mismo pin, mismo perfil, misma imagen, y la única diferencia es cuánto vive el navegador después del primer cuadro.

corridapinantespintóvida después de pintardespués
S1 18:27--crashes 05+97 scorta — --until-paint corta la observación1
cierre-1 19:06--crashes 04+120 s~360 s (ventana de 480 s, sin --until-paint)ausente

⇒ pintar no alcanza; sobrevivir un rato después de pintar, sí. Y explica A sin anomalía: A también siguió viva minutos después del primer cuadro, porque los volcados por el serial tardan.

Y el sello viaja con la limpieza: es UN gancho, no dos. El «antes» de la corrida siguiente trae last_success = 1789498722 = 18:58:42 UTC, que es la hora de arranque de cierre-1: el mismo arranque que limpió el contador escribió el sello. (Ese volcado sólo grepeaba el contador y se resolvió en el instrumento en vez de dejarlo como nota: last_success va ahora en los DOS volcados y se guarda en la medición, last_success_antes / _despues.)

⚠ El reloj es el del LANZAMIENTO, no el de la pintura — y el piso que sale de ahí

Leyendo el perfil en CADA captura (--watch-prefs), la traza de una corrida arrancada a las 19:12:17:

capturalast_success en discorecent_crashes
+30 s · +64 s · +100 s18:58:42 (el de la corrida anterior)0
+137 s ██ primera pintura19:12:17 (el suyo)ausente
+167…+433 s19:12:17ausente

⇒ el sello llega al disco entre +100 s y +137 s desde el lanzamiento. Y S1 —que parecía la excepción— lo acota por abajo de forma independiente: sus propios ficheros dicen que vivió 100 s desde el lanzamiento (primer cuadro a +97 s, conversión a PNG tres segundos después) y no selló. Dos corridas distintas, el mismo intervalo.

Si el reloj fuera el de la pintura, S1 tendría que haber sellado —pintó a +97 s y siguió unos segundos— y no lo hizo. Con el reloj del lanzamiento encaja todo sin excepciones: S1 pintó pronto y murió a los 100 s; cierre-1 pintó a +120 y vivió 480; A pintó a +195 y vivió los minutos de los volcados; la de arriba selló a los ~+120 pintara cuando pintara.

⚠ Y la consecuencia es contraintuitiva, y es la que muerde: cuanto MÁS RÁPIDO pinta, más fácil es envenenar el perfil, porque --until-paint corta antes. La serie que se rompió a la cuarta se rompió por eso — no por ser larga, por ser rápida. La regla está en el arnés y no en el runbook: PISO_SELLO = 180 (margen sobre la cota alta de 137), --until-paint no corta antes de ese piso y dice por qué sigue esperando. El comentario lleva los dos números medidos al lado, porque un piso que se lee como elegido lo baja el próximo que lo vea.

⚠ Y el piso se COMPRUEBA, no se confía. Los 180 s son de ESTA máquina —TCG sin KVM y con la carga que tenga el anfitrión, igual que los +97/+120/+137 de la pintura—, así que con el anfitrión cargado el mismo arranque puede no llegar al gancho: la corrida sumaría al contador y envenenaría a la siguiente en silencio, que es exactamente como empezó todo esto. Como el sello se lee antes y después, el arnés lo dice en la cara:

✓ el arranque SELLÓ el éxito (last_success 18:58:42 → 19:12:17): esta corrida no envenena la siguiente
⚠⚠ el sello NO se movió: este arranque no terminó ⇒ la corrida SUMA al contador. Subí PISO_SELLO

Un piso que falla callado es un piso que no existe; éste avisa cuando no alcanzó.

⚠ Y el 16 sigue sin explicación, con una contradicción medida encima: desapareció en una corrida que fue diálogo + ATUQ-EXIT=0, y las fases k2/k3 del umbral fueron diálogo + ATUQ-EXIT=0 también y no limpiaron nada (quedaron en 4 y 5). Mismo par de condiciones, resultado opuesto ⇒ «el diálogo que se cierra limpio limpia el contador» no se sostiene. Conviene no dejar que la respuesta buena —la de la tabla de arriba— tape esta, que es de otra pregunta.

Y la consecuencia para el instrumento es lo más caro de la serie: con --until-paint, cada corrida suma uno, pinte o no pinte. Cuatro corridas envenenan cualquier perfil, así que una serie larga se rompe sola a la cuarta — que es exactamente lo que había pasado ayer sin que nadie lo viera. Dos formas de convivir con eso, las dos escritas en el arnés: pinnear --crashes 0 en cada corrida de una serie (S1 muestra que el pin funciona incluso partiendo de 5: resetea la base y el incremento del arranque la deja en 1, lejos del borde), o dejar que el navegador llegue al gancho de cierre, que cuesta más tiempo de VM del que cuesta medir la pintura. Y mientras tanto el arnés avisa cuando el contador está en 3: la corrida siguiente va a abrir el diálogo y no el navegador.

Un discriminador que salió gratis y sirve para siempre: el ATUQ-EXIT de una fase que matamos es 137 (128+9) y el de una corrida que se fue sola por el diálogo es 0. El código de salida distingue «la mataron» de «se fue sola» sin mirar nada más — que es justo lo que el §6.10.terdecies no podía distinguir de una captura.

Qué se hizo con eso, en vez de borrar el fichero:

Tres arreglos de instrumento que salieron de la misma corrida

Y la lista ordenada del protocolo tenía su propio agujero: se ahogaba en los ~60 modos que anuncia QEMU (wl_output.mode(0, …)), así que el head -70 cortaba antes del set_window_geometry —la lista que existía para ordenar dos cosas se quedaba sin una de las dos—. Ahora excluye los modos no actuales y trae set_title/set_app_id, que es lo que identificó la ventana.

⚠ Dos agentes, una imagen — y lo que nos salvó fue el lock de QEMU

Mientras se medía esto, dos sesiones distintas corrieron atuq-en-imagen.py sobre el mismo takana-cosmic-qemu.img. La segunda murió en el arranque:

qemu-system-x86_64: -drive file=…/takana-cosmic-qemu.img,format=raw,if=virtio:
  Failed to get "write" lock. Is another process using the image?

Dos VMs escribiendo el mismo raw es corrupción silenciosa del sistema de ficheros del guest, y la imagen no se abre con snapshot=on a propósito (las corridas se acumulan: de ahí salen los logs del arranque anterior). Que el candado exista es de QEMU, no nuestro — el mismo patrón del ADR 0012 con el árbol de fuentes, pero con la mitigación ya puesta. Dos consecuencias:

Lo que queda, y una decisión de producto
  1. decidir si atuq debe traer el modo automático apagado. toolkit.startup.max_resumed_crashes = -1 lo desactiva. A favor: en una distro que se entrega, un diálogo modal en inglés sin navegador detrás es un arranque roto para el usuario, y el diálogo aparece por caídas que pueden no ser suyas. En contra: es una red de seguridad de upstream, y apagarla esconde caídas de arranque reales. Es una decisión, no un arreglo que se mete de paso — toca recipes/atuq/ y por lo tanto re-sella el artefacto (~340 M de store y todas las sondas a repetir);

  2. rehacer la serie de la tasa con perfil sano: hoy hay UNA corrida limpia (+123 s) y el resto está marcado. «Cuánto tarda en pintar» vuelve a estar sin medir, y ahora se puede medir bien;

  3. ¿respeta el sizemode=maximized que el perfil recuerda? CONTESTADA el mismo día: sí. Con perfil sano el navegador pintó a +195 s y el protocolo trae las dos cifras, cada una en su papel: set_window_geometry(26, 23, 1332, 852) —el tamaño normal que restaura del xulstore— y configure(1280, 692) acatado, que es el área de trabajo por el maximizado. Ninguna es 1152×720 ⇒ browser-init.js no calculó nada, que es lo correcto en un perfil que ya recuerda.

    De ahí sale el discriminador barato entre las dos ventanas, y vale para cualquier corrida futura: el navegador pide set_min_size(638, 120) —después (690, 172)— y set_max_size(16332, 16332); el diálogo pide set_min_size(117, 37) y set_max_size(348, 16332). Con mirar el min_size se sabe cuál abrió, y hay que mirar ése y no el título: set_title también las separa pero está TRADUCIDO, así que en una imagen en castellano el grep por «Troubleshoot Mode» deja de enganchar y su ausencia se lee como «no salió el diálogo» — la conclusión contraria a la verdadera. El min_size es la misma pregunta hecha a un número. (Y el 348 del diálogo sale de su propio Fluent: troubleshoot-mode-window .style = max-width: 400px. Una cadena de localización decidiendo geometría, que es por qué el 117×70 no se parecía al tamaño de nada.)

    Dos cosas más de esa corrida: el xulstore quedó igual ⇒ una corrida sana no ensucia lo que el perfil recuerda; y recent_crashes_despues salió ausente, que en su momento se leyó como «la corrida que pinta limpia el contador» y hay que no leerlo así — esa corrida llevaba --crashes 0 pinneado, o sea el valor por DEFECTO, y un pref igual al default no se persiste. El «ausente» puede ser eso y no una limpieza.

6.11 Y ahora la pregunta que faltaba: ¿REPRODUCE? (2026-09-07)

El §6.10 preguntó «¿qué NO puede hacer?» y contestó leyendo cadenas. Media pregunta. La otra mitad —¿se ve el vídeo?— no la contesta ningún auditor de ELF, y al hacerla aparecieron dos errores del mapa y un bug real del navegador.

Error 1: «no hay receta de ffmpeg». La hay. recipes/ffmpeg.toml, 7.1, sellada, publica libavcodec.so.61 —uno de los once sonames que libxul.so sondea por dlopen literal— y ya viajaba en la clausura de los cuatro escritorios, arrastrada por mpv. Comprobado con yupana.membresia, que es la vista honesta por par (cola, nombre), no leyendo el TOML:

escritorio-kde 308 nodos · escritorio-gnome 192 · escritorio-sway 203 · escritorio-cosmic 163
ffmpeg ✓ en los cuatro · libva ✓ en los cuatro

Lo que faltaba no era la receta: era la lista de raíces de scripts/atuq-nested.sh, escrita a mano, que hidrataba un rootfs más pobre que la imagen. Otra vez el instrumento y no el artefacto — la misma forma que gcc-libs el día anterior. Y la comprobación previa que sí se pudo hacer sin correr nada: de los 82 símbolos con forma av*_ que libxul dlsimboliza, los 9 que nuestro libavcodec no exporta son los de la API vieja (avcodec_decode_video2, av_lockmgr_register, avcodec_register_all…), que Firefox sólo pide para las versiones 53-57.

Error 2: «VA-API, sin receta». También la hay, y también está en las cuatro imágenes. El hueco es real pero por la OTRA mitad: las tres recetas de mesa construyen con -Dgallium-va=disabled y -Dvideo-codecs= vacío ⇒ no existe un solo *_drv_video.so. Un cargador sin ICD no acelera nada, exactamente como vulkan-loader. ⚠ Y con una trampa nueva: ahora que libva está en el rootfs, el mapa de --dlopen ya no la reporta, porque mide presencia de fichero. La librería presente SILENCIA el aviso sin encender la función. Queda escrito acá porque el guardián ya no puede decirlo.

El bug real: AV1 no reproducía. Gecko se come el primer elemento de LD_LIBRARY_PATH al lanzar el proceso RDD, el que decodifica vídeo. El lanzador ponía /usr/lib/atuq una sola vez —o sea, primero—, así que el RDD arrancaba sin él, no encontraba el ffvpx bundleado (donde vive dav1d) y anotaba FFVPX: Link result: NoProvidedLib. Y ahí no falla: se cae al ffmpeg del sistema, que cubre H.264/AAC/VP8/VP9/MP3/FLAC/Opus… pero no AV1 por software, porque el decodificador av1 de ffmpeg es sólo hwaccel y nuestra receta va sin dav1d. Resultado: un vídeo AV1 se quedaba en readyState=1 para siempre, sin un error, sin un NEEDED faltante y sin una cadena ausente.

Tres corridas del MISMO artefacto que sólo cambian esa variable:

LD=/usr/lib/atuq:/usr/lib:/lib             → RDD NoProvidedLib   AV1 ✗
LD=/usr/lib/atuq:/usr/lib/atuq:/usr/lib    → RDD Success         AV1 ✓
LD=/relleno:/usr/lib/atuq:/usr/lib         → RDD Success         AV1 ✓   (el relleno se lo come)

El arreglo es repetir el appdir en recipes/atuq/bin/atuq. Feo y correcto mientras no haya patchelf en el corpus para grabar RUNPATH=$ORIGIN, que borraría la variable entera.

Lo que quedó medido, con scripts/test-atuq-codecs.sh, headless, por el lanzador de verdad y sin LD_LIBRARY_PATH de afuera:

MuestraVeredicto
H.264 + AAC (mp4)✅ t=3.00, 75 cuadros
VP9 + Opus (webm)✅ t=3.00
AV1 (mp4)✅ t=3.00 — sólo desde el arreglo del lanzador
MP3✅
FLAC✅

El veredicto no es «se creó el decodificador»: es que currentTime AVANCE. La distinción no es retórica — en el caso de AV1 el decodificador se creaba (RemoteDecoderChild … codec: av1) y no entregaba un solo cuadro. El guardián corre con --negative-control, que saltea el lanzador y exige que AV1 falle: probado en los dos sentidos, porque uno que no puede fallar y uno que nunca falló se ven igual desde afuera.

Dos bugs de andamiaje que salieron de paso, los dos en atuq-nested.sh y los dos escondidos detrás de una caché: el bucle de hidratación salía 1 siempre (línea vacía final + set -e) y mataba el script sin imprimir nada, y la comparación del sello nunca daba igual (un \n que $(cat …) recorta de un lado y no del otro), así que el rootfs se rehidrataba entero en cada corrida. El primero sólo se veía cuando cambiaban los hashes; lo destapó agregar ffmpeg a las raíces.

4.g El chrome se programa SIN abrir omni.ja — y la vista dividida ya la trae el motor (2026-09-09)

El pendiente escrito era «el chrome de verdad dentro de omni.ja (split view, dientes propios): el re-empaque ya está resuelto, lo que falta es el contenido». Las dos mitades de esa frase eran falsas, y las dos se midieron con scripts/test-atuq-chrome.py sobre el artefacto vigente:

VIVO el JS de atuq.cfg corre
VENTANA con gBrowser
PREF splitView.enabled=true
WRAPPER tabs=2
ACTIVA si
BROWSERS 2

1. El zip no es el gancho del chrome; atuq.cfg sí. Ese fichero ya corre con privilegios —de ahí sale nsIStyleSheetService, y es la razón de sandbox_enabled = false—, así que desde ahí se observa browser-delayed-startup-finished y se toca el gBrowser de CADA ventana que abra el navegador. La sonda abre dos pestañas y las parte en dos desde atuq.cfg, sin tocar omni.ja.

Y no es un atajo: es la vía CORRECTA, porque re-empacar omni.ja es exactamente lo que pelea con el orden del jarlog del PGO —el motivo por el que el jarlog está aparcado (c3413d29)—. Dicho al revés: cada función del chrome que no entre al zip es una que no compite con la optimización de arranque. Eso cambia el orden de preferencia para todo lo que venga: primero prefs, después extensión de sistema, después JS de chrome por autoconfig, y omni.ja sólo si no queda otra.

2. La vista dividida no hay que escribirla: Firefox 154 la trae y viene PRENDIDA. En browser/omni.ja están tabbrowser/tabsplitview.js (492 líneas, el custom element completo con addTabs/unsplitTabs/reverseTabs), opentabs-splitview.mjs y split-view-footer.js; y defaults/preferences/firefox.js trae pref("browser.tabs.splitView.enabled", true). La sonda no se queda en «el fichero está»: llama a gBrowser.addTabSplitView([t1, t2]) y le pregunta al motor, que contesta activeSplitView puesta y splitViewBrowsers.length == 2. Funciona en nuestro build.

Es el mismo patrón que ya pagó dos veces en este SDD —los dientes (sidebar.verticalTabs), los contenedores (privacy.userContext.enabled)—: la función existe en el motor y viene apagada o sin usar, y el trabajo es prenderla y comprobarla, no escribirla. La forma de equivocarse es al revés: declarar como deuda propia algo que upstream ya nos dio.

⚠ Lo que esto NO dice. atuq todavía no lleva JS de chrome propio: lo medido es el GANCHO, con la sonda inyectada por una capa de overlay que no viaja en el artefacto. Cuando haya una función que lo justifique, va en recipes/atuq/chrome/atuq.js y la carga atuq.cfg. No se shipea un cargador vacío para «tenerlo listo».

El control negativo sale del propio motor y no de una idea nuestra: addTabSplitView documenta que si TODAS las pestañas están fijadas borra el wrapper y devuelve null. --negative-control las fija y exige justamente eso — sin él, una sonda que dijera «partió» pase lo que pase se vería idéntica a una que funciona.

2.ter Dónde vive la fuente de atuq — hizo falta un modo de [source] nuevo

Escribir la receta destapó un muro que este documento no había visto: [source] era obligatoriamente git o tarball, así que una receta cuyo contenido no viene de upstream sino de nosotros no se podía ni expresar. Los rodeos eran todos peores — fetchear una fuente que después se ignora miente sobre la identidad del artefacto, y colgar el overlay de un repo aparte obliga al worker a tener acceso a un repo privado.

Se agregó source.dir (commit 54fa4a9): un árbol dentro del propio repo, hasheado por contenido con ArtifactHash::of_tree —que ya existía para verificar bit-reproducibilidad en Stage 2— y no por ruta. Es la misma disciplina que ya tenían los patches, que entran al hash por bytes. Editar un CSS del overlay mueve el ArtifactHash; renombrar el directorio, no.

Un .swm no puede llevar una receta derivada, y los cuatro sitios que lo tocan fallan diciéndolo: el manifiesto es compartible y necesita un puntero resoluble desde fuera.

Hecho el 2026-09-05 (commits aeebb19 y 6f6c32b): recipes/atuq.toml + recipes/atuq/. La v0.2 (b3:7eff4ba4) ya trae el branding: application.ini (Vendor/Name/RemotingName/ CodeName, con el ID intacto para no salirse del ecosistema de complementos), brand.ftl dentro de browser/omni.ja, los binarios renombrados, iconos propios y .desktop. El re-empaque preserva orden, STORED y fechas del original, y se verificó contra el artefacto: 5306 entradas, mismo orden, y una sola con contenido distinto. El BuildID se deriva del contenido del overlay —no de la fecha— porque es la clave con la que Gecko invalida su startup cache: sin eso, un cambio de chrome arranca con la interfaz vieja y parece que el overlay no agarró.

La capa de configuración de la v0.1 son cuatro ficheros —autoconfig, atuq.cfg, chrome/atuq.css, policies.json— y el CSS se registra como USER_SHEET desde autoconfig porque userChrome.css vive en el perfil y exigiría que el usuario prenda una pref: atuq tiene que verse como atuq en el primer arranque. Falta construirlo: su dep firefox está en vuelo.

2.quater Abrirlo en una pantalla: tres fallos que la clausura no podía ver

El 2026-09-05, con atuq sellado y verificado, se hidrató y se abrió en el compositor de la sesión (waypipe). No arrancó, y los tres motivos son la diferencia entre «sellado» y «usable»:

  1. EXDEV al hidratar. hydrate proyecta con hardlinks y linkat() rechaza cruzar un punto de montaje aunque los dos lados sean el mismo filesystem. Acá el store es /dev/sdb bind-monteado y work/ vive en /dev/sdc. El rootfs va bajo /mnt/cosecha, donde el volumen está montado entero. Se comprueba con findmnt -T, nunca con stat -c %d.
  2. El lanzador no puede ser un symlink. Ni el binario ni sus .so traen RPATH/RUNPATH (readelf -d), así que el motor no encontraba su propio libnspr4.so y moría en XPCOMGlueLoad. Es un script con LD_LIBRARY_PATH + exec, como Debian y Fedora. La alternativa limpia —RPATH=$ORIGIN con patchelf, que es lo que hace Alpine— espera a que patchelf exista como receta del corpus.
  3. Un bug del corpus, no de atuq: atk se había tragado GObject. Declaraba la variante ESTÁTICA de glib y produce un objeto compartido, así que libatk-1.0.so llevaba una copia entera del sistema de tipos. Dos GObject en un proceso ⇒ 45 GLib-GObject-CRITICAL y Segmentation fault. El síntoma no nombra a atk, y no se ve mirando el rootfs: había una sola libgobject. Se cazó preguntando quién DEFINE el símbolo: nm -D --defined-only <cada .so> | grep " T g_type_register_static" — deben salir uno, salieron dos.

Radio medido antes de tocar (yupana radio atk): 4 transitivos, 3 sellados a deuda; GNOME fuera porque usa gtk4. La cadena atk → gtk3 → firefox → atuq volvió a sellar 4/4, y firefox tardó ~50 minutos, no las cuatro horas que repetía este documento — el número venía del folclore de la receta, no de una medición.

La lección para el plan: ninguna unidad de este frente está cerrada hasta que algo se ABRE en una pantalla. La clausura decía 100% con un navegador que no llegaba a pintar un píxel.

2.quinquies La v0.3 y los tres mecanismos: dos muertos, uno vivo

La página de inicio y la pestaña nueva parecían un pref y resultaron ser un frente. Lo que se midió, en orden:

MecanismoResultado
distribution/extensions/ — el clásico de las distrosNo instala nada. Firefox retiró el sideloading. El extensions.json del perfil ni lo mencionaba y el log no dijo una palabra: fallo perfectamente silencioso
policies.json → ExtensionSettings con install_urlSe lee (browser.policies.applied=true en el perfil) y falla nombrándose: ERROR_SIGNEDSTATE_REQUIRED
xpinstall.signatures.required = falseNo alcanza — la exigencia viene COMPILADA

El instrumento que destrabó el diagnóstico fue un testigo. defaultPref no deja rastro en prefs.js —sólo se guarda lo que difiere del default— así que un autoconfig que no se ejecuta es indistinguible de uno que sí y no hace nada. atuq.cfg escribe ahora atuq.autoconfig.ok como pref de usuario, legible desde fuera sin abrir el navegador. Salió true ⇒ el .cfg corría, y por lo tanto la pref de firma se estaba ignorando.

De ahí MOZ_REQUIRE_SIGNING vacío en recipes/firefox.toml. Dos detalles que costaron cada uno su vuelta:

  1. El default sale del milestone (milestone.is_release_or_beta), no del canal de actualización. El nuestro es default y la exigencia estaba activa igual.
  2. Se desactiva con el valor VACÍO, no con cero. =0 muere con «takes 0 values»: es booleana y el cero es un valor que no acepta. Leído del parser de mozbuild, no deducido.

El precio, escrito: este Firefox deja de exigir la firma de Mozilla para cualquier complemento. La alternativa es firmar en AMO —cuenta y revisión de Mozilla por versión—, que es la dependencia externa que esta distro existe para no tener. Y no es un desvío: sin esto, el sct del §6.1 tampoco se podría shipear nunca, así que la unidad 6 dependía de ésta sin que el plan lo dijera.

Método que conviene repetir: la prueba del testigo se hizo sin reconstruir, montando el .cfg modificado con --ro-bind por encima del artefacto. Editar el fichero en el rootfs habría escrito sobre un hardlink del store y corrompido el artefacto sellado.

2.sexies Lo que quedó PROBADO, y con qué

Tres afirmaciones que este documento venía haciendo sin medir, y cómo se cerraron:

AfirmaciónPrueba
El re-empaque del omni.ja es deterministawhy-differs entre dos builds de atuq: 85 entradas idénticas, 0 divergen
La capa de configuración se aplicatestigo atuq.autoconfig.ok escrito como pref de usuario en el perfil
El branding se vecaptura de la pantalla real con grim desde dentro de la jaula, y la página de inicio renderizada por el propio navegador con --headless --screenshot
La base también reproducewhy-differs entre dos builds de firefox en el worker: 56 entradas idénticas, 0 divergen

Y el contraste que salió de la primera: el derivado reproducía y la base no. El BuildID de firefox era la hora del build, así que dos construcciones con el mismo ArtifactHash daban bytes distintos — y build-state.json no lo podía ver, porque el hash es input-addressed y no se mueve por esto. Verde y mintiendo. Cerrado con MOZ_BUILD_DATE desde SOURCE_DATE_EPOCH, que es el mecanismo de hermeticidad que el sandbox ya tenía — y comprobado como se comprueba esto: apartando el artefacto como .ref, construyendo de nuevo y pasándole why-differs. Reproduce.

Dos guardianes nuevos en la fase install de firefox, hermanos entre sí, porque los dos fallos son de la misma familia —una bandera que el configure acepta y el artefacto ignora, muda y a 50 minutos de distancia—: uno comprueba MOZ_REQUIRE_SIGNING: false dentro del omni.ja, el otro que el BuildID sea el que SOURCE_DATE_EPOCH obliga.

La regla que sale de todo el día: cuando la duda es «¿llegó al artefacto?», la respuesta no está en el log del build ni en la receta — está dentro del artefacto, y hay que ir a buscarla ahí.

2.septies Los diecinueve guardianes, en una corrida — y el sello que se había quedado atrás (2026-09-15)

Cada afirmación de este documento tiene quién la mida, y son diecinueve guardianes. Hasta hoy ninguno corría solo: se corrían de a uno, el día que alguien tocaba esa parte. Ya se sabe cómo termina eso —el de descargas estuvo ROJO dos días y nadie lo corrió (29831e62)—, así que la corrida entera es ahora un comando: scripts/test-atuq-suite.sh.

Y lo primero que encontró fue un hueco que no era de ningún guardián, sino del ciclo. Todo recipes/atuq/ es source.dir, así que el commit de la bóveda de esta mañana (unidad 12, 6f0ba974) movió el ArtifactHash — y atuq quedó NO-SELLADO durante las nueve horas siguientes. En ese lapso ningún guardián podía medir nada: se niegan a correr contra un artefacto que no sea el vigente, que es lo correcto. Pero un guardián que se niega sólo grita cuando alguien lo corre, y nadie lo corría: el repo se veía sano, el commit estaba pusheado, y la única señal era un hash --check que nadie tenía motivo para teclear. Por eso lo primero que hace el runner es mirar el sello y NO correr nada si falta, diciendo el comando exacto para construirlo. Verificado con control: contra un store vacío sale por ahí y devuelve 1.

El re-sello costó 3,7 s —atuq es derivado y el firefox vigente estaba en el store— y el ciclo que manda es el de siempre: editar todo → resellar UNA vez → medir.

El cuadro, sobre b3:e556024b (gioser, en serie, 32,2 min):

17 en verdepolítica, rootfs, inicio, chrome, nativo, foco, instalación, ruteo, archivo, descargas, medios, torrent, sct, códecs, IA, archivo semántico y notificaciones
2 en ROJOvigia-atuq-verbos y test-atuq-boveda-coherente, los dos por la misma causa: los tres verbos de la bóveda (§7.quinquies)

Los caros son sct (242 s), el archivo semántico (238 s), la instalación (180 s, tres sesiones), la IA (182 s, levanta llama-server) y las notificaciones (180 s, sway headless). Los cuatro primeros salen en 5 s y no tocan el navegador: para «¿rompí el cruce política↔XPI?» alcanza --only.

Y al día siguiente, el mismo comando: 19 en verde y 0 en rojo (2026-09-16, 32,1 min). Los dos rojos se apagaron subiendo el pin de puriy-costura (§7.quinquies.bis) — no se tocó ningún guardián. Que la suite existiera antes del arreglo es lo que permite decir eso: el cuadro de ayer y el de hoy son el mismo instrumento.

⚠ Lo que hay que cuidar ahora es el control del propio runner. Mientras hubo un rojo conocido, el número esperado distinguía solo «producto sano» de «runner que no corre nada». Con todo en verde ya no, así que lo que sostiene la corrida son la puerta del sello (contra un store sin el artefacto no corre nada y devuelve 1, verificado a propósito) y el control interno de cada guardián. El runner no los reemplaza: los junta.

O sea: el commit de la bóveda no rompió nada más. Los quince guardianes que no tienen nada que ver con ella siguen verdes sobre el artefacto que la trae adentro, y eso es lo que hacía falta saber antes de seguir — porque la unidad 12 tocó atuq.cfg, la política y el empaquetado de extensiones, que es exactamente donde un cambio se lleva puesto algo de al lado sin decirlo.

⚠ El decimonoveno entró porque estaba nombrado como hueco, y eso envejece mal. La primera versión dejaba afuera al de notificaciones (scripts/wlr/dunst-headless.sh --via-atuq: una página llama new Notification(...), libxul hace dlopen("libnotify.so.4") —invisible para cualquier auditor de ELF—, el bus activa dunst y dunst DIBUJA) con la excusa de que vive en el arnés de sway, y lo escribía en la cabecera «para que su ausencia no se lea como cobertura». Dura tres días: al cuarto, la lista es la cobertura y el párrafo no lo lee nadie. Se corrió aparte, salió verde en 180 s, y entró. La lista pasó a llevar el COMANDO de cada guardián y no su nombre de fichero, que es lo que lo tenía afuera.

⚠ Y el control del runner se corrigió solo en la primera corrida. La cabecera anunciaba «17 en verde y UN rojo» —predicción escrita antes de correr— y el cuadro dijo dos: el guardián de coherencia de la bóveda lleva el mismo chequeo del cable adentro, que es justamente lo que el §7.quinquies le pidió. El número quedó escrito en la cabecera como control: un cuadro entero en verde puede ser un producto sano o un runner que no corre nada, y se ven igual.

7. La costura: un host de native messaging en Rust

Todo lo del §6 que no es CSS pasa por un solo mecanismo: un proceso Rust que habla native messaging con la extensión de atuq y, del otro lado, con la suite (agora para identidad Ed25519, khipu, el store, foreign-*, cortafuegos).

Que sea uno y no siete es la decisión: cada feature del §6 pasa a ser un verbo de ese host, no un proyecto nuevo. El binario vive en tawasuyu (es suite, no build system); atuq sólo lo declara como dep y le deja el manifiesto de native messaging en su sitio. El nombre del crate se decide en tawasuyu y con su regla 10 puesta — acá no se bautiza nada de allá.

7.bis El camino EXISTE, y tres cosas que no eran obvias (2026-09-07)

Antes de escribir un crate contra un mecanismo hay que saber si el mecanismo funciona en NUESTRO build —propio, rebrandeado, con MOZ_REQUIRE_SIGNING vacío—, así que se puso una sonda: extensión de diez líneas con permiso nativeMessaging y un host de tres que contesta {"ok":true}. Vive en scripts/test-atuq-nativo.py, con control negativo:

positivo  MENSAJE {"ok":true}
control   DESCONECTADO No such native application puente_atuq

El control no sólo falla: nombra la causa, que es lo que confirma que la ruta del manifiesto es la que se probó. Lo que hay que saber para escribir el host:

QuéMedido
Dónde va el manifiesto/usr/lib/mozilla/native-messaging-hosts/, NO el appdir. Sale de XRESysNativeManifests, un /usr/lib/mozilla compilado; el rebranding a atuq no lo mueve
Cómo se instala la extensiónpor el escaneo de distribution/extensions/. ExtensionSettings.install_url con file:// no instala nada, ni con force_installed ni con el XPI dentro del appdir, y sin una línea de log
Forma del hostun proceso largo: si escribe la respuesta y sale, Gecko cierra el puerto sin entregarla — onDisconnect «sin error y sin mensaje», el peor informe posible
Con qué diagnosticarel error del puerto de connectNative. sendNativeMessage devuelve «An unexpected error occurred» para todo y no distingue nada

⚠ La tercera fila del cuadro corrige una lectura fácil de policies.json: los dos mecanismos apuntan a los mismos ficheros y se tapan uno al otro, así que la política PARECE ser la que instala. Fija y configura; instalar, no.

7.ter Y esa fila ahora tiene guardián — porque el README decía lo contrario (2026-09-09)

La fila «cómo se instala la extensión» era una nota al pie de una sonda de native messaging, y mientras tanto recipes/atuq/README.md afirmaba lo opuesto: que el sideloading desde distribution/extensions/ «ya no funciona» y que instalaba ExtensionSettings. Dos documentos nuestros, contradictorios, sobre el mecanismo del que cuelgan TODAS las extensiones de atuq —hoy inicio y proxy, mañana la de sct—. Y ninguna observación del artefacto los distingue, porque los dos apuntan a los mismos ficheros.

scripts/test-atuq-instalacion.py lo rompe de a uno y mira qué pasa. Medido sobre atuq b3:fab2fbfb (firefox 154 con PGO v2) y REPETIDO sobre b3:9e16ac35 —el artefacto que esta misma corrección creó, porque el README vive dentro de source.dir y editarlo re-hashea atuq—, tres escenarios:

escenarioqué se rompemotor (homepageOverride)perfil
as-isnadamoz-extension://…/inicio.htmlinicio y proxy activas
no-policypolicies.json sin ExtensionSettingssigue siendo la nuestralas dos siguen activas
policy-onlylos XPI mudados a /usr/lib/atuq/xpi-politica/, con install_url apuntando ahíabout:homeninguna de las dos existe

⇒ instala el escaneo de la carpeta; install_url con file:// no instala nada. El §7.bis queda confirmado y el README corregido.

Dos cosas que este guión trae y que valen más que el veredicto:

  1. El control es por construcción, no un modo aparte. La sonda que le pregunta al motor vive en distribution/extensions/ y ninguna política la nombra: si la sonda habla, el escaneo está vivo en esa corrida. Una corrida muda no se lee como «no instaló» — se declara ROTA y se guarda el log. Sin eso, un atuq que no arranca se vería idéntico a una política que no instala.
  2. La segunda señal que probé NO discrimina, y queda dicho. Esperaba que el location de extensions.json separara los mecanismos (app-system-defaults vs. app-profile). No: los dos XPI salen app-profile también cuando los instala la carpeta y no hay política que los mencione. Se imprime igual porque dice si están, pero como discriminador es cero — y una señal que uno cree que discrimina y no discrimina es peor que ninguna.

Por qué esto es un guardián y no un test más. Mozilla lleva años retirando el sideloading, y el día que la carpeta deje de instalar, atuq arranca sin ninguna de sus extensiones y sin una línea de error: la página de inicio vuelve a ser la de Firefox y el proxy por contenedor simplemente no enruta. Es el modo de fallo silencioso de siempre, y el que aparece cuando SUBE la dep, no cuando alguien toca atuq.

⚠ Y por eso mismo hay que decir lo que todavía NO está: ninguno de los test-atuq-* corre en el latido. cosecha-cron.sh sólo ejecuta vigia-sonames.py; los de atuq se corren a mano. Meterlos cuesta ~3 min por ciclo (tres arranques de navegador headless por escenario) y exige que el hub tenga hidratado el rootfs de scripts/atuq-nested.sh, así que es una decisión con precio y no un echo más en el cron — queda escrita acá y sin dar por hecho lo contrario. El día que se meta, el sitio es el bloque de guardianes de cosecha-cron.sh (L341-352).

7.quater El host, escrito — y por qué la extensión NO puede hablarle al testigo (2026-09-10)

El §6.1 decía que sct v1 es «extensión con webRequest bloqueante que hashea cada respuesta de script y consulta al testigo antes de dejarla pasar». La primera mitad es correcta; la segunda es imposible tal como está escrita, y conviene decirlo porque parecía un atajo para saltarse esta unidad: el cable de puriy-sct-testigo es un POST con postcard en el body, no JSON. Un JS no puede ser su cliente. Y reimplementar el registro TOFU en la extensión sería fabricar un sustituto paralelo del original —lo que la regla 10 de tawasuyu prohíbe— además de duplicar la única pieza que ya estaba certificada. Entonces v1 pasa por el host, y el host es esta unidad.

Se decidió con el operador, con el grep de la regla 10 hecho primero: ningún término del dominio significaba «host de native messaging», y los que suenan a mensajero están todos ocupados por dominios ajenos y grandes (chasqui es un type broker, chaka el puente a COBOL, paloma el cliente de correo, tampu el común de objetos). El nombre salió del título de este propio §7: la costura.

PiezaDóndePor qué ahí
puriy-costura00_unanchay/puriy/regla 1: subcrate del dominio, no proliferación lateral. Precedentes: puriy-sct-testigo, rimay/verbo-daemon, watuy-daemon
foreign-webextshared/regla 4: el protocolo es AJENO. Lib pura sin binario, como los otros 37 puentes (medido: ninguno lleva [[bin]])
recipes/puriy-costura.tomltakanael artefacto: estático musl, pineado por commit, --locked

Verbos v1: ping, sct.observe, sct.state. Claves del cable en inglés (regla 7 de allá y regla 4 de acá: se tipean). phase es learning/stable/not-evaluable, y un events[] vacío es lo normal — sólo hay evento cuando un origen ya estable ejecuta un hash que nadie vio nunca.

Los tres tests que hacen que el veredicto signifique algo (de catorce): el mismo cambio de script mientras el origen todavía aprende no alerta; rotar ?v= con el mismo código tampoco (la identidad del recurso es su contenido, y sin eso cada despliegue con cache-buster sería un aviso de supply-chain: un detector que grita todos los días no se mira más); y el aprendizaje sobrevive al cierre del navegador, sin lo cual cada arranque volvería a «aprendiendo» y ningún cambio sería nunca un evento.

Los dos límites, escritos en el código y en los dos LEEME, no en una nota de sesión:

  1. Qué bytes se hashean. La extensión manda el TEXTO ya decodificado; se hashea su UTF-8. La detección vale —el TOFU compara lo mismo contra lo mismo—, pero nuestro hash no es comparable con el de los bytes servidos que publique un tercero, ni con el de la v2 (el gancho en el script loader). Un hash que uno cree comparable y no lo es sería peor que no tenerlo.
  2. La semilla del log es provisional: se deriva del directorio de estado, así que es determinística (el log se reanuda) pero no es un secreto ⇒ la bitácora es a prueba de REESCRITURA, no de SUPLANTACIÓN. La de verdad sale del perfil cuando el almacén del perfil exista.

Y lo que queda medido para la unidad 6, preguntándole al artefacto y no a la documentación de Mozilla (§2.sexies): filterResponseData y el permiso webRequestFilterResponse están en nuestro build (omni.ja, chrome/toolkit/content/extensions/schemas/web_request.json), así que la extensión va a poder leer el cuerpo de un script. Lo que NO va a poder es ver los scripts inline por esa vía —filterResponseData entrega el documento, no cada <script>—, y eso está bien para v1: el ataque que sct nombra es la sustitución en el CDN, que es exactamente el caso <script src>.

7.quinquies El cable tiene DOS puntas en DOS repos — y hoy no coinciden (2026-09-15)

La bóveda se commiteó esta mañana (unidad 12, 6f0ba974): la extensión, la política, el permiso del host, las preferencias, y un guardián que mira los cuatro. Los cuatro estaban bien. La bóveda no funciona igual, y el motivo no está en ninguno de esos cuatro sitios.

Cada función de atuq que no es CSS es un verbo del host nativo, y el host vive en OTRO REPO: recipes/puriy-costura.toml lo pinea por commit de tawasuyu. O sea que el cable tiene dos puntas en dos repos, y que vault.match sea un verbo o una cadena que nadie atiende lo decide un commit = de un TOML que la extensión nunca menciona. Escribir la extensión y subir el pin son dos unidades de trabajo distintas, y la primera se commitea sin la segunda sin que nada proteste.

Cómo se ve el fallo, que es por qué merece vigía. No falla nada. El manifiesto está, la política instala, connectNative conecta —el host existe y contesta—, la extensión manda vault.match y recibe:

{"ok": false, "error": "verbo desconocido: vault.match"}

fondo.js lee r.ok !== true, borra la insignia y se calla. El usuario ve un navegador sin bóveda; un guardián de ficheros ve todo en orden. Es la misma familia que el §6.10 del subcomando sin driver: sellado, con contenido, reproducible… y la función no está.

La medición, con su control al lado. El artefacto vigente (hash --check sobre la receta dice b3:1eb2b692 SELLADO) contra su propio binario:

strings del binario sellado   →  «vault»  0 veces
                              →  «cas»    5 veces   ⇐ EL CONTROL

El cero solo no probaba nada: un grep que devuelve cero puede ser una ausencia o un sitio equivocado, y las dos se ven igual. Lo que lo convierte en evidencia es el hermano que TENÍA que dar positivo en el mismo binario. Y desde la fuente sale lo mismo por otro camino: la receta pinea e19bb0e5, que git merge-base --is-ancestor confirma ANCESTRO de bc6903f9e, el commit que agregó los verbos. El host sellado es anterior a la bóveda por construcción.

Y no se podía deducir mirando la receta. Las otras nueve extensiones conviven bien con ese mismo pin viejo: 13 verbos sobre 8 extensiones, y los únicos tres sin dueño son los de la bóveda. «Cuándo se tocó la receta por última vez» no contesta esta pregunta.

extensiónverbos¿el pin los atiende?
archivoarchive.add, archive.ask, archive.search✓
bovedavault.match, vault.fill, vault.save✗ los tres
descargascas.ingest✓
focofocus.state✓
iaai.ask, archive.ask✓
mediosmedia.open✓
sctsct.observe✓
torrenttorrent.add✓
inicio, proxy—no le hablan al host, a propósito

Queda vigilado por scripts/vigia-atuq-verbos.py, que corre en un segundo sin construir nada y pregunta git grep '"<verbo>" =>' <commit> -- '*.rs' leyendo del objeto —sin checkout: el árbol de tawasuyu es compartido y siempre tiene ficheros en vuelo—. El mismo chequeo quedó además dentro de test-atuq-boveda-coherente.py, que pasa a decir cinco lugares y no cuatro.

⚠ Y lo que costó más que el vigía: el extractor de verbos no veía la mitad. Buscaba verb: "…", que es como lo escribe la bóveda; pero la extensión de IA arma el mensaje con postMessage({ id, verb: verbo, … }) —el verbo llega por VARIABLE— así que ese patrón encontraba cero verbos en ia y la declaraba sana sin haber mirado nada. Cero verbos encontrados y cero verbos faltando son el mismo cero con dos causas. De ahí salió un tercer control que no es ni positivo ni negativo sino de cobertura: una extensión de la que no se extrae ningún verbo se REPORTA, y las dos que de verdad no le hablan al host están nombradas en el código para que no se confundan con el extractor mudo.

Por qué el pin NO se subió en el mismo turno, y qué hace falta

El candidato es be8710cff —el más nuevo que toca puriy-costura, y trae además «la aplicación de la bóveda y el navegador ya pueden convivir», que pinear bc6903f9e se saltearía—. No se puede todavía, y el muro está medido, no supuesto: el Cargo.lock de tawasuyu no cierra para puriy-costura. Su entrada en el lock de main todavía lista las once deps viejas —sin pacha-boveda, sin pacha-cifrador— y pacha-boveda-daemon no está en el lock en absoluto.

Reproducido en un árbol LIMPIO del commit (git archive <sha> | tar -x, que es el procedimiento que la propia receta dejó escrito y que evita que el lock describa «el escritorio de todos»):

cargo metadata --locked --offline
error: cannot update the lock file … because --locked was passed to prevent this

—y, como la receta avisaba, no nombra al crate culpable. La reparación es barata y quedó comprobada en ese mismo árbol limpio: cargo metadata --offline cierra el lock con un diff de 37 líneas, todas de contabilidad de deps por ruta, sin mover una sola versión de registry.

Pero ese fichero no se commiteó, y la razón es la regla 2 de CLAUDE.md vista desde el otro lado. En el clon compartido, git status dice MM Cargo.lock: otra sesión lo tiene stageado Y modificado ahora mismo. Las tres versiones, medidas:

dóndetamañodeps de puriy-costura
main (2c40330e4)672 409 B11 — no cierra
el índice (stageado por otro)671 160 B11 — no cierra, y no es el de main
el árbol de esa sesión673 397 B18 — cierra

O sea que la reparación ya existe, sin commitear, en el árbol de otro agente. Tocarla es pisarle el trabajo, y el -- no protege de eso. Comprobado en un repo de juguete, porque la intuición es la contraria:

# f.txt en estado MM: v2 en el índice (del otro), v3 en el árbol (mío)
git commit -m … -- f.txt
#   ⇒ commitea v3 (EL ÁRBOL, no lo stageado)
#   ⇒ y el índice queda en v3: lo que el otro tenía stageado ahí DESAPARECE

El -- acota qué rutas entran al commit y con eso aísla de lo que el otro dejó en OTRAS rutas —que es lo que la regla 2 dice y sigue siendo cierto—; para la ruta que uno nombra, se lleva el árbol y borra el índice ajeno. En un repo compartido, un fichero en MM que uno no tocó no se commitea ni con pathspec: se avisa.

⇒ Pendiente, y no es de takana: que tawasuyu publique el lock cerrado. Con eso, subir el pin a be8710cff, reconstruir (2,4 G de vendoreo del workspace entero, ver la receta) y recién ahí escribir el guardián de METAL de la bóveda —servidor, navegador real, login real, el diálogo de consentimiento a la vista—, que es lo que el commit de la unidad 12 dejó anunciado.

7.quinquies.bis DESTRABADO (2026-09-16): el lock, el pin, y el mismo strings leyendo al revés

Las tres cosas que el párrafo de arriba dejaba pendientes están hechas, y en ese orden porque cada una destrababa a la siguiente.

1. El lock de tawasuyu, publicado (23a292863). Regenerado en un árbol LIMPIO del HEAD de allá —git archive | tar -x, que no toca el .git compartido— y verificado en los dos sentidos, que es lo que lo convierte en evidencia: cargo metadata --locked --offline moría antes de regenerarlo y pasa después. El diff son 215 líneas y todas de contabilidad de deps por ruta: cero checksum movidos, cero source movidos, ninguna versión de registry tocada. Lo que aparece es llimphi-registro, los cuatro crates de la bóveda y umbral dentro de pacha-boveda.

⚠ Commiteado con un índice TEMPORAL, no con git commit. El Cargo.lock seguía en vuelo en el clon compartido, y la regla 2 ya está medida: git commit -- Cargo.lock se lleva el árbol y deja el índice ahí, o sea que borra lo que el otro agente tuviera stageado en ESA ruta. El camino que no toca nada es plumbing:

export GIT_INDEX_FILE=/tmp/…/tw.index      # un índice PROPIO: el compartido no se abre
git read-tree HEAD
blob=$(git hash-object -w <el lock nuevo>)
git update-index --cacheinfo 100644,$blob,Cargo.lock
git commit-tree $(git write-tree) -p HEAD -F msg     # ⇒ commit sin tocar índice ni árbol
git push origin <commit>:main

Con su control puesto: git diff-tree -r --name-only HEAD <tree> tiene que decir un solo fichero.

⚠ Y la sorpresa que ordena el resto: el lock publicado resultó ser BYTE A BYTE el que la otra sesión ya tenía en su árbol —mismo blob b2f0b9eb—. No era una versión rival de la reparación: era la suya, que llevaba horas sin commitear. O sea que lo que bloqueó a atuq tres semanas no fue un problema técnico sino un fichero correcto que nadie había publicado, y del que ningún guardián de ninguno de los dos repos podía hablar.

2. El pin, subido a ese commit ⇒ b3:7d63655a. El artefacto se construyó en el worker: el vendoreo son 2,4 G y el disco del hub estaba al 99% (lo llena tawasuyu/target, 148 G), así que construir acá habría sido el cuadro de «disco lleno = ✗ falso». Bajar el artefacto son 2,3 M.

3. La misma medición que diagnosticó el problema, leída al revés. El §7.quinquies lo cerró con strings sobre el binario sellado: vault 0 veces, cas 5 —el control que tenía que dar positivo—. Sobre el binario nuevo, con el mismo comando:

antes (e19bb0e5)ahora (23a29286)
vault.010
cas. / sct. (control)presentespresentes

Y del lado del navegador, scripts/vigia-atuq-verbos.py pasa de 3 verbos sin dueño a cero, con su control positivo intacto (sct.observe sigue apareciendo ⇒ el grep mide).

Lo que queda de la unidad 12 es el guardián de METAL de la bóveda —servidor, navegador real, login real, el diálogo de consentimiento a la vista—, que ahora sí se puede escribir porque hay con qué correrlo. ⚠ Esto último resultó FALSO y está corregido en el §7.sexies: faltaban el dueño de la bóveda y el diálogo de consentimiento, y sin ellos el host contesta «cerrada».

7.sexies «Hay con qué correrlo» era falso: la bóveda del navegador contesta CERRADA (2026-09-18)

El párrafo de arriba cerró el §7.quinquies.bis diciendo que el guardián de metal «ahora sí se puede escribir porque hay con qué correrlo». No se podía, y la primera pregunta del guardián lo dice en veinte segundos. Antes de escribirlo se le preguntó al artefacto sellado —el mismo binario que va en las cuatro imágenes de escritorio— por marcos de 4 bytes, igual que le habla la extensión:

← {"id":1,"ok":true,"verb":"ping","version":"0.1.0"}
← {"id":2,"ok":true,"verb":"vault.status","locked":true,"count":0}
← {"id":3,"ok":true,"verb":"vault.match","locked":true,"items":[]}
stderr: puriy-costura: sin bóveda (No such file or directory (os error 2)):
        los verbos vault.* dirán «cerrada»

ping es el control: el host está, atiende y contesta. Lo que no hay es bóveda. Y el ok:true de las otras dos es la parte que engaña: «cerrada» es una respuesta exitosa, no un error, así que ningún reintento, ningún log y ninguna insignia distinguen esto de un usuario que todavía no desbloqueó la suya.

Por qué faltaba, y por qué el §7.quinquies no podía verlo. Ese capítulo midió lo que se podía medir desde acá: que el commit pineado del host CONOCE los verbos (strings → vault 10 veces, vigia-atuq-verbos → cero verbos sin dueño). Las dos mediciones eran correctas y siguen siéndolo. Lo que ninguna preguntaba es quién contesta del otro lado del socket: el host no abre la bóveda nunca, a propósito —sled toma un lock exclusivo y el proceso que lanza el navegador muere y revive con cada pestaña—, así que le habla a un DUEÑO, y el dueño no estaba en el corpus. Es la misma forma de fallo del §7.quinquies una capa más abajo: saber el verbo no es poder contestarlo.

Las dos piezas ausentes, las dos con el mismo efecto de «se ve apagado y nada falla»:

piezaqué esqué pasa sin ella
boveda (pacha-boveda-llimphi)la app de la bóveda, y el dueño único de la base: la abre para su ventana y levanta el socket del navegador en un hilovault.* contesta locked:true: insignia vacía, nada que ofrecer
shuma-preguntael diálogo de consentimiento que PorDialogo lanza como procesotodo vault.fill se deniega —Command::new falla ⇒ «no»—, indistinguible de que el usuario haya dicho que no

⇒ Hoy, en las cuatro imágenes de escritorio, atuq instala la décima extensión y la función está apagada de fábrica. Entran recipes/boveda.toml y recipes/shuma-pregunta.toml, las dos pineadas al mismo 23a292863 que ya comparten puriy-costura, shuma-* y pacha-* —un pin distinto es otro vendoreo de 2,4 G del workspace entero— y las dos con la forma de mirada-greeter, que es el patrón de una llimphi GUI: link = "dynamic" porque winit/wgpu cargan EGL/Vulkan/wayland por dlopen, y la inyección de LOCKSTEP_XML_PATH que evita el panic de zbus-lockstep-macros en el subdir xml/schemas/ de atspi (mismo accesskit_winit 0.33, mismo atspi-common 0.13 en el lock).

⚠ Y quedan DOS decisiones que no se toman de paso, las dos de producto:

  1. en qué imágenes se declaran. La función del navegador no existe sin las dos, así que o entran a las cuatro de escritorio o la bóveda de atuq se queda como una extensión que no ofrece nada. Es la misma pregunta abierta que el §6.7 dejó con llama-cpp, y se contesta con el tamaño medido al lado, no antes: boveda son 22 M y shuma-pregunta 21 M, o sea ~43 M por perfil, contra los ~1,25 GiB que pide el §6.7. ⚠ Pero hoy la respuesta es NO, y no por el tamaño: ninguna de las dos abre ventana en ninguna imagen (§7.septies), así que declararlas ahora sería agregar 43 M de binarios que no pueden pintar — y peor, dejar la bóveda «instalada» negando todo en silencio;
  2. de dónde sale la raíz de las claves. La app la saca de la seed de identidad del llavero del kernel, que en una máquina de desarrollo no está desbloqueada — y por eso existe BOVEDA_TEST_ROOT, que deriva la raíz de una cadena y abre otra bóveda, vacía, diciéndolo en pantalla. Es la escotilla con la que el guardián de metal va a poder sembrar una credencial sin tocar la de nadie.

Lo que queda de la unidad 12, entonces, no es «escribir el guardián de metal»: es sellar estas dos y recién ahí escribirlo. Lo que sí queda escrito desde hoy es la pregunta con la que ese guardián tiene que empezar —vault.status contra el host— porque es la que separa «la bóveda dijo que no» de «no hay bóveda», que se ven idénticas desde la extensión.

7.septies El diálogo NO ABRE, y el muro no es de la bóveda: ninguna app llimphi de escritorio puede pintar en esta distro (2026-09-18)

Con boveda y shuma-pregunta sellados, la etapa B del guardián de metal falla, y falla en un sitio que no tiene nada que ver con las contraseñas:

thread 'main' panicked at 02_ruway/llimphi/llimphi-hal/src/lib.rs:1032:83:
index out of bounds: the len is 0 but the index is 0

Esa línea es caps.formats[0] sobre las capacidades de la surface. O sea: la surface no tiene NI UN formato. La cadena entera, medida eslabón por eslabón con RUST_LOG=wgpu_hal=debug:

wgpu_hal::gles::egl: No (or unknown) windowing system ((None, Some(...))) present.
                     Using surfaceless platform
wgpu_hal::gles::egl: Trying native-render → No config found!
wgpu_hal::gles::egl: Trying presentation  → No config found!
wgpu_hal::gles::egl: Trying off-screen    → (ésta sí)

El None de ese par es desc.raw_display_handle, y no es un accidente del arnés: es lo que el propio llimphi hace a propósito, con su comentario al lado (llimphi-hal/src/lib.rs, camino de escritorio new_inner):

«Sin display: este camino no tiene ventana todavía (la surface se crea después, contra esta misma instancia). Los caminos que SÍ la tienen —new_for_raw_surface, recreate_for_window— la pasan.»

Sin display handle, wgpu abre el display EGL por la plataforma surfaceless, que por definición no publica configs con WINDOW_BIT; sin config de ventana, presentable es falso; sin presentable, surface_capabilities devuelve None y la lista de formatos sale vacía. Cada paso está en el log.

Por qué esto no se nota en la máquina de quien escribió llimphi, y sí acá. Ese camino elige Backends::PRIMARY —Vulkan/Metal/DX12— y a Vulkan el display handle no le hace falta. Sólo cae al backend GL cuando no hay ninguno de los tres… que es exactamente el caso de esta distro:

Vulkanefecto
recipes/mesa.toml (iris, la de las imágenes)-Dvulkan-drivers= vacíono hay ICD
recipes/mesa-swrast.toml · recipes/mesa-llvmpipe.toml-Dvulkan-drivers= vacíono hay ICD
vulkan-loadersólo en incoming-kde, y ningún perfil lo declaray el loader sin driver no es un driver

⇒ En ninguna imagen de takana hay un driver Vulkan. Con lo cual toda app llimphi de escritorio —no sólo el diálogo de la bóveda— toma el camino GL, y el camino GL sin display handle no puede abrir ventana. Comprobado en TRES binarios distintos y con dos versiones de wgpu: shuma-pregunta (wgpu 29), boveda (wgpu 29) y llimphi-counter (wgpu 27, pineado a otro repo).

Los controles, porque la conclusión es fuerte:

· pedir Vulkan explícito (LLIMPHI_WGPU_BACKEND=vulkan) ⇒ NoAdapter, y ls /usr/share/vulkan/icd.d y ls /usr/lib/libvulkan* no existen en ninguna capa. La premisa «no hay Vulkan» está medida, no supuesta; · con softpipe el fallo es OTRO y anterior —RequestDevice("Parent device is lost"), con indirect-validation error: ComputePipeline(… COMPUTE_SHADER …) una línea antes, porque softpipe se queda en GL 3.3 y wgpu pide compute—. O sea que llegar hasta el panic de los formatos ya exige llvmpipe (GL 4.5), que es lo que el arnés monta: mesa-llvmpipe + llvm18; · y el mismo arnés levanta sway, el socket wayland aparece y WAYLAND_DISPLAY está puesto — el compositor no es el que falta.

⚠ Lo que esto dice del producto, y es lo que importa: la bóveda de atuq no está «casi lista». Su consentimiento —PorDialogo, que lanza shuma-pregunta— no puede pedir permiso en ninguna imagen de hoy, y como el fallo del lanzamiento se traduce a «no» (Command::new falla ⇒ return false), lo que un usuario vería es una bóveda que niega todo, sin un error. Y no es sólo la bóveda: es cualquier ventana llimphi que la distro quiera abrir.

Dónde se arregla, y dónde NO. No se arregla en takana: ni en la receta, ni en el arnés, ni poniendo otro mesa. Las dos salidas son de otro repo o de otra receta, y conviene decir cuál es cuál:

  1. llimphi (tawasuyu): que el camino de escritorio pase el display handle cuando lo tiene, o que al caer al backend GL reconstruya la instancia con él. La función que hace falta ya existe ahí al lado (instancia_con, que usan new_for_raw_surface y recreate_for_window);
  2. un driver Vulkan por software en el corpus (lavapipe: -Dvulkan-drivers=swrast), que además destrabaría el muro de ScreenCast que el runbook de COSMIC documenta por otro camino. Es una receta nueva y una decisión de tamaño, no un arreglo de paso.

La 1 es la correcta: un navegador que pide permiso no debería depender de que la máquina tenga Vulkan. La 2 es la que, además, le sirve a otros frentes.

El arreglo, escrito y empujado en llimphi (eed3120b6) — falta medirlo

Se hizo la 1, que son dos ficheros y ninguna pieza nueva: la función que faltaba ya existía al lado. Hal::new_con_display usa el mismo instancia_con que el camino layer-shell, y el llamador de escritorio le pasa la window — que está ahí, creada una línea antes para hacerle la surface. new_inner y el nuevo comparten cuerpo (new_generico) y sólo se distinguen en cómo arman la instancia: duplicar la elección de backend y el fallback es cómo se termina con dos caminos que divergen sin que nadie se entere.

Controles antes de empujar, porque el arreglo no se puede probar hasta reconstruir: cargo check -p shuma-pregunta pasa con el parche y falla con una rotura a propósito en la línea tocada (si no, un check que no compila el fichero se ve igual que uno que sí).

⚠ Commiteado allá con índice temporal (GIT_INDEX_FILE + commit-tree + push <sha>:main), que es la única forma de publicar dos rutas en un clon compartido sin llevarse por delante los 181 ficheros que otra sesión tiene en vuelo. Control: git diff-tree -r --name-only nombra dos. Y el push fue rechazado la primera vez porque el remoto se había movido: se rehízo el commit-tree sobre el FETCH_HEAD nuevo, comprobando antes que los dos ficheros no habían cambiado allá.

⚠⚠ «SELLADA» en cero segundos: el latido REVIERTE la receta del worker (2026-09-18)

Al reconstruir con el pin nuevo, el log del worker dijo ### boveda SELLADA casi al instante. No era. El worker había vuelto a la receta VIEJA y lo que «selló» fue un acierto de caché sobre el artefacto anterior:

hub:     recipes/boveda.toml  →  b3:8d1d7536   (pin eed3120b6)
worker:  recipes/boveda.toml  →  b3:59ffd74b   (pin 23a292863, el de antes)

Quién la revierte: el latido (cosecha-cron.sh) siembra rsync -az --delete hub→worker al principio de cada ciclo, y hace su git pull --ff-only al final. O sea que cada media hora el worker vuelve al árbol de ESE hub, que puede estar hasta un ciclo atrasado — y un rsync manual de una receta dura lo que tarde el siguiente latido. El cron, además, no corre en este hub: corre en el otro, con su propio checkout.

Por qué es peligroso y no sólo molesto: el modo de fallo no es un error, es un éxito falso. Si el artefacto de la receta vieja ya está sellado, el build imprime SELLADA en cero segundos y el operador lee exactamente lo que esperaba leer. Es la forma «un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue bien» de la regla 3, un piso más abajo.

La mitigación, que es una línea y va DENTRO del mismo comando que toma el lock: preguntarle al worker el hash y compararlo con el del hub antes de construir.

h=$(./target/release/takana --store ./store hash recipes/boveda.toml | sed s/^b3://)
[ "$h" = "$ESPERADO" ] || { echo "### RECETA REVERTIDA: el worker hashea ${h:0:12}"; exit 3; }
flock -o work/.farm-build.lock ./target/release/takana --store ./store build recipes/boveda.toml

Con eso el segundo intento dijo ### receta verificada 8d1d75369548 antes de compilar nada.

⚠ El pin de boveda y shuma-pregunta ahora DIVERGE del de sus hermanas (eed3120b6 contra 23a292863), y eso cuesta un árbol de fuentes propio: otro vendoreo de 2,4 G, porque el árbol se comparte por <repo>-<sha>. Es el precio de no mover las otras once recetas del monorepo en el mismo turno; se paga hasta que suban.

Lo que queda escrito y corriendo mientras tanto es scripts/test-atuq-boveda-metal.py, con sus seis etapas declaradas y la A en verde: sin la app dueña, el host contesta locked:true. Es poco, y es exactamente lo que se puede afirmar hoy — que es mejor que un guardián que no existe y que uno que diera verde midiendo nada.

7.octies Con el navegador abierto la bóveda queda MUDA — el dueño atendía de a un cliente (2026-09-18)

Con llimphi arreglado, el guardián de metal llegó hasta la etapa D —guardar una contraseña con consentimiento real y encontrarla de nuevo— y se plantó en la E, que es la que usa el navegador. El cuadro era éste, y ninguna de sus líneas es un error:

BOVEDA CONECTADO puriy_costura        ← la extensión conecta con su host
BOVEDA-METAL EXTENSION cargada
BOVEDA-METAL MOSTRADA true            ← el botón de la extensión SÍ está para esa pestaña
BOVEDA-METAL INSIGNIA ""              ← …y la insignia está vacía
BOVEDA-METAL DISPARO api              ← se aprieta el botón
(nada más. Ni diálogo, ni relleno, ni una línea de `fondo.js`)

La extensión no dice nada porque no tiene nada que decir: vault.match se manda y no vuelve nunca. Sin respuesta no hay insignia, no hay log y no hay error — que desde el navegador es indistinguible de «este sitio no tiene contraseñas guardadas».

La causa, medida y no deducida. pacha_boveda_daemon::Dueno::servir llamaba a atender_cliente en el hilo del accept, y atender_cliente no vuelve hasta que el cliente se va ⇒ la primera conexión se queda con el dueño mientras viva y el resto espera en la cola del socket para siempre. Y el caso normal es justamente ése: Gecko lanza un puriy-costura por PUERTO —uno por extensión, ocho en atuq— y cada uno abre su conexión a la bóveda al arrancar (§7.quater ya había medido lo de «un host por puerto»; lo que faltaba era ver qué le hace eso al dueño).

El control, en los dos sentidos y sin navegador, dentro de la misma jaula:

== un solo cliente            → {"verb":"vault.status","locked":false}   en milisegundos
== con otro host conectado    → colgado; lo mata el `timeout` a los 30 s

Arreglado allá (cf3540460): un hilo por conexión. El argumento por el que se serializaba —dos diálogos de consentimiento a la vez es cómo alguien autoriza el que no era— sigue en pie, y ahora lo sostiene el Mutex de la bóveda, que quien atiende toma para responder y sostiene mientras pregunta. Lo que deja de serializarse es lo que nunca debió: estar conectado.

⚠ Y el arreglo obvio estaba mal por una razón que no se ve leyendo: clonar el Dueno para cada hilo hace que el primero que termina borre el socket (lo hace en su Drop, y está bien que lo haga: un socket huérfano deja al próximo cliente esperando en vez de decirle que no hay daemon). Por eso atiende un Atendedor —bóveda y a quién preguntarle, sin la ruta—. Lo cazó el test nuevo en el primer intento.

El test, y por qué el crate no podía ver su propio fallo. dos_clientes_vivos_a_la_vez_son_ atendidos. Los siete que ya existían abrían un cliente, lo usaban y lo soltaban antes del siguiente —y el propio arnés del test llama a atender_cliente en secuencia—, así que nunca hubo dos conexiones solapadas: el test serializaba justo lo que producción no serializa nunca. El segundo cliente se pregunta en un hilo con recv_timeout a propósito, porque el fallo ES colgarse y un test que se cuelga no dice qué pasó. Probado en los dos sentidos: con el bucle viejo falla a los 5 s, con el nuevo pasa, y los otros siete siguen verdes.

⚠ Y el lock de tawasuyu volvió a estar abierto — se cierra con UNA línea, no con mil

Subir el pin a cf3540460 dio el error de siempre: cargo vendor --locked muere con «cannot update the lock file … because --locked was passed», sin nombrar el crate. Es el mismo modo de fallo que bloqueó a atuq tres semanas (§7.quinquies.bis), y vuelve cada vez que alguien agrega una dep de ruta sin actualizar el lock.

Lo que importa para la próxima vez es cuál de las dos operaciones se usa para cerrarlo:

diffchecksums movidossirve
cargo metadata sin --locked (mínima)1 línea0✅
cargo generate-lockfile6.305 líneas750❌ invalida el vendoreo de todos

La mínima agrega sólo la arista que falta. La otra re-resuelve el workspace entero y se ve igual de «correcta» hasta que uno mira el diff. Las dos en un árbol LIMPIO (git archive | tar -x), con el control de siempre: cargo metadata --locked falla antes y pasa después. Publicado en b80f7567c, con índice temporal porque el Cargo.lock estaba MM en el clon compartido.

7.novies CERRADA: la bóveda entrega una contraseña, y sólo cuando alguien dice que sí (2026-09-18)

Las seis etapas del guardián de metal, en verde, sobre artefactos vigentes:

A ✓ sin la app dueña, el host contesta locked:true — y con ok:true, que es la trampa
B ✓ con la app, vault.status dice ABIERTA: el socket sube en 1-2 s
C ✓ el diálogo ABRE en el compositor y se lo CONTESTA: «1» ⇒ yes, «2» ⇒ no
D ✓ vault.save con consentimiento real guarda, y vault.match la encuentra SIN contraseña
E ✓ el navegador real, sobre una página servida por HTTP: la contraseña llega al campo
F ✓ contestando que NO, al campo no llega NADA

Lo que la E afirma, y por qué la F es la mitad que la hace valer. La E es la promesa entera del §6: un servidor de verdad sirve un formulario, atuq lo carga, se aprieta el botón de la extensión, vault.fill abre el diálogo, alguien dice que sí y la contraseña aparece en el campo. Sola no probaría nada: una bóveda que entrega siempre se ve idéntica a una que entrega con permiso. La F corre lo mismo contestando que no y exige que al campo no llegue nada.

Y lo que mide no es lo que el host contesta sino lo que hay en el campo: la página tiene un guión que delata al servidor lo que le pongan. Es la lección del §7.quater —«el guardián leyó el disco, no la respuesta»— aplicada al otro extremo.

Tres fallos del ARNÉS, y los tres se veían como fallos del producto

Ninguno era de la bóveda, y los tres habrían quedado escritos como «la bóveda no anda»:

  1. wait a secas esperaba también a la app de la bóveda, que es un trabajo en segundo plano. La jaula no cerraba nunca, se comía su timeout y a la app la mataba un KILL — y con ella se iba lo recién guardado, porque sled no alcanzaba a volcar. La etapa siguiente no encontraba la credencial: «la bóveda no guarda», que es el diagnóstico contrario al verdadero. wait "$contestador";
  2. el diálogo no responde al teclado mientras la app de la bóveda está inicializando su GPU. Con render por software en cuatro núcleos, dos llimphi arrancando a la vez se pisan: la ventana del diálogo entra en el árbol del compositor, toma el foco y se come doce teclas seguidas sin efecto; vault.save vuelve denied por timeout con la ventana todavía abierta. Se espera a que la app PINTE (aparezca en el árbol, 12-13 s), no sólo a que atienda el socket;
  3. una sola tecla no alcanza y el borde se mueve: con el mismo wtype rc=0, una corrida contestaba y otra volvía denied. El contestador insiste hasta que la ventana se va, que es la única señal que no depende de adivinar cuánto tarda en estar listo.

⚠ Y uno más, del lado del chrome: WebExtensionPolicy no es global en el scope de una ventana del navegador —la sonda moría con ReferenceError y eso se leía como «la extensión no está»—. Sale del módulo ExtensionParent, que además la reexporta.

Lo que costó llegar hasta acá, en fallos ajenos al navegador

El guardián se escribió para medir una función y terminó destapando tres piezas rotas, ninguna en atuq: que el dueño de la bóveda y el diálogo no estaban en el corpus (§7.sexies), que ninguna app llimphi de escritorio podía pintar sin Vulkan (§7.septies), y que el dueño atendía de a un cliente (§7.octies). Las tres se veían igual desde el navegador: una bóveda que no ofrece nada, sin un solo error. Es el argumento entero a favor de medir en metal y no leer ficheros.

7.decies La bóveda estaba SELLADA y en ninguna imagen — el sexto lugar donde una extensión se enchufa (2026-09-21)

El §7.novies cerró la función: las seis etapas en verde, el diálogo a la vista, el control de decir que no. Lo que quedaba abierto era la decisión 1 del §7.sexies —«en qué imágenes se declaran»— con su respuesta escrita como NO, y con el motivo: mientras ninguna app llimphi pudiera pintar (§7.septies), declararlas era instalar 43 M de binarios que niegan todo sin un error.

Ese motivo se cayó el 2026-09-18. La respuesta de hoy es SÍ, en las cuatro, y antes de tomarla la pregunta se volvió a medir en vez de darla por sabida:

atuq             sealed   perfiles=['escritorio-cosmic','escritorio-gnome','escritorio-kde','escritorio-sway']
puriy-costura    sealed   perfiles=['escritorio-cosmic','escritorio-gnome','escritorio-kde','escritorio-sway']
boveda           sealed   perfiles=[]
shuma-pregunta   sealed   perfiles=[]

sealed con perfiles: [] es la lección de foot otra vez —y no por falta de haberla escrito: targets.toml la repetía quince veces antes de hoy—: una receta sellada que ningún perfil declara NO ESTÁ EN NINGUNA IMAGEN, y la métrica de clausura no lo puede ver porque mide lo declarado. Las dos entran a los cuatro perfiles de escritorio de docs/state/targets.toml, las dos o ninguna: sin el dueño vault.match no ofrece nada, y sin el diálogo Command::new falla y TODO vault.fill se deniega — media bóveda es una que niega todo en silencio. Cuestan ~43 M por imagen (22 M + 21 M medidos sobre los artefactos sellados), contra los ~1,25 GiB que ya lleva el §6.7.

El hueco que apareció al declararlas: estar en la imagen no es poder abrirla

Con las dos raíces puestas, la app viaja en las cuatro imágenes y sigue sin existir para quien la usa: recipes/boveda.toml instalaba /usr/bin/boveda y nada más, y los lanzadores de los cuatro escritorios leen /usr/share/applications. A un binario que nadie lista sólo se llega escribiendo boveda en una terminal. Es la misma forma de fallo, una capa más arriba: la función instalada, apagada y sin un error — que es lo único que este capítulo entero viene persiguiendo.

Entra boveda.desktop en la fase install de la receta. Tres cosas se midieron antes de escribirlo:

El .desktop mueve el hash de la receta: b3:b0c6adc4 ⇒ b3:3f1072cc, reconstruida en el worker con la guarda del §7.quinquies puesta (### receta verificada 3f1072cc… antes de compilar nada, porque el latido revierte la receta del worker cada media hora y un acierto de caché sobre la receta vieja imprime SELLADA en cero segundos). Y el artefacto se miró por dentro, que es la regla 3:

/.hammer/recipe.toml
/usr/bin/boveda
/usr/share/applications/boveda.desktop     ← 22 M, y el validador del store lo acepta
El SEXTO lugar, y su guardián

test-atuq-boveda-coherente.py decía —y este documento con él— que una extensión de atuq está enchufada en cinco lugares. Son seis, y el sexto es el que faltaba:

lugarqué decidecómo se ve cuando falta
manifest.jsonel id que la extensión declara—
distribution/policies.jsonla política que la instalanavegador sin la función
native-messaging/*.jsonel permiso para hablarle al hostconnectNative falla en silencio
atuq.cfglas preferencias que la acompañanel gestor de Gecko se pelea por el campo
recipes/puriy-costura.tomlel COMMIT del host: si sus verbos existen«verbo desconocido» ⇒ se calla
docs/state/targets.tomlquién DECLARA al dueño y al diálogo en la imagenok:true, locked:true

El sexto chequeo mira targets.toml —el manifiesto de objetivo, no build-state.json, que es su derivado— y exige las dos raíces en los cuatro perfiles, con control positivo: atuq tiene que estar ahí, porque un in que no encuentra puede ser una raíz ausente o un campo equivocado y las dos se ven igual. El tercer control negativo (--negative-control-perfil) saca a boveda de una copia en memoria y exige que esto lo vea; sin él, un chequeo que siempre dice que sí se vería idéntico a uno que funciona. Probado en los dos sentidos: los cuatro perfiles en verde, y el control en rojo.

Lo que queda abierto, dicho como lo que es
  1. quién levanta la app. Hoy: la persona, desde el lanzador. Mientras no esté abierta, la bóveda del navegador contesta locked:true —correcto, y es lo que hace cualquier gestor de contraseñas—, pero nadie decidió todavía si debe autoarrancar con la sesión. No se decide de paso: autoarrancar la ata a que la identidad del llavero esté desbloqueada en el login (la decisión 2 del §7.sexies, que sigue abierta);
  2. el único proveedor de GL de las cuatro imágenes es iris (Intel) —mesa-llvmpipe no está en ningún perfil y mesa-swrast sólo en escritorio-mirada—, así que en metal sin GPU Intel el diálogo no pinta. ⚠ Pero esto no es un hueco que abra la bóveda, ni una decisión pendiente: el recorte iris-only es una decisión ESCRITA (SDD 14 §«Por qué iris-only»), el target declarado es una laptop Intel Iris Xe, y llvmpipe/radeonsi piden la cola de LLVM que ese recorte existe para no traer. El caso de la VM ya tiene camino y también está escrito: los runbooks de QEMU montan mesa-llvmpipe COMO CAPA por fuera de la imagen, que es exactamente lo que hace el guardián de metal. La bóveda hereda el alcance de hardware de la imagen y no le agrega nada: sin GL tampoco pinta el escritorio, que también es cliente de GL.

7.undecies La bóveda entró a las imágenes y NADIE puede abrirla — y lo que el navegador veía era una bóveda TEMPORAL (2026-09-21)

El §7.decies dejó dos cosas abiertas y las dos resultaron la misma: quién levanta la app y de dónde sale la raíz de las claves (la decisión 2 del §7.sexies). La cadena, medida eslabón por eslabón hacia atrás desde la app:

eslabónqué dicemedido con
raiz_de_identidad()saca la seed del llavero del kernel, clave pacha_llavero::SEED_IDENTIDADel fuente de pacha-boveda-llimphi
quién la ESCRIBEdos lugares en todo tawasuyu: agora-cli identity unlock y el onboarding de diseño, churay-welcome-runner (que la guarda bajo el mismo nombre)git grep de los guardar(…) contra el llavero
agora-cli en las imágenesreceta sellada, perfiles: [] — en ningunabuild-state
…y aunque se declararasu pin es 9967b02c, del 2026-06-18, donde no existen ni SEED_IDENTIDAD ni desbloqueargit grep sobre el pin, con HEAD de control (4 y 1 aciertos)
churay-welcomeno tiene receta en takana: no está en el corpusls recipes/

⇒ Hoy, en ninguna imagen de takana hay un binario capaz de sembrar la identidad. No es que el usuario no la desbloqueó: es que no tiene con qué. La bóveda de la app abre Err(boveda-cerrada) siempre, en las cuatro.

Y lo que el navegador veía NO era «cerrada»: era una bóveda temporal que se traga contraseñas

Acá está la parte que importa, y es la que ningún guardián de ficheros podía ver. Cuando abrir() falla, la ventana se levanta igual sobre bóveda_imposible() —un sled en /tmp/boveda-sin-abrir-<pid>— y dice el problema en pantalla, que está bien y se queda. Lo que estaba mal es la línea siguiente:

let compartida = Arc::new(Mutex::new(boveda));
atender_al_navegador(&compartida);      // ← sin preguntar si abrió

El socket del navegador se levantaba sobre esa bóveda temporal. Y del lado del host, acceso() prefiere al dueño si lo hay (AccesoPrestado::Ajeno), así que la extensión veía:

vault.status  ⇒  {"ok":true,"locked":false,"count":0}      ← ABIERTA, no «cerrada»
vault.save    ⇒  guarda… en /tmp/boveda-sin-abrir-<pid>

O sea: una persona apretaba el botón, el diálogo de consentimiento abría de verdad, decía que sí, y la contraseña se guardaba en un temporal que muere con el proceso — y que ni el arranque siguiente de la misma app volvería a mirar, porque el nombre lleva el pid.

⚠ Hasta dónde llega cada afirmación, porque no son todas del mismo tipo. Que el socket se levantaba sin preguntar si la bóveda abrió está MEDIDO: es el test de regresión, que falla con el arreglo neutralizado. Que la extensión veía locked:false es LECTURA del host —Costura::acceso() prefiere al dueño (AccesoPrestado::Ajeno) y vault_status contesta locked:false con el count del dueño, sea cual sea la bóveda que ese dueño tenga—; medirlo de punta a punta pide el guardián de metal, que no corre en esta máquina (no hay rootfs de sway ni store acá). La etapa que lo mediría es una hermana de la B: lanzar la app SIN BOVEDA_TEST_ROOT y exigir locked:true. El comentario de bóveda_imposible dice «nunca se escribe nada ahí»: es cierto para la ventana, y el navegador es el OTRO cliente. Es la regla 3 del CLAUDE.md entera: un ausente falla ruidosamente; esto llegaba hasta el final diciendo que todo fue bien.

Arreglado en tawasuyu (7917fbb96): atender_al_navegador toma el problema y no levanta el socket si la bóveda no abrió; la extensión ve locked:true, que es la verdad y lo que ya sabe mostrar. La ventana sigue levantándose y diciendo por qué, que era lo bueno del diseño y no se toca. Con test de regresión en los dos sentidos —sin bóveda no hay socket; con bóveda abierta el socket aparece, que es el control sin el cual un atender_al_navegador vacío pasaría igual—, y probado además AL REVÉS: con el if neutralizado a propósito el test falla con el mensaje que corresponde.

⚠⚠ Y el muro del Cargo.lock, por tercera vez — con una vuelta que NO estaba escrita

Antes de poder subir el pin: cargo metadata --locked sobre el HEAD publicado de tawasuyu muere con «cannot update the lock file», el mismo modo de fallo del §7.quinquies.bis y del §7.octies. Se aplicó la operación MÍNIMA que el §7.octies dejó escrita —cargo metadata SIN --locked—, dio un diff de 15 líneas de borrado y cero checksums movidos, y resultó byte a byte idéntico al que otra sesión ya tenía sin commitear en el árbol compartido. Todo encajaba con la vez anterior. Se publicó, se subió el pin… y el build murió con el mismo error de siempre sobre el árbol ya pineado.

La causa, y es la parte que hay que recordar: esa operación mínima sólo es correcta donde el registro de cargo está COMPLETO. Corrida en la jaula qorpa —cuyo registro local no tiene todos los crates— cargo metadata falla una descarga, termina con estado 0 y deja un lock al que le faltan dos miembros del workspace. Las dos corridas, lado a lado:

dóndediff del lockqué hizo
jaula (registro incompleto)−15 líneasse llevó cuentas-allichay y allichay
worker (registro completo)+1 líneaagregó "llimphi-widget-panel", la que faltaba

O sea que el diff equivocado se ve MÁS mínimo que el correcto —quince líneas contra una, y cero checksums movidos en los dos—, que es exactamente el criterio con el que el §7.octies enseñó a distinguir el arreglo bueno del malo. Y el «byte a byte idéntico al de la otra sesión», que la vez anterior fue la confirmación de que estaba bien, acá sólo significaba que la otra sesión lo había calculado en la misma jaula. Dos mediciones que coinciden no son dos mediciones independientes si comparten el mismo instrumento roto.

Publicado el lock bueno en 6f0408d40, y con él cargo metadata --locked pasa en el worker. El pin de las dos recetas sube ahí —se mueven juntas porque comparten árbol de fuentes por <repo>-<sha>— ⇒ boveda b3:5c43a827 (22 M) y shuma-pregunta b3:cd4277c5 (21 M), selladas en el worker con la guarda de receta puesta y miradas por dentro, que es la regla 3:

/.hammer/recipe.toml
/usr/bin/boveda                            ← `strings` encuentra el aviso del arreglo: 1
/usr/share/applications/boveda.desktop     ← el lanzador del §7.decies sigue adentro

El strings no es decoración: el artefacto es lo único que viaja a la imagen, y que el arreglo esté en el COMMIT no dice que esté en el BINARIO — es la lección del §7.quinquies, donde el host conocía los verbos y la función igual no existía.

⚠ La guarda de receta del §7.quinquies.bis NO alcanza para una TANDA

Las dos recetas se construyeron en un solo comando, con la guarda puesta —hash del worker contra el del hub— una vez, al principio. shuma-pregunta selló bien. Once minutos después, boveda dijo:

INFO takana_build: caché: artefacto ya en el store hash=b3:3f1072cc… name=boveda

que es el hash de la receta ANTERIOR: un acierto de caché en cero segundos sobre el artefacto viejo, o sea el éxito falso que ese capítulo existe para evitar. La causa es la misma y el agujero es nuevo: el latido (rsync --delete hub→worker, cada 30 min) revierte la receta EN EL MEDIO de la tanda, y una guarda que corre al principio no puede ver lo que pasa diez minutos después. Comprobado: recipes/boveda.toml en el worker había vuelto al pin b80f7567c, con mtime del ciclo del latido.

⇒ la guarda va PEGADA a cada build, no una vez por tanda. Y la cura de fondo es empujar la receta a origin/main ANTES de construir: el latido siembra desde el checkout del otro hub, así que mientras el commit no esté publicado, cada ciclo deshace lo que uno acaba de subir por rsync.

ℹ Y un detalle que ahorra construcciones y que conviene tener medido: los COMENTARIOS de una receta no mueven su hash —se hashean los campos declarados, no los bytes del fichero—. Comprobado en los dos sentidos sobre shuma-pregunta.toml: con un comentario de más y sin él, el mismo b3:cd4277c5. Lo que sí lo mueve es el contenido de una fase, que es por qué el .desktop del §7.decies costó un build y corregir tres párrafos de comentario no costó ninguno.

El llavero es el de SESIÓN, y en takana no hay quien lo cree

Aun con un sembrador en la imagen quedaría una pregunta que conviene tener medida antes de escribirla mal: pacha-llavero usa KEY_SPEC_SESSION_KEYRING (-3). Ese llavero lo crea el login, y quien no lo tiene recibe uno propio en cuanto lo toca — o sea que dos procesos HERMANOS pueden no compartirlo.

Lo medido, y dónde:

⇒ de ahí se SIGUE —y esto es inferencia, no medición: ver el aviso de abajo— que el día que haya sembrador el camino bueno sea el de la misma rama de procesos (desbloquear en la consola y lanzar el compositor desde ahí, que hereda), y que el de «desbloquear en una terminal de adentro» no alcance, porque la app del lanzador es hermana y no hija. Es lo primero que habrá que medir cuando exista el sembrador, y se mide con el guardián de metal, que sí tiene dónde correr.

⚠ Lo que NO se pudo medir, dicho como tal: la herencia hermano/hijo no se probó con una sonda directa. add_key da EPERM en la jaula qorpa y keyctl da ENOSYS en el LXC del worker (seccomp de contenedor, las dos), así que el control de la sonda no encendía. Lo de arriba es el censo de /proc/keys más la semántica documentada del kernel — no una medición de la herencia.

Entonces: ¿se quedan las dos recetas en las imágenes?

Sí, y el motivo cambió. La respuesta era NO cuando la app no podía pintar (§7.septies): eso era una bóveda que niega todo sin un error. Hoy la app pinta, dice en su ventana que no hay identidad desbloqueada, y —con el arreglo de arriba— el navegador contesta locked:true, que es la verdad. Son 43 M que muestran un estado explicable en vez de uno silencioso. Si mañana se prefiere sacarlas hasta que exista el sembrador, son dos líneas por perfil; queda dicho para que sea una decisión y no un olvido.

La unidad que queda, con sus dos formas posibles y sin elegir de paso: un sembrador de identidad en la imagen — o subir el pin de agora-cli a uno que tenga identity unlock (y es un pin compartido por árbol de fuentes con arje-zero y dominium-cli), o traer churay-welcome al corpus, que es el camino de diseño y es otra receta llimphi GUI como ésta. Y con cualquiera de los dos, la pregunta del llavero de sesión de acá arriba.

7.duodecies El sembrador entra a las imágenes — y de paso: agora-cli cifraba la seed de TODO EL MUNDO con una palabra pública (2026-09-21)

El §7.undecies cerró con una unidad y dos formas posibles: un sembrador de identidad en la imagen, o subiendo el pin de agora-cli a uno que tenga identity unlock, o trayendo churay-welcome al corpus. Ésta es esa unidad, y trajo un hallazgo que no estaba en ninguna de las dos formas.

Cuál de las dos, con lo medido y no con la intuición
agora-clichuray-welcome-llimphi
¿existe el binario?sí, receta sellada desde la etapa Gsí — src/main.rs sin [[bin]], o sea que cargo lo descubre con el nombre del paquete
¿siembra SEED_IDENTIDAD?sí, identity unlocksí, Efecto::GenerarSeedYDesbloquear por el runner
¿pide la frase?por terminalen un campo de la ventana — es el camino de diseño
¿qué MÁS decide?nadabackend de IA, dotfiles, fondo de pantalla, chasqui, first_run_done

⇒ entra agora-cli, y el motivo no es que sea mejor: es que el wizard trae consigo la experiencia de primer arranque entera, y eso es una decisión de producto que no se toma de paso dentro de una unidad del navegador. agora-cli hace UNA cosa, no impone ningún flujo, y es la que el propio tawasuyu nombra como «la que siembra el login» (comentario de pacha-secretos). Cuando churay-welcome tenga receta, esto se revisa — y queda dicho acá para que sea una revisión y no un descubrimiento.

⚠⚠ Y antes de poder declararlo: sin AGORA_PASSPHRASE, la frase era una constante pública

Esto apareció leyendo el fuente para otra cosa, y es lo que convierte la unidad en obligatoria en vez de conveniente. Sesion::abrir() resolvía la frase del keystore así:

let passphrase = std::env::var("AGORA_PASSPHRASE").unwrap_or_else(|_| {
    eprintln!("agora-cli: usando passphrase de desarrollo \"agora-dev\". …");
    "agora-dev".to_string()          // ← y el comando sigue, y termina en ✓
});

La cadena que eso toca, medida en el fuente y no supuesta:

la frase  →  Argon2id  →  ChaCha20-Poly1305 que cifra la seed en el keystore  (agora-keystore)
la seed   →  SEED_IDENTIDAD en el llavero   →  la clave con la que `boveda` descifra su base

O sea: en una máquina de trabajo es una comodidad; en una imagen de escritorio es la bóveda de todo el mundo cerrada con una palabra que está escrita en el fuente. Y otra vez la forma de fallo de este capítulo entero: no falla nada, el aviso va a stderr —donde nadie mira cuando el comando dice ✓— y lo que se ve es un gestor de contraseñas que anda.

Arreglado en tawasuyu (fd08dc03a), con la decisión separada de su ejecución para poder probarla sin una terminal delante:

variable  >  terminal (se pregunta, sin eco)  >  desarrollo (sólo si no hay a quién preguntarle)

Cuatro tests sobre elegir_origen(), y probado AL REVÉS, que es lo único que dice que el test mide: con el brazo Preguntar borrado a propósito, falla con left: Desarrollo / right: Preguntar, que es exactamente el comportamiento viejo.

⚠ El muro del Cargo.lock, por CUARTA vez — y esta vez la causa era otra

cargo vendor --locked sobre el HEAD publicado murió con el «cannot update the lock file» de siempre (§7.quinquies.bis, §7.octies, §7.undecies). Pero la causa no era la de las veces anteriores: no había nada mal en el lock que yo había tocado —mi cambio agrega una sola arista, rpassword, que ya era dependencia de workspace y por lo tanto ya estaba en el lock con su checksum—. Lo que faltaba era shuma-taller, un miembro que otro agente había metido en un Cargo.toml sin que el lock lo acompañara.

Conviene tenerlo escrito así porque cambia el diagnóstico: este muro no lo levanta quien toca el lock, lo levanta cualquiera que toque un Cargo.toml del monorepo. Va a volver.

El cierre se hizo donde el §7.undecies dijo —donde el registro de cargo está completo—, y acá salió gratis: el árbol ya estaba bajado en el worker por el build que acababa de fallar, así que cargo metadata corrió ahí mismo. +1 línea, cero checksums movidos, que es la forma que tiene el arreglo bueno; en la jaula, la misma operación devuelve 0 y borra miembros del workspace con un diff que se ve más mínimo.

⚠ Y el Cargo.lock del árbol compartido traía otra vez el malo

Al ir a commitear, Cargo.lock estaba en estado MM: el ÍNDICE con una versión sin llimphi-widget-panel —la arista que el §7.undecies había agregado— y el ÁRBOL con una a la que le faltaban cuentas-allichay y allichay, o sea el lock que sale de cerrarlo en una jaula con el registro incompleto, otra vez. Ninguno de los dos se tocó: el commit se armó con git commit-tree sobre el lock de HEAD, sin pasar por el índice ni por el árbol. Es la regla 2 del CLAUDE.md llevada un paso más allá — cuando el fichero que necesitás es justo el que el otro agente tiene a medias, el pathspec no alcanza y hay que salirse del índice.

Lo que quedó, y cómo se comprobó

El pin, con el rodeo del lock dentro: 9967b02c (2026-06-18) → fd08dc03a (el arreglo) → da5fb8968 (el lock cerrado). Sellado en el worker con la guarda de receta pegada al build —que es la lección del §7.undecies: el latido revierte la receta EN EL MEDIO de una tanda— ⇒ agora-cli b3:46529e14, 1,9 M, ELF estático.

⚠ Y un pin distinto es un árbol de fuentes distinto. No coincide con el de boveda/shuma-pregunta (6f0408d40) ni con el de sus hermanas de monorepo (9967b02c: dominium-cli, cosmos-cli, los arje-*), así que paga un vendoreo propio de ~2,4 G. Se paga a propósito: mover boveda para que coincidan cuesta dos builds caros de llimphi GUI y arrastra 28 commits de upstream sin relación con esto. El día que el pin de boveda suba, que suba a éste.

Mirado por dentro, que es la regla 3 — el arreglo está en el COMMIT no dice que esté en el BINARIO (§7.quinquies):

frase NUEVA para el keystore de …   1      ← la doble pregunta de la génesis
repetila / las dos frases no coinciden   1 / 1
sin AGORA_PASSPHRASE y sin terminal      1  ← la rama de desarrollo, ahora acotada
id:default                               1  ← el valor de SEED_IDENTIDAD: SÍ siembra
agora-dev                                1  ← control: la frase vieja sigue, para el caso sin tty

Y probado como artefacto, de punta a punta, con su control (en el worker, con el binario que va a la imagen):

identity new --name probador   ⇒  nueva identidad creada · id dcedffd4…
unlock                         ⇒  ✓ identidad dcedffd4… desbloqueada y cacheada en la sesión
/proc/keys                     ⇒  user  pacha:id:default: 32      ← la seed, sus 32 bytes
AGORA_PASSPHRASE=la-mala unlock ⇒ keystore: autenticación fallida  ← y NO re-siembra

⚠ Y de paso, una corrección al §7.undecies: el verbo NO es agora-cli identity unlock. Es agora-cli unlock, subcomando de primer nivel; identity no tiene unlock. Lo decía este documento, lo decía la receta, y hasta el mensaje de error del propio binario dice «crea una con agora-cli identity new <nombre>» cuando el nombre va por --name. Nada de eso falla en un guardián: se descubre la primera vez que alguien lo escribe.

El séptimo lugar del guardián de coherencia

test-atuq-boveda-coherente.py pasa de SEIS lugares a SIETE. El séptimo pregunta lo que queda debajo del sexto —el sexto dice si el DUEÑO está en la imagen; éste, si hay con qué abrirla— y lo hace en los dos planos, porque fallan distinto:

planoqué exigecómo se ve cuando falla
recipes/agora-cli.tomlque el COMMIT pineado llame a guardar(pacha_llavero::SEED_IDENTIDADunknown subcommand, o un binario que hace otra cosa
docs/state/targets.tomlque algún perfil de escritorio lo declaresellado ≠ instalado: locked:true para siempre

Con su control positivo (SEED_IDENTIDAD: &str, que cualquier commit del monorepo tiene que traer) y su control negativo propio (--negative-control-sembrador, que lo saca de una copia en memoria). El control positivo pagó solo: corrido contra el pin VIEJO no contesta «no siembra» sino «CONTROL ROTO: SEED_IDENTIDAD: &str tampoco aparece en 9967b02c» — que es la verdad exacta (ese commit es anterior a la constante) y no la acusación equivocada que un grep sin control habría hecho. Los cuatro controles negativos, en verde.

Lo que sigue abierto, y separado de lo que quedó medido
  1. la herencia del llavero, TODAVÍA sin medir, y con una corrección sobre cómo NO medirla: el §7.undecies dedujo del censo de /proc/keys que en el worker los hermanos comparten anillo. Acá se vio que /proc/keys leído como root lista claves que el proceso no necesariamente puede usar —una segunda sesión ssh «ve» la clave de la primera—, así que ese fichero prueba que la clave EXISTE, no quién puede recuperarla. Lo que sí quedó medido y no lo estaba: add_key funciona en el LXC del worker (lo hace agora-cli unlock, arriba); lo que da ENOSYS ahí es keyctl. La pregunta que importa —si la app lanzada desde el menú, que es HERMANA de la terminal, ve lo que la terminal sembró— sigue pidiendo el guardián de metal y un lector, no un censo;
  2. pacha y pacha-secretos tienen el mismo hueco, una capa al lado, y esto es medido: los dos están declarados sólo en perfil.servidor y los dos sólo LEEN SEED_IDENTIDAD, así que el Secret Service de esa imagen corre en «modo memoria» —los secretos no persisten— sin sembrador que lo evite. Es menos silencioso que la bóveda, porque pacha-secretos publica persistente por D-Bus justamente porque «desde afuera es indistinguible»; pero es la misma unidad sin hacer. No se declara agora-cli en servidor de paso: es otro perfil, otro dueño y otra decisión;
  3. el wizard, que es el camino de diseño y hoy no tiene receta.

7.terdecies «No hay identidad» era también «no pude preguntar» — y en un sitio eso guardaba los dotfiles EN CLARO (2026-09-21)

El §7.duodecies cerró con tres pendientes, y el segundo era pacha/pacha-secretos en perfil.servidor: los dos LEEN SEED_IDENTIDAD, ninguno la siembra, y nadie en esa imagen puede sembrarla. Esto es ir a contestar eso. La respuesta a la pregunta de origen cambió poco; lo que apareció por el camino, mucho.

El control positivo salió ROJO, y eso es lo que encontró el defecto

El plan era medir con el daemon de verdad: sembrar la identidad, arrancar pacha-secretos y ver si dice persistente. Cuatro sondas, con su control:

sembradocómo arranca el daemonesperadosalió
P0 control negativononormalmodo memoriamodo memoria ✓
P1 control POSITIVOsí, misma sesión, mismo uidnormalpersistentemodo memoria ✗
P2 el caso del servidorsísetsid (otra sesión)modo memoriamodo memoria
P3 otro uidsísu nobody—modo memoria

Con el control en rojo, P2 y P3 no prueban nada: los cuatro contestan igual, que es indistinguible de un guardián que siempre dice lo mismo. Así que antes de escribir una sola conclusión hubo que preguntar por qué, y la sonda de syscalls lo dijo en veinte segundos:

add_key            ->    679788099  errno=0
keyctl SEARCH      ->           -1  errno=38 Function not implemented

En el LXC del worker —seccomp de contenedor— se puede sembrar y no se puede cosechar. La seed estaba en /proc/keys, sus 32 bytes, puesta por el agora-cli del §7.duodecies; y el Secret Service arrancaba igual diciendo «SIN identidad agora desbloqueada». (En la jaula qorpa las dos dan EPERM, que es por qué esto no se puede medir desde acá.)

Los cinco lugares donde Option tenía dos estados y el llavero tres

La causa no es el contenedor: es que el lector traduce mal. Estaba en cinco sitios, todos con la misma forma —.recuperar(…).ok().flatten(), o un _ =>—, que convierte «no se pudo preguntar» en «no hay identidad desbloqueada»:

dóndequé se ve cuando el llavero fallagravedad
pacha-cli (dotfiles)los dotfiles se guardan SIN CIFRAR, sin una palabra, con la identidad del usuario desbloqueadala peor: es pérdida de confidencialidad, no un mensaje feo
pacha-secretos«SIN identidad agora desbloqueada — modo memoria»los secretos no persisten y la culpa apunta al usuario
pacha-cli (dotfiles pubkey)«identidad bloqueada: no hay seed»acusa de no haber desbloqueado
pacha-boveda-llimphi · pata-llimphi«La bóveda está cerrada. Se abre al entrar a la sesión.»consejo falso: entrar a la sesión no lo arregla
wawa-panel-llimphi«ninguna identidad desbloqueada»—

Y esto ya había pasado, con cicatriz escrita en el propio crate. El doc de pacha_llavero::SEED_IDENTIDAD cuenta que hasta el 2026-08-19 había dos nombres de clave que no se encontraban, y que la consecuencia fue «el Secret Service arrancaba SIEMPRE en modo memoria aunque la identidad estuviera desbloqueada, y no persistía un solo secreto» — semanas, sin que nadie lo viera. Se arregló el nombre; lo que lo hizo invisible seguía puesto.

Arreglado en tawasuyu (4f2b5eac7): pacha-llavero gana SeedDeSesion —Hay / NoHay / Fallo— con un motivo() cuyos tres textos son distintos a propósito, y los cinco lectores pasan por ahí. pacha-secretos gana además reason en su interfaz propia net.tawasuyu.Secretos1, al lado de persistent, que existía exactamente por esto: «desde afuera es indistinguible». Test en los dos sentidos —los_tres_estados_no_se_colapsan, con un llavero que siempre rompe y la exigencia de que los textos difieran—, y probado al revés: con Err(e) => Fallo(e) neutralizado a NoHay, falla en «un Err del llavero TIENE que llegar como fallo».

La pregunta de origen: ¿entra agora-cli en perfil.servidor? NO

Y no por lo que uno supondría. La premisa está medida:

scripts/targets.py --service-paths servidor   ⇒  incluye recipes/pacha-secretos.toml
takana service-cards recipes/pacha-secretos.toml ⇒ Card de scope system, "lifecycle": "daemon"

O sea que la Card entra en el genesis de la imagen y arje arranca el daemon en el arranque, con setuidgid pacha. De ahí se sigue, sin necesidad de medir ningún llavero:

  1. en el arranque no hay nadie logueado, así que no hay seed que sembrar todavía — un sembrador en la imagen no llega a tiempo, por construcción;
  2. abrir_almacen() se llama una sola vez, en main(), y el resultado queda en Estado: aunque el operador desbloquee después, el daemon ya decidió y no vuelve a mirar;
  3. y el agora-cli unlock de una sesión ssh siembra en el llavero de ESA sesión, que no es el del daemon.

⇒ declarar agora-cli en servidor no cerraría nada y dejaría la función pareciendo cerrada, que es peor que el hueco. En el escritorio (§7.duodecies) el sembrador sí tiene sentido porque la persona está delante; acá no hay persona en el momento que importa. La misma raíz, dos respuestas distintas, y por eso no se copia la decisión de un perfil al otro.

Lo que SÍ lo cerraría, nombrado y no decidido —el propio pacha-llavero ya lo tiene previsto: «la elección de backend de desbloqueo (kernel hoy; mañana PAM/TPM/passphrase+Argon2/greeter) NO está cementada: se cambia la impl de [Llavero]»—:

Hasta entonces el Secret Service de servidor es efímero —que es lo que su propia receta ya decía— y ahora lo dice con el motivo correcto y lo publica por D-Bus. Que es toda la diferencia entre un hueco conocido y uno silencioso.

Lo sellado, y el antes/después medido contra el artefacto

Las dos recetas suben de 23a292863 a cd9acd0d9 —4f2b5eac7 más el cierre de su Cargo.lock, ver abajo— y sellan en el worker con la guarda pegada a cada build: pacha-secretos b3:e8038352 (4,6 M) y pacha b3:a9fb8c17 (8,5 M). Miradas por dentro, que es la regla 3 —el arreglo en el commit no dice que esté en el binario—:

pacha-secretos   «NO se pudo consultar» 1 · «no hay identidad … en esta sesión» 1 · Reason 1
                 control: «SIN identidad agora desbloqueada» (el texto viejo) → 0
pacha            «los dotfiles se guardan SIN cifrar» 1 · control: «identidad bloqueada: no hay seed» → 0

Y corriendo el daemon sellado, que es la prueba que importa:

antes:  WARN  SIN identidad agora desbloqueada — modo memoria, los secretos NO persisten
ahora:  WARN  modo memoria — los secretos NO persisten
              motivo=el llavero de sesión NO se pudo consultar
                     (keyring del kernel: Function not implemented (os error 38))
                     — esto no es «no hay identidad»

⚠ Hasta dónde llega esto. En el LXC las DOS sondas dan ahora «fallo», porque ahí keyctl es ENOSYS siempre: lo que quedó probado contra el artefacto es que el caso de error deja de disfrazarse de ausencia. Que la rama NoHay siga diciendo «no hay» sólo lo cubre el test unitario —con un llavero que rompe a propósito—, porque para ejercitarla hace falta una máquina donde el llavero funcione, y ni la jaula ni el worker lo son.

ℹ La guarda pegada al build volvió a salvar una tanda, y en vivo

Las dos recetas se lanzaron en un solo comando, con la guarda por receta que el §7.undecies dejó escrita. La primera selló; la segunda no llegó a construirse:

GUARDA [recipes/pacha.toml]: esperaba b3:a9fb8c17… y hay b3:ea0c664f…

b3:ea0c664f es el hash ANTERIOR: el latido (rsync --delete cada 30 min) había revertido la receta entre las dos construcciones de la misma tanda — comprobado por el mtime de los dos ficheros en el worker, idéntico y de ese ciclo. Sin la guarda eso habría sido un acierto de caché en cero segundos sobre el artefacto viejo, o sea un éxito falso. Con ella, un error y un recopiado. Es la primera vez que esa guarda se dispara sola, y confirma que el capítulo que la pidió no estaba siendo paranoico.

⚠ Y otra vez el árbol compartido, ahora con un arreglo publicado DESHECHO en el índice

Al ir a commitear, pacha-boveda-llimphi/src/lib.rs estaba MM — y las dos versiones, la del índice y la del árbol, eran anteriores al arreglo de 7917fbb96: la que levanta el socket del navegador sin preguntar si la bóveda abrió (§7.undecies), con su comentario y todo borrado. Mis cambios se habían escrito encima de ESA base sin que nada avisara.

Se rehízo sobre HEAD y se commiteó con commit-tree, sin tocar índice ni árbol. Lo que lo delató no fue mirar el diff: fue que el test de regresión el_socket_del_navegador_sale_solo_si_la_boveda_abrio estaba en la lista de los que tenían que pasar. Es el tercer episodio del mismo tipo en dos días (el Cargo.lock dos veces, esto una), y la regla que sale es la misma que el §7.duodecies: cuando el fichero que necesitás es justo el que otro tiene a medias, el pathspec no alcanza — hay que salirse del índice, y hay que tener un test que grite si la base era la equivocada.

⚠ El Cargo.lock, quinta vez — y ahora la arista que faltaba era la MÍA de esa mañana

cargo vendor --locked volvió a morir. Esta vez la arista ausente era rpassword en agora-cli, publicada unas horas antes en da5fb8968 (§7.duodecies): se la llevó 338d4a9b9 "merge de git en «main» (import)", que resolvió el lock quedándose con el lado viejo. Bisecado y no adivinado:

git log -S'"rpassword",' da5fb8968..origin/main -- Cargo.lock   ⇒   338d4a9b9

El blob repuesto es byte a byte el que ya estaba publicado. Con esto la regla del §7.duodecies queda confirmada del peor modo: este muro no lo levanta quien toca el lock, lo levanta cualquiera que toque un Cargo.toml del monorepo — o un merge que lo resuelva mal. Y deja un aviso práctico: recipes/agora-cli.toml está pineada a da5fb8968, que SÍ tiene la arista, así que su artefacto sigue construyendo; quien la suba por encima de 338d4a9b9 sin el commit de reparación se come el muro, y el mensaje de cargo no nombra ni rpassword ni el merge.

ℹ Y una reparación de paso, porque costó media hora entender el síntoma: en el clon compartido de tawasuyu, cuatro directorios de .git/objects eran de nobody (uid sin mapear, del 2026-09-18), así que cualquier fetch cuyos objetos cayeran en 05/, a5/, b4/ o b8/ moría con «insufficient permission for adding an object» — intermitente, según el sha. Reparados copiando los cuatro objetos a directorios nuevos; git fsck limpio.

Lo que queda, y lo barato que sería

boveda y shuma-pregunta siguen pineadas en 6f0408d40, o sea sin este arreglo: la ventana de la bóveda todavía dice «se abre al entrar a la sesión» cuando el llavero es el que falló. Subirlas las arregla. No se hizo acá porque son dos builds de llimphi GUI y esto era la unidad del servidor; queda dicho para que sea una decisión.

⚠ Este párrafo decía además que subirlas «colapsa dos árboles de fuentes en uno» y que así el vendoreo de 2,4 G ya estaría pagado. Es FALSO, y se corrige en el §7.quaterdecies, que fue a hacerlo: el árbol de fuentes se nombra por RECETA, no por commit, así que compartir pin no comparte nada. Ya estaba medido, y en CLAUDE.md §3 bis, con estas dos mismas recetas de ejemplo.

7.quaterdecies La cola del capítulo anterior — y dos premisas suyas que no eran ciertas (2026-09-22)

El §7.terdecies cerró con dos pendientes escritos: subir boveda/shuma-pregunta al commit que las arregla, y medir la herencia del llavero de SESIÓN entre procesos HERMANOS. Esto es ir a pagar los dos. Los dos se pagaron, y los dos destaparon que la nota que los pedía tenía una premisa equivocada adentro — una por pendiente.

El pin sube, y el motivo que la nota le ponía no existe

boveda y shuma-pregunta estaban en 6f0408d40; el arreglo del llavero se publicó en 4f2b5eac7 y pacha/pacha-secretos ya estaban en cd9acd0d9, que lo contiene y además cierra el Cargo.lock. Se suben las dos a cd9acd0d9, no a 4f2b5eac7: mismo arreglo y encima el lock que cargo vendor --locked necesita.

Lo que cambia de verdad es un texto, y por eso importa. pacha-boveda-llimphi es uno de los cinco lectores que hacían .ok().flatten() sobre SEED_IDENTIDAD —Option tiene dos estados y el llavero tiene tres— así que la ventana decía «La bóveda está cerrada. Se abre al entrar a la sesión» en los dos casos: cuando no hay seed sembrada, que es verdad, y cuando el llavero FALLÓ, que es mentira y encima le echa la culpa a quien mira. Un mensaje equivocado no tiene código de salida: el artefacto sella, reproduce, entra a las cuatro imágenes y abre su ventana.

⚠⚠ Y la nota decía que subirlas «colapsa dos árboles de fuentes en uno», con lo que el vendoreo de 2,4 G «ya estaría pagado». No es cierto, y las dos recetas lo repetían en tres párrafos cada una como si fuera el motivo del pin propio. El árbol de fuentes es work/sources/<NOMBRE DE RECETA>-<commit> —crates/takana-build/src/fetch.rs, el format! de {}-{}— y encima se BORRA y se rehace en cada build (remove_dir_all antes del git archive). O sea que cada receta paga su copia del monorepo y su cargo vendor siempre, con el pin que sea; lo único compartido es el CARGO_HOME del hub, porque cargo vendor corre fuera del sandbox: se comparte la DESCARGA, no la copia.

Lo incómodo es que esto ya estaba medido, y en CLAUDE.md §3 bis, con estas dos recetas de ejemplo: «boveda y shuma-pregunta salen del mismo b80f7567… y ocupaban 2,7 G cada una». La nota del §7.terdecies y los cabezales de las dos recetas afirmaban lo contrario al lado de la medición que lo desmentía. Corregido en los tres sitios. La lección no es nueva —es la-etiqueta-no-es-el-hecho otra vez— pero la forma sí: una frase que justifica una decisión se copia entre ficheros y se revisa menos que un número, porque nadie la lee como una afirmación medible. Ésta viajó por dos recetas y un SDD.

⇒ Corolario práctico, que sí vale: el pin propio de estas dos recetas —vigente desde el 2026-09-18 porque necesitaban estar por encima de eed3120b6, donde llimphi aprende a pasarle el display handle a wgpu (§7.septies)— nunca costó un vendoreo extra. Costaba exactamente lo mismo que compartirlo. Lo que se gana subiéndolas es el arreglo, y nada más.

boveda           b3:5c43a827 → b3:1b7365bf
shuma-pregunta   b3:cd4277c5 → b3:fcc745e8

⏳ Los dos artefactos van al worker, como las tres veces anteriores: son llimphi GUI y el hub es la jaula qorpa. La tanda se lanzó con la guarda por receta del §7.undecies dentro del flock, que es donde la ventana existe de verdad — la del §7.terdecies se disparó justamente porque el latido revirtió una receta entre dos builds de la misma tanda. Quedó encolada detrás de la tanda de otro frente (once recetas del monorepo subiendo a c7545339a), que es lo que el lock del ADR 0012 hace y para lo que está.

⚠⚠ Y el latido no revierte: BORRA — el primer intento se evaporó entero

El §7.terdecies contó que el latido había revertido una receta entre dos builds. Falta la mitad: su rsync -az --delete no sólo pisa lo que difiere, borra todo lo que no esté en el árbol del hub. El primer lanzamiento de esta tanda puso el script y su log en /opt/takana/ —que parece el sitio natural, es donde está todo lo demás— y en el ciclo de las 10:30 desaparecieron los dos. El sh que estaba ejecutando el script murió con él. Lo que quedó fue un flock esperando un lock para nadie, y un tail -F sobre un fichero que ya no existía: desde afuera se ve idéntico a una tanda encolada y paciente, que es como se leyó durante veinte minutos.

Y la misma barrida revirtió la receta sembrada a mano, que era la otra mitad del error: seguir sembrando recetas al worker por rsync propio es pelearse con el latido cada media hora. Las dos correcciones son una sola frase: lo efímero vive fuera de /opt/takana (acá, /root), y las recetas las siembra el latido. El relanzamiento espera a que el hash pedido APAREZCA en el worker en vez de fallar, con su propio límite y su propio mensaje (SIN SEMILLA).

ℹ Y el latido siembra al EMPEZAR el ciclo y se pone al día (git pull --ff-only) al terminarlo, así que un commit recién empujado tarda dos ciclos en llegar al worker, no uno. Se acortó a cero haciendo ese pull a mano en el clon del latido —/opt/takana del anfitrión, que no es el clon donde se trabaja (/work/sergio/takana): son dos árboles distintos y esa distinción no estaba escrita en ningún lado—.

El OCTAVO lugar: nadie vigilaba que la receta no se quedara DETRÁS de su propio paquete

test-atuq-boveda-coherente.py miraba siete lugares y los siete preguntaban lo mismo: ¿la pieza ESTÁ? Ninguno preguntaba si la que está es la de hoy. Y ésa es exactamente la forma en que se rompió esto: la receta pineada tres días atrás, el arreglo publicado en el otro repo, y nada que falle. Es el cuerpo del §7.quinquies —el cable tiene dos puntas en dos repos y se desincronizan en silencio— un piso más abajo: allá la punta que se quedaba atrás era el HOST y el síntoma era «verbo desconocido»; acá es la receta de la app y el síntoma es un texto.

La pregunta se hace en la forma que no envejece: no se busca el símbolo SeedDeSesion —renombrarlo dejaría al guardián mintiendo en verde, que es el modo de falla de umbral-a-mano-envejece— sino que se le pregunta a git si hay commits en main que TOQUEN el paquete que la receta construye y que el pin no tenga:

boveda.toml          → shared/pacha/pacha-boveda-llimphi, shared/pacha/pacha-llavero
shuma-pregunta.toml  → 02_ruway/shuma/shuma-pregunta

Y no dice «subí el pin»: dice cuántos y cuáles, porque un pin viejo puede ser deliberado y «estás atrás» sin decir de qué no sirve para decidir. El control POSITIVO es que el pin sea ancestro de main: si no lo es, el pin..main da cero por una razón que no es «al día» —otra rama, un clon sin fetch— y el verde sería falso. El control negativo (--negative-control-deriva) finge el pin viejo, y es el único de los cinco cuyo «mal» ya ocurrió de verdad:

✗ boveda.toml pineada (6f0408d40) está DETRÁS de 2 commit(s) que tocan su propio paquete
      · pacha-boveda-llimphi: 4f2b5eac7 fix(pacha): «el llavero falló» se contaba como…
      · pacha-llavero:        4f2b5eac7 fix(pacha): «el llavero falló» se contaba como…
La herencia entre HERMANOS: sí, pero sólo si alguien abrió sesión

El otro pendiente. El §7.terdecies lo dejó «sin medir» y con la razón bien puesta: /proc/keys como root no lo mide, sólo dice que la clave existe. Ver que add_key funcionó no dice que el hermano pueda ENCONTRARLA — y en el LXC del worker esas dos cosas se separan de la peor manera.

La pregunta es del producto, no del kernel: en las cuatro imágenes la persona corre agora-cli identity unlock en una terminal y después abre boveda desde el lanzador. Son dos procesos y ninguno es hijo del otro: hermanos. Sonda nueva, scripts/sonda-llavero-hermanos.{c,sh}, con los roles como exec aparte y no como forks —un fork heredaría por definición y la sonda contestaría que sí sin haber medido nada—. Medido en el anfitrión (kernel 7.1.2, root):

A  el ancestro NO tiene sesión propia   sembrada ✓ · el hermano NO la encuentra
B  el ancestro se une a una sesión      sembrada ✓ · el hermano la ENCUENTRA (mismo id)

Los dos escenarios se corren juntos y siempre, porque el interesante es el contraste: el A solo, en rojo, se lee como «los hermanos no comparten nunca», que es falso y habría cerrado el flujo del escritorio por construcción. Lo que pasa en A es que pedir @s sin sesión previa hace que el kernel le fabrique una al proceso, y la seed muere con quien la sembró.

⇒ El flujo del escritorio es posible, pero no gratis: agora-cli unlock y boveda tienen que colgar de un ancestro con llavero de SESIÓN. Si arje lanza cada app sin eso, la bóveda contesta locked:true para siempre y no falla nada — la forma de fallo de todo este capítulo, una capa más abajo todavía. Es un requisito sobre arje que nadie había escrito, y es lo que esta medición agrega sobre «sí, los hermanos comparten».

Tres controles, y los tres se dispararon de verdad. El del INSTRUMENTO es el que más vale: corrida en el LXC del worker, la sonda se niega a contestar —keyctl(KEYCTL_SEARCH) da ENOSYS, se puede sembrar y no cosechar— y así reproduce sola la medición del §7.terdecies en vez de citarla. El POSITIVO es que el sembrador se busque su propia clave antes de salir. Y el NEGATIVO —un tercer proceso que salta a una sesión nueva y no la encuentra— sólo se corre donde el hermano SÍ encontró: si arriba salió «no», un «no» del control es el mismo «no» y no separa medir de no encontrar nunca.

ℹ El primer intento fue en Python con ctypes y no corre donde hace falta: el python del anfitrión musl no trae _ctypes (falta libffi.so.8) y se cae al importar, antes de medir nada. keyutils/keyctl(1) no están en el corpus. Queda el syscall pelado, que además es lo que hace pacha-llavero.

8. Plan, por unidades de trabajo

Cada una cierra sola, se commitea y se pushea. El orden no es preferencia: cada una destraba a la siguiente.

#UnidadPuerta que abreBloqueada por
1waterfox SELLA ✅ 2026-09-06 — b3:88b5a762, 377 M, BuildID determinista y REPRODUCE bit a bit. Cuatro muros, ninguno de la receta: takana viejo en el worker, el watchdog barriendo el post-mortem, deps estáticas vs. el cairo bundleado, y una rotura de upstream sólo-Linuxvalida la tesis del reúso—
2llvm-toolchain → apk add lld + compiler="clang" ✅ 2026-09-05LTO y el muro 3—
3Construir el firefox con LTO (hash b3:6f2a3b2f)el eje de velocidad y las extensiones de atuq2 ✅
3.aPGO ENCENDIDO ✅ 2026-09-07 — perfil de 501.521 funciones recogido bajo headless, publicado en el mirror y pineado por sha256; 189 compilaciones con -fprofile-use. Sin jarlog todavíala otra mitad de la ganancia3 ✅
3.bRLBox ENCENDIDO ✅ 2026-09-06 — cadena wasm de CINCO recetas selladas + guardián que la exige en el binario; firefox sellado y REPRODUCE bit a bit (verificar-repro.sh: 0 no-determinismos)cierra el hueco de seguridad del §3.bis2 ✅
4atuq: receta derivada + overlay de chrome ✅ v0.1 escritael andamio de todo lo demás3
4.bBranding + re-empaque de omni.ja ✅ v0.2el chrome de verdad—
4.dAbrirlo en pantalla — destapó EXDEV, el lanzador, el atk envenenado y la cadena GTK3 estática«usable», no sólo «sellado»—
4.ev0.3: página de inicio y pestaña nueva por extensión de sistema; exigió MOZ_REQUIRE_SIGNING vacío. Verificado 2026-09-07 con scripts/test-atuq-inicio.py: se le pregunta al MOTOR (browserSettings.homepageOverride / newTabPageOverride) y da moz-extension://…/inicio.html en las dos, contra about:home/about:newtab en el controly con eso queda destrabada la unidad 6 (sct)rebuild de firefox
4.fLos códecs, medidos ✅ 2026-09-07 — H.264+AAC, VP9+Opus, AV1, MP3 y FLAC reproducen; arreglado el LD_LIBRARY_PATH que dejaba al RDD sin ffvpx (AV1 muerto en silencio) y nacido scripts/test-atuq-codecs.sh con control negativo«se ve el vídeo», que ningún auditor de ELF podía responder—
4.cMOZ_BUILD_DATE determinista ✅ — BuildID=19700101000001, con guardián en install y why-differs 56/56 sobre dos builds realesel invariante de reproducibilidad—
4.gEl chrome se programa desde atuq.cfg, sin abrir omni.ja ✅ 2026-09-09 — y de paso: la vista dividida ya la trae el motor y viene prendida (fx 154), ejercitada de verdad con gBrowser.addTabSplitView ⇒ activeSplitView + 2 navegadores. Guardián scripts/test-atuq-chrome.py con control negativo del propio motorretira dos pendientes que eran de upstream, y deja el camino del chrome que NO pelea con el jarlog—
4.hQuién instala las extensiones, medido ✅ 2026-09-09 — tres escenarios en scripts/test-atuq-instalacion.py: instala el ESCANEO de distribution/extensions/, install_url con file:// no instala nada. El README de la receta afirmaba lo contrario y quedó corregidoel mecanismo del que cuelga la extensión de sct deja de ser una nota al pie y pasa a estar vigilado—
5Host de native messaging — HECHO 2026-09-10. puriy-costura en tawasuyu (ebe41e96) + el cable shared/foreign-webext, 14 tests sin navegador; y del lado de acá recipes/puriy-costura.toml ⇒ b3:3b633431, ELF estático de 952 K verificado como artefacto: contesta un marco de 4 bytes y hace la promesa del §6.1 completa (4 visitas aprendiendo → stable → un script cambiado = un evento). El camino ya estaba comprobado en el §7.bis con sonda y control negativolos verbos del §64 ✅
6sct v1 — HECHA 2026-09-10. Extensión sct@atuq.tawasuyu (filterResponseData → native messaging) + el manifiesto en /usr/lib/mozilla/native-messaging-hosts/ + el lanzador que le pone el --state que el manifiesto no puede llevar. Guardián scripts/test-atuq-sct.py: servidor HTTP real, seis cargas, una carga = una visita vigilado, la insignia releída, y dos controles (la 5ª repite el mismo script y no alerta; sin manifiesto no hay veredicto)el diferenciador que nadie tiene, andando5 ✅
7Descargas al CAS — HECHA 2026-09-10. cas.ingest/cas.list en el host + la extensión descargas@atuq.tawasuyu; ingesta en STREAMING (arje-cas::almacenar_fichero_en, nueva) para no cargar una ISO entera en RAM; raíz propia porque el GcCas de arje borraría las descargas en silencio; el fichero del usuario no se toca. Guardián scripts/test-atuq-descargas.py con descarga real y control negativo6.2, y alimenta 6.95 ✅
8Archivo personal — la mitad de congelar y buscar, HECHA 2026-09-10. archive.add/archive.search + la extensión archivo@atuq.tawasuyu: el HTML ya ejecutado al CAS, el índice buscable en JSON, y nada de ventanas privadas. Guardián scripts/test-atuq-archivo.py, cuyo control negativo además dice quién impidió archivar lo privado. Falta la mitad semántica: un motor rag-motor::RagMotor (como willay-rag), que exige daemon de embeddings + LLM real6.35 ✅, 7 ✅
9Medios (6.6) ✅ y torrent (6.9) ✅ — 2026-09-10. El medio lo abre mpv; el torrent lo toma puriy-costura-torrent, un daemon propio, configurable y perezoso que sobrevive al navegador y cosecha al CAS. Medido antes de decidir: la pila de librqbit compila para musl (226 crates), así que la decisión fue «no ahí» y no «no se puede». Faltan el foco (6.5) y la IA local (6.7), ésta bloqueada porque el corpus no tiene modelo ni embeddings6.5–6.7, 6.95 ✅
10Foco (6.5) — 2026-09-10. Entra al corpus la cadena nftables (libmnl + libnftnl + nft 1.1.6), que el grafo pedía y nadie había puesto, con dos arreglos de fábrica: el sello de tiempo que rompía la reproducción y un bashismo. Medido con root: el cgroup en foco no sale y el de al lado sí. Y del lado del navegador, la extensión foco MUESTRA el estado y no tiene verbo para apagarlo. Falta quién pone a atuq en un cgroup (delegación, decisión de arje) y quién aplica la política (root)6.56 ✅
11Motor de inferencia local (6.7) — 2026-09-11. Entra recipes/llama-cpp.toml (b10901, estática, 199 M, REPRODUCE), que resulta ser un solo muro para dos pendientes: el §6.7 y la mitad semántica del §6.3 no eran dos problemas, era que el corpus no tenía con qué correr un modelo (pluma-llm sólo tiene backends de nube; rimay-verbo-fastembed DESCARGA onnxruntime glibc + el modelo). ⚠ Y dejó medido lo que no se podía deducir: SOURCE_DATE_EPOCH —la variable que nos da reproducibilidad— apagaba las SEIS perillas de ISA de ggml, y el artefacto sellaba y corría con 0 %ymm. Ahora van declaradas y el install las comprueba. Guardián scripts/test-llama-cpp.py con control negativo vivo. Faltan el modelo (fuente pineada, no receta), los verbos del host y quién levanta el servidor6.7, y la mitad semántica de 6.35 ✅
12La bóveda (SDD-BOVEDA §8) — a medias, 2026-09-15. La décima extensión: ofrece la credencial del sitio abierto y no puede sacar una contraseña por su cuenta —vault.match contesta títulos y usuarios; la contraseña sale por vault.fill, que pregunta en el escritorio—. La dirección la pone el chrome, nunca la página. El gestor de Gecko se aparta como VALOR DE ARRANQUE y no como política. Destrabada el 2026-09-16 (§7.quinquies.bis): el Cargo.lock de tawasuyu publicado —y resultó ser byte a byte el que la otra sesión ya tenía sin commitear—, el pin subido a 23a292863 ⇒ b3:7d63655a, construido en el worker (el vendoreo son 2,4 G y el hub estaba al 99%) y medido con el mismo strings que había diagnosticado el hueco: vault de 0 a 10, con cas/sct de control. Falta, y no era lo que este renglón decía (§7.sexies, 2026-09-18): el host sellado contesta vault.status → locked:true porque el dueño de la bóveda y el diálogo de consentimiento no estaban en el corpus — la función está apagada de fábrica en las cuatro imágenes. Entran recipes/boveda.toml y recipes/shuma-pregunta.toml, las dos SELLADAS el 2026-09-18 (b3:59ffd74b y b3:99763eca, construidas en el worker). ⚠ Y con eso apareció el muro de verdad, que no es de la bóveda: ninguna app llimphi de escritorio abre ventana en esta distro porque no hay Vulkan en ninguna imagen y el camino GL de llimphi arma la instancia sin display handle (§7.septies). CERRADA el 2026-09-18 (§7.novies): el guardián de metal —scripts/test-atuq-boveda-metal.py— pasa sus seis etapas, con el navegador de verdad, el diálogo a la vista y el control de decir que NO. Para llegar hubo que arreglar tres piezas ajenas al navegador: el dueño y el diálogo no estaban en el corpus (§7.sexies), ninguna ventana llimphi podía pintar sin Vulkan (§7.septies, arreglado en llimphi) y el dueño atendía de a UN cliente, lo que con el navegador abierto dejaba la bóveda muda (§7.octies, arreglado en pacha con su test de regresión). Y ENTRA A LAS IMÁGENES el 2026-09-21 (§7.decies): hasta ese día las dos estaban sealed con perfiles: [] —o sea selladas y en ninguna imagen, la lección de foot que ese mismo fichero repetía quince veces—, así que la función seguía apagada de fábrica aunque anduviera. Se declaran en los cuatro perfiles de escritorio (~43 M por imagen) y la receta aprende a instalar boveda.desktop, porque estar en la imagen no es poder abrirla: sin entrada en /usr/share/applications a la app sólo se llega escribiendo su nombre en una terminal. El guardián de coherencia pasa de CINCO lugares a SEIS —el sexto es targets.toml— con su tercer control negativo. Y la decisión 2 CONTESTADA el 2026-09-21 (§7.undecies), con la respuesta incómoda: nadie puede abrirla, porque ninguna imagen trae un binario capaz de sembrar SEED_IDENTIDAD — agora-cli está en perfiles: [] y además pineado al 2026-06-18, donde el verbo unlock todavía no existe; churay-welcome, que es el camino de diseño, no tiene receta. Y al medirlo apareció lo que ningún guardián de ficheros veía: cuando la bóveda no abre, la app igual le levantaba el socket al navegador sobre una bóveda TEMPORAL ⇒ vault.status contestaba locked:false y un vault.save consentido guardaba en /tmp/boveda-sin-abrir-<pid>, que muere con el proceso. Arreglado en tawasuyu (7917fbb96) con test en los dos sentidos, y el pin de las dos recetas sube a 6f0408d40, que trae además el Cargo.lock cerrado — con una vuelta nueva: la «operación mínima» del §7.octies, corrida en una jaula con el registro de cargo incompleto, devuelve 0 y BORRA dos miembros del workspace, y su diff se ve más mínimo que el correcto. Y EL SEMBRADOR ENTRA el 2026-09-21 (§7.duodecies), que es lo que esa respuesta dejaba pendiente: de las dos formas posibles se elige agora-cli —el wizard churay-welcome-llimphi SÍ tiene binario, pero trae consigo backend de IA, dotfiles, fondo y chasqui, o sea la experiencia de primer arranque entera, y eso no se decide dentro de una unidad del navegador—. Y antes de poder declararlo apareció lo que lo volvía imposible: sin AGORA_PASSPHRASE, Sesion::abrir() caía en la frase de desarrollo "agora-dev" con un aviso por stderr y un ✓ en pantalla ⇒ en una imagen de escritorio, la seed de identidad de todo el mundo —que es la RAÍZ con la que la bóveda se descifra— cifrada con una palabra que está escrita en el fuente. Arreglado en tawasuyu (fd08dc03a): variable > terminal (se pregunta, sin eco, y DOS veces en la génesis) > desarrollo sólo si no hay a quién preguntarle, con la decisión en una función pura, cuatro tests y el control al revés. Pin 9967b02c → da5fb8968 (con el muro del Cargo.lock por cuarta vez, y esta vez la arista que faltaba era de OTRO agente: shuma-taller) ⇒ b3:46529e14, 1,9 M, declarado en los cuatro perfiles, medido como artefacto: identity new + unlock deja user pacha:id:default: 32 en /proc/keys, y con la frase equivocada falla y no re-siembra. El guardián de coherencia pasa de SEIS lugares a SIETE, con su cuarto control negativo. Y EL CASO servidor CONTESTADO el 2026-09-21 (§7.terdecies), con la respuesta CONTRARIA a la del escritorio: agora-cli NO va en perfil.servidor. La premisa está medida —la Card de pacha-secretos es scope=system y entra al genesis, o sea que arje arranca el daemon EN EL ARRANQUE— y de ahí se sigue que un sembrador no llega a tiempo por construcción: en el arranque no hay nadie logueado, abrir_almacen() decide una sola vez en main(), y un unlock de una sesión ssh siembra en el llavero de ESA sesión. Declararlo dejaría la función PARECIENDO cerrada, que es peor que el hueco. Y al ir a medirlo apareció el defecto que sí se arregló: los cinco lectores de la seed hacían .ok().flatten(), que convierte «el llavero no se pudo consultar» en «no hay identidad desbloqueada» — y en pacha-cli eso guardaba los dotfiles SIN CIFRAR, en silencio. Lo encontró un control positivo en ROJO (con la seed presente en /proc/keys el daemon decía que no había), cuya causa mide la sonda de syscalls: en el LXC add_key funciona y keyctl(KEYCTL_SEARCH) da ENOSYS. Arreglado en tawasuyu (4f2b5eac7) con SeedDeSesion —tres estados, tres textos— más reason en net.tawasuyu.Secretos1; pin de las dos recetas 23a292863 → cd9acd0d9. Queda: la herencia del llavero de SESIÓN entre procesos HERMANOS (sin medir — y /proc/keys como root no la mide, sólo dice que la clave existe), y subir boveda/shuma-pregunta a ese mismo commit, que las arregla y de paso colapsa dos árboles de fuentes en unoel gestor de contraseñas de la suite dentro del navegador5 ✅
9.aProxy por contenedor ✅ v0.5 — contenedores por política + extensión con proxy.onRequest6.8, y NO dependía de 5: es API de Firefox—

Las unidades 2 y 4 son paralelizables: la toolchain no toca el chrome y el chrome no toca la toolchain. Si hay dos frentes, van juntas.

9. Por qué esto no abandona a puriy

puriy es el motor DOM/CSS propio con JS real (QuickJS-NG sobre wasmi) cuyo límite declarado no es el lenguaje sino el wiring nativo: DOM bindings completos, red, render. Eso no se acelera escribiendo otro navegador; se acelera con tiempo.

atuq no le compite porque no comparte una sola línea con él, y le sirve porque todo lo del §6 vive del lado Rust: el host del §7, el testigo de sct, las descargas al CAS, el archivo con RAG. Son agnósticos del motor. Cuando puriy madure, se enchufan del mismo lado sin reescribirse.

Dicho al revés, que es como conviene recordarlo: atuq es el navegador que se puede usar mientras puriy crece, y el andamio de las features que puriy va a heredar. Si dentro de dos años atuq se apaga, lo que se tira es una receta derivada y una hoja de CSS; todo lo demás sobrevive.