tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ 78f1c6a77153a4915281622f6ead2fa2d706aea0 docs/26-atuq-envoltorio-gecko.md Markdown · 1444 siq'ikuna · 103.4 KB Ñawpa willay ch'uya

atuq: re-pin del daemon con el arreglo del inmortal, y la lección en el SDD 78f1c6a77 · Sergio ·

1# SDD 26 — `atuq`: el envoltorio Gecko de la distro
2
3Escrito 2026-09-05, a partir de la pregunta del usuario: *«ya habiendo compilado firefox y waterfox,
4qué tan complicado vs provechoso suena crear nuestro propio envoltorio, en vez de zen?»*, y de su
5aclaración de por qué la pregunta existe: **`puriy` quedó muy verde y es difícil sacarlo de ahí**.
6
7`atuq` es *zorro* en quechua. El nombre estaba libre —verificado con `grep -ri atuq` sobre
8`/mnt/vvv/takana` y `/mnt/vvv/tawasuyu`, cero coincidencias— y dice exactamente lo que la cosa es:
9otro fox, con marca propia y sin usar la de Mozilla (ver el §branding de `recipes/firefox.toml`).
10
11**Este documento no reemplaza a `puriy` ni lo cancela.** `puriy` sigue siendo el motor soberano; el
12§9 explica por qué `atuq` es su andamio y no su desvío.
13
14---
15
16## 1. La corrección que ordena todo: no hay envoltorio fuera del chrome
17
18La intuición natural —una carcasa Llimphi con nuestros widgets, y Gecko adentro pintando la página—
19**no es posible**. Gecko no tiene API de embebido en escritorio desde que murió XULRunner; GeckoView
20existe y es de Android. No hay forma soportada de meter el motor en una ventana de `mirada`.
21
22⇒ un envoltorio de Gecko es, necesariamente, **chrome-level**: JS/CSS/XHTML *dentro* del propio
23Firefox, más extensiones, más procesos externos que hablen con él.
24
25Y eso es exactamente lo que Zen es. Zen no toca el motor: su diferencia entera —workspaces, split
26view, glance, compact mode— vive en el chrome, más una herramienta (`surfer`) que reaplica sus
27parches sobre el árbol de Firefox y recompila. **Su costo no es el motor: es la cinta de correr del
28rebase cada cuatro semanas.** Copiar su método sería heredar su costo sin heredar su equipo.
29
30## 2. La decisión de arquitectura: artefacto DERIVADO, no fork de fuente
31
32Si `atuq` parchea el árbol, **cada iteración de UI cuesta un build de Gecko de cuatro horas**. Eso
33mata el proyecto en la primera semana de diseño, porque el diseño de chrome es iterativo por
34naturaleza.
35
36Pero nosotros no distribuimos un binario: sellamos artefactos. Entonces `atuq` es una receta cuyo
37`[deps]` incluye `firefox`, y cuyas fases copian el árbol instalado e inyectan encima:
38
39| Qué se inyecta | Mecanismo de Firefox | Sin recompilar |
40|---|---|---|
41| Marca, iconos, nombre | `distribution/`, `application.ini` | ✅ |
42| Prefs de fábrica | `defaults/pref/*.js` + autoconfig (`.cfg`) | ✅ |
43| Política de empresa | `distribution/policies.json` | ✅ |
44| Chrome propio (dientes, layout) | `userChrome.css` + re-empaque de `omni.ja` | ✅ |
45| Extensiones nuestras | `distribution/extensions/` | ✅ |
46
47**Ventajas que esto tiene sobre el método de Zen, y que son estructurales:**
48
491. **Iteración en segundos, no en horas.** El chrome se toca sin volver a compilar C++.
502. **No mueve el `ArtifactHash` de `firefox`.** El corpus no se invalida; `waterfox` y cualquier otro
51   fork siguen compartiendo el mismo artefacto base.
523. **Las CVE se heredan gratis.** Un fork de fuente te vuelve dueño del reloj de seguridad del
53   binario más atacado de la máquina — es lo que hundió a casi todos los forks de Firefox, y el
54   reproche histórico a Waterfox. Un derivado se reconstruye solo cuando sube `firefox`.
55
56**Los dos gotchas reales del camino, que son trabajo pero chico:**
57
58- **`omni.ja` es un zip ⇒ hay que re-empacarlo determinista** (mtimes fijos, orden estable) o el
59  artefacto deja de reproducir bit a bit. Es la misma disciplina que ya exige la imagen del lab.
60- **El `jarlog` del PGO ordena el `omni.ja` para el arranque** (§3.bis). Re-empacarlo a lo bruto
61  tira esa optimización a la basura sin que nadie lo note. El re-empaque preserva el orden.
62- **Hay que invalidar el startup cache** tras tocarlo (fichero `.purgecaches` junto al binario, o
63  bump del BuildID). Si no, los cambios no se ven y **parece que el overlay no agarró**: un falso
64  negativo de los caros, de la misma familia que el cache-hit que congela regresiones.
65
66### 2.bis Cuándo SÍ hay que parchear el árbol
67
68Cuando algo necesite tocar C++ del motor. Hoy conocemos un caso: `sct` en su forma fuerte (§6.1).
69La regla es: **nada baja al árbol antes de que exista la toolchain del §3**, porque hasta entonces
70cada intento cuesta cuatro horas. Y cuando baje, el vigía de parches (`scripts/vigia-parches.py`,
71commit `aa200a4`) es lo que convierte esas cuatro horas en un minuto para saber si el parche agarra.
72
73## 3. La puerta de las optimizaciones: no es un flag, es una toolchain
74
75El usuario decidió habilitar las optimizaciones. Lo que hay que saber antes de intentarlo:
76
77`recipes/firefox.toml` va con `compiler = "gcc"` por el **muro 3** (zig enlaza `libc++` estática para
78musl y el `configure` de Mozilla exige encontrar un `NEEDED …libc++`; es negativa de upstream, no una
79perilla). El problema es que **PGO, LTO y BOLT son cadena de clang en Gecko**:
80
81- `--enable-lto=cross` quiere clang + lld. El LTO de GCC sobre Gecko no está soportado upstream.
82- `--enable-profile-use` espera `-fprofile-instr-use` (clang), no `-fprofile-use` (gcc).
83- BOLT necesita `llvm-bolt`.
84
85Y **el corpus no tiene clang usable como compilador**. Verificado en las recetas, no supuesto:
86
87- `recipes/clang18.toml` compila `ninja -C build libclang.so clang-resource-headers` y su fase
88  `install` copia **sólo** `build/lib/libclang.so*` y las cabeceras `clang-c`. Está ahí porque
89  `bindgen` carga `libclang.so` en runtime. **No hay driver `clang`, ni `lld`, ni `libc++`.**
90- `llvm18` se selló con `-DLLVM_ENABLE_PROJECTS=""` — LLVM a secas.
91
92⇒ la primera versión de este documento concluyó que hacía falta **una receta `llvm-toolchain`
93(clang+lld+libc++ desde fuente)**. **Era caro de más, y se corrigió el mismo día mirando el lab en
94vez de suponerlo:**
95
96- `.dev-fs/alpine` **ya trae clang22 + llvm22 22.1.8** — la misma major que usa el APKBUILD de
97  Alpine para este mismo Firefox (`_llvmver=22`).
98- **`Compiler::Clang` ya existe en takana**, cableado de punta a punta (`parse_compiler` lo acepta;
99  `takana-build` pone `CC=clang`, `CXX=clang++`, `AR=llvm-ar`). Ninguna receta lo usaba.
100- Faltaba **sólo `ld.lld`**: `apk add lld` ⇒ dos paquetes, cero upgrades.
101
102Y los tres muros caen sin perder lo que gcc daba: el sondeo de linker se satisface con
103`--enable-linker=lld`, el `ar` lo pone takana solo, y el `NEEDED` de la stdlib de C++ existe porque
104clang++ de Alpine usa la `libstdc++` **compartida** — la prueba no es teórica: Alpine construye este
105Firefox con clang22 y **sin** `libcxx` en sus makedepends.
106
107**La huella del lab no se movió, y se midió antes de tocar nada.** `lld` no casa ningún prefijo de
108`TOOLCHAIN_PREFIXES` (`takana-core/src/lab.rs`), así que los 43 paquetes que entran en `hash_inputs`
109salieron idénticos ⇒ **los 837 artefactos sellados quedan intactos**. Eso abarata el cambio hoy y a
110la vez deja un agujero escrito: la versión de `lld` no es parte de la identidad del artefacto, y sólo
111expone a las recetas `compiler="clang"` — hoy, una. Cerrarlo cuesta re-hashear el corpus entero.
112
113**Hecho el 2026-09-05** (commit `cb3ecd5`): `firefox` va con `compiler = "clang"`,
114`--enable-linker=lld`, `--enable-lto=cross`, `--enable-packed-relative-relocs` y
115`--with-unsigned-addon-scopes=app,system`. Falta construirlo.
116
117**Riesgos de PGO, escritos antes de empezar:**
118
119- El PGO de Mozilla **corre el navegador** para juntar el perfil (`profileserver.py`, headless +
120  marionette). Dentro de un sandbox hermético, sin red y sin X11, eso hay que hacerlo andar.
121  Headless no necesita display, pero sí necesita que el árbol de perfil se genere adentro.
122- **El perfil resultante es un input del artefacto.** O entra en `hash_inputs`, o `atuq` deja de ser
123  determinista y no lo vamos a notar: sería otra forma del lab que no está en `hash_inputs`.
124- Ganancia esperable, para calibrar expectativas: PGO+LTO en Gecko son del orden de 10-25% en carga
125  de página y JS. **Hoy estamos ATRÁS de Zen en este eje, no adelante** — Zen hereda la
126  configuración de Mozilla tal cual. Esto no es una ventaja nuestra: es una deuda que se salda.
127
128### 3.bis El diff contra Alpine — y no es sólo PGO
129
130Medido el 2026-09-05 trayendo el `APKBUILD` y el `mozconfig` de `community/firefox` de aports y el
131`PKGBUILD` de Arch, y comparándolos línea a línea con el mozconfig de `recipes/firefox.toml`.
132**Alpine es nuestro propio upstream** —de ahí salen los once parches de musl— así que la comparación
133no es contra una distro lejana: es contra la que ya construye este mismo código.
134
135| | `recipes/firefox.toml` | Alpine `community/firefox` |
136|---|---|---|
137| LTO | **no** | `--enable-lto=cross` |
138| PGO | **no** | `--enable-profile-use=cross` + `merged.profdata` + `jarlog` |
139| Linker | GNU ld (via gcc) | `--enable-linker=lld` (lld 22) |
140| RELR | **no** | `--enable-packed-relative-relocs` |
141| Sandbox RLBox | **APAGADO** (`--without-wasm-sandboxed-libraries`) | `--with-wasi-sysroot` |
142| Extensiones sin firmar | **no declarado** | `--with-unsigned-addon-scopes=app,system` |
143| Allocator | `--disable-jemalloc` | `--disable-jemalloc` — **igual, no es desviación nuestra** |
144| Librerías | todo bundleado | `--with-system-{icu,nss,nspr,av1,vpx,webp,png,jpeg,zlib,ffi,libevent,pixman,pipewire}` |
145
146Arch hace lo mismo en el eje de velocidad («Do 3-tier PGO», `--enable-lto=cross`,
147`--enable-profile-use=cross`). **Ni Alpine ni Arch usan BOLT** (cero menciones en los dos ficheros)
148⇒ BOLT no es la desventaja, es el extra. Lo que nos separa del resto del mundo es **PGO + LTO**, del
149orden de 10-20% en carga de página y JS.
150
151**Tres lecturas que hay que sacar de esa tabla:**
152
1531. **RLBox apagado es una desventaja de SEGURIDAD, no de velocidad, y probablemente pesa más que el
154   PGO.** Es la jaula wasm alrededor de los parsers de fuentes y medios —graphite, ogg, expat,
155   woff2—, o sea exactamente el código que come entrada no confiable. Encenderlo pide `wasi-sdk` +
156   `wasi-compiler-rt` en el corpus: receta propia, no un flag.
1572. **`--with-unsigned-addon-scopes=app,system` nos falta y `atuq` lo NECESITA** para shipear sus
158   propias extensiones desde `distribution/extensions/` (§6.1, §7). Es un flag de `configure` ⇒
159   **una capacidad de `atuq` que no se resuelve en el overlay: exige tocar la base.**
1603. **Las librerías del sistema son diferencia a propósito, no deuda.** Alpine usa las suyas; nuestro
161   corpus *es* la fuente y bundlear es lo que mantiene la clausura cerrada.
162
163**Corolario que ordena el plan: todo esto entra en UN SOLO rebuild de `firefox`.** Cada rebuild son
164cuatro horas y re-sella la cola Gecko entera (waterfox incluido), así que LTO, PGO, RELR, lld, RLBox
165y el scope de addons se juntan en una sola pasada, no en seis.
166
167### 3.ter Los dos muros del PGO que las distros no tienen
168
169**El perfil se junta corriendo el navegador, y las dos distros lo hacen bajo `xvfb-run`** (Alpine:
170`xvfb-run -a -s "-screen 0 1920x1080x24" ./mach python build/pgo/profileserver.py`; Arch, idéntico).
171**Nosotros no tenemos X11 en el corpus** —es la decisión Wayland-only de toda la distro— así que ese
172camino no existe acá. La salida takana-nativa es correrlo bajo un **sway headless**
173(`WLR_BACKENDS=headless`), que ya tenemos del frente wlr/sway y arranca en segundos.
174
175**✅ EL MURO DEL DISPLAY ESTÁ DESPEJADO (2026-09-06).** No es una previsión: se corrió. `sway`
176headless con `WLR_BACKENDS=headless` y `WLR_RENDERER=pixman` arranca en **gioser**, que es un LXC
177**sin `/dev/dri`** —o sea sin GPU ninguna, todo software— y dibuja píxeles de verdad:
178`docs/evidencia/sway-headless-gioser-sin-gpu-2026-09-06.png` (sway 1.10 / wlroots 0.18.2 /
179foot 1.27.0, los tres binarios nuestros, salida `HEADLESS-1` a 1280x720). El ciclo entero es de
180~2 min con `scripts/wlr/sway-headless.sh`.
181
182Dos cosas que hubo que resolver y conviene tener escritas antes de repetirlo:
183· **Hidratar bajo el montaje del store.** `hydrate-profile.py` ENLAZA, y un hardlink no cruza
184  montajes: con `--into work/...` sale `EXDEV` porque el store vive en otro disco. Con
185  `--into store/.rootfs/sway` son 193/193 nodos, 41.677 ficheros y **4,9 G que no ocupan** (son
186  hardlinks al store).
187· **Inyectar el loader de musl.** `sway` salió DINÁMICO y pide `/lib/ld-musl-x86_64.so.1`, que el
188  cierre no trae; el error es `exec: /usr/bin/sway: not found`, que se lee como «falta el binario»
189  y lo que falta es su intérprete. Se resuelve con
190  `--ro-bind /usr/lib/musl/lib/libc.so /lib/ld-musl-x86_64.so.1` (en musl el `libc.so` ES el
191  loader). Es la misma familia que el resto de la noche: el mensaje nombra lo que buscó, no lo que
192  falta.
193
194**Y el perfil no es determinista.** Los contadores dependen del timing de la corrida, así que dos
195generaciones del profdata no dan los mismos bytes — a las distros no les importa porque no persiguen
196bit-repro; a nosotros nos rompe el invariante. La salida: **generar el perfil UNA vez y sellarlo como
197artefacto propio, consumido por hash** desde `firefox`. El build optimizado vuelve a ser determinista
198aunque su insumo no lo sea, y el perfil se regenera a propósito, no por accidente.
199
200### 3.quinquies El PGO, y los tres muros que el plan no tenía escritos (2026-09-07)
201
202El §3.ter daba dos muros: el display (X11 que no tenemos) y el no-determinismo del perfil. Los dos
203eran ciertos. Aparecieron **tres más**, y ninguno se ve sin construir:
204
2051. **`libclang_rt.profile.a` no existía.** El lab trae clang 22.1.8 pero **ninguna runtime de
206   compiler-rt** — su `lib/` del resource dir ni existe, exactamente como ya documentaba
207   `wasi-compiler-rt` para el caso wasm. El build instrumentado murió en el minuto 38:56 con
208   `ld.lld: cannot open …/libclang_rt.profile.a`. De ahí sale `recipes/compiler-rt-profile.toml`.
209
2102. **Los sandboxes de Firefox matan la corrida de perfilado.** Con ellos puestos los procesos hijo
211   mueren con **signal 11**, el que renderiza no llega a nacer, el servidor local registra **0 GET**
212   y el único `.profraw` que queda es el del padre arrancando — un perfil de nada, con todo en
213   verde. Es lo mismo que hace el `profileserver.py` de Mozilla. Sólo aplica a la corrida de
214   entrenamiento: el firefox que se distribuye lleva sus sandboxes intactos.
215
2163. **El worker no puede bajar del mirror de fuentes.** Tiene `scripts/fuentes/mirror-env.sh` desde
217   el 2026-09-01 pero no la clave del Storage Box, así que una receta pineada al mirror —y el perfil
218   es la primera— no se construye allá. Se sembró el artefacto sellado, que es content-addressed:
219   consigue lo mismo sin mover una credencial de máquina.
220
221**Cómo se verificó que el PGO llegó, y por qué NO como RLBox.** Con RLBox el artefacto delata la
222jaula (símbolos `w2c_*`, cero → 634). Acá el discriminante análogo serían las secciones
223`.text.hot`/`.text.unlikely` que clang emite al particionar por temperatura — y **no sirve**: con
224lld y ThinLTO el enlazador las fusiona, así que dan cero en el binario con PGO y sin él. La
225evidencia que sí vale está un nivel por debajo del `configure`: **189 invocaciones del compilador
226llevan `-fprofile-use`**, el configure encontró `llvm-profdata`, y no hay ni un aviso de perfil que
227no cuadre. `libxul.so` pasa de 226.078.752 a 227.802.816 bytes, consistente con inlining de caminos
228calientes. Es evidencia de build, no de artefacto, y conviene decirlo así.
229
230⚠⚠ **LEER PRIMERO: LOS PORCENTAJES DE LAS TRES TABLAS QUE SIGUEN ESTÁN DILUIDOS Y NO SON LO
231QUE PARECEN.** Miden el ciclo completo del proceso, del que **~16 s son arranque**. La corrección,
232con el control que faltaba, está más abajo en «EL CONTROL QUE FALTABA». Se dejan tal cual se
233publicaron, sin retocar, porque el error de método es el que enseña.
234
235**LA GANANCIA, MEDIDA (2026-09-07).** Mismo rootfs, mismo script, corridas INTERCALADAS A/B/A/B
236para que la deriva de carga afecte a las dos, mediana de 7, calentamiento descartado. Lo único que
237cambia entre variantes es un `--ro-bind` de `/usr/lib/firefox`.
238
239| página | sin PGO | con PGO | |
240|---|---|---|---|
241| DOM+layout+strings, **fuera** del corpus de entrenamiento | 23.540 ms | 21.327 ms | **−9,4 %** |
242| SunSpider `3d-raytrace`, **dentro** del corpus | 16.034 ms | 15.878 ms | −1,0 % |
243
244Los rangos no se solapan en ninguna de las dos (23.185–23.654 vs 21.030–21.650), así que las dos
245diferencias son reales y no ruido.
246
247⚠ **Y el resultado sale al revés de lo esperable, que es lo interesante.** La intuición dice que
248medir sobre el conjunto de ENTRENAMIENTO infla la ganancia; acá el corpus da el número MÁS BAJO. La
249razón es que `3d-raytrace` es aritmética pura en un bucle caliente, y ese bucle **no lo ejecuta el
250C++ de SpiderMonkey: lo ejecuta código máquina que el JIT genera en tiempo de ejecución**. El PGO
251optimiza el intérprete, el GC y el propio compilador JIT — no el código que el JIT emite. Un
252benchmark JIT-bound es casi ciego al PGO por construcción.
253
254Donde sí se ve es en el camino DOM/layout/arranque, que es C++ de principio a fin. Y eso es también
255lo que un usuario percibe: la medición cubre el ciclo COMPLETO del proceso (arrancar → renderizar →
256capturar → salir), no el rendimiento en régimen de una página ya cargada. De ahí que hasta la página
257de SunSpider tarde 16 s: casi todo es arranque.
258
259**Corolario para el corpus de entrenamiento — APLICADO Y MEDIDO (2026-09-08).** El corpus estaba
260sesgado hacia JS justo donde el PGO menos rinde, así que se le sumaron 10 páginas de maquetación
261propias (`scripts/pgo-corpus/`): flexbox, grid, tablas con los dos algoritmos de layout, texto en
262columnas, selectores contra 600 reglas, pintado, transforms, SVG, scroll pegajoso y layout
263thrashing. El perfil pasó de 5.885.254.799 ejecuciones registradas a **38.498.366.335 (6,5×)**.
264
265| | sin PGO | PGO v1 (36 pág.) | PGO v2 (46 pág.) |
266|---|---|---|---|
267| DOM/maquetación, fuera del corpus | 23.285 ms | 21.050 ms (−9,6 %) | **20.640 ms (−11,4 %)** |
268| SunSpider `3d-raytrace` | 16.003 ms | 15.846 ms (−1,0 %) | 15.857 ms (−0,9 %) |
269
270**La mejora en maquetación es real y no de medianas:** SEIS de las siete muestras de v1 son más
271lentas que TODAS las de v2; sólo una cae dentro del rango de v2. En SunSpider, en cambio, v1 y v2
272son **indistinguibles** —los valores se entrelazan por completo— que es justo lo esperable: ese
273camino lo ejecuta el JIT y el PGO no lo toca. Un corpus mejor no puede mejorar lo que el PGO no
274alcanza.
275
276⚠ Una de las siete corridas de v2 dio 120.029 ms contra ~20.600 de las otras seis: un atípico
277transitorio. **La mediana lo ignora por diseño**; una media lo habría convertido en «v2 es
278catastróficamente peor». Fue la razón de elegir mediana antes de ver un solo número.
279
280**EL JARLOG: PROBADO, MEDIDO, Y APARCADO PORQUE ROMPE `atuq` (2026-09-08).** Desde
281`d9833a58` el perfil trae también el `jarlog` y `firefox` lo consume con `--with-pgo-jarlog`. No
282hizo falta la extensión `Quitter` de Mozilla, que este documento daba por bloqueante: su
283`profileserver.py` sólo traduce `JARLOG_FILE` a `MOZ_JAR_LOG_FILE` en el entorno del navegador.
284
285| | sin PGO | v1 (36 pág.) | v2 (46 pág.) | v3 (= v2 + jarlog) |
286|---|---|---|---|---|
287| DOM/maquetación | 23.008 ms | 20.851 (−9,4 %) | 20.527 (−10,8 %) | 20.502 (−10,9 %) |
288| SunSpider | 16.006 ms | 15.859 (−0,9 %) | 15.869 (−0,9 %) | 15.838 (−1,0 %) |
289
290**El jarlog solo aporta −0,1 % y −0,2 %: indistinguible de cero.** Y el argumento no es que los
291rangos se solapen —que se solapan— sino que **la misma variante varía ~1 % entre corridas de días
292distintos** (v1 dio 20.851 hoy y 21.050 ayer). El suelo de ruido del banco es **diez veces** el
293efecto buscado.
294
295Eso NO es un fallo del jarlog: es que el banco corre con la **caché de página caliente**, y ahí
296reordenar `omni.ja` no ahorra ninguna lectura. Su terreno es el arranque EN FRÍO, que este
297instrumento no reproduce; medirlo pediría vaciar la caché entre corridas, que es una acción de
298sistema. **Lo que sí está probado es que la función existe**, y del lado del artefacto: `libxul` es
299IDÉNTICO byte a byte entre v2 y v3 y el `omni.ja` difiere en 26 bytes — el jarlog tocó exactamente
300lo que debía tocar y nada más. **Y sí costaba.** Al reconstruir `atuq` sobre ese firefox, murió:
301
302    zipfile.BadZipFile: Bad magic number for central directory   (rebrand.py)
303
304El jarlog convierte el `omni.ja` al **formato «jar optimizado» de Mozilla**, que mueve el directorio
305central al principio:
306
307    sin jarlog:  PK\003\004    ZIP estándar — `zipfile` lo abre, 5306 entradas
308    con jarlog:  \376\204#\0   `zipfile` lo rechaza
309
310Y `atuq` reempaqueta el `omni.ja` con `zipfile` para su branding. O sea que el jarlog **rompe el
311navegador propio de la distro**.
312
313**Aparcado, y la cuenta es asimétrica:** el coste está MEDIDO y el beneficio NO. Cambiar algo que
314funciona por una ganancia que no se pudo medir, rompiendo algo que sí funcionaba, es mal negocio.
315Se retoma cuando (1) se mida el arranque EN FRÍO, que es su terreno, y (2) `rebrand.py` sepa leer el
316jar optimizado o des-optimizarlo antes. El blob `92497cdd…` del mirror ya trae el jarlog, así que
317retomarlo es cambiar una línea y re-pinear el sha256 — no volver a perfilar.
318
319⚠ **Y la lección de método, que es la que más vale:** el jarlog parecía GRATIS. «Es lo que hace
320upstream y no cuesta nada» — lo escribí yo, en este documento, hace unas horas. El coste no apareció
321midiendo el jarlog sino **construyendo lo que dependía de él**. Una función que se declara gratuita
322sin haber reconstruido a sus consumidores no es gratuita: es no medida.
323
324⚠ **Dato que los datos delatan:** los dos valores atípicos del banco son 120.034 y 120.030 ms, o sea
325exactamente el `timeout 120` del arnés. No son ruido de carga: son corridas COLGADAS al arrancar en
326headless, 2 de 56 (~3,5 %). Sin perseguir, pero anotado.
327
328⚠ Y lo que este banco NO puede afirmar: las 10 páginas nuevas ejercitan los mismos subsistemas que
329la página de medición, así que el parentesco con lo que ahora se entrena es mayor que antes. El
330−11,4 % es real para ESTA carga; generalizarlo a «cualquier página» sería justo el error que el
331propio corpus de Mozilla cometía al revés.
332
333## EL CONTROL QUE FALTABA: el arranque eran 16 s, y sin restarlo ningún porcentaje significa nada (2026-09-08)
334
335Todo lo de arriba mide **el ciclo completo del proceso**: arrancar firefox, renderizar, capturar el
336PNG y salir. Nunca se midió cuánto de eso era la página. Faltaba el control más barato posible —una
337página VACÍA— y con él las cifras publicadas se leen distinto:
338
339| carga | total sin PGO | total con PGO | **trabajo** sin → con | mejora del TRABAJO |
340|---|---|---|---|---|
341| `vacia.html` (arranque puro) | 16.026 ms | 15.883 ms | — (ES la línea base) | **−0,9 %** |
342| DOM/maquetación | 23.307 ms | 20.627 ms | 7.281 → 4.744 ms | **−34,8 %** |
343| carga ajena (canvas/JSON/img) | 20.576 ms | 20.517 ms | 4.550 → 4.634 ms | **+1,8 % (nada)** |
344| SunSpider `3d-raytrace` | 15.992 ms | 15.877 ms | **−34 → −6 ms** | bajo el ruido |
345
346Las cuatro cargas y las dos variantes, **en una sola sesión intercalada**: restar el arranque de una
347corrida al total de otra no vale, porque la misma variante deriva ~1 % entre días.
348
349**Qué cambia, en concreto:**
350
3511. **El «−10,9 %» que publiqué es correcto como número y engañoso como afirmación.** Es real para el
352   ciclo completo, que es lo que un usuario espera al abrir el navegador. Pero se leía como
353   «firefox es un 11 % más rápido», y sobre el trabajo de la página la mejora es **−34,8 %**: el
354   PGO rinde TRES VECES más de lo que dije. El error subestimaba mi propio resultado.
355
3562. **La explicación que di del SunSpider era aire.** Escribí que el −1,0 % se explicaba porque un
357   benchmark JIT-bound es ciego al PGO. Mirá la fila: su trabajo mide **−34 ms**, negativo — la
358   página con el benchmark tardó MENOS que la vacía. `3d-raytrace` corre en decenas de ms y su
359   trabajo es más chico que la variación entre corridas. **Ahí no había nada que medir.** El −1,0 %
360   era el −0,9 % del arranque, y yo le colgué encima una teoría sobre el JIT.
361
362   La teoría puede ser cierta —el mecanismo es real y está bien descrito— pero **esta medición nunca
363   la probó**, y la presenté como si sí. Un número real más un mecanismo real no hacen una
364   explicación: hace falta que el número mida el mecanismo.
365
3663. **La carga ajena confirma que el PGO no es magia.** +1,8 % sobre su trabajo, o sea cero: mejora
367   lo que se le entrenó (maquetación, −34,8 %) y no lo que no (canvas/JSON, 0 %). Eso es exactamente
368   lo que un PGO honesto debe hacer, y es mejor evidencia de que funciona que el número grande.
369
3704. **El jarlog no se rehabilita.** Su −0,1 % diluido pasa a ~−0,5 % sin diluir; sigue muy por debajo
371   del ~1 % que la misma variante deriva entre días. La conclusión de aparcarlo no se mueve.
372
373⚠ **La lección, que es la misma de toda esta jornada con otro disfraz.** El instrumento funcionaba,
374las medianas eran correctas, las corridas estaban intercaladas, los rangos no se solapaban — todo el
375rigor estadístico estaba puesto **sobre una cantidad que no era la que yo creía estar midiendo**.
376Ninguna cantidad de repeticiones lo habría revelado: sólo lo revela un control, y el control costó
377siete corridas de una página vacía. **Antes de refinar la precisión de un número, hay que gastar una
378medición en averiguar qué mide.**
379
380Y el detalle que lo vuelve barato de recordar: el control salió NEGATIVO en una fila. Un valor
381imposible es la forma más limpia que tiene una medición de avisar que el modelo está mal, y por eso
382esa fila se deja publicada con su signo menos en lugar de redondearla a cero.
383
384**Y la pregunta que un insumo no determinista obliga a hacer, respondida: `firefox` SIGUE
385REPRODUCIENDO bit a bit** (`verificar-repro.sh`: REPRODUCEN 1, DERIVA 0, NO-DETERMINISMO 0). Era el
386riesgo real de esta unidad, no el rendimiento: si el `-fprofile-use` hubiera metido cualquier
387decisión dependiente del orden o del timing, habríamos cambiado velocidad por el invariante del
388proyecto. Congelar el perfil como fuente pineada era el argumento; esto es la medición.
389
390### 3.quater La cadena wasm, y las tres cosas que costó (2026-09-06)
391
392`--without-wasm-sandboxed-libraries` se cambió por `--with-wasi-sysroot=/usr/share/wasi-sysroot`,
393que es literalmente el flag de Alpine. Detrás hay cinco recetas nuestras, en este orden obligatorio:
394
395```
396wasi-libc-headers → wasi-compiler-rt → wasi-libc → wasi-libcxx → wasi-sdk → firefox
397```
398
399`wasi-libc-headers` existe **sólo** para romper el ciclo del bootstrap y pinea un commit VIEJO a
400propósito (triple `wasm32-wasi`), mientras `wasi-libc` pinea el nuevo (`wasm32-wasip1`, el que
401clang 22.1 usa de verdad). Es lo mismo que hace Alpine y no es un descuido.
402
403**Lo que costó, que es la parte reusable:**
404
4051. **`check-symbols` falla por sesgo de clang, no por un artefacto malo.** El lab trae clang 22.1.8
406   y el snapshot del árbol viene de uno anterior: sobra `#define __wasip1__ 1` y nada más. Se
407   reconcilia el snapshot; **no** se salta con `make no-check-symbols`, que se lleva por delante la
408   comparación de símbolos —la mitad que sí vale y que pasaba byte a byte.
409
4102. **Faltaba `wasi-libcxx`, y el hueco era visible en el grafo sin construir nada.** El configure
411   murió a los 6 s con `'cstring' file not found`, culpando a los headers de WASI, que estaban
412   perfectos: `<cstring>` es C++ y las librerías enjauladas no son todas C. El `wasi-sdk` de Alpine
413   declara `wasi-libc wasi-libcxx wasi-compiler-rt` —TRES— y la cadena tenía dos. **El `depends`
414   del paquete ajeno equivalente es una comprobación de completitud gratis**, antes de gastar CPU.
415
4163. **Un directorio VACÍO que sí importa.** Con `wasi-libcxx` sellado el configure volvió a morir
417   igual, y `cstring` estaba en el artefacto: clang no lo buscaba. Falta el `mkdir -p
418   include/c++/v1` que Alpine tiene en su `package()` y que parece ruido de empaquetado — es la
419   sonda por la que el driver decide que el sysroot tiene layout de libc++. Medido dentro del lab:
420   sin ese directorio, `'cstring' file not found`; con él, exit 0. Es el **reverso exacto de la
421   regla 3 del `CLAUDE.md`**: allá un directorio vacío es un artefacto mentiroso, acá es una
422   declaración dirigida al compilador.
423
424De ahí sale el criterio de guardián que ahora usan estas recetas: **«existe» no es «se encuentra»**.
425`wasi-libcxx` corre hoy la misma prueba que hace el configure de firefox, contra un sysroot
426fusionado con el de su dep, así que un fallo aparece en el segundo 20 de una receta chica —con la
427lista de búsqueda de clang impresa— y no en el segundo 6 de un build de cuatro horas que además
428culpa a otro. Y `firefox` exige RLBox **en el binario**: sobre `b3:352d7880` (el último con RLBox
429apagado) los patrones `w2c_`, `rlbox` y `wasm2c` dan cero los tres en `libxul.so`, así que
430cualquiera apareciendo prueba que la jaula entró. Es la tercera repetición de la lección de
431`MOZ_REQUIRE_SIGNING` y `MOZ_BUILD_DATE`: una bandera que el configure acepta y el artefacto ignora.
432
433**Evidencia de reproducibilidad cruzada (2026-09-06).** Las cinco recetas se construyeron por
434separado en las DOS máquinas —el hub (gioser, 4c) y el worker (`dev.gioser.net`, LXC 6c)— y los
435cinco artefactos salen **byte a byte idénticos**, comparando el árbol completo de cada uno:
436
437| receta | sha256 del árbol | |
438|---|---|---|
439| `wasi-libc-headers` | `0d34f9cb27530606…` | ✓ |
440| `wasi-compiler-rt` | `0c4fc6fdf08ef908…` | ✓ |
441| `wasi-libc` | `c13acdf896f2b78c…` | ✓ |
442| `wasi-libcxx` | `b1d9aeec08499063…` | ✓ |
443| `wasi-sdk` | `b49f7a74a5caed3f…` | ✓ |
444
445No es un trámite: **el lab NO entra en `hash_inputs`**, así que dos labs distintos pueden sellar
446bytes distintos en la misma dirección del store y nada lo detecta. Esto dice que para esta cadena
447los dos labs coinciden de verdad, y no sólo que coinciden los `ArtifactHash`.
448
449## 4. Lo que `atuq` NO promete
450
451Esta sección existe antes que la lista de features a propósito, con el mismo criterio que el modelo
452de adversario de `qullqa`: **lo que no se promete se escribe primero, porque una promesa falsa de
453privacidad es peor que no ofrecer nada.**
454
455**`atuq` no es Tor Browser y no va a tener «modo Tor».** Tor Browser no es «Firefox + SOCKS»: su
456valor son las contramedidas de huella (RFP, letterboxing, normalización de fuentes/canvas/timers) y
457sobre todo **un conjunto de anonimato donde todos se ven idénticos**. `atuq` es, por construcción, un
458binario único en el mundo: musl, Wayland-only, branding propio, extensiones propias. Alguien saliendo
459por Tor desde `atuq` es **más** identificable, no menos, y su conjunto de anonimato son diez
460personas. Para anonimato: Tor Browser, y lo decimos nosotros primero.
461
462Lo que sí se ofrece, y es honesto porque es otra cosa: **proxy por contenedor** (§6.8) — separación
463de tráfico, no anonimato.
464
465**`atuq` tampoco promete** anti-fingerprinting propio (es un programa de investigación con equipo
466full-time) ni bloqueo de anuncios propio (se shipea uBO y listo).
467
468## 5. Contexto verificado al escribir (2026-09-05)
469
470De `docs/state/build-state.json`, no de memoria:
471
472| Dato | Valor |
473|---|---|
474| Recetas / nodos | 838 / 840 |
475| Selladas | 837 |
476| `firefox` 154.0 | **sealed**, cola `corpus` |
477| `waterfox` 6.7.1.1 | **never** — primer build en curso |
478| Plataforma compartida | `gtk3`, `atk`, `nodejs`, `clang18`, `cbindgen`: todas sealed |
479
480**`atuq` no arranca hasta que `waterfox` selle.** Waterfox es la prueba de la tesis del reúso —«las
481diferencias reales con `firefox.toml` son TRES: la fuente, el branding y la versión»—. Si esa tesis
482falla, el precio de todo este documento cambia y hay que releerlo.
483
484## 6. Los diferenciadores, priorizados
485
486La columna «quién más lo tiene» es lo que evita que nos contemos un cuento.
487
488| # | Qué | Quién más lo tiene | Pieza que ya existe | Costo |
489|---|---|---|---|---|
490| 6.1 | **`sct` — transparencia de scripts** | **nadie** | `puriy-sct`, `puriy-sct-testigo` | medio |
491| 6.2 | **Descargas direccionadas por contenido** | nadie | store CAS de takana, `tejido` | bajo |
492| 6.3 | **Archivo personal + RAG local** | Rewind/Recall (nube, Windows); SingleFile (guarda, no busca) | `khipu`, `rag-motor`, `willay-rag` | medio |
493| 6.4 | **Historial y perfil sobre `qullqa`** | nadie | `qullqa-core`, `qullqa-pozo` | medio |
494| 6.5 | **Foco por `cortafuegos`, no por extensión** | nadie (nadie es dueño del navegador *y* del sistema) | `cortafuegos`, `pacha` | 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** | Chrome/Edge son nube; Zen no tiene | `rimay`, `iniy` | 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, y ya tiene forma: un motor `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.4 `qullqa` con su advertencia puesta
678
679Almacenamiento con negación plausible y modelo de adversario **escrito** — ningún navegador tiene
680eso; el modo incógnito es teatro. ⚠ Con la advertencia que el propio SDD de `qullqa` ya anotó y que
681**hay que repetir acá, no esconder**: en una máquina con swap sin cifrar, el documento no aplica.
682Se promete lo que se puede probar.
683
684### 6.6 Medios fuera del navegador — HECHO (2026-09-10)
685
686Una navegación de PRIMER NIVEL a un medio (`Content-Type: video/*` o `audio/*`) se cancela y la URL se
687la lleva el reproductor de la distro. `mpv` ya viaja en las cuatro imágenes de escritorio, así que
688esto no agrega ni una receta: es cablear lo que ya estaba.
689
690**La regla es estrecha a propósito.** Un `<video>` embebido es parte de la página y sacarlo de ahí
691rompería el sitio que lo puso; lo que se cambia es el caso en que el navegador no estaba pintando una
692página sino haciendo de reproductor —seguiste un enlace a un `.mp4`—. Las reglas anchas en el camino
693de cada petición son las que terminan rompiendo la web de alguien.
694
695**La seguridad del verbo es que el binario NO viene del cable**: es una constante del host
696(`/usr/bin/mpv`). Si el programa viajara en el mensaje, esto dejaría de ser «abrir un vídeo» y sería
697un lanzador de procesos arbitrarios manejado, al final de la cadena, por una página web. Sólo viaja la
698URL, sólo `http(s)`, y detrás de un `--`.
699
700**Fail-open, y es la promesa que el control negativo prueba.** Cancelar es SÍNCRONO y la respuesta del
701host llega después, así que no se puede saber en el momento si el reproductor arrancó. Se cancela sólo
702si el puerto está vivo, y si el host contesta que no pudo, la URL **vuelve al navegador** (con una
703marca para no entrar en bucle). Medido:
704
705    SIN REPRODUCTOR no hay reproductor en /usr/bin/mpv: esta imagen no lo trae
706    VUELVE AL NAVEGADOR http://…/audio.wav?atuq-medios=no
707
708Quedarse sin vídeo Y sin pestaña es el único resultado inaceptable, y es el que se consigue si uno
709confía en que salió bien.
710
711**⚠ Y el bug que esto destapó vale más que la función.** La primera corrida hizo todo bien —detectó el
712medio, canceló, el host contestó con el pid— y después:
713
714    DESCONECTADO Native application tried to send a message of 546281442 bytes,
715    which exceeds the limit of 1048576 bytes.
716
717**Un hijo hereda los descriptores del padre, y los del padre SON la tubería de native messaging.** El
718reproductor escribía su salida ahí y el navegador la leía como un marco. Tres redirecciones, cada una
719contra un fallo distinto: `stdin` a null (`mpv` lee stdin para sus comandos: heredándolo **se come los
720mensajes del navegador**), `stdout` al **`stderr` del host** —no a `/dev/null`: apagarlo arreglaría el
721bug y se llevaría puesto el diagnóstico, y Gecko manda ese `stderr` al log del navegador, que es donde
722uno mira cuando «el vídeo no abrió»— y `stderr` heredado por lo mismo.
723
724El test que lo fija **arranca el binario, no la lib**, y ésa es la parte reutilizable: en proceso, el
725`stdout` del host es el del arnés y la contaminación no se ve — el test habría pasado con el bug
726puesto. Se comprobó rompiéndolo a propósito.
727
728⚠ Y una nota del arnés que ya apareció dos veces: la URL no se pasa por la línea de comandos, porque
729una petición del ARRANQUE compite con la inicialización de la extensión. El guardián navega a una
730página que redirige a los tres segundos — que además es lo que hace una persona: seguir un enlace.
731
732### 6.8 Proxy por contenedor — HECHO (v0.5, 2026-09-06)
733
734**Es el primer diferenciador del §6 que se paga entero**, y se pudo pagar ahora porque es el único
735de la lista que no pasa por el host del §7: es API de Firefox y nada más. Cada contenedor
736—Personal, Trabajo, Banco, Compras— puede salir por su propio proxy.
737
738Tres piezas, en tres ficheros, y **ninguna alcanza sola**:
739
740| Pieza | Dónde | Qué aporta |
741|---|---|---|
742| `privacy.userContext.enabled` | `atuq.cfg` | prende los contenedores, que Firefox trae apagados |
743| `Containers.Default` | `distribution/policies.json` | los CREA — es el único mecanismo que los pone en un perfil NUEVO |
744| `3rdparty.Extensions` | `distribution/policies.json` | la config de fábrica, que la extensión lee como `storage.managed` |
745| `proxy.onRequest` | `extensions/proxy/fondo.js` | lo único que ve el `cookieStoreId` de cada petición |
746
747**Las cuatro se comprobaron DENTRO del artefacto antes de escribir una línea**, que es la regla que
748dejó el §2.sexies: `Containers` y `3rdparty` están en el `policies-schema.json` de `browser/omni.ja`,
749`cookieStoreId` en el `schemas/proxy.json` de `omni.ja`, y `storage.managed` lee
750`Services.policies.getExtensionPolicy(id)` en `ext-storage.js`. Ninguna de las cuatro se leyó de la
751documentación de Mozilla: se leyeron de **nuestro** build, que es el que las tiene que traer.
752
753**Tres decisiones de diseño que valen más que el código:**
754
7551. **La configuración se indexa por NOMBRE de contenedor, no por `cookieStoreId`.** El id
756   (`firefox-container-3`) depende del ORDEN en que se crearon, así que la misma configuración
757   aplicada a otro perfil apuntaría a otro contenedor. Un identificador que cambia de significado
758   entre máquinas no sirve para configurar una distro.
7592. **Fail closed.** Un contenedor que TIENE proxy configurado y no se pudo honrar —entrada
760   inválida, API que falla, mapa a medio construir— **no sale directo**: va a un destino cerrado y
761   el navegador muestra el error. Salir directo sería una fuga silenciosa, y es la misma familia
762   que el artefacto vacío de la regla 3: el fallo que llega hasta el final diciendo que todo fue
763   bien. Un usuario que pidió que «Banco» no salga por su línea tiene que ver un error, no navegar.
7643. **`proxyDNS` viene PRENDIDO.** Sin él, Gecko resuelve el nombre por su cuenta antes de hablar
765   con el proxy: la consulta DNS sale justo por la línea que se quería evitar. Es la fuga clásica de
766   esta configuración, así que el default es el seguro y hay que apagarlo a mano.
767
768**Y lo que NO promete está en la propia página de opciones, arriba de todo y no en un pie**:
769separación de tráfico, no anonimato; no toca la huella del navegador; para anonimato, Tor Browser.
770Es el §4 cumplido en el sitio donde el usuario lo va a leer.
771
772#### Lo que quedó PROBADO, y con qué
773
774Corriendo el árbol de atuq en la misma jaula que usa `scripts/atuq-nested.sh`:
775
776| Afirmación | Prueba |
777|---|---|
778| 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 |
779| Las dos extensiones se INSTALAN | `extensions.json` del perfil nombra `inicio@atuq.tawasuyu` y `proxy@atuq.tawasuyu` |
780| 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 |
781| **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 |
782| 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 |
783| 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 |
784
785⚠ **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`.
786
787**Y de paso quedó comprobado que la dep SÍ se sigue**, que era la promesa del §2 y no una que convenga
788creer sin medir: en un catálogo de sonda, cambiar una bandera del `firefox` copiado mueve el hash de
789`atuq` (`f2960991` → `60ade76a`), mientras que con el `firefox` idéntico al del corpus da el mismo
790hash. O sea que un `atuq` viejo **no** puede quedarse tapando un motor nuevo — que es justo la forma
791que tendría acá el cache-hit que congela regresiones.
792
793**Cómo se cerró lo que faltaba (2026-09-06).** Durante unas horas esta sección decía que probar el
794paquete quedaba pendiente, con tres puertas cerradas. Las tres siguen cerradas y la prueba existe
795igual, porque el camino era otro.
796
797Las tres puertas, que conviene NO volver a golpear:
798
7991. **Desde la línea de comandos no hay flag** para el `userContextId` de la pestaña inicial. Y un
800   `moz-extension://` tampoco se abre así: el manejador intenta resolverlo como fichero y muere con
801   `NS_NOINTERFACE [nsIFileURL.file]`, abriendo la home en su lugar.
8022. **Marionette está en el artefacto y responde** —se le habló con un cliente propio de 60 líneas,
803   el protocolo es `<longitud>:<json>`—, pero abrir una pestaña de contenedor pide contexto chrome,
804   y eso exige `-remote-allow-system-access`; en la jaula no lo tomó ni por bandera, ni con
805   `--remote-debugging-port`, ni por `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`, que es lo que
806   `RemoteAgent.sys.mjs` lee en su constructor. Cabo suelto medido, no suposición.
8073. **Desde la extensión, `tabs.create({cookieStoreId})` exige el permiso `cookies`**
808   (`ext-tabs-base.js:getUserContextIdForCookieStoreId`). Dárselo a la extensión del proxy para que
809   pueda probarse a sí misma sería pagar con la superficie de ataque del producto una comodidad del
810   test. Sigue sin dárselo.
811
812**La cuarta puerta estaba abierta: el fichero de sesión.** `sessionstore` guarda el `userContextId`
813de cada pestaña y Gecko lo restaura, así que se fabrica a mano. El formato es `mozLz40\0` + tamaño +
814un bloque LZ4, y **un bloque de sólo literales es LZ4 válido**: veinte líneas, sin depender de
815ninguna librería. Con eso se abren dos pestañas —misma url, distinto contenedor— sin pedirle nada al
816navegador.
817
818Y la medición no le pregunta nada tampoco: **se le ponen dos oídos en la red y se mira a cuál llama.**
819El destino contesta un HTTP 200; el «proxy» no habla SOCKS, sólo anota quién lo saludó. Que al
820puerto del proxy llegue un `\x05\x01\x00` es la prueba: ese saludo sólo aparece si Gecko decidió
821hablar con un proxy para esa petición, y el único que se lo pudo indicar es nuestro
822`proxy.onRequest` mirando el `cookieStoreId`.
823
824Tres detalles sin los cuales la prueba mide un silencio y se lee como un fallo:
825`browser.sessionstore.restore_on_demand=false` (si no, las pestañas restauradas no piden nada hasta
826que alguien las mira), `network.proxy.allow_hijacking_localhost=true` (Gecko saltea el proxy para
827localhost, y la petición de «Banco» iría directa haciendo parecer culpable a la extensión), y
828`triggeringPrincipal_base64: vQ==` en cada entrada de sesión (sin principal la pestaña se restaura
829pero no navega).
830
831**Y la prueba trae su propio control negativo**, porque es lo que este repo se exige desde hoy: con
832`--negative-control` no se configura el proxy y se exige el resultado CONTRARIO —las dos pestañas
833directas, nadie llamando al proxy—. La única diferencia entre las dos corridas es una línea de
834configuración y el observable se da vuelta entero. Sin ese modo, una prueba que se hubiera vuelto
835ciega se vería idéntica a una que funciona.
836
837### 6.9 Torrent adentro — HECHO (2026-09-10), con un daemon propio, configurable y perezoso
838
839Vivaldi ya trae torrent, y termina en una carpeta. Lo que cambia acá es que **lo que baja entra al
840CAS**: un objeto BLAKE3 deduplicado y verificable, con la misma identidad que una descarga del
841navegador (§6.2) o un `.swm` de takana. Ésa, y no «tener torrent», es la razón por la que esto vale.
842
843    página de la extensión → fondo → connectNative → puriy-costura
844      → /usr/bin/puriy-costura-torrent add   (que LEVANTA el daemon si no está)
845      → daemon: librqbit, y al completarse, el CAS
846
847**Por qué un proceso aparte y no un verbo más del host — dos razones, y la primera decide:**
848
8491. **Una descarga tiene que sobrevivir al navegador.** Gecko lanza un host de native messaging **por
850   puerto** y lo mata cuando el puerto se cierra (medido este mismo día, §6.2.bis). Un cliente
851   BitTorrent dentro de `puriy-costura` dejaría de bajar al cerrar la pestaña.
8522. **El precio.** `librqbit` + tokio + rustls son **226 crates**: ese host —952 K, compartido por las
853   cinco extensiones— pasaría a ~20 MB, y cada iteración de cualquier función del navegador a un
854   cuarto de hora.
855
856⚠ **Y que esa pila COMPILA para musl con zig-cc se midió antes de decidir**, con una receta
857desechable: 226 crates y sella. Era una incógnita real —ninguna receta del corpus había construido
858tokio+rustls desde tawasuyu; las que hay dicen «deps livianas (sin tokio/libp2p/axum)»— así que la
859decisión no fue «no se puede», fue **«no ahí»**. Medir primero es lo que separa una decisión de
860arquitectura de una excusa.
861
862**Perezoso en los dos sentidos**, que es lo que pidió el operador:
863
864| | qué significa |
865|---|---|
866| 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 |
867| 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 |
868
869⚠ Se levanta con **`setsid`**: sin eso queda en el grupo de procesos del navegador y **se muere con
870él**, que es exactamente lo que este proceso existe para no hacer. Si `setsid` faltara se arranca
871igual —mejor bajar mientras el navegador esté abierto que no bajar— pero **avisando**, porque es una
872degradación silenciosa de su única promesa.
873
874**Configurable**: un TOML opcional (`descargas`, `cas`, `indice`, `sembrar_tras_bajar`,
875`inactividad_seg`, techos de velocidad). Sin fichero los defaults andan; un fichero **roto** sí es un
876error, porque si un typo se comportara como «sin configuración» el usuario buscaría el problema en
877cualquier otra parte. **El CAS por defecto es el del host**, y hay un test que falla si esas dos
878raíces se separan: es lo que hace que un torrent y una descarga del mismo contenido sean **un objeto**.
879
880**Lo que el guardián NO hace, y por qué** (`scripts/test-atuq-torrent.py`):
881
8821. **no usa un `magnet:` sino un `.torrent` servido por HTTP** — dar de alta un magnet BLOQUEA
883   esperando la metadata del enjambre, y sin peers eso no llega nunca: se mediría un timeout y no la
884   cadena. Con un `.torrent` la metadata viene en el fichero y el alta termina. El `.torrent` se arma
885   en el propio guardián, en bencode a mano: un binario de prueba en el repo es una dependencia que
886   nadie revisa;
8872. **no pasa por el despacho de protocolos de Gecko** — un clic en un `magnet:` abre el diálogo de
888   «¿con qué lo abro?», que en headless no contesta nadie, y eso es una elección del usuario y no
889   código nuestro. Se abre la página de la extensión, que es la que el handler abriría, descubriendo
890   su URL base del `dump` de una primera corrida (el UUID lo asigna Gecko por perfil).
891
892Y el control negativo prueba lo que importa: **sin el cliente del daemon, el host falla diciéndolo y
893nombrando lo que falta** — no finge haberlo tomado.
894
895⚠ **Y el bug que encontró MIRAR LA MÁQUINA, no un test.** Después de las pruebas, `ps` mostraba un
896daemon con **23 minutos de vida** y `idle_seconds = 300`. El bucle hacía
897`let Ok(g) = gestor() else { continue }`: si la sesión de torrent no se podía crear, el `continue`
898saltaba la evaluación de «¿sobro?» y el daemon quedaba **vivo para siempre** — lo contrario de
899perezoso, que es su única promesa. Los tests cubrían la decisión como función PURA, y esa función
900estaba bien: **el que no llegaba a llamarla era el bucle**. Una decisión correcta que nadie toma se ve
901igual que una que no existe. Arreglado (sin sesión = nada activo) y fijado con dos tests que corren el
902PROCESO y el reloj de verdad, el segundo comprobado volviendo a poner el `continue`.
903
904⚠ **Lo que sigue sin estar en ningún test automático: un transfer real entre peers.** Haría falta un
905sembrador y una espera que volverían la suite una que nadie corre. Está dicho también en el test del
906daemon, al pie, para que nadie lo lea como «probado de punta a punta».
907
908### 6.10 Lo que `atuq` NO PUEDE hacer hoy, y por qué (2026-09-07)
909
910Sale de un punto ciego que nombramos entre los dos frentes y que **ningún auditor de `readelf`
911puede cubrir**. La jerarquía de lo que se ve:
912
913    NEEDED del ejecutable          → lo ve `test-atuq-rootfs.py` y lo ve `vigia-sonames.py`
914    NEEDED de un .so dlopeado      → lo ven los dos, si el fichero está en el artefacto
915    dlopen("libfoo.so.1") LITERAL  → NO LO VE NINGUNO
916
917Y un navegador vive del tercer escalón: Firefox sondea ffmpeg, VA-API, vulkan, libnotify y una
918docena más **por nombre**, y cuando no están **no falla: apaga la función y sigue**. No hay línea
919roja; hay una función que nadie ofrece y nadie reclama. Es la misma forma que ya nos costó caro con
920OBS (`dlopen("libGL.so.1")` desde su glad).
921
922`scripts/test-atuq-rootfs.py --dlopen` busca esas cadenas y las cruza contra el rootfs. **Es un
923heurístico y se declara como tal** —una cadena no prueba un `dlopen`, y su ausencia no prueba que no
924lo haya—, así que no falla nunca: imprime un mapa triado. Lo que sí se puede afirmar es lo
925contrario, que es lo útil: **si la cadena está y el fichero no, esa función no existe en esta
926imagen.**
927
928De 85 cadenas con forma de soname, **47 no tenían quién las provea**. Triadas (y con dos filas
929corregidas el mismo día — ver §6.11, que las midió en vez de deducirlas; hoy el mapa da **7 huecos**):
930
931| Qué se pierde | Estado |
932|---|---|
933| **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** |
934| **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ó |
935| **Llavero** (`libsecret`) | hueco — pero **la receta ya existe** en `incoming-gnome` |
936| **Sonidos del sistema** (`libcanberra`) | hueco — receta en `incoming-kde` |
937| **WebGPU / vulkan** (`libvulkan`) | hueco — receta `vulkan-loader` en `incoming-kde` |
938| **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` |
939| **Lectura en voz alta** (`libspeechd`) | hueco — sin receta |
940| `libGL` | **decisión**: la distro es Wayland-only sin GLX y Firefox va por EGL |
941| `libcurl` | **decisión**: sólo lo usa `pingsender`, que manda telemetría — apagada |
942| Kerberos, menú global, ML | fuera de alcance |
943| `libdl/libpthread/librt/libudev.so.0/libfreebl3` | ruido: musl los funde, o el soname es viejo |
944
945**El primero ya está cerrado.** `libnotify` no tenía receta y ahora la tiene: es la prueba de que el
946mapa sirve para algo más que mirarlo. ⚠ Con una advertencia que va escrita en la propia receta:
947`libnotify` no trae un daemon, manda `org.freedesktop.Notifications` por D-Bus ⇒ tenerla resuelve la
948mitad —que firefox la encuentre— y la otra mitad es que en la imagen haya alguien escuchando ese
949nombre. Nadie debería leer «notificaciones arregladas» y esperar un globo en un QEMU pelado.
950
951**⚠ CORRECCIÓN, mismo día: escribí que tres de los siete eran «baratos, promoción y no autoría»
952—`libsecret`, `libcanberra`, `vulkan-loader`— y es FALSO.** Lo comprobé antes de declararlos en los
953perfiles, y en los tres casos falta la otra mitad:
954
955| «barata» | por qué NO alcanza con declararla |
956|---|---|
957| `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 |
958| `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 |
959| `libcanberra` | no hay receta de `sound-theme-freedesktop`: la librería carga y no suena nada |
960
961Declararlas habría subido el número de paquetes de la imagen sin encender una sola función. **Una
962librería es media función; la otra mitad es quien la atiende**, y eso vale para todas las de esta
963tabla. Quedan como huecos, no como promociones pendientes.
964
965⚠ **Y esta frase también se cayó el mismo día.** Decía que de los otros cuatro «el más caro para el
966usuario es `ffmpeg`: sin él, un sitio que sirva H.264 no reproduce» — con lo cual el hueco más caro
967del mapa **no existía**. Ver §6.11: la receta estaba, faltaba declararla en el runner, y H.264 (y
968AAC, VP9, Opus, AV1, MP3, FLAC) reproducen. De los cuatro «sin receta» quedan tres, y ninguno es un
969códec: lectura en voz alta (`libspeechd`), el driver de VA-API, y `libpci` —que sólo lo usa
970`glxtest`, o sea irrelevante sin GLX.
971
972**Y la misma vara aplicada a `libnotify`, que sí se declaró:** su otra mitad es un daemon que
973escuche `org.freedesktop.Notifications`. Medido perfil por perfil:
974
975    escritorio-kde      plasma-workspace        ✓ completa
976    escritorio-gnome    gnome-shell             ✓ completa
977    escritorio-cosmic   cosmic-notifications    ✓ completa
978    escritorio-sway     dunst 1.12.2            ✓ completa — CERRADA el 2026-09-07
979
980La de sway se cerró el mismo día con `recipes/incoming-wlr/dunst.toml`, y la trampa del homónimo
981—**el nombre `mako` YA ESTÁ OCUPADO en el corpus** por el motor de plantillas de Python de Mesa— se
982esquivó eligiendo dunst, que además habla D-Bus por GDBus y no por `sd-bus`, o sea sin receta nueva
983de `basu`. La prueba no es que selle: `scripts/wlr/dunst-headless.sh` levanta sway headless y un bus
984de sesión, manda un `notify-send` y **deja que el bus active el daemon solo** —el mismo camino que
985recorre una página web en `atuq`—, y mide un diff de píxeles antes/después: 14.832 cambiados con el
986`.service` puesto, 0 sin él. Evidencia en `docs/evidencia/dunst-sway-notificacion-2026-09-07.png`.
987
988Y la cadena se cerró hasta el final, que es donde vive la promesa: con `--via-atuq` el emisor no es
989`notify-send` sino **una página web dentro del navegador**. Ahí se ejercitan las cinco piezas —
990`new Notification(...)` → `libxul` haciendo `dlopen("libnotify.so.4")` (que no es NEEDED de ningún
991ELF, o sea invisible para cualquier auditor) → D-Bus → activación → dunst dibujando—, y el veredicto
992es el `onshow` del motor, no el diff de píxeles: con el `.service` puesto llega a `MOSTRADA #33`, sin
993él el motor dice `ERROR al mostrar` y no se dibuja nada. Evidencia en
994`docs/evidencia/atuq-notificacion-web-sway-2026-09-07.png`.
995
996Vale la pena decir qué prueba eso de más: que en el proceso de `atuq` **la GLib no se duplica**. El
997navegador usa la cadena `-shared` (la que arrastra su GTK3) y `libnotify.so.4` enlaza esa misma, así
998que el `dlopen` no repite GObject. El que sí lo repetía era `dunstify`, y por eso no se shipea.
999
1000⚠ De paso apareció otra media función, y ésta toca a `libnotify` directamente: **`dunstify`
1001segfaultea hasta en `--help`** porque mezcla la GLib ESTÁTICA del corpus con la COMPARTIDA que
1002arrastra `libnotify.so.4` — dos copias de GObject en un proceso. No se shipea; el emisor es
1003`notify-send`, que viene dentro del artefacto de libnotify y enlaza toda la cadena compartida. Vale
1004como recordatorio de que en este corpus **quién enlaza qué GLib es parte del contrato**, no un
1005detalle del build.
1006
1007**Lo que esta sección cambia de fondo:** hasta hoy la pregunta era «¿arranca?», y la respuesta era
1008sí. La pregunta que faltaba era «¿y qué NO puede hacer?», que ninguna métrica del repo respondía
1009porque todas miran presencia y ésta mira ausencia declarada por el propio binario.
1010
1011#### 6.10.bis Una descarga terminada MATA a `atuq --headless` (2026-09-10)
1012
1013Medido al escribir el guardián del §6.2, y vale la pena por cómo se atribuyó, no por el bug:
1014
1015| corrida | qué pasa |
1016|---|---|
1017| `--headless`, con la extensión de descargas | baja el fichero, la extensión dice `TERMINADA`, y el proceso muere con **SIGSEGV (139)** |
1018| `--headless`, **sin** la extensión (control) | baja el fichero, y muere igual |
1019| `--headless`, con el panel de descargas apagado (`browser.download.alwaysOpenPanel=false`) | muere igual |
1020| **sway headless (compositor REAL, salida offscreen)** | baja, ingiere al CAS y **no se cae** |
1021
1022Las dos primeras filas son las que importan: **el control sin la extensión descarta que sea nuestro**,
1023y la última descarta que sea del producto. Es el modo `--headless` de Gecko, en esta jaula, cuando una
1024descarga completa. El fichero se baja bien en los cuatro casos — lo que muere es el proceso después.
1025
1026Sin el control, esto se lee de dos maneras y las dos son caras: «la extensión de descargas rompe el
1027navegador» (perseguir un bug que no existe) o «atuq no puede descargar» (reportar un bug de producto
1028que tampoco existe). Por eso `scripts/test-atuq-descargas.py` corre sobre sway y no sobre `--headless`,
1029y por eso dice en su cabecera POR QUÉ — un arnés distinto al de los demás guardianes, sin explicación,
1030es una invitación a «simplificarlo» de vuelta al que se cae.
1031
1032⚠ Lo que NO se midió: si el crash existe también en el `--headless` de una máquina sin jaula. No hace
1033falta para lo que se decidió acá, y afirmarlo sin medirlo sería justo lo que este documento no hace.
1034
1035### 6.11 Y ahora la pregunta que faltaba: ¿REPRODUCE? (2026-09-07)
1036
1037El §6.10 preguntó «¿qué NO puede hacer?» y contestó leyendo cadenas. Media pregunta. La otra mitad
1038—**¿se ve el vídeo?**— no la contesta ningún auditor de ELF, y al hacerla aparecieron **dos errores
1039del mapa y un bug real del navegador**.
1040
1041**Error 1: «no hay receta de ffmpeg».** La hay. `recipes/ffmpeg.toml`, 7.1, sellada, publica
1042`libavcodec.so.61` —uno de los once sonames que `libxul.so` sondea por `dlopen` literal— y **ya
1043viajaba en la clausura de los cuatro escritorios**, arrastrada por `mpv`. Comprobado con
1044`yupana.membresia`, que es la vista honesta por par `(cola, nombre)`, no leyendo el TOML:
1045
1046    escritorio-kde 308 nodos · escritorio-gnome 192 · escritorio-sway 203 · escritorio-cosmic 163
1047    ffmpeg ✓ en los cuatro · libva ✓ en los cuatro
1048
1049Lo que faltaba no era la receta: era **la lista de raíces de `scripts/atuq-nested.sh`**, escrita a
1050mano, que hidrataba un rootfs más pobre que la imagen. Otra vez el instrumento y no el artefacto —
1051la misma forma que `gcc-libs` el día anterior. Y la comprobación previa que sí se pudo hacer sin
1052correr nada: de los 82 símbolos con forma `av*_` que `libxul` dlsimboliza, **los 9 que nuestro
1053`libavcodec` no exporta son los de la API vieja** (`avcodec_decode_video2`, `av_lockmgr_register`,
1054`avcodec_register_all`…), que Firefox sólo pide para las versiones 53-57.
1055
1056**Error 2: «VA-API, sin receta».** También la hay, y también está en las cuatro imágenes. El hueco
1057es real pero por la OTRA mitad: las tres recetas de mesa construyen con `-Dgallium-va=disabled` y
1058`-Dvideo-codecs=` vacío ⇒ **no existe un solo `*_drv_video.so`**. Un cargador sin ICD no acelera
1059nada, exactamente como `vulkan-loader`. ⚠ Y con una trampa nueva: ahora que `libva` está en el
1060rootfs, **el mapa de `--dlopen` ya no la reporta**, porque mide presencia de fichero. La librería
1061presente SILENCIA el aviso sin encender la función. Queda escrito acá porque el guardián ya no
1062puede decirlo.
1063
1064**El bug real: AV1 no reproducía.** Gecko **se come el primer elemento de `LD_LIBRARY_PATH`** al
1065lanzar el proceso RDD, el que decodifica vídeo. El lanzador ponía `/usr/lib/atuq` una sola vez —o
1066sea, primero—, así que el RDD arrancaba sin él, no encontraba el ffvpx bundleado (donde vive dav1d)
1067y anotaba `FFVPX: Link result: NoProvidedLib`. Y ahí **no falla**: se cae al ffmpeg del sistema, que
1068cubre H.264/AAC/VP8/VP9/MP3/FLAC/Opus… pero **no AV1 por software**, porque el decodificador `av1`
1069de ffmpeg es sólo hwaccel y nuestra receta va sin dav1d. Resultado: un vídeo AV1 se quedaba en
1070`readyState=1` para siempre, sin un error, sin un NEEDED faltante y sin una cadena ausente.
1071
1072Tres corridas del MISMO artefacto que sólo cambian esa variable:
1073
1074    LD=/usr/lib/atuq:/usr/lib:/lib             → RDD NoProvidedLib   AV1 ✗
1075    LD=/usr/lib/atuq:/usr/lib/atuq:/usr/lib    → RDD Success         AV1 ✓
1076    LD=/relleno:/usr/lib/atuq:/usr/lib         → RDD Success         AV1 ✓   (el relleno se lo come)
1077
1078El arreglo es repetir el appdir en `recipes/atuq/bin/atuq`. Feo y correcto mientras no haya
1079`patchelf` en el corpus para grabar `RUNPATH=$ORIGIN`, que borraría la variable entera.
1080
1081**Lo que quedó medido**, con `scripts/test-atuq-codecs.sh`, headless, por el lanzador de verdad y
1082sin `LD_LIBRARY_PATH` de afuera:
1083
1084| Muestra | Veredicto |
1085|---|---|
1086| H.264 + AAC (mp4) | ✅ `t=3.00`, 75 cuadros |
1087| VP9 + Opus (webm) | ✅ `t=3.00` |
1088| AV1 (mp4) | ✅ `t=3.00` — sólo desde el arreglo del lanzador |
1089| MP3 | ✅ |
1090| FLAC | ✅ |
1091
1092El veredicto **no es «se creó el decodificador»**: es que `currentTime` AVANCE. La distinción no es
1093retórica — en el caso de AV1 el decodificador se creaba (`RemoteDecoderChild … codec: av1`) y no
1094entregaba un solo cuadro. El guardián corre con `--negative-control`, que saltea el lanzador y
1095**exige que AV1 falle**: probado en los dos sentidos, porque uno que no puede fallar y uno que nunca
1096falló se ven igual desde afuera.
1097
1098**Dos bugs de andamiaje que salieron de paso**, los dos en `atuq-nested.sh` y los dos escondidos
1099detrás de una caché: el bucle de hidratación salía 1 siempre (línea vacía final + `set -e`) y mataba
1100el script sin imprimir nada, y la comparación del sello nunca daba igual (un `\n` que `$(cat …)`
1101recorta de un lado y no del otro), así que el rootfs se rehidrataba entero en cada corrida. El
1102primero sólo se veía cuando cambiaban los hashes; lo destapó agregar `ffmpeg` a las raíces.
1103
1104### 4.g El chrome se programa SIN abrir `omni.ja` — y la vista dividida ya la trae el motor (2026-09-09)
1105
1106El pendiente escrito era «el chrome de verdad dentro de `omni.ja` (split view, dientes propios): el
1107re-empaque ya está resuelto, lo que falta es el contenido». Las dos mitades de esa frase eran falsas,
1108y las dos se midieron con `scripts/test-atuq-chrome.py` sobre el artefacto vigente:
1109
1110    VIVO el JS de atuq.cfg corre
1111    VENTANA con gBrowser
1112    PREF splitView.enabled=true
1113    WRAPPER tabs=2
1114    ACTIVA si
1115    BROWSERS 2
1116
1117**1. El zip no es el gancho del chrome; `atuq.cfg` sí.** Ese fichero ya corre con privilegios —de ahí
1118sale `nsIStyleSheetService`, y es la razón de `sandbox_enabled = false`—, así que desde ahí se observa
1119`browser-delayed-startup-finished` y se toca el `gBrowser` de CADA ventana que abra el navegador. La
1120sonda abre dos pestañas y las parte en dos desde `atuq.cfg`, sin tocar `omni.ja`.
1121
1122Y no es un atajo: es la vía CORRECTA, porque re-empacar `omni.ja` es exactamente lo que pelea con el
1123orden del `jarlog` del PGO —el motivo por el que el `jarlog` está aparcado (`c3413d29`)—. Dicho al
1124revés: **cada función del chrome que no entre al zip es una que no compite con la optimización de
1125arranque.** Eso cambia el orden de preferencia para todo lo que venga: primero prefs, después
1126extensión de sistema, después JS de chrome por autoconfig, y `omni.ja` sólo si no queda otra.
1127
1128**2. La vista dividida no hay que escribirla: Firefox 154 la trae y viene PRENDIDA.** En
1129`browser/omni.ja` están `tabbrowser/tabsplitview.js` (492 líneas, el custom element completo con
1130`addTabs`/`unsplitTabs`/`reverseTabs`), `opentabs-splitview.mjs` y `split-view-footer.js`; y
1131`defaults/preferences/firefox.js` trae `pref("browser.tabs.splitView.enabled", true)`. La sonda no se
1132queda en «el fichero está»: llama a `gBrowser.addTabSplitView([t1, t2])` y le pregunta al motor, que
1133contesta `activeSplitView` puesta y `splitViewBrowsers.length == 2`. **Funciona en nuestro build.**
1134
1135Es el mismo patrón que ya pagó dos veces en este SDD —los dientes (`sidebar.verticalTabs`), los
1136contenedores (`privacy.userContext.enabled`)—: la función existe en el motor y viene apagada o sin
1137usar, y el trabajo es prenderla y comprobarla, no escribirla. La forma de equivocarse es al revés:
1138declarar como deuda propia algo que upstream ya nos dio.
1139
1140⚠ **Lo que esto NO dice.** atuq todavía **no lleva JS de chrome propio**: lo medido es el GANCHO, con
1141la sonda inyectada por una capa de overlay que no viaja en el artefacto. Cuando haya una función que
1142lo justifique, va en `recipes/atuq/chrome/atuq.js` y la carga `atuq.cfg`. No se shipea un cargador
1143vacío para «tenerlo listo».
1144
1145El control negativo sale del propio motor y no de una idea nuestra: `addTabSplitView` documenta que
1146si TODAS las pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control` las fija
1147y exige justamente eso — sin él, una sonda que dijera «partió» pase lo que pase se vería idéntica a
1148una que funciona.
1149
1150### 2.ter Dónde vive la fuente de `atuq` — hizo falta un modo de `[source]` nuevo
1151
1152Escribir la receta destapó un muro que este documento no había visto: `[source]` era
1153**obligatoriamente** git o tarball, así que una receta cuyo contenido no viene de upstream sino de
1154nosotros no se podía ni expresar. Los rodeos eran todos peores — fetchear una fuente que después se
1155ignora **miente** sobre la identidad del artefacto, y colgar el overlay de un repo aparte obliga al
1156worker a tener acceso a un repo privado.
1157
1158Se agregó **`source.dir`** (commit `54fa4a9`): un árbol dentro del propio repo, hasheado por
1159**contenido** con `ArtifactHash::of_tree` —que ya existía para verificar bit-reproducibilidad en
1160Stage 2— y no por ruta. Es la misma disciplina que ya tenían los `patches`, que entran al hash por
1161bytes. Editar un CSS del overlay mueve el `ArtifactHash`; renombrar el directorio, no.
1162
1163Un `.swm` **no** puede llevar una receta derivada, y los cuatro sitios que lo tocan fallan
1164diciéndolo: el manifiesto es compartible y necesita un puntero resoluble desde fuera.
1165
1166**Hecho el 2026-09-05** (commits `aeebb19` y `6f6c32b`): `recipes/atuq.toml` + `recipes/atuq/`.
1167La **v0.2** (`b3:7eff4ba4`) ya trae el branding: `application.ini` (Vendor/Name/RemotingName/
1168CodeName, con el `ID` intacto para no salirse del ecosistema de complementos), `brand.ftl` dentro de
1169`browser/omni.ja`, los binarios renombrados, iconos propios y `.desktop`. El re-empaque preserva
1170orden, `STORED` y fechas del original, y se verificó contra el artefacto: **5306 entradas, mismo
1171orden, y una sola con contenido distinto**. El `BuildID` se deriva del contenido del overlay —no de
1172la fecha— porque es la clave con la que Gecko invalida su startup cache: sin eso, un cambio de
1173chrome arranca con la interfaz vieja y *parece* que el overlay no agarró.
1174
1175La capa de configuración de la v0.1 son cuatro ficheros —autoconfig, `atuq.cfg`, `chrome/atuq.css`,
1176`policies.json`— y el CSS se registra como `USER_SHEET` desde autoconfig porque `userChrome.css`
1177vive en el perfil y exigiría que el usuario prenda una pref: atuq tiene que verse como atuq en el
1178primer arranque. Falta construirlo: su dep `firefox` está en vuelo.
1179
1180### 2.quater Abrirlo en una pantalla: tres fallos que la clausura no podía ver
1181
1182El 2026-09-05, con `atuq` sellado y verificado, se hidrató y se abrió en el compositor de la sesión
1183(waypipe). **No arrancó**, y los tres motivos son la diferencia entre «sellado» y «usable»:
1184
11851. **EXDEV al hidratar.** `hydrate` proyecta con hardlinks y `linkat()` rechaza cruzar un punto de
1186   montaje aunque los dos lados sean el mismo filesystem. Acá el store es `/dev/sdb` bind-monteado
1187   y `work/` vive en `/dev/sdc`. El rootfs va bajo `/mnt/cosecha`, donde el volumen está montado
1188   entero. Se comprueba con `findmnt -T`, **nunca** con `stat -c %d`.
11892. **El lanzador no puede ser un symlink.** Ni el binario ni sus `.so` traen `RPATH`/`RUNPATH`
1190   (`readelf -d`), así que el motor no encontraba su propio `libnspr4.so` y moría en
1191   `XPCOMGlueLoad`. Es un script con `LD_LIBRARY_PATH` + `exec`, como Debian y Fedora. La
1192   alternativa limpia —`RPATH=$ORIGIN` con `patchelf`, que es lo que hace Alpine— espera a que
1193   `patchelf` exista como receta del corpus.
11943. **Un bug del corpus, no de atuq: `atk` se había tragado GObject.** Declaraba la variante
1195   ESTÁTICA de glib y produce un objeto compartido, así que `libatk-1.0.so` llevaba una copia
1196   entera del sistema de tipos. Dos GObject en un proceso ⇒ 45 `GLib-GObject-CRITICAL` y
1197   `Segmentation fault`. **El síntoma no nombra a atk**, y no se ve mirando el rootfs: había una
1198   sola `libgobject`. Se cazó preguntando quién DEFINE el símbolo:
1199   `nm -D --defined-only <cada .so> | grep " T g_type_register_static"` — deben salir uno, salieron
1200   dos.
1201
1202Radio medido antes de tocar (`yupana radio atk`): 4 transitivos, 3 sellados a deuda; GNOME fuera
1203porque usa gtk4. La cadena `atk → gtk3 → firefox → atuq` volvió a sellar 4/4, y **firefox tardó
1204~50 minutos, no las cuatro horas** que repetía este documento — el número venía del folclore de la
1205receta, no de una medición.
1206
1207**La lección para el plan: ninguna unidad de este frente está cerrada hasta que algo se ABRE en una
1208pantalla.** La clausura decía 100% con un navegador que no llegaba a pintar un píxel.
1209
1210### 2.quinquies La v0.3 y los tres mecanismos: dos muertos, uno vivo
1211
1212La página de inicio y la pestaña nueva parecían un `pref` y resultaron ser un frente. Lo que se
1213midió, en orden:
1214
1215| Mecanismo | Resultado |
1216|---|---|
1217| `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 |
1218| `policies.json` → `ExtensionSettings` con `install_url` | **Se lee** (`browser.policies.applied=true` en el perfil) y falla nombrándose: `ERROR_SIGNEDSTATE_REQUIRED` |
1219| `xpinstall.signatures.required = false` | **No alcanza** — la exigencia viene COMPILADA |
1220
1221**El instrumento que destrabó el diagnóstico fue un testigo.** `defaultPref` no deja rastro en
1222`prefs.js` —sólo se guarda lo que difiere del default— así que un autoconfig que **no se ejecuta**
1223es indistinguible de uno que sí y no hace nada. `atuq.cfg` escribe ahora `atuq.autoconfig.ok` como
1224pref de **usuario**, legible desde fuera sin abrir el navegador. Salió `true` ⇒ el `.cfg` corría, y
1225por lo tanto la pref de firma se estaba ignorando.
1226
1227De ahí `MOZ_REQUIRE_SIGNING` vacío en `recipes/firefox.toml`. Dos detalles que costaron cada uno su
1228vuelta:
1229
12301. El default sale del **milestone** (`milestone.is_release_or_beta`), no del canal de actualización.
1231   El nuestro es `default` y la exigencia estaba activa igual.
12322. **Se desactiva con el valor VACÍO, no con cero.** `=0` muere con «takes 0 values»: es booleana y
1233   el cero es un valor que no acepta. Leído del parser de mozbuild, no deducido.
1234
1235**El precio, escrito:** este Firefox deja de exigir la firma de Mozilla para cualquier complemento.
1236La alternativa es firmar en AMO —cuenta y revisión de Mozilla por versión—, que es la dependencia
1237externa que esta distro existe para no tener. Y no es un desvío: **sin esto, el `sct` del §6.1
1238tampoco se podría shipear nunca**, así que la unidad 6 dependía de ésta sin que el plan lo dijera.
1239
1240**Método que conviene repetir:** la prueba del testigo se hizo **sin reconstruir**, montando el
1241`.cfg` modificado con `--ro-bind` por encima del artefacto. Editar el fichero en el rootfs habría
1242escrito sobre un **hardlink del store** y corrompido el artefacto sellado.
1243
1244### 2.sexies Lo que quedó PROBADO, y con qué
1245
1246Tres afirmaciones que este documento venía haciendo sin medir, y cómo se cerraron:
1247
1248| Afirmación | Prueba |
1249|---|---|
1250| El re-empaque del `omni.ja` es determinista | `why-differs` entre dos builds de `atuq`: **85 entradas idénticas, 0 divergen** |
1251| La capa de configuración se aplica | testigo `atuq.autoconfig.ok` escrito como pref de **usuario** en el perfil |
1252| 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` |
1253| La base también reproduce | `why-differs` entre dos builds de `firefox` en el worker: **56 entradas idénticas, 0 divergen** |
1254
1255Y el contraste que salió de la primera: **el derivado reproducía y la base no.** El `BuildID` de
1256`firefox` era la hora del build, así que dos construcciones con el mismo `ArtifactHash` daban bytes
1257distintos — y `build-state.json` no lo podía ver, porque el hash es input-addressed y no se mueve
1258por esto. Verde y mintiendo. Cerrado con `MOZ_BUILD_DATE` desde `SOURCE_DATE_EPOCH`, que es el
1259mecanismo de hermeticidad que el sandbox ya tenía — y **comprobado como se comprueba esto**:
1260apartando el artefacto como `.ref`, construyendo de nuevo y pasándole `why-differs`. Reproduce.
1261
1262**Dos guardianes nuevos en la fase `install` de `firefox`**, hermanos entre sí, porque los dos fallos
1263son de la misma familia —una bandera que el `configure` acepta y el artefacto ignora, muda y a 50
1264minutos de distancia—: uno comprueba `MOZ_REQUIRE_SIGNING: false` dentro del `omni.ja`, el otro que
1265el `BuildID` sea el que `SOURCE_DATE_EPOCH` obliga.
1266
1267**La regla que sale de todo el día:** cuando la duda es «¿llegó al artefacto?», la respuesta no está
1268en el log del build ni en la receta — está **dentro del artefacto**, y hay que ir a buscarla ahí.
1269
1270## 7. La costura: un host de native messaging en Rust
1271
1272Todo lo del §6 que no es CSS pasa por **un solo mecanismo**: un proceso Rust que habla native
1273messaging con la extensión de `atuq` y, del otro lado, con la suite (`agora` para identidad Ed25519,
1274`khipu`, el store, `foreign-*`, `cortafuegos`).
1275
1276Que sea **uno** y no siete es la decisión: cada feature del §6 pasa a ser un verbo de ese host, no un
1277proyecto nuevo. El binario vive en tawasuyu (es suite, no build system); `atuq` sólo lo declara como
1278dep y le deja el manifiesto de native messaging en su sitio. **El nombre del crate se decide en
1279tawasuyu y con su regla 10 puesta** — acá no se bautiza nada de allá.
1280
1281### 7.bis El camino EXISTE, y tres cosas que no eran obvias (2026-09-07)
1282
1283Antes de escribir un crate contra un mecanismo hay que saber si el mecanismo funciona en NUESTRO
1284build —propio, rebrandeado, con `MOZ_REQUIRE_SIGNING` vacío—, así que se puso una **sonda**:
1285extensión de diez líneas con permiso `nativeMessaging` y un host de tres que contesta `{"ok":true}`.
1286Vive en `scripts/test-atuq-nativo.py`, con control negativo:
1287
1288    positivo  MENSAJE {"ok":true}
1289    control   DESCONECTADO No such native application puente_atuq
1290
1291El control no sólo falla: **nombra la causa**, que es lo que confirma que la ruta del manifiesto es
1292la que se probó. Lo que hay que saber para escribir el host:
1293
1294| Qué | Medido |
1295|---|---|
1296| 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 |
1297| 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** |
1298| 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 |
1299| Con qué diagnosticar | el error del puerto de `connectNative`. `sendNativeMessage` devuelve «An unexpected error occurred» para todo y no distingue nada |
1300
1301⚠ La tercera fila del cuadro corrige una lectura fácil de `policies.json`: los dos mecanismos
1302apuntan a los mismos ficheros y se tapan uno al otro, así que la política PARECE ser la que instala.
1303Fija y configura; instalar, no.
1304
1305### 7.ter Y esa fila ahora tiene guardián — porque el README decía lo contrario (2026-09-09)
1306
1307La fila «cómo se instala la extensión» era una nota al pie de una sonda de native messaging, y
1308mientras tanto **`recipes/atuq/README.md` afirmaba lo opuesto**: que el *sideloading* desde
1309`distribution/extensions/` «ya no funciona» y que instalaba `ExtensionSettings`. Dos documentos
1310nuestros, contradictorios, sobre el mecanismo del que cuelgan TODAS las extensiones de atuq —hoy
1311`inicio` y `proxy`, mañana la de `sct`—. Y ninguna observación del artefacto los distingue, porque
1312los dos apuntan a los mismos ficheros.
1313
1314`scripts/test-atuq-instalacion.py` lo rompe de a uno y mira qué pasa. Medido sobre
1315`atuq b3:fab2fbfb` (firefox 154 con PGO v2) y REPETIDO sobre `b3:9e16ac35` —el artefacto que esta
1316misma corrección creó, porque el README vive dentro de `source.dir` y editarlo re-hashea atuq—,
1317tres escenarios:
1318
1319| escenario | qué se rompe | motor (`homepageOverride`) | perfil |
1320|---|---|---|---|
1321| `as-is` | nada | `moz-extension://…/inicio.html` | `inicio` y `proxy` activas |
1322| `no-policy` | `policies.json` **sin** `ExtensionSettings` | **sigue siendo la nuestra** | **las dos siguen activas** |
1323| `policy-only` | los XPI mudados a `/usr/lib/atuq/xpi-politica/`, con `install_url` apuntando ahí | `about:home` | **ninguna de las dos existe** |
1324
1325⇒ **instala el escaneo de la carpeta; `install_url` con `file://` no instala nada.** El §7.bis
1326queda confirmado y el README corregido.
1327
1328Dos cosas que este guión trae y que valen más que el veredicto:
1329
13301. **El control es por construcción, no un modo aparte.** La sonda que le pregunta al motor vive en
1331   `distribution/extensions/` y **ninguna política la nombra**: si la sonda habla, el escaneo está
1332   vivo en esa corrida. Una corrida muda no se lee como «no instaló» — se declara ROTA y se guarda
1333   el log. Sin eso, un atuq que no arranca se vería idéntico a una política que no instala.
13342. **La segunda señal que probé NO discrimina, y queda dicho.** Esperaba que el `location` de
1335   `extensions.json` separara los mecanismos (`app-system-defaults` vs. `app-profile`). No: los dos
1336   XPI salen `app-profile` **también** cuando los instala la carpeta y no hay política que los
1337   mencione. Se imprime igual porque dice si están, pero como discriminador es cero — y una señal
1338   que uno cree que discrimina y no discrimina es peor que ninguna.
1339
1340**Por qué esto es un guardián y no un test más.** Mozilla lleva años retirando el *sideloading*, y
1341el día que la carpeta deje de instalar, atuq arranca **sin ninguna de sus extensiones** y sin una
1342línea de error: la página de inicio vuelve a ser la de Firefox y el proxy por contenedor
1343simplemente no enruta. Es el modo de fallo silencioso de siempre, y el que aparece cuando SUBE la
1344dep, no cuando alguien toca atuq.
1345
1346⚠ **Y por eso mismo hay que decir lo que todavía NO está: ninguno de los `test-atuq-*` corre en el
1347latido.** `cosecha-cron.sh` sólo ejecuta `vigia-sonames.py`; los de atuq se corren a mano. Meterlos
1348cuesta ~3 min por ciclo (tres arranques de navegador headless por escenario) y exige que el hub tenga
1349hidratado el rootfs de `scripts/atuq-nested.sh`, así que **es una decisión con precio y no un
1350`echo` más en el cron** — queda escrita acá y sin dar por hecho lo contrario. El día que se meta, el
1351sitio es el bloque de guardianes de `cosecha-cron.sh` (L341-352).
1352
1353### 7.quater El host, escrito — y por qué la extensión NO puede hablarle al testigo (2026-09-10)
1354
1355El §6.1 decía que `sct` v1 es «extensión con `webRequest` bloqueante que hashea cada respuesta de
1356script y **consulta al testigo** antes de dejarla pasar». La primera mitad es correcta; la segunda es
1357imposible tal como está escrita, y conviene decirlo porque parecía un atajo para saltarse esta
1358unidad: **el cable de `puriy-sct-testigo` es un POST con `postcard` en el body**, no JSON. Un JS no
1359puede ser su cliente. Y reimplementar el registro TOFU en la extensión sería fabricar un sustituto
1360paralelo del original —lo que la regla 10 de tawasuyu prohíbe— además de duplicar la única pieza que
1361ya estaba certificada. Entonces v1 pasa por el host, y el host es esta unidad.
1362
1363Se decidió con el operador, con el `grep` de la regla 10 hecho primero: **ningún término del dominio
1364significaba «host de native messaging»**, y los que suenan a mensajero están todos ocupados por
1365dominios ajenos y grandes (`chasqui` es un type broker, `chaka` el puente a COBOL, `paloma` el cliente
1366de correo, `tampu` el común de objetos). El nombre salió del título de este propio §7: **la costura**.
1367
1368| Pieza | Dónde | Por qué ahí |
1369|---|---|---|
1370| `puriy-costura` | `00_unanchay/puriy/` | regla 1: subcrate del dominio, no proliferación lateral. Precedentes: `puriy-sct-testigo`, `rimay/verbo-daemon`, `watuy-daemon` |
1371| `foreign-webext` | `shared/` | regla 4: el protocolo es AJENO. Lib pura sin binario, como los otros 37 puentes (medido: ninguno lleva `[[bin]]`) |
1372| `recipes/puriy-costura.toml` | takana | el artefacto: estático musl, pineado por commit, `--locked` |
1373
1374Verbos v1: `ping`, `sct.observe`, `sct.state`. Claves del cable en inglés (regla 7 de allá y regla 4
1375de acá: se tipean). `phase` es `learning`/`stable`/`not-evaluable`, y un `events[]` vacío es lo normal
1376— sólo hay evento cuando un origen **ya estable** ejecuta un hash que nadie vio nunca.
1377
1378**Los tres tests que hacen que el veredicto signifique algo** (de catorce): el mismo cambio de script
1379mientras el origen todavía aprende **no** alerta; rotar `?v=` con el mismo código **tampoco** (la
1380identidad del recurso es su contenido, y sin eso cada despliegue con cache-buster sería un aviso de
1381supply-chain: un detector que grita todos los días no se mira más); y el aprendizaje **sobrevive al
1382cierre del navegador**, sin lo cual cada arranque volvería a «aprendiendo» y ningún cambio sería nunca
1383un evento.
1384
1385**Los dos límites, escritos en el código y en los dos LEEME, no en una nota de sesión:**
1386
13871. **Qué bytes se hashean.** La extensión manda el TEXTO ya decodificado; se hashea su UTF-8. La
1388   detección vale —el TOFU compara lo mismo contra lo mismo—, pero nuestro hash **no es comparable**
1389   con el de los bytes servidos que publique un tercero, ni con el de la v2 (el gancho en el script
1390   loader). Un hash que uno cree comparable y no lo es sería peor que no tenerlo.
13912. **La semilla del log es provisional**: se deriva del directorio de estado, así que es
1392   determinística (el log se reanuda) pero **no es un secreto** ⇒ la bitácora es a prueba de
1393   REESCRITURA, no de SUPLANTACIÓN. La de verdad sale del perfil cuando el almacén del perfil exista.
1394
1395**Y lo que queda medido para la unidad 6, preguntándole al artefacto y no a la documentación de
1396Mozilla** (§2.sexies): `filterResponseData` y el permiso `webRequestFilterResponse` **están** en
1397nuestro build (`omni.ja`, `chrome/toolkit/content/extensions/schemas/web_request.json`), así que la
1398extensión va a poder leer el cuerpo de un script. Lo que NO va a poder es ver los scripts **inline**
1399por esa vía —`filterResponseData` entrega el documento, no cada `<script>`—, y eso está bien para v1:
1400el ataque que `sct` nombra es la sustitución en el CDN, que es exactamente el caso `<script src>`.
1401
1402## 8. Plan, por unidades de trabajo
1403
1404Cada una cierra sola, se commitea y se pushea. El orden no es preferencia: cada una destraba a la
1405siguiente.
1406
1407| # | Unidad | Puerta que abre | Bloqueada por |
1408|---|---|---|---|
1409| 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 | — |
1410| 2 | ~~`llvm-toolchain`~~ → **`apk add lld` + `compiler="clang"`** ✅ 2026-09-05 | LTO **y** el muro 3 | — |
1411| 3 | Construir el `firefox` con LTO (hash `b3:6f2a3b2f`) | el eje de velocidad **y** las extensiones de `atuq` | 2 ✅ |
1412| 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 ✅ |
1413| 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 ✅ |
1414| 4 | **`atuq`: receta derivada + overlay de chrome** ✅ v0.1 escrita | el andamio de todo lo demás | 3 |
1415| 4.b | Branding + re-empaque de `omni.ja` ✅ v0.2 | el chrome de verdad | — |
1416| 4.d | **Abrirlo en pantalla** — destapó EXDEV, el lanzador, el `atk` envenenado y la cadena GTK3 estática | «usable», no sólo «sellado» | — |
1417| 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 |
1418| 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 | — |
1419| 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 | — |
1420| 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` | — |
1421| 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 | — |
1422| 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 ✅ |
1423| 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 ✅ |
1424| 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 ✅ |
1425| 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 ✅ |
1426| 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 ✅ |
1427| 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 | — |
1428
1429Las unidades 2 y 4 son **paralelizables**: la toolchain no toca el chrome y el chrome no toca la
1430toolchain. Si hay dos frentes, van juntas.
1431
1432## 9. Por qué esto no abandona a `puriy`
1433
1434`puriy` es el motor DOM/CSS propio con JS real (QuickJS-NG sobre `wasmi`) cuyo límite declarado no es
1435el lenguaje sino **el wiring nativo**: DOM bindings completos, red, render. Eso no se acelera
1436escribiendo otro navegador; se acelera con tiempo.
1437
1438`atuq` no le compite porque no comparte una sola línea con él, y **le sirve** porque todo lo del §6
1439vive del lado Rust: el host del §7, el testigo de `sct`, las descargas al CAS, el archivo con RAG.
1440Son **agnósticos del motor**. Cuando `puriy` madure, se enchufan del mismo lado sin reescribirse.
1441
1442Dicho al revés, que es como conviene recordarlo: **`atuq` es el navegador que se puede usar mientras
1443`puriy` crece, y el andamio de las features que `puriy` va a heredar.** Si dentro de dos años `atuq`
1444se apaga, lo que se tira es una receta derivada y una hoja de CSS; todo lo demás sobrevive.

26-atuq-envoltorio-gecko.md

SDD 26 — atuq: el envoltorio Gecko de la distro

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Riesgos de PGO, escritos antes de empezar:

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

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

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

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

Tres lecturas que hay que sacar de esa tabla:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Qué cambia, en concreto:

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

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

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

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

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

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

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

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

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

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

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

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

Lo que costó, que es la parte reusable:

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

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

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

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

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

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

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

4. Lo que atuq NO promete

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

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

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

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

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

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

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

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

6. Los diferenciadores, priorizados

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

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

6.1 sct es la joya

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

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

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

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

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

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

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

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

6.2 Una descarga con identidad

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

mitadestado
congelar cada página leída y poder encontrarla✅ HECHA — archive.add / archive.search + la extensión archivo@atuq.tawasuyu
preguntarle al historial en lenguaje natural❌ falta, y ya tiene forma: un motor 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.4 qullqa con su advertencia puesta

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.

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.8 Proxy por contenedor — HECHO (v0.5, 2026-09-06)

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

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

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

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

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

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

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

Lo que quedó PROBADO, y con qué

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

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

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

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

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

Las tres puertas, que conviene NO volver a golpear:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

8. Plan, por unidades de trabajo

Cada una cierra sola, se commitea y se pushea. El orden no es preferencia: cada una destraba a la siguiente.

#UnidadPuerta que abreBloqueada por
1waterfox SELLA ✅ 2026-09-06 — b3:88b5a762, 377 M, BuildID determinista y REPRODUCE bit a bit. Cuatro muros, ninguno de la receta: takana viejo en el worker, el watchdog barriendo el post-mortem, deps estáticas vs. el cairo bundleado, y una rotura de upstream sólo-Linuxvalida la tesis del reúso—
2llvm-toolchain → apk add lld + compiler="clang" ✅ 2026-09-05LTO y el muro 3—
3Construir el firefox con LTO (hash b3:6f2a3b2f)el eje de velocidad y las extensiones de atuq2 ✅
3.aPGO ENCENDIDO ✅ 2026-09-07 — perfil de 501.521 funciones recogido bajo headless, publicado en el mirror y pineado por sha256; 189 compilaciones con -fprofile-use. Sin jarlog todavíala otra mitad de la ganancia3 ✅
3.bRLBox ENCENDIDO ✅ 2026-09-06 — cadena wasm de CINCO recetas selladas + guardián que la exige en el binario; firefox sellado y REPRODUCE bit a bit (verificar-repro.sh: 0 no-determinismos)cierra el hueco de seguridad del §3.bis2 ✅
4atuq: receta derivada + overlay de chrome ✅ v0.1 escritael andamio de todo lo demás3
4.bBranding + re-empaque de omni.ja ✅ v0.2el chrome de verdad—
4.dAbrirlo en pantalla — destapó EXDEV, el lanzador, el atk envenenado y la cadena GTK3 estática«usable», no sólo «sellado»—
4.ev0.3: página de inicio y pestaña nueva por extensión de sistema; exigió MOZ_REQUIRE_SIGNING vacío. Verificado 2026-09-07 con scripts/test-atuq-inicio.py: se le pregunta al MOTOR (browserSettings.homepageOverride / newTabPageOverride) y da moz-extension://…/inicio.html en las dos, contra about:home/about:newtab en el controly con eso queda destrabada la unidad 6 (sct)rebuild de firefox
4.fLos códecs, medidos ✅ 2026-09-07 — H.264+AAC, VP9+Opus, AV1, MP3 y FLAC reproducen; arreglado el LD_LIBRARY_PATH que dejaba al RDD sin ffvpx (AV1 muerto en silencio) y nacido scripts/test-atuq-codecs.sh con control negativo«se ve el vídeo», que ningún auditor de ELF podía responder—
4.cMOZ_BUILD_DATE determinista ✅ — BuildID=19700101000001, con guardián en install y why-differs 56/56 sobre dos builds realesel invariante de reproducibilidad—
4.gEl chrome se programa desde atuq.cfg, sin abrir omni.ja ✅ 2026-09-09 — y de paso: la vista dividida ya la trae el motor y viene prendida (fx 154), ejercitada de verdad con gBrowser.addTabSplitView ⇒ activeSplitView + 2 navegadores. Guardián scripts/test-atuq-chrome.py con control negativo del propio motorretira dos pendientes que eran de upstream, y deja el camino del chrome que NO pelea con el jarlog—
4.hQuién instala las extensiones, medido ✅ 2026-09-09 — tres escenarios en scripts/test-atuq-instalacion.py: instala el ESCANEO de distribution/extensions/, install_url con file:// no instala nada. El README de la receta afirmaba lo contrario y quedó corregidoel mecanismo del que cuelga la extensión de sct deja de ser una nota al pie y pasa a estar vigilado—
5Host de native messaging — HECHO 2026-09-10. puriy-costura en tawasuyu (ebe41e96) + el cable shared/foreign-webext, 14 tests sin navegador; y del lado de acá recipes/puriy-costura.toml ⇒ b3:3b633431, ELF estático de 952 K verificado como artefacto: contesta un marco de 4 bytes y hace la promesa del §6.1 completa (4 visitas aprendiendo → stable → un script cambiado = un evento). El camino ya estaba comprobado en el §7.bis con sonda y control negativolos verbos del §64 ✅
6sct v1 — HECHA 2026-09-10. Extensión sct@atuq.tawasuyu (filterResponseData → native messaging) + el manifiesto en /usr/lib/mozilla/native-messaging-hosts/ + el lanzador que le pone el --state que el manifiesto no puede llevar. Guardián scripts/test-atuq-sct.py: servidor HTTP real, seis cargas, una carga = una visita vigilado, la insignia releída, y dos controles (la 5ª repite el mismo script y no alerta; sin manifiesto no hay veredicto)el diferenciador que nadie tiene, andando5 ✅
7Descargas al CAS — HECHA 2026-09-10. cas.ingest/cas.list en el host + la extensión descargas@atuq.tawasuyu; ingesta en STREAMING (arje-cas::almacenar_fichero_en, nueva) para no cargar una ISO entera en RAM; raíz propia porque el GcCas de arje borraría las descargas en silencio; el fichero del usuario no se toca. Guardián scripts/test-atuq-descargas.py con descarga real y control negativo6.2, y alimenta 6.95 ✅
8Archivo personal — la mitad de congelar y buscar, HECHA 2026-09-10. archive.add/archive.search + la extensión archivo@atuq.tawasuyu: el HTML ya ejecutado al CAS, el índice buscable en JSON, y nada de ventanas privadas. Guardián scripts/test-atuq-archivo.py, cuyo control negativo además dice quién impidió archivar lo privado. Falta la mitad semántica: un motor rag-motor::RagMotor (como willay-rag), que exige daemon de embeddings + LLM real6.35 ✅, 7 ✅
9Medios (6.6) ✅ y torrent (6.9) ✅ — 2026-09-10. El medio lo abre mpv; el torrent lo toma puriy-costura-torrent, un daemon propio, configurable y perezoso que sobrevive al navegador y cosecha al CAS. Medido antes de decidir: la pila de librqbit compila para musl (226 crates), así que la decisión fue «no ahí» y no «no se puede». Faltan el foco (6.5) y la IA local (6.7), ésta bloqueada porque el corpus no tiene modelo ni embeddings6.5–6.7, 6.95 ✅
9.aProxy por contenedor ✅ v0.5 — contenedores por política + extensión con proxy.onRequest6.8, y NO dependía de 5: es API de Firefox—

Las unidades 2 y 4 son paralelizables: la toolchain no toca el chrome y el chrome no toca la toolchain. Si hay dos frentes, van juntas.

9. Por qué esto no abandona a puriy

puriy es el motor DOM/CSS propio con JS real (QuickJS-NG sobre wasmi) cuyo límite declarado no es el lenguaje sino el wiring nativo: DOM bindings completos, red, render. Eso no se acelera escribiendo otro navegador; se acelera con tiempo.

atuq no le compite porque no comparte una sola línea con él, y le sirve porque todo lo del §6 vive del lado Rust: el host del §7, el testigo de sct, las descargas al CAS, el archivo con RAG. Son agnósticos del motor. Cuando puriy madure, se enchufan del mismo lado sin reescribirse.

Dicho al revés, que es como conviene recordarlo: atuq es el navegador que se puede usar mientras puriy crece, y el andamio de las features que puriy va a heredar. Si dentro de dos años atuq se apaga, lo que se tira es una receta derivada y una hoja de CSS; todo lo demás sobrevive.