El ensamblado dejaba de ser reproducible en cuanto se cerraba la terminal: cuatro pasos, tres de
ellos con una trampa que no se ve. Ahora es un script, con las trampas escritas en su cabecera:
1. **El kernel.** `linux.toml` NO sirve para hcloud (apaga SCSI y el disco de Hetzner es virtio-SCSI).
Va `linux-generic`, que sí trae `CONFIG_SCSI_VIRTIO=y` — dato que no está en la receta, lo pone
`make defconfig`, y sólo se ve en el `.config` que el artefacto publica.
2. **El init se pisa.** La clausura del perfil trae busybox y su `/sbin/init` gana por hidratarse
después. El script lo restaura e IMPRIME la reparación (`../bin/busybox → /usr/bin/arje-zero`),
que es la diferencia entre arreglarlo y creer que estaba bien.
3. **EXDEV.** Comprueba con `findmnt` que STORE y WORKDIR estén en el MISMO montaje y aborta con un
mensaje que dice qué hacer, en vez de fallar fichero por fichero a mitad de un `cp -al`.
4. **La clave es obligatoria.** Sin `AUTHKEYS` no arma nada: una instalación remota sin
`authorized_keys` deja una máquina viva e INALCANZABLE, que es peor que una que no arrancó.
Y el cmdline va SIN `init=`, a propósito: así corre el wrapper que escribe `install-image.sh`, que
es quien monta `/store` y `/var/lib/hammer`.
**`netup` entra como raíz de `perfil.servidor`** aunque su binario ya venga dentro del
`product-rootfs`. La razón es concreta y se pagó hoy: el del producto está congelado en el artefacto
sellado del bootstrap, así que el arreglo de la ruta on-link NO llegaba a la imagen. Declarado como
raíz, la hidratación lo proyecta encima y la imagen lleva el vigente — verificado por sha256: la
imagen trae `a23df20e…` y el product-rootfs `a84b0a0d…`.
`recipes/netup.toml` re-pineado a `be383ea4` (el commit del arreglo).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
Medido en la caja `takana` (Hetzner Cloud, hel1) el 2026-09-10. Hetzner entrega la IPv4 así:
inet 2.29.29.217/32 scope global eth0
default via 172.31.1.1 dev eth0
172.31.1.1 dev eth0 scope link <-- ESTA es la que faltaba
La dirección es un /32 y la puerta no pertenece a ninguna red conectada. `add_default_route` mandaba
`RTA_GATEWAY` con scope UNIVERSE y nada más, así que el kernel rechazaba la ruta.
**Y el modo de fallo es el peor**: la máquina arranca, `netup` consigue el lease, se pone la IP,
arje-zero levanta sshd... y no se puede entrar, porque las respuestas no tienen por dónde salir. Un
`ssh` desde fuera da `Connection timed out` y desde dentro no hay nada que se vea roto.
Se agrega `add_onlink_host_route` (dst=/32, scope LINK) y se llama ANTES del default. Es lo que hace
`ip route add <gw> dev <if> scope link`, y es literalmente lo que el propio rescue de Hetzner tiene
en su tabla. No es fatal si falla —un EEXIST no debe tumbar la red—; el default sí lo sigue siendo.
**Control causal**, en un netns con una dummy y una /32:
ip route add default via 172.31.1.1 dev dummy0 => Error: Nexthop has invalid gateway.
ip route add 172.31.1.1 dev dummy0 scope link => ok
ip route add default via 172.31.1.1 dev dummy0 => ACEPTADO
O sea: no es que "ayude", es que era exactamente eso. netup se validaba contra el DHCP de QEMU slirp,
que da un /24 con la puerta dentro — por eso nunca apareció hasta tocar una nube de verdad.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
El wrapper que `install-image.sh` escribe como `/sbin/init` monta las dos particiones dedicadas
antes de hacer `exec` del init real, y las nombraba `/dev/vda3` y `/dev/vda4`. Eso vale sólo donde
el disco es virtio-blk. **En una caja Hetzner Cloud la controladora es virtio-SCSI y el disco es
`sda`** (medido: el driver de `sda` es `sd` y `virtio_scsi` está cargado), así que los dos montajes
fallaban.
Y fallaban del peor modo posible: **el sistema arranca igual**. Sólo que `hammerd` —que la semilla
de arje lanza con `--store /store --journal /var/lib/hammer/journal`— queda sin store y sin diario,
con dos líneas de aviso perdidas en el arranque. Es exactamente la regla 3 de CLAUDE.md: un ausente
falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien.
Ahora el disco se DERIVA de dónde está montada la raíz (`/proc/mounts`), que es la única fuente que
no depende ni del nombre del dispositivo ni de udev. Probado en los dos sentidos con /proc/mounts
sintéticos: `/dev/sda2` → `/dev/sda3`, `/dev/vda2` → `/dev/vda3`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
Dos cosas encontradas armando la primera imagen del `perfil.servidor` (SDD 28 §1b).
**1. `[ -e "$ROOTFS/sbin/init" ]` rechazaba el rootfs CORRECTO y aceptaba el equivocado.** `-e`
sigue el symlink y lo resuelve contra el HOST. El `/sbin/init` del product-rootfs es un symlink
ABSOLUTO a `/usr/bin/arje-zero`, que en el host no existe ⇒ la guarda daba falso. Y al revés: un
rootfs cuyo init es `../bin/busybox` —symlink RELATIVO, que sí resuelve dentro del árbol— la pasaba
sin chistar. O sea que la única guarda que separaba «esto arranca takana» de «esto arranca otra
cosa» estaba exactamente al revés. Ahora acepta `-L`, y se probó en los dos sentidos: acepta el
symlink absoluto a arje-zero, sigue rechazando un directorio sin init.
Esto no es teórico: hidratar `perfil.servidor` sobre el product-rootfs **pisa `/sbin/init`** —
busybox entra en la clausura y su symlink gana por llegar después—. La imagen habría arrancado
perfecto con el init de busybox en vez de arje-zero, que es peor que no arrancar. Se restaura el
symlink al ensamblar y el cmdline lleva `init=/usr/bin/arje-zero` explícito, por las dos puntas.
**2. `STAGE` estaba cableado a `work/.install-stage`.** El staging HARDLINKEA desde `$ROOTFS`, y un
hardlink no cruza un montaje aunque sea el mismo disco. Con el layout normal de este repo —`store`
y `work/out` son bind-mounts de /dev/sdb, `work/` está en /dev/sdc— el rootfs vive del otro lado y
`cp -al` muere con EXDEV fichero por fichero. Ahora es env, documentado en la cabecera.
De paso: el fichero no tenía bit de ejecución (100644) mientras sus siete hermanos son 100755, así
que su propio ejemplo de uso `./scripts/install-image.sh` no funcionaba. Venía de antes; corregido.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
`takana` (id 165447050, cx33, hel1, 2.29.29.217, €8,49/mes). Va sin el label `role=hammer-worker`
a propósito: `farm-down.sh` y `deadman.sh` borran SÓLO lo que lo lleva, así que ninguna
automatización de la granja puede tocarla. Sin protección de borrado, también a propósito: es la
caja de sacrificio.
**`cx53` no se pudo comprar — `Available: no` en las TRES ubicaciones.** Y la disponibilidad cambia
por día: `cx33` daba `no` en hel1 el 09 y `yes` el 10 con la misma consulta. `hcloud server-type
list` devuelve precios de tipos que no se pueden crear ⇒ la recomendación de ayer (cx53 a €29,49)
estaba hecha sobre la tabla de precios, no sobre el stock. Queda escrito para no repetirlo.
**Tres cosas del §3 quedan confirmadas en la caja DESTINO, no inferidas de gioser:**
- Arranca por BIOS (sin `/sys/firmware/efi`) **y aun así la imagen trae un ESP** (`sda15`, 244 M
vfat). ⇒ **una partición EFI presente NO significa arranque EFI**; un instalador que decidiera la
rama mirando particiones elegiría mal justo acá. `takana-live-install.sh` mira lo correcto.
- `console=tty1 console=ttyS0` viene en el cmdline de Debian ⇒ el hueco del serial es real: sin él
la consola web de Hetzner no muestra nada.
- `eth0` con `/32` dinámico por DHCP, idéntico a gioser ⇒ es el caso exacto de `netup`.
Anotado lo que la caja arrastra: 7,6 GiB y CERO swap (misma clase que el `global_oom` de gioser ⇒
no construir pesado ahí) y `sshd` con `PasswordAuthentication yes`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
El 6.6 de la unidad 9 del SDD 26. Una navegación de PRIMER NIVEL a un medio
(`Content-Type: video/*` o `audio/*`) se cancela y la URL se la lleva `mpv`, que ya viaja en las
cuatro imágenes de escritorio: esto no agrega ni una receta, es cablear lo que ya estaba.
MEDIO http://…/audio.wav
CANCELADA la navegación http://…/audio.wav
ABIERTO /usr/bin/mpv pid=266 http://…/audio.wav
mpv: AO: [null] 8000Hz mono 1ch u8 ← su propio log: DECODIFICÓ, no sólo arrancó
LA REGLA ES ESTRECHA A PROPÓSITO: sólo el primer nivel. Un `<video>` embebido es parte de la página y
sacarlo de ahí rompería el sitio que lo puso. Las reglas anchas en el camino de cada petición son las
que terminan rompiendo la web de alguien.
FAIL-OPEN, y es lo que prueba el control negativo: 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 con el
puerto vivo y, si el host contesta que no pudo, la URL VUELVE al navegador con una marca para no
entrar en bucle:
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 justo el que se consigue si
uno confía en que salió bien.
⚠ EL BUG QUE ESTE GUARDIÁN DESTAPÓ VALE MÁS QUE LA FUNCIÓN, y estaba DESPUÉS del éxito aparente: la
primera corrida detectó el medio, canceló y recibió el pid… y después el puerto murió con «Native
application tried to send a message of 546281442 bytes». **Un hijo hereda los descriptores del padre,
y los del padre SON la tubería de native messaging**: `mpv` escribía su salida ahí y el navegador la
leía como un marco. Arreglado en tawasuyu (`2e99d216`, pineado acá) con tres redirecciones, cada una
contra un fallo distinto — `stdin` a null porque mpv LEE stdin y se comería los mensajes del
navegador; `stdout` al stderr del host y no a `/dev/null`, porque apagarlo arreglaría el bug y se
llevaría puesto el diagnóstico; `stderr` heredado por lo mismo.
Y dos cosas del ARNÉS, las dos ya vistas antes en esta misma sesión:
· la URL NO se pasa por la línea de comandos: una petición del arranque compite con la
inicialización de la extensión (la misma carrera del guardián de `sct`). El guardián navega a una
página que redirige a los 3 s, que además es lo que hace una persona: seguir un enlace;
· la evidencia de que el reproductor CORRIÓ se le pide a su propio `log-file`, porque su salida ya no
va al stdout del host y Gecko no vuelca el stderr del host al del navegador. Sin eso sólo se sabría
que hubo un `spawn`.
Del §6 quedan el foco (6.5), la IA local (6.7) —que además es la que traería el motor que le falta al
archivo del §6.3— y el torrent (6.9). Medido de paso: **el corpus no tiene ninguna receta de LLM ni de
embeddings**, así que 6.7 hoy no se puede pagar.
Medido sobre `atuq b3:a7080058` y `puriy-costura b3:060314e6`.
La unidad 8 del SDD 26 (§6.3), en su mitad pagable. Cada página que se lee se congela: el HTML **ya
ejecutado** —lo que la persona vio, no lo que mandó el servidor— va al CAS con su BLAKE3, y la url,
el título y el texto visible al índice. Después se busca, sin el navegador.
visita 1 (página A) ⇒ archivada, visitas=1, total=1
visita 2 (página A) ⇒ dedup, visitas=2, total=1 ← volver NO duplica
visita 3 (página B) ⇒ total=2
«masa» → 1 de 2 · «tobera» → 1 de 2 · «masa tobera» → 0 · «helicóptero» → 0
LA IDENTIDAD ES EL BLAKE3 DEL HTML, NO LA URL, y de ahí sale lo que hace que esto valga: la misma URL
con otro contenido ES otra página, así que el archivo tiene VERSIONES de lo que leíste. Un historial
de direcciones guarda punteros, y los punteros se pudren.
⚠ LA MITAD QUE NO SE PAGA HOY, dicha en el código, en el LEEME de allá y en el §6.3.bis: preguntarle
al historial en LENGUAJE NATURAL. Eso es el registro semántico y en la suite ya tiene forma —un motor
`rag-motor::RagMotor`, como `willay-rag`—, que necesita un daemon de embeddings y un backend LLM real
y devuelve `None` cuando no están. `archive.search` es el registro LITERAL: todas las palabras tienen
que aparecer. Enseñar lo uno diciendo que es lo otro sería el peor cambio posible.
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 (el
HTML de un visor de PDF llenaría el archivo de cosas que no se encuentran). Y si no se puede saber si
la pestaña es privada, NO se archiva.
⚠ Y EL CONTROL NEGATIVO NO SÓLO COMPRUEBA QUE NO SE ARCHIVE: DICE QUIÉN LO IMPIDIÓ. La respuesta
medida no es la que uno supondría — hoy lo impide **el navegador**, porque las extensiones no corren
en ventanas privadas salvo que se las habilite, así que nuestro `if (incognito) return` no se ejecuta
nunca. No es código muerto: es lo que haría seguro habilitar `private_browsing` el día que haga
falta. Lo que sí habría sido un error es escribir «la extensión no archiva lo privado» sin saber cuál
de las dos cosas estaba pasando.
DOS TECHOS CON NOMBRE (en el host): 4 KiB de texto por página en el índice —es JSON para poder leerse
con `cat`, y uno sin techo deja de poder— y el corte por CARÁCTER y 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.
Del lado de tawasuyu (`357791a8`, pineado acá): `archive.add`/`archive.search`, 6 tests nuevos (20 en
total) y el CAS renombrado de `descargas-cas` a `cas` — el mismo almacén guarda ahora lo bajado y lo
leído, y un nombre que describe la mitad de su contenido es el que se lee mal dentro de seis meses.
Medido sobre `atuq b3:5cfe3221` y `puriy-costura b3:c06f55aa`.
La unidad 7 del SDD 26 (§6.2). Cuando una descarga TERMINA, la extensión
`descargas@atuq.tawasuyu` le pasa al host la ruta, el nombre y de dónde salió; el host la ingiere a
un CAS BLAKE3 y contesta el hash, el tamaño y **cuántas veces se bajó ese mismo contenido**. El mismo
contenido con otro nombre es UN objeto y dos nombres, y el navegador lo dice — que es exactamente lo
que ningún navegador sabe hacer: para todos una descarga es un blob con nombre, y por eso la bajás
dos veces y no te enterás.
No es un almacén nuevo: es `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y
tejido. El `<hex>` del CAS ES el `expected_hash` de un `.swm`.
⚠ TRES DECISIONES QUE SALIERON DE MEDIR, NO DE DISEÑAR:
1. **La raíz del CAS de descargas no es la del sistema.** `arje_cas::gc` borra todo blob que no esté
en el set `reachable` de su llamador, y el único que existe (`arje-brain`, `GcCas`) lo arma con la
cadena de audit y las raíces vivas del grafo. Una descarga del usuario no está en ninguno: el
primer GC se la llevaría, en silencio. Van a `<estado>/descargas-cas` — mismo formato (mover un
objeto al CAS del sistema es un `rename`), fuera del alcance del GC de otro. El precio queda
escrito: tejido sirve el CAS por defecto, así que compartir una descarga hoy exige apuntarlo ahí.
2. **El fichero del usuario no se toca**: se copia y se deja donde estaba. El guardián lo comprueba
byte a byte. Borrar o mover lo que alguien acaba de bajar no es decisión de un navegador.
3. **La extensión observa, no intercepta.** Se entera cuando la descarga terminó. Meterse en el medio
obligaría a decidir qué pasa si el CAS falla, y la respuesta correcta —que la descarga siga igual—
es lo que se consigue no metiéndose.
Y EL HALLAZGO QUE COSTÓ LA TARDE, que no es de esta unidad: **en `--headless` el navegador se cae con
SIGSEGV en cuanto una descarga termina.** Se atribuyó con dos controles antes de tocar nada:
--headless, con la extensión → baja el fichero, TERMINADA, Segmentation fault (139)
--headless, SIN la extensión → baja el fichero, Segmentation fault (139) ← no es nuestro
--headless, panel de descargas apagado → Segmentation fault (139)
sway headless (compositor REAL) → baja, INGIERE al CAS, y no se cae ← no es del producto
Sin el primer control esto se leía como «la extensión de descargas rompe el navegador» (perseguir un
bug que no existe); sin el último, como «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 lo dice en su cabecera: 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. Queda en el §6.10.bis del SDD, que es donde vive
lo que atuq NO puede hacer.
El guardián baja de verdad (`Content-Disposition: attachment`, no una llamada a la API) dos veces
dentro de un mismo compositor, 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.
Del lado de tawasuyu (`ffa939c6`, pineado acá): `cas.ingest`/`cas.list` en el host y
`arje-cas::almacenar_fichero_en` — ingesta en streaming, porque `store` toma `&[u8]` y una ISO de
4 GiB serían 4 GiB de `Vec`. 5 tests nuevos en arje-cas y 5 en puriy-costura.
Y EL BUG QUE EL GUARDIÁN DESTAPÓ, que vale 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, y el de `sct` revertía el índice de descargas. Los dos contestaban
bien — el daño estaba sólo en el disco. Se vio porque el guardián lee el índice EN DISCO en vez de
creerle a la respuesta: decía `veces=2` y el fichero decía 1. Arreglado en tawasuyu (`55b918e8`,
pineado acá): cada proceso escribe sólo la parte que tocó, y el test que lo fija se comprobó
ROMPIENDO el arreglo a propósito — porque la primera versión de ese test abría los hosts en secuencia
y pasaba con el bug puesto.
La unidad 6 del SDD 26, que es el diferenciador del §6 que no tiene ningún navegador. Cadena entera,
medida de punta a punta con un servidor HTTP real y seis cargas de página:
servidor HTTP → filterResponseData → connectNative → /usr/lib/mozilla/native-messaging-hosts/
→ puriy-costura --state → puriy-sct (TOFU + bitácora)
carga 1-3 (mismo script) fase=learning eventos=0 insignia vacía
carga 4 (mismo script) fase=stable eventos=0 insignia vacía
carga 5 (mismo script, estable) fase=stable eventos=0 insignia vacía ← control
carga 6 (script CAMBIADO) fase=stable eventos=1 insignia "1"
EVENTO ext:…/app.js 0ff4771fb797→f7ccb9fedfbd +27B
La extensión NO hashea ni guarda nada: ve bytes y pregunta. El registro es `puriy-sct`, del otro lado
del cable — duplicarlo en JS habría sido un segundo registro que se desalinea del primero, y el
primero es el que está certificado sin red.
CINCO COSAS QUE SE MIDIERON EN VEZ DE SUPONERSE, y las cinco fallan calladas:
1. el manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/` y NO en el appdir: la ruta sale de
`XRESysNativeManifests`, un `/usr/lib/mozilla` COMPILADO dentro de Gecko;
2. **el manifiesto no puede llevar argumentos** — `NativeMessaging.sys.mjs` hace
`command = manifest.path` y los únicos argumentos son `[ruta-del-manifiesto, id]`. Y sin `--state`
el host corre en MEMORIA: cada arranque volvería a «aprendiendo» y nada alertaría nunca. De ahí el
lanzador `bin/puriy-costura-host`, que además decide la ruta del estado — dónde vive el estado de
un usuario es layout del FHS, o sea asunto de la distro y no del crate;
3. `filterResponseData` y el permiso `webRequestFilterResponse` SÍ están en nuestro `omni.ja`
(se le preguntó al artefacto, no a la documentación de Mozilla);
4. los scripts `inline` NO se ven por esta vía —`filterResponseData` entrega el cuerpo de una
PETICIÓN— y para v1 alcanza: el ataque que sct nombra es la sustitución en el CDN;
5. ⚠ **una carga de página puede producir dos peticiones del mismo documento, y una llega con
`tabId = -1`.** La primera versión agrupaba por `(tabId, documento)` y contaba esa carga como DOS
visitas. No es cosmético: inflar las visitas estabiliza el origen 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. Ahora agrupa por documento (dos pestañas con la misma url cuentan UNA: es el error
seguro, tarda más en proteger y no alerta de más) y el guardián VIGILA el invariante «una carga,
una visita», así que si vuelve, falla ruidoso.
DOS CORRECCIONES DEL PROPIO §6.1, que decía «consulta al testigo antes de dejarla pasar»: el cable
del testigo es un POST con postcard, así que un JS no puede ser su cliente; y v1 OBSERVA Y AVISA, no
bloquea — es lo que puriy-sct dice de su propia v1, y poner un viaje entre procesos en el camino
crítico de cada script de cada página no es «más seguro», es un navegador que nadie usa.
EL AVISO SE MIDE, NO SE SUPONE: la extensión relee la insignia con `getBadgeText` después de ponerla,
y el guardián exige vacía en las cinco cargas sin novedad y "1" en la del script cambiado. Es la única
parte de la cadena que el usuario ve; dejarla en «se llamó a la API» era dejar sin medir el final.
CONTROL NEGATIVO: `--negative-control` borra el manifiesto y exige que NO haya veredicto — o sea que
el veredicto de la corrida positiva viene del host y no de la extensión inventándolo.
Y el aviso pasivo es decisión, no falta de tiempo: insignia y tooltip, no modal. Un modal por cada
despliegue de un sitio entrena a la gente a cerrarlo sin leer, y entonces el que importa también se
cierra.
Además: `rebrand.py` instala y CRUZA los manifiestos nativos (que el `path` exista y sea ejecutable
dentro del artefacto, y que sus `allowed_extensions` sean extensiones que de verdad empaquetamos), y
de paso se corrige el comentario del §4.ter que repetía la afirmación falsa sobre quién instala las
extensiones — lo mide `scripts/test-atuq-instalacion.py`: instala el escaneo de la carpeta.
`runtime = [..., "puriy-costura"]` en atuq.toml: sin eso la imagen llevaría manifiesto y extensión y
el host NO estaría, y la función se apagaría sola sin una línea de error. `yupana radio` confirma que
llega a las cuatro imágenes de escritorio.
⚠ Límite escrito en `fondo.js`, en el `lib.rs` del host y en los dos LEEME: se hashea el TEXTO ya
decodificado que entrega la extensión, no los bytes que sirvió el servidor. Vale para comparar dos
cargas nuestras; NO es comparable con el hash que publique un tercero sobre los bytes servidos, ni
con el de la v2, que engancha el script loader y ve los bytes reales.
Y una segunda cosa que el guardián encontró y que es del PRODUCTO, no del test: **la página que abre
el navegador al lanzarse puede no ser observada** — compite con la inicialización de la extensión, y
la carrera se gana o se pierde según la corrida. En uso real sólo afecta a esa primera página (después
la extensión ya está escuchando). Por eso las aserciones van sobre la SECUENCIA OBSERVADA y no sobre
un calendario: se exige que ninguna carga se observe dos veces, que la única que puede faltar sea la
del arranque, y que la secuencia aprender→estabilizar→no-alertar→alertar sea la correcta.
Sin regresiones: `test-atuq-politica.py` («guardianes: todos correctos», y sus cinco roturas siguen
matando el build con tres extensiones) y `test-atuq-inicio.py` (la home y la pestaña nueva siguen
siendo las nuestras) pasan sobre el artefacto final `b3:d3ced586`.
Cierra la fila 5 del plan con lo que hay: `puriy-costura` + `shared/foreign-webext` en tawasuyu
(`ebe41e96`, 14 tests) y `recipes/puriy-costura.toml` acá (`b3:3b633431`, verificado como artefacto:
contesta un marco de 4 bytes y hace la promesa del §6.1 completa).
El §7.quater deja tres cosas que no estaban escritas:
· POR QUÉ HACÍA FALTA el host y no se podía atajar: el §6.1 decía que la extensión «consulta al
testigo», y el cable del testigo es un POST con postcard, no JSON — un JS no puede ser su cliente.
Reimplementar el TOFU en la extensión sería el sustituto paralelo que la regla 10 de tawasuyu
prohíbe, además de duplicar la única pieza ya certificada.
· CÓMO SE ELIGIÓ EL NOMBRE, con el grep de la regla 10 hecho ANTES: ningún término del dominio
significaba esto, y los que suenan a mensajero están ocupados por dominios ajenos y grandes
(chasqui = type broker, chaka = puente a COBOL, paloma = correo, tampu = común de objetos). El
nombre salió del título de este propio §7: la costura.
· LOS DOS LÍMITES, que van en el código y en los LEEME y no en una nota de sesión: se hashea el texto
ya decodificado (vale para detectar, no es comparable con el hash de un tercero ni con el de la v2),
y la semilla del log sale del directorio de estado ⇒ a prueba de reescritura, no de suplantación.
Y lo medido para la unidad 6 preguntándole al artefacto: `filterResponseData` y el permiso
`webRequestFilterResponse` SÍ están en nuestro omni.ja ⇒ la extensión podrá leer el cuerpo de un
script. Los inline NO se ven por esa vía, y para v1 está bien: el ataque que sct nombra es la
sustitución en el CDN, o sea el caso `<script src>`.
El binario de «la costura» (SDD 26 §7) entra al corpus: `b3:3b633431`, ELF estático musl de 952 K
pineado a tawasuyu `ebe41e96`. Verificado como artefacto y no sólo como build — un marco de 4 bytes
por stdin y contesta `{"id":1,"ok":true,"verb":"ping","version":"0.1.0"}`; y la promesa del §6.1
entera contra el binario SELLADO: cuatro visitas aprendiendo, a la cuarta `stable`, y en la quinta un
script cambiado ⇒ un evento que nombra el recurso y el delta de tamaño (+6 bytes). Deja
`registro.postcard` y `bitacora.postcard` en el `--state`.
Las dos cosas que costaron, las dos con el mismo patrón (el error no nombra la causa):
1. `cargo vendor --locked` murió con «cannot update the lock file», que NO dice qué crate falta. El
lock de tawasuyu estaba desalineado con su propio `main`, y —esto es lo reutilizable— **un
`Cargo.lock` regenerado en ese clon compartido NO describe su `main`**: ese árbol tiene cientos de
ficheros en vuelo de otras sesiones. El nombre del crate culpable salió de diffear el lock
publicado contra el que resuelve un árbol LIMPIO del commit (`git archive <sha> | tar -x`, que
además no toca el `.git` compartido). Arreglado allá en dos commits; acá se pinea el que cierra.
2. Después murió por el `vendor/` propio del monorepo: tawasuyu parchea `smithay` a mano y lo enchufa
con `[patch.crates-io] path = "vendor/smithay"`. Un patch por RUTA no necesita
`.cargo-checksum.json` —y upstream no lo commitea—, pero `cargo vendor` convierte su directorio de
salida en un directorio de REEMPLAZO DE FUENTE, donde cada crate sí tiene que traerlo. El síntoma
fue 1 de 1699 crates sin checksum y un error que hablaba de `taffy`. La solución ya estaba en el
corpus: `cargo_vendor_dir`, que las otras cinco recetas de este monorepo ya llevan.
⚠ Y la explicación FÁCIL era falsa: «cargo vendor borró el checksum» — `git ls-tree` dice que ese
fichero nunca existió. Queda escrito en la receta para que nadie lo vuelva a deducir.
El precio, medido y anotado: el vendoreo es del workspace ENTERO (2,4 G, 1699 crates, `axum` y
`aws-lc-sys` incluidos) para un host que usa quince deps. Mismo precio que ya pagan las otras cinco.
NO se declara en ningún perfil de `targets.toml` todavía, y NO se agrega el manifiesto de native
messaging a atuq: sin la extensión que lo llame (unidad 6) sería un binario que nadie invoca y un
manifiesto apuntando a una extensión inexistente, que es un fallo silencioso — el navegador arranca
igual y la función no está.
Medido recorriendo los `.service` de D-Bus de TODO el store: los únicos backends de portal sellados
eran los de COSMIC y GNOME. Ninguno de KDE, y la cola `incoming-kde` no tenía ni el frontend.
Consecuencia concreta: la imagen KDE trae `obs-studio` con su plugin linux-pipewire, que captura
pantalla POR EL PORTAL — o sea que en Plasma no tenía con quién hablar mientras en los otros dos
escritorios ese frente estaba cerrado. Sin portal tampoco hay diálogo de fichero ni compartir
pantalla para nada que los use.
Van los DOS paquetes porque la cadena es de tres:
cliente → org.freedesktop.portal.Desktop [frontend] → org.freedesktop.impl.portal.* [backend]
El frontend se copia de la cola de COSMIC y **comparte artefacto**: hash idéntico (ecbe13f68eca6)
desde las dos colas ⇒ un solo directorio en el store, cero builds.
El backend es receta NUEVA, 6.7.2 — la MISMA versión que plasma-workspace, kwin y kscreenlocker de
esta cola, porque habla interfaces privadas de kwin para el ScreenCast y desalinear ahí es pedir que
dos mitades del mismo release se entiendan por casualidad. **Cero frontera nueva**: sus 22
dependencias (Qt6, KF6, KWayland, protocolos de Wayland, xkbcommon) ya estaban todas, verificadas
una por una antes de escribir la receta.
⚠ SIN EL PORTAL DE IMPRESIÓN, y la causa es más honda que el error. El build moría en
`src/print.cpp:27` con `'QtPrintSupport/private/qcups_p.h' file not found`, y la cadena está medida
entera: no hay NINGUNA receta de cups en el catálogo ⇒ qtbase 6.11.1 se construyó sin CUPS ⇒ su
`QtPrintSupport/private/` trae qpaintengine_alpha_p.h y qprintengine_pdf_p.h pero NO qcups_p.h.
No falta una cabecera: falta el subsistema. Un portal de impresión sin con qué imprimir no es
funcionalidad que se pierde, es código muerto que no enlaza.
Upstream no expone `option()` para portales —se compilan todos—, así que el parche va en TRES
sitios y no en uno: la fuente, la instanciación (desktopportal.cpp/.h) y **la lista de interfaces
que el backend ANUNCIA**. El tercero es el que se olvidaría y el que importa: anunciar
`impl.portal.Print` sin implementarlo hace que el frontend enrute a un portal que no contesta, que
es peor que no tenerlo. Cada `sed` lleva su `grep` de verificación al lado, para que el día que
upstream mueva esas líneas el parche falle RUIDOSO en vez de volverse inerte.
VERIFICADO sobre el artefacto sellado: publica `org.freedesktop.impl.portal.desktop.kde.service`,
y su `kde.portal` anuncia 17 interfaces con **0 apariciones de Print** e incluyendo ScreenCast,
Screenshot y RemoteDesktop, que es exactamente lo que OBS necesita. Y sobre el rootfs hidratado:
están el frontend (`org.freedesktop.portal.Desktop.service`), el backend y el `kde.portal`.
⚠ Y la licencia casi la firmo mal: leí la cabecera de UN fichero y generalicé. Contadas las
etiquetas SPDX del árbol entero: 88 `LGPL-2.0-or-later`, 23 de la fórmula de KDE e.V. y 1
`GPL-2.0-or-later`. El binario las enlaza a todas ⇒ la expresión es la CONJUNCIÓN.
El perfil pasa a 98 raíces, 361/361 listo, deuda 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCt3ettR4Z7b6wPCBmbvEV
El README de la receta afirmaba (a) que las extensiones las instala la POLÍTICA porque el
sideloading desde `distribution/extensions/` «ya no funciona», y (b) que el chrome de verdad
—split view— exige entrar a `omni.ja` y escribirlo nosotros. Las dos son falsas, y ninguna
observación del artefacto las distinguía: hubo que romper un mecanismo por vez.
scripts/test-atuq-instalacion.py — tres escenarios:
as-is nada roto ⇒ las dos extensiones puestas
no-policy `policies.json` sin ExtensionSettings ⇒ SIGUEN puestas
policy-only los XPI fuera de la carpeta ⇒ NINGUNA, y la home vuelve a about:home
⇒ instala el ESCANEO de la carpeta; `install_url` con `file://` no instala nada. El §7.bis del
SDD 26 tenía razón. El control es por construcción: la sonda vive en la carpeta y ninguna política
la nombra, así que una corrida muda se declara ROTA en vez de leerse como «no instaló». Y la
segunda señal que probé NO discrimina, queda dicho: el `location` de `extensions.json` sale
`app-profile` también cuando instala la carpeta.
scripts/test-atuq-chrome.py — el chrome se programa desde `atuq.cfg`, sin abrir el zip: observando
`browser-delayed-startup-finished` se toca el `gBrowser` de cada ventana. Y la vista dividida ya la
trae el motor (fx 154) PRENDIDA de fábrica, ejercitada de verdad —`addTabSplitView` ⇒
`activeSplitView` + 2 navegadores—, no «el fichero está». Dos pendientes que eran de upstream.
Control negativo del propio motor: con las pestañas fijadas devuelve null (WRAPPER null, ACTIVA no).
Corolario para lo que venga: cada función del chrome que NO entre a `omni.ja` es una que no pelea
con el orden del `jarlog` del PGO — que es justo lo que tiene al jarlog aparcado.
Queda escrito lo que NO está: ninguno de los `test-atuq-*` corre en el latido (sólo lo hace
`vigia-sonames.py`), y meterlos cuesta ~3 min por ciclo más el rootfs hidratado en el hub.
Medido sobre atuq b3:fab2fbfb → b3:8f6d09c2 (el README entra en el hash: documentar la medición
re-hashea el artefacto medido, y los guardianes se niegan a medir uno que no sea el vigente).
ADR 0016 los listaba entre los CONGELADOS; el usuario pidió descongelarlos al abrir el SDD 28. La
enmienda queda escrita en el propio ADR, que si no la etiqueta deja de describir el hecho.
`hammer-install.sh` → `takana-install.sh`, `hammer-live-install.sh` → `takana-live-install.sh`,
`hammer-banner.txt` → `takana-banner.txt`, `BRIEFING-hammer.md` → `BRIEFING-takana.md`.
**Lo que hacía caro esto no es el nombre del fichero.** El instalador se inyecta en el ISO como
`/usr/bin/hammer-install` y su éxito se detecta con un `grep` de `HAMMER-INSTALL-OK` desde TRES
scripts de prueba. Renombrar un solo lado los deja casando NADA — sin fallar —, que es literalmente
el modo en que `atribuir-fallos.py` quedó mudo cuando el renombre movió el target de `tracing`.
Se renombraron las dos puntas en el mismo commit (`/usr/bin/takana-install`,
`TAKANA-INSTALL-OK/FAIL`, `TAKANA_INSTALL_*`, `work/takana-install.img`, `/run/takana-install`),
se comprobó por `grep` que no quede ningún token viejo fuera del ADR, y —lo que decide— se CORRIÓ
`install-tui-test.sh`: 4/4 casos verdes.
Las tres `TAKANA_INSTALL_*` caen al nombre viejo (`${TAKANA_INSTALL_X:-${HAMMER_INSTALL_X:-}}`):
el llamador puede ser un ISO anterior al renombre. Misma convención que `TAKANA_ROOT_PW` unas
líneas más arriba en ese mismo script.
NO se tocó `/usr/sbin/hammer-recover` ni su hook de arranque —renombrarlo rompe máquinas YA
INSTALADAS, no el repo—: sobrevive intacto dentro del script renombrado, verificado por conteo
antes y después (8 ocurrencias). Tampoco `hammerd`, `hammer-edit` (su `name` está en la ruta del
store), `/var/lib/hammer`, `HAMMER_LIVE` ni los siete literales de hash.
De paso: `scripts/.hammer-banner.txt.kate-swp` era un swap de editor commiteado por error. Fuera.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
El frente arranca con el inventario MEDIDO de gioser, no con un plan: PID 1 es `arje-zero` (nuestro
init ya gobierna un servidor real, aunque sobre userland Artix), Hetzner Cloud arranca por **BIOS**
—que es justo el layout que `takana-install.sh` ya escribe— y el kernel propio trae VIRTIO/EXT4 `=y`
con `netup` haciendo DHCPv4, que es exactamente el caso de una caja hcloud.
Dos hallazgos que cambian el trabajo:
- **La secuencia de arranque de gioser no está escrita en ningún lado.** `rc-status` dice `stopped`
para caddy, gitea, sshd, cronie y act-runner y los cinco están VIVOS colgando de PID 1. Un
`arje-absorb --from openrc` produciría una Semilla perfecta que no levanta el servidor.
- **La mitad del Caddyfile describe un servidor que ya no existe**: 4 de 19 dominios sin DNS y sin
ficheros en `/var/www`, uno en 502. Una mudanza fiel copia también la basura ⇒ la regla queda
escrita: no se muda lo que no responde.
`[perfil.servidor]` hereda `cli` (SDD 27 §7.1) y declara 5 raíces que existen (openssh, caddy, curl,
wget, tmux) y **5 que NO** (takana, chrony, nftables, cronie, logrotate). Quedan `wanted`, y `wanted`
no es `debt` ⇒ `drenaje.json` seguirá diciendo `deuda=0`. Se declaran igual: sin ellas el perfil
saldría N/N —completo y verde— describiendo un servidor sin hora, sin cortafuegos y sin latido. Es
la lección de `foot` en escritorio-sway, pagada por adelantado. Cuenta esperada: 5, verificada
resolviendo cada nombre contra el campo `name` de las 869 recetas.
La peor de esas cinco: **takana no tiene receta**. El corpus construye 869 y no la suya, así que el
host no puede instalar takana desde el repo de takana — el bloqueante de que el servidor se sirva a
sí mismo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
El usuario preguntó si va a poder ejecutar kate desde mirada o si kate «está para KDE y no se
hereda». La pregunta separa dos cosas que hoy comparten un solo mecanismo: qué CONTIENE un
metapaquete (lo que resolvió el §7.1) y qué PUEDE CORRER en un sistema.
MEDIDO sobre la clausura de kate: son 110 recetas, y lo único que suena a Plasma —kwindowsystem,
plasma-activities, plasma-wayland-protocols— son LIBRERÍAS. No aparece plasmashell, ni kwin, ni
plasma-workspace. O sea que kate necesita Qt6 + KF6 + un compositor, y mirada TIENE compositor: que
hoy no corra ahí no es técnico, es que no está instalado. Coste medido: mirada son 41 nodos, kate
110, ya coinciden 24 ⇒ faltan 86, que es traerse Qt6 y KF6. Ese precio no lo cobra KDE, lo cobra Qt.
Y la distinción YA EXISTE en el repo sin estar nombrada: mpv, atuq, swayimg y libnotify están
declaradas en los CUATRO escritorios —repetidas, no heredadas—, zathura en tres, foot en dos. El
repo ya trata las apps distinto de kwin, sólo que copiando la línea en cada perfil.
Propuesta escrita: que el metapaquete del escritorio sea el ESCRITORIO (shell, compositor, tema,
ajustes) y que las apps salgan a metapaquetes propios instalables desde cualquier sistema, de modo
que `takana install kate` funcione desde mirada sin instalar Plasma. Se lleva bien con el §8: con
vistas montables, «tengo KDE y GNOME y elijo en el greeter» y «uso kate dentro de GNOME» dejan de
ser dos problemas y pasan a ser uno solo — qué clausura se compone para esta sesión.
Cuesta mover ~20 declaraciones de targets.toml y no toca ninguna receta ni ningún artefacto: es
reclasificar, no reconstruir.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCt3ettR4Z7b6wPCBmbvEV
Paso 1 del SDD 27. Resultó ser UNA LÍNEA POR PERFIL: el mecanismo `hereda` ya existía en
`scripts/targets.py` —transitivo, con detección de ciclos y orden preservado— y `escritorio-sway`
ya lo usaba desde el 2026-08-07. Los otros tres nunca lo declararon, y eso no era una decisión: era
composición no declarada.
LO QUE ARREGLA, medido antes: de los 27 paquetes del perfil `base`, la clausura de escritorio-kde
alcanzaba DOS. Contra el rootfs real: sin `bash`, sin `sudo`/`doas`, sin `git`, sin `useradd`, sin
`gpg` y **sin `dhcpcd`** —o sea sin con qué pedir una IP—; y lo que parecía estar (`ip`, `mount`,
`fsck`) eran applets de busybox (`/sbin/ip → ../bin/busybox`).
DESPUÉS, verificado hidratando de verdad y no leyendo el grafo: en el rootfs aparecen `bash`,
`sudo`, `doas`, `git`, `dhcpcd`, `useradd`, `gpg`, `rg`, `fd`, `bat`, y `/sbin/ip` pasa a ser el
BINARIO de iproute2 en vez del applet.
raíces kde 49→96 · gnome 39→86 · cosmic 43→88
nodos kde 301→356 · gnome 204→256 · cosmic 162→216
deuda 0 en los tres — no hubo que construir NADA, ya estaba todo sellado
colisiones nuevas al hidratar: CERO (los 28 .hammer-tmp son los de gmp/mpfr de antes)
⚠ `escritorio-mirada` NO hereda a propósito: es el rootfs *slim* del USB y sumarle 47 raíces
contradice su razón de ser. Si algún día se quiere, es la misma línea.
`kde-rootfs` en el volumen se rehidrató con la herencia (11 G) para que el nombre canónico no quede
viejo — que es el error que este mismo día costó encontrar en GNOME y COSMIC. El rootfs FUNDIDO y la
imagen siguen siendo los de antes: rehacerlos es un paso aparte.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCt3ettR4Z7b6wPCBmbvEV
El usuario señaló que «perfil» suena a preset exclusivo y preguntó lo que hay que preguntar: si no
va a poder instalar KDE, GNOME y mirada a la vez y elegir en un greeter.
MEDIDO, comparando los dos rootfs hidratados que hay en disco (kde-rootfs 78.934 ficheros,
gnome-rootfs 19.955): 14.455 rutas están en LOS DOS, y de ésas **12.766 comparten INODO** — o sea
que son el mismo artefacto del store, no un conflicto. Sólo **1.689 (12 %) colisionan de verdad**.
⇒ Lo que impide instalar dos escritorios NO es el store, que ya los tiene conviviendo: es
proyectarlos al MISMO /usr. Y las colisiones no son misteriosas —libuuid.so.1.3.0, dbus-daemon,
gdbus, gapplication, /etc/dbus-1/*.conf— salen de que cada cola tiene su propia glib y su propio
dbus. Es el mismo cuadro de las dos poppler que targets.toml ya documenta, y el de gmp/mpfr que
dejó 28 .hammer-tmp al hidratar KDE hoy.
Dos caminos, no excluyentes: bajar las colisiones unificando lo duplicado (una glib, un dbus), o no
proyectar a un /usr único — que es literalmente lo que el SDD 18 ya propone («activar un nodo =
montar su composefs como /usr»). Con vistas, las 1.689 dejan de existir por construcción. El
greeter encaja solo: el arranque por grafo del ADR 0010 ya elige qué clausura activar.
Y queda anotado el corolario de orden: el paso 5 del plan (unificar imagen e instalador) construiría
sobre el supuesto de un solo escritorio a la vez. Hay que decidir FHS plano vs vista composefs ANTES
de unificarlos, porque es la diferencia entre «instalás uno» y «instalás los tres».
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCt3ettR4Z7b6wPCBmbvEV
PROPUESTA, nada implementado. Escrito a pedido del usuario mientras se tapaba el hueco de upower.
⚠ EL HALLAZGO QUE LO MOTIVA, y es peor de lo que parecía: `targets.toml` ya avisaba que
«kde/gnome/cosmic NO heredan base», pero la consecuencia no estaba escrita en ningún lado. Medida:
**de los 27 paquetes del perfil `base`, la clausura de escritorio-kde alcanza 2**. Y comprobado
contra el rootfs real de la imagen: NO hay `bash`, ni `sudo`/`doas`, ni `git`, ni `useradd`, ni
`gpg`, **ni `dhcpcd`** — o sea que la imagen no sabe pedir una IP. Lo que sí hay (`ip`, `mount`,
`fsck`) son APPLETS DE BUSYBOX: `/sbin/ip → ../bin/busybox`.
O sea que la respuesta a «¿abrir un escritorio es como si base no existiera?» es **sí**. Y la causa
es que hay DOS cosas llamadas base que nadie había distinguido por escrito:
· base de ARRANQUE (`metal-rootfs`): arje-zero + busybox. La imagen la funde. Existe.
· base de USERLAND (`[perfil.base]`): las 27. No la funde nadie.
El documento separa las cuatro capas (piso / escritorio / extras / toolbox CLI), explica que un
«metapaquete» YA EXISTE y se llama perfil, y nombra las tres cosas que le faltan para ser
instalable: herencia entre perfiles, publicación al repo firmado, y un verbo que resuelva una LISTA
y no un paquete.
Y plantea la única decisión que no es de implementación: instalar un escritorio REPRODUCIENDO
(coherente con que el canal no distribuya binarios, pero son horas en la máquina del usuario) o
HIDRATANDO artefactos firmados (segundos, pero es entregar binarios y activa entera la obligación
de licencias que se cerró hoy). La salida propuesta es la del ADR 0014: que existan las dos y que
reproducir sea lo que hace verificable a hidratar.
Nota de nomenclatura, porque el usuario preguntó: `.swm` NO es residuo del renombre — significa
«Software Mutación» y describe lo que el fichero ES. El verbo ya es el correcto, `takana install`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCt3ettR4Z7b6wPCBmbvEV