Files
takana/docs/26-atuq-envoltorio-gecko.md
T

1577 lines
113 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SDD 26 — `atuq`: el envoltorio Gecko de la distro
Escrito 2026-09-05, a partir de la pregunta del usuario: *«ya habiendo compilado firefox y waterfox,
qué tan complicado vs provechoso suena crear nuestro propio envoltorio, en vez de zen?»*, y de su
aclaración de por qué la pregunta existe: **`puriy` quedó muy verde y es difícil sacarlo de ahí**.
`atuq` es *zorro* en quechua. El nombre estaba libre —verificado con `grep -ri atuq` sobre
`/mnt/vvv/takana` y `/mnt/vvv/tawasuyu`, cero coincidencias— y dice exactamente lo que la cosa es:
otro fox, con marca propia y sin usar la de Mozilla (ver el §branding de `recipes/firefox.toml`).
**Este documento no reemplaza a `puriy` ni lo cancela.** `puriy` sigue siendo el motor soberano; el
§9 explica por qué `atuq` es su andamio y no su desvío.
---
## 1. La corrección que ordena todo: no hay envoltorio fuera del chrome
La intuición natural —una carcasa Llimphi con nuestros widgets, y Gecko adentro pintando la página—
**no es posible**. Gecko no tiene API de embebido en escritorio desde que murió XULRunner; GeckoView
existe y es de Android. No hay forma soportada de meter el motor en una ventana de `mirada`.
⇒ un envoltorio de Gecko es, necesariamente, **chrome-level**: JS/CSS/XHTML *dentro* del propio
Firefox, más extensiones, más procesos externos que hablen con él.
Y eso es exactamente lo que Zen es. Zen no toca el motor: su diferencia entera —workspaces, split
view, glance, compact mode— vive en el chrome, más una herramienta (`surfer`) que reaplica sus
parches sobre el árbol de Firefox y recompila. **Su costo no es el motor: es la cinta de correr del
rebase cada cuatro semanas.** Copiar su método sería heredar su costo sin heredar su equipo.
## 2. La decisión de arquitectura: artefacto DERIVADO, no fork de fuente
Si `atuq` parchea el árbol, **cada iteración de UI cuesta un build de Gecko de cuatro horas**. Eso
mata el proyecto en la primera semana de diseño, porque el diseño de chrome es iterativo por
naturaleza.
Pero nosotros no distribuimos un binario: sellamos artefactos. Entonces `atuq` es una receta cuyo
`[deps]` incluye `firefox`, y cuyas fases copian el árbol instalado e inyectan encima:
| Qué se inyecta | Mecanismo de Firefox | Sin recompilar |
|---|---|---|
| Marca, iconos, nombre | `distribution/`, `application.ini` | ✅ |
| Prefs de fábrica | `defaults/pref/*.js` + autoconfig (`.cfg`) | ✅ |
| Política de empresa | `distribution/policies.json` | ✅ |
| Chrome propio (dientes, layout) | `userChrome.css` + re-empaque de `omni.ja` | ✅ |
| Extensiones nuestras | `distribution/extensions/` | ✅ |
**Ventajas que esto tiene sobre el método de Zen, y que son estructurales:**
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:**
- **`omni.ja` es un zip ⇒ hay que re-empacarlo determinista** (mtimes fijos, orden estable) o el
artefacto deja de reproducir bit a bit. Es la misma disciplina que ya exige la imagen del lab.
- **El `jarlog` del PGO ordena el `omni.ja` para el arranque** (§3.bis). Re-empacarlo a lo bruto
tira esa optimización a la basura sin que nadie lo note. El re-empaque preserva el orden.
- **Hay que invalidar el startup cache** tras tocarlo (fichero `.purgecaches` junto al binario, o
bump del BuildID). Si no, los cambios no se ven y **parece que el overlay no agarró**: un falso
negativo de los caros, de la misma familia que el cache-hit que congela regresiones.
### 2.bis Cuándo SÍ hay que parchear el árbol
Cuando algo necesite tocar C++ del motor. Hoy conocemos un caso: `sct` en su forma fuerte (§6.1).
La regla es: **nada baja al árbol antes de que exista la toolchain del §3**, porque hasta entonces
cada intento cuesta cuatro horas. Y cuando baje, el vigía de parches (`scripts/vigia-parches.py`,
commit `aa200a4`) es lo que convierte esas cuatro horas en un minuto para saber si el parche agarra.
## 3. La puerta de las optimizaciones: no es un flag, es una toolchain
El usuario decidió habilitar las optimizaciones. Lo que hay que saber antes de intentarlo:
`recipes/firefox.toml` va con `compiler = "gcc"` por el **muro 3** (zig enlaza `libc++` estática para
musl y el `configure` de Mozilla exige encontrar un `NEEDED …libc++`; es negativa de upstream, no una
perilla). El problema es que **PGO, LTO y BOLT son cadena de clang en Gecko**:
- `--enable-lto=cross` quiere clang + lld. El LTO de GCC sobre Gecko no está soportado upstream.
- `--enable-profile-use` espera `-fprofile-instr-use` (clang), no `-fprofile-use` (gcc).
- BOLT necesita `llvm-bolt`.
Y **el corpus no tiene clang usable como compilador**. Verificado en las recetas, no supuesto:
- `recipes/clang18.toml` compila `ninja -C build libclang.so clang-resource-headers` y su fase
`install` copia **sólo** `build/lib/libclang.so*` y las cabeceras `clang-c`. Está ahí porque
`bindgen` carga `libclang.so` en runtime. **No hay driver `clang`, ni `lld`, ni `libc++`.**
- `llvm18` se selló con `-DLLVM_ENABLE_PROJECTS=""` — LLVM a secas.
⇒ la primera versión de este documento concluyó que hacía falta **una receta `llvm-toolchain`
(clang+lld+libc++ desde fuente)**. **Era caro de más, y se corrigió el mismo día mirando el lab en
vez de suponerlo:**
- `.dev-fs/alpine` **ya trae clang22 + llvm22 22.1.8** — la misma major que usa el APKBUILD de
Alpine para este mismo Firefox (`_llvmver=22`).
- **`Compiler::Clang` ya existe en takana**, cableado de punta a punta (`parse_compiler` lo acepta;
`takana-build` pone `CC=clang`, `CXX=clang++`, `AR=llvm-ar`). Ninguna receta lo usaba.
- Faltaba **sólo `ld.lld`**: `apk add lld` ⇒ dos paquetes, cero upgrades.
Y los tres muros caen sin perder lo que gcc daba: el sondeo de linker se satisface con
`--enable-linker=lld`, el `ar` lo pone takana solo, y el `NEEDED` de la stdlib de C++ existe porque
clang++ de Alpine usa la `libstdc++` **compartida** — la prueba no es teórica: Alpine construye este
Firefox con clang22 y **sin** `libcxx` en sus makedepends.
**La huella del lab no se movió, y se midió antes de tocar nada.** `lld` no casa ningún prefijo de
`TOOLCHAIN_PREFIXES` (`takana-core/src/lab.rs`), así que los 43 paquetes que entran en `hash_inputs`
salieron idénticos ⇒ **los 837 artefactos sellados quedan intactos**. Eso abarata el cambio hoy y a
la vez deja un agujero escrito: la versión de `lld` no es parte de la identidad del artefacto, y sólo
expone a las recetas `compiler="clang"` — hoy, una. Cerrarlo cuesta re-hashear el corpus entero.
**Hecho el 2026-09-05** (commit `cb3ecd5`): `firefox` va con `compiler = "clang"`,
`--enable-linker=lld`, `--enable-lto=cross`, `--enable-packed-relative-relocs` y
`--with-unsigned-addon-scopes=app,system`. Falta construirlo.
**Riesgos de PGO, escritos antes de empezar:**
- El PGO de Mozilla **corre el navegador** para juntar el perfil (`profileserver.py`, headless +
marionette). Dentro de un sandbox hermético, sin red y sin X11, eso hay que hacerlo andar.
Headless no necesita display, pero sí necesita que el árbol de perfil se genere adentro.
- **El perfil resultante es un input del artefacto.** O entra en `hash_inputs`, o `atuq` deja de ser
determinista y no lo vamos a notar: sería otra forma del lab que no está en `hash_inputs`.
- Ganancia esperable, para calibrar expectativas: PGO+LTO en Gecko son del orden de 10-25% en carga
de página y JS. **Hoy estamos ATRÁS de Zen en este eje, no adelante** — Zen hereda la
configuración de Mozilla tal cual. Esto no es una ventaja nuestra: es una deuda que se salda.
### 3.bis El diff contra Alpine — y no es sólo PGO
Medido el 2026-09-05 trayendo el `APKBUILD` y el `mozconfig` de `community/firefox` de aports y el
`PKGBUILD` de Arch, y comparándolos línea a línea con el mozconfig de `recipes/firefox.toml`.
**Alpine es nuestro propio upstream** —de ahí salen los once parches de musl— así que la comparación
no es contra una distro lejana: es contra la que ya construye este mismo código.
| | `recipes/firefox.toml` | Alpine `community/firefox` |
|---|---|---|
| LTO | **no** | `--enable-lto=cross` |
| PGO | **no** | `--enable-profile-use=cross` + `merged.profdata` + `jarlog` |
| Linker | GNU ld (via gcc) | `--enable-linker=lld` (lld 22) |
| RELR | **no** | `--enable-packed-relative-relocs` |
| Sandbox RLBox | **APAGADO** (`--without-wasm-sandboxed-libraries`) | `--with-wasi-sysroot` |
| Extensiones sin firmar | **no declarado** | `--with-unsigned-addon-scopes=app,system` |
| Allocator | `--disable-jemalloc` | `--disable-jemalloc`**igual, no es desviación nuestra** |
| Librerías | todo bundleado | `--with-system-{icu,nss,nspr,av1,vpx,webp,png,jpeg,zlib,ffi,libevent,pixman,pipewire}` |
Arch hace lo mismo en el eje de velocidad («Do 3-tier PGO», `--enable-lto=cross`,
`--enable-profile-use=cross`). **Ni Alpine ni Arch usan BOLT** (cero menciones en los dos ficheros)
⇒ BOLT no es la desventaja, es el extra. Lo que nos separa del resto del mundo es **PGO + LTO**, del
orden de 10-20% en carga de página y JS.
**Tres lecturas que hay que sacar de esa tabla:**
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ágina | sin PGO | con PGO | |
|---|---|---|---|
| DOM+layout+strings, **fuera** del corpus de entrenamiento | 23.540 ms | 21.327 ms | **9,4 %** |
| SunSpider `3d-raytrace`, **dentro** del corpus | 16.034 ms | 15.878 ms | 1,0 % |
Los rangos no se solapan en ninguna de las dos (23.18523.654 vs 21.03021.650), así que las dos
diferencias son reales y no ruido.
**Y el resultado sale al revés de lo esperable, que es lo interesante.** La intuición dice que
medir sobre el conjunto de ENTRENAMIENTO infla la ganancia; acá el corpus da el número MÁS BAJO. La
razón es que `3d-raytrace` es aritmética pura en un bucle caliente, y ese bucle **no lo ejecuta el
C++ de SpiderMonkey: lo ejecuta código máquina que el JIT genera en tiempo de ejecución**. El PGO
optimiza el intérprete, el GC y el propio compilador JIT — no el código que el JIT emite. Un
benchmark JIT-bound es casi ciego al PGO por construcción.
Donde sí se ve es en el camino DOM/layout/arranque, que es C++ de principio a fin. Y eso es también
lo que un usuario percibe: la medición cubre el ciclo COMPLETO del proceso (arrancar → renderizar →
capturar → salir), no el rendimiento en régimen de una página ya cargada. De ahí que hasta la página
de SunSpider tarde 16 s: casi todo es arranque.
**Corolario para el corpus de entrenamiento — APLICADO Y MEDIDO (2026-09-08).** El corpus estaba
sesgado hacia JS justo donde el PGO menos rinde, así que se le sumaron 10 páginas de maquetación
propias (`scripts/pgo-corpus/`): flexbox, grid, tablas con los dos algoritmos de layout, texto en
columnas, selectores contra 600 reglas, pintado, transforms, SVG, scroll pegajoso y layout
thrashing. El perfil pasó de 5.885.254.799 ejecuciones registradas a **38.498.366.335 (6,5×)**.
| | sin PGO | PGO v1 (36 pág.) | PGO v2 (46 pág.) |
|---|---|---|---|
| DOM/maquetación, fuera del corpus | 23.285 ms | 21.050 ms (9,6 %) | **20.640 ms (11,4 %)** |
| SunSpider `3d-raytrace` | 16.003 ms | 15.846 ms (1,0 %) | 15.857 ms (0,9 %) |
**La mejora en maquetación es real y no de medianas:** SEIS de las siete muestras de v1 son más
lentas que TODAS las de v2; sólo una cae dentro del rango de v2. En SunSpider, en cambio, v1 y v2
son **indistinguibles** —los valores se entrelazan por completo— que es justo lo esperable: ese
camino lo ejecuta el JIT y el PGO no lo toca. Un corpus mejor no puede mejorar lo que el PGO no
alcanza.
⚠ Una de las siete corridas de v2 dio 120.029 ms contra ~20.600 de las otras seis: un atípico
transitorio. **La mediana lo ignora por diseño**; una media lo habría convertido en «v2 es
catastróficamente peor». Fue la razón de elegir mediana antes de ver un solo número.
**EL JARLOG: PROBADO, MEDIDO, Y APARCADO PORQUE ROMPE `atuq` (2026-09-08).** Desde
`d9833a58` el perfil trae también el `jarlog` y `firefox` lo consume con `--with-pgo-jarlog`. No
hizo falta la extensión `Quitter` de Mozilla, que este documento daba por bloqueante: su
`profileserver.py` sólo traduce `JARLOG_FILE` a `MOZ_JAR_LOG_FILE` en el entorno del navegador.
| | sin PGO | v1 (36 pág.) | v2 (46 pág.) | v3 (= v2 + jarlog) |
|---|---|---|---|---|
| DOM/maquetación | 23.008 ms | 20.851 (9,4 %) | 20.527 (10,8 %) | 20.502 (10,9 %) |
| SunSpider | 16.006 ms | 15.859 (0,9 %) | 15.869 (0,9 %) | 15.838 (1,0 %) |
**El jarlog solo aporta 0,1 % y 0,2 %: indistinguible de cero.** Y el argumento no es que los
rangos se solapen —que se solapan— sino que **la misma variante varía ~1 % entre corridas de días
distintos** (v1 dio 20.851 hoy y 21.050 ayer). El suelo de ruido del banco es **diez veces** el
efecto buscado.
Eso NO es un fallo del jarlog: es que el banco corre con la **caché de página caliente**, y ahí
reordenar `omni.ja` no ahorra ninguna lectura. Su terreno es el arranque EN FRÍO, que este
instrumento no reproduce; medirlo pediría vaciar la caché entre corridas, que es una acción de
sistema. **Lo que sí está probado es que la función existe**, y del lado del artefacto: `libxul` es
IDÉNTICO byte a byte entre v2 y v3 y el `omni.ja` difiere en 26 bytes — el jarlog tocó exactamente
lo que debía tocar y nada más. **Y sí costaba.** Al reconstruir `atuq` sobre ese firefox, murió:
zipfile.BadZipFile: Bad magic number for central directory (rebrand.py)
El jarlog convierte el `omni.ja` al **formato «jar optimizado» de Mozilla**, que mueve el directorio
central al principio:
sin jarlog: PK\003\004 ZIP estándar — `zipfile` lo abre, 5306 entradas
con jarlog: \376\204#\0 `zipfile` lo rechaza
Y `atuq` reempaqueta el `omni.ja` con `zipfile` para su branding. O sea que el jarlog **rompe el
navegador propio de la distro**.
**Aparcado, y la cuenta es asimétrica:** el coste está MEDIDO y el beneficio NO. Cambiar algo que
funciona por una ganancia que no se pudo medir, rompiendo algo que sí funcionaba, es mal negocio.
Se retoma cuando (1) se mida el arranque EN FRÍO, que es su terreno, y (2) `rebrand.py` sepa leer el
jar optimizado o des-optimizarlo antes. El blob `92497cdd…` del mirror ya trae el jarlog, así que
retomarlo es cambiar una línea y re-pinear el sha256 — no volver a perfilar.
**Y la lección de método, que es la que más vale:** el jarlog parecía GRATIS. «Es lo que hace
upstream y no cuesta nada» — lo escribí yo, en este documento, hace unas horas. El coste no apareció
midiendo el jarlog sino **construyendo lo que dependía de él**. Una función que se declara gratuita
sin haber reconstruido a sus consumidores no es gratuita: es no medida.
**Dato que los datos delatan:** los dos valores atípicos del banco son 120.034 y 120.030 ms, o sea
exactamente el `timeout 120` del arnés. No son ruido de carga: son corridas COLGADAS al arrancar en
headless, 2 de 56 (~3,5 %). Sin perseguir, pero anotado.
⚠ Y lo que este banco NO puede afirmar: las 10 páginas nuevas ejercitan los mismos subsistemas que
la página de medición, así que el parentesco con lo que ahora se entrena es mayor que antes. El
11,4 % es real para ESTA carga; generalizarlo a «cualquier página» sería justo el error que el
propio corpus de Mozilla cometía al revés.
## EL CONTROL QUE FALTABA: el arranque eran 16 s, y sin restarlo ningún porcentaje significa nada (2026-09-08)
Todo lo de arriba mide **el ciclo completo del proceso**: arrancar firefox, renderizar, capturar el
PNG y salir. Nunca se midió cuánto de eso era la página. Faltaba el control más barato posible —una
página VACÍA— y con él las cifras publicadas se leen distinto:
| carga | total sin PGO | total con PGO | **trabajo** sin → con | mejora del TRABAJO |
|---|---|---|---|---|
| `vacia.html` (arranque puro) | 16.026 ms | 15.883 ms | — (ES la línea base) | **0,9 %** |
| DOM/maquetación | 23.307 ms | 20.627 ms | 7.281 → 4.744 ms | **34,8 %** |
| carga ajena (canvas/JSON/img) | 20.576 ms | 20.517 ms | 4.550 → 4.634 ms | **+1,8 % (nada)** |
| SunSpider `3d-raytrace` | 15.992 ms | 15.877 ms | **34 → 6 ms** | bajo el ruido |
Las cuatro cargas y las dos variantes, **en una sola sesión intercalada**: restar el arranque de una
corrida al total de otra no vale, porque la misma variante deriva ~1 % entre días.
**Qué cambia, en concreto:**
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:
| receta | sha256 del árbol | |
|---|---|---|
| `wasi-libc-headers` | `0d34f9cb27530606…` | ✓ |
| `wasi-compiler-rt` | `0c4fc6fdf08ef908…` | ✓ |
| `wasi-libc` | `c13acdf896f2b78c…` | ✓ |
| `wasi-libcxx` | `b1d9aeec08499063…` | ✓ |
| `wasi-sdk` | `b49f7a74a5caed3f…` | ✓ |
No es un trámite: **el lab NO entra en `hash_inputs`**, así que dos labs distintos pueden sellar
bytes distintos en la misma dirección del store y nada lo detecta. Esto dice que para esta cadena
los dos labs coinciden de verdad, y no sólo que coinciden los `ArtifactHash`.
## 4. Lo que `atuq` NO promete
Esta sección existe antes que la lista de features a propósito, con el mismo criterio que el modelo
de adversario de `qullqa`: **lo que no se promete se escribe primero, porque una promesa falsa de
privacidad es peor que no ofrecer nada.**
**`atuq` no es Tor Browser y no va a tener «modo Tor».** Tor Browser no es «Firefox + SOCKS»: su
valor son las contramedidas de huella (RFP, letterboxing, normalización de fuentes/canvas/timers) y
sobre todo **un conjunto de anonimato donde todos se ven idénticos**. `atuq` es, por construcción, un
binario único en el mundo: musl, Wayland-only, branding propio, extensiones propias. Alguien saliendo
por Tor desde `atuq` es **más** identificable, no menos, y su conjunto de anonimato son diez
personas. Para anonimato: Tor Browser, y lo decimos nosotros primero.
Lo que sí se ofrece, y es honesto porque es otra cosa: **proxy por contenedor** (§6.8) — separación
de tráfico, no anonimato.
**`atuq` tampoco promete** anti-fingerprinting propio (es un programa de investigación con equipo
full-time) ni bloqueo de anuncios propio (se shipea uBO y listo).
## 5. Contexto verificado al escribir (2026-09-05)
De `docs/state/build-state.json`, no de memoria:
| Dato | Valor |
|---|---|
| Recetas / nodos | 838 / 840 |
| Selladas | 837 |
| `firefox` 154.0 | **sealed**, cola `corpus` |
| `waterfox` 6.7.1.1 | **never** — primer build en curso |
| Plataforma compartida | `gtk3`, `atk`, `nodejs`, `clang18`, `cbindgen`: todas sealed |
**`atuq` no arranca hasta que `waterfox` selle.** Waterfox es la prueba de la tesis del reúso —«las
diferencias reales con `firefox.toml` son TRES: la fuente, el branding y la versión»—. Si esa tesis
falla, el precio de todo este documento cambia y hay que releerlo.
## 6. Los diferenciadores, priorizados
La columna «quién más lo tiene» es lo que evita que nos contemos un cuento.
| # | Qué | Quién más lo tiene | Pieza que ya existe | Costo |
|---|---|---|---|---|
| 6.1 | **`sct` — transparencia de scripts** | **nadie** | `puriy-sct`, `puriy-sct-testigo` | medio |
| 6.2 | **Descargas direccionadas por contenido** | nadie | store CAS de takana, `tejido` | bajo |
| 6.3 | **Archivo personal + RAG local** | Rewind/Recall (nube, Windows); SingleFile (guarda, no busca) | `khipu`, `rag-motor`, `willay-rag` | medio |
| 6.4 | **Historial y perfil sobre `qullqa`** | nadie | `qullqa-core`, `qullqa-pozo` | medio |
| 6.5 | **Foco por `cortafuegos`, no por extensión****cadena y navegador; falta quién lo aplica** | nadie (nadie es dueño del navegador *y* del sistema) | `cortafuegos`, `nftables` (nuevo en el corpus) | bajo |
| 6.6 | **Medios por fuera del navegador** | extensiones sueltas; de fábrica no | `foreign-ytdlp`, `-platform`, `-dlna` | bajo |
| 6.7 | **IA local en la barra lateral** | Chrome/Edge son nube; Zen no tiene | `rimay`, `iniy` | bajo |
| 6.8 | **Proxy por contenedor****v0.5** | nadie de fábrica | — (API de Firefox) | bajo |
| 6.9 | **Torrent adentro** | **Vivaldi lo trae; Brave tuvo WebTorrent** | `shared/foreign-torrent` (SDD propio, sobre librqbit) | bajo |
### 6.1 `sct` es la joya
BLAKE3 a todo lo que se ejecuta, y **negarse a correr código que ningún testigo vio nunca**. Es lo
más cerca que hay de «Certificate Transparency, pero para el JavaScript que te corre en la cara», y
no lo tiene nadie: ni Zen, ni Brave, ni Tor Browser.
En tawasuyu ya está escrito y —dice su README— la lógica es **agnóstica del transporte** y está
certificada sin red; `puriy-sct-testigo` es sólo el cable HTTP.
- **v1 — HECHA 2026-09-10, y con una corrección de este propio párrafo.** La extensión intercepta el
cuerpo de cada `<script src>` con `filterResponseData` y se lo pasa al **host** (§7), que hashea y
decide. Decía «consulta al testigo antes de dejarla pasar», y las dos mitades había que ajustarlas:
el cable del testigo es un POST con **postcard**, así que un JS no puede ser su cliente (§7.quater);
y v1 **observa y avisa, no bloquea** — que es lo que `puriy-sct` dice de su propia v1, y además
bloquear cada script contra un viaje entre procesos no es «más seguro», es un navegador que nadie
usa. Medida de punta a punta por `scripts/test-atuq-sct.py`, con servidor HTTP real, seis cargas de
página y dos controles.
- **v2, forma fuerte:** gancho en el script loader de Gecko. Eso sí es parche de árbol ⇒ §2.bis ⇒
después de la toolchain del §3. Es también lo único que ve los scripts **inline**
`filterResponseData` entrega el cuerpo de una PETICIÓN, y un inline viaja dentro del HTML— y lo único
que puede negarse a ejecutar.
#### 6.1.bis Lo que costó armarla, que es lo que no se puede deducir (2026-09-10)
Cinco cosas se midieron en vez de suponerse, y cada una tenía su forma de fallar en silencio:
| Qué | Cómo falla si se supone mal |
|---|---|
| El manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/`, no en el appdir (`XRESysNativeManifests` es un `/usr/lib/mozilla` **compilado**) | el puerto se cierra «sin error y sin mensaje» |
| **El manifiesto NO puede llevar argumentos**`NativeMessaging.sys.mjs`: `command = manifest.path`, y los únicos argumentos son `[ruta-del-manifiesto, id]` | `puriy-costura` sin `--state` corre en memoria ⇒ cada arranque vuelve a «aprendiendo» y **nada alerta nunca**: la función existe, no falla, y no protege |
| `filterResponseData` + el permiso `webRequestFilterResponse` **están** en nuestro `omni.ja` | se habría escrito la extensión contra una API que el build no tiene |
| Los `inline` no se ven por esta vía | se habría prometido cobertura total teniendo la mitad |
| **Una carga puede producir dos peticiones del mismo documento, y una llega con `tabId = -1`** | agrupando por `(tabId, documento)` esa carga contaba **dos visitas** |
La última es la que más importa y la que menos se veía. Con las visitas infladas, un origen **se
estabiliza antes de conocer su código real**, y entonces alerta por churn legítimo: el falso positivo
que la spec de `puriy-sct` pide evitar por encima de todo. Se agrupa por documento —lo que hace que dos
pestañas con la misma url cuenten UNA, que es el error seguro: tarda más en proteger, no alerta de
más— y el guardián vigila el invariante «una carga, una visita», así que si vuelve, falla ruidoso.
**Y el aviso se mide, no se supone.** La extensión relee la insignia con `getBadgeText` después de
ponerla y lo dice, así que el guardián exige insignia vacía en las cinco cargas sin novedad y `"1"` en
la del script cambiado. Es la única parte de toda la cadena que el usuario llega a ver: dejarla como
«se llamó a la API» sería dejar sin medir justo el final.
**Dos límites escritos donde no se pueden no leer** (el propio `fondo.js`, el `lib.rs` del host y los
dos LEEME): se hashea el TEXTO ya decodificado, que vale para comparar dos cargas nuestras y **no** es
comparable con el hash de los bytes servidos que publique un tercero; y el aviso es pasivo (insignia,
no modal) porque un modal por despliegue entrena a cerrarlo sin leer.
### 6.2 Una descarga con identidad
Lo bajado no es un archivo en `~/Downloads`: es un objeto BLAKE3 en el store — deduplicado,
verificable, y re-compartible por `tejido`. El torrent (6.9) alimenta lo mismo. **Ningún navegador
trata una descarga como algo con identidad**; para todos es un blob con nombre, y por eso lo bajás
dos veces y no lo sabés.
Ésa, y no «tener torrent», es la razón por la que 6.9 vale: Vivaldi ya tiene torrent, pero termina en
una carpeta.
#### 6.2.bis Hecha (2026-09-10) — y el almacén NO es el del sistema, por una pérdida de datos medida
`cas.ingest` en el host + la extensión `descargas@atuq.tawasuyu`, que observa las descargas
TERMINADAS y le pasa la ruta. El objeto entra a un CAS BLAKE3 y el navegador contesta el hash, el
tamaño, con qué nombre apareció la primera vez y **cuántas veces se bajó ese mismo contenido**.
1ª descarga contenido A como `uno.bin` ⇒ dedup=false, veces=1
2ª descarga contenido A como `dos.bin` ⇒ dedup=TRUE, veces=2, UN solo blob en disco
**No es un almacén nuevo**: es `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y
`tejido` — lo dice el propio código de tejido («esto NO es un almacén nuevo»). Un objeto que entra por
acá queda donde los otros lo van a buscar, y el `<hex>` del CAS **es** el `expected_hash` de un `.swm`.
**Tres cosas que este trabajo dejó, y ninguna es la feature:**
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:
| mitad | estado |
|---|---|
| **congelar cada página leída y poder encontrarla** | ✅ HECHA — `archive.add` / `archive.search` + la extensión `archivo@atuq.tawasuyu` |
| **preguntarle al historial en lenguaje natural** | ❌ falta, 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.5 Foco por `cortafuegos` — la mitad del sistema, MEDIDA (2026-09-10)
**La primera corrección es a este propio documento.** La fila de la tabla dice «foco por
`cortafuegos`» y es fácil leerla como «bloquear sitios que distraen». **El cortafuegos de tawasuyu no
sabe de sitios**: su política de egress (`cortafuegos-core::Politica`) tiene exactamente una
dimensión —**qué cgroup puede salir**— y ninguna de destino; ni dominio, ni IP, ni puerto. Leído en
la fuente (`UnidadRed { nombre, cgroup_path, concesion }`), no supuesto. Así que el foco que estas
piezas permiten no es «Twitter no carga»: es **«el navegador no sale»**, con todo lo local
—incluido el archivo personal del §6.3 y el CAS del §6.2— funcionando igual. Que es, además, una
promesa más honesta: una lista de dominios se esquiva con un espejo, y un cgroup sin egress no.
**Y la diferencia con una extensión no es de grado.** Un bloqueador de extensión lo apaga en dos
clics la misma persona que quería no distraerse; un reglaset `nft` que aplicó root no lo toca el
navegador ni sus extensiones. Por eso la fila decía «nadie lo tiene»: nadie es dueño del navegador
*y* del sistema.
**Lo que hizo falta traer.** `nftables` estaba en el grafo como `wanted` y nadie había puesto la
cadena: entran `recipes/libmnl.toml`, `recipes/libnftnl.toml` y `recipes/nftables.toml` (1.1.6, la
versión contra la que el SDD del cortafuegos validó `socket cgroupv2` en metal). Dos cosas venían de
fábrica y están en los comentarios de la receta: nftables **no reproduce**`MAKE_STAMP` es
`$(shell date +%s)` horneado en el binario, la familia del `BuildID` de waterfox— y su
`config.status` trae un bashismo que busybox rechaza. Las dos se arreglan en el parche, y el control
existe: con el sello vivo, dos builds de la misma fuente **divergen** en `libnftables.so`
(`why-differs`: `.data`, `.text`); con el parche, `verificar-repro.sh` da REPRODUCE.
**La medición, que es la unidad de trabajo de verdad** (`scripts/test-foco-egress.sh`, lanzado con
`scripts/foco-egress-remoto.sh`):
```
nft: nftables v1.1.6 ← el NUESTRO, musl, desde el artefacto sellado
CONEXION linea-base exit=0 ← sin reglas: el harness mide algo
CONEXION control exit=0 ← con reglas: el cgroup permitido SÍ conecta
CONEXION foco exit=1 ← el cgroup del navegador NO
```
Tres medidas y no una: la **línea base** existe porque un harness roto («no conectó») se lee
exactamente igual que un foco que funciona, y el **control** porque un reglaset que niega de más no
se distingue de uno que niega bien. Con `--broken-rules` —que le abre egress también al cgroup en
foco— el guardián falla diciendo `el cgroup EN FOCO conectó igual`, y sale 1.
**Por qué no corre en el hub, y es una restricción real, no comodidad.** Crear un cgroup pide root:
medido, `mkdir /sys/fs/cgroup/...` da EPERM **incluso dentro de un userns con `--cap-add ALL`**,
porque el permiso se chequea contra la jerarquía real. Y `nft -c` **no es un chequeo de sintaxis**:
resuelve el path del cgroup contra la máquina viva y falla con `cgroupv2 path fails` si no existe. O
sea que ni siquiera *verificar* una política del cortafuegos se puede sin los cgroups puestos. Corre
en el LXC prestado, y todo va dentro de `unshare -m --propagation private -n`.
**Y ese `--propagation private` se pagó caro.** `/sys/fs/cgroup` está montado **shared**, así que
desmontar la COPIA de un `--rbind` **se propaga al montaje real**: dos corridas dejaron al worker
**sin `cgroup2` montado**, en silencio —los builds seguían— hasta que el guardián siguiente dijo «no
hay cgroup2 montado». Se remontó y la jerarquía volvió intacta (los cgroups viven en el kernel, no en
el montaje). Tres lecciones que quedan en el guión: los montajes van en un **mount namespace propio**
y se van solos; **nunca `umount -l`** antes de un `rm -rf` (un desmontaje perezoso desaparece de
`/proc/mounts` mientras el árbol sigue ahí, así que la guarda pasa y el borrado entra en el `/sys` de
la máquina); y antes de borrar, **preguntarle a `/proc/mounts`**.
**Tres falsos positivos más que cazó el propio harness, y todos se veían como éxito:**
1. un `gmp` **viejo** empaquetado por `ls store/*-gmp` (sólo `libgmp.a`) ⇒ `libnftables.so` no
relocaba y **ninguna** conexión salía. En el hub no se había notado porque ahí el lab tiene su
propia libgmp y la tapaba: el artefacto parecía autosuficiente y no lo era. Ahora los artefactos
se resuelven por `takana hash` (vigente), y el guardián corre `nft --version` **antes** de todo;
2. `/sbin` fuera del `PATH` del chroot ⇒ no había `ip`, el loopback quedaba caído y todo fallaba;
3. y el mejor: **un comentario que se ejecutó**. El guión interior se escribía con un heredoc sin
comillas, y un comentario que mencionaba `` `ip` `` y `` `ifconfig` `` entre backticks los corrió
**en el host** y pegó la salida —con las IPs de la máquina— dentro del guión generado. Ahora el
heredoc va entrecomillado y los nombres entran por entorno.
**Lo que falta para que esto sea un botón, y por qué no se inventó de paso.** Dos piezas, ninguna del
navegador:
- **quién pone a `atuq` en un cgroup.** Hoy nadie: los cgroups de la distro los crea arje para sus
servicios, y una app de escritorio la lanza el usuario. Hace falta delegación de un subárbol a la
sesión, que es decisión de arje (SDD 10), no de este documento;
- **quién aplica la política.** `cortafuegos apply` pide root. Un demonio que lo haga a pedido del
navegador es superficie nueva: se decide, no se agrega de paso.
Mientras eso no exista, lo que `atuq` puede tener —y tiene, §6.5.bis— es la mitad honesta: **ver** el
estado del foco y **no poder apagarlo**.
#### 6.5.bis La mitad del navegador: `focus.state`, y la asimetría a propósito (2026-09-10)
La extensión `foco` **sólo lee**. Pregunta `focus.state` al host cada minuto —el estado lo cambia root
por fuera, no hay evento al que suscribirse— y pinta tres cosas: `foco`, nada, o **`?`**. El tercer
caso es el que importa: el host contesta `unknown` cuando nadie escribió el estado o cuando el
contenido no se entiende, y **eso no se redondea a `off`**; una insignia que dijera «sin foco» sin
haber mirado afirmaría un hecho que no tiene.
**Y no hay verbo para apagarlo.** `focus.stop` cae en «verbo desconocido», igual que `focus.off` y
`focus.start`. No es una omisión pendiente: es la propiedad entera. Si el navegador pudiera levantar
el foco, esto valdría exactamente lo que vale un bloqueador de extensión —dos clics— que es el
problema que el §6.5 venía a resolver. Encenderlo tampoco está: aplicar un reglaset pide root y este
proceso corre como el usuario. Hay un test en tawasuyu que lo fija por nombre, y se puso rojo a
propósito agregando un `focus.stop` «por comodidad».
El estado vive en `/etc/takana/focus` (lo escribe root junto con el reglaset). `scripts/test-atuq-foco.py`
corre **el path de producción, no la escotilla de pruebas** —una escotilla mide el código, no el
contrato con la imagen— en tres sesiones del navegador, y en cada una comprueba tres cosas: qué dijo
la extensión, qué pintó, y que el fichero **quedó igual** después de la sesión. Eso último es la
promesa del §6.5 medida del lado del navegador. Verificado rompiéndolo: con una extensión parcheada
que pinta «foco» pase lo que pase (dentro de una COPIA del artefacto, vía `ATUQ_DIR`), el guardián
falla nombrando la insignia.
```
-- fichero de foco: on → ESTADO on · INSIGNIA "foco" · quedó on
-- fichero de foco: off → ESTADO off · INSIGNIA "" · quedó off
-- fichero de foco: (no existe) → ESTADO unknown · INSIGNIA "?" · siguió ausente
```
### 6.6 Medios fuera del navegador — HECHO (2026-09-10)
Una navegación de PRIMER NIVEL a un medio (`Content-Type: video/*` o `audio/*`) se cancela y la URL se
la lleva el reproductor de la distro. `mpv` ya viaja en las cuatro imágenes de escritorio, así que
esto no agrega ni una receta: es cablear lo que ya estaba.
**La regla es estrecha a propósito.** Un `<video>` embebido es parte de la página y sacarlo de ahí
rompería el sitio que lo puso; lo que se cambia es el caso en que el navegador no estaba pintando una
página sino haciendo de reproductor —seguiste un enlace a un `.mp4`—. Las reglas anchas en el camino
de cada petición son las que terminan rompiendo la web de alguien.
**La seguridad del verbo es que el binario NO viene del cable**: es una constante del host
(`/usr/bin/mpv`). Si el programa viajara en el mensaje, esto dejaría de ser «abrir un vídeo» y sería
un lanzador de procesos arbitrarios manejado, al final de la cadena, por una página web. Sólo viaja la
URL, sólo `http(s)`, y detrás de un `--`.
**Fail-open, y es la promesa que el control negativo prueba.** Cancelar es SÍNCRONO y la respuesta del
host llega después, así que no se puede saber en el momento si el reproductor arrancó. Se cancela sólo
si el puerto está vivo, y si el host contesta que no pudo, la URL **vuelve al navegador** (con una
marca para no entrar en bucle). Medido:
SIN REPRODUCTOR no hay reproductor en /usr/bin/mpv: esta imagen no lo trae
VUELVE AL NAVEGADOR http://…/audio.wav?atuq-medios=no
Quedarse sin vídeo Y sin pestaña es el único resultado inaceptable, y es el que se consigue si uno
confía en que salió bien.
**⚠ Y el bug que esto destapó vale más que la función.** La primera corrida hizo todo bien —detectó el
medio, canceló, el host contestó con el pid— y después:
DESCONECTADO Native application tried to send a message of 546281442 bytes,
which exceeds the limit of 1048576 bytes.
**Un hijo hereda los descriptores del padre, y los del padre SON la tubería de native messaging.** El
reproductor escribía su salida ahí y el navegador la leía como un marco. Tres redirecciones, cada una
contra un fallo distinto: `stdin` a null (`mpv` lee stdin para sus comandos: heredándolo **se come los
mensajes del navegador**), `stdout` al **`stderr` del host** —no a `/dev/null`: apagarlo arreglaría el
bug y se llevaría puesto el diagnóstico, y Gecko manda ese `stderr` al log del navegador, que es donde
uno mira cuando «el vídeo no abrió»— y `stderr` heredado por lo mismo.
El test que lo fija **arranca el binario, no la lib**, y ésa es la parte reutilizable: en proceso, el
`stdout` del host es el del arnés y la contaminación no se ve — el test habría pasado con el bug
puesto. Se comprobó rompiéndolo a propósito.
⚠ Y una nota del arnés que ya apareció dos veces: la URL no se pasa por la línea de comandos, porque
una petición del ARRANQUE compite con la inicialización de la extensión. El guardián navega a una
página que redirige a los tres segundos — que además es lo que hace una persona: seguir un enlace.
### 6.8 Proxy por contenedor — HECHO (v0.5, 2026-09-06)
**Es el primer diferenciador del §6 que se paga entero**, y se pudo pagar ahora porque es el único
de la lista que no pasa por el host del §7: es API de Firefox y nada más. Cada contenedor
—Personal, Trabajo, Banco, Compras— puede salir por su propio proxy.
Tres piezas, en tres ficheros, y **ninguna alcanza sola**:
| Pieza | Dónde | Qué aporta |
|---|---|---|
| `privacy.userContext.enabled` | `atuq.cfg` | prende los contenedores, que Firefox trae apagados |
| `Containers.Default` | `distribution/policies.json` | los CREA — es el único mecanismo que los pone en un perfil NUEVO |
| `3rdparty.Extensions` | `distribution/policies.json` | la config de fábrica, que la extensión lee como `storage.managed` |
| `proxy.onRequest` | `extensions/proxy/fondo.js` | lo único que ve el `cookieStoreId` de cada petición |
**Las cuatro se comprobaron DENTRO del artefacto antes de escribir una línea**, que es la regla que
dejó el §2.sexies: `Containers` y `3rdparty` están en el `policies-schema.json` de `browser/omni.ja`,
`cookieStoreId` en el `schemas/proxy.json` de `omni.ja`, y `storage.managed` lee
`Services.policies.getExtensionPolicy(id)` en `ext-storage.js`. Ninguna de las cuatro se leyó de la
documentación de Mozilla: se leyeron de **nuestro** build, que es el que las tiene que traer.
**Tres decisiones de diseño que valen más que el código:**
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ón | Prueba |
|---|---|
| La política CREA los cuatro contenedores | captura de `about:preferences#containers` con Personal/Trabajo/Banco/Compras y sus iconos, más el `containers.json` del perfil |
| Las dos extensiones se INSTALAN | `extensions.json` del perfil nombra `inicio@atuq.tawasuyu` y `proxy@atuq.tawasuyu` |
| El ruteo se arma contra los contenedores reales | `console.info: "atuq/proxy: 4 contenedor(es) enrutado(s)"` — o sea que leyó la config de fábrica por `storage.managed` Y la casó con los contenedores que creó la política |
| **Una petición hecha en «Banco» SALE por el proxy de «Banco»** | `scripts/test-atuq-ruteo.py`: dos pestañas piden la misma url; al destino llega `GET /directo` y al puerto del proxy un saludo **SOCKS5 (`\x05\x01\x00`)**, y la de «Banco» no aparece nunca en el destino. Con `--negative-control` (sin proxy configurado) las dos van directas y al proxy no llama nadie |
| Los guardianes del cruce política↔XPI FALLAN cuando deben | `scripts/test-atuq-politica.py`: 5 formas de romperlo, las 5 matan el build, y el control con la política intacta pasa |
| La capa sobrevive al cambio de BASE | rehecho sobre el `firefox` con RLBox (`b3:8116bdec`, 2026-09-06): `atuq` sella en 2 s como `b3:f2960991`, su `libxul.so` trae **634 símbolos que se LLAMAN `w2c_*`** donde la base anterior traía 0, y los cuatro contenedores y las dos extensiones siguen apareciendo en un arranque real |
⚠ **Ese 634 hay que contarlo anclado, y la primera vez se contó mal.** `llvm-nm --defined-only \| grep -c w2c_` da **1547** sobre el mismo fichero, y los 913 de más son nombres C++ mangleados que contienen `w2c_` en el MEDIO (`_ZN5rlbox...PK16w2c_mem_capacity...`), no símbolos de la jaula. El patrón que cuenta lo que dice contar es `grep -c ' w2c_'` —o el equivalente `awk '{print $3}' \| grep -c '^w2c_'`—, y da 634, que es lo que mide el guardián de `recipes/firefox.toml`. La conclusión no cambia (la base anterior daba **0** con cualquiera de los dos patrones); lo que cambia es que un número sin su patrón no es una medición. Muestra de lo que sí es un símbolo de la jaula: `w2c_rlbox_0x5F_cxa_atexit_0`.
**Y de paso quedó comprobado que la dep SÍ se sigue**, que era la promesa del §2 y no una que convenga
creer sin medir: en un catálogo de sonda, cambiar una bandera del `firefox` copiado mueve el hash de
`atuq` (`f2960991` → `60ade76a`), mientras que con el `firefox` idéntico al del corpus da el mismo
hash. O sea que un `atuq` viejo **no** puede quedarse tapando un motor nuevo — que es justo la forma
que tendría acá el cache-hit que congela regresiones.
**Cómo se cerró lo que faltaba (2026-09-06).** Durante unas horas esta sección decía que probar el
paquete quedaba pendiente, con tres puertas cerradas. Las tres siguen cerradas y la prueba existe
igual, porque el camino era otro.
Las tres puertas, que conviene NO volver a golpear:
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 arranca | **no hay servicio en la imagen**: el binario está y no corre. Lo levanta su propio cliente la primera vez que hay un torrent que tomar |
| se va solo | cuando no queda nada activo por `inactividad_seg` (300 s por defecto; `0` = nunca), sale. Sembrar cuenta como actividad: no se va mientras le devuelva piezas al enjambre |
⚠ Se levanta con **`setsid`**: sin eso queda en el grupo de procesos del navegador y **se muere con
él**, que es exactamente lo que este proceso existe para no hacer. Si `setsid` faltara se arranca
igual —mejor bajar mientras el navegador esté abierto que no bajar— pero **avisando**, porque es una
degradación silenciosa de su única promesa.
**Configurable**: un TOML opcional (`descargas`, `cas`, `indice`, `sembrar_tras_bajar`,
`inactividad_seg`, techos de velocidad). Sin fichero los defaults andan; un fichero **roto** sí es un
error, porque si un typo se comportara como «sin configuración» el usuario buscaría el problema en
cualquier otra parte. **El CAS por defecto es el del host**, y hay un test que falla si esas dos
raíces se separan: es lo que hace que un torrent y una descarga del mismo contenido sean **un objeto**.
**Lo que el guardián NO hace, y por qué** (`scripts/test-atuq-torrent.py`):
1. **no usa un `magnet:` sino un `.torrent` servido por HTTP** — dar de alta un magnet BLOQUEA
esperando la metadata del enjambre, y sin peers eso no llega nunca: se mediría un timeout y no la
cadena. Con un `.torrent` la metadata viene en el fichero y el alta termina. El `.torrent` se arma
en el propio guardián, en bencode a mano: un binario de prueba en el repo es una dependencia que
nadie revisa;
2. **no pasa por el despacho de protocolos de Gecko** — un clic en un `magnet:` abre el diálogo de
«¿con qué lo abro?», que en headless no contesta nadie, y eso es una elección del usuario y no
código nuestro. Se abre la página de la extensión, que es la que el handler abriría, descubriendo
su URL base del `dump` de una primera corrida (el UUID lo asigna Gecko por perfil).
Y el control negativo prueba lo que importa: **sin el cliente del daemon, el host falla diciéndolo y
nombrando lo que falta** — no finge haberlo tomado.
⚠ **Y el bug que encontró MIRAR LA MÁQUINA, no un test.** Después de las pruebas, `ps` mostraba un
daemon con **23 minutos de vida** y `idle_seconds = 300`. El bucle hacía
`let Ok(g) = gestor() else { continue }`: si la sesión de torrent no se podía crear, el `continue`
saltaba la evaluación de «¿sobro?» y el daemon quedaba **vivo para siempre** — lo contrario de
perezoso, que es su única promesa. Los tests cubrían la decisión como función PURA, y esa función
estaba bien: **el que no llegaba a llamarla era el bucle**. Una decisión correcta que nadie toma se ve
igual que una que no existe. Arreglado (sin sesión = nada activo) y fijado con dos tests que corren el
PROCESO y el reloj de verdad, el segundo comprobado volviendo a poner el `continue`.
⚠ **La pereza tiene un residuo, y el guardián lo daba por bueno (2026-09-10).** El daemon se va
«tras 300 s **sin nada**», y un torrent sin enjambre nunca termina ⇒ nunca está sin nada: se queda,
que es lo correcto para un cliente de torrent. Pero el guardián cerraba el sandbox y se iba, y
**quedaba vivo el daemon Y el `bwrap` interno**, sosteniendo abiertos overlays ya borrados —
encontrado con `ps` sobre la máquina, no por un test. La corrección tiene dos mitades y ninguna
sobra:
1. el guión de adentro **despide al daemon** con `puriy-costura-torrent stop` — el propio verbo del
producto, así que de paso se ejerce;
2. el guardián **cuenta los daemons antes y después** (`ps -C`, nunca `pkill -f`, que empareja la
línea de comandos y se lleva la shell del que corre el test) y **falla nombrando el PID** si quedó
alguno; y barre lo propio en el `finally`, para que un test rojo tampoco deje basura.
Y el orden importa: la huella del socket se anota **adentro y antes** del `stop`, porque al irse el
daemon lo borra — mirarlo desde el hub después mediría el reloj, no el hecho. Comprobado en los dos
sentidos, quitando el `stop` en una copia fuera del repo: sin él el guardián decía ✓ igual y dejaba
un daemon suelto; con la aserción nueva sale
`✗ quedó un daemon vivo tras cerrar el sandbox: PID(s) [20157]`. La primera hipótesis —que sin `stop`
el guardián se COLGARÍA, porque `bwrap --unshare-pid` espera a su namespace— **era falsa y la
medición la descartó**: el `bwrap` externo vuelve, y el que se queda esperando es el interno. El tope
de 180 s quedó igual, pero por lo que es: seguro contra un cuelgue, no el que detecta la fuga.
⚠ **Lo que sigue sin estar en ningún test automático: un transfer real entre peers.** Haría falta un
sembrador y una espera que volverían la suite una que nadie corre. Está dicho también en el test del
daemon, al pie, para que nadie lo lea como «probado de punta a punta».
### 6.10 Lo que `atuq` NO PUEDE hacer hoy, y por qué (2026-09-07)
Sale de un punto ciego que nombramos entre los dos frentes y que **ningún auditor de `readelf`
puede cubrir**. La jerarquía de lo que se ve:
NEEDED del ejecutable → lo ve `test-atuq-rootfs.py` y lo ve `vigia-sonames.py`
NEEDED de un .so dlopeado → lo ven los dos, si el fichero está en el artefacto
dlopen("libfoo.so.1") LITERAL → NO LO VE NINGUNO
Y un navegador vive del tercer escalón: Firefox sondea ffmpeg, VA-API, vulkan, libnotify y una
docena más **por nombre**, y cuando no están **no falla: apaga la función y sigue**. No hay línea
roja; hay una función que nadie ofrece y nadie reclama. Es la misma forma que ya nos costó caro con
OBS (`dlopen("libGL.so.1")` desde su glad).
`scripts/test-atuq-rootfs.py --dlopen` busca esas cadenas y las cruza contra el rootfs. **Es un
heurístico y se declara como tal** —una cadena no prueba un `dlopen`, y su ausencia no prueba que no
lo haya—, así que no falla nunca: imprime un mapa triado. Lo que sí se puede afirmar es lo
contrario, que es lo útil: **si la cadena está y el fichero no, esa función no existe en esta
imagen.**
De 85 cadenas con forma de soname, **47 no tenían quién las provea**. Triadas (y con dos filas
corregidas el mismo día — ver §6.11, que las midió en vez de deducirlas; hoy el mapa da **7 huecos**):
| Qué se pierde | Estado |
|---|---|
| **Códecs del sistema (H.264/AAC)** | ❌ **ERA FALSO** — `recipes/ffmpeg.toml` existe, está sellada y ya viajaba en los cuatro escritorios por `mpv`. Corregido en §6.11: **reproduce** |
| **Notificaciones web** (`libnotify`) | ✅ **CERRADO el 2026-09-07**: `recipes/libnotify.toml`, 0.8.8, compartida. Es el primer hueco que este mapa encontró Y cerró |
| **Llavero** (`libsecret`) | hueco — pero **la receta ya existe** en `incoming-gnome` |
| **Sonidos del sistema** (`libcanberra`) | hueco — receta en `incoming-kde` |
| **WebGPU / vulkan** (`libvulkan`) | hueco — receta `vulkan-loader` en `incoming-kde` |
| **Vídeo por hardware** (VA-API) | hueco, pero **no por falta de receta**: `libva` está sellada y en las cuatro imágenes. Falta el driver — las tres mesa van con `-Dgallium-va=disabled` y `-Dvideo-codecs=` vacío ⇒ ni un `*_drv_video.so` |
| **Lectura en voz alta** (`libspeechd`) | hueco — sin receta |
| `libGL` | **decisión**: la distro es Wayland-only sin GLX y Firefox va por EGL |
| `libcurl` | **decisión**: sólo lo usa `pingsender`, que manda telemetría — apagada |
| Kerberos, menú global, ML | fuera de alcance |
| `libdl/libpthread/librt/libudev.so.0/libfreebl3` | ruido: musl los funde, o el soname es viejo |
**El primero ya está cerrado.** `libnotify` no tenía receta y ahora la tiene: es la prueba de que el
mapa sirve para algo más que mirarlo. ⚠ Con una advertencia que va escrita en la propia receta:
`libnotify` no trae un daemon, manda `org.freedesktop.Notifications` por D-Bus ⇒ tenerla resuelve la
mitad —que firefox la encuentre— y la otra mitad es que en la imagen haya alguien escuchando ese
nombre. Nadie debería leer «notificaciones arregladas» y esperar un globo en un QEMU pelado.
**⚠ CORRECCIÓN, mismo día: escribí que tres de los siete eran «baratos, promoción y no autoría»
—`libsecret`, `libcanberra`, `vulkan-loader`— y es FALSO.** Lo comprobé antes de declararlos en los
perfiles, y en los tres casos falta la otra mitad:
| «barata» | por qué NO alcanza con declararla |
|---|---|
| `vulkan-loader` | **las tres recetas de mesa construyen con `-Dvulkan-drivers=` VACÍO** ⇒ el loader no encontraría un solo dispositivo. Un cargador sin ICD no es WebGPU |
| `libsecret` | no hay receta de `gnome-keyring` ni de ningún servicio de secretos: firefox hablaría a `org.freedesktop.secrets` y no habría nadie |
| `libcanberra` | no hay receta de `sound-theme-freedesktop`: la librería carga y no suena nada |
Declararlas habría subido el número de paquetes de la imagen sin encender una sola función. **Una
librería es media función; la otra mitad es quien la atiende**, y eso vale para todas las de esta
tabla. Quedan como huecos, no como promociones pendientes.
⚠ **Y esta frase también se cayó el mismo día.** Decía que de los otros cuatro «el más caro para el
usuario es `ffmpeg`: sin él, un sitio que sirva H.264 no reproduce» — con lo cual el hueco más caro
del mapa **no existía**. Ver §6.11: la receta estaba, faltaba declararla en el runner, y H.264 (y
AAC, VP9, Opus, AV1, MP3, FLAC) reproducen. De los cuatro «sin receta» quedan tres, y ninguno es un
códec: lectura en voz alta (`libspeechd`), el driver de VA-API, y `libpci` —que sólo lo usa
`glxtest`, o sea irrelevante sin GLX.
**Y la misma vara aplicada a `libnotify`, que sí se declaró:** su otra mitad es un daemon que
escuche `org.freedesktop.Notifications`. Medido perfil por perfil:
escritorio-kde plasma-workspace ✓ completa
escritorio-gnome gnome-shell ✓ completa
escritorio-cosmic cosmic-notifications ✓ completa
escritorio-sway dunst 1.12.2 ✓ completa — CERRADA el 2026-09-07
La de sway se cerró el mismo día con `recipes/incoming-wlr/dunst.toml`, y la trampa del homónimo
—**el nombre `mako` YA ESTÁ OCUPADO en el corpus** por el motor de plantillas de Python de Mesa— se
esquivó eligiendo dunst, que además habla D-Bus por GDBus y no por `sd-bus`, o sea sin receta nueva
de `basu`. La prueba no es que selle: `scripts/wlr/dunst-headless.sh` levanta sway headless y un bus
de sesión, manda un `notify-send` y **deja que el bus active el daemon solo** —el mismo camino que
recorre una página web en `atuq`—, y mide un diff de píxeles antes/después: 14.832 cambiados con el
`.service` puesto, 0 sin él. Evidencia en `docs/evidencia/dunst-sway-notificacion-2026-09-07.png`.
Y la cadena se cerró hasta el final, que es donde vive la promesa: con `--via-atuq` el emisor no es
`notify-send` sino **una página web dentro del navegador**. Ahí se ejercitan las cinco piezas —
`new Notification(...)` → `libxul` haciendo `dlopen("libnotify.so.4")` (que no es NEEDED de ningún
ELF, o sea invisible para cualquier auditor) → D-Bus → activación → dunst dibujando—, y el veredicto
es el `onshow` del motor, no el diff de píxeles: con el `.service` puesto llega a `MOSTRADA #33`, sin
él el motor dice `ERROR al mostrar` y no se dibuja nada. Evidencia en
`docs/evidencia/atuq-notificacion-web-sway-2026-09-07.png`.
Vale la pena decir qué prueba eso de más: que en el proceso de `atuq` **la GLib no se duplica**. El
navegador usa la cadena `-shared` (la que arrastra su GTK3) y `libnotify.so.4` enlaza esa misma, así
que el `dlopen` no repite GObject. El que sí lo repetía era `dunstify`, y por eso no se shipea.
⚠ De paso apareció otra media función, y ésta toca a `libnotify` directamente: **`dunstify`
segfaultea hasta en `--help`** porque mezcla la GLib ESTÁTICA del corpus con la COMPARTIDA que
arrastra `libnotify.so.4` — dos copias de GObject en un proceso. No se shipea; el emisor es
`notify-send`, que viene dentro del artefacto de libnotify y enlaza toda la cadena compartida. Vale
como recordatorio de que en este corpus **quién enlaza qué GLib es parte del contrato**, no un
detalle del build.
**Lo que esta sección cambia de fondo:** hasta hoy la pregunta era «¿arranca?», y la respuesta era
sí. La pregunta que faltaba era «¿y qué NO puede hacer?», que ninguna métrica del repo respondía
porque todas miran presencia y ésta mira ausencia declarada por el propio binario.
#### 6.10.bis Una descarga terminada MATA a `atuq --headless` (2026-09-10)
Medido al escribir el guardián del §6.2, y vale la pena por cómo se atribuyó, no por el bug:
| corrida | qué pasa |
|---|---|
| `--headless`, con la extensión de descargas | baja el fichero, la extensión dice `TERMINADA`, y el proceso muere con **SIGSEGV (139)** |
| `--headless`, **sin** la extensión (control) | baja el fichero, y muere igual |
| `--headless`, con el panel de descargas apagado (`browser.download.alwaysOpenPanel=false`) | muere igual |
| **sway headless (compositor REAL, salida offscreen)** | baja, ingiere al CAS y **no se cae** |
Las dos primeras filas son las que importan: **el control sin la extensión descarta que sea nuestro**,
y la última descarta que sea del producto. Es el modo `--headless` de Gecko, en esta jaula, cuando una
descarga completa. El fichero se baja bien en los cuatro casos — lo que muere es el proceso después.
Sin el control, esto se lee de dos maneras y las dos son caras: «la extensión de descargas rompe el
navegador» (perseguir un bug que no existe) o «atuq no puede descargar» (reportar un bug de producto
que tampoco existe). Por eso `scripts/test-atuq-descargas.py` corre sobre sway y no sobre `--headless`,
y por eso dice en su cabecera POR QUÉ — un arnés distinto al de los demás guardianes, sin explicación,
es una invitación a «simplificarlo» de vuelta al que se cae.
⚠ Lo que NO se midió: si el crash existe también en el `--headless` de una máquina sin jaula. No hace
falta para lo que se decidió acá, y afirmarlo sin medirlo sería justo lo que este documento no hace.
### 6.11 Y ahora la pregunta que faltaba: ¿REPRODUCE? (2026-09-07)
El §6.10 preguntó «¿qué NO puede hacer?» y contestó leyendo cadenas. Media pregunta. La otra mitad
—**¿se ve el vídeo?**— no la contesta ningún auditor de ELF, y al hacerla aparecieron **dos errores
del mapa y un bug real del navegador**.
**Error 1: «no hay receta de ffmpeg».** La hay. `recipes/ffmpeg.toml`, 7.1, sellada, publica
`libavcodec.so.61` —uno de los once sonames que `libxul.so` sondea por `dlopen` literal— y **ya
viajaba en la clausura de los cuatro escritorios**, arrastrada por `mpv`. Comprobado con
`yupana.membresia`, que es la vista honesta por par `(cola, nombre)`, no leyendo el TOML:
escritorio-kde 308 nodos · escritorio-gnome 192 · escritorio-sway 203 · escritorio-cosmic 163
ffmpeg ✓ en los cuatro · libva ✓ en los cuatro
Lo que faltaba no era la receta: era **la lista de raíces de `scripts/atuq-nested.sh`**, escrita a
mano, que hidrataba un rootfs más pobre que la imagen. Otra vez el instrumento y no el artefacto —
la misma forma que `gcc-libs` el día anterior. Y la comprobación previa que sí se pudo hacer sin
correr nada: de los 82 símbolos con forma `av*_` que `libxul` dlsimboliza, **los 9 que nuestro
`libavcodec` no exporta son los de la API vieja** (`avcodec_decode_video2`, `av_lockmgr_register`,
`avcodec_register_all`…), que Firefox sólo pide para las versiones 53-57.
**Error 2: «VA-API, sin receta».** También la hay, y también está en las cuatro imágenes. El hueco
es real pero por la OTRA mitad: las tres recetas de mesa construyen con `-Dgallium-va=disabled` y
`-Dvideo-codecs=` vacío ⇒ **no existe un solo `*_drv_video.so`**. Un cargador sin ICD no acelera
nada, exactamente como `vulkan-loader`. ⚠ Y con una trampa nueva: ahora que `libva` está en el
rootfs, **el mapa de `--dlopen` ya no la reporta**, porque mide presencia de fichero. La librería
presente SILENCIA el aviso sin encender la función. Queda escrito acá porque el guardián ya no
puede decirlo.
**El bug real: AV1 no reproducía.** Gecko **se come el primer elemento de `LD_LIBRARY_PATH`** al
lanzar el proceso RDD, el que decodifica vídeo. El lanzador ponía `/usr/lib/atuq` una sola vez —o
sea, primero—, así que el RDD arrancaba sin él, no encontraba el ffvpx bundleado (donde vive dav1d)
y anotaba `FFVPX: Link result: NoProvidedLib`. Y ahí **no falla**: se cae al ffmpeg del sistema, que
cubre H.264/AAC/VP8/VP9/MP3/FLAC/Opus… pero **no AV1 por software**, porque el decodificador `av1`
de ffmpeg es sólo hwaccel y nuestra receta va sin dav1d. Resultado: un vídeo AV1 se quedaba en
`readyState=1` para siempre, sin un error, sin un NEEDED faltante y sin una cadena ausente.
Tres corridas del MISMO artefacto que sólo cambian esa variable:
LD=/usr/lib/atuq:/usr/lib:/lib → RDD NoProvidedLib AV1 ✗
LD=/usr/lib/atuq:/usr/lib/atuq:/usr/lib → RDD Success AV1 ✓
LD=/relleno:/usr/lib/atuq:/usr/lib → RDD Success AV1 ✓ (el relleno se lo come)
El arreglo es repetir el appdir en `recipes/atuq/bin/atuq`. Feo y correcto mientras no haya
`patchelf` en el corpus para grabar `RUNPATH=$ORIGIN`, que borraría la variable entera.
**Lo que quedó medido**, con `scripts/test-atuq-codecs.sh`, headless, por el lanzador de verdad y
sin `LD_LIBRARY_PATH` de afuera:
| Muestra | Veredicto |
|---|---|
| H.264 + AAC (mp4) | ✅ `t=3.00`, 75 cuadros |
| VP9 + Opus (webm) | ✅ `t=3.00` |
| AV1 (mp4) | ✅ `t=3.00` — sólo desde el arreglo del lanzador |
| MP3 | ✅ |
| FLAC | ✅ |
El veredicto **no es «se creó el decodificador»**: es que `currentTime` AVANCE. La distinción no es
retórica — en el caso de AV1 el decodificador se creaba (`RemoteDecoderChild … codec: av1`) y no
entregaba un solo cuadro. El guardián corre con `--negative-control`, que saltea el lanzador y
**exige que AV1 falle**: probado en los dos sentidos, porque uno que no puede fallar y uno que nunca
falló se ven igual desde afuera.
**Dos bugs de andamiaje que salieron de paso**, los dos en `atuq-nested.sh` y los dos escondidos
detrás de una caché: el bucle de hidratación salía 1 siempre (línea vacía final + `set -e`) y mataba
el script sin imprimir nada, y la comparación del sello nunca daba igual (un `\n` que `$(cat …)`
recorta de un lado y no del otro), así que el rootfs se rehidrataba entero en cada corrida. El
primero sólo se veía cuando cambiaban los hashes; lo destapó agregar `ffmpeg` a las raíces.
### 4.g El chrome se programa SIN abrir `omni.ja` — y la vista dividida ya la trae el motor (2026-09-09)
El pendiente escrito era «el chrome de verdad dentro de `omni.ja` (split view, dientes propios): el
re-empaque ya está resuelto, lo que falta es el contenido». Las dos mitades de esa frase eran falsas,
y las dos se midieron con `scripts/test-atuq-chrome.py` sobre el artefacto vigente:
VIVO el JS de atuq.cfg corre
VENTANA con gBrowser
PREF splitView.enabled=true
WRAPPER tabs=2
ACTIVA si
BROWSERS 2
**1. El zip no es el gancho del chrome; `atuq.cfg` sí.** Ese fichero ya corre con privilegios —de ahí
sale `nsIStyleSheetService`, y es la razón de `sandbox_enabled = false`—, así que desde ahí se observa
`browser-delayed-startup-finished` y se toca el `gBrowser` de CADA ventana que abra el navegador. La
sonda abre dos pestañas y las parte en dos desde `atuq.cfg`, sin tocar `omni.ja`.
Y no es un atajo: es la vía CORRECTA, porque re-empacar `omni.ja` es exactamente lo que pelea con el
orden del `jarlog` del PGO —el motivo por el que el `jarlog` está aparcado (`c3413d29`)—. Dicho al
revés: **cada función del chrome que no entre al zip es una que no compite con la optimización de
arranque.** Eso cambia el orden de preferencia para todo lo que venga: primero prefs, después
extensión de sistema, después JS de chrome por autoconfig, y `omni.ja` sólo si no queda otra.
**2. La vista dividida no hay que escribirla: Firefox 154 la trae y viene PRENDIDA.** En
`browser/omni.ja` están `tabbrowser/tabsplitview.js` (492 líneas, el custom element completo con
`addTabs`/`unsplitTabs`/`reverseTabs`), `opentabs-splitview.mjs` y `split-view-footer.js`; y
`defaults/preferences/firefox.js` trae `pref("browser.tabs.splitView.enabled", true)`. La sonda no se
queda en «el fichero está»: llama a `gBrowser.addTabSplitView([t1, t2])` y le pregunta al motor, que
contesta `activeSplitView` puesta y `splitViewBrowsers.length == 2`. **Funciona en nuestro build.**
Es el mismo patrón que ya pagó dos veces en este SDD —los dientes (`sidebar.verticalTabs`), los
contenedores (`privacy.userContext.enabled`)—: la función existe en el motor y viene apagada o sin
usar, y el trabajo es prenderla y comprobarla, no escribirla. La forma de equivocarse es al revés:
declarar como deuda propia algo que upstream ya nos dio.
⚠ **Lo que esto NO dice.** atuq todavía **no lleva JS de chrome propio**: lo medido es el GANCHO, con
la sonda inyectada por una capa de overlay que no viaja en el artefacto. Cuando haya una función que
lo justifique, va en `recipes/atuq/chrome/atuq.js` y la carga `atuq.cfg`. No se shipea un cargador
vacío para «tenerlo listo».
El control negativo sale del propio motor y no de una idea nuestra: `addTabSplitView` documenta que
si TODAS las pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control` las fija
y exige justamente eso — sin él, una sonda que dijera «partió» pase lo que pase se vería idéntica a
una que funciona.
### 2.ter Dónde vive la fuente de `atuq` — hizo falta un modo de `[source]` nuevo
Escribir la receta destapó un muro que este documento no había visto: `[source]` era
**obligatoriamente** git o tarball, así que una receta cuyo contenido no viene de upstream sino de
nosotros no se podía ni expresar. Los rodeos eran todos peores — fetchear una fuente que después se
ignora **miente** sobre la identidad del artefacto, y colgar el overlay de un repo aparte obliga al
worker a tener acceso a un repo privado.
Se agregó **`source.dir`** (commit `54fa4a9`): un árbol dentro del propio repo, hasheado por
**contenido** con `ArtifactHash::of_tree` —que ya existía para verificar bit-reproducibilidad en
Stage 2— y no por ruta. Es la misma disciplina que ya tenían los `patches`, que entran al hash por
bytes. Editar un CSS del overlay mueve el `ArtifactHash`; renombrar el directorio, no.
Un `.swm` **no** puede llevar una receta derivada, y los cuatro sitios que lo tocan fallan
diciéndolo: el manifiesto es compartible y necesita un puntero resoluble desde fuera.
**Hecho el 2026-09-05** (commits `aeebb19` y `6f6c32b`): `recipes/atuq.toml` + `recipes/atuq/`.
La **v0.2** (`b3:7eff4ba4`) ya trae el branding: `application.ini` (Vendor/Name/RemotingName/
CodeName, con el `ID` intacto para no salirse del ecosistema de complementos), `brand.ftl` dentro de
`browser/omni.ja`, los binarios renombrados, iconos propios y `.desktop`. El re-empaque preserva
orden, `STORED` y fechas del original, y se verificó contra el artefacto: **5306 entradas, mismo
orden, y una sola con contenido distinto**. El `BuildID` se deriva del contenido del overlay —no de
la fecha— porque es la clave con la que Gecko invalida su startup cache: sin eso, un cambio de
chrome arranca con la interfaz vieja y *parece* que el overlay no agarró.
La capa de configuración de la v0.1 son cuatro ficheros —autoconfig, `atuq.cfg`, `chrome/atuq.css`,
`policies.json`— y el CSS se registra como `USER_SHEET` desde autoconfig porque `userChrome.css`
vive en el perfil y exigiría que el usuario prenda una pref: atuq tiene que verse como atuq en el
primer arranque. Falta construirlo: su dep `firefox` está en vuelo.
### 2.quater Abrirlo en una pantalla: tres fallos que la clausura no podía ver
El 2026-09-05, con `atuq` sellado y verificado, se hidrató y se abrió en el compositor de la sesión
(waypipe). **No arrancó**, y los tres motivos son la diferencia entre «sellado» y «usable»:
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:
| Mecanismo | Resultado |
|---|---|
| `distribution/extensions/` — el clásico de las distros | **No instala nada.** Firefox retiró el *sideloading*. El `extensions.json` del perfil ni lo mencionaba y el log no dijo una palabra: fallo perfectamente silencioso |
| `policies.json` → `ExtensionSettings` con `install_url` | **Se lee** (`browser.policies.applied=true` en el perfil) y falla nombrándose: `ERROR_SIGNEDSTATE_REQUIRED` |
| `xpinstall.signatures.required = false` | **No alcanza** — la exigencia viene COMPILADA |
**El instrumento que destrabó el diagnóstico fue un testigo.** `defaultPref` no deja rastro en
`prefs.js` —sólo se guarda lo que difiere del default— así que un autoconfig que **no se ejecuta**
es indistinguible de uno que sí y no hace nada. `atuq.cfg` escribe ahora `atuq.autoconfig.ok` como
pref de **usuario**, legible desde fuera sin abrir el navegador. Salió `true` ⇒ el `.cfg` corría, y
por lo tanto la pref de firma se estaba ignorando.
De ahí `MOZ_REQUIRE_SIGNING` vacío en `recipes/firefox.toml`. Dos detalles que costaron cada uno su
vuelta:
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ón | Prueba |
|---|---|
| El re-empaque del `omni.ja` es determinista | `why-differs` entre dos builds de `atuq`: **85 entradas idénticas, 0 divergen** |
| La capa de configuración se aplica | testigo `atuq.autoconfig.ok` escrito como pref de **usuario** en el perfil |
| El branding se ve | captura de la pantalla real con `grim` desde dentro de la jaula, y la página de inicio renderizada por el propio navegador con `--headless --screenshot` |
| La base también reproduce | `why-differs` entre dos builds de `firefox` en el worker: **56 entradas idénticas, 0 divergen** |
Y el contraste que salió de la primera: **el derivado reproducía y la base no.** El `BuildID` de
`firefox` era la hora del build, así que dos construcciones con el mismo `ArtifactHash` daban bytes
distintos — y `build-state.json` no lo podía ver, porque el hash es input-addressed y no se mueve
por esto. Verde y mintiendo. Cerrado con `MOZ_BUILD_DATE` desde `SOURCE_DATE_EPOCH`, que es el
mecanismo de hermeticidad que el sandbox ya tenía — y **comprobado como se comprueba esto**:
apartando el artefacto como `.ref`, construyendo de nuevo y pasándole `why-differs`. Reproduce.
**Dos guardianes nuevos en la fase `install` de `firefox`**, hermanos entre sí, porque los dos fallos
son de la misma familia —una bandera que el `configure` acepta y el artefacto ignora, muda y a 50
minutos de distancia—: uno comprueba `MOZ_REQUIRE_SIGNING: false` dentro del `omni.ja`, el otro que
el `BuildID` sea el que `SOURCE_DATE_EPOCH` obliga.
**La regla que sale de todo el día:** cuando la duda es «¿llegó al artefacto?», la respuesta no está
en el log del build ni en la receta — está **dentro del artefacto**, y hay que ir a buscarla ahí.
## 7. La costura: un host de native messaging en Rust
Todo lo del §6 que no es CSS pasa por **un solo mecanismo**: un proceso Rust que habla native
messaging con la extensión de `atuq` y, del otro lado, con la suite (`agora` para identidad Ed25519,
`khipu`, el store, `foreign-*`, `cortafuegos`).
Que sea **uno** y no siete es la decisión: cada feature del §6 pasa a ser un verbo de ese host, no un
proyecto nuevo. El binario vive en tawasuyu (es suite, no build system); `atuq` sólo lo declara como
dep y le deja el manifiesto de native messaging en su sitio. **El nombre del crate se decide en
tawasuyu y con su regla 10 puesta** — acá no se bautiza nada de allá.
### 7.bis El camino EXISTE, y tres cosas que no eran obvias (2026-09-07)
Antes de escribir un crate contra un mecanismo hay que saber si el mecanismo funciona en NUESTRO
build —propio, rebrandeado, con `MOZ_REQUIRE_SIGNING` vacío—, así que se puso una **sonda**:
extensión de diez líneas con permiso `nativeMessaging` y un host de tres que contesta `{"ok":true}`.
Vive en `scripts/test-atuq-nativo.py`, con control negativo:
positivo MENSAJE {"ok":true}
control DESCONECTADO No such native application puente_atuq
El control no sólo falla: **nombra la causa**, que es lo que confirma que la ruta del manifiesto es
la que se probó. Lo que hay que saber para escribir el host:
| Qué | Medido |
|---|---|
| Dónde va el manifiesto | **`/usr/lib/mozilla/native-messaging-hosts/`**, NO el appdir. Sale de `XRESysNativeManifests`, un `/usr/lib/mozilla` compilado; el rebranding a `atuq` no lo mueve |
| Cómo se instala la extensión | por el **escaneo de `distribution/extensions/`**. `ExtensionSettings.install_url` con `file://` no instala nada, ni con `force_installed` ni con el XPI dentro del appdir, y **sin una línea de log** |
| Forma del host | un proceso **largo**: si escribe la respuesta y sale, Gecko cierra el puerto sin entregarla — `onDisconnect` «sin error y sin mensaje», el peor informe posible |
| Con qué diagnosticar | el error del puerto de `connectNative`. `sendNativeMessage` devuelve «An unexpected error occurred» para todo y no distingue nada |
⚠ La tercera fila del cuadro corrige una lectura fácil de `policies.json`: los dos mecanismos
apuntan a los mismos ficheros y se tapan uno al otro, así que la política PARECE ser la que instala.
Fija y configura; instalar, no.
### 7.ter Y esa fila ahora tiene guardián — porque el README decía lo contrario (2026-09-09)
La fila «cómo se instala la extensión» era una nota al pie de una sonda de native messaging, y
mientras tanto **`recipes/atuq/README.md` afirmaba lo opuesto**: que el *sideloading* desde
`distribution/extensions/` «ya no funciona» y que instalaba `ExtensionSettings`. Dos documentos
nuestros, contradictorios, sobre el mecanismo del que cuelgan TODAS las extensiones de atuq —hoy
`inicio` y `proxy`, mañana la de `sct`—. Y ninguna observación del artefacto los distingue, porque
los dos apuntan a los mismos ficheros.
`scripts/test-atuq-instalacion.py` lo rompe de a uno y mira qué pasa. Medido sobre
`atuq b3:fab2fbfb` (firefox 154 con PGO v2) y REPETIDO sobre `b3:9e16ac35` —el artefacto que esta
misma corrección creó, porque el README vive dentro de `source.dir` y editarlo re-hashea atuq—,
tres escenarios:
| escenario | qué se rompe | motor (`homepageOverride`) | perfil |
|---|---|---|---|
| `as-is` | nada | `moz-extension://…/inicio.html` | `inicio` y `proxy` activas |
| `no-policy` | `policies.json` **sin** `ExtensionSettings` | **sigue siendo la nuestra** | **las dos siguen activas** |
| `policy-only` | los XPI mudados a `/usr/lib/atuq/xpi-politica/`, con `install_url` apuntando ahí | `about:home` | **ninguna de las dos existe** |
⇒ **instala el escaneo de la carpeta; `install_url` con `file://` no instala nada.** El §7.bis
queda confirmado y el README corregido.
Dos cosas que este guión trae y que valen más que el veredicto:
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**.
| Pieza | Dónde | Por qué ahí |
|---|---|---|
| `puriy-costura` | `00_unanchay/puriy/` | regla 1: subcrate del dominio, no proliferación lateral. Precedentes: `puriy-sct-testigo`, `rimay/verbo-daemon`, `watuy-daemon` |
| `foreign-webext` | `shared/` | regla 4: el protocolo es AJENO. Lib pura sin binario, como los otros 37 puentes (medido: ninguno lleva `[[bin]]`) |
| `recipes/puriy-costura.toml` | takana | el artefacto: estático musl, pineado por commit, `--locked` |
Verbos v1: `ping`, `sct.observe`, `sct.state`. Claves del cable en inglés (regla 7 de allá y regla 4
de acá: se tipean). `phase` es `learning`/`stable`/`not-evaluable`, y un `events[]` vacío es lo normal
— sólo hay evento cuando un origen **ya estable** ejecuta un hash que nadie vio nunca.
**Los tres tests que hacen que el veredicto signifique algo** (de catorce): el mismo cambio de script
mientras el origen todavía aprende **no** alerta; rotar `?v=` con el mismo código **tampoco** (la
identidad del recurso es su contenido, y sin eso cada despliegue con cache-buster sería un aviso de
supply-chain: un detector que grita todos los días no se mira más); y el aprendizaje **sobrevive al
cierre del navegador**, sin lo cual cada arranque volvería a «aprendiendo» y ningún cambio sería nunca
un evento.
**Los dos límites, escritos en el código y en los dos LEEME, no en una nota de sesión:**
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.
| # | Unidad | Puerta que abre | Bloqueada por |
|---|---|---|---|
| 1 | **`waterfox` SELLA** ✅ 2026-09-06 — `b3:88b5a762`, 377 M, BuildID determinista **y REPRODUCE bit a bit**. Cuatro muros, **ninguno de la receta**: takana viejo en el worker, el watchdog barriendo el post-mortem, deps estáticas vs. el cairo bundleado, y una rotura de upstream sólo-Linux | valida la tesis del reúso | — |
| 2 | ~~`llvm-toolchain`~~ → **`apk add lld` + `compiler="clang"`** ✅ 2026-09-05 | LTO **y** el muro 3 | — |
| 3 | Construir el `firefox` con LTO (hash `b3:6f2a3b2f`) | el eje de velocidad **y** las extensiones de `atuq` | 2 ✅ |
| 3.a | **PGO ENCENDIDO** ✅ 2026-09-07 — perfil de 501.521 funciones recogido bajo headless, publicado en el mirror y pineado por sha256; 189 compilaciones con `-fprofile-use`. Sin `jarlog` todavía | la otra mitad de la ganancia | 3 ✅ |
| 3.b | **RLBox ENCENDIDO** ✅ 2026-09-06 — cadena wasm de CINCO recetas selladas + guardián que la exige en el binario; firefox sellado **y REPRODUCE bit a bit** (`verificar-repro.sh`: 0 no-determinismos) | cierra el hueco de seguridad del §3.bis | 2 ✅ |
| 4 | **`atuq`: receta derivada + overlay de chrome** ✅ v0.1 escrita | el andamio de todo lo demás | 3 |
| 4.b | Branding + re-empaque de `omni.ja` ✅ v0.2 | el chrome de verdad | — |
| 4.d | **Abrirlo en pantalla** — destapó EXDEV, el lanzador, el `atk` envenenado y la cadena GTK3 estática | «usable», no sólo «sellado» | — |
| 4.e | **v0.3: página de inicio y pestaña nueva** por extensión de sistema; exigió `MOZ_REQUIRE_SIGNING` vacío. **Verificado 2026-09-07** con `scripts/test-atuq-inicio.py`: se le pregunta al MOTOR (`browserSettings.homepageOverride` / `newTabPageOverride`) y da `moz-extension://…/inicio.html` en las dos, contra `about:home`/`about:newtab` en el control | y con eso queda destrabada la unidad 6 (`sct`) | rebuild de firefox |
| 4.f | **Los códecs, medidos** ✅ 2026-09-07 — H.264+AAC, VP9+Opus, AV1, MP3 y FLAC reproducen; arreglado el `LD_LIBRARY_PATH` que dejaba al RDD sin ffvpx (AV1 muerto en silencio) y nacido `scripts/test-atuq-codecs.sh` con control negativo | «se ve el vídeo», que ningún auditor de ELF podía responder | — |
| 4.c | `MOZ_BUILD_DATE` determinista ✅ — `BuildID=19700101000001`, con guardián en install y **`why-differs` 56/56 sobre dos builds reales** | el invariante de reproducibilidad | — |
| 4.g | **El chrome se programa desde `atuq.cfg`, sin abrir `omni.ja`** ✅ 2026-09-09 — y de paso: la **vista dividida ya la trae el motor y viene prendida** (fx 154), ejercitada de verdad con `gBrowser.addTabSplitView` ⇒ `activeSplitView` + 2 navegadores. Guardián `scripts/test-atuq-chrome.py` con control negativo del propio motor | retira dos pendientes que eran de upstream, y deja el camino del chrome que NO pelea con el `jarlog` | — |
| 4.h | **Quién instala las extensiones, medido** ✅ 2026-09-09 — tres escenarios en `scripts/test-atuq-instalacion.py`: instala el ESCANEO de `distribution/extensions/`, `install_url` con `file://` no instala nada. El README de la receta afirmaba lo contrario y quedó corregido | el mecanismo del que cuelga la extensión de `sct` deja de ser una nota al pie y pasa a estar vigilado | — |
| 5 | **Host de native messaging — HECHO 2026-09-10.** `puriy-costura` en tawasuyu (`ebe41e96`) + el cable `shared/foreign-webext`, 14 tests sin navegador; y del lado de acá `recipes/puriy-costura.toml` ⇒ `b3:3b633431`, ELF estático de 952 K **verificado como artefacto**: contesta un marco de 4 bytes y hace la promesa del §6.1 completa (4 visitas aprendiendo → `stable` → un script cambiado = un evento). El camino ya estaba comprobado en el §7.bis con sonda y control negativo | los verbos del §6 | 4 ✅ |
| 6 | **`sct` v1 — HECHA 2026-09-10.** Extensión `sct@atuq.tawasuyu` (`filterResponseData` → native messaging) + el manifiesto en `/usr/lib/mozilla/native-messaging-hosts/` + el lanzador que le pone el `--state` que el manifiesto no puede llevar. Guardián `scripts/test-atuq-sct.py`: servidor HTTP real, seis cargas, **una carga = una visita** vigilado, la insignia releída, y dos controles (la 5ª repite el mismo script y no alerta; sin manifiesto no hay veredicto) | el diferenciador que nadie tiene, andando | 5 ✅ |
| 7 | **Descargas al CAS — HECHA 2026-09-10.** `cas.ingest`/`cas.list` en el host + la extensión `descargas@atuq.tawasuyu`; ingesta en STREAMING (`arje-cas::almacenar_fichero_en`, nueva) para no cargar una ISO entera en RAM; raíz propia porque el `GcCas` de arje borraría las descargas en silencio; el fichero del usuario no se toca. Guardián `scripts/test-atuq-descargas.py` con descarga real y control negativo | 6.2, y alimenta 6.9 | 5 ✅ |
| 8 | **Archivo personal — la mitad de congelar y buscar, HECHA 2026-09-10.** `archive.add`/`archive.search` + la extensión `archivo@atuq.tawasuyu`: el HTML ya ejecutado al CAS, el índice buscable en JSON, y nada de ventanas privadas. Guardián `scripts/test-atuq-archivo.py`, cuyo control negativo además **dice quién** impidió archivar lo privado. **Falta la mitad semántica**: un motor `rag-motor::RagMotor` (como `willay-rag`), que exige daemon de embeddings + LLM real | 6.3 | 5 ✅, 7 ✅ |
| 9 | **Medios (6.6) ✅ y torrent (6.9) ✅ — 2026-09-10.** El medio lo abre `mpv`; el torrent lo toma `puriy-costura-torrent`, un daemon **propio, configurable y perezoso** que sobrevive al navegador y cosecha al CAS. Medido antes de decidir: la pila de librqbit compila para musl (226 crates), así que la decisión fue «no ahí» y no «no se puede». **Faltan** el foco (6.5) y la IA local (6.7), ésta bloqueada porque el corpus no tiene modelo ni embeddings | 6.56.7, 6.9 | 5 ✅ |
| 10 | **Foco (6.5) — 2026-09-10.** Entra al corpus la cadena `nftables` (libmnl + libnftnl + nft 1.1.6), que el grafo pedía y nadie había puesto, con dos arreglos de fábrica: el sello de tiempo que rompía la reproducción y un bashismo. Medido con root: el cgroup en foco no sale y el de al lado sí. Y del lado del navegador, la extensión `foco` MUESTRA el estado y **no tiene verbo para apagarlo**. **Falta** quién pone a `atuq` en un cgroup (delegación, decisión de arje) y quién aplica la política (root) | 6.5 | 6 ✅ |
| 9.a | **Proxy por contenedor ✅ v0.5** — contenedores por política + extensión con `proxy.onRequest` | 6.8, y NO dependía de 5: es API de Firefox | — |
Las unidades 2 y 4 son **paralelizables**: la toolchain no toca el chrome y el chrome no toca la
toolchain. Si hay dos frentes, van juntas.
## 9. Por qué esto no abandona a `puriy`
`puriy` es el motor DOM/CSS propio con JS real (QuickJS-NG sobre `wasmi`) cuyo límite declarado no es
el lenguaje sino **el wiring nativo**: DOM bindings completos, red, render. Eso no se acelera
escribiendo otro navegador; se acelera con tiempo.
`atuq` no le compite porque no comparte una sola línea con él, y **le sirve** porque todo lo del §6
vive del lado Rust: el host del §7, el testigo de `sct`, las descargas al CAS, el archivo con RAG.
Son **agnósticos del motor**. Cuando `puriy` madure, se enchufan del mismo lado sin reescribirse.
Dicho al revés, que es como conviene recordarlo: **`atuq` es el navegador que se puede usar mientras
`puriy` crece, y el andamio de las features que `puriy` va a heredar.** Si dentro de dos años `atuq`
se apaga, lo que se tira es una receta derivada y una hoja de CSS; todo lo demás sobrevive.