gioser

sergio/takana

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

TOML 1472Shell 176Rust 106Markdown 953592 Definitions

⎇ 50d1dbcc314c529ff4a6dd9b22c35b1e05476166 docs/adr/0015-imagenes-ajenas.md Markdown · 604 lines · 39.4 KB History raw

qorpa D3: `packages` se instala solo — el manifiesto deja de ser un adorno 50d1dbcc3 · Sergio ·

1# ADR 0015 — qorpa: el mundo glibc entra enjaulado, y no entra al store
2
3- **Estado:** PROPUESTO — nombre ADOPTADO (`qorpa`, 2026-09-03); implementación EN CURSO por el
4  §Orden de trabajo. Este documento decide la frontera; el código viene después.
5- **Fecha:** 2026-09-03
6- **Frontera (a crear):** `hammer qorpa {pull,create,run,export,list,prune}`,
7  `/var/lib/hammer/qorpa/images/<sha256>/`, `/var/lib/hammer/qorpa/instances/<id>/`,
8  `instance.toml` (manifiesto), clase de nodo `ajeno` en `build-state.py`.
9- **Superficie:** verbos en inglés, mensajes en castellano — `CLAUDE.md` regla 4. Este ADR nació
10  proponiendo `traer/crear/correr` y se corrigió el 2026-09-03; el nombre `qorpa` sí es quechua.
11- **Continúa:** [SDD 04](../04-overlay.md) (overlay), [SDD 16](../16-harkaq-jaula.md) (harkaq),
12  [ADR 0004](0004-no-custom-nix.md) (hammer no usa nix), [SDD 20](../20-catalogo-publicable-y-completa.md)
13  (catálogo publicable).
14- **Absorbe:** [`plan-jaula-juegos.md`](../plan-jaula-juegos.md) §Capa 3. Su **F1** pasa a ser un
15  caso particular de este ADR (imagen de tipo 2); su **F0** (flatpak al catálogo) queda innecesaria;
16  su **F2** (glibc + multilib 32-bit desde fuente, «una campaña entera») queda **cancelada**.
17
18---
19
20## Contexto
21
22### El problema, medido
23
24El corpus tiene hoy 788 recetas selladas y cuatro escritorios que cierran. De apps de usuario final
25tiene esto:
26
27```
28kate konsole dolphin okular gwenview ark spectacle kcalc filelight kfind · foot · vim
29```
30
31Ni navegador, ni suite ofimática, ni reproductor, ni editor de imagen. La pregunta natural es qué
32lo impide, y la respuesta se parte en tres montones **con causas distintas**:
33
34| montón | qué hay adentro | qué lo bloquea |
35|---|---|---|
36| **A** | Firefox, Chromium, LibreOffice, mpv, GIMP, Inkscape, Blender, OBS | **nada estructural**: son Wayland-nativos y compilables. Falta escribirlas. |
37| **B** | Steam/Proton, IDEs de JetBrains, Zoom/Slack/Discord/Spotify, binarios comerciales | **la libc**. Son prebuilts glibc. Mueren en el loader, antes de tocar el servidor gráfico. |
38| **C** | herramental X11: `xdotool`, `xbindkeys`, `x11vnc`, Barrier | **Wayland**, y Xwayland tampoco los salva (sólo ve clientes X). |
39
40La medición que ordena el debate: **la intersección de «sólo X11» con «compilable desde fuente en
41musl» es casi vacía**. Casi todo lo que se pierde por Wayland ya estaba perdido por musl. El montón
42que duele es **B**, y ninguna cantidad de trabajo en el corpus lo mueve: son binarios que nadie va a
43recompilar.
44
45### Por qué no alcanza con lo que ya decidimos
46
47Tres salidas parecen razonables y las tres son peores:
48
491. **Construir glibc + multilib 32-bit en el corpus.** Es la F2 del plan de juegos, descrita ahí
50   mismo como «una campaña entera»: toolchain dual, loader, locales. Y aun así no corrés Steam: el
51   cliente espera un userland glibc completo, no una libc suelta.
522. **Flatpak al catálogo** (F0 del mismo plan). Funciona, pero mete ostree + flatpak + su modelo de
53   runtimes y portales, y la cadena de custodia pasa a ser de flathub, no nuestra. Era la validación
54   barata mientras no hubiera jaula propia.
553. **Aflojar Wayland-only.** No resuelve B —el problema es la libc— y sí revierte una decisión
56   tomada con fundamento.
57
58### Lo que ya está construido
59
60Este ADR compone piezas existentes. No inventa infraestructura:
61
62| pieza | dónde | estado |
63|---|---|---|
64| aislamiento de namespaces | `recipes/bwrap.toml` | sellada; ya es dep del lab de build |
65| capa mutable sobre base inmutable | [SDD 04](../04-overlay.md) + `crates/hammer-overlay` | overlayfs, `hammer try` |
66| política de grano fino en el kernel | [SDD 16](../16-harkaq-jaula.md) | Landlock + seccomp; fase 1 ✅, barrido con 0 irreducibles |
67| rootfs ajeno pineado por sha256 | `scripts/lab-image.sh` | en producción desde 2026-08-10 |
68| `newuidmap` / `newgidmap` / `/etc/subuid` | `recipes/shadow.toml` | **instalados**; falta provisionarlos |
69| kernel con lo necesario | `recipes/linux-metal.toml:111-114` | `OVERLAY_FS` (+redirect_dir/index/xino/metacopy), `USER_NS`, `NAMESPACES`, `SECURITY_LANDLOCK`, `SECCOMP` |
70
71Nada de esto hay que re-sellar. El kernel propio ya arranca con todo encendido.
72
73---
74
75## Decisión
76
77**Se adopta un subsistema de imágenes ajenas: rootfs de otras distribuciones, traídos enteros y
78pineados por digest, que corren enjaulados sobre el mismo kernel y comparten con el escritorio
79únicamente protocolos y nodos de dispositivo. Sus artefactos NO entran al store.**
80
81### D1 — La invariante del store
82
83> **Una imagen ajena no es un artefacto y no vive en el store.**
84
85No es prolijidad. El store promete que **cada entrada se reconstruye desde fuente bit a bit**. Un
86rootfs de Fedora no. Meterlo ahí haría que el store mienta, y sería la misma clase de error que el
87artefacto vacío que ya nos costó caro: *algo que se lee como garantía y no lo es*
88(`CLAUDE.md` regla 3 — «un ausente falla ruidosamente; un vacío llega hasta el final diciendo que
89todo fue bien»).
90
91Espacio de nombres paralelo, direccionado por digest, **fuera de `hash_inputs` de todo**:
92
93```
94/var/lib/hammer/qorpa/images/<sha256>/    imagen  — INMUTABLE, verificada por digest
95/var/lib/hammer/qorpa/instances/<id>/
96    ├── instance.toml                     manifiesto — LA VERDAD
97    ├── upper/                            capa mutable — CACHÉ
98    └── work/                             overlayfs
99```
100
101El precedente exacto ya existe y funciona: `scripts/lab-image.sh` trae el rootfs del lab pineado por
102sha256 porque `apk add` contra Alpine edge es irrepetible por diseño. Misma figura, otro consumidor.
103
104### D2 — La regla del borde
105
106> **Se cruza el borde con protocolos y nodos de dispositivo, nunca con librerías compartidas.**
107
108| qué | cómo cruza | por qué |
109|---|---|---|
110| pantalla | socket `wayland-0` | protocolo, no ABI. La imagen trae su `libwayland`. |
111| audio | socket de PipeWire | ídem |
112| IPC | socket de D-Bus (opcional, por sesión) | ídem |
113| GPU | `/dev/dri/*` | ABI de kernel (DRM), estable |
114| juegos | `/dev/ntsync` | ABI de kernel |
115| input | `/dev/input/*` (sólo si se concede) | ABI de kernel |
116| **Mesa / libc / cualquier `.so`** | **no cruza** | nuestro Mesa es musl; un proceso glibc no lo carga. La imagen trae el suyo, que habla DRM con nuestro kernel sin conocerlo. |
117
118El corolario práctico: **la jaula no sabe ni le importa qué libc hay adentro.** Glibc, musl ajeno,
119uclibc, bionic. Lo único que cruza es la ABI del kernel, que es estable por contrato. Por eso este
120mecanismo resuelve el montón B completo de una sola vez.
121
122El error a evitar —y es el que convierte esto en un pantano— es intentar compartir librerías del
123host «para ahorrar espacio».
124
125### D3 — El manifiesto es la verdad; el `upper` es caché
126
127> **Una instancia se declara, no se acumula.**
128
129Si el `upper` con 200 paquetes instalados a mano fuese el activo, tendríamos un blob irreemplazable:
130justo lo que hammer existe para no tener. La relación correcta es la misma que receta↔artefacto:
131
132Los campos van en inglés como el resto de la superficie (`CLAUDE.md` regla 4):
133
134```toml
135# /var/lib/hammer/qorpa/instances/games/instance.toml
136base     = "sha256:…"                 # digest de la imagen, inmutable
137distro   = "arch"                     # informativo
138packages = ["steam", "mesa"]
139
140[grants]                              # POR DEFECTO NADA
141sockets = ["wayland", "pipewire"]
142devices = ["/dev/dri", "/dev/ntsync"]
143dirs    = [{ host = "~/Juegos", inside = "/home/user/Juegos", mode = "rw" }]
144network = true                        # lo necesita el gestor de paquetes y lo necesita Steam
145
146[export]                              # capa de transparencia; ver D5
147binaries = ["steam"]
148apps     = ["steam.desktop"]
149```
150
151Consecuencias que se caen solas:
152- **`hammer qorpa recreate <id>`** reconstruye la instancia desde el manifiesto. El `upper` es
153  descartable.
154- **Actualizar la base** (Fedora 43 → 44) no es un rebase riesgoso: se cambia el digest y se recrea.
155- **El respaldo** es el manifiesto (KB), no el `upper` (GB). `respaldo-storagebox.sh` no toca
156  `/var/lib/hammer/qorpa/instances/*/upper` y eso es correcto, no un olvido.
157- La poda tiene una regla trivial: **un `upper` siempre se puede borrar.**
158
159**✅ COMPLETADO 2026-09-03 — hasta acá `packages` era una lista que nadie leía.** Durante unas
160horas el ADR afirmaba las cuatro consecuencias de arriba mientras `recreate` confesaba en su propia
161salida que «instalarlos todavía es a mano»: o sea que el `upper` **sí** era el activo y el
162manifiesto un adorno. Ahora `hammer qorpa provision <id>` instala lo declarado, y **`recreate` lo
163llama solo** (`--no-provision` para no hacerlo).
164
165| decisión | por qué |
166|---|---|
167| el gestor se **detecta** (`apt`, `pacman`, `dnf`, `apk`), no se configura | cada imagen trae el suyo. Y **no se multiplexa**: el ADR rechaza un comando único al estilo `pmm` de Bedrock; acá se elige cuál correr, no se inventa una interfaz que los tape |
168| `provision` **ensancha la política y lo dice en la cara** | instalar pide las tres cosas que una instancia bien declarada no tiene —red, root y la imagen sin sellar—. Se ensancha sólo durante esa operación, el manifiesto **no se toca**, y el siguiente `run` vuelve a lo escrito. Ensancharlo en silencio sería exactamente lo que D7 prohíbe |
169| el registro (`provisioned.toml`) vive **fuera** del `upper` | si viviera dentro se iría con la capa y nadie sabría qué había. Por eso `recreate` lo borra a mano: un registro que afirma paquetes sobre una capa recién vaciada es la forma más pura del error de la regla 3 |
170| los nombres de paquete se **validan y se comillan** | salen del manifiesto, que lo escribe una persona; `strace; rm -rf /` no llega al guión |
171
172**Las dos manías que sólo aparecen provisionando de verdad** (medidas, no leídas): el bootstrap de
173Arch trae la **mirrorlist entera comentada** —`pacman` muere con «no servers configured»— y el
174**llavero sin inicializar**, con lo que toda firma es inválida; el guión pone el mirror geo oficial
175**avisando cuál**, y corre `pacman-key --init && --populate` sólo si falta. `apt`, en cambio, no
176necesita ni un workaround: es el dividendo del rango de subuid.
177
178**Probado de punta a punta en los dos gestores**: `apt` sobre Ubuntu base y `pacman` sobre el
179bootstrap de Arch instalan, el binario corre después con la red apagada otra vez, y un `recreate`
180tira la capa y la deja igual — que es la prueba de esta sección entera.
181
182### D4 — Granularidad: cuatro tipos, no uno
183
184| tipo | qué es | mutable | ¿sellable? | para qué |
185|---|---|---|---|---|
186| **1** | rootfs completo con gestor (Fedora+`dnf`, Arch+`pacman`) | sí | no | «literalmente lo que sea» |
187| **2** | runtime curado congelado (Steam Linux Runtime *sniper*, runtime freedesktop) | **no** | **sí**, `file_drop` por sha256 | cadena de custodia nuestra |
188| **3** | bundle por app (AppImage) | no | sí | una app suelta |
189| **4** | glibc en un prefijo, sin jaula | — | — | **rechazado**: frágil y ensucia justo lo que se protege |
190
191El tipo 2 es la F1 del plan de juegos y **sigue siendo el preferido cuando alcanza**: es inmutable,
192se sella y su digest entra en el índice firmado. El tipo 1 existe porque `dnf install` es
193precisamente lo que el tipo 2 no permite.
194
195### D5 — Transparencia: shims generados, no un sistema de ficheros
196
197El objetivo es el poder de Bedrock Linux —un espacio de nombres unificado, apps de cualquier
198«stratum» disponibles en todos lados— **sin sus formas**: un FUSE global en el camino de cada
199`exec`, integración a nivel de PID 1, y arbitraje por heurística de qué binario gana.
200
201Acá no hace falta nada de eso, porque **el FHS de esta distro ya es una proyección, no la verdad**:
202`hammer hydrate` proyecta artefactos del store a un árbol. Extender la proyección a instancias es
203idiomático. Tres capas:
204
205**Capa 1 — shims.** Al crear o actualizar una instancia se **generan** lanzadores finos en el
206espacio del host: un script que hace `exec` hacia adentro, más el `.desktop` con su icono. Firefox
207de Fedora aparece en el menú de Plasma al lado de kate; `steam` está en el `PATH`.
208
209Gana en tres cosas concretas contra el FUSE:
210- **cero costo en runtime** — no hay proceso en el camino crítico de cada `exec`;
211- **inspeccionable** — `cat` al shim y ves exactamente qué hace;
212- **revocable** — se borran los shims y la instancia desaparece del sistema sin desmontar nada.
213
214⚠ **Se generan, no se copian.** El `Exec=` de un `.desktop` ajeno es texto ajeno; copiarlo pondría
215una línea de comando que no escribimos en el menú del usuario.
216
217**Capa 2 — se exporta lo declarado.** Si se exporta todo, el `ls` y el `systemctl` de Fedora compiten
218con los nuestros. Ésa es la falla de Bedrock: **arbitra en tiempo de `exec`**, con heurísticas. Acá
219la lista `[export]` del manifiesto resuelve la ambigüedad **al declarar**. Tiene precedente directo:
220la hidratación del escritorio ya va por lista explícita (`TARGETS="…"`), nunca por glob.
221
222**Capa 3 — el grafo lo sabe.** Los nodos exportados entran en `build-state.json` con **clase
223`ajeno`**, y el reporte pasa a decir:
224
225```
226escritorio-kde       187/188 listo   falta   1  (raíces 14, + 1 ajenas)
227```
228
229Así sale hoy, de verdad: el `xwayland` que era `wanted` (deuda: receta por escribir) pasó a `ajeno`
230y **la deuda se disolvió sin escribir la receta**. Dos decisiones que sostienen la cifra:
231
232- **Los ajenos se restan del denominador.** Si entraran en la fracción, el número que todo el mundo
233  lee como «cuánto construimos» crecería solo cada vez que alguien enjaula una app ajena.
234- **La declaración vive en `docs/state/qorpa-ajenos.toml`, en el repo — no se lee de
235  `/var/lib/hammer`.** `build-state.json` se commitea y lo regenera el cron en dos máquinas: si la
236  clase saliera de las instancias instaladas, cada una diría algo distinto y se pisarían en cada
237  cosecha. Es el mismo error que ya se cometió con `sealed_remoto`. **Qué provee una imagen ajena es
238  diseño, compartido; qué instancias tiene tu disco, no.**
239
240Bedrock no puede decirte qué tenés. Nosotros sí, y esto cierra el riesgo real de todo el ADR: que en
241seis meses alguien cuente esas apps como parte del corpus. **En otra clase no se pueden contar mal.**
242
243### D6 — El caso Steam, que es el que ordena el diseño
244
245Steam **ya es un contenedor**: el cliente corre en el sistema y los juegos corren dentro de
246*pressure-vessel*, el contenedor bwrap de Valve con el runtime *sniper*. La forma correcta no es
247meter Steam en nuestra jaula sino anidarlas:
248
249```
250arje-zero → kwin (wayland)
251  └─ instancia glibc (tipo 1: drivers, libs de 32 bits, dnf)
252       └─ cliente Steam
253            └─ pressure-vessel (tipo 2, de Valve)      ← anidado
254                 └─ el juego + Proton
255```
256
257Corre mejor así porque **es la configuración exacta que Valve prueba**. Nosotros ponemos el nivel de
258afuera y la política; adentro no tocamos nada.
259
260⚠ **Verificar, no asumir:** pressure-vessel necesita crear user namespaces *desde dentro* de uno ya
261creado. El kernel los tiene encendidos, pero el anidamiento hay que probarlo con un juego real. Es
262la clase de cosa que sella verde y falla en la mano del usuario.
263
264### D7 — La política se escribe, y es la única vez
265
266harkaq deriva la política en vez de escribirla: `política = clausura(deps declaradas)`, y de ahí sale
267la evidencia negativa que hace valioso al subproducto ([SDD 16](../16-harkaq-jaula.md) §3).
268
269**Una imagen ajena no tiene clausura declarada.** Es el primer y único lugar del sistema donde la
270política hay que **autorarla**. No es un defecto: es una excepción que conviene nombrar y acotar en
271vez de descubrir. El bloque `[grants]` del manifiesto es esa política, **por defecto vacío**, y
272se compila al mismo `PolicySpec` que ya consume `harkaq-exec`.
273
274Un `dnf` que no alcanza `~/.ssh` es un argumento que ninguna distro ofrece.
275
276### D8 — Qué se pinea (el suelo, no la instancia) y cuántas imágenes se curan
277
278Dos preguntas que se confunden y tienen respuestas distintas.
279
280**a) Pinear no congela: fija el punto de partida.** El digest ES la identidad, igual que
281`alpine-minirootfs`, `zig` y el rootfs del lab. Lo que queda dentro y fuera del pin:
282
283| qué | ¿pineado? | cómo se actualiza |
284|---|---|---|
285| el rootfs base | **sí**, por sha256 | cambiar el digest en `instance.toml` + `hammer qorpa recreate` |
286| lo que instalás adentro (`dnf install steam`, `pacman -Syu`) | **no, y no puede estarlo** | con el gestor de la imagen, cuando quieras |
287
288Subir la base de versión es barato **precisamente por D3**: como el manifiesto es la verdad y el
289`upper/` es caché, se cambia `base = "sha256:…"` y se recrea. Sin D3 sería un rebase sobre un blob
290irreemplazable; con D3 son dos líneas y un rato de red.
291
292El riesgo real del pin —que upstream borre el tarball— **ya está resuelto por
293[ADR 0013](0013-mirror-de-fuentes.md)**: la URL no entra en la identidad, sólo el sha256, así que
294espejar una imagen es gratis y una imagen pineada no se puede perder.
295
296**b) Pinear ≠ lista cerrada.** `hammer qorpa pull <url> --sha256` acepta cualquier rootfs. Lo corto
297no es lo que *se puede* traer, sino lo que **nosotros probamos, espejamos y publicamos**, porque cada
298imagen curada es una segunda cadena de suministro que hay que sostener (§NO-resuelve 3). Se curan
299**tres**, y cada una entra por un trabajo distinto — no por sabor:
300
301| imagen | tipo | qué trabajo hace | pin |
302|---|---|---|---|
303| **Arch bootstrap** | 1 | juegos: `multilib` de 32 bits de primera clase, y SteamOS *es* Arch ⇒ extiende el argumento de D6 («la configuración exacta que Valve prueba») | `archlinux-bootstrap-2026.09.01-x86_64.tar.zst`, en `archive.archlinux.org` (archivado para siempre) |
304| **Ubuntu base LTS** | 1 | binarios comerciales: Zoom, Slack, Discord, Spotify, Chrome, JetBrains se compilan y prueban contra esto | `ubuntu-base-24.04.3-base-amd64.tar.gz`, `cdimage.ubuntu.com` + `old-releases` |
305| **Steam Runtime «sniper»** | 2 | la única **sellable**: al no mutar entra al store por `file_drop` y su digest va al índice firmado — cadena de custodia nuestra | ✅ **verificado 2026-09-03**: snapshot `3.0.20260805.254768` (el que despliega el cliente, no «el último»), imagen y depot |
306
307**Los digests enteros están en [`docs/state/qorpa-imagenes.toml`](../state/qorpa-imagenes.toml), no
308acá.** Estaban acá, abreviados —`895661bd…`— y cuando la poda se llevó las imágenes del disco no
309alcanzaron para volver a traerlas: hubo que ir a buscarlos otra vez a upstream. **Un pin que no se
310puede pegar en un comando no es un pin, es una cita.** El fichero además dice de qué lista de sumas
311salió cada uno y **si esa lista viene firmada**, que es la única forma honesta de hablar de una
312cadena de suministro ajena: Ubuntu firma su `SHA256SUMS`, Arch no firma la lista pero sí el tarball,
313y **Valve no firma nada** — su garantía entera es TLS más el pin.
314
315**Del sniper se pinean dos cosas, y la segunda es la que destraba el paso 6.** La imagen
316(`Platform-…-runtime.tar.gz`, un rootfs) y el **depot** `SteamLinuxRuntime_sniper.tar.xz`, que trae
317pressure-vessel más la imagen y es lo que Steam despliega en `steamapps/common/`. Ese runtime
318**sólo se baja al instalar un juego**, y eso pedía credenciales: pineado se coloca a mano, y
319pressure-vessel se puede ejercitar **sin cuenta, sin juego y sin pantalla**. El depot **no es un
320rootfs**: se trae con `--verify-only`, y traerlo a secas falla el anclaje en vez de adivinar
321(comprobado: «no encuentro un rootfs en el archivo»).
322
323**Fedora queda BYO, no curada.** No por calidad —publica `Fedora-Container-Base-Generic-43-1.6`
324con CHECKSUM firmado y se pinea igual de bien— sino porque hace el **mismo trabajo que Arch** en el
325slot «fresco», y una tercera cadena mutable es justo la normalización que §NO-resuelve 3 quiere
326evitar. El ejemplo de D3 sigue diciendo `fedora-43` a propósito: el mecanismo no distingue, y eso es
327la demostración de que la terna es una decisión de curaduría, no del diseño.
328
329**c) Compartibles, más adelante.** Una imagen pineada es cuerpo inmutable direccionado por
330contenido, así que **[ADR 0014](0014-distribucion-multiorigen.md) se le aplica sin cambiar nada**:
331puede servirse desde cualquier origen porque el digest la verifica, y ausencia ⇒ seguir mientras que
332contenido distinto ⇒ ABORTAR. No se hace el día uno, pero no hay que diseñar nada para que se pueda:
333lo único que falta es la decisión. ⚠ Antes de publicarlas a terceros (no a nuestras propias
334máquinas, que es lo que ya hace ADR 0013 con las fuentes) hay que mirar licencia y **marca**:
335redistribuir un rootfs de Ubuntu o Fedora sin modificar suele estar permitido, pero la política de
336marca es cosa aparte y no es una pregunta técnica.
337
338### D9 — Qué aporta harkaq a una instancia, y los dos conflictos que sólo se ven midiendo
339
340**Honestidad primero: en el eje del sistema de ficheros, harkaq casi no agrega nada.** El namespace
341de montaje de bwrap ya es una lista blanca —lo que no se bindea no existe adentro, y un `--ro-bind`
342es de sólo lectura por el kernel— así que escribir reglas Landlock que repiten eso sería un sello de
343goma. Por eso la política de una instancia **no sellada** es una sola línea (`rw /`) y no finge.
344
345Lo que harkaq sí aporta, y pesa mucho más con un binario ajeno y opaco que con nuestros propios
346builds:
347
348| aporte | por qué importa acá |
349|---|---|
350| **seccomp** | bwrap **no instala filtro alguno**: hoy una instancia puede `io_uring`, `bpf`, `ptrace`, `process_vm_readv`, `userfaultfd`, `keyctl`, `perf_event_open`, `init_module`, `kexec`. Ésa es superficie de kernel contra un prebuilt de terceros |
351| **`no_new_privs`** | ningún setuid de la imagen ajena escala adentro |
352| **canal de evidencia** | con Landlock ABI ≥ 7 el kernel audita las denegaciones ⇒ se puede saber **qué intentó tocar** un binario cerrado. Es la única forma de auditar el montón B (leer ese canal es trabajo aparte: pide el lector con `CAP_AUDIT_READ`) |
353| **`seal_image`** | lo único del eje fs que el namespace no da: congelar `/usr`, `/bin`, `/lib`, `/opt` **aunque adentro seas root**, para que un exploit no persista en el `upper` |
354
355**Conflicto 1 — Landlock y los contenedores anidados son incompatibles hoy.** Medido: con un dominio
356Landlock activo, `mount` falla con **EACCES aunque seccomp lo permita**; el kernel no admite montajes
357nuevos bajo un dominio, porque escaparían de sus reglas por-ruta. Es decir: **pressure-vessel no
358arranca bajo Landlock** (D6). Por eso `nesting = true` cuesta la política de ficheros —`harkaq-exec
359--allow-nesting --no-landlock`—, y **seccomp y `no_new_privs` siguen puestos**, que es lo que más
360pesa. Nunca por defecto y siempre dicho en la línea de arranque: harkaq no afirma una jaula que no
361puso.
362
363**Conflicto 2 — ~~`root` adentro y anidar se pelean~~, RESUELTO el mismo día.** Con el userns de un
364solo id que crea bwrap, `--uid 0` impedía que un userns anidado escribiera su `uid_map`: la
365instancia de juegos y la de gestor de paquetes querían mapeos **opuestos**. Con el mapeo por rango
366de §NO-resuelve 2 el conflicto desaparece —anida **con `root = true`**, verificado— y `root` deja de
367ser una elección incómoda. El campo se queda en el manifiesto porque sigue describiendo quién sos
368adentro, no porque haya que elegir entre dos males.
369
370**Y un detalle que ordena las expectativas:** bwrap **tira todas las capabilities**, así que dentro
371de una instancia no hay `CAP_SYS_ADMIN` — el anidamiento sólo puede ser por userns sin privilegios,
372que es exactamente como lo hace pressure-vessel.
373
374### D10 — Los tres muros que sólo aparecieron con Steam en la mano
375
376Ninguno estaba en el ADR, y los tres son de la clase «sella verde y falla en la mano del usuario»
377que §D6 nombraba sin poder anticipar.
378
379**1. La jaula mataba TODOS los binarios de 32 bits.** El filtro seccomp de harkaq comprueba
380`arch == AUDIT_ARCH_X86_64` y **mata** lo que no lo sea. Para un build es la defensa clásica y
381correcta —los números de syscall son por arquitectura—; para el montón B es fatal, porque el cliente
382de Steam es un ELF i386. El síntoma medido fue `ldd: exited with unknown exit code (159)` — **159 =
383128+31 = SIGSYS** — que no se parece en nada a «tu jaula mata los 32 bits». La salida no es aflojar
384el check (eso reabre el agujero) sino **darle a i386 su propia tabla y aplicarle la misma política**.
385Los 25 números van literales y se verificaron **uno por uno contra `/usr/include/asm/unistd_32.h`**:
38624 estaban bien y **uno mal** — `kexec_file_load` no existe en i386, y su número de x86_64 (320) es
387ahí `utimensat`, o sea que habríamos denegado algo que usa cualquier cosa que toque una marca de
388tiempo. Es la diferencia entre una tabla escrita de memoria y una verificada.
389
390**2. Steam se niega a correr como root**, y cambiar el mapa para evitarlo **corrompe la instancia.**
391`bin_steam.sh: Error: Cannot run as root user`. La vía tentadora es mapear el uid 1000 en vez del 0
392—el mapa decide quién sos—, pero **un fichero creado bajo un mapa aparece con OTRO uid bajo el
393otro**: el `useradd` de la preparación deja un `/home` que después su propio dueño no puede escribir
394(`mkdir: /home/jugador/.steam: Permission denied`). ⇒ el mapa es parte de la IDENTIDAD de la
395instancia y cambiarlo pide `recreate`. La vía correcta es la de cualquier runtime de contenedores:
396**un solo mapa, y se BAJA de privilegio adentro** (`run_as` en el manifiesto, `setpriv`, con el
397`CAP_SETUID` que ya tenemos en el namespace).
398
399**3. El `XDG_RUNTIME_DIR` es del usuario que CORRE, no del uid del mapa.** Con `run_as`, apuntarlo
400al de root deja a las herramientas del Steam Runtime sin poder crear su temporal
401(`srt-logger: g_mkdtemp: Permission denied`). Se lee como un aviso menor hasta que algo deja de
402andar sin explicar por qué.
403
404**Lo que sí quedó probado, y es el corazón del ADR:** un userland glibc ajeno con su cadena de 32
405bits completa corre enjaulado sobre nuestro kernel, con seccomp y `no_new_privs` puestos, y **un
406contenedor anidado funciona adentro** — que es la forma exacta en que Valve prueba Proton.
407
408### D11 — El proxy de Wayland: lista BLANCA, y por qué al revés que las syscalls
409
410El compositor anuncia **todos** sus globals a quien se conecte. El proxy se mete en el medio, relaya
411todo salvo dos mensajes y con eso alcanza:
412
413| mensaje | qué se hace | por qué |
414|---|---|---|
415| `wl_registry.global` (del compositor) | si el interface no está en la lista, **no se reenvía** | el cliente nunca se entera de que existe |
416| `wl_registry.bind` (del cliente) | si el `name` es uno de los ocultos, `wl_display.error` y se corta | el `name` es **un número que se puede adivinar**: ocultar sin cortar el bind dejaría el filtro en decorativo |
417
418**Lista blanca, al revés que la denylist de syscalls de harkaq, y la asimetría tiene razón:** el
419conjunto *peligroso* de Wayland **crece** —cada compositor inventa protocolos privilegiados— mientras
420que el conjunto *necesario para dibujar una ventana* es corto y estable. Una denylist estaría
421desactualizada el día que alguien actualice el compositor, **y sin un solo error visible**. La lista
422incluye a propósito lo que los juegos piden y suele olvidarse: `pointer_constraints`,
423`relative_pointer`, `tearing_control`, `idle_inhibit`.
424
425**Lo que hace esto viable en un fichero:** ningún mensaje que se descarta lleva descriptores, así que
426los fds (que Wayland pasa por `SCM_RIGHTS` para `wl_shm` y dmabuf, y sin los cuales no se dibuja
427nada) se relayan en orden de llegada **sin tener que asociarlos a su mensaje**.
428
429**Probado sin compositor**, con uno FALSO que anuncia siete globals —tres permitidos y cuatro
430peligrosos— y un cliente que cuenta lo que ve: pasan exactamente los tres, y el intento de bindear
431por número el `name` que nunca se anunció corta la conexión. Es la clase de prueba que no necesita
432GPU y aun así responde la pregunta entera. Un quinto test cubre el `size` menor que la cabecera, que
433haría avanzar 0 bytes y colgar el proxy en un bucle.
434
435---
436
437## Lo que este ADR admite que NO resuelve
438
439Se escriben acá para que no se descubran en producción.
440
4411. **El socket de Wayland — ~~el agujero~~ CERRADO 2026-09-03.** Se escribió el proxy que faltaba
442   (D11): la instancia ya no ve el registro entero del compositor sino una **lista blanca**. Lo que
443   queda del párrafo original es la razón por la que se hizo: pasar el socket crudo **no es abrir un
444   caño, es un borde de privilegio** — `screencopy` captura la pantalla, `virtual_keyboard`
445   sintetiza teclas, `data_control` lee el portapapeles sin que nadie copie y `layer_shell` dibuja
446   encima de todo. Sigue disponible con `wayland_raw`, apagado por defecto y avisando a gritos.
447
4482. **UID mapping — ~~el muro~~ RESUELTO 2026-09-03.** Este punto ya no describe un hueco; queda
449   por lo que enseñó. La instancia veía `uid_map: 0 1001 1` y `setgroups: deny`, y eso rompía `apt`
450   (no podía bajar a `_apt`), `pacman` (no podía chownear su descarga a `alpm`) y el userns anidado
451   de pressure-vessel. **Tres síntomas, una causa.**
452
453   La cura entera —`hammer qorpa run` la aplica sola cuando puede— tiene **tres partes, y ninguna
454   sobra**:
455
456   | pieza | por qué |
457   |---|---|
458   | `setcap cap_setuid+ep newuidmap` (y `cap_setgid` en `newgidmap`) | el binario está en el corpus (`recipes/shadow.toml`) pero **sin provisionar**; sin la capability no puede escribir el mapa |
459   | crear el userns **nosotros**, mapear el rango de `/etc/subuid` 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 |
460   | devolver las **capabilities** dentro del namespace (`--cap-add`) | **bwrap las tira todas**, y en Linux ser root es tener `CAP_SETUID`, no tener uid 0. Sin esto `apt` sigue sin poder `seteuid(42)` — un síntoma que *parecía* de subuid y no lo era |
461
462   Con las tres: `uid_map` de **65537 ids**, `setgroups: allow`, `apt` instala **sin** el
463   `APT::Sandbox::User=root`, `pacman` sincroniza con `DownloadUser = alpm` **intacto**, y el
464   userns anidado monta **con `root = true`** ⇒ el conflicto 2 de D9 se disuelve. Las capabilities
465   concedidas son seguras por construcción: dentro de un user namespace sólo alcanzan a lo que ese
466   namespace posee, o sea nuestros propios subuid. `CAP_SYS_ADMIN` queda fuera y cuelga de `nesting`.
467
468   **Y una consecuencia que sólo aparece después:** el `upper/` pasa a contener ficheros de los
469   subuid (apt deja los suyos con uid 165577) que **nuestro uid normal no puede borrar** ⇒
470   `recreate` y la poda tienen que entrar a un userns mapeado para limpiar
471   (`unshare -U --map-auto -r rm -rf`). Quitar el impuesto mueve el problema, no lo evapora.
472
4733. **Segunda cadena de suministro, sin ninguna garantía de hammer.** Sin repro, sin cierre firmado,
474   sin escaneo de licencias. El riesgo no es técnico: es que se normalice. Mitigación = D5 capa 3
475   (clase `ajeno` en el grafo) + un renglón explícito en [SDD 20](../20-catalogo-publicable-y-completa.md):
476   **las imágenes ajenas no entran en el catálogo publicable ni en el reporte de licencias, porque
477   no podemos enumerarlas.**
478
4794. **El alcance del claim.** Desde el día que exista una instancia, la frase «hammer reproduce bit a
480   bit» hay que acotarla por escrito: *el sistema base reproduce; las instancias ajenas no, y se
481   declaran como tales*. Sin eso, la cultura de números honestos se erosiona sola — que es
482   exactamente la falla que ya nos pasó con el store-gc y con la deuda fantasma.
483
4845. **Disco.** Un rootfs de Fedora son 1-2 G y no lo alcanzan `store-gc` ni `.dmerge`. Necesita poda
485   propia. Este repo ya tuvo tres emergencias de disco; que la poda nazca con el subsistema, no
486   después.
487
4886. **Red adentro.** `dnf` la necesita. Es la concesión más peligrosa del manifiesto y por eso es la
489   única que se escribe sola en una línea aparte. También hace falta `/etc/resolv.conf`: es el
490   segundo fallo clásico de la primera corrida.
491
4927. **X entre instancias.** Un Xwayland dentro de una instancia levanta *su* servidor X. Portapapeles
493   con el escritorio funciona (pasa por el compositor); entre dos instancias, no. Aceptado.
494
495---
496
497## Lo que se cae, y lo que se resuelve gratis
498
499- **`xwayland` deja de ser deuda del corpus.** Si las apps X sólo corren dentro de instancias, el
500  Xwayland va **adentro de la imagen** —Fedora y Arch ya lo traen— y se cuelga de nuestro kwin por
501  el socket. El nodo `wanted` de `escritorio-kde` se disuelve sin escribir la receta y sin tocar la
502  postura Wayland-only. `targets.toml` debe sacarlo de las raíces con una nota que apunte acá.
503- **GIMP e Inkscape dejan de reabrir GTK3.** Son GTK3 y GTK3 está aparcado a propósito
504  (ver la memoria del frente GNOME). Con instancias, corren sin traer el árbol GTK3 al corpus.
505- **F2 del plan de juegos, cancelada.** Las libs de 32 bits las trae la imagen.
506- **F0 del plan de juegos, innecesaria.** No hace falta meter flatpak+ostree al catálogo para tener
507  juegos.
508
509## Lo que este ADR NO cambia
510
511- **El montón A se sigue construyendo nativo.** La jaula es para binarios ajenos, no una excusa para
512  no empaquetar. Orden acordado: **mpv → OBS → Firefox**.
513- **Wayland-only sigue en pie**, y este ADR es lo que lo hace barato.
514- **arje-zero sigue siendo PID 1.** Nada de integración a nivel de init, que es una de las formas de
515  Bedrock que se rechazan explícitamente.
516- **Cada instancia se gestiona con su propio gestor**, entrando explícitamente. Nada de multiplexar
517  `dnf`/`pacman` detrás de un comando único (el `pmm` de Bedrock): es menos mágico y no miente.
518
519---
520
521## Orden de trabajo propuesto
522
5231. **Provisionar subuid** y probar `dnf install` en una instancia mínima. Es lo primero que falla
524   (§NO-resuelve 2) y define si el resto es fácil o difícil.
5252. ✅ **HECHO 2026-09-03.** `hammer qorpa pull <url> --sha256` + verificación de digest antes de
526   desempacar. El rootfs se ancla por estructura (`etc/` + `usr|bin`), con `--subdir` de escape.
5273. ✅ **HECHO 2026-09-03.** Instancia = overlay sobre la imagen + `instance.toml`; `create` /
528   `recreate` / `run`. El overlay lo monta bwrap (`--overlay-src` + `--overlay`) dentro de su
529   propio namespace: sin root y sin montar nada en el host. `run` entra con `--clearenv` y con
530   `--unshare-net` salvo que se declare `network` — el entorno del host también es una concesión.
531
532   **Corregido el mismo día, y el bug era una verdad a destiempo:** dos `run` seguidos sobre la
533   misma instancia y el segundo moría con `Can't make overlay mount … Device or resource busy`; el
534   tercero andaba. El kernel se **niega a propósito** a montar dos overlays vivos que compartan
535   `upperdir`/`workdir` —eso corrompe la capa—, así que el EBUSY es un guardián correcto: lo que
536   estaba mal era el momento, porque la corrida anterior ya devolvió el prompt y su namespace
537   todavía no terminó de reaparse. Ahora `run` reintenta acotado y, si el overlay sigue tomado,
538   dice **la causa** («¿hay otra `run` viva?») en vez de dejar pasar el mensaje crudo de bwrap.
5394. ✅ **HECHO 2026-09-03.** Concesiones → política de harkaq. `harkaq-exec` entra como último
540   eslabón dentro de bwrap (cruza un binario **estático**, no una librería ⇒ D2 sigue en pie), y
541   aporta lo que bwrap no da: **seccomp** —hoy una instancia podría `io_uring`, `bpf`, `ptrace`,
542   `userfaultfd`, `keyctl`, `perf_event_open`—, `no_new_privs`, y el canal de evidencia. Ver D9.
5435. ✅ **HECHO 2026-09-03.** Shims + `.desktop` generados (`export`), y clase `ajeno` en
544   `build-state.py`. El `.desktop` se genera con lista BLANCA de claves —`Exec`, `TryExec`, `Path`,
545   `DBusActivatable` quedan fuera **por definición**, no por enumeración— y el icono se copia al
546   host, porque un icono que el host no resuelve se ve como un cuadrito gris. Cada fichero escrito
547   queda en un registro (`exported.json`) para que `--remove` borre **exactamente** lo generado y no
548   por patrón sobre el `~/.local/bin` de alguien.
5496. ⚠️ **PARCIAL 2026-09-03** — hasta donde esta máquina permite, y destapó tres muros. Steam
550   1.0.0.87 **instalado de verdad** desde el `multilib` de Arch (las libs de 32 bits que la F2
551   daba por «una campaña entera»), su cliente i386 **bajado y desempacado** por el propio
552   bootstrap de Valve, y el **bwrap anidado verificado explícitamente** (`BWRAP-ANIDADO-OK`, 24
553   montajes propios) — que era lo que este paso pedía. Ver D10.
554
555   **Lo que NO se pudo, y por qué:** la máquina no tiene ninguna sesión gráfica (`/dev/dri` sí,
556   socket Wayland no), así que Steam no dibuja; y el runtime *sniper* sólo se baja al instalar un
557   juego, lo que exige credenciales. **pressure-vessel con un juego real sigue sin ejercitarse.**
558
559   **✅ AMPLIADO 2026-09-03: pressure-vessel SÍ se ejercitó — sin cuenta, sin juego y sin pantalla.**
560   Lo que lo destrabó fue pinear el depot (D8): `SteamLinuxRuntime_sniper.tar.xz` es lo que Steam
561   despliega, y colocado a mano no hace falta comprar nada. Corrido dentro de una instancia qorpa
562   *sobre Ubuntu base*, con `nesting` y la jaula puesta:
563
564   | evidencia | fuera de pressure-vessel | dentro |
565   |---|---|---|
566   | `/etc/os-release` | `Ubuntu 24.04.3 LTS` | **`Steam Runtime 3 (sniper)`** |
567   | namespace de montaje | `mnt:[4026532468]` | **`mnt:[4026532526]`** |
568   | `/usr/lib/x86_64-linux-gnu` | el de Ubuntu | **675 libs de sniper, `libSDL2-2.0.so.0` incluida** |
569
570   O sea: **contenedor de Valve anidado dentro de nuestra jaula, con el runtime real adentro.** Es
571   la pregunta que ordenaba D6 y ya no está abierta.
572
573   **Y la concesión resultó ser de verdad, no un adorno:** con `nesting = false` el mismo comando
574   muere en `bwrap: Creating new namespace failed: Operation not permitted`. Una concesión que no
575   se puede apagar no es una concesión.
576
577   Tres detalles que sólo salen corriéndolo: pressure-vessel avisa `Cannot determine ld.so for
578   i386-linux-gnu` porque Ubuntu base no trae multilib —para juegos la base es Arch, que es
579   justamente por lo que Arch está en la terna—; `ldd --version` sigue diciendo la glibc de la
580   instancia y **está bien**, porque pressure-vessel importa la libc del host cuando es más nueva
581   que la del runtime, así que la prueba de que el runtime entró es `os-release`, no `ldd`; y no
582   hay ICD de Vulkan porque no se concedió `/dev/dri` — la jaula no regala dispositivos.
583
584   **Lo que sigue faltando, y ahora es sólo esto:** un juego real dibujando, que pide sesión
585   gráfica y credenciales. Ninguna de las dos es una pregunta de diseño.
5867. ✅ **HECHO 2026-09-03.** `hammer qorpa prune` + el renglón en
587   [SDD 20](../20-catalogo-publicable-y-completa.md#imágenes-ajenas-qorpa-fuera-del-catálogo-y-por-escrito).
588   Poda restos de pulls a medias, imágenes que ninguna instancia usa (se re-traen por digest: es la
589   propiedad que da el pin) y, con `--upper`, las capas mutables. **Sin `--yes` es un simulacro**, y
590   tras borrar **comprueba que el directorio se fue**: la cicatriz de `store-gc`, que reportaba
591   borrados que no ocurrían. Los tamaños de un árbol con directorios ilegibles se marcan con `≥`,
592   porque un número silenciosamente bajo es peor que ninguno cuando con él se decide borrar.
5938. ✅ **HECHO 2026-09-03.** Proxy filtrante de Wayland. Ver D11.
594
595## Nombre — ADOPTADO 2026-09-03
596
597El subsistema se llama **`qorpa`** — *huésped alojado*: alguien que entra a la casa, se le dan
598habitaciones concretas y no las demás. Elegido por el autor del repo; entra en la familia (harkaq,
599yupana, arje, wawafs, kikin, churay, mirada, minga).
600
601Consecuencias de nombre, ya aplicadas arriba: la frontera es `hammer qorpa {…}`, el espacio de
602nombres es `/var/lib/hammer/qorpa/{imagenes,instancias}/` —uno solo, para que la poda de
603§NO-resuelve 5 tenga un único árbol que barrer— y el adjetivo del grafo sigue siendo `ajeno`
604(clase de nodo), porque describe la **procedencia** del nodo, no el subsistema que lo aloja.

0015-imagenes-ajenas.md

ADR 0015 — qorpa: el mundo glibc entra enjaulado, y no entra al store


Contexto

El problema, medido

El corpus tiene hoy 788 recetas selladas y cuatro escritorios que cierran. De apps de usuario final tiene esto:

kate konsole dolphin okular gwenview ark spectacle kcalc filelight kfind · foot · vim

Ni navegador, ni suite ofimática, ni reproductor, ni editor de imagen. La pregunta natural es qué lo impide, y la respuesta se parte en tres montones con causas distintas:

montónqué hay adentroqué lo bloquea
AFirefox, Chromium, LibreOffice, mpv, GIMP, Inkscape, Blender, OBSnada estructural: son Wayland-nativos y compilables. Falta escribirlas.
BSteam/Proton, IDEs de JetBrains, Zoom/Slack/Discord/Spotify, binarios comercialesla libc. Son prebuilts glibc. Mueren en el loader, antes de tocar el servidor gráfico.
Cherramental X11: xdotool, xbindkeys, x11vnc, BarrierWayland, y Xwayland tampoco los salva (sólo ve clientes X).

La medición que ordena el debate: 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 musl. El montón que duele es B, y ninguna cantidad de trabajo en el corpus lo mueve: son binarios que nadie va a recompilar.

Por qué no alcanza con lo que ya decidimos

Tres salidas parecen razonables y las tres son peores:

  1. Construir glibc + multilib 32-bit en el corpus. Es la F2 del plan de juegos, descrita ahí mismo como «una campaña entera»: toolchain dual, loader, locales. Y aun así no corrés Steam: el cliente espera un userland glibc completo, no una libc suelta.
  2. Flatpak al catálogo (F0 del mismo plan). Funciona, pero mete ostree + flatpak + su modelo de runtimes y portales, y la cadena de custodia pasa a ser de flathub, no nuestra. Era la validación barata mientras no hubiera jaula propia.
  3. Aflojar Wayland-only. No resuelve B —el problema es la libc— y sí revierte una decisión tomada con fundamento.

Lo que ya está construido

Este ADR compone piezas existentes. No inventa infraestructura:

piezadóndeestado
aislamiento de namespacesrecipes/bwrap.tomlsellada; ya es dep del lab de build
capa mutable sobre base inmutableSDD 04 + crates/hammer-overlayoverlayfs, hammer try
política de grano fino en el kernelSDD 16Landlock + seccomp; fase 1 ✅, barrido con 0 irreducibles
rootfs ajeno pineado por sha256scripts/lab-image.shen producción desde 2026-08-10
newuidmap / newgidmap / /etc/subuidrecipes/shadow.tomlinstalados; falta provisionarlos
kernel con lo necesariorecipes/linux-metal.toml:111-114OVERLAY_FS (+redirect_dir/index/xino/metacopy), USER_NS, NAMESPACES, SECURITY_LANDLOCK, SECCOMP

Nada de esto hay que re-sellar. El kernel propio ya arranca con todo encendido.


Decisión

Se adopta un subsistema de imágenes ajenas: rootfs de otras distribuciones, traídos enteros y pineados por digest, que corren enjaulados sobre el mismo kernel y comparten con el escritorio únicamente protocolos y nodos de dispositivo. Sus artefactos NO entran al store.

D1 — La invariante del store

Una imagen ajena no es un artefacto y no vive en el store.

No es prolijidad. El store promete que cada entrada se reconstruye desde fuente bit a bit. Un rootfs de Fedora no. Meterlo ahí haría que el store mienta, y sería la misma clase de error que el artefacto vacío que ya nos costó caro: algo que se lee como garantía y no lo es (CLAUDE.md regla 3 — «un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue bien»).

Espacio de nombres paralelo, direccionado por digest, fuera de hash_inputs de todo:

/var/lib/hammer/qorpa/images/<sha256>/    imagen  — INMUTABLE, verificada por digest
/var/lib/hammer/qorpa/instances/<id>/
    ├── instance.toml                     manifiesto — LA VERDAD
    ├── upper/                            capa mutable — CACHÉ
    └── work/                             overlayfs

El precedente exacto ya existe y funciona: scripts/lab-image.sh trae el rootfs del lab pineado por sha256 porque apk add contra Alpine edge es irrepetible por diseño. Misma figura, otro consumidor.

D2 — La regla del borde

Se cruza el borde con protocolos y nodos de dispositivo, nunca con librerías compartidas.

quécómo cruzapor qué
pantallasocket wayland-0protocolo, no ABI. La imagen trae su libwayland.
audiosocket de PipeWireídem
IPCsocket de D-Bus (opcional, por sesión)ídem
GPU/dev/dri/*ABI de kernel (DRM), estable
juegos/dev/ntsyncABI de kernel
input/dev/input/* (sólo si se concede)ABI de kernel
Mesa / libc / cualquier .sono cruzanuestro Mesa es musl; un proceso glibc no lo carga. La imagen trae el suyo, que habla DRM con nuestro kernel sin conocerlo.

El corolario práctico: la jaula no sabe ni le importa qué libc hay adentro. Glibc, musl ajeno, uclibc, bionic. Lo único que cruza es la ABI del kernel, que es estable por contrato. Por eso este mecanismo resuelve el montón B completo de una sola vez.

El error a evitar —y es el que convierte esto en un pantano— es intentar compartir librerías del host «para ahorrar espacio».

D3 — El manifiesto es la verdad; el upper es caché

Una instancia se declara, no se acumula.

Si el upper con 200 paquetes instalados a mano fuese el activo, tendríamos un blob irreemplazable: justo lo que hammer existe para no tener. La relación correcta es la misma que receta↔artefacto:

Los campos van en inglés como el resto de la superficie (CLAUDE.md regla 4):

# /var/lib/hammer/qorpa/instances/games/instance.toml
base     = "sha256:…"                 # digest de la imagen, inmutable
distro   = "arch"                     # informativo
packages = ["steam", "mesa"]

[grants]                              # POR DEFECTO NADA
sockets = ["wayland", "pipewire"]
devices = ["/dev/dri", "/dev/ntsync"]
dirs    = [{ host = "~/Juegos", inside = "/home/user/Juegos", mode = "rw" }]
network = true                        # lo necesita el gestor de paquetes y lo necesita Steam

[export]                              # capa de transparencia; ver D5
binaries = ["steam"]
apps     = ["steam.desktop"]

Consecuencias que se caen solas:

✅ COMPLETADO 2026-09-03 — hasta acá packages era una lista que nadie leía. Durante unas horas el ADR afirmaba las cuatro consecuencias de arriba mientras recreate confesaba en su propia salida que «instalarlos todavía es a mano»: o sea que el upper sí era el activo y el manifiesto un adorno. Ahora hammer qorpa provision <id> instala lo declarado, y recreate lo llama solo (--no-provision para no hacerlo).

decisiónpor qué
el gestor se detecta (apt, pacman, dnf, apk), no se configuracada imagen trae el suyo. Y no se multiplexa: el ADR rechaza un comando único al estilo pmm de Bedrock; acá se elige cuál correr, no se inventa una interfaz que los tape
provision ensancha la política y lo dice en la carainstalar pide las tres cosas que una instancia bien declarada no tiene —red, root y la imagen sin sellar—. Se ensancha sólo durante esa operación, el manifiesto no se toca, y el siguiente run vuelve a lo escrito. Ensancharlo en silencio sería exactamente lo que D7 prohíbe
el registro (provisioned.toml) vive fuera del uppersi viviera dentro se iría con la capa y nadie sabría qué había. Por eso recreate lo borra a mano: un registro que afirma paquetes sobre una capa recién vaciada es la forma más pura del error de la regla 3
los nombres de paquete se validan y se comillansalen del manifiesto, que lo escribe una persona; strace; rm -rf / no llega al guión

Las dos manías que sólo aparecen provisionando de verdad (medidas, no leídas): el bootstrap de Arch trae la mirrorlist entera comentada —pacman muere con «no servers configured»— y el llavero sin inicializar, con lo que toda firma es inválida; el guión pone el mirror geo oficial avisando cuál, y corre pacman-key --init && --populate sólo si falta. apt, en cambio, no necesita ni un workaround: es el dividendo del rango de subuid.

Probado de punta a punta en los dos gestores: apt sobre Ubuntu base y pacman sobre el bootstrap de Arch instalan, el binario corre después con la red apagada otra vez, y un recreate tira la capa y la deja igual — que es la prueba de esta sección entera.

D4 — Granularidad: cuatro tipos, no uno

tipoqué esmutable¿sellable?para qué
1rootfs completo con gestor (Fedora+dnf, Arch+pacman)síno«literalmente lo que sea»
2runtime curado congelado (Steam Linux Runtime sniper, runtime freedesktop)nosí, file_drop por sha256cadena de custodia nuestra
3bundle por app (AppImage)nosíuna app suelta
4glibc en un prefijo, sin jaula——rechazado: frágil y ensucia justo lo que se protege

El tipo 2 es la F1 del plan de juegos y sigue siendo el preferido cuando alcanza: es inmutable, se sella y su digest entra en el índice firmado. El tipo 1 existe porque dnf install es precisamente lo que el tipo 2 no permite.

D5 — Transparencia: shims generados, no un sistema de ficheros

El objetivo es el poder de Bedrock Linux —un espacio de nombres unificado, apps de cualquier «stratum» disponibles en todos lados— sin sus formas: un FUSE global en el camino de cada exec, integración a nivel de PID 1, y arbitraje por heurística de qué binario gana.

Acá no hace falta nada de eso, porque el FHS de esta distro ya es una proyección, no la verdad: hammer hydrate proyecta artefactos del store a un árbol. Extender la proyección a instancias es idiomático. Tres capas:

Capa 1 — shims. Al crear o actualizar una instancia se generan lanzadores finos en el espacio del host: un script que hace exec hacia adentro, más el .desktop con su icono. Firefox de Fedora aparece en el menú de Plasma al lado de kate; steam está en el PATH.

Gana en tres cosas concretas contra el FUSE:

⚠ Se generan, no se copian. El Exec= de un .desktop ajeno es texto ajeno; copiarlo pondría una línea de comando que no escribimos en el menú del usuario.

Capa 2 — se exporta lo declarado. Si se exporta todo, el ls y el systemctl de Fedora compiten con los nuestros. Ésa es la falla de Bedrock: arbitra en tiempo de exec, con heurísticas. Acá la lista [export] del manifiesto resuelve la ambigüedad al declarar. Tiene precedente directo: la hidratación del escritorio ya va por lista explícita (TARGETS="…"), nunca por glob.

Capa 3 — el grafo lo sabe. Los nodos exportados entran en build-state.json con clase ajeno, y el reporte pasa a decir:

escritorio-kde       187/188 listo   falta   1  (raíces 14, + 1 ajenas)

Así sale hoy, de verdad: el xwayland que era wanted (deuda: receta por escribir) pasó a ajeno y la deuda se disolvió sin escribir la receta. Dos decisiones que sostienen la cifra:

Bedrock no puede decirte qué tenés. Nosotros sí, y esto cierra el riesgo real de todo el ADR: que en seis meses alguien cuente esas apps como parte del corpus. En otra clase no se pueden contar mal.

D6 — El caso Steam, que es el que ordena el diseño

Steam ya es un contenedor: el cliente corre en el sistema y los juegos corren dentro de pressure-vessel, el contenedor bwrap de Valve con el runtime sniper. La forma correcta no es meter Steam en nuestra jaula sino anidarlas:

arje-zero → kwin (wayland)
  └─ instancia glibc (tipo 1: drivers, libs de 32 bits, dnf)
       └─ cliente Steam
            └─ pressure-vessel (tipo 2, de Valve)      ← anidado
                 └─ el juego + Proton

Corre mejor así porque es la configuración exacta que Valve prueba. Nosotros ponemos el nivel de afuera y la política; adentro no tocamos nada.

⚠ Verificar, no asumir: pressure-vessel necesita crear user namespaces desde dentro de uno ya creado. El kernel los tiene encendidos, pero el anidamiento hay que probarlo con un juego real. Es la clase de cosa que sella verde y falla en la mano del usuario.

D7 — La política se escribe, y es la única vez

harkaq deriva la política en vez de escribirla: política = clausura(deps declaradas), y de ahí sale la evidencia negativa que hace valioso al subproducto (SDD 16 §3).

Una imagen ajena no tiene clausura declarada. Es el primer y único lugar del sistema donde la política hay que autorarla. No es un defecto: es una excepción que conviene nombrar y acotar en vez de descubrir. El bloque [grants] del manifiesto es esa política, por defecto vacío, y se compila al mismo PolicySpec que ya consume harkaq-exec.

Un dnf que no alcanza ~/.ssh es un argumento que ninguna distro ofrece.

D8 — Qué se pinea (el suelo, no la instancia) y cuántas imágenes se curan

Dos preguntas que se confunden y tienen respuestas distintas.

a) Pinear no congela: fija el punto de partida. El digest ES la identidad, igual que alpine-minirootfs, zig y el rootfs del lab. Lo que queda dentro y fuera del pin:

qué¿pineado?cómo se actualiza
el rootfs basesí, por sha256cambiar el digest en instance.toml + hammer qorpa recreate
lo que instalás adentro (dnf install steam, pacman -Syu)no, y no puede estarlocon el gestor de la imagen, cuando quieras

Subir la base de versión es barato precisamente por D3: como el manifiesto es la verdad y el upper/ es caché, se cambia base = "sha256:…" y se recrea. Sin D3 sería un rebase sobre un blob irreemplazable; con D3 son dos líneas y un rato de red.

El riesgo real del pin —que upstream borre el tarball— ya está resuelto por ADR 0013: la URL no entra en la identidad, sólo el sha256, así que espejar una imagen es gratis y una imagen pineada no se puede perder.

b) Pinear ≠ lista cerrada. hammer qorpa pull <url> --sha256 acepta cualquier rootfs. Lo corto no es lo que se puede traer, sino lo que nosotros probamos, espejamos y publicamos, porque cada imagen curada es una segunda cadena de suministro que hay que sostener (§NO-resuelve 3). Se curan tres, y cada una entra por un trabajo distinto — no por sabor:

imagentipoqué trabajo hacepin
Arch bootstrap1juegos: multilib de 32 bits de primera clase, y SteamOS es Arch ⇒ extiende el argumento de D6 («la configuración exacta que Valve prueba»)archlinux-bootstrap-2026.09.01-x86_64.tar.zst, en archive.archlinux.org (archivado para siempre)
Ubuntu base LTS1binarios comerciales: Zoom, Slack, Discord, Spotify, Chrome, JetBrains se compilan y prueban contra estoubuntu-base-24.04.3-base-amd64.tar.gz, cdimage.ubuntu.com + old-releases
Steam Runtime «sniper»2la única sellable: al no mutar entra al store por file_drop y su digest va al índice firmado — cadena de custodia nuestra✅ verificado 2026-09-03: snapshot 3.0.20260805.254768 (el que despliega el cliente, no «el último»), imagen y depot

Los digests enteros están en docs/state/qorpa-imagenes.toml, no acá. Estaban acá, abreviados —895661bd…— y cuando la poda se llevó las imágenes del disco no alcanzaron para volver a traerlas: hubo que ir a buscarlos otra vez a upstream. Un pin que no se puede pegar en un comando no es un pin, es una cita. El fichero además dice de qué lista de sumas salió cada uno y si esa lista viene firmada, que es la única forma honesta de hablar de una cadena de suministro ajena: Ubuntu firma su SHA256SUMS, Arch no firma la lista pero sí el tarball, y Valve no firma nada — su garantía entera es TLS más el pin.

Del sniper se pinean dos cosas, y la segunda es la que destraba el paso 6. La imagen (Platform-…-runtime.tar.gz, un rootfs) y el depot SteamLinuxRuntime_sniper.tar.xz, que trae pressure-vessel más la imagen y es lo que Steam despliega en steamapps/common/. Ese runtime sólo se baja al instalar un juego, y eso pedía credenciales: pineado se coloca a mano, y pressure-vessel se puede ejercitar sin cuenta, sin juego y sin pantalla. El depot no es un rootfs: se trae con --verify-only, y traerlo a secas falla el anclaje en vez de adivinar (comprobado: «no encuentro un rootfs en el archivo»).

Fedora queda BYO, no curada. No por calidad —publica Fedora-Container-Base-Generic-43-1.6 con CHECKSUM firmado y se pinea igual de bien— sino porque hace el mismo trabajo que Arch en el slot «fresco», y una tercera cadena mutable es justo la normalización que §NO-resuelve 3 quiere evitar. El ejemplo de D3 sigue diciendo fedora-43 a propósito: el mecanismo no distingue, y eso es la demostración de que la terna es una decisión de curaduría, no del diseño.

c) Compartibles, más adelante. Una imagen pineada es cuerpo inmutable direccionado por contenido, así que ADR 0014 se le aplica sin cambiar nada: puede servirse desde cualquier origen porque el digest la verifica, y ausencia ⇒ seguir mientras que contenido distinto ⇒ ABORTAR. No se hace el día uno, pero no hay que diseñar nada para que se pueda: lo único que falta es la decisión. ⚠ Antes de publicarlas a terceros (no a nuestras propias máquinas, que es lo que ya hace ADR 0013 con las fuentes) hay que mirar licencia y marca: redistribuir un rootfs de Ubuntu o Fedora sin modificar suele estar permitido, pero la política de marca es cosa aparte y no es una pregunta técnica.

D9 — Qué aporta harkaq a una instancia, y los dos conflictos que sólo se ven midiendo

Honestidad primero: en el eje del sistema de ficheros, harkaq casi no agrega nada. El namespace de montaje de bwrap ya es una lista blanca —lo que no se bindea no existe adentro, y un --ro-bind es de sólo lectura por el kernel— así que escribir reglas Landlock que repiten eso sería un sello de goma. Por eso la política de una instancia no sellada es una sola línea (rw /) y no finge.

Lo que harkaq sí aporta, y pesa mucho más con un binario ajeno y opaco que con nuestros propios builds:

aportepor qué importa acá
seccompbwrap no instala filtro alguno: hoy una instancia puede io_uring, bpf, ptrace, process_vm_readv, userfaultfd, keyctl, perf_event_open, init_module, kexec. Ésa es superficie de kernel contra un prebuilt de terceros
no_new_privsningún setuid de la imagen ajena escala adentro
canal de evidenciacon Landlock ABI ≥ 7 el kernel audita las denegaciones ⇒ se puede saber qué intentó tocar un binario cerrado. Es la única forma de auditar el montón B (leer ese canal es trabajo aparte: pide el lector con CAP_AUDIT_READ)
seal_imagelo único del eje fs que el namespace no da: congelar /usr, /bin, /lib, /opt aunque adentro seas root, para que un exploit no persista en el upper

Conflicto 1 — Landlock y los contenedores anidados son incompatibles hoy. Medido: con un dominio Landlock 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. Es decir: pressure-vessel no arranca bajo Landlock (D6). Por eso nesting = true cuesta la política de ficheros —harkaq-exec --allow-nesting --no-landlock—, y seccomp y no_new_privs siguen puestos, que es lo que más pesa. Nunca por defecto y siempre dicho en la línea de arranque: harkaq no afirma una jaula que no puso.

Conflicto 2 — root adentro y anidar se pelean, RESUELTO el mismo día. Con el userns de un solo id que crea bwrap, --uid 0 impedía que un userns anidado escribiera su uid_map: la instancia de juegos y la de gestor de paquetes querían mapeos opuestos. Con el mapeo por rango de §NO-resuelve 2 el conflicto desaparece —anida con root = true, verificado— y root deja de ser una elección incómoda. El campo se queda en el manifiesto porque sigue describiendo quién sos adentro, no porque haya que elegir entre dos males.

Y un detalle que ordena las expectativas: bwrap tira todas las capabilities, así que dentro de una instancia no hay CAP_SYS_ADMIN — el anidamiento sólo puede ser por userns sin privilegios, que es exactamente como lo hace pressure-vessel.

D10 — Los tres muros que sólo aparecieron con Steam en la mano

Ninguno estaba en el ADR, y los tres son de la clase «sella verde y falla en la mano del usuario» que §D6 nombraba sin poder anticipar.

1. La jaula mataba TODOS los binarios de 32 bits. El filtro seccomp de harkaq comprueba arch == AUDIT_ARCH_X86_64 y mata lo que no lo sea. Para un build es la defensa clásica y correcta —los números de syscall son por arquitectura—; para el montón B es fatal, porque el cliente de Steam es un ELF i386. El síntoma medido fue ldd: exited with unknown exit code (159) — 159 = 128+31 = SIGSYS — que no se parece en nada a «tu jaula mata los 32 bits». La salida no es aflojar el check (eso reabre el agujero) sino darle a i386 su propia tabla y aplicarle la misma política. Los 25 números van literales y se verificaron uno por uno contra /usr/include/asm/unistd_32.h: 24 estaban 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 escrita de memoria y una verificada.

2. Steam se niega a correr como root, y cambiar el mapa para evitarlo corrompe la instancia. bin_steam.sh: Error: Cannot run as root user. La vía tentadora es mapear el uid 1000 en vez del 0 —el mapa decide quién sos—, pero un fichero creado bajo un mapa aparece con OTRO uid bajo el otro: el useradd de la preparación deja un /home que después su propio dueño no puede escribir (mkdir: /home/jugador/.steam: Permission denied). ⇒ el mapa es parte de la IDENTIDAD de la instancia y cambiarlo pide recreate. La vía correcta es la de cualquier runtime de contenedores: un solo mapa, y se BAJA de privilegio adentro (run_as en el manifiesto, 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 a las herramientas del Steam Runtime sin poder crear su temporal (srt-logger: g_mkdtemp: Permission denied). Se lee como un aviso menor hasta que algo deja de andar sin explicar 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 — que es la forma exacta en que Valve prueba Proton.

D11 — El proxy de Wayland: lista BLANCA, y por qué al revés que las syscalls

El compositor anuncia todos sus globals a quien se conecte. El proxy se mete en el medio, relaya todo salvo dos mensajes y con eso alcanza:

mensajequé se hacepor qué
wl_registry.global (del compositor)si el interface no está en la lista, no se reenvíael cliente nunca se entera de que existe
wl_registry.bind (del cliente)si el name es uno de los ocultos, wl_display.error y se cortael name es un número que se puede adivinar: ocultar sin cortar el bind dejaría el filtro en decorativo

Lista blanca, al revés que la denylist de syscalls de harkaq, y la asimetría tiene razón: el conjunto peligroso de Wayland crece —cada compositor inventa protocolos privilegiados— mientras que el conjunto necesario para dibujar una ventana es corto y estable. Una denylist estaría desactualizada el día que alguien actualice el compositor, y sin un solo error visible. La lista incluye a propósito lo que los juegos piden y suele olvidarse: pointer_constraints, relative_pointer, tearing_control, idle_inhibit.

Lo que hace esto viable en un fichero: ningún mensaje que se descarta lleva descriptores, así que los fds (que Wayland pasa por SCM_RIGHTS para wl_shm y dmabuf, y sin los cuales no se dibuja nada) se relayan en orden de llegada sin tener que asociarlos a su mensaje.

Probado sin compositor, con uno FALSO que anuncia siete globals —tres permitidos y cuatro peligrosos— y un cliente que cuenta lo que ve: pasan exactamente los tres, y el intento de bindear por número el name que nunca se anunció corta la conexión. Es la clase de prueba que no necesita GPU y aun así responde la pregunta entera. Un quinto test cubre el size menor que la cabecera, que haría avanzar 0 bytes y colgar el proxy en un bucle.


Lo que este ADR admite que NO resuelve

Se escriben acá para que no se descubran en producción.

  1. El socket de Wayland — el agujero CERRADO 2026-09-03. Se escribió el proxy que faltaba (D11): la instancia ya no ve el registro entero del compositor sino una lista blanca. Lo que queda del párrafo original es la razón por la que se hizo: pasar el socket crudo no es abrir un caño, es un borde de privilegio — screencopy captura la pantalla, virtual_keyboard sintetiza teclas, data_control lee el portapapeles sin que nadie copie y layer_shell dibuja encima de todo. Sigue disponible con wayland_raw, apagado por defecto y avisando a gritos.

  2. UID mapping — el muro RESUELTO 2026-09-03. Este punto ya no describe un hueco; queda por lo que enseñó. La instancia veía uid_map: 0 1001 1 y setgroups: deny, y eso rompía apt (no podía bajar a _apt), pacman (no podía chownear su descarga a alpm) y el userns anidado de pressure-vessel. Tres síntomas, una causa.

    La cura entera —hammer qorpa run la aplica sola cuando puede— tiene tres partes, y ninguna sobra:

    piezapor qué
    setcap cap_setuid+ep newuidmap (y cap_setgid en newgidmap)el binario está en el corpus (recipes/shadow.toml) pero sin provisionar; sin la capability no puede escribir el mapa
    crear el userns nosotros, mapear el rango de /etc/subuid y pasárselo a bwrap con --userns FDbwrap crea el suyo con un solo id a propósito y nunca llama a newuidmap: el trabajo es de quien lo invoca
    devolver las capabilities dentro del namespace (--cap-add)bwrap las tira todas, y en Linux ser root es tener CAP_SETUID, no tener uid 0. Sin esto apt sigue sin poder seteuid(42) — un síntoma que parecía de subuid y no lo era

    Con las tres: 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. Las capabilities concedidas son seguras por construcción: dentro de un user namespace sólo alcanzan a lo que ese namespace posee, o sea nuestros propios subuid. CAP_SYS_ADMIN queda fuera y cuelga de nesting.

    Y una consecuencia que sólo aparece después: el upper/ pasa a contener ficheros de los subuid (apt deja los suyos con uid 165577) que nuestro uid normal no puede borrar ⇒ recreate y la poda tienen que entrar a un userns mapeado para limpiar (unshare -U --map-auto -r rm -rf). Quitar el impuesto mueve el problema, no lo evapora.

  3. Segunda cadena de suministro, sin ninguna garantía de hammer. Sin repro, sin cierre firmado, sin escaneo de licencias. El riesgo no es técnico: es que se normalice. Mitigación = D5 capa 3 (clase ajeno en el grafo) + un renglón explícito en SDD 20: las imágenes ajenas no entran en el catálogo publicable ni en el reporte de licencias, porque no podemos enumerarlas.

  4. El alcance del claim. Desde el día que exista una instancia, la frase «hammer reproduce bit a bit» hay que acotarla por escrito: el sistema base reproduce; las instancias ajenas no, y se declaran como tales. Sin eso, la cultura de números honestos se erosiona sola — que es exactamente la falla que ya nos pasó con el store-gc y con la deuda fantasma.

  5. Disco. Un rootfs de Fedora son 1-2 G y no lo alcanzan store-gc ni .dmerge. Necesita poda propia. Este repo ya tuvo tres emergencias de disco; que la poda nazca con el subsistema, no después.

  6. Red adentro. dnf la necesita. Es la concesión más peligrosa del manifiesto y por eso es la única que se escribe sola en una línea aparte. También hace falta /etc/resolv.conf: es el segundo fallo clásico de la primera corrida.

  7. X entre instancias. Un Xwayland dentro de una instancia levanta su servidor X. Portapapeles con el escritorio funciona (pasa por el compositor); entre dos instancias, no. Aceptado.


Lo que se cae, y lo que se resuelve gratis

Lo que este ADR NO cambia


Orden de trabajo propuesto

  1. Provisionar subuid y probar dnf install en una instancia mínima. Es lo primero que falla (§NO-resuelve 2) y define si el resto es fácil o difícil.

  2. ✅ HECHO 2026-09-03. hammer qorpa pull <url> --sha256 + verificación de digest antes de desempacar. El rootfs se ancla por estructura (etc/ + usr|bin), con --subdir de escape.

  3. ✅ HECHO 2026-09-03. Instancia = overlay sobre la imagen + instance.toml; create / recreate / run. El overlay lo monta bwrap (--overlay-src + --overlay) dentro de su propio namespace: sin root y sin montar 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.

    Corregido el mismo día, y el bug era una verdad a destiempo: dos run seguidos sobre la misma instancia y el segundo moría con Can't make overlay mount … Device or resource busy; el tercero andaba. El kernel se niega a propósito a montar dos overlays vivos que compartan upperdir/workdir —eso corrompe la capa—, así que el EBUSY es un guardián correcto: lo que estaba mal era el momento, porque la corrida anterior ya devolvió el prompt y su namespace todavía no terminó de reaparse. Ahora run reintenta acotado y, si el overlay sigue tomado, dice la causa («¿hay otra run viva?») en vez de dejar pasar el mensaje crudo de bwrap.

  4. ✅ HECHO 2026-09-03. Concesiones → política de harkaq. harkaq-exec entra como último eslabón dentro de bwrap (cruza un binario estático, no una librería ⇒ D2 sigue en pie), y aporta lo que bwrap no da: seccomp —hoy una instancia podría io_uring, bpf, ptrace, userfaultfd, keyctl, perf_event_open—, no_new_privs, y el canal de evidencia. Ver D9.

  5. ✅ HECHO 2026-09-03. Shims + .desktop generados (export), y clase ajeno en build-state.py. El .desktop se genera con lista BLANCA de claves —Exec, TryExec, Path, DBusActivatable quedan fuera por definición, no por enumeración— y el icono se copia al host, porque un icono que el host no resuelve se ve como un cuadrito gris. Cada fichero escrito queda en un registro (exported.json) para que --remove borre exactamente lo generado y no por patrón sobre el ~/.local/bin de alguien.

  6. ⚠️ PARCIAL 2026-09-03 — hasta donde esta máquina permite, y destapó tres muros. Steam 1.0.0.87 instalado de verdad desde el multilib de Arch (las libs de 32 bits que la F2 daba por «una campaña entera»), su cliente i386 bajado y desempacado por el propio bootstrap de Valve, y el bwrap anidado verificado explícitamente (BWRAP-ANIDADO-OK, 24 montajes propios) — que era lo que este paso pedía. Ver D10.

    Lo que NO se pudo, y por qué: la máquina no tiene ninguna sesión gráfica (/dev/dri sí, socket Wayland no), así que Steam no dibuja; y el runtime sniper sólo se baja al instalar un juego, lo que exige credenciales. pressure-vessel con un juego real sigue sin ejercitarse.

    ✅ AMPLIADO 2026-09-03: pressure-vessel SÍ se ejercitó — sin cuenta, sin juego y sin pantalla. Lo que lo destrabó fue pinear el depot (D8): SteamLinuxRuntime_sniper.tar.xz es lo que Steam despliega, y colocado a mano no hace falta comprar nada. Corrido dentro de una instancia qorpa sobre Ubuntu base, con nesting y la jaula puesta:

    evidenciafuera de pressure-vesseldentro
    /etc/os-releaseUbuntu 24.04.3 LTSSteam Runtime 3 (sniper)
    namespace de montajemnt:[4026532468]mnt:[4026532526]
    /usr/lib/x86_64-linux-gnuel de Ubuntu675 libs de sniper, libSDL2-2.0.so.0 incluida

    O sea: contenedor de Valve anidado dentro de nuestra jaula, con el runtime real adentro. Es la pregunta que ordenaba D6 y ya no está abierta.

    Y la concesión resultó ser de verdad, no un adorno: con nesting = false el mismo comando muere en bwrap: Creating new namespace failed: Operation not permitted. Una concesión que no se puede apagar no es una concesión.

    Tres detalles que sólo salen corriéndolo: pressure-vessel avisa Cannot determine ld.so for i386-linux-gnu porque Ubuntu base no trae multilib —para juegos la base es Arch, que es justamente por lo que Arch está en la terna—; ldd --version sigue diciendo la glibc de la instancia y está bien, porque pressure-vessel importa la libc del host cuando es más nueva que la del runtime, así que la prueba de que el runtime entró es os-release, no ldd; y no hay ICD de Vulkan porque no se concedió /dev/dri — la jaula no regala dispositivos.

    Lo que sigue faltando, y ahora es sólo esto: un juego real dibujando, que pide sesión gráfica y credenciales. Ninguna de las dos es una pregunta de diseño.

  7. ✅ HECHO 2026-09-03. hammer qorpa prune + el renglón en SDD 20. Poda restos de pulls a medias, imágenes que ninguna instancia usa (se re-traen por digest: es la propiedad que da el pin) y, con --upper, las capas mutables. Sin --yes es un simulacro, y tras borrar comprueba que el directorio se fue: la cicatriz de store-gc, que reportaba borrados que no ocurrían. Los tamaños de un árbol con directorios ilegibles se marcan con ≥, porque un número silenciosamente bajo es peor que ninguno cuando con él se decide borrar.

  8. ✅ HECHO 2026-09-03. Proxy filtrante de Wayland. Ver D11.

Nombre — ADOPTADO 2026-09-03

El subsistema se llama qorpa — huésped alojado: alguien que entra a la casa, se le dan habitaciones concretas y no las demás. Elegido por el autor del repo; entra en la familia (harkaq, yupana, arje, wawafs, kikin, churay, mirada, minga).

Consecuencias de nombre, ya aplicadas arriba: la frontera es hammer qorpa {…}, el espacio de nombres es /var/lib/hammer/qorpa/{imagenes,instancias}/ —uno solo, para que la poda de §NO-resuelve 5 tenga un único árbol que barrer— y el adjetivo del grafo sigue siendo ajeno (clase de nodo), porque describe la procedencia del nodo, no el subsistema que lo aloja.