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.
1859
1860### 6.11 Y ahora la pregunta que faltaba: ¿REPRODUCE? (2026-09-07)
1861
1862El §6.10 preguntó «¿qué NO puede hacer?» y contestó leyendo cadenas. Media pregunta. La otra mitad
1863—**¿se ve el vídeo?**— no la contesta ningún auditor de ELF, y al hacerla aparecieron **dos errores
1864del mapa y un bug real del navegador**.
1865
1866**Error 1: «no hay receta de ffmpeg».** La hay. `recipes/ffmpeg.toml`, 7.1, sellada, publica
1867`libavcodec.so.61` —uno de los once sonames que `libxul.so` sondea por `dlopen` literal— y **ya
1868viajaba en la clausura de los cuatro escritorios**, arrastrada por `mpv`. Comprobado con
1869`yupana.membresia`, que es la vista honesta por par `(cola, nombre)`, no leyendo el TOML:
1870
1871 escritorio-kde 308 nodos · escritorio-gnome 192 · escritorio-sway 203 · escritorio-cosmic 163
1872 ffmpeg ✓ en los cuatro · libva ✓ en los cuatro
1873
1874Lo que faltaba no era la receta: era **la lista de raíces de `scripts/atuq-nested.sh`**, escrita a
1875mano, que hidrataba un rootfs más pobre que la imagen. Otra vez el instrumento y no el artefacto —
1876la misma forma que `gcc-libs` el día anterior. Y la comprobación previa que sí se pudo hacer sin
1877correr nada: de los 82 símbolos con forma `av*_` que `libxul` dlsimboliza, **los 9 que nuestro
1878`libavcodec` no exporta son los de la API vieja** (`avcodec_decode_video2`, `av_lockmgr_register`,
1879`avcodec_register_all`…), que Firefox sólo pide para las versiones 53-57.
1880
1881**Error 2: «VA-API, sin receta».** También la hay, y también está en las cuatro imágenes. El hueco
1882es real pero por la OTRA mitad: las tres recetas de mesa construyen con `-Dgallium-va=disabled` y
1883`-Dvideo-codecs=` vacío ⇒ **no existe un solo `*_drv_video.so`**. Un cargador sin ICD no acelera
1884nada, exactamente como `vulkan-loader`. ⚠ Y con una trampa nueva: ahora que `libva` está en el
1885rootfs, **el mapa de `--dlopen` ya no la reporta**, porque mide presencia de fichero. La librería
1886presente SILENCIA el aviso sin encender la función. Queda escrito acá porque el guardián ya no
1887puede decirlo.
1888
1889**El bug real: AV1 no reproducía.** Gecko **se come el primer elemento de `LD_LIBRARY_PATH`** al
1890lanzar el proceso RDD, el que decodifica vídeo. El lanzador ponía `/usr/lib/atuq` una sola vez —o
1891sea, primero—, así que el RDD arrancaba sin él, no encontraba el ffvpx bundleado (donde vive dav1d)
1892y anotaba `FFVPX: Link result: NoProvidedLib`. Y ahí **no falla**: se cae al ffmpeg del sistema, que
1893cubre H.264/AAC/VP8/VP9/MP3/FLAC/Opus… pero **no AV1 por software**, porque el decodificador `av1`
1894de ffmpeg es sólo hwaccel y nuestra receta va sin dav1d. Resultado: un vídeo AV1 se quedaba en
1895`readyState=1` para siempre, sin un error, sin un NEEDED faltante y sin una cadena ausente.
1896
1897Tres corridas del MISMO artefacto que sólo cambian esa variable:
1898
1899 LD=/usr/lib/atuq:/usr/lib:/lib → RDD NoProvidedLib AV1 ✗
1900 LD=/usr/lib/atuq:/usr/lib/atuq:/usr/lib → RDD Success AV1 ✓
1901 LD=/relleno:/usr/lib/atuq:/usr/lib → RDD Success AV1 ✓ (el relleno se lo come)
1902
1903El arreglo es repetir el appdir en `recipes/atuq/bin/atuq`. Feo y correcto mientras no haya
1904`patchelf` en el corpus para grabar `RUNPATH=$ORIGIN`, que borraría la variable entera.
1905
1906**Lo que quedó medido**, con `scripts/test-atuq-codecs.sh`, headless, por el lanzador de verdad y
1907sin `LD_LIBRARY_PATH` de afuera:
1908
1909| Muestra | Veredicto |
1910|---|---|
1911| H.264 + AAC (mp4) | ✅ `t=3.00`, 75 cuadros |
1912| VP9 + Opus (webm) | ✅ `t=3.00` |
1913| AV1 (mp4) | ✅ `t=3.00` — sólo desde el arreglo del lanzador |
1914| MP3 | ✅ |
1915| FLAC | ✅ |
1916
1917El veredicto **no es «se creó el decodificador»**: es que `currentTime` AVANCE. La distinción no es
1918retórica — en el caso de AV1 el decodificador se creaba (`RemoteDecoderChild … codec: av1`) y no
1919entregaba un solo cuadro. El guardián corre con `--negative-control`, que saltea el lanzador y
1920**exige que AV1 falle**: probado en los dos sentidos, porque uno que no puede fallar y uno que nunca
1921falló se ven igual desde afuera.
1922
1923**Dos bugs de andamiaje que salieron de paso**, los dos en `atuq-nested.sh` y los dos escondidos
1924detrás de una caché: el bucle de hidratación salía 1 siempre (línea vacía final + `set -e`) y mataba
1925el script sin imprimir nada, y la comparación del sello nunca daba igual (un `\n` que `$(cat …)`
1926recorta de un lado y no del otro), así que el rootfs se rehidrataba entero en cada corrida. El
1927primero sólo se veía cuando cambiaban los hashes; lo destapó agregar `ffmpeg` a las raíces.
1928
1929### 4.g El chrome se programa SIN abrir `omni.ja` — y la vista dividida ya la trae el motor (2026-09-09)
1930
1931El pendiente escrito era «el chrome de verdad dentro de `omni.ja` (split view, dientes propios): el
1932re-empaque ya está resuelto, lo que falta es el contenido». Las dos mitades de esa frase eran falsas,
1933y las dos se midieron con `scripts/test-atuq-chrome.py` sobre el artefacto vigente:
1934
1935 VIVO el JS de atuq.cfg corre
1936 VENTANA con gBrowser
1937 PREF splitView.enabled=true
1938 WRAPPER tabs=2
1939 ACTIVA si
1940 BROWSERS 2
1941
1942**1. El zip no es el gancho del chrome; `atuq.cfg` sí.** Ese fichero ya corre con privilegios —de ahí
1943sale `nsIStyleSheetService`, y es la razón de `sandbox_enabled = false`—, así que desde ahí se observa
1944`browser-delayed-startup-finished` y se toca el `gBrowser` de CADA ventana que abra el navegador. La
1945sonda abre dos pestañas y las parte en dos desde `atuq.cfg`, sin tocar `omni.ja`.
1946
1947Y no es un atajo: es la vía CORRECTA, porque re-empacar `omni.ja` es exactamente lo que pelea con el
1948orden del `jarlog` del PGO —el motivo por el que el `jarlog` está aparcado (`c3413d29`)—. Dicho al
1949revés: **cada función del chrome que no entre al zip es una que no compite con la optimización de
1950arranque.** Eso cambia el orden de preferencia para todo lo que venga: primero prefs, después
1951extensión de sistema, después JS de chrome por autoconfig, y `omni.ja` sólo si no queda otra.
1952
1953**2. La vista dividida no hay que escribirla: Firefox 154 la trae y viene PRENDIDA.** En
1954`browser/omni.ja` están `tabbrowser/tabsplitview.js` (492 líneas, el custom element completo con
1955`addTabs`/`unsplitTabs`/`reverseTabs`), `opentabs-splitview.mjs` y `split-view-footer.js`; y
1956`defaults/preferences/firefox.js` trae `pref("browser.tabs.splitView.enabled", true)`. La sonda no se
1957queda en «el fichero está»: llama a `gBrowser.addTabSplitView([t1, t2])` y le pregunta al motor, que
1958contesta `activeSplitView` puesta y `splitViewBrowsers.length == 2`. **Funciona en nuestro build.**
1959
1960Es el mismo patrón que ya pagó dos veces en este SDD —los dientes (`sidebar.verticalTabs`), los
1961contenedores (`privacy.userContext.enabled`)—: la función existe en el motor y viene apagada o sin
1962usar, y el trabajo es prenderla y comprobarla, no escribirla. La forma de equivocarse es al revés:
1963declarar como deuda propia algo que upstream ya nos dio.
1964
1965⚠ **Lo que esto NO dice.** atuq todavía **no lleva JS de chrome propio**: lo medido es el GANCHO, con
1966la sonda inyectada por una capa de overlay que no viaja en el artefacto. Cuando haya una función que
1967lo justifique, va en `recipes/atuq/chrome/atuq.js` y la carga `atuq.cfg`. No se shipea un cargador
1968vacío para «tenerlo listo».
1969
1970El control negativo sale del propio motor y no de una idea nuestra: `addTabSplitView` documenta que
1971si TODAS las pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control` las fija
1972y exige justamente eso — sin él, una sonda que dijera «partió» pase lo que pase se vería idéntica a
1973una que funciona.
1974
1975### 2.ter Dónde vive la fuente de `atuq` — hizo falta un modo de `[source]` nuevo
1976
1977Escribir la receta destapó un muro que este documento no había visto: `[source]` era
1978**obligatoriamente** git o tarball, así que una receta cuyo contenido no viene de upstream sino de
1979nosotros no se podía ni expresar. Los rodeos eran todos peores — fetchear una fuente que después se
1980ignora **miente** sobre la identidad del artefacto, y colgar el overlay de un repo aparte obliga al
1981worker a tener acceso a un repo privado.
1982
1983Se agregó **`source.dir`** (commit `54fa4a9`): un árbol dentro del propio repo, hasheado por
1984**contenido** con `ArtifactHash::of_tree` —que ya existía para verificar bit-reproducibilidad en
1985Stage 2— y no por ruta. Es la misma disciplina que ya tenían los `patches`, que entran al hash por
1986bytes. Editar un CSS del overlay mueve el `ArtifactHash`; renombrar el directorio, no.
1987
1988Un `.swm` **no** puede llevar una receta derivada, y los cuatro sitios que lo tocan fallan
1989diciéndolo: el manifiesto es compartible y necesita un puntero resoluble desde fuera.
1990
1991**Hecho el 2026-09-05** (commits `aeebb19` y `6f6c32b`): `recipes/atuq.toml` + `recipes/atuq/`.
1992La **v0.2** (`b3:7eff4ba4`) ya trae el branding: `application.ini` (Vendor/Name/RemotingName/
1993CodeName, con el `ID` intacto para no salirse del ecosistema de complementos), `brand.ftl` dentro de
1994`browser/omni.ja`, los binarios renombrados, iconos propios y `.desktop`. El re-empaque preserva
1995orden, `STORED` y fechas del original, y se verificó contra el artefacto: **5306 entradas, mismo
1996orden, y una sola con contenido distinto**. El `BuildID` se deriva del contenido del overlay —no de
1997la fecha— porque es la clave con la que Gecko invalida su startup cache: sin eso, un cambio de
1998chrome arranca con la interfaz vieja y *parece* que el overlay no agarró.
1999
2000La capa de configuración de la v0.1 son cuatro ficheros —autoconfig, `atuq.cfg`, `chrome/atuq.css`,
2001`policies.json`— y el CSS se registra como `USER_SHEET` desde autoconfig porque `userChrome.css`
2002vive en el perfil y exigiría que el usuario prenda una pref: atuq tiene que verse como atuq en el
2003primer arranque. Falta construirlo: su dep `firefox` está en vuelo.
2004
2005### 2.quater Abrirlo en una pantalla: tres fallos que la clausura no podía ver
2006
2007El 2026-09-05, con `atuq` sellado y verificado, se hidrató y se abrió en el compositor de la sesión
2008(waypipe). **No arrancó**, y los tres motivos son la diferencia entre «sellado» y «usable»:
2009
20101. **EXDEV al hidratar.** `hydrate` proyecta con hardlinks y `linkat()` rechaza cruzar un punto de
2011 montaje aunque los dos lados sean el mismo filesystem. Acá el store es `/dev/sdb` bind-monteado
2012 y `work/` vive en `/dev/sdc`. El rootfs va bajo `/mnt/cosecha`, donde el volumen está montado
2013 entero. Se comprueba con `findmnt -T`, **nunca** con `stat -c %d`.
20142. **El lanzador no puede ser un symlink.** Ni el binario ni sus `.so` traen `RPATH`/`RUNPATH`
2015 (`readelf -d`), así que el motor no encontraba su propio `libnspr4.so` y moría en
2016 `XPCOMGlueLoad`. Es un script con `LD_LIBRARY_PATH` + `exec`, como Debian y Fedora. La
2017 alternativa limpia —`RPATH=$ORIGIN` con `patchelf`, que es lo que hace Alpine— espera a que
2018 `patchelf` exista como receta del corpus.
20193. **Un bug del corpus, no de atuq: `atk` se había tragado GObject.** Declaraba la variante
2020 ESTÁTICA de glib y produce un objeto compartido, así que `libatk-1.0.so` llevaba una copia
2021 entera del sistema de tipos. Dos GObject en un proceso ⇒ 45 `GLib-GObject-CRITICAL` y
2022 `Segmentation fault`. **El síntoma no nombra a atk**, y no se ve mirando el rootfs: había una
2023 sola `libgobject`. Se cazó preguntando quién DEFINE el símbolo:
2024 `nm -D --defined-only <cada .so> | grep " T g_type_register_static"` — deben salir uno, salieron
2025 dos.
2026
2027Radio medido antes de tocar (`yupana radio atk`): 4 transitivos, 3 sellados a deuda; GNOME fuera
2028porque usa gtk4. La cadena `atk → gtk3 → firefox → atuq` volvió a sellar 4/4, y **firefox tardó
2029~50 minutos, no las cuatro horas** que repetía este documento — el número venía del folclore de la
2030receta, no de una medición.
2031
2032**La lección para el plan: ninguna unidad de este frente está cerrada hasta que algo se ABRE en una
2033pantalla.** La clausura decía 100% con un navegador que no llegaba a pintar un píxel.
2034
2035### 2.quinquies La v0.3 y los tres mecanismos: dos muertos, uno vivo
2036
2037La página de inicio y la pestaña nueva parecían un `pref` y resultaron ser un frente. Lo que se
2038midió, en orden:
2039
2040| Mecanismo | Resultado |
2041|---|---|
2042| `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 |
2043| `policies.json` → `ExtensionSettings` con `install_url` | **Se lee** (`browser.policies.applied=true` en el perfil) y falla nombrándose: `ERROR_SIGNEDSTATE_REQUIRED` |
2044| `xpinstall.signatures.required = false` | **No alcanza** — la exigencia viene COMPILADA |
2045
2046**El instrumento que destrabó el diagnóstico fue un testigo.** `defaultPref` no deja rastro en
2047`prefs.js` —sólo se guarda lo que difiere del default— así que un autoconfig que **no se ejecuta**
2048es indistinguible de uno que sí y no hace nada. `atuq.cfg` escribe ahora `atuq.autoconfig.ok` como
2049pref de **usuario**, legible desde fuera sin abrir el navegador. Salió `true` ⇒ el `.cfg` corría, y
2050por lo tanto la pref de firma se estaba ignorando.
2051
2052De ahí `MOZ_REQUIRE_SIGNING` vacío en `recipes/firefox.toml`. Dos detalles que costaron cada uno su
2053vuelta:
2054
20551. El default sale del **milestone** (`milestone.is_release_or_beta`), no del canal de actualización.
2056 El nuestro es `default` y la exigencia estaba activa igual.
20572. **Se desactiva con el valor VACÍO, no con cero.** `=0` muere con «takes 0 values»: es booleana y
2058 el cero es un valor que no acepta. Leído del parser de mozbuild, no deducido.
2059
2060**El precio, escrito:** este Firefox deja de exigir la firma de Mozilla para cualquier complemento.
2061La alternativa es firmar en AMO —cuenta y revisión de Mozilla por versión—, que es la dependencia
2062externa que esta distro existe para no tener. Y no es un desvío: **sin esto, el `sct` del §6.1
2063tampoco se podría shipear nunca**, así que la unidad 6 dependía de ésta sin que el plan lo dijera.
2064
2065**Método que conviene repetir:** la prueba del testigo se hizo **sin reconstruir**, montando el
2066`.cfg` modificado con `--ro-bind` por encima del artefacto. Editar el fichero en el rootfs habría
2067escrito sobre un **hardlink del store** y corrompido el artefacto sellado.
2068
2069### 2.sexies Lo que quedó PROBADO, y con qué
2070
2071Tres afirmaciones que este documento venía haciendo sin medir, y cómo se cerraron:
2072
2073| Afirmación | Prueba |
2074|---|---|
2075| El re-empaque del `omni.ja` es determinista | `why-differs` entre dos builds de `atuq`: **85 entradas idénticas, 0 divergen** |
2076| La capa de configuración se aplica | testigo `atuq.autoconfig.ok` escrito como pref de **usuario** en el perfil |
2077| 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` |
2078| La base también reproduce | `why-differs` entre dos builds de `firefox` en el worker: **56 entradas idénticas, 0 divergen** |
2079
2080Y el contraste que salió de la primera: **el derivado reproducía y la base no.** El `BuildID` de
2081`firefox` era la hora del build, así que dos construcciones con el mismo `ArtifactHash` daban bytes
2082distintos — y `build-state.json` no lo podía ver, porque el hash es input-addressed y no se mueve
2083por esto. Verde y mintiendo. Cerrado con `MOZ_BUILD_DATE` desde `SOURCE_DATE_EPOCH`, que es el
2084mecanismo de hermeticidad que el sandbox ya tenía — y **comprobado como se comprueba esto**:
2085apartando el artefacto como `.ref`, construyendo de nuevo y pasándole `why-differs`. Reproduce.
2086
2087**Dos guardianes nuevos en la fase `install` de `firefox`**, hermanos entre sí, porque los dos fallos
2088son de la misma familia —una bandera que el `configure` acepta y el artefacto ignora, muda y a 50
2089minutos de distancia—: uno comprueba `MOZ_REQUIRE_SIGNING: false` dentro del `omni.ja`, el otro que
2090el `BuildID` sea el que `SOURCE_DATE_EPOCH` obliga.
2091
2092**La regla que sale de todo el día:** cuando la duda es «¿llegó al artefacto?», la respuesta no está
2093en el log del build ni en la receta — está **dentro del artefacto**, y hay que ir a buscarla ahí.
2094
2095## 7. La costura: un host de native messaging en Rust
2096
2097Todo lo del §6 que no es CSS pasa por **un solo mecanismo**: un proceso Rust que habla native
2098messaging con la extensión de `atuq` y, del otro lado, con la suite (`agora` para identidad Ed25519,
2099`khipu`, el store, `foreign-*`, `cortafuegos`).
2100
2101Que sea **uno** y no siete es la decisión: cada feature del §6 pasa a ser un verbo de ese host, no un
2102proyecto nuevo. El binario vive en tawasuyu (es suite, no build system); `atuq` sólo lo declara como
2103dep y le deja el manifiesto de native messaging en su sitio. **El nombre del crate se decide en
2104tawasuyu y con su regla 10 puesta** — acá no se bautiza nada de allá.
2105
2106### 7.bis El camino EXISTE, y tres cosas que no eran obvias (2026-09-07)
2107
2108Antes de escribir un crate contra un mecanismo hay que saber si el mecanismo funciona en NUESTRO
2109build —propio, rebrandeado, con `MOZ_REQUIRE_SIGNING` vacío—, así que se puso una **sonda**:
2110extensión de diez líneas con permiso `nativeMessaging` y un host de tres que contesta `{"ok":true}`.
2111Vive en `scripts/test-atuq-nativo.py`, con control negativo:
2112
2113 positivo MENSAJE {"ok":true}
2114 control DESCONECTADO No such native application puente_atuq
2115
2116El control no sólo falla: **nombra la causa**, que es lo que confirma que la ruta del manifiesto es
2117la que se probó. Lo que hay que saber para escribir el host:
2118
2119| Qué | Medido |
2120|---|---|
2121| 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 |
2122| 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** |
2123| 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 |
2124| Con qué diagnosticar | el error del puerto de `connectNative`. `sendNativeMessage` devuelve «An unexpected error occurred» para todo y no distingue nada |
2125
2126⚠ La tercera fila del cuadro corrige una lectura fácil de `policies.json`: los dos mecanismos
2127apuntan a los mismos ficheros y se tapan uno al otro, así que la política PARECE ser la que instala.
2128Fija y configura; instalar, no.
2129
2130### 7.ter Y esa fila ahora tiene guardián — porque el README decía lo contrario (2026-09-09)
2131
2132La fila «cómo se instala la extensión» era una nota al pie de una sonda de native messaging, y
2133mientras tanto **`recipes/atuq/README.md` afirmaba lo opuesto**: que el *sideloading* desde
2134`distribution/extensions/` «ya no funciona» y que instalaba `ExtensionSettings`. Dos documentos
2135nuestros, contradictorios, sobre el mecanismo del que cuelgan TODAS las extensiones de atuq —hoy
2136`inicio` y `proxy`, mañana la de `sct`—. Y ninguna observación del artefacto los distingue, porque
2137los dos apuntan a los mismos ficheros.
2138
2139`scripts/test-atuq-instalacion.py` lo rompe de a uno y mira qué pasa. Medido sobre
2140`atuq b3:fab2fbfb` (firefox 154 con PGO v2) y REPETIDO sobre `b3:9e16ac35` —el artefacto que esta
2141misma corrección creó, porque el README vive dentro de `source.dir` y editarlo re-hashea atuq—,
2142tres escenarios:
2143
2144| escenario | qué se rompe | motor (`homepageOverride`) | perfil |
2145|---|---|---|---|
2146| `as-is` | nada | `moz-extension://…/inicio.html` | `inicio` y `proxy` activas |
2147| `no-policy` | `policies.json` **sin** `ExtensionSettings` | **sigue siendo la nuestra** | **las dos siguen activas** |
2148| `policy-only` | los XPI mudados a `/usr/lib/atuq/xpi-politica/`, con `install_url` apuntando ahí | `about:home` | **ninguna de las dos existe** |
2149
2150⇒ **instala el escaneo de la carpeta; `install_url` con `file://` no instala nada.** El §7.bis
2151queda confirmado y el README corregido.
2152
2153Dos cosas que este guión trae y que valen más que el veredicto:
2154
21551. **El control es por construcción, no un modo aparte.** La sonda que le pregunta al motor vive en
2156 `distribution/extensions/` y **ninguna política la nombra**: si la sonda habla, el escaneo está
2157 vivo en esa corrida. Una corrida muda no se lee como «no instaló» — se declara ROTA y se guarda
2158 el log. Sin eso, un atuq que no arranca se vería idéntico a una política que no instala.
21592. **La segunda señal que probé NO discrimina, y queda dicho.** Esperaba que el `location` de
2160 `extensions.json` separara los mecanismos (`app-system-defaults` vs. `app-profile`). No: los dos
2161 XPI salen `app-profile` **también** cuando los instala la carpeta y no hay política que los
2162 mencione. Se imprime igual porque dice si están, pero como discriminador es cero — y una señal
2163 que uno cree que discrimina y no discrimina es peor que ninguna.
2164
2165**Por qué esto es un guardián y no un test más.** Mozilla lleva años retirando el *sideloading*, y
2166el día que la carpeta deje de instalar, atuq arranca **sin ninguna de sus extensiones** y sin una
2167línea de error: la página de inicio vuelve a ser la de Firefox y el proxy por contenedor
2168simplemente no enruta. Es el modo de fallo silencioso de siempre, y el que aparece cuando SUBE la
2169dep, no cuando alguien toca atuq.
2170
2171⚠ **Y por eso mismo hay que decir lo que todavía NO está: ninguno de los `test-atuq-*` corre en el
2172latido.** `cosecha-cron.sh` sólo ejecuta `vigia-sonames.py`; los de atuq se corren a mano. Meterlos
2173cuesta ~3 min por ciclo (tres arranques de navegador headless por escenario) y exige que el hub tenga
2174hidratado el rootfs de `scripts/atuq-nested.sh`, así que **es una decisión con precio y no un
2175`echo` más en el cron** — queda escrita acá y sin dar por hecho lo contrario. El día que se meta, el
2176sitio es el bloque de guardianes de `cosecha-cron.sh` (L341-352).
2177
2178### 7.quater El host, escrito — y por qué la extensión NO puede hablarle al testigo (2026-09-10)
2179
2180El §6.1 decía que `sct` v1 es «extensión con `webRequest` bloqueante que hashea cada respuesta de
2181script y **consulta al testigo** antes de dejarla pasar». La primera mitad es correcta; la segunda es
2182imposible tal como está escrita, y conviene decirlo porque parecía un atajo para saltarse esta
2183unidad: **el cable de `puriy-sct-testigo` es un POST con `postcard` en el body**, no JSON. Un JS no
2184puede ser su cliente. Y reimplementar el registro TOFU en la extensión sería fabricar un sustituto
2185paralelo del original —lo que la regla 10 de tawasuyu prohíbe— además de duplicar la única pieza que
2186ya estaba certificada. Entonces v1 pasa por el host, y el host es esta unidad.
2187
2188Se decidió con el operador, con el `grep` de la regla 10 hecho primero: **ningún término del dominio
2189significaba «host de native messaging»**, y los que suenan a mensajero están todos ocupados por
2190dominios ajenos y grandes (`chasqui` es un type broker, `chaka` el puente a COBOL, `paloma` el cliente
2191de correo, `tampu` el común de objetos). El nombre salió del título de este propio §7: **la costura**.
2192
2193| Pieza | Dónde | Por qué ahí |
2194|---|---|---|
2195| `puriy-costura` | `00_unanchay/puriy/` | regla 1: subcrate del dominio, no proliferación lateral. Precedentes: `puriy-sct-testigo`, `rimay/verbo-daemon`, `watuy-daemon` |
2196| `foreign-webext` | `shared/` | regla 4: el protocolo es AJENO. Lib pura sin binario, como los otros 37 puentes (medido: ninguno lleva `[[bin]]`) |
2197| `recipes/puriy-costura.toml` | takana | el artefacto: estático musl, pineado por commit, `--locked` |
2198
2199Verbos v1: `ping`, `sct.observe`, `sct.state`. Claves del cable en inglés (regla 7 de allá y regla 4
2200de acá: se tipean). `phase` es `learning`/`stable`/`not-evaluable`, y un `events[]` vacío es lo normal
2201— sólo hay evento cuando un origen **ya estable** ejecuta un hash que nadie vio nunca.
2202
2203**Los tres tests que hacen que el veredicto signifique algo** (de catorce): el mismo cambio de script
2204mientras el origen todavía aprende **no** alerta; rotar `?v=` con el mismo código **tampoco** (la
2205identidad del recurso es su contenido, y sin eso cada despliegue con cache-buster sería un aviso de
2206supply-chain: un detector que grita todos los días no se mira más); y el aprendizaje **sobrevive al
2207cierre del navegador**, sin lo cual cada arranque volvería a «aprendiendo» y ningún cambio sería nunca
2208un evento.
2209
2210**Los dos límites, escritos en el código y en los dos LEEME, no en una nota de sesión:**
2211
22121. **Qué bytes se hashean.** La extensión manda el TEXTO ya decodificado; se hashea su UTF-8. La
2213 detección vale —el TOFU compara lo mismo contra lo mismo—, pero nuestro hash **no es comparable**
2214 con el de los bytes servidos que publique un tercero, ni con el de la v2 (el gancho en el script
2215 loader). Un hash que uno cree comparable y no lo es sería peor que no tenerlo.
22162. **La semilla del log es provisional**: se deriva del directorio de estado, así que es
2217 determinística (el log se reanuda) pero **no es un secreto** ⇒ la bitácora es a prueba de
2218 REESCRITURA, no de SUPLANTACIÓN. La de verdad sale del perfil cuando el almacén del perfil exista.
2219
2220**Y lo que queda medido para la unidad 6, preguntándole al artefacto y no a la documentación de
2221Mozilla** (§2.sexies): `filterResponseData` y el permiso `webRequestFilterResponse` **están** en
2222nuestro build (`omni.ja`, `chrome/toolkit/content/extensions/schemas/web_request.json`), así que la
2223extensión va a poder leer el cuerpo de un script. Lo que NO va a poder es ver los scripts **inline**
2224por esa vía —`filterResponseData` entrega el documento, no cada `<script>`—, y eso está bien para v1:
2225el ataque que `sct` nombra es la sustitución en el CDN, que es exactamente el caso `<script src>`.
2226
2227## 8. Plan, por unidades de trabajo
2228
2229Cada una cierra sola, se commitea y se pushea. El orden no es preferencia: cada una destraba a la
2230siguiente.
2231
2232| # | Unidad | Puerta que abre | Bloqueada por |
2233|---|---|---|---|
2234| 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 | — |
2235| 2 | ~~`llvm-toolchain`~~ → **`apk add lld` + `compiler="clang"`** ✅ 2026-09-05 | LTO **y** el muro 3 | — |
2236| 3 | Construir el `firefox` con LTO (hash `b3:6f2a3b2f`) | el eje de velocidad **y** las extensiones de `atuq` | 2 ✅ |
2237| 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 ✅ |
2238| 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 ✅ |
2239| 4 | **`atuq`: receta derivada + overlay de chrome** ✅ v0.1 escrita | el andamio de todo lo demás | 3 |
2240| 4.b | Branding + re-empaque de `omni.ja` ✅ v0.2 | el chrome de verdad | — |
2241| 4.d | **Abrirlo en pantalla** — destapó EXDEV, el lanzador, el `atk` envenenado y la cadena GTK3 estática | «usable», no sólo «sellado» | — |
2242| 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 |
2243| 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 | — |
2244| 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 | — |
2245| 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` | — |
2246| 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 | — |
2247| 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 ✅ |
2248| 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 ✅ |
2249| 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 ✅ |
2250| 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 ✅ |
2251| 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 ✅ |
2252| 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 ✅ |
2253| 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 ✅ |
2254| 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 | — |
2255
2256Las unidades 2 y 4 son **paralelizables**: la toolchain no toca el chrome y el chrome no toca la
2257toolchain. Si hay dos frentes, van juntas.
2258
2259## 9. Por qué esto no abandona a `puriy`
2260
2261`puriy` es el motor DOM/CSS propio con JS real (QuickJS-NG sobre `wasmi`) cuyo límite declarado no es
2262el lenguaje sino **el wiring nativo**: DOM bindings completos, red, render. Eso no se acelera
2263escribiendo otro navegador; se acelera con tiempo.
2264
2265`atuq` no le compite porque no comparte una sola línea con él, y **le sirve** porque todo lo del §6
2266vive del lado Rust: el host del §7, el testigo de `sct`, las descargas al CAS, el archivo con RAG.
2267Son **agnósticos del motor**. Cuando `puriy` madure, se enchufan del mismo lado sin reescribirse.
2268
2269Dicho al revés, que es como conviene recordarlo: **`atuq` es el navegador que se puede usar mientras
2270`puriy` crece, y el andamio de las features que `puriy` va a heredar.** Si dentro de dos años `atuq`
2271se 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 inyecta | Mecanismo de Firefox | Sin recompilar |
| Marca, iconos, nombre | distribution/, application.ini | ✅ |
| Prefs de fábrica | defaults/pref/*.js + autoconfig (.cfg) | ✅ |
| Política de empresa | distribution/policies.json | ✅ |
| Chrome propio (dientes, layout) | userChrome.css + re-empaque de omni.ja | ✅ |
| Extensiones nuestras | distribution/extensions/ | ✅ |
Ventajas que esto tiene sobre el método de Zen, y que son estructurales:
- Iteración en segundos, no en horas. El chrome se toca sin volver a compilar C++.
- No mueve el
ArtifactHash de firefox. El corpus no se invalida; waterfox y cualquier otro
fork siguen compartiendo el mismo artefacto base.
- 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:
omni.ja es un zip ⇒ hay que re-empacarlo determinista (mtimes fijos, orden estable) o el
artefacto deja de reproducir bit a bit. Es la misma disciplina que ya exige la imagen del lab.
- El
jarlog del PGO ordena el omni.ja para el arranque (§3.bis). Re-empacarlo a lo bruto
tira esa optimización a la basura sin que nadie lo note. El re-empaque preserva el orden.
- Hay que invalidar el startup cache tras tocarlo (fichero
.purgecaches junto al binario, o
bump del BuildID). Si no, los cambios no se ven y parece que el overlay no agarró: un falso
negativo de los caros, de la misma familia que el cache-hit que congela regresiones.
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:
--enable-lto=cross quiere clang + lld. El LTO de GCC sobre Gecko no está soportado upstream.
--enable-profile-use espera -fprofile-instr-use (clang), no -fprofile-use (gcc).
- BOLT necesita
llvm-bolt.
Y el corpus no tiene clang usable como compilador. Verificado en las recetas, no supuesto:
recipes/clang18.toml compila ninja -C build libclang.so clang-resource-headers y su fase
install copia sólo build/lib/libclang.so* y las cabeceras clang-c. Está ahí porque
bindgen carga libclang.so en runtime. No hay driver clang, ni lld, ni libc++.
llvm18 se selló con -DLLVM_ENABLE_PROJECTS="" — LLVM a secas.
⇒ 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:
.dev-fs/alpine ya trae clang22 + llvm22 22.1.8 — la misma major que usa el APKBUILD de
Alpine para este mismo Firefox (_llvmver=22).
Compiler::Clang ya existe en takana, cableado de punta a punta (parse_compiler lo acepta;
takana-build pone CC=clang, CXX=clang++, AR=llvm-ar). Ninguna receta lo usaba.
- Faltaba sólo
ld.lld: apk add lld ⇒ dos paquetes, cero upgrades.
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:
- El PGO de Mozilla corre el navegador para juntar el perfil (
profileserver.py, headless +
marionette). Dentro de un sandbox hermético, sin red y sin X11, eso hay que hacerlo andar.
Headless no necesita display, pero sí necesita que el árbol de perfil se genere adentro.
- El perfil resultante es un input del artefacto. O entra en
hash_inputs, o atuq deja de ser
determinista y no lo vamos a notar: sería otra forma del lab que no está en hash_inputs.
- Ganancia esperable, para calibrar expectativas: PGO+LTO en Gecko son del orden de 10-25% en carga
de página y JS. Hoy estamos ATRÁS de Zen en este eje, no adelante — Zen hereda la
configuración de Mozilla tal cual. Esto no es una ventaja nuestra: es una deuda que se salda.
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.toml | Alpine community/firefox |
| LTO | no | --enable-lto=cross |
| PGO | no | --enable-profile-use=cross + merged.profdata + jarlog |
| Linker | GNU ld (via gcc) | --enable-linker=lld (lld 22) |
| RELR | no | --enable-packed-relative-relocs |
| Sandbox RLBox | APAGADO (--without-wasm-sandboxed-libraries) | --with-wasi-sysroot |
| Extensiones sin firmar | no declarado | --with-unsigned-addon-scopes=app,system |
| Allocator | --disable-jemalloc | --disable-jemalloc — igual, no es desviación nuestra |
| Librerías | todo 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:
- 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.
--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.
- 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:
-
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.
-
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.
-
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ágina | sin PGO | con PGO | |
| DOM+layout+strings, fuera del corpus de entrenamiento | 23.540 ms | 21.327 ms | −9,4 % |
SunSpider 3d-raytrace, dentro del corpus | 16.034 ms | 15.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 PGO | PGO v1 (36 pág.) | PGO v2 (46 pág.) |
| DOM/maquetación, fuera del corpus | 23.285 ms | 21.050 ms (−9,6 %) | 20.640 ms (−11,4 %) |
SunSpider 3d-raytrace | 16.003 ms | 15.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 PGO | v1 (36 pág.) | v2 (46 pág.) | v3 (= v2 + jarlog) |
| DOM/maquetación | 23.008 ms | 20.851 (−9,4 %) | 20.527 (−10,8 %) | 20.502 (−10,9 %) |
| SunSpider | 16.006 ms | 15.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:
| carga | total sin PGO | total con PGO | trabajo sin → con | mejora del TRABAJO |
vacia.html (arranque puro) | 16.026 ms | 15.883 ms | — (ES la línea base) | −0,9 % |
| DOM/maquetación | 23.307 ms | 20.627 ms | 7.281 → 4.744 ms | −34,8 % |
| carga ajena (canvas/JSON/img) | 20.576 ms | 20.517 ms | 4.550 → 4.634 ms | +1,8 % (nada) |
SunSpider 3d-raytrace | 15.992 ms | 15.877 ms | −34 → −6 ms | bajo 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:
-
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.
-
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.
-
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.
-
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:
-
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.
-
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.
-
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:
| receta | sha256 del árbol | |
wasi-libc-headers | 0d34f9cb27530606… | ✓ |
wasi-compiler-rt | 0c4fc6fdf08ef908… | ✓ |
wasi-libc | c13acdf896f2b78c… | ✓ |
wasi-libcxx | b1d9aeec08499063… | ✓ |
wasi-sdk | b49f7a74a5caed3f… | ✓ |
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:
| Dato | Valor |
| Recetas / nodos | 838 / 840 |
| Selladas | 837 |
firefox 154.0 | sealed, cola corpus |
waterfox 6.7.1.1 | never — primer build en curso |
| Plataforma compartida | gtk3, 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 tiene | Pieza que ya existe | Costo |
| 6.1 | sct — transparencia de scripts | nadie | puriy-sct, puriy-sct-testigo | medio |
| 6.2 | Descargas direccionadas por contenido | nadie | store CAS de takana, tejido | bajo |
| 6.3 | Archivo personal + RAG local | Rewind/Recall (nube, Windows); SingleFile (guarda, no busca) | khipu, rag-motor, willay-rag | medio |
| 6.4 | Historial y perfil sobre qullqa | nadie | qullqa-core, qullqa-pozo | medio |
| 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 |
| 6.6 | Medios por fuera del navegador | extensiones sueltas; de fábrica no | foreign-ytdlp, -platform, -dlna | bajo |
| 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 |
| 6.8 | Proxy por contenedor ✅ v0.5 | nadie de fábrica | — (API de Firefox) | bajo |
| 6.9 | Torrent adentro | Vivaldi lo trae; Brave tuvo WebTorrent | shared/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.
- v1 — HECHA 2026-09-10, y con una corrección de este propio párrafo. La extensión intercepta el
cuerpo de cada
<script src> con filterResponseData y se lo pasa al host (§7), que hashea y
decide. Decía «consulta al testigo antes de dejarla pasar», y las dos mitades había que ajustarlas:
el cable del testigo es un POST con postcard, así que un JS no puede ser su cliente (§7.quater);
y v1 observa y avisa, no bloquea — que es lo que puriy-sct dice de su propia v1, y además
bloquear cada script contra un viaje entre procesos no es «más seguro», es un navegador que nadie
usa. Medida de punta a punta por scripts/test-atuq-sct.py, con servidor HTTP real, seis cargas de
página y dos controles.
- v2, forma fuerte: gancho en el script loader de Gecko. Eso sí es parche de árbol ⇒ §2.bis ⇒
después de la toolchain del §3. Es también lo único que ve los scripts inline —
filterResponseData entrega el cuerpo de una PETICIÓN, y un inline viaja dentro del HTML— y lo único
que puede negarse a ejecutar.
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.ja | se habría escrito la extensión contra una API que el build no tiene |
Los inline no se ven por esta vía | se habría prometido cobertura total teniendo la mitad |
Una carga puede producir dos peticiones del mismo documento, y una llega con tabId = -1 | agrupando 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:
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.
- ⚠ 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.
- 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:
| mitad | estado |
| 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:
| pieza | dónde | por qué ahí |
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 |
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 |
| 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 |
| cuándo se embebe y qué se pregunta | puriy-costura | es lo nuestro |
Tres decisiones con su porqué:
- los vectores se calculan al PREGUNTAR, no al archivar. Archivar una página no puede quedarse
esperando a que cargue un modelo, ni fallar porque la imagen no lo traiga. El precio es que la
primera pregunta paga el atraso, y por eso la respuesta dice cuántas quedaron indexadas;
- es un SEGUNDO
llama-server (--embedding --pooling mean): llama.cpp carga un modelo por
proceso y el de chat no sabe embeber. Ese --pooling no es un gusto — sin él el endpoint contesta
un 400 que parece del cliente;
- sin umbral de puntaje. Los cosenos de e5 dan ~0,9 entre frases sin relación: sus vectores viven
en un cono estrecho, así que cualquier umbral «razonable» deja pasar todo o corta todo. Lo que
significa algo es el orden, y de eso se ocupa el top-K.
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:
--trust | resultado |
./trust | release: trusted (by release) — 94 paquete(s) |
| un directorio vacío | release: 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:
- 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;
- 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;
- 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—:
- ¿el batching? No: los puntajes son idénticos pidiendo los pasajes juntos o de a uno;
- ¿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);
- ¿nuestro código? No: los mismos textos a mano contra el servidor dan lo mismo;
- ¿la cuantización nuestra? No: el fp32 sin tocar falla igual;
- ¿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;
- 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:
- 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.
- 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.
- 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):
fuse_mount_pure intenta primero fuse_mount_sys —el mount(2) directo sobre /dev/fuse—,
que funciona como root y no necesita ningún helper. Sólo si eso no se permite cae a
fusermount3;
- desmontar hace
umount2(MNT_DETACH) directo, con el helper también como respaldo;
- la excepción es
MountOption::AutoUnmount: esa opción fuerza el camino del helper, siempre.
⇒ 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:
- 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;
/sbin fuera del PATH del chroot ⇒ no había ip, el loopback quedaba caído y todo fallaba;
- 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:
- quién pone a
atuq en un cgroup. Hoy nadie: los cgroups de la distro los crea arje para sus
servicios, y una app de escritorio la lanza el usuario. Hace falta delegación de un subárbol a la
sesión, que es decisión de arje (SDD 10), no de este documento;
- quién aplica la política.
cortafuegos apply pide root. Un demonio que lo haga a pedido del
navegador es superficie nueva: se decide, no se agrega de paso.
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:
| pieza | por qué no corre un modelo acá |
pluma-llm | sus backends son anthropic, cohere, claude-cli y mock. Todos de nube o de juguete |
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 |
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) | 0 | 42.356 |
vfmadd (FMA) | 0 | 1.298 |
roundps (SSE4.2) | 0 | 86 |
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:
/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.
--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:
- el modelo, que es fuente pineada por sha256, no receta — el mismo patrón que el perfil de
PGO de firefox. Son dos decisiones distintas y las dos cuestan disco en la imagen: uno de
embeddings para el §6.3 (chico, ~100 MB) y uno de chat para el §6.7.
stories260K es un modelo
REAL pero de juguete: sirve para ejercitar la cadena, no para contestar;
- los verbos en el host (
puriy-costura): un ai.ask y un archive.search semántico que hablen
con llama-server. Hoy archive.search es LITERAL y por eso se llama search;
- quién levanta el servidor. Misma pregunta que dejó abierta el torrent del §6.9, y ahí ya hay
respuesta escrita: un daemon propio, perezoso, con
setsid para que sobreviva al navegador.
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:
- 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;
- 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;
- 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:
| Pieza | Dónde | Qué aporta |
privacy.userContext.enabled | atuq.cfg | prende los contenedores, que Firefox trae apagados |
Containers.Default | distribution/policies.json | los CREA — es el único mecanismo que los pone en un perfil NUEVO |
3rdparty.Extensions | distribution/policies.json | la config de fábrica, que la extensión lee como storage.managed |
proxy.onRequest | extensions/proxy/fondo.js | lo ú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:
- 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.
- 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.
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ón | Prueba |
| 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 |
| Las dos extensiones se INSTALAN | extensions.json del perfil nombra inicio@atuq.tawasuyu y proxy@atuq.tawasuyu |
| 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 |
| 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 deben | scripts/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 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 |
⚠ 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:
- 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.
- 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.
- 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:
- 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.
- 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 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 |
| 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 |
⚠ 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):
- 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;
- 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:
- el guión de adentro despide al daemon con
puriy-costura-torrent stop — el propio verbo del
producto, así que de paso se ejerce;
- 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 pierde | Estado |
| 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 |
libGL | decisión: la distro es Wayland-only sin GLX y Firefox va por EGL |
libcurl | decisión: sólo lo usa pingsender, que manda telemetría — apagada |
| Kerberos, menú global, ML | fuera de alcance |
libdl/libpthread/librt/libudev.so.0/libfreebl3 | ruido: 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-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 |
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 |
libcanberra | no 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:
| corrida | qué pasa |
--headless, con la extensión de descargas | baja 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—:
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;
- imagen EFI de 12 G construida y arrancada en QEMU: COSMIC pinta panel, dock y fondo en ~2 min
(TCG, sin KVM);
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);
- 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:
- no es el sandbox de Gecko: relanzado con los cinco
MOZ_DISABLE_*_SANDBOX puestos, idéntico;
- no es que el proceso muera: sigue vivo y ejecutando el JS de las extensiones;
- no es el modelo ni la IA: pasa con
about:blank.
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 QEMU | DRM en el guest | magenta en pantalla |
-vga none | card0 | 703 766 px |
VGA=1 (como el §6.10.ter) | card0 card1 | 703 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:
| # | lanzamiento | vídeo | observación | primera pintura |
| 1 | perfil nuevo en tmpfs | -vga none | 180 s | +197 s |
| 2 | perfil nuevo en tmpfs | VGA=1 | 240 s | +197 s |
| 3 | perfil del usuario | VGA=1 | 291 s | ✗ no pintó |
| 4 | perfil del usuario | VGA=1 | 900 s | +204 s |
| 5 | perfil del usuario | VGA=1 | 900 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:
| lanzamiento | pintaron | primera pintura |
| perfil del usuario (lo que hace un usuario) | 3 de 13 | 184, 199, 204 s |
| perfil nuevo en tmpfs | 2 de 2 | 197, 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:
- 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;
- 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ó:
- 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;
- 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.
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:
| Muestra | Veredicto |
| 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»:
- 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.
- 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.
- 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:
| Mecanismo | Resultado |
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 |
policies.json → ExtensionSettings con install_url | Se lee (browser.policies.applied=true en el perfil) y falla nombrándose: ERROR_SIGNEDSTATE_REQUIRED |
xpinstall.signatures.required = false | No 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:
- 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.
- 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ón | Prueba |
El re-empaque del omni.ja es determinista | why-differs entre dos builds de atuq: 85 entradas idénticas, 0 divergen |
| La capa de configuración se aplica | testigo atuq.autoconfig.ok escrito como pref de usuario en el perfil |
| 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 |
| La base también reproduce | why-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í.
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ó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 |
| 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 |
| Con qué diagnosticar | el 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:
| escenario | qué se rompe | motor (homepageOverride) | perfil |
as-is | nada | moz-extension://…/inicio.html | inicio y proxy activas |
no-policy | policies.json sin ExtensionSettings | sigue siendo la nuestra | las dos siguen activas |
policy-only | los XPI mudados a /usr/lib/atuq/xpi-politica/, con install_url apuntando ahí | about:home | ninguna 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:
- 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.
- 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.
| Pieza | Dónde | Por qué ahí |
puriy-costura | 00_unanchay/puriy/ | regla 1: subcrate del dominio, no proliferación lateral. Precedentes: puriy-sct-testigo, rimay/verbo-daemon, watuy-daemon |
foreign-webext | shared/ | regla 4: el protocolo es AJENO. Lib pura sin binario, como los otros 37 puentes (medido: ninguno lleva [[bin]]) |
recipes/puriy-costura.toml | takana | el 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:
- 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.
- 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>.
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.
| # | Unidad | Puerta que abre | Bloqueada por |
| 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 | — |
| 2 | llvm-toolchain → apk add lld + compiler="clang" ✅ 2026-09-05 | LTO y el muro 3 | — |
| 3 | Construir el firefox con LTO (hash b3:6f2a3b2f) | el eje de velocidad y las extensiones de atuq | 2 ✅ |
| 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 ✅ |
| 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 ✅ |
| 4 | atuq: receta derivada + overlay de chrome ✅ v0.1 escrita | el andamio de todo lo demás | 3 |
| 4.b | Branding + re-empaque de omni.ja ✅ v0.2 | el chrome de verdad | — |
| 4.d | Abrirlo en pantalla — destapó EXDEV, el lanzador, el atk envenenado y la cadena GTK3 estática | «usable», no sólo «sellado» | — |
| 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 |
| 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 | — |
| 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 | — |
| 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 | — |
| 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 | — |
| 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 ✅ |
| 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 ✅ |
| 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 ✅ |
| 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 ✅ |
| 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 ✅ |
| 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 ✅ |
| 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 ✅ |
| 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 | — |
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.