Commit Graph
67 Commits
Author SHA1 Message Date
Sergio 27ec9caea0 ADR 0020: el experimento CORRIDO — 10 de 12 sobreviven a -ec, y el que cae tiene el artefacto BIEN
El diseño es la parte que vale: NO SELLA NADA. Modificar una receta le cambia el ArtifactHash, así
que medirlo a lo bruto habría dejado una docena de duplicados basura en un store que se respalda. En
vez de eso se inyecta `set -e` en cada fase Y un `exit 77` al final de la última: el build siempre
falla, nunca sella, y el veredicto se lee en el código de salida — 77 significa que todas las fases
corrieron enteras bajo set -e. Queda en scripts/farm/exp-ec.sh.

Muestra de 12 recetas ligeras (los pesos pesados quedan fuera por tiempo, y el sesgo va declarado):
10 sobreviven sin tocar nada, 1 se rompe, 1 inconcluso por timeout.

El que se rompe es `e2fsprogs`, y mirarlo de cerca cambia lo que significa: su ./configure aborta con
`external uuid library not found` pese a que la receta pasa --disable-libuuid, y hoy eso se traga
—comprobado: la misma receta sin set -e y con exit 77 LLEGA al 77—. Pero el artefacto que sella está
bien: 149 ficheros con e2fsck, mke2fs, los cuatro fsck.ext* y los mkfs.ext*. O sea que ahí `-ec` no
atrapa un bug: rompe una receta que funciona. Ése es el coste, y es el número que no se podía
adivinar leyendo: ~8%, unas 8 recetas sobre las 96 expuestas.

También se corrige el recuento: 96, no 89. El 89 salía de restar 142−53 dando por hecho que las 53
con `set -e` propio estaban todas dentro de las 142, y no lo estaban.

jaula-preparar suma lo que hizo falta para poder correr todo esto desde adentro: rsync/python3/jq/
cargo enlazados del store, y el cargador de musl, sin el cual python3 y cargo no arrancan en una
imagen glibc.
2026-09-18 18:17:42 +00:00
Sergio 08b475ec17 ADR 0020: el corpus ya se defiende solo en 53 de las 142 — el radio de explosión son 89, no 142
Medido al construir `wtype` en la caja: su fase de install empieza con `set -e` puesto a mano por el
autor. No es la única — 53 de las 142 recetas con fase multi-comando hacen lo mismo.

Son dos datos. Uno: nadie escribe `set -e` en 53 recetas por gusto, se escribe después de que algo
selle mal, así que el default ya estaba pagándose de a una. Dos: esas 53 ya corren bajo `set -e` y
no romperían con `-ec`, o sea que la migración es sobre las 89 restantes y la muestra del
experimento sale de ahí, no de las 142.
2026-09-18 17:40:05 +00:00
Sergio 48a9aff129 ADR 0020: las fases corren con sh -c sin -e, y un fallo no final sella igual
Sale de construir `zsh`. El agujero es estrictamente el de los comandos NO FINALES de una fase: el
estado de salida de `sh -c` es el del último comando, así que `make … install install.info` podía
morir con `makeinfo: not found` y el artefacto sellaba con `/usr/share/info` VACÍO adentro.

Medido, no supuesto: 144 logs del worker, 88 sellados, 4 con fallo de alta señal. Clasificados uno
por uno da UN hueco real confirmado (zsh, ya reparado en 3b36e9df), uno que NO lo es (python3: ahí
takana SÍ abortó, porque el `make` era el último comando de su fase) y uno inconcluso (strace, en
logs multi-receta donde el target que falla no se puede atribuir).

Ese caso de python corrige mi propio encuadre inicial, que decía «las fases sellan con huecos
silenciosos» a secas y era demasiado amplio. Queda escrito en el ADR porque la versión amplia manda
a buscar el bug donde no está.

Exposición medida: de 943 recetas, 142 tienen fase multi-comando escrita a mano. Las ~800 restantes
NO están expuestas — usan las fases de `resolve_phases`, que encadena con `&&` y sí propaga.

