Paso 7 del ADR 0015, sus dos mitades.
PODA. Un rootfs son cientos de MB o varios GB y no lo alcanzan ni store-gc ni
la caché .dmerge: es un tercer montón sin dueño, como ya lo fue work/sources.
`hammer qorpa prune` barre restos de pulls a medias, imágenes que ninguna
instancia usa —se re-traen por digest, que es justo la propiedad que da el pin—
y, con --upper, las capas mutables, que por D3 siempre se pueden tirar.
Con las dos cicatrices del repo cableadas:
- SIN --yes es un simulacro. Y tras borrar COMPRUEBA que el directorio se fue,
porque store-gc reportaba borrados que no ocurrían y eso se descubrió tarde.
Si dice que sí y sigue ahí, sale ≠0 diciendo que el número de arriba no es lo
que se liberó.
- El tamaño de un árbol con directorios ilegibles sale MENOR de lo que es: un
upper con ficheros de los subuid no se puede recorrer entero. Ahora se cuentan
los directorios ciegos y la cifra se marca con `≥`. Un número silenciosamente
bajo es peor que ninguno cuando con él se decide borrar.
Y --upper deja la instancia USABLE: sin upper/ y work/ no vuelve a arrancar.
LICENCIAS (SDD 20). Las imágenes ajenas quedan fuera del catálogo publicable y
del reporte de licencias por escrito, y la razón no es pereza: no podemos
enumerarlas — un `pacman -S` dentro de una instancia trae paquetes que nadie
declaró acá, y afirmar una licencia sobre eso sería inventarla. Lo que sí se
hace: contarlas aparte en clase `ajeno` (el riesgo real del ADR es que en seis
meses alguien las cuente como corpus), y dejar dicho que si algún día se espejan
hay que mirar licencia Y MARCA antes, igual que con Firefox. Más el corolario
que faltaba escribir donde se lea: el claim «hammer reproduce bit a bit» hay que
acotarlo desde el día que exista una instancia.
2 tests nuevos (la poda no toca una imagen en uso y sí los restos; con --upper
la capa se va pero la instancia queda usable). 50/50.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
La cadena: 5 recetas nuevas (girara, xxhash, poppler-glib, zathura,
zathura-pdf-poppler) y 5 promociones GRATIS al corpus (json-glib, lcms2, glib-shared,
pcre2-shared, bzip2-shared — todas con hash idéntico desde la cola y desde el corpus,
medido con hammer hash antes de mover nada).
NO va en escritorio-kde, y está escrito en tres sitios para que no se liste por
descuido: su backend necesita una poppler con ENABLE_GLIB=ON, y esa imagen ya trae la
de Qt6 vía okular. Las dos instalan /usr/lib/libpoppler.so.146 y son artefactos
DISTINTOS ⇒ hidratar las dos pone dos ficheros en la misma ruta. La salida no sería
listarla igual, sería jubilar una de las dos popplers.
LAS TRES COSAS QUE SE ROMPIERON, que valen más que las recetas:
1. -DENABLE_GLIB=ON SE APAGA SOLO Y EL BUILD SALE OK. CMakeLists:260 hace
`if(NOT CAIRO_FOUND) set(ENABLE_GLIB OFF)`: no falla, obedece distinto. El primer
artefacto selló sin nada de glib, exit 0, sin aviso. La causa era una línea que este
repo ya tiene como patrón: `.pc Requires` → `[deps].build`. cairo.pc pide pixman-1 y
pixman no estaba declarado ⇒ `pkg-config --exists cairo` falso ⇒ CAIRO_FOUND falso.
Un .pc que falta a tres saltos apaga una FUNCIÓN, no una librería. La receta ahora
comprueba en install que poppler-glib.pc y libpoppler-glib.so existan.
2. DOS COPIAS ESTÁTICAS DE GObject EN UN PROCESO. Con la glib estática del corpus todo
sella y zathura arranca — y al dlopen del plugin escupe «cannot register existing
type 'gchar'» y no abre nada. No son dos ficheros en una ruta: son dos copias del
sistema de tipos dentro del mismo proceso, una en el ejecutable y otra dentro de
libpoppler-glib.so. Un .a NO duplica (sólo tiene símbolos sin definir, se resuelven
al ligar); el problema aparece sólo cuando dos objetos enlazados por separado meten
cada uno la suya. Arreglo: las TRES recetas de la cadena declaran glib-shared.
3. FUGA AL LAB, cazada por scripts/vigia-sonames.py: zathura selló con
NEEDED libsqlite3.so.0, un SONAME que ningún artefacto del cierre publica — meson
había resuelto dependency('sqlite3') contra el sysroot Alpine DEL LAB. Y declarar la
dep NO alcanzó: el .pc da un -lsqlite3 pelado y el linker prefiere la .so del lab
sobre la .a del store. Lo arregla -Dprefer_static=true. Medido: los NEEDED bajaron de
DIEZ a CUATRO y los cuatro los publica el cierre.
DOS DEFECTOS DE IMAGEN PREEXISTENTES que salieron de paso:
- libbz2.so.1 no lo publicaba nadie y freetype-shared lo pide, en los TRES perfiles.
Se vio de verdad al probar el visor (el plugin no hacía dlopen). bzip2-shared existía
sólo en incoming-kde; promovida y puesta de raíz junto a expat-shared/libffi-shared.
- escritorio-gnome NO TRAÍA NI UN FICHERO DE FUENTE. Contado sobre los artefactos del
cierre: gnome=0, sway=22, kde=1 (una de rebote dentro de qtbase). Tenía fontconfig,
que es el MOTOR que busca fuentes, no una fuente. Un PDF con base-14 se ve VACÍO y sin
error. Añadida dejavu-fonts, la única receta de fuentes del catálogo entero.
⚠ KDE queda igual y NO se toca acá: no lleva zathura y su árbol lo trabaja otro frente.
EVIDENCIA DE QUE ANDA, no de que sella:
- `zathura --version` con el plugin lista «(plugin) pdf-poppler (2026.07.18)» sin un
solo GObject-CRITICAL.
- poppler-render-check, receta-TESTIGO en el espíritu de gtk4-hello: arma un PDF a mano,
lo abre con poppler-glib, lo rasteriza sobre cairo y CUENTA PÍXELES — 9600 negros,
exactamente el rectángulo de 160x60. Un lienzo blanco no pasa. Su primera versión
medía el TEXTO y falló: el sandbox no tiene fuentes, que es cómo se descubrió el
hueco de arriba. El assert quedó sobre el rectángulo, que no depende de tipografía, y
el texto se informa aparte.
Los cinco grafos quedan en N/N con cero deuda.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XB2iEmxeLZgzqNfhWChrLo
Paso 6 del ADR 0015, PARCIAL y dicho como parcial. Steam 1.0.0.87 instalado de
verdad desde el multilib de Arch —las libs de 32 bits que la F2 del plan de
juegos daba por «una campaña entera»—, su cliente i386 bajado y desempacado por
el bootstrap de Valve junto con el Steam Runtime, y el BWRAP ANIDADO VERIFICADO
EXPLÍCITAMENTE (24 montajes propios dentro de la instancia), que es lo que este
paso pedía. Lo que NO se pudo: la máquina no tiene sesión gráfica, y el runtime
sniper sólo se baja al instalar un juego, que exige credenciales ⇒
pressure-vessel con un juego real sigue sin ejercitarse. Se dice, no se insinúa.
Tres muros, ninguno en el ADR:
1. LA JAULA MATABA TODOS LOS BINARIOS DE 32 BITS. El filtro seccomp comprueba
arch == x86_64 y MATA lo que no lo sea; para un build es la defensa clásica y
correcta, para el montón B es fatal porque el cliente de Steam es un ELF i386.
El síntoma fue `ldd: exited with unknown exit code (159)` = 128+31 = SIGSYS,
que no se parece en nada a la causa. La salida no es aflojar el check sino
darle a i386 su propia tabla con la MISMA política. Los 25 números se
verificaron uno por uno contra /usr/include/asm/unistd_32.h: 24 bien y UNO
MAL — kexec_file_load no existe en i386 y su número de x86_64 (320) es ahí
`utimensat`, o sea que habríamos denegado algo que usa cualquier cosa que
toque una marca de tiempo. Es la diferencia entre una tabla de memoria y una
verificada.
2. STEAM SE NIEGA A CORRER COMO ROOT, y cambiar el mapa para evitarlo CORROMPE
la instancia: un fichero creado bajo un mapa aparece con otro uid bajo el
otro, así que el useradd de la preparación deja un /home que su propio dueño
no puede escribir. ⇒ el mapa es parte de la IDENTIDAD de la instancia. La vía
correcta es la de cualquier runtime de contenedores: un solo mapa y se BAJA de
privilegio adentro — campo `run_as`, setpriv, con el CAP_SETUID que ya
tenemos en el namespace.
3. EL XDG_RUNTIME_DIR ES DEL USUARIO QUE CORRE, no del uid del mapa: con
`run_as`, apuntarlo al de root deja al Steam Runtime sin poder crear su
temporal. Se lee como un aviso menor hasta que algo deja de andar sin decir
por qué.
Lo que sí quedó probado, y es el corazón del ADR: un userland glibc ajeno con su
cadena de 32 bits completa corre enjaulado sobre nuestro kernel, con seccomp y
no_new_privs puestos, y un contenedor anidado funciona adentro — la forma exacta
en que Valve prueba Proton.
1 test nuevo (run_as resuelve uid/gid/home del passwd de la imagen, y un run_as
inexistente NO cae a root). 48/48.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
⚠ CORRIGE UNA CREENCIA DEL REPO: zathura NO está bloqueada por GTK3. Upstream VACIÓ
girara — en 2026.07.18 su árbol son siete .c (datastructures, input-history, log,
template, utils) y su meson.build no menciona GTK ni una vez. Toda la UI se mudó
adentro de zathura, que hoy pide GTK4 >= 4.12, y gtk4 4.18.6 está en el corpus.
El plan de apps daba a zathura por «verificar si su cadena es GTK4 y no GTK3»: lo es.
Las tres piezas que faltaban, medidas con scripts/provee.py --desde corpus:
- girara: sella al primer intento; sus tres deps (glib/gobject/gio) salen del mismo
artefacto glib. -Ddocs=disabled porque ese target es doxygen y produce HTML de API.
- xxhash 0.8.3 (zathura lo usa para la caché de páginas). DISPATCH=0 no es preferencia,
el build se cae sin eso: Makefile:58-62 hace `$(CC) -dumpmachine | grep x86_64` y
enciende DISPATCH solo por detección de arquitectura — una perilla auto que hace
depender el artefacto de dónde se construyó. Con ella, xxh_x86dispatch.c muere porque
pide AVX-512 con -mavx512f a secas y el clang de zig ya exige además -mevex512
(«AVX vector argument of type __m512i without evex512 enabled changes the ABI»).
No se arregla subiendo flags: esa variante sólo la usa el despacho en runtime, que es
justo lo que no queremos. Apaga las dos cosas de una.
- json-glib PROMOVIDA al corpus, y salió GRATIS por la misma razón que pipewire: la
variante de COSMIC resuelve sus deps enteras contra el catálogo padre ⇒ mismo
ArtifactHash desde las dos partes (b3:62011e81, medido antes de mover nada). La de
GNOME no podía: arrastra gobject-introspection, glib-introspected, gi-foreign-girs y
py3-setuptools. La copia de incoming-cosmic se jubila, verificado como manda el repo:
su único consumidor (xdg-desktop-portal) hashea b3:749fbffe ANTES y DESPUÉS ⇒ cero
rebuilds. La de incoming-gnome SE QUEDA: variante deliberada, artefacto distinto.
Falta todavía el backend de PDF, que es el hueco real: incoming-kde/poppler va con
-DENABLE_GLIB=OFF ⇒ publica libpoppler.so pero NO poppler-glib, y encima sólo se
alcanza desde su cola.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XB2iEmxeLZgzqNfhWChrLo
El triaje de apps falló TRES veces, cada vez por una pregunta distinta y cada vez la
anterior daba verde:
1. ¿existe la receta en el disco? — el método original
2. ¿la ALCANZA el consumidor? — wf-recorder grababa mudo: sus backends de
audio existían, pero en colas hermanas, que una receta del corpus no ve
3. ¿la VARIANTE sellada publica la ABI? — imv: mesa existe, es alcanzable, y aun así
no sirve; las tres variantes no publican libGL.so ni gl.pc
Las tres son la misma equivocación disfrazada: preguntarle al CATÁLOGO lo que sólo
sabe el ARTEFACTO. Este vigía indexa los .pc y las librerías (.a y .so, porque
find_library no mira pkg-config) de todos los artefactos sellados y contesta quién
publica cada nombre y desde qué colas es pedible, aplicando sibling-first: lo del
corpus lo ve todo el mundo, lo de una incoming-* sólo esa cola.
Es el hermano de BUILD de vigia-sonames.py, que cubre la mitad de RUNTIME. La de build
se paga antes: es la que decide si el configure de una receta nueva va a morir.
Tres decisiones de forma que salieron de usarlo y verlo fallar:
- el nombre se busca flojo: gl, gl.pc, libGL.so.1 y librsvg (que tiene que encontrar
librsvg-2.0.pc) dan lo mismo. El nombre que trae un APKBUILD casi nunca lleva la
versión, y comparar a lo bruto daba falsos «nadie lo publica».
- agrupado por receta, no por fichero: mesa publica cuatro ficheros de EGL y repetirla
cuatro veces convierte el informe en ruido justo cuando hay que leerlo rápido.
- --desde <cola> sale con código 1 si algo no se alcanza ⇒ sirve de puerta en cron/CI.
Verificado contra los dos casos conocidos: --desde corpus con lo que swayimg pide da
0, y con lo que imv pedía (gl, opengl) da 1. Barrido completo ~9 s con caché por
ArtifactHash en work/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XB2iEmxeLZgzqNfhWChrLo
Entró como 4.7 —la última sin Lua— sólo porque no había receta de luajit. Con luajit
sellada, sube a la actual. Lo que se gana: el motor de configuración pasó a ser Lua
(src/luaengine.cpp), y con él los perfiles de teclas y el .lua de ejemplo. Formatos:
suma qoi, ttf y xbm a los de 4.7.
Dos cambios de forma que sacan dos deps: `compositor` ya no pide json-c (la
integración con sway se rehizo sin JSON) y la man page viene PRE-GENERADA en
extra/swayimg.1 en vez de armarse con scdoc. -Ddoc=false porque ese target regenera
markdown con dos scripts de Python y lo que instala son .md, no páginas de manual.
Corregido de paso el comentario de las opciones apagadas: son DOS por cola, no una.
`svg` pide librsvg-2.0 (sólo en incoming-gnome) y `exif` pide exiv2 (sólo en
incoming-kde). Las dos existen en el disco y ninguna es alcanzable desde el corpus.
Por eso este visor no muestra metadatos EXIF, y queda escrito dónde está el hueco.
Evidencia de que la cadena cierra, no sólo de que sella: `swayimg -e` ejecuta Lua
dentro del visor y reporta «Lua 5.1 · jit=true · LuaJIT 2.1.1787165859» — que es
exactamente el relver de nuestra receta de luajit, o sea que el intérprete que corre
es el que construimos. Un script con error de sintaxis se reporta como error de Lua.
NEEDED sigue siendo sólo libc.so.
Los cinco grafos quedan en N/N con cero deuda.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XB2iEmxeLZgzqNfhWChrLo
Tercer Lua del catálogo y hay razón para cada uno: `lua` 5.4 para wireplumber,
`lua5.2` para el OSC de mpv, y ésta porque hay consumidores que piden LuaJIT POR
NOMBRE — swayimg >=5.0 hace dependency('luajit') sin alternativa. No es la misma ABI:
LuaJIT es Lua 5.1 + FFI + JIT. Convive sin pisar: todas sus rutas ya vienen
versionadas (include/luajit-2.1, libluajit-5.1.so.2, /usr/bin/luajit-2.1.<relver>).
⚠ EL PIN DE COMMIT NO ALCANZA. Desde que 2.1 es rolling no hay tags: la versión sale
de `git show -s --format=%ct` sobre el árbol, y NO es cosmética — entra en un SÍMBOLO
EXPORTADO (luaJIT_version_2_1_ROLLING). Dos builds del mismo commit con distinto
estado de .git publican símbolos distintos y el consumidor muere con símbolo
indefinido. Se escribe .relver a mano Y se borra .git, porque la rama que consulta git
gana si el directorio existe. Verificado: nm -D da luaJIT_version_2_1_1787165859.
LIBS=-lunwind: la misma deuda que ya paga librsvg. Sin esto, nueve _Unwind_*
indefinidos desde lj_err.o — LuaJIT usa unwinding DWARF de verdad y en glibc ese
runtime vive en libgcc_s, que musl no tiene. Va por LIBS= y no LDFLAGS= porque
TARGET_ALIBS se expande DESPUÉS de los objetos, que es donde el linker resuelve.
TARGET_STRIP=true: su Makefile strippea como parte de la compilación, lo que pediría
binutils y borraría el .debug_* antes de que hammer lo parta (SDD 23).
Evidencia de que anda: NEEDED = libc.so y nada más; jit.status() da true con todas las
optimizaciones; FFI llama a C; y los cuatro caminos de unwinding responden — pcall,
error DENTRO de un trace compilado (err_unwind_jit, el que pedía _Unwind_GetIP),
metamétodo y xpcall+traceback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XB2iEmxeLZgzqNfhWChrLo
El plan proponía `imv` como paso 4. Al medirla apareció un muro que el método de
triaje no podía ver: imv dibuja con OpenGL de función fija (glBegin/glOrtho) y esta
distro NO tiene proveedor de GL de escritorio — las tres variantes de mesa van
-Dglx=disabled -Dglvnd=false y no publican libGL.so ni gl.pc ni opengl.pc, y no hay
receta libglvnd. La trampa fina: mesa SÍ instala GL/gl.h, así que compila entero y
muere al ligar. Darlo vuelta = autorar libglvnd + rehacer mesa (radio 139).
swayimg cubre el mismo caso de uso sin tocar GL (rasteriza a wl_shm) y con CERO
recetas nuevas: sus deps requeridas ya estaban en el corpus. De yapa trae backend DRM
(imágenes en TTY pelada, sin compositor).
Pineada 4.7 y no 5.5 a propósito: desde la 5.0 luajit es obligatoria y no hay receta
(la lua5.2 del OSC de mpv es otra ABI). Escribir luajit es el próximo paso barato.
-Dversion=4.7 no es cosmético: su default 0.0.0 dispara un `git describe` sobre el
árbol ⇒ la versión estampada en el binario dependería del checkout. Misma familia que
el -Dbuild-date de mpv.
Evidencia de que anda, no sólo de que sella: NEEDED = libc.so y nada más (cero glibc,
cero X11, cero GL), `--version` dice 4.7 con jpeg/png/gif/webp/tiff, prueba las dos
UIs (Wayland → DRM) y el bucle de decodificación rechaza lo que no es imagen.
Añadida a las raíces de los cuatro perfiles (sellado ≠ instalado). Los cinco grafos
quedan en N/N con cero deuda.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XB2iEmxeLZgzqNfhWChrLo
`xdg-desktop-portal-cosmic` b3:9e6d432b, 9 ficheros / 38 M. Los cuatro perfiles cierran otra vez:
KDE 188/188, GNOME 132/132, COSMIC 106/106, sway 146/146. CERO deuda.
No era la receta, era la máquina, y ahora hay el número que lo prueba. Los tres intentos anteriores
murieron por OOM entre los 24 y los 25 minutos con el zram de 4 GiB lleno al 90%. Éste tardó **48
minutos** y su pico de swap fue **5,4 GiB** — o sea que necesitaba 1,4 GiB MÁS de los que el swap
entero podía darle. Con 4 GiB no había paciencia que alcanzara.
CÓMO SE PASÓ DE 4 A 12 GiB, y por qué no fue con `swap-zram 12`. Su freno se negaba con razón:
había 3.694 M adentro contra 3.283 M de RAM disponible, y ese guardián existe porque un `swapoff`
sin destino es como se volteó la máquina el 2026-08-17. Esperar tampoco servía: el zram no se drena
solo, sus páginas son de procesos vivos. Se hizo AÑADIENDO ANTES DE QUITAR, que es la forma estándar
de redimensionar swap sin el pico:
1. zramctl --find --size 12G --algorithm zstd → /dev/zram1
2. verificar `backing_dev: none` ← el único agujero de zram contra T9 de qullqa; con un
backing_dev las páginas salen EN CLARO a disco. Se comprobó a mano por no pasar por el script.
3. swapon --priority 110 /dev/zram1 ← destino disponible antes de quitar
4. swapoff /dev/zram0 ← migra, no choca contra la pared
5. zramctl --reset /dev/zram0
El 12 no es a ojo: sale de la compresión MEDIDA acá (`mm_stat` daba orig=3,7G compr=1,3G ⇒ 3,0x con
zstd), así que 12 GiB de páginas viven en ~4 GiB de RAM. 12 sobre 7,6 de RAM es 1,6x.
⚠ NO se puso un swapfile en disco, ni en claro ni cifrado, y no es prudencia: T9 del SDD de qullqa
dice que «un `swapon` sin cifrar anula todo el documento», y la variante cifrada (dm-crypt sobre
loop) congeló la máquina 70 minutos el 2026-08-17. Queda escrito en `/etc/conf.d/zram-swap`, que
además hace persistente el tamaño (`ZRAM_TAM_GIB=12`) por el override que el propio init prevé.
Se cierra la pregunta abierta de anoche. Ganó promover UNA pipewire al corpus, y el documento pasa
a registrar POR QUÉ salió barata (la variante de cosmic ya resolvía todo contra el padre) y por qué
pulseaudio quedó afuera (45.712 vs 2.455.600 bytes de `libpulse-mainloop-glib`: la del corpus se
traga la glib estática).
Queda escrita la regla que decide la próxima promoción: enlazar contra una variante y correr contra
otra es legítimo cuando lo que cruza es un SONAME; lo que no se puede es hidratar dos artefactos
distintos en la misma ruta.
Sellada b3:25caa359 al primer intento. `--help` corre y los NEEDED traen `libpipewire-0.3.so.0`:
graba CON audio, que era exactamente lo que anoche no se podía.
Es la app que estaba bloqueada por el defecto del triaje —preguntaba «¿existe la receta?» en vez de
«¿la alcanza quien la usa?»—: sus dos backends de captura de sonido vivían sólo en colas y una
receta del corpus no alcanza una cola hermana. Con pipewire en el corpus, cae sola.
`-Ddefault_audio_backend=pipewire` explícito y no `auto`: su meson resuelve `auto` mirando qué
encontró en el sandbox (meson.build:95-101), o sea que el artefacto dependería de lo que quedó
montado en el lab. Misma disciplina que en mpv y ffmpeg.
⚠ SE LISTA SÓLO EN `escritorio-sway`, y la razón es de PROTOCOLO: captura por `wlr-screencopy`, que
implementan los compositores wlroots. KWin y mutter NO lo implementan —usan el portal/ScreenCast—,
así que meterlo en esas dos imágenes sería enviar una herramienta que no puede funcionar ahí: deuda
fantasma con forma de app. En COSMIC hay que verificar si cosmic-comp expone el protocolo antes de
listarlo; no se listó a ciegas.
Cubre el caso de uso principal por el que uno instala OBS, con UNA receta en vez de las 20 + Qt6 +
X11 que OBS pedía. escritorio-sway 146/146.
Cierra la decisión que quedó abierta anoche en `docs/plan-apps-usuario-final.md`: el corpus no
tenía ningún cliente de audio salvo ALSA, y eso bloqueaba a toda app multimedia.
GANA LA VARIANTE DE COSMIC y la promoción es GRATIS: era la única cuyo cierre ya resolvía entero
contra el catálogo padre, así que `recipes/pipewire.toml` sella b3:e240653a — el mismo hash que
tenía en la cola. Con ella suben `libsndfile` (las tres copias sellaban idéntico) y `dbus-shared`
(gnome y cosmic, idénticas). Se jubilan 9 copias de cola.
POR QUÉ ESTO NO REPITE EL EPISODIO DE LAS DOS GLIB — leído de los artefactos sellados, no razonado:
**ningún pipewire de los tres enlaza glib.** Los NEEDED de las tres variantes son exactamente
`libdbus-1.so.3`, `libpulse.so.0` y `libsndfile.so.1`; glib entraba sólo como Requires transitivo
de pkg-config, en tiempo de build. ⇒ `libpipewire-0.3.so` es glib-free y se comparte entre las
cuatro imágenes sin riesgo.
⚠ Y POR ESO `pulseaudio` NO SE PROMUEVE, aunque sea la dep de al lado. Ahí la objeción SÍ aplica y
el número lo dice solo: `libpulse-mainloop-glib.so.0.0.6` mide **45.712 bytes** en la variante de
GNOME (NEEDED `libglib-2.0.so.0`) y **2.455.600** en la del corpus, que se traga la glib ESTÁTICA
del catálogo. Meter ésa en el proceso de gnome-shell —que ya carga la glib sombra dinámica— es
literalmente el cuadro de colord. `incoming-kde/pulseaudio` e `incoming-gnome/pulseaudio` se quedan
donde están: son variantes deliberadas, no duplicados. La copia del corpus existe sólo para que
pipewire tenga contra qué enlazar `libpulse.so.0`, y ese enlace es por SONAME — en runtime lo sirve
la libpulse de cada imagen, ABI-compatible (misma 0.24.3).
Enlazar contra una variante y correr contra otra es legítimo cuando lo que cruza es un SONAME; lo
que NO se puede es hidratar dos artefactos distintos en la misma ruta. Esa distinción es la que
decide qué se promueve y qué no.
mpv (b3:392e9690) pasa a `-Dpipewire=enabled`, con ALSA encendida detrás: prueba pipewire primero y
cae a alsa si no hay demonio —KDE todavía no lista PipeWire entre sus raíces—. Evidencia:
NEEDED … libasound.so.2 **libpipewire-0.3.so.0** libEGL.so.1 libc.so
--ao=help → pipewire (PipeWire audio output), alsa, null
Impacto medido antes de tocar, hasheando las 336 recetas de cola y comparando: 9 retiradas, **5
rebuilds** (wireplumber, xdg-desktop-portal ×2, kpipewire, spectacle) — los 5 sellados. Sin la
salvedad de pulseaudio habrían sido 8, incluido gnome-shell.
Perfiles: KDE 188/188, GNOME 132/132, sway 145/145.
⚠ COSMIC 105/106: `xdg-desktop-portal-cosmic` murió por OOM por TERCERA vez (7 G de RAM sin cgroups,
con otro agente compilando Rust). Sigue siendo la máquina, no la receta.
Paso 5 del ADR 0015, sus dos mitades.
SHIMS. `hammer qorpa export <id>` genera lanzadores finos y `.desktop` en el
espacio del host, desde lo DECLARADO en `[export]` — nunca todo: exportar todo
haría que el `ls` de la imagen compita con el nuestro, que es la falla de
Bedrock (arbitra en tiempo de exec, por heurística). Gana en tres cosas contra
un FUSE: cero costo en runtime, `cat` al shim y ves qué hace, y se revoca
borrándolos.
Se GENERAN, no se copian. El `.desktop` se arma con lista BLANCA de claves, así
que `Exec`, `TryExec`, `Path` y `DBusActivatable` quedan fuera por definición y
no por enumeración — una lista negra dejaría entrar la próxima clave ejecutable
que invente el estándar. El Exec original se cita en un comentario del fichero
generado, para que se vea qué decía y qué no se copió. El icono se busca en la
vista merged (upper primero, imagen después: si no, se perdería lo que instaló
el gestor de paquetes) y se copia al host, porque un icono que el host no
resuelve se ve como un cuadrito gris. Y `exported.json` registra cada fichero
escrito, para que `--remove` borre EXACTAMENTE eso y no por patrón sobre el
~/.local/bin de alguien.
Probado de punta a punta con un .desktop ajeno real de la imagen de Arch: el
shim corre `pacman -Q` del huésped desde el host, el X-KDE-Wayland-Interfaces
quedó fuera, el icono viajó, y --remove dejó 0 ficheros con la instancia intacta.
CLASE `ajeno`. build-state.py inyecta los nodos declarados en el nuevo
docs/state/qorpa-ajenos.toml ANTES que los `wanted`, y ese orden es la mitad del
punto: un nodo que provee una imagen ajena no es una receta por escribir. Con
eso el `xwayland` de escritorio-kde deja de ser deuda y pasa a contarse aparte:
escritorio-kde 187/188 listo falta 1 (raíces 14, + 1 ajenas)
Dos decisiones que sostienen esa cifra: los ajenos se RESTAN del denominador (si
entraran, el número que se lee como "cuánto construimos" crecería solo cada vez
que alguien enjaula una app), y la declaración vive en el REPO y no se lee de
/var/lib/hammer — build-state.json se commitea y lo regenera el cron en dos
máquinas; si la clase saliera de las instancias instaladas, cada una diría algo
distinto y se pisarían en cada cosecha. Es el error que ya se cometió con
sealed_remoto. Qué provee una imagen ajena es diseño; qué tenés instalado, no.
Un ajeno tampoco se hashea, y no por comodidad: no tiene procedencia de fuente,
así que un hash afirmaría que lo reproducimos.
2 tests nuevos (que del .desktop ajeno no sobreviva nada ejecutable; que el shim
no se rompa con rutas raras). 47/47.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
⚠ INCIDENTE, y queda escrito porque es la regla 2 del CLAUDE.md en vivo: los cuatro `git mv` de
esta promoción (pipewire, pulseaudio, libsndfile, dbus-shared) estaban STAGED y sin commitear
cuando `89da9c8` («qorpa: el mapeo por rango») se los llevó puestos. El commit ajeno barrió el
índice de otro agente — exactamente lo que la regla prohíbe— y encima lo hizo A MEDIAS: se llevó
los cuatro `.toml` pero no el `.patch`, porque el `git mv` del parche se cortó cuando el disco se
llenó al 100%.
Resultado: HEAD quedó con `recipes/pipewire.toml` declarando `patches = ["pipewire-sound-
initialized.patch"]` y ese fichero **inexistente en `recipes/`**. Cualquiera que clonara veía
`Error: receta inválida: no pude leer patch`. Esto lo repara.
El hash confirma que la promoción es gratis: `recipes/pipewire.toml` sella
b3:e240653adae656318d5c3445b36ffb37aa491589b5c34c8688515bc3806944ab, EXACTAMENTE el mismo que tenía
`incoming-cosmic/pipewire`. Era la variante que ya resolvía todo su cierre contra el catálogo padre.
Resuelve §NO-resuelve 2 del ADR 0015, que era el ticket que más desbloqueaba.
Tres síntomas que parecían distintos —apt sin poder bajar a `_apt`, pacman sin
poder chownear a `alpm`, pressure-vessel sin poder escribir su uid_map— eran la
misma causa: bwrap crea el userns con UN SOLO id.
La cura resultó tener TRES partes, y ninguna sobra:
1. `setcap cap_setuid+ep newuidmap` (+ cap_setgid en newgidmap). shadow.toml los
instala pero no los provisiona; sin la capability no escriben el mapa.
2. Crear el userns nosotros, mapear el rango de /etc/subuid con newuidmap y
pasárselo a bwrap con `--userns FD`. bwrap crea el suyo con un solo id A
PROPÓSITO y nunca llama a newuidmap: el trabajo es de quien lo invoca. El fd
lo abre la shell (`exec 3<…`), porque un fd sólo cruza el exec si no es
CLOEXEC y no valía la pena una dep de C para un fcntl.
3. Devolver las capabilities DENTRO del namespace. Ésta no estaba en el plan y
es la que costó: bwrap las tira todas, y en Linux ser root es tener
CAP_SETUID, no tener uid 0. Sin ella apt seguía sin poder seteuid(42) — un
síntoma que parecía de subuid y no lo era. Son seguras por construcción:
dentro de un userns sólo alcanzan lo que ese namespace posee, o sea nuestros
propios subuid. CAP_SYS_ADMIN queda fuera y sigue colgando de `nesting`.
MEDIDO después: uid_map de 65537 ids, setgroups: allow, apt instala SIN el
APT::Sandbox::User=root, pacman sincroniza con DownloadUser=alpm INTACTO, y el
userns anidado monta con root=true ⇒ el conflicto 2 de D9 se disuelve solo.
Dos cosas más que salieron por medir, no por pensar:
- El guardián MENTÍA. qorpa-preflight envolvía al hijo en `timeout`, que forkea,
así que newuidmap apuntaba al PID equivocado y el kernel respondía "Operation
not permitted" — un falso negativo idéntico a un fallo real. Decía que subuid
no andaba cuando a mano andaba. Ahora sale exit 0.
- Quitar el impuesto MUEVE el problema: el upper pasa a contener ficheros de los
subuid (apt deja los suyos con uid 165577) que nuestro uid no puede borrar ⇒
recreate entra a un userns mapeado para limpiar. Y cuando no hay rango, se
degrada diciendo la causa exacta en vez de quedar en misterio.
Y un detalle que no es cosmético: `--perms 1777` antes del `--tmpfs /tmp`, o el
_apt al que apt baja no puede escribir su fichero temporal. Un /tmp que no es
1777 no es /tmp.
2 tests nuevos (el rango se lee por usuario; CAP_SYS_ADMIN NO está en las caps
de root, o `nesting` dejaría de ser una decisión). 45/45.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
Continúa lo que ya se hizo el 2026-09-02 con las 8 copias de gnome. Medido receta por receta con
`hammer hash`: de los 30 nombres que viven en el corpus Y en alguna cola, 13 dan el MISMO
ArtifactHash que su gemela del corpus y 22 son variantes deliberadas (glib/gtk4/pango de gnome,
dbus/openssl/libxml2 de kde…) que NO se tocan.
Se van las 13 redundantes: alsa-lib ×3, nasm ×2, zlib-shared ×2, y de incoming-kde ffmpeg,
fontconfig-shared, freetype-shared, libjpeg-turbo-shared, libpng-shared y vulkan-headers. Cada
consumidor las resuelve ahora en el catálogo PADRE, con el mismo hash.
VERIFICADO ANTES Y DESPUÉS, no argumentado: se snapshotearon los 47 consumidores directos, se
borraron las 13, y se re-hashearon. **0 consumidores vivos con hash distinto** (las 4 diferencias
son las propias recetas borradas, que se consumían entre sí). Cero rebuild.
EL EFECTO QUE NO SE BUSCABA Y ES EL MÁS VALIOSO: `build-state.json` guarda sus nodos POR NOMBRE, y
cuando un nombre vive en dos colas la membresía se consulta por el par `(cola, nombre)` de UNA sola
de ellas. Resultado: `ffmpeg`, `alsa-lib`, `vulkan-headers` y `zlib-shared` reportaban
`perfiles: []` —o sea "no está en ninguna imagen"— **mientras estaban en las cuatro**. Ahora dicen
la verdad, y `escritorio-kde` pasa de 183 a 185 porque aparecen dos nodos que el grafo no contaba.
La lección para el próximo análisis, que ya quedó en `scripts/vigia-sonames.py`: sobre el grafo se
pregunta con `yupana.membresia()`, que llavea por `(cola,nombre)`; el campo `perfiles` del JSON es
fiable sólo mientras el nombre viva en una sola cola.
Sin cambios en la deuda: cosmic sigue 105/106 por el OOM de xdg-desktop-portal-cosmic.
Las tres recetas pasan a `-Dpipewire-alsa=enabled`. Sirve a TODA app ALSA del corpus, no sólo a mpv:
un cliente que abre "default" desemboca en PipeWire por `libasound_module_pcm_pipewire.so` en vez de
pelearle la tarjeta al servidor. Es protocolo en vez de ABI compartida — la misma figura con la que
el ADR 0015 cruza el borde de la jaula.
EL FLAG SOLO NO HACE NADA, y ésta es la parte que no se ve venir: meson deja los plugins en
`/usr/lib/alsa-lib` (bien, ahí los busca alsa-lib) pero la CONFIGURACIÓN en
`/usr/share/alsa/alsa.conf.d`, **un directorio que alsa-lib no lee**. Sus `@hooks` cargan
`/var/lib/alsa/conf.d`, `/usr/etc/alsa/conf.d` y `/etc/alsa/conf.d` — leído del `alsa.conf` del
artefacto sellado, no supuesto. Sin el enlace que agrega la fase install, el plugin queda instalado
y no lo usa nadie: sellado e inerte, que es la familia de fallo del artefacto vacío. Se enlazan los
DOS ficheros: `50-pipewire.conf` hace que el destino EXISTA, `99-pipewire-default.conf` hace que sea
el destino POR DEFECTO — sin el segundo, mpv abriendo "default" no llega igual.
EVIDENCIA (test discriminante, porque "no falla" no prueba nada acá):
mpv --audio-device=alsa/pipewire → abre el device, falla al conectar (no hay demonio) ⇒ el PCM
ESTÁ DEFINIDO
mpv --audio-device=alsa/noexiste → "ALSA lib: Unknown PCM noexiste" ⇒ el control
El camino de verdad —que suene— sólo se puede ejercer con un PipeWire vivo, o sea en la sesión; eso
no se probó y no se afirma.
⚠ PRECIO, escrito para que no se diagnostique mal: con `99-pipewire-default.conf` puesto,
`pcm.!default` ES PipeWire. En una máquina donde PipeWire no esté corriendo, un cliente ALSA ya no
cae a la tarjeta: no suena. Es el trato que hace toda distro con pipewire-alsa.
Radio medido antes de tocar (`yupana radio pipewire`): 4 cosmic / 3 gnome / 2 kde. Reconstruidos 10
de 11 dependientes.
⚠ DEUDA DECLARADA, 1: `incoming-cosmic/xdg-desktop-portal-cosmic` murió DOS VECES por OOM (7 G de
RAM, sin cgroups, y otro agente compilando Rust a la vez — `dmesg` confirma «Out of memory: Killed
process (cargo)»). No es un fallo de la receta ni del cambio: es la máquina. Queda visible en el
grafo (escritorio-cosmic 105/106) en vez de escondido, y lo levanta el worker o un reintento con la
máquina libre.
Los otros tres perfiles cierran: KDE 183/183, GNOME 132/132, sway 140/140.
El OSC de mpv es `player/lua/osc.lua`: sin Lua el reproductor anda y responde al teclado pero NO
DIBUJA NADA de interfaz. La `lua` del corpus no sirve — `meson.build:701` acepta 5.1, 5.2 o LuaJIT
y el `lua.pc` genérico va acotado a `>=5.1.0, <5.3.0`; la nuestra es 5.4.8 y entró para wireplumber.
CÓMO CONVIVEN LAS DOS SIN PISARSE, que es lo que hace legítimo tener dos Lua: esta receta instala
**sólo la librería**. Sin `/usr/bin/lua`, sin `luac` y sin `lua.pc` genérico — que son exactamente
los tres ficheros que colisionarían si una imagen hidratara las dos. Las `.so` no colisionan porque
el SONAME lleva la versión, y los headers van a `/usr/include/lua5.2/` porque los de 5.2 y 5.4 se
llaman igual y dicen cosas distintas.
La diferencia con 5.4 que muerde: en 5.2.4 `luaconf.h:43-49` hace que `LUA_USE_LINUX` implique
`LUA_USE_READLINE` (en 5.4 eso vive en el target `linux-readline` del Makefile). Se esquiva
construyendo el target `a` —sólo `liblua.a`— en vez de `all`: `LUA_USE_READLINE` lo consume nada
más que `lua.c`, el binario del REPL, que acá no se compila. Sale gratis y de paso es lo que hace
que no haya `/usr/bin/lua` que colisione.
`-Dlua=lua5.2` y no `auto`: auto recorre ocho nombres de `.pc` y se lleva el primero que encuentre
en el sandbox ⇒ el artefacto dependería de qué otra cosa quedó montada en el lab.
EVIDENCIA, del mpv re-sellado (b3:d9072a10):
List of enabled features: … libass libplacebo **lua5.2** … wayland
[cplayer] Set property: user-data/osc/visibility="auto" -> 1
[cplayer] Done loading scripts.
o sea que el script del OSC no sólo compiló: se carga y corre.
alsa-lib: se corrige el comentario, que decía que las pipewire iban con `-Dpipewire-alsa=disabled`.
Ya no. Y queda escrito el gotcha que costó encontrarlo: el `alsa.conf` que instala carga
`/var/lib/alsa/conf.d`, `/usr/etc/alsa/conf.d` y `/etc/alsa/conf.d`, NO `/usr/share/alsa/alsa.conf.d`,
que es donde meson deja la config del plugin.
Paso 4 del ADR 0015. harkaq-exec entra como último eslabón dentro de bwrap,
igual que en el sandbox del build. Cruza el borde un binario ESTÁTICO, no una
librería, así que D2 sigue en pie: lo único compartido es la ABI del kernel.
Honestidad primero, y está escrita en el código: en el eje del sistema de
ficheros harkaq casi no agrega nada, porque el namespace de montaje de bwrap ya
es una lista blanca. Escribir reglas `ro` que repiten eso sería un sello de
goma, así que la política de una instancia no sellada es UNA línea (`rw /`) y no
finge. Lo que sí aporta: seccomp (bwrap no instala filtro alguno — hoy una
instancia podía io_uring, bpf, ptrace, userfaultfd, keyctl, perf_event_open),
no_new_privs, el canal de evidencia, y `seal_image`, que congela /usr /bin /lib
/opt aunque adentro seas root.
Verificado contra el kernel, no contra el log: Landlock ABI 9, logging
post-exec ON, NoNewPrivs 1, Seccomp 2. Y sellando, `/usr/bin` y `/bin` denegados
mientras /etc y /var siguen escribibles.
DOS CONFLICTOS que sólo se ven midiendo, y ninguno estaba en el ADR:
1. Landlock y los contenedores anidados son INCOMPATIBLES hoy: con un dominio
activo, `mount` falla con EACCES aunque seccomp lo permita — el kernel no
admite montajes nuevos bajo un dominio porque escaparían de sus reglas
por-ruta. ⇒ pressure-vessel no arranca bajo Landlock. Por eso `nesting` pasa
`--allow-nesting --no-landlock` y lo dice a gritos; seccomp y no_new_privs
siguen puestos, que es lo que más pesa con un binario ajeno.
2. `root` adentro y anidar se pelean: con --uid 0, un userns anidado no puede
escribir su uid_map. Sin remapear anida, pero el gestor de paquetes se queja.
La instancia de juegos y la de paquetes quieren mapeos OPUESTOS, y ahora el
manifiesto lo declara (`root`, encendido por defecto).
Las dos mitades se curan con lo mismo que el impuesto de apt: un rango real de
subuid con newuidmap + --userns FD. Ése es el ticket que más desbloquea.
En harkaq-exec, dos flags ADITIVOS y apagados por defecto (--allow-nesting,
--no-landlock): el camino del build no cambia ni un byte, que es requisito duro
con 700+ artefactos sellados. La lista de syscalls del anidamiento se separó de
la base y el _Static_assert del techo de salto BPF ahora suma las dos.
3 tests nuevos: que la política sin sellar no finja, que sellando el `rw /` no
sobreviva (uniría derechos por ancestro y anularía el sellado), y que `root` sea
lo único que nace encendido. 43/43.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
Paso 3 del ADR 0015. El overlay lo monta bwrap dentro de su propio namespace
(--overlay-src + --overlay), así que no hace falta root ni se monta nada en el
host. `run` entra con --clearenv y con --unshare-net salvo que se declare
`network`: el entorno del host TAMBIÉN es una concesión, y lo que no se declara
no entra (D2/D7). `--dry-run` imprime el bwrap entero, una línea por concesión,
porque una jaula que no se puede leer no se puede auditar. `list` ahora enumera
también las instancias con lo que abre cada una — una instancia sin política y
una con la pantalla abierta se ven IGUAL desde fuera y no son lo mismo.
Los campos del manifiesto van en inglés (regla 4); el ADR los tenía en
castellano y quedan corregidos, igual que las rutas images/ e instances/.
D3 VALIDADO en la mano, no en el papel: la escritura va al upper, la imagen base
no se toca, y `recreate` tira la capa y la instancia sigue siendo la misma.
Y se midió la otra mitad del paso 1, que el ADR daba por «lo primero que va a
fallar». Falla, sí, pero el veredicto es MEJOR de lo que decía:
uid_map: 0 1001 1 · setgroups: deny
- apt: el método http hace setgroups para bajar a _apt ⇒ para. Salteándolo con
-o APT::Sandbox::User=root baja 34 MB, instala y corre los triggers de dpkg
enteros; el único residuo es un AVISO de chown a root:adm.
- pacman: chownea el directorio de descarga a `alpm` ⇒ para en duro. Con
DownloadUser comentado sincroniza, y tras pacman-key --init/--populate
instala y el binario corre.
⇒ subuid no es un muro, es un IMPUESTO: un solo id alcanza para instalar
paquetes reales en los dos gestores, y lo que rompe es el chown/setgroups a
OTRO id, que cada gestor hace en un sitio distinto. Y quitarlo pide algo que el
ADR no decía: bwrap crea el userns con un solo id A PROPÓSITO y no llama a
newuidmap, así que además del setcap hay que crear el namespace aparte, mapear
el rango y pasárselo con --userns FD.
5 tests nuevos (nace sin concesiones, rechaza imagen vacía, el upper es caché,
sin grants la red queda fuera, y que el aviso de wayland no sea tibio). 40/40.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
Paso 2 del §Orden de trabajo del ADR 0015. Verbos en inglés (regla 4); el ADR
decía traer/crear/correr y queda corregido, con una línea que dice por qué para
que no se vuelva a proponer.
`hammer qorpa pull <url> --sha256 <sha>` baja, VERIFICA y recién entonces
desempaca — nunca al revés: un tar ajeno sin verificar es código ajeno que ya
escribió en tu disco. Veredicto de ADR 0014: contenido distinto ⇒ ABORTAR, y no
queda nada a medias. La identidad es el sha256 del ARCHIVO, no del árbol, así
que la URL es informativa y espejar sale gratis (ADR 0013). `list` marca a
gritos las imágenes vacías y sale ≠0 (regla 3). Los pasos 3-7 están declarados
en la superficie y fallan diciendo a qué paso del ADR pertenecen.
Nada de esto toca el store: es el espacio paralelo /var/lib/hammer/qorpa (D1).
PROBADO de punta a punta contra las dos imágenes curadas — Ubuntu base 24.04.3
(2760 ficheros, 78 M) y Arch bootstrap 2026.09.01 (31748, 534 M), las dos con
su glibc adentro, que es el montón B entero. Y probarlo de verdad destapó tres
cosas que en verde no se ven:
1. `-p` sin `--delay-directory-restore` NO extrae un rootfs real sin ser root:
/etc/ca-certificates/extracted/cadir es 0555 y tar lo crea con su modo final
ANTES de llenarlo.
2. Mi limpieza mentía: `remove_dir_all().ok()` no puede con un árbol que trae
directorios de sólo-lectura, así que el staging de un pull roto SOBREVIVÍA y
el siguiente pull extraía encima. El síntoma («Permission denied» en un
directorio recién creado) no se parece en nada a la causa.
3. Renombrar un DIRECTORIO exige escritura sobre el directorio mismo, y el
root.x86_64 de Arch viene dr-xr-xr-x. Se abre, se mueve y se le devuelve su
modo exacto.
Y una regla que sonaba razonable y era falsa: «si hay un solo directorio arriba,
ése es el rootfs». El bootstrap de Arch trae TRES entradas arriba (root.x86_64,
version, pkglist) ⇒ no disparaba y el rootfs quedaba un nivel abajo, con todo
verde y sin un error. Ahora se ancla por ESTRUCTURA (tiene etc/ y usr|bin), con
--subdir como escape, y si no acierta FALLA en vez de adivinar: un rootfs mal
anclado no rompe acá, rompe cuando la instancia no encuentra su loader. Los
hermanos descartados quedan escritos en el manifiesto, no tirados en silencio.
5 tests nuevos, incluida la cicatriz de Arch. 35/35 en hammer-cli.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
Se descubrió usándolo. `wf-recorder` daba 0 faltantes / 0 muros, y al ir a escribirlo resultó que
sus dos backends de audio —pipewire y pulse— existen en las TRES colas de escritorio y en NINGUNA
en el corpus. Una receta del corpus no ve una cola hermana, así que wf-recorder construye pero
graba MUDO, y no tiene backend ALSA con el que caerse.
Lo que eso destapa es más grande que wf-recorder y por eso queda escrito como decisión abierta:
**el corpus no tiene ningún cliente de audio salvo ALSA**. Ya chocó dos veces en un día — mpv
terminó con `--ao=alsa` en vez de su salida nativa, y ahora esto. Las tres salidas posibles
(promover UNA pipewire al corpus / aceptar ALSA como la ABI única / que las apps multimedia vivan
en las colas) quedan planteadas con su precio; la primera choca con la enfermedad de las dos glib
y no se decide de madrugada.
wf-recorder NO se escribe hasta entonces: un grabador de pantalla mudo es la clase de media-cosa
que conviene no sellar sin que alguien la haya elegido.
Código nuevo nace con la superficie de CLI en inglés. Nació con `--fallar` unas horas antes de
que la regla quedara escrita; se corrige ahora que no lo llama nadie todavía.
Al ir por la segunda app del montón A salió que la premisa del ADR —«no lo bloquea nada
estructural, falta escribirlas»— vale para mpv y no para el resto. Medido contra el catálogo real
(1079 recetas) cruzando los makedepends del APKBUILD de Alpine de cada candidata:
· firefox → pide gtk+3.0-dev. Firefox NO tiene backend GTK4. Más X11 y un toolchain wasi que
no existe acá. La memoria decía «le falta subir el techo MSRV»: eso es cierto y es
LO MENOR. ⇒ NO es montón A.
· obs → pide qt6-qtbase/qtsvg, y Qt6 vive SÓLO en incoming-kde. Una receta del corpus no
alcanza una cola hermana ⇒ OBS hoy sólo puede ser una app DE KDE, no de las cuatro
imágenes. Más X11 y 20 recetas.
· chromium, libreoffice, gimp → peores, y los tres con GTK3 encima.
La consecuencia estratégica, que es lo que hay que decidir despierto: TODOS los navegadores Linux
son GTK3 (Firefox y derivados) o Chromium (que arrastra GTK3 y Qt6). «Tener navegador» no es un
ticket de recetas: es elegir entre autorar GTK3 o darle el navegador a qorpa (ADR 0015). La segunda
es coherente con lo ya decidido, y el navegador es justamente el proceso al que menos ganas dan de
darle el sistema entero.
Lo que SÍ está a mano, y es el hallazgo útil: `wf-recorder` sale con CERO recetas faltantes y cero
muros —su cierre quedó completo cuando entró mpv— y cubre el caso de uso principal por el que uno
instala OBS. Después `imv` (7 faltantes, todos cargadores de formato opcionales).
El método incluye su propio control: mpv, que YA ESTÁ SELLADA, aparece con 16 faltantes, que son
exactamente las perillas que su receta apaga a propósito. La columna que decide no es «cuántas
faltan» sino «cuántos MUROS», porque un muro no se paga escribiendo una receta sino cambiando una
decisión.
La regla existía —kernel_cmd.rs la cita como «Regla 7.bis»— pero no estaba
escrita en ningún sitio que un agente lea, así que nadie la respetó: el ADR
0015 nació proponiendo traer/crear/correr. Queda en CLAUDE.md, que es lo que
se carga en cada sesión.
Con la deuda declarada en vez de tapada: varios scripts/ exponen flags en
castellano y el barrido es su propia unidad de trabajo, porque tocarlos de
paso rompe cron y la granja. Código nuevo nace en inglés desde hoy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
Verificando que mpv anduviera en las cuatro imágenes salió esto: `libEGL.so.1` (mesa) pide
`libexpat.so.1` y `libwayland-client.so.0` pide `libffi.so.8`, las recetas canónicas de expat y
libffi son `--disable-shared`, y ningún artefacto del cierre publica esos SONAME ⇒ los tres rootfs
los resolvían contra el **sysroot Alpine DEL LAB**.
Es peor que una dep faltante: el lab NO entra en `hash_inputs`, así que el store no puede notarlo —
el artefacto se sella, el perfil reporta 100%, y la imagen sólo arranca en una máquina con Alpine
debajo. El perfil de sway ya tenía escrito exactamente este párrafo desde 2026-08-26; lo que faltaba
era el instrumento para ver que las otras tres estaban igual.
Arreglo (gratis, sin rebuild: las dos recetas ya estaban selladas por sway): `expat-shared` y
`libffi-shared` pasan a raíces de los tres perfiles. Van de raíces y no de `[deps]` por la misma
razón que las fuentes y el XKB — son data de RUNTIME y ninguna arista de build las alcanza.
`scripts/vigia-sonames.py` es el guardián que sale del punto ciego: recorre los NEEDED de todo el
cierre de cada imagen contra los SONAME que ese mismo cierre publica. Dos decisiones de diseño:
· keying por PAR `(cola, nombre)` vía yupana, NO por nombre — `build-state.json` colapsa los
nombres que viven en dos colas y su campo `perfiles` puede quedar vacío para una receta que sí
está en la imagen.
· imprime SIEMPRE quién pide cada soname, porque el cierre incluye herramientas de build
(python3, cmake, perl, go) que la hidratación no instala: sin esa columna el informe no se tría.
Después del arreglo, `libexpat.so.1` y `libffi.so.8` desaparecen de los tres. Lo que queda son
hallazgos REALES que no son de este commit y quedan anotados:
· kde: karchive pide libbz2.so.1 y liblzma.so.5
· gnome: spidermonkey pide libstdc++.so.6 y libgcc_s.so.1; libadwaita pide liblzma.so.5;
freetype-shared pide libbz2.so.1; sqlite-shared pide libreadline.so.8
· cosmic: llvm18 pide libgcc_s.so.1
· los tres: `pw-top` de pipewire enlaza `libncursesw.so.6` DEL LAB — y la receta afirma en un
comentario que sin la dep «meson saltea pw-top». Es falso desde al menos el 2026-08-29:
`dependency('ncursesw')` lo encuentra igual en el sysroot del lab. El comentario dice una cosa
y el binario otra.
Faltaba decir en el ADR lo que se confunde solo: "pinear" no significa que la
imagen no se actualice. El digest es la IDENTIDAD, igual que en el rootfs del
lab. Lo que se pinea es el suelo; lo que instalás adentro con dnf/pacman ni
está pineado ni puede estarlo, y se actualiza normal.
Subir la base de versión es barato PRECISAMENTE por D3: manifiesto = verdad,
upper = caché ⇒ cambiar el digest y recrear. Y el riesgo del pin (que upstream
borre el tarball) ya lo resolvió ADR 0013: la URL no entra en la identidad,
sólo el sha256, así que espejar es gratis.
Curaduría decidida con el usuario — TRES, cada una por un trabajo distinto:
- Arch bootstrap (juegos: multilib 32-bit y SteamOS es Arch ⇒ extiende D6),
- Ubuntu base LTS (binarios comerciales: es contra lo que se compilan),
- Steam Runtime sniper (la única SELLABLE: inmutable ⇒ file_drop al store).
Fedora queda BYO: hace el mismo trabajo que Arch en el slot "fresco" y una
tercera cadena mutable es la normalización que §NO-resuelve 3 quiere evitar.
Los pines de las dos primeras están VERIFICADOS contra upstream hoy (Arch
2026.09.01 sha 895661bd…, Ubuntu 24.04.3 sha 6bc2cde3…); el de sniper NO, y se
dice que no en vez de suponerlo.
Y queda anotado que hacerlas compartibles más adelante no pide diseño nuevo:
una imagen pineada es cuerpo inmutable direccionado por contenido ⇒ ADR 0014
se le aplica tal cual. Lo único que hay que mirar antes de publicarlas a
terceros es licencia y marca, que no es una pregunta técnica.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
Sin esto mpv quedaba sellado y en NINGUNA imagen. Es exactamente la lección que este fichero ya
tiene escrita en el perfil de sway: `foot` estaba sellado en el corpus, ningún perfil lo listaba,
y el escritorio daba 121/121 SIN EMULADOR DE TERMINAL. La métrica de clausura mide las raíces
declaradas y no puede ver lo que falta en la declaración.
Se lista en los cuatro porque mpv vive en el corpus: una receta resuelve sibling-first y después
el catálogo padre, así que desde cualquiera de las cuatro colas se alcanza.
Los cuatro perfiles siguen en 0 deuda y cierran con la clausura nueva ya sellada:
escritorio-kde 171 → 180/180
escritorio-gnome 119 → 129/129
escritorio-cosmic 90 → 103/103
escritorio-sway 129 → 139/139
Corpus 788 → 798 recetas, 798 selladas. grafo: CIERRA | topo-sort: OK en los cinco.
Cuatro escritorios que cierran y ninguna forma de abrir un video. El ADR 0015 ordenó los montones
y puso a mpv primero del A —lo que no tiene muro estructural, sólo falta escribirlo—. Éste es.
Sellada b3:8e6ab4e7. Evidencia de que ANDA, no sólo de que sella:
· `mpv --version` corre con el loader musl → v0.41.0, libplacebo v7.360.1, FFmpeg 7.1
· decodifica un h264 640x480 + AAC de prueba, 75 frames, `Exiting... (End of file)`
· `--vo=help` → `gpu-next` (libplacebo) y `gpu`; `--ao=help` → `alsa`
· `--gpu-context=help` → SÓLO `wayland` (Wayland/EGL). Ni un contexto X11 en el binario.
· NEEDED: libav*, libplacebo, libasound, libEGL, libc. Cero glibc, cero X11.
En el corpus y no en una cola: un reproductor lo quieren las cuatro imágenes, y una receta resuelve
sibling-first + catálogo padre, nunca una cola hermana.
`-Dbuild-date=false` no es cosmético: su default estampa la fecha de compilación DENTRO del binario
⇒ dos builds del mismo commit darían bytes distintos y el artefacto dejaría de reproducir.
`-Dprefer_static=true` (como foot) porque sin él meson resuelve los `.pc` sin `Libs.private` y el
link muere con símbolos `XML_*` sin definir «en libfontconfig.a» — culpando a fontconfig cuando lo
que falta es propagar expat.
Las ~90 opciones van EXPLÍCITAS: casi todas nacen `auto`, o sea que miran el sandbox y se prenden
con lo que encuentren. Misma trampa que ffmpeg cierra con `--disable-autodetect`.
TRES HUECOS CONOCIDOS, escritos en la receta para que no se descubran en la mano del usuario:
1. audio ALSA, y las tres pipewire llevan `-Dpipewire-alsa=disabled` ⇒ hoy no suena en un
escritorio con PipeWire vivo. Radio de darlo vuelta: 4/3/2. Es el próximo paso.
2. sin Lua ⇒ sin OSC. mpv 0.41 sólo acepta 5.1/5.2/LuaJIT y la del corpus es 5.4.8 (wireplumber).
3. sin vaapi ⇒ sin decodificación por hardware (libva está sólo en incoming-kde), y el ffmpeg
heredado va `--disable-x86asm` ⇒ tampoco SIMD.
`meson.build:29` de mpv la pide sin `required:` ⇒ es la segunda dep dura, junto con libass.
EL PROBLEMA REAL NO ERA COMPILARLA, era que upstream le entrega CINCO 3rdparty por submódulo git y
`[source]` de hammer no clona submódulos (sólo repo+commit o tarball+sha256). Resolución uno por uno:
· glad + jinja + markupsafe → recetas del corpus (commit anterior), como el `py3-glad` de Alpine
· Vulkan-Headers → sube al corpus, headers-only, hash idéntico (b3:7731a012)
· fast_float → upstream lo guarda con `fs.is_dir` ⇒ su ausencia es camino soportado
La sorpresa fue Vulkan-Headers: hace falta AUNQUE Vulkan vaya apagado. `src/vulkan/stubs.c` se
compila siempre y hace `#include <vulkan/vulkan.h>` — los stubs de «Vulkan no disponible» también
necesitan saber contra qué API no están.
Dinámica y no estática: `src/convert.cc` compila siempre ⇒ el artefacto lleva C++ y arrastra su
runtime. La `.so` se lo lleva puesto en vez de obligar a cada consumidor a pedir `-lc++`. Además es
lo coherente con mesa y ffmpeg, sus dos vecinos en el cierre de mpv.
Todas las perillas explícitas aunque el default sea `auto`: `auto` escanea el sandbox y activa lo
que encuentre — el mismo no-determinismo que ffmpeg cierra con `--disable-autodetect`.
Sellada b3:4346feca, con `pl_has_opengl=1` / `pl_has_vulkan=0` en el .pc y 8 símbolos `pl_opengl`
exportados. Vulkan queda pendiente a propósito: el loader arrastra el stack X11 entero por los WSI
y esta distro es Wayland-only ⇒ subirlo es una decisión con radio propio.
`meson.build:32` de mpv pide libass sin `required:` ⇒ no hay build de mpv sin esto.
libunibreak es "opcional" para upstream (`default=check`) y obligatorio acá: sin él libass parte
los renglones por espacios, que es justo lo que no sirve en japonés, chino o tailandés. Cuesta una
receta leaf de C plano.
`--enable-asm`: 234 símbolos SSE2/AVX2 en la `.a` sellada. Es la razón por la que `nasm` subió al
corpus en el commit anterior.
La lección de las deps, para la próxima receta que resuelva `.pc`: más de la mitad de `[deps]` no
aparece en el `./configure`. La harfbuzz del corpus va `-Dglib=enabled` (por GTK4) ⇒ su `.pc` pide
`glib-2.0`, que arrastra pcre2 y libffi. Y el fallo NO dice que falte glib: dice «Package
requirements (harfbuzz >= 1.2.3) were not met», culpando a la lib que sí estaba.
Selladas: libunibreak b3:a01fb6bd, libass b3:213df6b1.
libplacebo genera su `gl.h` con `python -m glad`, y glad le llega a upstream como submódulo git
(`3rdparty/glad`). El `[source]` de hammer no clona submódulos —sólo repo+commit o
tarball+sha256— así que glad entra como receta propia desde PyPI, que además pinea por sha256.
Es lo mismo que hace Alpine con `py3-glad` como makedepend.
Python puro las dos, mismo molde que `mako`/`markupsafe`: se copia el paquete a site-packages,
sin pip ni wheel. jinja2 es dep de runtime de glad; markupsafe ya estaba en el corpus.
El sdist de glad trae los XML de Khronos adentro ⇒ el generador no sale a la red, que es lo que
lo hace usable dentro del sandbox hermético.
Selladas: jinja2 b3:74a3aa9f (27 ficheros), glad b3:c1d773f2 (75 ficheros).
Primer paso del frente de apps (ADR 0015, montón A: mpv → OBS → Firefox). `mpv` va al corpus
porque lo quieren las cuatro imágenes, y una receta del corpus resuelve sibling-first en
`recipes/`: desde ahí NO alcanza `incoming-kde/` ni `incoming-cosmic/`. Sus tres deps de sistema
vivían sólo en colas.
Lo que hace que esto no sea duplicar: `hammer hash` da el MISMO ArtifactHash a cada par
(nasm b3:3624bdd8, alsa-lib b3:73fb200a, ffmpeg b3:bfef7bf3), porque las deps que las colas les
resolvían ya caían al catálogo padre. Cache hit, cero rebuild, y una imagen que arrastre las dos
recetas hidrata UN artefacto en vez de dos peleando por la misma ruta.
Verificado también que comentar la receta es gratis: las cabeceras nuevas no mueven el hash.
ADR 0015 pasa de "propuesta de nombre" a `qorpa` adoptado: la frontera es
`hammer qorpa {…}` y el espacio de nombres se unifica en
/var/lib/hammer/qorpa/{imagenes,instancias}/ — un solo árbol, para que la poda
de §NO-resuelve 5 tenga un único sitio que barrer. La clase de nodo del grafo
sigue siendo `ajeno`: describe la procedencia, no el subsistema.
Y arranca el §Orden de trabajo 1 (subuid) como GUARDIÁN en vez de a mano:
scripts/qorpa/qorpa-preflight.sh mide las cinco capacidades de entorno que un
huésped necesita y que no están en ningún grafo — userns sin privilegios (+
anidado), mapeo multi-id, overlayfs sin root, los nodos del borde y disco.
Tres niveles (BLOQUEA/LIMITA/NOTA) y salida 0/1/2, porque "arranca pero sin
dnf" es una respuesta legítima, no un error.
Medido en `momento` (exit 2, 2 limitaciones):
- userns ANIDADO funciona ⇒ el "verificar, no asumir" de D6 (pressure-vessel
creando su userns dentro del nuestro) queda verificado a nivel de primitiva.
- subuid es papel mojado acá: el rango está declarado en /etc/subuid y las
herramientas están, pero newuidmap/newgidmap vienen sin setuid y sin
capability ⇒ no pueden escribir el uid_map. Es exactamente la "primera cosa
que va a fallar" del ADR, y resulta ser de PROVISIÓN, no de kernel.
El guardián ya se corrigió a sí mismo una vez: marcaba LIMITA por
CONFIG_OVERLAY_FS=m mientras tres secciones más abajo el overlay montaba de
verdad. Manda la prueba funcional, no la declarada.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
Decide la frontera antes de escribir código. Sale de una medición incómoda: el
corpus tiene cuatro escritorios que cierran y CERO navegador, ofimática,
reproductor o editor de imagen. Al partir el «qué falta» por causa, la
intersección de «sólo X11» con «compilable desde fuente en musl» es casi vacía:
casi todo lo que se pierde por Wayland ya estaba perdido por la libc. El montón
que duele son binarios ajenos que nadie va a recompilar.
Las siete decisiones:
D1 — Una imagen ajena NO es un artefacto y no vive en el store. El store promete
reconstrucción bit a bit desde fuente; un rootfs de Fedora no. Meterlo ahí
sería la misma clase de error que el artefacto vacío: algo que se lee como
garantía y no lo es. Namespace paralelo, por digest, fuera de hash_inputs.
D2 — Se cruza el borde con protocolos y nodos de dispositivo, NUNCA con
librerías. Wayland/PipeWire son protocolos; /dev/dri y /dev/ntsync son ABI
de kernel. Mesa va adentro de la imagen. Corolario: la jaula no sabe qué
libc hay adentro, y por eso resuelve el montón entero de una vez.
D3 — El manifiesto es la verdad; el `upper` del overlay es CACHÉ. Misma relación
que receta↔artefacto. De ahí se caen solas la actualización de base (se
recrea, no se rebasea), el respaldo (KB, no GB) y la poda.
D4 — Cuatro granularidades, no una. El runtime curado inmutable (tipo 2) sigue
siendo el preferido cuando alcanza: se sella. El rootfs con dnf existe
porque es justo lo que el tipo 2 no permite.
D5 — Transparencia por shims GENERADOS, no por un FUSE global. Es el poder de
Bedrock sin sus formas: cero costo en runtime, inspeccionable, revocable, y
se exporta lo declarado (Bedrock arbitra en tiempo de exec, con heurísticas).
Los nodos exportados entran al grafo con clase `ajeno` ⇒ no se pueden contar
como corpus. Bedrock no puede decirte qué tenés.
D6 — Steam ya ES un contenedor: se anida pressure-vessel adentro, que es la
configuración que Valve prueba. El bwrap anidado hay que VERIFICARLO.
D7 — Es el único lugar del sistema donde la política se escribe en vez de
derivarse. harkaq deriva `política = clausura(deps)`; una imagen ajena no
tiene clausura declarada. Excepción nombrada y acotada, por defecto vacía.
Y lo que el ADR admite que NO resuelve, escrito para no descubrirlo en producción:
el socket de Wayland es un borde de privilegio y lo pasamos crudo (screencopy y
virtual-keyboard incluidos — Flatpak pasa un proxy filtrante, nosotros no lo
tenemos); el UID mapping va a fallar primero y las piezas ya están en el corpus
(shadow instala newuidmap/newgidmap y crea /etc/subuid vacío, falta
provisionarlo); es una segunda cadena de suministro sin garantías; y hay que
acotar por escrito el claim de bit-repro o la cultura de números honestos se
erosiona sola.
Se cae gratis: `xwayland` deja de ser deuda del corpus (va DENTRO de la imagen,
que ya lo trae, y se cuelga de kwin por el socket) ⇒ el wanted de KDE se
disolvería sin escribir la receta y sin tocar Wayland-only. GIMP e Inkscape dejan
de reabrir la deuda GTK3. Y del plan de juegos: F2 (glibc+multilib desde fuente,
«una campaña entera») queda CANCELADA y F0 (flatpak+ostree al catálogo)
innecesaria.
Lo nativo no se afloja: el montón A se sigue construyendo, en orden mpv → OBS →
Firefox.
Toca sólo documentación: el ADR nuevo, la nota de generalización en
plan-jaula-juegos.md §Capa 3, y el comentario en targets.toml que evita que
alguien escriba la receta de xwayland sin ver la decisión pendiente. Cero recetas
tocadas, cero re-hasheo: --kde sigue en 978 sealed / 1 wanted / 171-171, CIERRA.
Tercer y cuarto hueco de RUNTIME del triaje. Sin polkit-qt-1, Plasma no puede
elevar privilegios: ni montar un disco, ni cambiar ajustes del sistema.
Son DOS recetas porque hacía falta una segunda polkit. Hammer resuelve las deps
hermano→padre —la cola propia, después recipes/— y nunca cruza a una cola hermana,
así que la polkit de incoming-gnome es invisible desde incoming-kde. Y aunque se
viera no serviría: aquélla va con -Dintrospection=true contra glib-introspected,
que es la ISLA DINÁMICA del shell de GNOME (gjs importa gi://Polkit y necesita el
typelib). KDE enlaza las libs desde C++ y el typelib le sobra; traerlo obligaría a
meter gobject-introspection entero en la cola.
La nueva es homónima a propósito, mismo patrón deliberado que dbus (incoming-kde vs
raíz) y gmp (incoming-kde vs incoming-cosmic), con el mismo coste conocido:
store-gc no puede decidir por NOMBRE cuál de los dos sellados es el vigente. No la
renombré a polkit-kde porque el .pc que busca polkit-qt-1 es polkit-gobject-1, no
el nombre del paquete: renombrar sólo movería el problema al lector.
Radio medido antes de escribirla (yupana radio polkit): la de incoming-gnome tiene
6 dependientes transitivos y no se toca; la nueva, 0. Cero daño colateral.
Nada de esto necesitó descubrirse: glib-shared ya existía en la cola KDE y su
propio encabezado nombraba a polkit como uno de los motivos por los que se escribió.
── El guardián que sí hizo falta ────────────────────────────────────────────────
polkit-qt-1 sondea polkit con check_function_exists, que COMPILA Y ENLAZA. Si ese
enlace falla por algo del sandbox —y no porque la función falte—, cmake NO da
error: imprime «You have an older polkit-1 version» y define
POLKIT_QT_1_COMPATIBILITY_MODE. El build sella, el artefacto pesa lo esperado y la
autorización queda recortada sin que nadie se entere. La fase configure ahora
aborta si HAVE_POLKIT_SYSTEM_BUS_NAME_GET_USER_SYNC no llegó a 1 en el header
generado: polkit 127 tiene esa función, así que un 0 significa «el sondeo no pudo
enlazar», no «polkit es viejo». Pasó: no hay modo compatibilidad.
También va -DQT_MAJOR_VERSION=6 explícito. El default del CMakeLists es "5", y sin
la flag construiría bindings con otros nombres (polkit-qt5-1) que el dependiente
no encontraría hasta mucho después.
── Lo que esto NO enciende, a propósito ─────────────────────────────────────────
Los dos consumidores siguen esquivando polkit igual que antes:
· kauth construye con el backend polkit OPCIONAL apagado (cae al backend fake);
· plasma-workspace apaga el helper de Región&Idioma con -DGLIBC_LOCALE_GEN=OFF
-DGLIBC_LOCALE_PREGENERATED=ON, que es justo lo que evita el PolkitQt6-1
REQUIRED.
Encenderlos es otra decisión con su propio radio (plasma-workspace arrastra medio
escritorio). La pieza está puesta; la palanca no se toca sin pedirlo.
⚠ Y con las librerías viaja la postura de seguridad que ya declaraba
arje-polkit-compat: el demonio responde is_authorized=true a TODO. Estas libs son
la INTERFAZ que el escritorio espera, no la política. Queda repetido en las dos
recetas nuevas porque son las que ponen la pieza en manos del escritorio.
Las dos REPRODUCEN bit a bit (verificar-repro.sh 2/2). Los NEEDED de
libpolkit-qt6-core-1 son exactamente los esperados: Qt6DBus, Qt6Core,
libpolkit-gobject-1, gio/gobject/glib y libc.
corpus 788/788 · escritorio-kde 171/171 · grafo CIERRA · gate --check OK
Queda 1 wanted en KDE: xwayland — y ése no es deuda técnica sino una decisión
(X11 al tacho, Xwayland = compat opcional por-imagen, no en el core).
Segundo hueco de RUNTIME del triaje de la frontera. Nadie lo pide para construir
—por eso el escritorio sellaba completo sin él— pero sin este módulo los controles
QML de Plasma (botones, sliders, combos, los menús de los applets y de los KCM) se
dibujan con el estilo genérico de Qt. No lo reemplaza qqc2-desktop-style: aquél es
el estilo de escritorio integrado con KStyle; éste es la implementación QML nativa
de Breeze, la que Plasma 6 usa por defecto.
Dos cosas que no eran obvias:
1. Se llama casi igual que el vecino y viene de otro tarball. qqc2-DESKTOP-style
sale de Frameworks 6.27.0; qqc2-BREEZE-style sale del release de Plasma 6.7.2,
el mismo de breeze/kwin/plasma-workspace. Copiar la URL del vecino da 404.
El sha256 va contrastado contra el .sha256 que publica KDE al lado del tarball,
no sólo contra lo que bajó acá: la primera descarga volvió con 0 bytes y su
sha256 era el de la cadena vacía — un vacío que se lee como éxito.
2. La lista de deps no es la del vecino copiada: sale de la clausura de
find_dependency de los KF6*Config.cmake que ya están en el store. Ese recorrido
enseñó algo reutilizable: casi todo lo que esos configs piden (X11, XCB,
Wayland, OpenSSL, BZip2, LibLZMA) vive dentro de un `if (NOT TRUE)` — la rama
de build ESTÁTICO — y por lo tanto es código muerto en nuestro corpus, que
compila KF6 dinámico. Por eso acá no hay libX11 ni xorgproto, coherente con
Wayland-only. El find_package(X11) del CMakeLists raíz no es REQUIRED, así que
falla en silencio sin romper el feature_summary(FATAL_ON_MISSING_REQUIRED).
Confirmado a posteriori: los NEEDED del plugin son exactamente los 5 KF6 que la
clausura predijo (KirigamiPlatform, IconThemes, ColorScheme, GuiAddons,
ConfigCore) más Qt6 Quick/Gui/DBus/Core.
La fase install verifica por CONTENIDO, no por presencia: qmldir de org.kde.breeze
y de org.kde.breeze.impl no vacíos, Button.qml presente y el plugin de plataforma
de kirigami instalado. Un módulo QML sin su qmldir ocupa disco y arranca con el
estilo genérico sin decir nada. Salieron 83 controles y 3 .so.
Selló al primer intento y REPRODUCE bit a bit (verificar-repro.sh 1/1).
corpus 788/788 · escritorio-kde 166/166 · grafo CIERRA · gate --check OK
Quedan 2 wanted en KDE: xwayland y polkit-qt-1.
Nota sobre el diff de los cinco grafos: cambian los `dependientes_total` de las
deps de esta receta en TODAS las vistas, no sólo en --kde. Es correcto y está
documentado en build-state.py: ese campo se calcula sobre el grafo entero (todas
las colas del disco), que es la corrección del bug de libdrm.
Primero de los cuatro huecos de RUNTIME del triaje de la frontera. Ninguna receta
lo pedía para construir (por eso escritorio-kde cerraba 163/163 sin él), pero sin
la base el escritorio no sabe qué es un fichero: ni asociaciones, ni iconos, ni
abrir-con. Qt6 y GLib lo leen los dos de /usr/share/mime.
Vive en recipes/ y no en incoming-kde: es freedesktop puro, y en la raíz lo ven
los cinco grafos (GNOME y COSMIC lo quieren igual — gdk-pixbuf hoy lo ESQUIVA con
-Dgio_sniffing=false).
Tres cosas que no eran obvias:
1. Fuente por GIT, no tarball. Freedesktop no publica downloads de release para
este proyecto: la API sólo ofrece los -/archive/ autogenerados de GitLab, que
no son estables byte a byte (mismo criterio ya escrito en wlr-randr). El pin
es el tag 2.5.1 PELADO — acá el tag es objeto tag, la trampa de las 4 del
mirror (ADR 0013).
2. i18n.merge_file de data/meson.build es INCONDICIONAL: no lo apaga
-Dbuild-translations=false, porque es el paso que PRODUCE freedesktop.org.xml,
el payload entero. Eso exige un msgfmt que entienda --xml. El de gettext-tiny
sirve y no degrada nada: desde 2.x el template ya es XML válido (las
traducciones se marcan con reglas ITS externas, cero ocurrencias de "<_"), así
que sin catálogos la salida de un msgfmt real ES el template. Verificado a mano:
copia byte a byte de los 384036 del template.
3. meson install deja SÓLO el XML fuente. Los consumidores no leen ese XML: leen
los índices que genera update-mime-database (mime.cache, globs2, magic,
aliases, subclasses). Sin ese paso el artefacto pasa toda verificación de
presencia y el escritorio sigue sin saber qué es un .png — la regla 3 del repo
en su forma exacta. La fase install lo corre contra /out y verifica por
CONTENIDO: mime.cache y globs2 no vacíos, image/png en types. Salieron 1038
tipos y 1448 globs.
Nace con strip_debug = true y REPRODUCE bit a bit (verificar-repro.sh: 1/1, cero
divergencias), caché binaria incluida. Estático, 0 NEEDED.
corpus 788/788 sealed · escritorio-kde 165/165 · grafo CIERRA · gate --check OK
Quedan 3 wanted en KDE: xwayland, polkit-qt-1, qqc2-breeze-style.
Deja de ser una plantilla con `commit = 000…0`. Tres cosas, y las dos últimas
son el valor del commit:
1. PIN. `v0.1.0` era el tag que la versión declaraba y NO SIRVE: su `Cargo.lock`
está desincronizado con sus manifiestos en el propio repo, y `cargo vendor
--locked` se niega ("cannot update the lock file … because --locked"). No es
de hammer: el vendoreo del fetch arranca bien y muere DENTRO de cargo.
Comprobado con `cargo metadata --locked` en los tres refs — v0.1.0
incoherente, v0.2.0 y main coherentes. Se pinea v0.2.0, PELADO con ^{commit}
porque es objeto tag (93253cc2, no 0beb83b7).
=> Un tag no sirve como pin sólo por existir: hay que verificar que su
lockfile cierre.
2. El requisito de `vendor/` en la fuente que pedía el comentario quedó VIEJO:
hammer vendorea en el FETCH a partir del Cargo.lock, host-side, y por eso el
`--offline --locked` del sandbox se cumple. El repo no trae vendor/ en ningún
tag y aun así construye.
3. ⚠ FASE `install` PROPIA, obligatoria con `--example`. El default de Cargo hace
`find target/release -maxdepth 1 -type f -perm -100`, y cargo deja los
ejemplos en `target/release/examples/`. El find no encontraba nada, salía 0 y
**hammer selló un artefacto SIN BINARIO**: 22 min de compilación y un `sealed`
con sólo `.hammer/recipe.toml` dentro, 20K. `hash --check` decía SELLADO y el
grafo lo habría contado como al día.
No lo atrapa `Store::has` ni el guardián de vacíos de build-state, porque el
directorio NO está vacío — tiene el manifiesto. Es la regla 3 del CLAUDE.md en
su peor forma: un ausente falla a gritos, esto llegó al final diciendo que
todo fue bien. Por eso la fase lleva un `test -x` que hace ruidosa la
ausencia.
Verificado por contenido, no por el `sealed`: 17 M, usr/bin/llimphi-counter, ELF
pie, NEEDED = libc.so (dinámico, como la receta promete para un binario que
dlopea Vulkan/Wayland). El artefacto falso se podó y quedó en el ledger.
`version` pasa de 0.1.0 a 0.2.0 y NO mueve el hash: no está en hash_inputs (como
`license`). La identidad la da el commit.
Corpus: 787/787 SELLADAS, 0 deuda, 0 never. Gate --check OK, grafo CIERRA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LykB7vw38b4Ck1Zij15RQ4
gdm, gnome-session y gnome-settings-daemon salen de la cola y quedan aparcadas
en `recipes/incoming-gnome/.deferred/`, que es el mecanismo que el repo ya usa
(precedente: incoming-kde/.deferred/libXft.toml). El glob del worker es
TOP-LEVEL y el del grafo también, así que dejan de molerse cada ciclo y dejan de
contarse como deuda que nadie va a pagar. `targets.toml` ya no las lista desde
el 2026-08-07 («APARCADAS POR DISEÑO»); esto alinea el árbol con esa decisión.
Y el mapa que llevaban estaba mal en las dos direcciones:
1. «La terna GTK3» es un nombre engañoso: **GTK3 no es el muro**. GTK3 tiene
backend Wayland y se construye con -Dx11_backend=false. Autorarlo no habría
destrabado ninguna de las tres. Lo que bloquea de verdad:
- g-s-d 48.1: gtk+-x11-3.0 / x11 / xfixes INCONDICIONALES => imposible en
Wayland-only, no «pendiente».
- gnome-session 48.0: dependency('libsystemd', required: true) en meson:124
es una comprobación de pkg-config EN BUILD. arje-logind-compat no la
satisface y es a propósito — su receta explica que GNOME consulta login1
en RUNTIME por D-Bus y que no hace falta la C-ABI sd-login. Único camino:
parchear ese required a false, que es una decisión, no un arreglo.
- gdm: cuelga de gnome-session, y con mirada-greeter probablemente sobra.
2. La lista «FRONTERA» estaba VIEJA: ya existen libX11, libXfixes, xorgproto,
libXau/Xcursor/Xdmcp/Xext/Xi/Xrender/Xtst, libxcb (casi todas por KDE) y
polkit, upower, geocode-glib, libgweather. Cuatro de las ocho líneas de g-s-d
habían dejado de ser ciertas. Faltan de verdad: gtk3, libnotify y una
variante libcanberra-gtk3.
Verificado contra el TARBALL (sha256 = el del pin), no contra el comentario.
incoming-gnome queda en 79 recetas, sellado=79 deuda=0 nunca=0.
Gate --check OK, grafo CIERRA, escritorio-gnome sigue 119/119.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LykB7vw38b4Ck1Zij15RQ4
`fontconfig-shared`, `freetype-shared`, `libjpeg-turbo-shared`, `libpng-shared`,
`libtiff-shared`, `libxml2-shared`, `libyaml-shared` y `zlib-shared` existían a
la vez en `incoming-gnome/` y en `recipes/`, y las ocho daban el MISMO
ArtifactHash que su gemela del corpus. Como la resolución de deps cae al padre
cuando no hay hermano, quitarlas deja a las consumidoras resolviendo contra
`recipes/` y con el mismo hash.
Medido, no supuesto: se hashearon las 1144 recetas antes y las 1136 después, y
de las 1136 supervivientes **0 cambiaron de hash**. No hay rebuild.
Lo que NO se toca, y conviene que quede dicho porque se parece:
- Las variantes de la ISLA DINÁMICA (glib, gtk4, gdk-pixbuf, pango, harfbuzz,
graphene, libadwaita, json-glib, libusb, libxcvt, pipewire, pulseaudio,
wireplumber, xdg-desktop-portal): mismo nombre, hash DISTINTO. Son sombras a
propósito — la introspección de GNOME exige .so reales.
- Las copias entre COLAS (alsa-lib, hwdata, xkeyboard-config, libdisplay-info,
lcms2, icu4c, lua, nasm, fuse3, libsndfile, libelogind, hicolor-icon-theme,
dbus-shared): también dan el mismo hash, pero NO sobran. Cada cola necesita su
propio hermano para cerrar su clausura; borrar la de una rompe esa cola. No es
el caso de onda-2, donde la cola entera duplicaba a otra.
Y el criterio, otra vez: `pipewire` y `pulseaudio` son TEXTUALMENTE idénticas a
las de COSMIC y sellan distinto, porque sus deps resuelven distinto según la
cola. El fichero no dice la verdad; el hash sí.
Gate --check OK, grafo CIERRA, escritorio-gnome sigue 119/119. La cola queda en
82 recetas (79 selladas + la terna GTK3) y sus 5 parches, todos en uso.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LykB7vw38b4Ck1Zij15RQ4