La decisión (`-ec` o no) queda ABIERTA a propósito: falta el único número que importa, que es
cuántas de esas 142 dejan de sellar bajo `-ec`, y eso no se saca leyendo sino reconstruyendo.
Cambiar el default a ciegas sobre 1115 sellados es el reflejo que este repo evita.
2026-09-18 16:31:33 +00:00
SergioandClaude Opus 5 447e151424 ADR 0019: el dueño del formato Card — y takana lo escribe a mano en cinco sitios
La mudanza emite tarjetas de arje y el producto instalado también; quién es dueño del formato no
estaba escrito, así que por omisión son dos repos. Medido hoy:

  el tipo canónico existe y está en tawasuyu   `shared/card/card-core::Card`
  …y VALIDA su versión                          CARD_SCHEMA_VERSION = 1, comparada al deserializar
  emisores dentro de takana                     2, en dos lenguajes (bootstrap Rust + arje.py)
  deps de card-core en crates/*/Cargo.toml      0
  `"schema_version": 1` escrito a mano          5 sitios
  arje-absorb                                   usa card_core::Card y ya trae systemd/openrc/runit/
                                                dinit/sysvinit; formatos/ reimplementa DOS en Python

El día que el esquema pase a 2, arje rechaza las tarjetas de takana — y no falla en un test: falla
en el arranque de una caja recién instalada. Hoy coinciden por suerte, no por construcción. Es el
mismo fallo que ya se pagó un piso más abajo, cuando había dos emisores DENTRO de takana y cada uno
tenía un campo distinto mal en la raíz.

Decisión: tawasuyu DEFINE, takana GENERA. Un solo emisor acá y que serialice con el tipo, no con
`format!` — criterio de aceptación negativo: cero literales `"schema_version"` en el árbol. Los
lectores de init ajeno se RETIRAN (absorb ya los tiene), no se mejoran, y mientras convivan hay una
prueba que compara las dos salidas y falla si divergen. Lo que takana aporta —el lector `proc`, que
lee lo VIVO porque `rc-status` miente— va hacia absorb, no en paralelo.

Y la herramienta NO se muda a tawasuyu: la mudanza habla de recetas, perfiles, store y ArtifactHash.
Lo que cruza la frontera es el tipo, no la herramienta.

Dos precondiciones que no se saltan: `card` es uno de los 26 repos sin copia fuera de gioser (hacer
que el bootstrap dependa en compilación de un repo que sólo vive en la máquina que se borra es
convertir un problema conocido en un bloqueo de arranque); y `STAGE1_SEED_CARD` ENTRA EN EL HASH del
bootstrap, así que reserializar con card-core puede mover el baseline del selfhost — se mide con
`takana hash` antes, y si se mueve es una decisión propia. La mudanza no tiene ese problema y puede
adoptar el tipo primero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Tf9T4vGzsMoT7eS8YzMFn
2026-09-14 15:01:05 +00:00
SergioandClaude Opus 5 8f43b7ad03 ADR 0017: el kexec SALTÓ — dos kernels, un solo paso por el firmware
Verificado en OVMF con el kernel linux-metal-kexec recién construido
(b3:965acc37…, CONFIG_KEXEC_FILE=y confirmado en el .config sellado):

  BdsDxe: starting Boot0002 "UEFI Misc Device"   ← el firmware, UNA vez
  KEXEC-PRUEBA: kernel = 6.16.12
  ✓ kernel cargado en memoria
   saltando — el userspace muere ACÁ. Esto no vuelve.
  KEXEC-PRUEBA: kernel = 7.1.2
  KEXEC-PRUEBA-LLEGADA: este kernel NO lo arrancó el firmware

La prueba no es que aparezca el 7.1.2: es que "BdsDxe: starting" aparece
UNA SOLA VEZ. Dos kernels distintos corrieron y el firmware sólo
intervino en el primero. Y el 7.1.2 vive dentro del initramfs, sin
entrada de arranque, así que el firmware no podría haberlo lanzado.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
2026-09-12 11:26:43 +00:00
SergioandClaude Opus 5 d604d850db ADR 0017: kexec por kexec_file_load, y la decisión 2 pasa de viable a costo
La diferencia entre los dos syscalls decide el frente: kexec_load pide
los segmentos armados y el purgatory (= kexec-tools entero);
kexec_file_load recibe los descriptores y hace el trabajo adentro, ~50
líneas sin dependencias.

Con recipes/linux-metal-kexec.toml (única diferencia: CONFIG_KEXEC_FILE)
queda respondida media decisión abierta nº2: kexec y Secure Boot SÍ
pueden coexistir, porque lockdown prohíbe kexec_load y acepta
kexec_file_load con imagen firmada. Lo que queda es si se firma y quién
paga el artefacto extra — decisión de coste, no de viabilidad.

Y queda anotado el diagnóstico que mandaba al lugar opuesto: EPERM no es
"falta CONFIG_KEXEC_FILE" sino falta de CAP_SYS_BOOT. Medido en el hub,
cuyo kernel trae el flag y aun así dio EPERM por no ser root. Tercera
vez en dos días que el error caro no es el mecanismo sino el mensaje que
lo explica.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
2026-09-12 01:05:57 +00:00
SergioandClaude Opus 5 93d873c41d ADR 0017: la vuelta atrás del kernel, MEDIDA — y la hace el firmware
Cuatro arranques en OVMF con dos kernels REALES del store:

  1º  6.16.12 estable → stage del 7.1.2: "copiado (17142784 bytes)",
      "BootNext = Boot0004"
  2º  starting Boot0004 "takana (candidato)" → uname = 7.1.2,
      " EN PRUEBA" → confirm → "✓ promovido"
  3º  arranque normal → uname = 7.1.2: el estable cambió
  4º  stage de un candidato ROTO (4K de basura) y reinicio →
      starting Boot0002 (la ruta normal) → uname = 7.1.2 →
      "✗ el candidato Boot0004 NO arrancó: esta sesión vino del estable"

La cuarta es la que justifica todo: la máquina sobrevivió a que le
instalaran un kernel que no arranca, sin que nadie interviniera, y
además SABE que pasó.

Y lo hizo sin código nuestro: BootNext es de un solo uso y el firmware
la consume al leerla, así que si el kernel muere el siguiente arranque
ya no la encuentra y cae en BootOrder. No hay contador que mantener ni
estado que se pueda corromper — el mecanismo ES el firmware.

Se suma la detección de entradas huérfanas en boot-status, que salió de
ver al banco de pruebas perder el estado en un tmpfs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
2026-09-12 00:52:20 +00:00
SergioandClaude Opus 5 2b87ecb50f respaldo del arranque: lista blanca, porque BootCurrent rompía la idempotencia
Dos arranques reales seguidos, sin tocar nada, daban dos respaldos con
nombre distinto: boot-27b952c9….json y boot-f999e1e8….json. La promesa
de "un arranque que no cambió produce el mismo fichero" era falsa.

La causa: el filtro era «empieza por Boot», y eso deja entrar
BootCurrent, que dice por dónde arrancó ESTA vez y cambia en cada
arranque. Y encima es de SÓLO LECTURA, así que restore habría intentado
escribirla.

Ahora es lista blanca —BootOrder y Boot#### y nada más—, que también
deja fuera BootNext (de un solo uso: restaurarla dispararía un arranque
que nadie pidió) y BootOptionSupport (informativa). Ninguna de las tres
describe cómo debe arrancar la máquina.

Comprobado: cambiando BootCurrent a mano, el respaldo da el mismo
fichero y lo dice — "idéntico a uno que ya estaba: el arranque no
cambió".

Esto sólo se veía ARRANCANDO DOS VECES. Un test del respaldo contra un
efivarfs fabricado habría pasado en verde: la variable que rompía la
propiedad la pone el firmware, no el código.

80/80 del CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
2026-09-12 00:37:13 +00:00
SergioandClaude Opus 5 bd1fa8bf8a boot entry backup/restore: el arranque como estado reproducible (ADR 0018 §5)
Guarda las variables de NVRAM CRUDAS —se reescriben tal cual;
decodificarlas para volver a codificarlas al restaurar sería una
oportunidad de perder algo que no entendemos— y de la ESP un manifiesto
con hashes, no los bytes: el kernel ya vive en el store y copiarlo otra
vez sería churn. El manifiesto es evidencia de qué había; para lo que no
esté en el store dice qué falta, en vez de prometer reponerlo.

El ADR se equivocaba en DÓNDE: decía "volcar al store". No va al store,
porque lo barre store-gc.sh, que clasifica por nombre de receta — un
respaldo ahí sería huérfano y se borraría en el primer --huerfanos. Vive
en /var/lib/hammer/boot/, que en las imágenes es su propia partición. El
nombre sale del CONTENIDO, así que un arranque que no cambió produce el
mismo fichero: corre en cada arranque sin llenar la partición de copias.

Lo que encontró la prueba de punta a punta y no estaba en el diseño:
restaurar es volver al pasado, y lo que llegó DESPUÉS no estaba en ese
pasado. Al reponer el BootOrder del respaldo, el Windows instalado más
tarde quedaba FUERA del orden — correcto, y justo lo que el usuario no
espera de algo llamado "restaurar el arranque". Ahora se avisa antes,
con nombre y apellido, y restore NO escribe por defecto: la NVRAM es lo
único de la máquina que no se rehace desde el store.

El lector FAT ganó lectura de ficheros, con su trampa propia: hay que
truncar al tamaño DECLARADO en el directorio, no al final del último
cluster. Un fichero de 1,5 MB en clusters de 1 KiB termina con relleno y
hashear el relleno daría un hash distinto al del mismo fichero en disco
— el síntoma sería "dos respaldos del mismo arranque difieren".
Verificado contra mtools como oráculo (1 500 000 y 900 000 bytes
exactos, mismo sha256 que los originales) y con un test determinista que
fabrica una FAT16 a mano, sin depender de que mtools esté.

79/79 del CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
2026-09-12 00:34:21 +00:00
SergioandClaude Opus 5 2201fac08e boot entry survey: el informe del terreno, leyendo la ESP SIN montarla (ADR 0018 §4)
Reporta firmware, discos, ESPs, con quién se comparten y si la
instalación entra. No escribe nada — y para poder prometer eso hubo que
leer la ESP sin montarla, que es lo que obligó al lector FAT de sólo
lectura (fat_ro.rs). Montar para averiguarlo pedía privilegios, dejaba
un efecto secundario justo cuando prometimos no tocar nada, y falla si
el vecino dejó la FAT sucia por su hibernación.

Sobre una ESP de fábrica (100 MiB) con Windows dentro:

  FAT32 «ESP» — 100.0 MiB totales, 77.1 MiB usados, 22.1 MiB libres
  vecinos en \EFI: Microsoft, BOOT
    ⚠ «Microsoft» ⇒ hay Windows en esta ESP. Es el que reordena
      BootOrder al actualizarse.
  ✗ NO ENTRA: hacen falta 31.0 MiB y hay 22.1 MiB libres

Contrastado contra mtools como oráculo, que es lo que hace creíble el
número: mdir dice "23 221 248 bytes free" y el lector propio dice
22.1 MiB — el mismo. Los clusters libres se cuentan recorriendo la FAT y
NO se lee el FSInfo de FAT32 a propósito: ese campo lo deja
desactualizado un SO que desmontó mal, y un número optimista de más
haría fallar la instalación a mitad — justo lo que el §4 evita.

En el instalador el §4 resultó ser algo más que "cuánto espacio hay": la
rama UEFI se lleva el disco ENTERO, así que lo que hay que decir en voz
alta es con qué se lo va a llevar puesto. El survey corre antes de
particionar y nombra el \EFI\Microsoft si está. El usuario se entera
ANTES, y no después de que su Windows dejó de arrancar.

Dos cosas que habrían pasado inadvertidas sin test: el nombre largo
tiene que ganarle al 8.3 (sin juntar los LFN, "Microsoft" se lee
"MICROS~1" y la advertencia no dispara nunca), y el tipo de FAT sale del
número de clusters y no del texto del BPB, que es informativo y hay
formateadores que mienten. El primer test del tipo lo escribí mal —1999
clusters ES FAT12— y el síntoma es idéntico a un bug del lector.

78/78 del CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
2026-09-11 22:55:46 +00:00
SergioandClaude Opus 5 66eaf8738f el reconciliador ARRANCA solo: montar sysfs, y un diagnóstico que no mentía a medias
Cierra el §2 del ADR 0018, verificado con dos arranques de la imagen
completa en OVMF:

  1º (por la fallback) → "ESP detectada en /dev/vda p1",
     "⚠ EL ARRANQUE ESTABA CAMBIADO — takana lo repuso",
     "faltaba la entrada «takana» ⇒ escrita en Boot0004",
     "BootOrder: 0000,0001,0002,0003 → 0004,0000,0001,0002,0003"
  2º → BdsDxe: starting Boot0004 "takana" from HD(1,GPT,923A070F-…)
       /\EFI\takana\takanax64.efi
       "✓ arranque en orden: Boot0004 «takana» ya es la primera"

Un sistema recién instalado se da de alta en el firmware en su PRIMER
arranque y desde el segundo arranca por su propia entrada, sin que nadie
corra un comando. Y el segundo no escribe nada.

Lo caro no fue el reconciliador sino un diagnóstico FALSO: el primer
arranque con el hook dijo "sin firmware EFI — esta máquina no arrancó
por UEFI" en una VM que SÍ arrancó por UEFI. La causa no tenía nada que
ver con UEFI: busybox switch_root no arrastra /sys —igual que no
arrastra /dev, cosa que el script ya contemplaba— así que el directorio
de efivars no existía. El mensaje mandaba a investigar el firmware, que
estaba perfecto.

Dos arreglos:
- el wrapper monta sysfs y después efivarfs. CONFIG_EFIVAR_FS=y ya
  estaba en el .config SELLADO (verificado, no supuesto) ⇒ no se
  re-hashea ningún kernel.
- el mensaje distingue TRES estados que se parecen: no existe (BIOS o
  /sys sin montar), existe y está VACÍO (falta el mount, y lo dice con
  el comando exacto), o tiene variables. Decir "no hay UEFI" cuando
  falta un mount manda a diagnosticar al lugar equivocado.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
2026-09-11 22:25:12 +00:00
SergioandClaude Opus 5 2f0d5654e8 ADR 0018: el arranque por vendor path, MEDIDO — el seguro se cobra
Segunda enmienda, y cierra la primera del mismo día.

El experimento: la MISMA imagen con la ruta fallback pisada con 4K de
basura, dos corridas en OVMF que difieren SÓLO en la NVRAM.

  control (NVRAM limpia) → "No bootable option or device was found",
                            cero líneas del initramfs
  prueba  (con la entrada
           que escribió takana) → BdsDxe: starting Boot0004 "takana"
     from HD(1,GPT,DA80800E-…,0x800,0x30000)/\EFI\takana\takanax64.efi

El firmware imprime el device path que decodificó: 0x800 = LBA 2048 y
0x30000 = 196608 sectores, exactamente los números que takana leyó del
GPT. No es que "arrancó": arrancó POR la entrada que escribimos, con la
fallback destruida. Es el escenario de Windows con el seguro cobrado.

La idempotencia del §2 quedó probada en condiciones reales y no en un
test: la segunda corrida dice "ya dice exactamente esto — no se
reescribe" y "BootOrder ya empieza por Boot0004 — no se toca". La NVRAM
tiene un número finito de escrituras y esto corre en cada arranque.

El parser se validó además contra las CUATRO entradas reales del
firmware (BootManagerMenuApp, EFI Firmware Setup, UEFI Misc Device, EFI
Internal Shell): un vector que nadie escribió para que pasara.

Y la dependencia efibootmgr se retira: no hizo falta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
2026-09-11 21:31:02 +00:00
SergioandClaude Opus 5 294959c1da arranque y GC: las dos rutas EFI, guardián de ESP y el kernel vivo como raíz
Primera tanda de los ADR 0017/0018, con las mediciones que la corrigieron.

ADR 0018 §3 — install-image-efi.sh y takana-live-install.sh escriben el
kernel en \EFI\takana\takanax64.efi ADEMAS de la ruta fallback, con un
guardián de capacidad que comprueba ANTES y con los números a la vista
(duplicar el kernel cuesta, y el costo se dice). En el live-install la
verificación es por TAMAÑO, no por presencia: un cp truncado en FAT32
deja el fichero ahí y test -e diría que todo salió bien.

Verificado con imagen real en OVMF: las dos copias dan el mismo sha256
que el bzImage del store, y la imagen arranca hasta arje-zero PID1.
CONTROL NEGATIVO: pisando la fallback con 4K de basura el firmware dice
"No bootable option or device was found" y no aparece HAMMER-EFI ni una
vez ⇒ el escenario de Windows, reproducido.

Y lo que NO se pudo verificar cambia el alcance, así que va en el ADR:
sin entrada NVRAM el firmware NO busca el vendor path. La copia vendor
es hoy un seguro que no se cobra solo — el §3 es necesario y NO
suficiente sin el §1. Eso asciende la receta efibootmgr a bloqueante.

ADR 0017 §4 — store-gc.sh suma como raíz el kernel EN EJECUCIÓN,
identificado por .config byte a byte. Sin esto el artefacto del kernel
vivo cae en "superados" cuando la receta se movió, y se borra: el
sistema sigue andando perfecto hasta el día que hace falta volver atrás.
Probado en los tres sentidos, incluido el control que TIENE que seguir
condenado.

Además, dos bugs preexistentes que aparecieron al ir a medir:

- install-image-efi.sh abortaba con "ROOTFS sin /sbin/init" en TODO
  rootfs sano: /sbin/init es un symlink ABSOLUTO (→/usr/bin/arje-zero) y
  [ -e ] lo sigue contra la raíz del HOST. Un chequeo que validaba algo
  distinto de lo que creía validar. Arreglado resolviendo el destino
  dentro del rootfs.
- (no arreglado, es del entorno) el cp -al del staging da EXDEV si
  ROOTFS y STAGE no están en el MISMO MOUNT — y el bind-mount del store
  cuenta como otro mount aunque sea el mismo /dev/sdb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
2026-09-11 20:40:34 +00:00
SergioandClaude Opus 5 822d0b8949 ADR 0017 y 0018: ciclo de vida del kernel y soberanía del arranque
Dos preguntas de diseño convertidas en decisiones con su evidencia.

0017 — el kernel es artefacto de contenido, así que el rollback ya es
gratis y atómico: lo que falta es A/B con vuelta atrás automática.
Medido sobre los 6 .config SELLADOS (no sobre las recetas): KEXEC=y en
los 6 (la primitiva está pagada, gratis), LIVEPATCH en NINGUNO —lo que
hay es HAVE_LIVEPATCH, que es capacidad de arch, no el feature— y
MODULES sólo en linux. Un livepatch ES un .ko ⇒ en los 5 kernels que
arrancan máquinas está cerrado por construcción.

0018 — la NVRAM es estado mutable compartido y Windows es otro agente
escribiendo sin coordinarse: el mismo patrón del índice de git (regla 2)
y del árbol de fuentes (ADR 0012) ⇒ reconciliación, no confianza.
Medido: efibootmgr aparece 0 veces en el repo y takana NUNCA crea una
entrada NVRAM — depende entera de la ruta EFI/BOOT/BOOTX64.EFI, que por
especificación es la de medios REMOVIBLES. Correcto para el USB, la peor
dirección posible para un disco compartido con Windows.

Se atan por KEXEC_FILE: no está en ninguno de los 6, y lockdown (que
trae Secure Boot) prohíbe el kexec_load viejo ⇒ hoy kexec y Secure Boot
no coexisten.

Abiertas a propósito: si el perfil servidor quiere livepatch (variante
con MODULES, nunca un flag en el kernel general), y si takana firma.
"No firmar" no es neutral: le traslada al usuario la clave de
recuperación de BitLocker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
2026-09-11 19:08:08 +00:00
SergioandClaude Opus 5 008dd3925e renombre: los instaladores pasan a takana-* — y el peligro no era el fichero, era el PROTOCOLO
ADR 0016 los listaba entre los CONGELADOS; el usuario pidió descongelarlos al abrir el SDD 28. La
enmienda queda escrita en el propio ADR, que si no la etiqueta deja de describir el hecho.

`hammer-install.sh` → `takana-install.sh`, `hammer-live-install.sh` → `takana-live-install.sh`,
`hammer-banner.txt` → `takana-banner.txt`, `BRIEFING-hammer.md` → `BRIEFING-takana.md`.

**Lo que hacía caro esto no es el nombre del fichero.** El instalador se inyecta en el ISO como
`/usr/bin/hammer-install` y su éxito se detecta con un `grep` de `HAMMER-INSTALL-OK` desde TRES
scripts de prueba. Renombrar un solo lado los deja casando NADA — sin fallar —, que es literalmente
el modo en que `atribuir-fallos.py` quedó mudo cuando el renombre movió el target de `tracing`.
Se renombraron las dos puntas en el mismo commit (`/usr/bin/takana-install`,
`TAKANA-INSTALL-OK/FAIL`, `TAKANA_INSTALL_*`, `work/takana-install.img`, `/run/takana-install`),
se comprobó por `grep` que no quede ningún token viejo fuera del ADR, y —lo que decide— se CORRIÓ
`install-tui-test.sh`: 4/4 casos verdes.

Las tres `TAKANA_INSTALL_*` caen al nombre viejo (`${TAKANA_INSTALL_X:-${HAMMER_INSTALL_X:-}}`):
el llamador puede ser un ISO anterior al renombre. Misma convención que `TAKANA_ROOT_PW` unas
líneas más arriba en ese mismo script.

NO se tocó `/usr/sbin/hammer-recover` ni su hook de arranque —renombrarlo rompe máquinas YA
INSTALADAS, no el repo—: sobrevive intacto dentro del script renombrado, verificado por conteo
antes y después (8 ocurrencias). Tampoco `hammerd`, `hammer-edit` (su `name` está en la ruta del
store), `/var/lib/hammer`, `HAMMER_LIVE` ni los siete literales de hash.

De paso: `scripts/.hammer-banner.txt.kate-swp` era un swap de editor commiteado por error. Fuera.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-09 22:54:13 +00:00
Sergio a4e93565e8 ADR 0016: corregida la afirmacion sobre el baseline del selfhost
Se afirmo que la etapa 4 invalidaba el baseline of_tree porque el nombre del
crate va en los simbolos de hammerd. El razonamiento era correcto y la
conclusion no: recipes/hammerd.toml pinea su fuente por COMMIT, no por el arbol
de trabajo, asi que el renombre no la alcanza.

Medido: los cuatro componentes de Stage 1 tienen vigente == sellado
(musl ce952f72, busybox 2a2b1280, hammerd 48bbbe52, arje-zero b34c571b).
No hay nada que rehacer por causa del renombre.

Queda anotado aparte que el of_tree del stage1 sellado hoy (d152f060) no
coincide con ninguna de las dos constantes del script. No se re-ancla: sin
correr el verify in-VM seria poner una constante que nadie verifico, y esta
maquina no tiene KVM.
2026-09-09 20:29:54 +00:00
Sergio b393687d10 ADR 0016: worker migrado, y corregida una recaida del barrido
El worker vive en /opt/takana con takana-farm.service, verificado por un ciclo
de build real alla y una cosecha completa aca.

Y queda anotada la recaida: el barrido ad-hoc de este mismo cambio no respeto
el aviso que este fichero tiene arriba y dio vuelta cuatro afirmaciones
HISTORICAS, dejandolo diciendo que a las 18:40 se reinicio takana-farm.service
cuando en ese momento se llamaba hammer-farm.service. Corregidas a mano. La
exclusion hay que ponerla en CADA barrido, no una sola vez.
2026-09-09 20:21:53 +00:00
Sergio aacee245d6 takana: el worker pasa a /opt/takana y takana-farm.service
Renombrado el directorio del worker y la unit, con las tres units del repo
(farm, deadman service y timer). Todas las rutas /opt/hammer del hub pasan a
/opt/takana: eran defaults REMOTE= y HAMMER_DIR=, mas los systemctl/journalctl
que nombraban la unit sin el sufijo .service, que el primer sed no casaba.

Orden, para que no quedara partido: el hub siembra por rsync a una ruta fija,
asi que se saco el cron ANTES de mover. Si se movia el worker con el hub
apuntando a la ruta vieja, la siguiente cosecha recreaba /opt/hammer y quedaban
dos arboles.

Verificado de punta a punta:
- el worker cerro un ciclo de build REAL desde /opt/takana (dunst sellado, 1/1)
- una cosecha completa del hub contra la ruta nueva: siembra, manifiesto, los
  nueve grafos, static-audit (691 estaticos, MIENTEN 0) y estado pusheado
- la unit vieja quedo deshabilitada y borrada; /opt tiene una sola entrada

NO se tocan dos referencias a /opt/hammer que siguen siendo CORRECTAS:
docs/23-plan-rehasheo.md describe rutas EMBEBIDAS en artefactos ya construidos
—que literalmente dicen /opt/hammer/work en sus secciones .debug— y el HANDOFF
de la noche de KDE es registro. Cambiarlas haria que los documentos mientan.
2026-09-09 20:20:16 +00:00
Sergio f297d93b7e ADR 0016: mudanza del directorio hecha, con lo que no estaba en el plan
Los cuatro bind-mounts desde /mnt/cosecha hacia dentro del arbol eran lo que
hacia imposible un mv a secas, y no estaban previstos. Tampoco que ~/hammer
fuera un symlink que quedaba colgado.

Avisar al otro agente sirvio: hammer-9f aporto dos sitios con la ruta absoluta
fuera del repo que no estaban en el inventario.

Verificado por el ciclo real del cron de las 20:00 desde la ruta nueva, que
ademas pusheo al gitea renombrado. Token de renombre revocado.
2026-09-09 20:08:50 +00:00
Sergio 9bd95cd307 ADR 0016: estado de la etapa 6 y las 7 etiquetas que casi se rompen
La etapa 6 no es un barrido: son seis despliegues coordinados. Dos hechos
(--takana-bin, y la unit del worker fijando las dos variables, que era lo que
bloqueaba retirar las caidas), dos que conviene esperar, uno atado al baseline
del selfhost, y el directorio del repo que necesita decision porque hay otro
agente trabajando adentro ahora mismo.

Y queda anotada la tabla de las 7 etiquetas de separacion de dominio que el
barrido ancho habria reescrito, con hammer-tree-v1 a la cabeza: es el prefijo
de ArtifactHash::of_tree, o sea los 4750 hashes del store.
2026-09-09 19:44:46 +00:00
Sergio 6623f80909 ADR 0016: etapa 5 cerrada, con los dos guardianes que dispararon
5a recetas (1427 comentarios, cero hashes movidos y medido), 5b docs (59),
5c scripts (250, sin tocar una sola linea que no empiece por #).

Los dos guardianes que hicieron trabajo real: la linea base de hashes atrapo 3
recetas que se movian porque un comentario de SHELL dentro de una fase parece un
comentario de TOML; y el salteo de heredocs atrapo 3 MOTD que son texto del
producto, no comentarios.

Y queda anotado el bug que introdujo la etapa 4: atribuir-fallos.py casaba
contra el target de tracing, que es el module_path y por lo tanto el nombre del
crate. Quedo casando nada sin fallar.
2026-09-09 19:29:40 +00:00
Sergio 476168bb07 takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4
250 líneas de comentario en 151 scripts. Control verificado: el diff no toca
NI UNA línea que no empiece por #, y la sintaxis de los 151 pasa.

El barrido saltea heredocs y cadenas triples, y el guardián DISPARÓ 3 veces:
las tres eran el MOTD que el script escribe DENTRO de la imagen construida —
texto del producto, no comentario del script. Se cambiaron aparte y a
propósito, que es rebranding, no limpieza.

Y el hallazgo caro:  casaba contra ,
que es el TARGET de tracing — o sea el module_path!, o sea el nombre del crate.
La etapa 4 lo movió a  y el script quedó casando NADA. No fallaba:
imprimía cero atribuciones, indistinguible de un log sin problemas. Comprobado
con el binario (RUST_LOG=info sobre zlib), no deducido. Ahora acepta las dos, y
tiene que seguir aceptándolas porque los logs viejos en disco dicen la vieja.

Además 14 rutas de módulo  en docs, que el barrido anterior no tocó
porque  no es frontera de palabra.
2026-09-09 19:28:48 +00:00
Sergio 97ceb72411 takana etapa 5b: los 59 docs de diseño, runbooks y ADR
645 líneas. Los ADR entran porque en este repo SON documentos vivos, no
registros inmutables: el 0013 tiene 5 commits, el 0009 dos. Eso se comprobó
antes de decidir, no se asumió por convención general.

EXCLUIDOS por ser REGISTRO o generado: docs/evidencia/ (6), el HANDOFF de la
noche de KDE (1) y docs/state/ (24, se regenera solo). Reescribir un comando
dentro de una evidencia la falsifica.

Y el ADR 0016 se excluye de todo barrido, con un aviso adentro para el próximo
que barra: habla SOBRE el renombre, así que necesita seguir diciendo 'hammer'.
El barrido se lo llevó puesto y lo dejó titulado 'Renombre del sistema: takana
→ takana'; revertido.

Congelados, verificados uno por uno con controles: /opt/hammer, /var/lib/hammer,
/usr/bin/hammer, /mnt/vvv/hammer, la URL de gitea, hammer-farm.service,
hammer-live-install.sh, BRIEFING-hammer.md, hammerd, hammer-recover y
HAMMER_LIVE.
2026-09-09 19:25:51 +00:00
Sergio 24cb5d1f0f ADR 0016: cerrada la unidad de variables de entorno
Con lo que la medición cambió respecto del plan, los dos controles negativos
del helper, y la distinción que importa: la caída sirve al lector NUEVO; donde
el script exporta, hay que seguir poniendo la vieja porque el lector puede ser
un binario viejo.
2026-09-09 19:15:58 +00:00
Sergio a35f4dcf1a ADR 0016: etapa 4 (crates) y por qué las variables de entorno son unidad aparte
Los 10 crates de librería + CLI renombrados, con los dos binarios congelados y
su razón. Anotada la consecuencia real: renombrar hammer-core mueve los bytes
de hammerd igual, así que el baseline of_tree del selfhost hay que rehacerlo.

Y la medición que cambia el plan: 'la variable HAMMER=' no existe como cosa
única — son 14 variables, ~260 apariciones, y el BINARIO las lee (HAMMER_LAB,
HAMMER_ZIG, HAMMER_WORK, HAMMER_ROOTFS, HAMMER_MIRROR_KEY, HAMMER_LLM_*). Con
knobs de instalador entre ellas y el entorno del worker trayéndolas puestas
desde fuera del repo. Eso es contrato de usuario, no churn: pide leer las dos
con caída a la vieja, que es código, no sed.

Sumada la evidencia del worker: tras reiniciar el servicio compiló los dos
binarios y cerró un ciclo real (dunst sellado, 1/1).
2026-09-09 18:48:14 +00:00
Sergio d47cafa05d ADR 0016: etapa 3 cerrada, con la evidencia del ciclo de cron
Se commitea DESPUÉS de verificar, no antes: el ADR afirma que el cron corrió
limpio con los scripts migrados, y ese ciclo (18:30:00Z → 18:32:21Z) ya está
en el log — siembra ✓, manifiesto ✓, los 9 JSON del grafo regenerados por
build-state.py invocando takana, estado pusheado, cero errores.

Documenta además cómo converge el worker, que se midió en vez de suponerse:
/opt/hammer no es un clon git sino rsync, hammer-farm.service corre el loop
como servicio largo, y los dos estados intermedios (antes y después de
reiniciarlo) son coherentes porque la 3a puso los dos binarios en los cargo
build antes de tocar ninguna invocación.
2026-09-09 18:33:31 +00:00
Sergio 80319d9bab takana: etapa 2 del renombre + los dos puntos de la hoja de marca
Etapa 2 (ADR 0016): el binario canónico es `takana` y `hammer` se sigue
emitiendo. Son DOS [[bin]] al mismo main.rs, no un symlink: la siembra de la
granja excluye /target (un symlink del hub no existiría en el worker) y
`cargo clean` lo borraría. Ningún llamador tocado; los 124 siguen andando.

Adoptados los dos puntos de la hoja de marca que chocaban con contratos:

- `forja` como ALIAS de clap sobre `build`, no como reemplazo. El canónico
  sigue siendo el inglés, que es lo que usan scripts, cron y runbooks. Y se
  enmienda la regla 4 de CLAUDE.md en el mismo commit: cambiar el
  comportamiento dejando escrito el contrato viejo es lo peor de las dos
  opciones, porque el otro agente del repo aplica lo que lee.

- `.tkn` como extensión de paquete. Salió barato y por una razón medida: la
  extensión no es lógica sino salida — se escribe en UN solo lugar
  (main.rs:1800) y el descubrimiento va por índice, no por glob
  (PackageEntry.file, repo.rs:75). Los repos con entradas .swm siguen
  resolviendo y un repo mixto es válido; cero ficheros .swm versionados.
  Los tipos Swm/SwmBuild/swm_bridge no se tocan: son internos, van en la
  etapa 4.

287 tests en verde (hammer-cli + hammer-core), incluidos los que fabrican
repos con nombres .swm a mano — que son justamente la prueba de que la
compatibilidad hacia atrás se sostiene.
2026-09-09 18:22:15 +00:00
Sergio 7358a1054f marca: takana — branding + ADR 0016 del renombre
El sistema pasa de hammer a takana (martillo en quechua/aimara): traducción
literal, conserva la metáfora de forja y mantiene el registro agentivo del
resto de la familia (khipu/yupana/harkaq/qorpa/churay).

Medido antes de tocar nada: hash_inputs es una LISTA DE CAMPOS, no el fichero
crudo (recipe.rs:535) ⇒ los comentarios de las 692 recetas importadas se pueden
renombrar GRATIS, sin mover un solo ArtifactHash. Pero las fases SÍ entran al
hash: las 10 recetas con .hammer-zig-cc dentro de una fase se congelan, igual
que los 5 cargo_vendor_dir (que ni siquiera están en hash_inputs y aun así
pueden cambiar bytes).

El renombre va por etapas con alias; el directorio /mnt/vvv/hammer es lo último
porque el cron de la cosecha lo referencia por ruta absoluta y moverlo mata el
latido en silencio.

No se adoptan dos ejemplos de la hoja de marca: 'takana forja' (viola la regla 4,
los verbos van en inglés) y .tkn (el formato es .swm, 266 menciones).
2026-09-09 18:10:11 +00:00
SergioandClaude Opus 5 fdce080d96 qorpa D8: sniper sellado al store, con la marca que evita que la cifra mienta
D8 decía que sniper «entra al store por `file_drop`». Dos correcciones, y la
primera es de vocabulario: **`file_drop` en hammer es otra cosa** — una
operación de `hammer apply` que coloca un fichero en el sistema instalado
verificando su hash. No tenía nada que ver con sellar. Lo que sella es lo de
siempre, una receta. Queda escrito en el ADR: un término inventado que suena a
mecanismo existente manda a buscar el código donde no está.

`recipes/steam-runtime-sniper.toml` sella el árbol del runtime (11196 ficheros)
pineado por el sha256 que ya estaba verificado. Entra donde Arch y Ubuntu no
pueden por una propiedad, no por simpatía: **no muta** —nadie le instala nada
adentro— así que el mismo tarball da siempre el mismo árbol y sellarlo es una
afirmación verdadera.

**La marca: `foreign = true`.** No cambia el build en un byte y **no entra en
`hash_inputs`** (describe procedencia, no identidad — hay test). Lo que cambia
es contable: `build-state.py` la clasifica `ajeno`, la resta del denominador de
las imágenes y la deja fuera del recuento de recetas. Sin eso, sellar un
prebuilt habría subido la cifra que todo el mundo lee como «cuánto
construimos» — el riesgo que el ADR escribió antes de que existiera la primera
instancia. Verificado: sigue diciendo 821 recetas, y aparte
`de las ajenas, 1 selladas al store (prebuilt pineado, sin procedencia de fuente)`.

Y la diferencia con el otro ajeno: `xwayland` no se hashea (no hay receta, y un
hash afirmaría que lo reproducimos); el sellado **sí conserva su hash**, porque
está en el store y que un artefacto exista mientras el grafo lo niega sería otra
forma de mentir. Comparten el estado, que es lo que protege la cifra.

**`hammer qorpa import --from-store <hash>`** lo consume, y ahí está el detalle
que hace que valga: la imagen se registra bajo el **sha256 del archivo de
upstream**, no bajo el ArtifactHash. Al revés, la imagen del store y la traída
con `pull` serían dos imágenes distintas con los mismos bytes y las instancias
de dos máquinas dejarían de coincidir — justo lo que el pin existe para evitar.
El árbol se **enlaza**: una imagen nunca se escribe (lo que escribe la instancia
va a su `upper`), así que compartir inodos con un artefacto sellado y de sólo
lectura es correcto por construcción y la imagen cuesta ~0 bytes. La contracara
conocida de `.dmerge`: mientras el artefacto siga en el store, borrar la imagen
no libera disco; `--copy` lo evita.

Licencia `LicenseRef-qorpa-ajena-no-enumerable` a propósito: adentro hay cientos
de paquetes Debian y no podemos enumerarlos; vacío se leería como «todavía no la
poblamos». SDD 20 lo recoge y afila la distinción: replicarla a nuestras
máquinas es lo que ya hace ADR 0013 con las fuentes; publicarla a terceros sigue
pidiendo licencia y marca.

29 tests verdes. El sellado en sí corre aparte, esperando el lock de la granja.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q
2026-09-04 00:09:20 +00:00
SergioandClaude Opus 5 4a68dfe761 qorpa D9: el canal de evidencia, cableado — la única forma de auditar el montón B
De un binario ajeno no hay fuente que leer. Lo único observable es lo que el
kernel le NIEGA y anota, y hasta acá ese canal existía en el build pero ninguna
instancia lo abría. `hammer qorpa run <id> --evidence` levanta el lector
(`harkaq-audit`) en el HOST —el audit no está namespaceado— antes de que arranque
la instancia, y al terminar imprime uno de tres estados. Los tres, medidos:

  HERMÉTICO     0 denegaciones Y el canario las respalda
  IMPURO        `touch /usr/INTRUSO; mkdir /opt/INTRUSO` →
                  fs.make_reg · /usr     fs.make_dir · /opt
  SIN EVIDENCIA quitándole las capabilities al lector. NO es «limpio»

**El canario es lo que hace que «cero denegaciones» valga algo:** un fichero
donde la política no alcanza; al leerlo, el kernel emite una denegación que
revela el `domain=` de ESTE dominio Landlock, un número que desde fuera no se
adivina. Sin él, `denials=[]` sería el instrumento callado.

**Dos condiciones estructurales, y se FALLA en vez de dar un veredicto vacío:**
con `nesting` no hay Landlock (D9 conflicto 1) ⇒ o anidás o auditás; y sin
`seal_image` la política es `rw /` ⇒ no hay NADA denegable y el veredicto sería
limpio por construcción, no por mérito. No es un defecto de la implementación:
**la evidencia sólo existe donde algo puede ser negado.**

**Un bug del propio instrumento, que sólo salió usándolo:** sin CAP_AUDIT_READ
el kernel RESPONDE que no (`NLMSG_ERROR`/EPERM) y el lector ignoraba esa
respuesta esperando una que no iba a llegar — 8 s por consulta, 16 s en su
compuerta. Como `qorpa run` lo despierta al terminar, moría por señal dentro de
la compuerta **sin emitir nada**: un «no» tardío se parecía demasiado a un
cuelgue. Ahora atiende el NLMSG_ERROR y dice su motivo en 2 s. Y si aun así el
veredicto sale vacío, se reporta con el código de salida del lector, que es el
único dato que queda.

Guardián: `scripts/qorpa/evidence-probe.sh`, con las tres aserciones. La 2 es la
que sostiene a la 1 — sin algo que TIENE que salir sucio, «HERMÉTICO» lo cumple
igual un canal muerto. Comprueba también las capabilities del lector, que **se
pierden en cada recompilación** y son la forma más probable de que el canal
muera en silencio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q
2026-09-03 21:57:26 +00:00
SergioandClaude Opus 5 50d1dbcc31 qorpa D3: packages se instala solo — el manifiesto deja de ser un adorno
Hasta acá el ADR afirmaba «el manifiesto es la verdad, el upper es caché»
mientras `recreate` confesaba en su propia salida que «instalarlos todavía es a
mano». Con eso el `upper` SÍ era el activo: un blob irreemplazable, que es justo
lo que hammer existe para no tener.

`hammer qorpa provision <id>` instala lo declarado, y **`recreate` lo llama
solo** (`--no-provision` para saltarlo).

Cuatro decisiones, cada una con su porqué:

- **El gestor se DETECTA** en la vista merged (apt, pacman, dnf, apk), no se
  configura: cada imagen trae el suyo. Y no se multiplexa detrás de un comando
  único —el `pmm` de Bedrock que el ADR rechaza—: se elige cuál correr.
- **`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. En silencio sería lo que D7 prohíbe.
- **El registro vive FUERA del `upper`** (`provisioned.toml`): dentro se iría
  con la capa. Por eso `recreate` lo borra — 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 se validan y se comillan**: salen de un fichero que escribe una
  persona, así que `strace; rm -rf /` no llega al guión.

**Las dos manías que sólo salen provisionando de verdad:** el bootstrap de Arch
trae la mirrorlist ENTERA comentada (pacman muere con «no servers configured») y
el llavero sin inicializar (toda firma inválida). El guión pone el mirror geo
oficial avisando cuál, y hace `pacman-key --init && --populate` sólo si falta.
`apt` 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, pacman
sobre el bootstrap de Arch—: instalan, el binario corre después con la red
apagada, y un `recreate` tira la capa y la deja igual. 27 tests verdes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q
2026-09-03 21:00:10 +00:00
SergioandClaude Opus 5 74fec60281 qorpa §Orden 6: pressure-vessel ANIDA bajo la jaula — sin cuenta, sin juego y sin pantalla
El paso 6 quedaba parcial por esta frase: «pressure-vessel con un juego real
sigue sin ejercitarse», porque el runtime sniper sólo se baja al instalar un
juego y eso pide credenciales. Era un techo falso: el depot está publicado y
ahora pineado, así que se coloca a mano y la pregunta que ordenaba D6 se
responde entera.

**La evidencia**, dentro de una instancia qorpa sobre Ubuntu base, con `nesting`
y la jaula puesta:

  os-release   Ubuntu 24.04.3 LTS  →  Steam Runtime 3 (sniper)
  ns de montaje  mnt:[4026532468]  →  mnt:[4026532526]
  /usr/lib/x86_64-linux-gnu        →  675 libs de sniper, libSDL2 incluida

Contenedor de Valve anidado dentro del nuestro, con el runtime real adentro.

**Y la concesión resultó de verdad:** con `nesting = false` el mismo comando
muere en `bwrap: Creating new namespace failed: Operation not permitted`. Queda
como guardián (`scripts/qorpa/pressure-vessel-probe.sh`), que exige las DOS
mitades — sin la negativa, «no salió sniper» lo cumpliría también un cuelgue.

Tres cicatrices del camino, todas en los comentarios:

1. **`ldd --version` es la comprobación equivocada** y es la primera que uno
   escribe: pressure-vessel importa la libc del host cuando es más nueva que la
   del runtime, así que ver la glibc de afuera adentro es lo correcto y no
   prueba nada. El veredicto es `os-release`.
2. **`SALIDA=$(timeout … qorpa run …)` se cuelga para siempre** aunque timeout
   mate al hijo: la sustitución no espera al PROCESO, espera a que se cierre el
   PIPE, y pressure-vessel deja descendientes con el fd abierto. A fichero
   termina y devuelve su código.
3. **Dos `run` seguidos sobre la misma instancia fallaban** con `Can't make
   overlay mount … Device or resource busy`. El kernel niega dos overlays vivos
   con el mismo `upper` porque eso corrompe la capa — o sea que el EBUSY es un
   guardián correcto a destiempo: la corrida anterior ya devolvió el prompt y su
   namespace no terminó de reaparse. `run` reintenta acotado y, si sigue tomado,
   dice la causa en vez de soltar el mensaje crudo de bwrap.

El punto 3 salió porque el guardián exige evidencia POSITIVA de la denegación:
con «no apareció sniper» habría dado OK tapando un fallo distinto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q
2026-09-03 20:36:32 +00:00
SergioandClaude Opus 5 3f560de8af qorpa D8: el pin de sniper verificado, y el pin deja de ser una cita
Cierra el ⚠ que quedaba en la tabla de D8. Tres cosas, y la del medio es la que
más duele.

1. **Sniper pineado, y por la versión correcta.** No se pinea «la última» sino
   la que `latest-container-runtime-depot.txt` dice que despliega el cliente de
   Steam — 3.0.20260805.254768. Pinear otra sería pinear algo que nadie corre.
   Se pinean DOS artefactos: la imagen (rootfs, 302 MB, 11196 ficheros) y el
   depot `SteamLinuxRuntime_sniper.tar.xz` con pressure-vessel adentro. El
   segundo es el que destraba el paso 6: ese runtime sólo se baja al instalar un
   juego (credenciales), y pineado se coloca a mano ⇒ pressure-vessel se puede
   ejercitar sin cuenta, sin juego y sin pantalla.

2. **Los digests estaban en prosa y ABREVIADOS, y eso no es un pin.** Cuando la
   poda se llevó las imágenes, `895661bd…` no alcanzó para volver a traerlas:
   hubo que ir a buscar los sha256 otra vez a upstream. Ahora enteros y
   verificados en `docs/state/qorpa-imagenes.toml`, con de qué lista salieron y
   **si esa lista está firmada** — que no todas: Ubuntu firma su SHA256SUMS,
   Arch firma el tarball pero no la lista, y Valve no firma nada.

3. **`--retry` de curl no cubría el fallo que de verdad pasa.** Los 302 MB de
   sniper murieron al 73% con `HTTP/2 INTERNAL_ERROR` y curl NO reintentó: sin
   `--retry-all-errors` sólo considera transitorios los timeouts y los 5xx. Y
   aun reintentando, sin `-C -` cada intento vuelve a empezar de cero. Con las
   dos banderas el mismo pull sobrevivió dos cortes más y llegó. El parcial ya
   no se borra al fallar (es lo que permite reanudar); es seguro porque quien
   decide es el sha256 de después, y `prune` ya lo barre.

De paso, dos comprobaciones en vez de suposiciones: el rootfs de sniper viene en
`files/` con un hermano `metadata` y el anclaje por estructura lo elevó solo
(tercera forma real de empaquetado), y traer el depot como rootfs FALLA en vez
de adivinar («no encuentro un rootfs en el archivo»).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q
2026-09-03 19:56:29 +00:00
SergioandClaude Opus 5 ce76796b9b qorpa D11: el proxy de Wayland — el socket deja de ser un cheque en blanco
Paso 8 del ADR 0015, el que el propio ADR llamaba el más valioso, y cierra su
§NO-resuelve 1.

Pasar el socket de Wayland crudo NO es abrir un caño: es un borde de privilegio.
El compositor anuncia TODOS sus globals, y entre ellos hay protocolos que hacen
cosas que nadie concedió — screencopy captura la pantalla, virtual_keyboard
sintetiza teclas, data_control lee el portapapeles sin que nadie copie, y
layer_shell dibuja encima de todo. Una instancia ajena con el socket crudo podía
las cuatro, y lo único que teníamos era un aviso.

El proxy relaya todo salvo dos mensajes:
- `wl_registry.global` del compositor: si el interface no está en la lista, no se
  reenvía y el cliente nunca se entera de que existe.
- `wl_registry.bind` del cliente: si el `name` es uno de los ocultos, se manda un
  wl_display.error y se corta. Sin esto el filtro sería decorativo — el `name` es
  un número y se puede adivinar.

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 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 responde la pregunta
entera. Un test más cubre el `size` menor que la cabecera, que haría avanzar 0
bytes y colgar el proxy en un bucle.

El socket crudo sigue disponible con `wayland_raw`, apagado por defecto y
avisando a gritos. Y si no hay compositor, `run` FALLA en vez de degradar.

Las features `socket`/`uio` de nix son aditivas: no cambian lo que compilan los
demás crates. 54/54.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
2026-09-03 16:24:05 +00:00
SergioandClaude Opus 5 d77ad15d01 qorpa prune: la poda nace con el subsistema, no después
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
2026-09-03 12:18:10 +00:00
SergioandClaude Opus 5 1d9ddcee37 qorpa D10: Steam en la mano, y los tres muros que sólo se ven así
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
2026-09-03 12:01:32 +00:00
SergioandClaude Opus 5 f64859bade qorpa export: shims generados, y la clase ajeno para que nadie los cuente mal
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
2026-09-03 06:15:19 +00:00
SergioandClaude Opus 5 89da9c822f qorpa: el mapeo por rango — subuid + --userns FD, y el impuesto se paga
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
2026-09-03 05:27:03 +00:00
SergioandClaude Opus 5 5b82474079 qorpa D9: harkaq enjaula la instancia, y midiendo salieron dos conflictos
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
2026-09-03 05:02:56 +00:00
SergioandClaude Opus 5 ba09fdb403 qorpa create/recreate/run: la instancia nace sin ver nada
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
2026-09-03 04:38:25 +00:00
SergioandClaude Opus 5 fe155cf20d qorpa pull/list: la imagen ajena entra verificada, o no entra
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
2026-09-03 04:02:46 +00:00
SergioandClaude Opus 5 d8778e4dd9 qorpa D8: el pin fija el suelo, no la instancia; y la terna curada
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
2026-09-03 03:39:36 +00:00
SergioandClaude Opus 5 aa5bb67fd7 qorpa: nombre adoptado y el preflight que mide si una máquina puede alojar
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
2026-09-03 03:05:12 +00:00
Sergio 776692d01a ADR 0015 (propuesto): imágenes ajenas — el mundo glibc entra enjaulado y no entra al store
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.
2026-09-03 02:49:40 +00:00
SergioandClaude Opus 5 8b2ff3cfd4 digest: blake3 pelado, no length-prefijado — el mismo archivo tenía dos hex
Lo destapó escribir la contraparte del ADR 0014 para churay: churay direcciona sus blobs
con blake3(bytes) pelado y hammer iba a sellar el digest con of_inputs(&[bytes]), los dos
bajo el prefijo `b3:`. El mismo archivo con dos hex distintos, y un CAS compartido que no
falla ruidosamente: cada lado busca un nombre distinto para los mismos bytes.

Con una sola entrada el length-prefijado no desambigua ninguna concatenación — sólo hace
que el nombre deje de ser verificable por un tercero con b3sum en la mano.

`ArtifactHash::of_bytes` YA EXISTÍA y ya era blake3 pelado: es la convención de of_file y
la del expected_hash de un .swm, así que of_inputs era además la pieza fuera de sitio
dentro del propio repo. of_inputs se queda para lo que fue escrito: hashear una LISTA.

Coste: una línea, porque ningún índice publicado lleva todavía el campo. Es el argumento
del ADR aplicado a sí mismo — decidir antes de que haya usuarios. La corrección queda en
el ADR, no reescrita en silencio.

Guardián: digest_es_blake3_pelado_y_no_length_prefijado, con vector fijo de b3sum y un
assert_ne contra of_inputs que nombra la consecuencia.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CK6HpSoHcN9M4GBpqRSusR
2026-09-01 19:10:07 +00:00
SergioandClaude Opus 5 48b3c5be3f ADR 0014: distribución multi-origen — el origen no necesita confianza
Se decide ahora, antes de tener usuarios, porque toca el formato del índice y el
comportamiento de clientes instalados en máquinas ajenas: después de la primera descarga
pública el cambio rompe a alguien o se arrastran dos formatos para siempre.

La decisión entera cuelga de una asimetría medida: ~51 G de cuerpo inmutable y verificable
por contenido, contra KB de raíz mutable que es lo único que necesita ser fresco y
auténtico. El cuerpo puede servirse desde cualquier host, incluso hostil ⇒ conseguir
espejos deja de ser un problema de infraestructura.

Cloudflare R2 como primer origen público por el egress a 0, pero es INSTANCIA, no
arquitectura: lo que se decide es que R2 sea una entrada más de una lista. No depender de un
servidorcito no se logra contratando un proveedor grande, sino pudiendo perder cualquiera
sin enterarse. Regla derivada: nunca menos de dos orígenes en dos proveedores.

Verificado de punta a punta con dos orígenes HTTP y un .swm con UN byte cambiado bajo el
mismo nombre: origen muerto ⇒ failover; origen manipulado ⇒ aborta y NO cae al bueno de
detrás. Sin ese segundo caso la lista sería un mecanismo para tapar espejos mentirosos.

Queda pendiente y anotado: el vigía sólo mira upstream, no nuestros orígenes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CK6HpSoHcN9M4GBpqRSusR
2026-09-01 18:45:10 +00:00
SergioandClaude Opus 5 ce69ea23d4 ADR 0013: el pin de una receta no siempre es un commit
Documenta los tags anotados de diffutils/findutils-xargs/kustomize y por qué rompían las dos puntas
del mirror, y corrige la pregunta abierta: ningún servidor ha negado aún el fetch por sha suelto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o
2026-08-26 21:13:59 +00:00
SergioandClaude Opus 5 276efdeced mirror git: el poblador reporta el error real de git, no una conjetura
Imprimía siempre «upstream no da el commit» pasara lo que pasara. En la primera tanda marcó así a
diffutils, findutils-xargs y kustomize, cuyos commits se traen a mano sin problema: era transitorio.
Ahora bundle() devuelve (ruta, motivo) con el stderr del paso que falló.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o
2026-08-26 21:07:10 +00:00
SergioandClaude Opus 5 8ab5040a8d ADR 0013: mirror de fuentes git — bundles shallow por commit
Los 606 repos por commit son el 52% de las fuentes y el mirror de tarballs no los cubría. Se espejan
como `hammer/fuentes-git/{commit}.bundle` — 571 commits distintos, porque hay commits compartidos
entre colas y se espeja uno solo.

La identidad es el commit y la verificación la hace git: al desempaquetar comprueba cada objeto
contra su SHA, así que un bundle alterado no pasa. No hace falta índice ni sha256 aparte.

SHALLOW, NO CLONES COMPLETOS. El bundle sale de un `fetch --depth 1` del commit exacto: para `act`
son 9,3 MB en vez del repo entero, y con 571 fuentes eso decide si el mirror cabe. Es legítimo
porque hammer NUNCA usa la historia — lo único que hace con un repo es `git archive <commit> |
tar -x`, materializar un árbol.

EL DETALLE QUE COSTÓ ENCONTRAR. Un bundle hecho desde un repo shallow no lleva la frontera de
historia, y al desempaquetarlo git aborta con «Failed to traverse parents … did not send all
necessary objects». El mensaje dice que faltan objetos y es ENGAÑOSO: llegan enteros —`git archive`
ya funciona pese al error—; lo que falta es decirle a git dónde termina la historia. Se escribe el
propio commit en `<destino>/shallow` antes del fetch. No hay nada que transportar: la frontera de un
`--depth 1` es exactamente ese commit.

Y UN BUG PROPIO QUE VALE DOCUMENTAR: se pasaba al `git fetch` la ruta RELATIVA del bundle, y como
`run_git` invoca `git -C <destino>`, git la resolvía dentro de `<destino>`. Como el fallo del mirror
se traga a propósito para caer a upstream, el síntoma salía lejísimos: el build moría con «commit …
no existe en <repo> tras fetch», culpando a upstream de un error de ruta local. Ése es el precio de
que el mirror falle en silencio, y por eso el silencio se paga con comentarios explícitos.

Verificado igual que el de tarballs, con receta EFÍMERA para que no haya cache-hit: commit de `act`
ya espejado + un repo cuyo host no resuelve por DNS. Sin HAMMER_MIRROR_GIT falla en el clone; con
él, sella — y el artefacto trae el README.md real de act.

`cargo test -p hammer-build`: 5/5. Población de los 571 bundles corriendo aparte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o
2026-08-26 19:25:28 +00:00
SergioandClaude Opus 5 45b95f78b9 ADR 0013: mirror de fuentes — la URL es transporte, el sha256 es la identidad
`rsync` (404 de samba.org) y `musl` (musl.libc.org no responde) no se pueden construir hoy, y no por
culpa nuestra. Es el estado estacionario: una distro que construye TODO desde fuente tiene tantos
puntos de fallo como fuentes, y son servidores de terceros que nadie nos prometió mantener.

LA MEDIDA, peor de lo que parecía. Sobre 1167 fuentes (561 tarball + 606 git, 79 hosts):
github.com sostiene 742 — el 64% del corpus depende de UN host. Doce hosts sostienen el 89%. Y 43
hosts sostienen exactamente UNA receta cada uno: ahí es donde muerde el bit-rot lento.

El vigía, en su primera corrida: 8 URLs muertas de 1167. Tres de ellas —busybox, freetype,
freetype-shared— están SELLADAS Y EN USO: son el shell y las fuentes del escritorio que se capturó
hoy. Se salvan sólo porque el tarball sigue en la caché local de esta máquina.

LA URL NUNCA FUE LA IDENTIDAD, y el código ya lo sabía: `hash_inputs` usa `tarball:{sha256}` /
`git:{commit}` y el `..` descarta la URL; la caché se nombra `{sha256}.tar` con un comentario que
dice literalmente que cambiar de mirror no la invalida. Añadir mirrors NO re-hashea NADA. Faltaba el
mecanismo, no el diseño.

Orden: caché local → mirror propio → upstream. El mirror va ANTES, no como rescate: el sha256 se
verifica igual, así que no hay diferencia de contenido posible, y un mirror que sólo se usa cuando
upstream falla es un mirror que nadie prueba — se descubre roto el día que hace falta.

LO QUE HAY QUE HACER BIEN. Un mirror que sirve calladamente lo que upstream perdió convierte un
fallo ruidoso en silencio. Por eso construir y vigilar van SEPARADOS: `hammer build` nunca avisa
(sería ruido en 561 recetas), y `fuentes-vigia.sh` pide cabeceras, escribe
docs/state/fuentes-vigia.json y lo corre el latido. Sin ese contrapeso las URLs se mueren una a una
y el corpus queda irreconstruible con todo en verde — el mismo modo de fallo que dejó el grafo de
wlr 17 días anunciando un 121/121 falso.

 PROHIBIDO cambiar el sha256 para "arreglar" una URL muerta. Es la tentación natural ante un 404 y
no arregla una descarga: cambia lo que la distro construye. Otro sha256 es otro contenido, y la
receta seguiría diciendo `rsync 3.4.4` mientras construye otra cosa.

VERIFICADO DE PUNTA A PUNTA. El primer intento —construir busybox con el mirror puesto— dijo BUILD
OK y NO PROBÓ NADA: cache-hit del artefacto, cero bytes descargados. La prueba válida usa una receta
efímera con un sha256 que sí está en el mirror y una URL que ni resuelve por DNS. Sin HAMMER_MIRROR:
`curl (6) Could not resolve host`. Con él: sella, con el contenido real.

Mirror poblado: 126 objetos en el Storage Box que ya se paga. `cargo test -p hammer-build`: 5/5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o
2026-08-26 18:57:40 +00:00