Commit Graph
100 Commits
Author SHA1 Message Date
Sergio 54bf3e810e ia: el modelo de chat, pineado — Qwen2.5-1.5B-Instruct Q4_K_M (Apache-2.0)
Elegido por tres cosas, y las tres medidas antes de pinearlo: licencia Apache-2.0 (lo que una distro
puede shipear sin letra chica, al revés que Llama-3.2 o Gemma), habla español —se le preguntó qué es
una distribución de GNU/Linux y contestó dos frases correctas— y corre en CPU: 20,1 tokens/s en el
hub sin GPU, 1,04 GiB.

El objeto pineado es un tar que envuelve el .gguf, publicado en el mirror y servido por sha256:
takana extrae toda fuente con `tar` y un GGUF pelado no lo es. Es el camino de firefox-pgo-profile.
La receta anota el sha256 del GGUF DE UPSTREAM (el lfs.oid de HuggingFace, verificado al bajarlo) y
no sólo el del tar nuestro, para que nadie tenga que confiar en nuestro tar. Round-trip verificado:
apartados el caché y el artefacto, el build lo bajó del mirror y selló el mismo ArtifactHash.

Guardián en la receta, porque el fallo es callado: un fichero truncado o un HTML de error renombrado
a .gguf se instala igual y sella en verde. Se comprueban el mágico GGUF y el tamaño.

⚠ Y el modelo de verdad destapó una CARRERA en el guardián del §6.7: el censo de motores contaba en
el instante del cierre — con el de juguete daba 0 y con el de la imagen daba 1, que se lee como fuga
cuando en realidad matar un proceso con un giga mapeado tarda ~1 s. Ahora espera hasta 15 s y anota
cuánto tardó. La rotura a propósito sigue fallando, ahora con el tiempo a la vista.

Falta decidir en qué imágenes se declara (con el motor son ~1,25 GiB por perfil) y pinear el de
embeddings (multilingual-e5-small) para la mitad semántica del §6.3.
2026-09-11 22:41:46 +00:00
Sergio 3038195ea7 estado: cosecha granja 2026-09-11T22:31:56Z — avance del árbol KDE 2026-09-11 22:31:56 +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 236abe48f3 boot entry reconcile: converger con la NVRAM en CADA arranque (ADR 0018 §2)
La NVRAM es estado compartido y el vecino la reescribe sin coordinarse.
Contra eso no sirve confiar: sirve converger. `reconcile` descubre su
propia ESP, repone la entrada y el BootOrder, y es idempotente.

Descubre la ESP por TIPO de partición (GUID de ESP en GPT, 0xEF en MBR),
que es el único dato fiable sin montar nada — un reconciliador que monta
sistemas de ficheros en el arranque es un efecto secundario que no
queremos. Si hay VARIAS ESP no adivina: las lista y pide --disk. Elegir
mal significa escribir una entrada que apunta a un disco que puede no
estar, y eso es peor que no escribir nada.

No rompe el arranque por nada: sin firmware EFI (máquina por BIOS) lo
dice y sale 0.

Y cuando actúa, GRITA — va a /dev/tty0 además del serial, a diferencia
del menú de arranque. Un reconciliador que repara en silencio deja al
usuario conviviendo con una rareza intermitente que no entiende; el
mensaje dice explícitamente que si se repite en cada arranque es que
otro sistema operativo le está reescribiendo la NVRAM.

Enganchado al wrapper de PID1 de las dos imágenes, con el mismo timeout
y el mismo || true que el menú: corre ANTES del exec de arje-zero y
colgarse ahí es un arranque muerto e indistinguible de un kernel colgado.

El test que vale es el escenario completo: takana se instala, llega el
vecino y se pone primero, y el siguiente arranque lo repone — SIN borrar
la entrada del vecino (takana se pone primera, no lo echa) — y el
arranque siguiente ya no escribe nada. Más el GUID de ESP, que va en
orden DE DISCO y no en el legible: escribirlo "como se lee" es el error
clásico y no casaría con ninguna ESP real.

15 tests en el módulo, 74/74 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:18:16 +00:00
Sergio 733065b1c4 qdrant: CONSTRUYE y ARRANCA — pero el artefacto venía con 69 M de basura, y la causa es del lab
Selló en el worker con el arreglo del `compiler = "gcc"`. Verificado allá, corriéndolo y no por el
código de salida: binario estático de 70 M sin NEEDED, `qdrant --version` → `qdrant 1.19.1`, y
levantándolo de verdad:

    Qdrant HTTP listening on 6399
    Qdrant gRPC listening on 6334
    Access web UI at http://localhost:6399/dashboard

⚠ **Y al mirar el ÁRBOL del artefacto antes de promoverlo, pesaba 139 M — la mitad, basura.** 140
ficheros bajo `src/target/release/build/protobuf-src-*/out/install/include/google/protobuf/…`: las
cabeceras y libs del protobuf que `protobuf-src` compila para su uso interno.

**La causa no es de esta receta, y por eso vale escribirla.** El sandbox exporta **`DESTDIR=/out` de
forma GLOBAL** (`sandbox.rs`), para que el `make install` de las recetas autotools funcione.
`protobuf-src` hace su propio `make install` DENTRO de la fase compile, con
`--prefix=/src/target/release/build/…/out/install`, y ese install anidado **hereda el DESTDIR** ⇒ su
prefijo aterriza en `/out/src/target/…`. Nadie lo pidió, nada falla, y el artefacto sella con el
doble de tamaño y un `/src` en la raíz que al hidratar se proyectaría sobre el FHS de la imagen.

Le puede pasar a cualquier receta cuyo build ejecute un `make install` anidado — los crates `*-src`
son la familia entera. Se limpia en la receta y NO en el lab: quitar el `DESTDIR` global cambiaría el
comportamiento de todas las recetas autotools del corpus, que es una campaña con su propia
verificación. `/out/src` nunca es salida legítima — la raíz del artefacto es un FHS y `/src` es el
nombre del bind del lab.

Queda en `incoming/` hasta que el worker selle la versión limpia; promover a `recipes/` con el
artefacto sucio sería meter los 69 M al grafo.
2026-09-11 21:36:20 +00:00
Sergio 3064798fe5 estado: cosecha granja 2026-09-11T21:31:43Z — avance del árbol KDE 2026-09-11 21:31:43 +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 ba5754551f takana boot entry: la entrada NVRAM, sin efibootmgr — con el muro medido
ADR 0018 §1. El ADR dejaba dos vías y se intentó primero la ajena:
efivar 38 con musl/zig-cc choca con secure_getenv (no existe en musl),
sys/cdefs.h (tampoco) y -Wl,--add-needed (lld lo rechaza) — tres muros
sólo para compilar su generador de tablas, y arrastraría popt al sistema
instalado. Es el patrón "pantano de parches de distro".

Lo que efibootmgr hace en el fondo es escribir DOS ficheros, Boot#### y
BootOrder, con una estructura de la spec UEFI estable desde 2.0. Hacerlo
acá es menos código que mantener los parches, no agrega dependencias al
sistema instalado y el binario takana ya está en el disco.

crates/takana-cli/src/efi_boot.rs — EFI_LOAD_OPTION + device path
(HARD_DRIVE/FILE_PATH/END), lectura y escritura de efivarfs, y un lector
de tabla de particiones que detecta GPT o MBR solo. Los dos casos hacen
falta: install-image-efi.sh fabrica GPT y takana-live-install.sh instala
sobre MBR.

`takana boot entry {list,add}` (CLI en inglés, regla 4). `add` es
IDEMPOTENTE a propósito: está pensado para correr en CADA arranque como
reconciliador (§2), y la NVRAM tiene un número finito de escrituras, así
que si ya dice exactamente eso no se reescribe.

parse_load_option existe para poder COMPROBAR al constructor: un device
path mal formado no da error, el firmware ignora la entrada en silencio
y el usuario ve "no arranca" sin una sola pista.

10 tests, con los negativos que son los que valen: un disco sin 0x55AA
falla en vez de devolver ceros, una entrada GPT vacía no se convierte en
partición de tamaño 0, y un FilePathListLength que miente es error y no
un truncado silencioso. 69/69 del CLI en verde.

Verificado además contra una tabla GPT REAL de sfdisk: inicio LBA 2048 y
131072 sectores, los mismos números que reporta sfdisk -l.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj
2026-09-11 21:26:46 +00:00
Sergio 2ecce583e5 crun: un contenedor ARRANCÓ de verdad — la hoja de la última familia vacía
`crun` 1.29.1, y la prueba no es `--version`: con el `busybox` del propio corpus como rootfs y un
bundle OCI mínimo, rootless,

    $ crun run prueba-takana
    HOLA-DESDE-EL-CONTENEDOR
    Linux
    0
    93

Entra como HOJA a propósito de la familia «contenedores», la última que la tabla de `planear.py`
daba vacía. Un runtime OCI es lo que cualquiera de los tres candidatos (docker, podman, containerd)
acaba ejecutando por debajo, así que esto **no presupone cuál se elige arriba** — esa decisión sigue
abierta y no la toma una receta.

crun y no runc: C en vez de Go, ~300 KB contra ~10 MB, y es el runtime por defecto de Alpine ⇒ musl
es objetivo probado. No son excluyentes.

Tres deps que NO se deducen del proyecto, y las tres abortaban el `configure`:

· `--disable-systemd` no es preferencia: `AC_CHECK_HEADERS([systemd/sd-bus.h], [], [AC_MSG_ERROR…])`
  lo hace OBLIGATORIO salvo que se apague. Esta distro no lleva systemd ⇒ el cgroup manager será
  `cgroupfs`. Coherente con el resto del sistema, y mejor decidido acá que descubierto después.
· **`argp-standalone`**: crun parsea flags con `argp_parse(3)`, extensión de glibc que musl no tiene.
  La receta YA estaba en el corpus y sellada — sólo faltaba que alguien la pidiera.
· **`python3`** aunque crun sea C puro: su `AM_PATH_PYTHON` no está marcado opcional. Misma figura
  que meson.

**Y el vigía de enlace estático se ganó el sueldo.** El primer sellado pasó todo —corría, 0 deuda—
pero `static-audit.sh` cantó: `✗ crun dice static, es DINÁMICO → libc.so`. crun enlaza con libtool,
que lee el `-static` del lab como «preferí los `.a` de libtool» y no como flag al linker. El arreglo
es `LDFLAGS="-all-static -no-pie"` en compile **y en install** — libtool RELINKEA al instalar, así
que sólo en compile deja bueno el árbol de build y dinámico lo que se sella. Ahora: 0 NEEDED, y
`+CAP +SECCOMP +EBPF +JSON_C` intactos.

libcap y libseccomp se declaran y NO se apagan, que es la decisión contraria a los `--disable-*` de
arriba y es deliberada: son las dos piezas con las que un runtime OCI acota de verdad al contenedor.
Un crun sin ellas compila igual, aísla mucho menos, y el binario no lo diría.

**Promoción**: `libseccomp` pasa de `recipes/incoming-gnome/` al corpus, porque ya tiene dos
consumidores independientes (el stack de GNOME y crun) y no hay variante homónima con la que colisionar.
Control en los dos sentidos antes de moverla: su hash y los de `gnome-shell`/`mutter` son idénticos
antes y después, y coinciden con los que el grafo ya tenía registrados ⇒ **cero re-hasheo**. La
resolución sibling→padre de `resolve_dep_path` hace que los consumidores de la cola la sigan viendo.
2026-09-11 21:23:57 +00:00
SergioandClaude Opus 5 73f75d45ed censar: sonda DNS-only (--probe-dns) — y los dominios fósiles vuelven a la lista de decisiones
Censo real de gioser (204.168.193.248, identificado por IP y no por hostname, que dice «momento»):
19 vivos, 18 NO-DECLARADOS, 85 declarados-muertos, 29 dominios.

**El HTTP toca al origen; el DNS no.** Sondear los dominios desde la propia máquina disparó su
fail2ban y la dejó incomunicada, así que el censo en local los salteaba — y quedaban 29 de 66 ítems
SIN recomendación por una precaución correcta aplicada de más. Lo que dispara la jaula es el HTTP:
`getent` le pregunta al DNS, no al servidor. `--probe-dns` sondea sólo el nombre, sin UNA SOLA
petición a gioser, y contesta la pregunta que más pesa en una mudanza: cuáles siguen apuntando acá y
cuáles son fósiles. 29/29 clasificados: 12 apuntan acá, 4 ya se mudaron, 13 NO RESUELVEN.

Lo que el DNS solo no puede decir es si el backend contesta, así que ésos quedan en `apunta-aca`,
clase propia y NO `vivo`: decir «vivo» sin haber pedido una página sería la respuesta falsa con forma
de respuesta que este censo existe para evitar.

**Y un hueco de verdad: los `fosil-sin-dns` quedaban fuera de `entradas()`**, con el argumento de que
un dominio sin DNS no necesita ningún paso. Cierto para el DNS, FALSO para lo que arrastra: son 13 de
29, y `terapeuta.ec` tenía 279 M de contenido y su bloque en el servidor web. Fuera de la lista eran
invisibles, así que nadie decidía borrarlos y sus datos viajaban a la caja nueva por omisión — el
«mudar fósiles» que este plan existe para evitar, y contra su propia regla 2 («lo que muere se dice
por su nombre y con su tamaño, ANTES de borrar nada»). Ahora entran, con recomendación `muere` y el
aviso de revisar qué dejaron en disco.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 21:21:03 +00:00
Sergio 21e7cc7159 estado: cosecha granja 2026-09-11T21:01:56Z — avance del árbol KDE 2026-09-11 21:01:56 +00:00
Sergio 11ea3d66e4 estado: cosecha granja 2026-09-11T20:50:23Z — avance del árbol KDE 2026-09-11 20:50:23 +00:00
SergioandClaude Opus 5 3aab82d6f6 planear: el centro de traducción ENGANCHADO al plan — y el plan que emitíamos no era TOML válido
El plan ya advertía «cambiar de servidor web obliga a REESCRIBIR la configuración entera». Cierto, y
la advertencia correcta, pero dejaba al humano con un párrafo y ninguna herramienta. Ahora es un PASO
con su comando literal: `traducir.py --from openrc --to arje --in /etc/init.d/caddy …`.

Dos traducciones distintas por servicio, y confundirlas es caro: la DECLARACIÓN (cómo se levanta,
`openrc`/`systemd` → tarjeta de arje) y la CONFIGURACIÓN (qué hace, sólo si se eligió un
equivalente). Un servicio que se muda a sí mismo no necesita la segunda: ofrecérsela es inventarle
trabajo. Los pares se le PREGUNTAN al registro de plugins, no se listan acá — una lista propia se
desincroniza del centro y el plan ofrecería una traducción que no existe. Sin par, el paso lo dice.

La verificación es HUMANA a propósito: `traducir.py` sale ≠0 cuando algo quedó sin traducir, así que
dar el paso por bueno por su código de salida sería al revés de lo que hay que mirar. Lo que verifica
es que alguien LEYÓ el acta.

**Y en el camino salió un fallo del producto: el plan no era TOML válido.** Apareció con el primer
comando multilínea, pero estaba latente desde el principio y tiene DOS modos:

  · `awk "\$1==1"` —que está en el `verifica` de TODO servicio— con cadena básica da
    `Unescaped '\' in a string`: el plan queda ILEGIBLE.
  · `cmd --from x \` + salto: la cadena básica PARSEA y se come el salto y la sangría ⇒ el comando
    que sale NO es el que se escribió, sin que nada falle. Ése es el peor.

El plan es el producto: se revisa, se versiona, se lleva a otro proveedor y se vuelve a correr. Uno
que no vuelve a parsear no sirve para ninguna de las dos cosas para las que existe. Arreglado con
cadena literal, y con un guardián que ESCRIBE Y RELEE antes de tocar el disco: si el TOML generado no
parsea, no se escribe nada. Convierte un fallo diferido —aparecía cuando alguien iba a EJECUTAR el
plan— en uno inmediato.

Probado de punta a punta: el comando que el plan emite, copiado tal cual, traduce el
`/etc/init.d/caddy` real de esta máquina a una tarjeta con `Restart{initial:3000}` (de su
`respawn_delay=3`) y reporta la única `reload()` que no se puede portar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 20:49:52 +00:00
Sergio 07cc386580 qdrant: murió a los 408 crates por el NOMBRE del wrapper del lab, no por qdrant
Primer intento en el worker: 1,5 h, 408 crates compilados, y esto:

    "/src/vendor/protobuf-src/protobuf/configure" … "--host=/src/.hammer-zig"
    Invalid configuration `/src/.hammer-zig': machine `/src/.hammer-unknown' not recognized

La cadena es qdrant → `raft-proto` (tikv/raft-rs) → `protobuf-build` → **`protobuf-src`**, que
compila protobuf 21.5 DESDE FUENTE con el crate `autotools` — o sea que ni siquiera usa el `protoc`
que acabo de meter en el catálogo. Y `autotools` adivina el triple `--host` **recortándole el sufijo
al nombre del compilador**. Leído en `vendor/autotools/src/lib.rs` del árbol de post-mortem, no
supuesto:

    let host = cc_path.strip_suffix("-cc").or_else(|| cc_path.strip_suffix("-gcc"));
    if let Some(host) = host { args.push(format!("--host={}", host)); }

El lab exporta `CC="$PWD/.hammer-zig-cc"` ⇒ recortar `-cc` deja `/src/.hammer-zig`, que viaja como
triple. **El fallo no tiene nada que ver con qdrant ni con protobuf: lo causa cómo se llama nuestro
wrapper.** Cualquier receta Cargo que arrastre el crate `autotools` va a chocar igual.

Con `compiler = "gcc"` el lab exporta `CC="gcc"`, al que no se le puede recortar `-cc` ni `-gcc` ⇒ el
crate **no añade `--host`** y configure corre nativo. El propio crate ya tiene un caso especial
`cc_path != "musl-gcc"`, señal de que la heurística es frágil y upstream lo sabe.

⚠ **Y NO se arregla renombrando el wrapper del lab, aunque sea lo obvio:** ese nombre vive dentro de
la cadena de la fase `compile`, que entra en `hash_inputs` ⇒ tocarlo re-hashea las **234 recetas Rust
del corpus**. Es una campaña con su propia verificación, no un arreglo de paso. La palanca por receta
es la correcta, y queda escrito en la receta para que el próximo que lo vea no reabra la discusión.
2026-09-11 20:46:05 +00:00
Sergio f52205af31 opensmtpd: la familia «correo» estaba vacía — y la colisión de símbolos se CONTÓ antes de arreglarla
Tercera de las cuatro familias que la tabla de `planear.py` daba SIN NADA en el catálogo. Quedan dos:
contenedores y base vectorial (qdrant está moliendo en el worker).

Cuál de los tres servidores es una decisión, y va con el motivo para poder discutirla: **postfix**
asume glibc en varios sitios (NIS, nsswitch, su `dict_nis`) y son ~250 kLOC — portarlo a musl es un
frente, no una receta; **exim** tiene una configuración que es literalmente un lenguaje de
programación, y esa superficie ha sido su fuente histórica de CVEs; **OpenSMTPD** viene de OpenBSD
con separación de privilegios POR DISEÑO, ~40 kLOC, ISC, y su rama «portable» existe justo para
construirse fuera de OpenBSD ⇒ musl es un objetivo previsto. Si alguien necesita las tablas
MySQL/LDAP de postfix esto no sirve — pero se discute con la razón delante en vez de descubrir la
ausencia durante una mudanza.

Verificado: los 10 binarios estáticos, CERO NEEDED, y `smtpd -n -f` sobre una `smtpd.conf` real
contesta `configuration OK`. REPRODUCE bit a bit.

**El muro fue una colisión de símbolos, y lo que importa es que se MIDIÓ antes de elegir el arreglo.**
OpenSMTPD-portable trae su propia **libtls** (la envoltura de LibreSSL) porque la necesita cuando se
construye contra OpenSSL; y OpenSSL 3 define en su capa de récords una función INTERNA con el mismo
nombre. Estático, las dos caen en el mismo binario:

    ld.lld: error: duplicate symbol: tls_free
      defined at ../../openbsd-compat/libtls/tls.c:708
      defined at ssl/record/methods/tls_common.c:1473  in archive /usr/lib/libssl.a

En vez de suponer el tamaño del problema, se contó:

    nm -g --defined-only openbsd-compat/libtls/*.o | sort -u   →  158 símbolos
    comm -12 <esos> <los globales de libssl.a>                 →  **1**:  tls_free

Uno solo ⇒ el arreglo es renombrar ése y nada más, con `--with-cppflags=-Dtls_free=…` (la perilla que
upstream ya expone). El `-D` alcanza toda la compilación de OpenSMTPD, así que renombra a la vez la
definición, la declaración de `tls.h` y los usos — consistente por construcción; la de OpenSSL vive
en un `.a` ya compilado y no se toca. Es seguro porque esa libtls es interna a este build.

Si hubieran sido docenas de símbolos, el arreglo correcto era otro —traer LibreSSL al corpus, que es
contra lo que upstream construye— y por eso se midió primero. Queda escrito en la receta para que el
día que OpenSSL sume otro choque se vuelva a contar en vez de apilar `-D`s.

Dos cosas más anotadas y no tapadas: `--with-libfts=/usr` es obligatorio porque `fts(3)` está en
glibc y NO en musl (el corpus tiene `musl-fts` justo para eso) y sin pasarlo `configure` lo buscaría
en el LAB, que no entra en `hash_inputs`; y los usuarios `_smtpd`/`_smtpq` todavía no existen en la
distro — se dejan en su nombre canónico a propósito, porque cambiarlos por `root` tiraría la
separación de privilegios, que es la razón principal para elegir este servidor.
2026-09-11 20:43:35 +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
Sergio 2546f5cdf8 kubectl: el vigía nuevo señaló dos inertes y esto los enciende — 46 → 44
`kubectl-neat` y `kubectl-tree` llevaban meses sellados y **no se podían invocar**: los dos son
subcomandos que despacha `kubectl`, y `kubectl` no estaba en el catálogo. Probado, no supuesto —
`kubectl plugin list` con los dos artefactos en el PATH los lista a los dos.

`kubectl version --client -o json` contesta con todos los campos poblados (gitVersion v1.37.0,
gitCommit, gitTreeState clean, goVersion go1.26.4). REPRODUCE bit a bit.

Los `-X` del ldflags no son adorno: sin ellos `kubectl version` dice `v0.0.0-master+$Format:%H$`, y
un cliente de k8s NEGOCIA por versión — varios comandos avisan de desfase cliente/servidor
comparando exactamente esos campos. Los nombres son los de `hack/lib/version.sh` de upstream, para
no inventar un esquema paralelo.

**Uno de esos campos era una trampa de reproducibilidad**: `buildDate`, que upstream llena con
`date -u` ⇒ dos builds del mismo fuente darían binarios distintos (la familia del sello de nftables
y del `BUILD_ID` de valkey). Se resuelve como lo resuelve upstream, leyendo `SOURCE_DATE_EPOCH`, que
el lab exporta con valor FIJO. Sale `1970-01-01T00:00:01Z` — feo y CONSTANTE, que es lo que importa.

`gitCommit` va pineado a mano porque el tarball no trae `.git`. ⚠ El tag `v1.37.0` es ANOTADO: el
`git/ref` devuelve el sha del OBJETO TAG, no el del commit, y quedarse con ése habría horneado un
sha que no es ningún commit. Hay que resolver tag → commit.

⚠ **Y un hallazgo lateral que dejo anotado en la receta porque no es de esta receta:** el árbol de
k8s trae su propio `vendor/` curado, pero el lab corre `go mod vendor` en el FETCH siempre que haya
`go.mod`, sin mirar si el proyecto ya traía uno (`vendor_go_deps`, incondicional — se ve en el log:
`go: downloading …` ANTES de `configure`). O sea que se compila el vendor REGENERADO, no el de
upstream. Acá salió bien y reproduce, pero es la misma figura que el clobber del `vendor/` de Cargo,
que sí necesitó un campo de receta para resolverse.
2026-09-11 20:37:01 +00:00
Sergio 6fa955f297 estado: cosecha granja 2026-09-11T20:32:34Z — avance del árbol KDE 2026-09-11 20:32:34 +00:00
Sergio 4cb776ecd7 vigía: 46 herramientas selladas que NO SE PUEDEN INVOCAR — cuatro verdes sobre algo inerte
Hay una familia de recetas que no son programas: son SUBCOMANDOS. `protoc-gen-go` es un plugin que
ejecuta `protoc`; `cargo-audit` existe para escribirse `cargo audit`; `kubectl-tree` lo despacha
`kubectl`. Están selladas, tienen contenido, resuelven sus sonames, el grafo las cuenta y
`verificar-repro` dice que reproducen. **Cuatro indicadores en verde sobre binarios que no se pueden
usar**, porque el driver que los invoca no está en el catálogo.

Ninguna métrica existente puede verlo: la arista «me ejecuta aquél» NO es una dep de build, así que
no existe en el grafo. Lo descubrí por accidente — `protoc-gen-go` llevaba meses sellado y el
catálogo no tenía `protoc`; lo delató ir a escribir la receta de un servicio gRPC y preguntarme con
qué se generan los stubs. Eso es exactamente un punto ciego, y acá va su guardián.

Lo que mide hoy:

    ✗ falta `cargo`   ⇒ 44 recetas INERTES (cargo-audit, cargo-nextest, cargo-deny, …)
    ✗ falta `kubectl` ⇒  2 recetas INERTES (kubectl-neat, kubectl-tree)
    TOTAL: 46. No es deuda de BUILD —construyen y reproducen— es deuda de CATÁLOGO.

Dos decisiones de diseño que son la diferencia entre un vigía que se lee y uno que se ignora:

· **Comprueba el BINARIO, no el nombre de la receta.** El driver de `protoc-gen-*` es `protoc`, que
  lo publica la receta `protobuf`. Preguntar «¿existe recipes/protoc.toml?» habría seguido diciendo
  «no» DESPUÉS de cerrarlo, y un guardián que grita cuando ya está arreglado se empieza a ignorar.

· **La tabla de drivers es explícita, no una heurística sobre guiones.** `git-cliff` es subcomando de
  git; `gettext-tiny` no es subcomando de `gettext`. Adivinar por el guión da falsos positivos, y un
  vigía con falsos positivos no se lee.

Y trae `--autoprueba` con los dos controles, porque un guardián que nunca falló no se sabe si sirve:
NEGATIVO (escondo `git`, que sí está ⇒ tiene que cantar `git-`) y POSITIVO (sin tocar nada, no debe
cantar sobre los drivers presentes). Los dos pasan.

⚠ Una trampa medida escribiéndolo, dentro del propio script: `glob('store/*-go')` casa por SUFIJO y
matchea `…-protoc-gen-go`, o sea que contestaba que `go` estaba sellado cuando no lo estaba. El
nombre de receta se saca con una regex ANCLADA sobre el basename.
2026-09-11 20:31:12 +00:00
SergioandClaude Opus 5 61c33800c5 traducir: lector de OpenRC — el par que cierra el camino de gioser, y casi la mitad NO se puede leer
`openrc → arje` sin escribir ningún traductor de ese par: cuarto plugin, cuarto par. OpenRC es el
init de gioser, la máquina que esta mudanza termina borrando.

**El hecho que manda acá: un servicio de OpenRC es un PROGRAMA, no una declaración.** Un unit de
systemd se lee; un script de OpenRC puede hacer cualquier cosa antes de arrancar nada. Medido sobre
el `/etc/init.d` real: **60 de 121 definen su propia `start()`/`stop()`**. En ésos no hay `command=`
que valga — leer las variables y emitir tarjeta daría un servicio que arranca OTRA COSA. Regla al
revés que en systemd: `start()` propia ⇒ SIN-TRADUCIR y NO se emite tarjeta.

Los números del corpus real cierran solos: 121 − 1 (no es openrc-run) − 60 (start propia) − 1 (sin
`command=`) = 59, y el lector emitió exactamente 59, con 59 ids únicos.

Dos cosas que OpenRC obliga a ir a buscar fuera del script: `/etc/conf.d/<x>`, donde viven los
argumentos de verdad (a diferencia del `EnvironmentFile=` de systemd, éste SÍ está en la máquina: se
lee y se aplica), y `/etc/runlevels/`, que es lo único que dice si el servicio arranca solo.

**Tres defectos que sólo aparecieron corriendo contra las 121 de verdad**, no sobre un ejemplo mío:

- `name=` no es un identificador sino un rótulo humano: en gioser vale «Aura Backend», con espacio,
  y se iba al id de la tarjeta y al path del cgroup. El identificador es el nombre del fichero.
- Ids repetidos: `/etc/init.d` guarda copias `*.bak-FECHA` junto a los servicios vivos y son scripts
  válidos; dos con el mismo nombre dan el MISMO id determinista ⇒ semilla con dos cards homónimas.
  El escritor lo detecta y no emite la segunda.
- La tarjeta decía `"desde": "systemd"` viniendo de OpenRC: el campo que existe para saber de dónde
  salió algo era justo el que mentía. Estaba cableado.

Y el centro deja de filtrar la entrada por extensión: los servicios de OpenRC no tienen ninguna, y
filtrar en el centro es que lo que no entra se pierda EN SILENCIO. Filtra el lector, que sabe, y lo
anota — así un `.bak` en `/etc/init.d` se REPORTA en vez de desaparecer.

Controles: dos corridas dan el fichero byte a byte idéntico; los tres pares previos sin regresión
(`nginx → caddy` sigue dando `Valid configuration`, `systemd → arje` sigue emitiendo sus 3 tarjetas).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 20:30:20 +00:00
Sergio e1bd9198c6 estado: cosecha granja 2026-09-11T20:02:21Z — avance del árbol KDE 2026-09-11 20:02:21 +00:00
Sergio 0f8dff1c62 estado: cosecha granja 2026-09-11T19:32:11Z — avance del árbol KDE 2026-09-11 19:32:11 +00:00
Sergio a14b62436c atuq §6.7.bis: la barra lateral pregunta, y contesta un modelo de ESTA máquina
Inferencia REAL de punta a punta, en una jaula sin red: panel → fondo → host → `llama-server` (el
llama-cpp del corpus) → respuesta. El servidor lo levanta el host en la primera pregunta —no hay
servicio de IA en la imagen— y **se muere con el navegador**, al revés que el daemon del torrent
(§6.9): un torrent tiene trabajo que sobrevive; medio giga de modelo en RAM, no.

El puerto nativo vive en el FONDO y no en la página: si viviera en la página, cerrar la barra lateral
se llevaría el host y el modelo cargado con él.

`scripts/test-atuq-ia.py` mide tres cosas: que la respuesta salga del modelo de la imagen, que no
venga VACÍA, y que al cerrarse el navegador queden cero `llama-server`. Control negativo: sin modelo
aparece la causa y NO hay respuesta inventada.

⚠ Ese censo nació roto y del género que este repo colecciona: `pgrep -c` no existe en busybox y el
`|| echo 0` convertía el error en un cero — o sea en un verde. Con un motor vivo a propósito el
guardián pasaba igual. Ahora cuenta leyendo /proc y la rotura falla como debe. Y el PRIMER intento de
romperlo tampoco rompía nada: el motor de mentira moría al hacer bind porque el socket estaba en
/salida, compartido entre las dos corridas.

Queda UNA decisión, no trabajo: qué modelo se pinea (tamaño de imagen y licencia). Hasta entonces la
imagen no trae modelo y el panel lo dice con todas las letras.
2026-09-11 19:24:33 +00:00
SergioandClaude Opus 5 fefa92ec5a traducir: familia «servicio» — systemd → tarjeta de arje, y el pivote pasa a ser uno POR FAMILIA
Un unit de systemd no es un sitio web. Meterlo en `Sitio`/`Ruta` sería justo el «parecerse» que el
acta existe para evitar, así que cada familia tiene su modelo y el centro empareja SÓLO dentro de la
familia. Un par cruzado se rechaza con un error, no con un intento: `systemd → caddy` sale por
`sys.exit`, porque traducir entre familias daría un fichero que PARECE correcto.

Dos familias hoy: `web` (nginx, apache → caddy) y `servicio` (systemd → arje). 3 pares con 5 plugins.

**Dónde una elisión silenciosa no pierde una opción sino que CAMBIA el sistema.** El payload `Native`
de arje acepta `exec`, `argv` y `envp` y nada más (comprobado en la semilla real del producto): NO
hay campo de usuario. Un `User=git` traducido en silencio correría el servicio COMO ROOT — escalación
de privilegios en un fichero generado que nadie vuelve a leer. Sale SIN-TRADUCIR con su línea. Igual
`EnvironmentFile=` (el fichero no está en la máquina donde se traduce ⇒ envp incompleto, falla
tarde), `Type=forking` (arje supervisa al proceso que lanza ⇒ BUCLE DE REINICIO) y todo el
endurecimiento, que callado entrega un servicio MENOS confinado que el original.

Lo que sí tiene destino exacto: `Type=oneshot` → `"supervision": "OneShot"`, que ya existe en la
semilla del producto — buscarlo antes de declararlo intraducible evitó una elisión inventada.

**Lector y escritor no opinan del mismo campo.** `User=` lo CAPTURA el lector y lo JUZGA el escritor;
cuando los dos anotaban, el acta decía dos cosas distintas de la misma línea, y un acta que se
contradice se deja de leer. El lector registra en qué línea vio cada campo (`Servicio.lineas`) para
que el escritor señale la línea real en vez de un 0.

Controles: la tarjeta generada tiene el MISMO juego de claves que la tarjeta real de `sshd` del
producto (+`_mudanza` de procedencia); dos corridas dan el fichero byte a byte idéntico; y la familia
`web` sigue validando con el caddy del corpus (`Valid configuration` en los dos pares).

De paso, `ulid_determinista` sale a `ids.py`: lo necesitaban `declarar.py` y el escritor de arje, y
copiarlo habría dejado dos generadores de id que se pueden separar sin que nada falle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 19:15:21 +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
Sergio 2a836e5224 estado: cosecha granja 2026-09-11T19:05:04Z — avance del árbol KDE 2026-09-11 19:05:04 +00:00
Sergio 4b9f4acd3d estado: cosecha granja 2026-09-11T19:02:19Z — avance del árbol KDE 2026-09-11 19:02:20 +00:00
Sergio 37bf7077e7 qdrant: a la cola del worker — y dos comprobaciones previas que podían haberla matado
La familia «base vectorial» de la tabla de `planear.py` sale SIN NADA en el catálogo (ni qdrant, ni
milvus, ni weaviate), y el censo del servidor de origen encontró `qdrant` CORRIENDO como binario
suelto en `/usr/local/bin` — de los que nadie provee y se pierden al apagar la máquina vieja. Si la
mudanza llega a ese servicio sin receta, se para.

Va a `recipes/incoming/` y no a `recipes/`: son 912 crates, no está construida todavía, y el hub
clasifica DESPUÉS de que el worker selle. El worker está ocioso y es gratis — ése es su trabajo.

Lo que la destrabó fue el `protoc` del commit anterior: su `build.rs` genera los stubs gRPC con
tonic/prost, que lo invocan. Es dep declarada, no accidente del lab.

Dos cosas comprobadas ANTES de escribir la receta, cada una capaz de matarla:

1. **MSRV.** Pide `rust-version = "1.97"` y el lab trae **exactamente** `rust-1.97.0-r0`. Margen
   CERO — y el toolchain del lab sale de Alpine edge, que es rodante: esto entra hoy porque edge ya
   movió. Queda escrito en la receta para que el día que falle no se busque en otro lado.
   (De paso: la nota que yo tenía de «techo MSRV 1.96» está vencida; el lock dice 1.97.0.)

2. **Qué `*-sys` arrastra**, que es la frontera conocida de las recetas Cargo. Grep sobre el
   `Cargo.lock`: **no hay rocksdb, ni librocksdb-sys, ni openssl-sys, ni bindgen** — qdrant migró a
   Gridstore y se sacó RocksDB de encima. Queda `tikv-jemalloc-sys`, que compila jemalloc desde
   fuente (de ahí `make`). Sin ese grep, la suposición razonable era «qdrant = rocksdb = C++ +
   libclang», media tarde de trabajo que no hacía falta.
2026-09-11 19:01:32 +00:00
Sergio 78e0c126cb protoc: el catálogo tenía el PLUGIN y no el compilador — y abseil y protobuf tienen que compartir runtime de C++
`recipes/protoc-gen-go.toml` estaba SELLADO y era INERTE: un plugin de protoc no hace nada sin
`protoc`, y `protoc` no estaba en el catálogo. Cualquier servicio gRPC lo necesita en tiempo de
build — empezando por `qdrant`, que el censo del servidor de origen encontró corriendo como binario
suelto, de los que se pierden al apagar la máquina vieja. Entran dos recetas: `abseil-cpp` (que
protobuf exige) y `protobuf`.

Verificado corriéndolo, no por el código de salida: `protoc --version` contesta `libprotoc 36.1`, y
un `.proto` de prueba se compila a `.pb.h`/`.pb.cc` reales. Único NEEDED `libc.so`, que provee
`musl-shared`. Las dos REPRODUCEN bit a bit.

**Dos muros, y el segundo escondía al primero.**

1. **`zig c++` se cae al enlazar los tres plugins `protoc-gen-upb*`**: `Error running link command:
   Segmentation fault` — un crash del linker, no un error de símbolos. Se aisló FUERA de takana y
   fuera del sandbox, rehaciendo el link a mano sobre los mismos `.o` y las mismas `.a`: `zig c++`
   vuelve a segfaultear. No es presión de memoria (corrió solo) y no es el `-Wl,-rpath,::::::::::::::`
   que CMake emite — se probó sin él y cae igual. `protobuf_BUILD_LIBUPB=OFF` tampoco es escapatoria:
   queda `OFF` en el `CMakeCache` y los plugins se construyen igual. Con `g++` y el `ld` de GNU los
   cuatro binarios enlazan.

2. Al pasar **sólo** protobuf a `gcc`, con abseil todavía construida por `zig-cc`, el link murió con
   `undefined reference to std::__1::basic_string<…>::assign(char const*, unsigned long)`. **El `__1`
   es el namespace inline de libc++** —la libstdc++ que trae zig— y g++ usa la de GNU, con otro
   mangling para los mismos tipos. O sea: **abseil y protobuf tienen que compartir runtime de C++ o
   no enlazan**, y el error habla de `std::string` sin nombrar a abseil ni una vez. Es la familia de
   las dos glib estáticas en un proceso, pero en C++ y en tiempo de link.

⇒ Las dos recetas llevan `compiler = "gcc"` y está escrito en ambas que cambiar una sin la otra
vuelve a romper. `-static-libstdc++ -static-libgcc` para que `protoc` no salga pidiendo una soname
que sólo vive en el lab.

Y una decisión que NO es «la última versión»: abseil va pineada a `20250512.1` porque es exactamente
la que protobuf 36.1 declara en `cmake/dependencies.cmake`. Abseil no promete ABI estable entre
releases; el disparador para subirla es que protobuf suba la suya, no que abseil publique.
2026-09-11 18:59:10 +00:00
Sergio 6f94a346a3 estado: cosecha granja 2026-09-11T18:32:23Z — avance del árbol KDE 2026-09-11 18:32:23 +00:00
SergioandClaude Opus 5 3137d47f16 traducir: CENTRO de traducción con modelo pivote y plugins — N+M en vez de N×M
Traducir de a pares no escala: con nginx, apache, caddy, haproxy y lighttpd son 20 traductores, y
cada formato nuevo agrega 2N. Con un modelo intermedio son N lectores + M escritores: un formato
nuevo cuesta uno o dos plugins y estrena todos los pares de golpe.

**Probado, no argumentado**: el lector de apache se agregó SIN TOCAR el escritor de Caddy, y con eso
`apache → caddy` funciona sin que exista ningún traductor de ese par. Las dos salidas las valida el
caddy del propio corpus: `Valid configuration` en las dos.

Los plugins se DESCUBREN (`formatos/` + `@lector` / `@escritor`): una lista mantenida a mano se
desincroniza y el formato nuevo «no existe» sin que nada falle. `--list` dice qué pares habilita.

**El acta viaja DENTRO del modelo**, y es la decisión que hace que el pivote no mienta: si fuera un
efecto de cada traductor, lo que el LECTOR no entendió se perdería antes de llegar al escritor. El
escritor además puede agregarle — Caddy no tiene equivalente de `location ~`, así que en vez de
emitir un `handle` de prefijo que SE PARECE, lo manda al acta. Parecerse es peor que faltar.

Y un bug del lector de apache, encontrado probando: `ProxyPass` tiene dos formas y dentro de
`<Location>` lleva sólo la URL. Mirar sólo la de tres tokens dejaba sin traducir la forma más común y
el acta la reportaba como «no reconocida» — un falso negativo que manda a escribir a mano algo que el
lector sí sabe hacer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 18:27:32 +00:00
Sergio 030d728fa4 estado: cosecha granja 2026-09-11T18:03:01Z — avance del árbol KDE 2026-09-11 18:03:01 +00:00
SergioandClaude Opus 5 033913bb4a traducir.py: nginx → caddy, con acta — y validar con el binario real cazó un bug que CAMBIABA el sentido
Cambiar de nginx a caddy es reescribir la configuración. `traducir.py` hace la parte mecánica y dice
CON NÚMERO DE LÍNEA lo que no pudo traducir.

Doctrina heredada de `soltar`/`paskaq` (tawasuyu): su dominio es otro —datos presos en formatos
cautivos— pero sus principios son los que hacían falta. **Elisión honesta**: lo que no se pudo
traducir se reporta con su línea y su motivo, porque un traductor que descarta en silencio te deja un
servidor sin una redirección o sin una regla de auth y el sitio parece funcionar. **No adivinar en
silencio**: cada heurística queda como decisión explícita. **Procedencia**: cada bloque dice de qué
línea salió.

⚠ **Validar con el caddy real destapó dos bugs que leer la salida no mostraba:**

1. `ambiguous site definition` — en nginx dos `server` con el mismo nombre se distinguen por su
   `listen`; en Caddy, por el esquema de la dirección.
2. **El grave**: un `if (...) { return 403; }` salía como `respond 403` INCONDICIONAL — el sitio
   entero devolviendo 403. La directiva estaba dentro de un bloque declarado intraducible y se
   absorbía igual al de afuera. Es peor que la pérdida silenciosa: no pierde, CAMBIA el sentido.
   Ahora todo lo que vive en un bloque opaco sale `SIN-TRADUCIR` con su motivo.

Con los dos arreglados: `Valid configuration` según el caddy del propio corpus.

Y es honesto sobre su alcance: traduce el núcleo común y declara el resto. Uno que cubre el 70 % y
dice cuál es el 30 % restante es útil; uno que aparenta cubrir el 100 % es una trampa.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 17:46:44 +00:00
SergioandClaude Opus 5 d6734cc0a2 caddy: receta TERMINADA — era un import de nix con un tag flotante
Su propia cabecera decía «PUNTO DE PARTIDA, no final», y el `commit` era el TAG `v2.11.4`. Eso
contradice el ADR 0006: un tag se puede mover, y entonces la misma receta construye otra cosa sin que
el hash lo note. Anclado con `takana pin` al SHA inmutable `e2eee6a7…`; el hash se movió a propósito
(`b3:d38acaa0…` → `b3:c915987d…`), que es exactamente lo que un anclaje debe hacer.

**Por qué importaba terminarla**: el `caddy` de gioser NO TIENE DUEÑO — es un binario puesto a mano
en `/usr/bin`, sin paquete y sin receta (lo midió `scripts/mudanza/censar.py` preguntándole a
pacman). Se pierde con la máquina. Con la receta anclada, el servidor nuevo lo declara en su perfil y
lo reconstruye: la diferencia entre mudar un servidor y poder mudarlo otra vez.

Verificado que sirve, no sólo que compila: 77 MB estáticos, **0 intérpretes requeridos** (Go estático
no arrastra sonames, así que no repite la fuga del `NEEDED` colgante), y un `file-server` de prueba
contesta **200**.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 17:43:25 +00:00
Sergio c3d0c339bc estado: cosecha granja 2026-09-11T17:32:16Z — avance del árbol KDE 2026-09-11 17:32:16 +00:00
Sergio a90b4ef626 valkey: la familia «caché en memoria» estaba VACÍA — y el artefacto salió vacío sin que nada fallara
`scripts/mudanza/planear.py` agrupa el software del origen en familias funcionales y contesta, para
cada una, con qué la reemplaza takana. Cruzando esa tabla contra el catálogo, cuatro familias salen
SIN NADA sellado: caché en memoria, contenedores, correo y base vectorial. Ésta cierra la primera.

Valkey y no redis a propósito: redis dejó de ser software libre en 2024 (RSALv2/SSPLv1, que no pasan
la OSI); valkey es el fork de la Linux Foundation desde el último commit BSD, mismo protocolo y mismo
RDB/AOF. Y upstream instala los seis alias `redis-*`, así que un `redis-server` se reemplaza sin
tocar datos ni clientes. Para una distro que publica el catálogo con `license` poblado receta a
receta, meter SSPL sería meter algo que después hay que sacar.

**El hallazgo caro de esta receta no fue compilar: fue que el PRIMER artefacto se selló VACÍO.**
`make install PREFIX=/usr DESTDIR=/out` imprimió sus `INSTALL valkey-server` en verde y salió 0 — y
`/out` quedó sin un solo fichero. La causa está en la línea 65 de `src/Makefile`: valkey define
`INSTALL_BIN=$(PREFIX)/bin` **sin prefijar `$(DESTDIR)`**, o sea que IGNORA `DESTDIR` y copió los
binarios a `/usr/bin` dentro del sandbox, que se tira al terminar. Lo sellado fue un directorio con
`.hammer/recipe.toml` y nada más: un cache-hit permanente que habría contestado «valkey ya está»
para siempre. Es la regla 3 del repo en vivo — *un ausente falla ruidosamente; un vacío llega hasta
el final diciendo que todo fue bien*. Se vio mirando el árbol del artefacto, NO el código de salida.
El arreglo es `PREFIX=/out/usr`.

El otro muro, medido con un control mínimo fuera de valkey: con `zig cc` el link muere con
`undefined symbol: __cpu_model` en los tres binarios. No es valkey — es que `__builtin_cpu_supports()`
(que valkey usa para elegir en runtime las rutas AVX2/AVX-512) emite esa referencia y el
`compiler_rt` de zig 0.16.0 no la trae:

    printf '#include <stdio.h>\nint main(void){__builtin_cpu_init();return printf("%d",__builtin_cpu_supports("avx2"));}\n' > t.c
    zig cc -target x86_64-linux-musl -O2 -o t t.c   ⇒  ld.lld: error: undefined symbol: __cpu_model

De ahí `compiler = "gcc"`, que es la palanca declarativa que el lab expone justo para esto. Arrancarle
el multiversioning a parches habría costado las rutas SIMD de `BITCOUNT`/`PFCOUNT` y habría que
rehacer el parche en cada versión.

Verificado: REPRODUCE bit a bit. Único NEEDED `libc.so`, que provee `musl-shared` — raíz de `base`
desde hoy ⇒ resuelve en cualquier imagen sin declarar nada en la receta.
2026-09-11 17:29:41 +00:00
Sergio 9a1139ce5f granja: las cuatro recetas nuevas REPRODUCEN bit a bit
`verificar-repro.sh` sobre popt, logrotate, chrony y cronie: 4 REPRODUCEN, 0 deriva,
0 no-determinismo. Importaba comprobarlo el mismo día y no dentro de tres meses: recién selladas,
el artefacto guardado y el lab son el MISMO, así que una diferencia acá sólo podía ser
no-determinismo — no deriva. Verificarlas más tarde mezcla las dos causas y obliga a la segunda
reconstrucción para desempatar.

Vale la pena decir cuál era el riesgo concreto, porque no era genérico: chrony mete en el binario un
`NTP_ERA_SPLIT` que por default calcula con `date` en tiempo de configure («hace 50 años»). Es
exactamente la familia del sello de tiempo de nftables y del BuildID de waterfox. No hizo falta
parchearlo: upstream ya respeta `SOURCE_DATE_EPOCH`, que el lab exporta — y esto lo confirma.
2026-09-11 17:17:03 +00:00
SergioandClaude Opus 5 68fdd9eb72 mudanza: el perfil — el software del servidor nuevo se DECLARA, no se instala a mano
`declarar.py --perfil-out` emite un `[perfil.<label>]` listo para `targets.toml`, generado desde el
censo: cada raíz está ahí porque un servicio que CORRÍA la necesita y takana tiene receta.

Por qué un perfil y no una lista de `install`: mudar servicio por servicio a una caja viva va contra
el diseño —el software de una máquina se declara y viene en la imagen, reproducible y firmado— y un
servidor armado a fuerza de instalaciones sueltas no se puede volver a construir. Que es justo el
problema que esta mudanza existe para no repetir: el `caddy` de gioser no tiene dueño ni receta y se
pierde con la máquina.

Y hay un impedimento medido, no estético: `takana install` REPRODUCE desde fuente y eso exige el lab
entero en el cliente (SDD 28 §5.4), que una caja de destino no tiene. El perfil lo resuelve por el
lado correcto: el software entra al armar la imagen, en el hub, que sí tiene lab.

Del censo de gioser salen `caddy`, `gitea`, `python3` (agrupado: lo piden python3 y uvicorn).
Verificado que el instrumental lo acepta: `targets.py gioser-mudado` expande a 33 raíces.

⚠ Y dice lo que NO puede cubrir, uno por uno con su motivo: 34 servicios entre `suelto`,
`paquete-ajeno` y `desconocido`. Un perfil que se calla lo que le falta sale N/N describiendo un
servidor incompleto — la lección de `foot` en escritorio-sway.

Con esto la mudanza produce TRES documentos declarativos que reconstruyen el servidor desde cero:
el perfil (qué software), la semilla (qué corre y cómo) y el plan (qué datos, en qué orden). Ninguno
es un log de lo hecho: los tres son entradas que se vuelven a ejecutar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 17:16:16 +00:00
Sergio cc5aebd8c4 granja: las tres wanted del perfil servidor, construidas — y popt, que faltaba debajo
`targets.toml` declaraba `chrony`, `cronie` y `logrotate` como raíces del perfil `servidor` con el
motivo escrito al lado, y ninguna tenía receta: el grafo las contaba como `wanted` y ésa era toda la
deuda que le quedaba al perfil. Ahora sellan las cuatro (popt es la hoja que `logrotate` exige:
incluye `<popt.h>` en la primera pantalla y su configure aborta sin él).

    servidor  97/97 → 101/101 listo, falta 0.  `wanted` desaparece de los totales.

Lo que se midió, no se supuso:
  · logrotate  estático, CERO NEEDED, corre, y las rutas de gzip quedan en `/bin` — que es donde
    busybox las deja de verdad; el default de upstream en Linux es `/usr/bin/gzip`, que en esta
    distro NO EXISTE y habría fallado en runtime diciendo «no se pudo comprimir».
  · chrony     `+CMDMON +REFCLOCK +RTC +PRIVDROP +IPV6`; `cap_set_proc` está en el ELF, o sea que
    libcap entró de verdad y chronyd suelta privilegios. Sale `-NTS -SECHASH` a propósito: NTS
    necesita nettle o gnutls y ninguna está en el corpus.
  · cronie     los cuatro binarios estáticos sin NEEDED, con inotify dentro, y `/bin/vi` pineado.

**El hilo que recorre las tres recetas es el mismo, y es el que valía la pena escribir:** los tres
`configure` DECIDEN MIRANDO EL LAB. `logrotate` trae `--with-selinux/--with-acl` en `[default=check]`;
`chrony` prueba nettle, gnutls, libcap, seccomp y editline; `cronie` resuelve el editor de
`crontab -e` con `AC_PATH_PROG([vi])` y lo graba en el binario. El lab NO entra en `hash_inputs` ⇒
dos labs distintos sellarían bytes distintos en la MISMA dirección del store y nada lo notaría.
Cada palanca va fijada en la receta —que sí entra en el hash— para que sea una decisión y no un
accidente del entorno.

Y una anotada en vez de tapada: cronie no reemplaza al `crond` de busybox por reflejo (busybox ya lo
pone en `/sbin`); agrega `@reboot`, crontabs por usuario, `/etc/cron.d` y anacron. Cuál va en cada
imagen es decisión de perfil — lo que faltaba era que la opción existiera en el corpus.
2026-09-11 17:13:42 +00:00
SergioandClaude Opus 5 8ad9f0490d planear: alternativas por familia funcional, con una recomendación que no es gusto
Un servicio casi nunca es insustituible. `planear.py` agrupa por familia (servidor web, base de
datos, caché, forja git, contenedores, dns, correo, base vectorial), muestra qué equivalentes tiene
takana —marcando sellado / receta sin sellar / no está— y recomienda.

Pero no «el mejor» en abstracto: un gusto disfrazado de dato es peor que no recomendar. Los criterios
son hechos comprobables, en orden:

1. **El que ya corre, si takana lo construye** — porque cambiar de servidor web no es cambiar un
   binario: es REESCRIBIR la configuración entera, y eso casi siempre pesa más que cualquier ventaja
   teórica del otro.
2. Si el que corre no está en el catálogo pero un equivalente sí, se recomienda ése (takana puede
   construirlo, firmarlo y reproducirlo) diciendo lo que cuesta.
3. Si no hay ninguno, se dice. **No se sugiere «usá otro» cuando ese otro tampoco está.**

Probado contra el catálogo real: `caddy` → se queda (ya corre y está sellado); `nginx` → recomienda
caddy nombrando el costo; `redis` → «recomendado: NINGUNO», porque ni redis ni valkey ni memcached
están en el corpus.

⚠ Y el dato que la recomendación destapa, que vale más que la recomendación: de servidores web el
corpus sólo tiene **caddy y traefik**. No hay nginx ni apache.

Si se elige un equivalente queda en el censo (`alternativa = "…"`) y el plan lo dice en su paso, con
la advertencia arriba de todo: la configuración del origen no sirve tal cual.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 17:07:05 +00:00
SergioandClaude Opus 5 6f5cc2629a mudanza: de dónde sale cada binario — y la herramienta corrigió un error mío
«Instalá el paquete» es lo que uno ya sabía. La respuesta útil sale de cruzar dos hechos: quién posee
el fichero en el ORIGEN (el censo se lo pregunta al gestor de paquetes de esa máquina, en un lote) y
si el corpus de takana tiene una receta con ese nombre.

  receta-takana  → instalarlo del repo firmado, NO copiar el binario
  paquete-ajeno  → escribir receta, o qorpa (ADR 0015)
  suelto         → nadie lo provee: llevarlo con su entorno o escribirle receta

Medido sobre gioser (37 servicios vivos): 4 receta-takana · 4 paquete-ajeno · **11 SUELTOS** · 7 sin
binario legible. Los sueltos viven en `/usr/local/bin` o en un home (`qdrant`, `matilda`,
`pacha-secretos`, `act_runner`, `shuma-gateway`) y son los que se pierden al apagar el origen.
Sorpresa medida: `/usr/bin/caddy` tampoco tiene dueño — está puesto a mano.

**Y corrigió un error mío**: en el SDD 28 escribí que `gitea` no tenía receta y que «es la que más
peso tiene». Las dos cosas falsas — `recipes/gitea.toml` existe y está sellada. Lo afirmé de memoria;
el programa fue a mirar. Corregido allá con la nota.

**Y un fallo del clasificador, que vale como regla**: calculaba la raíz del repo con un `dirname` de
menos, no encontraba ninguna receta y contestaba `suelto` A TODO, incluido `caddy`. Un clasificador
que contesta siempre lo mismo no clasifica. Ahora falla ruidosamente si no encuentra el catálogo, en
vez de dar una respuesta falsa con forma de respuesta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 17:02:52 +00:00
SergioandClaude Opus 5 808e4a4660 install-image: UNA imagen que arranca por BIOS **y** por UEFI
«Instalable en Hetzner o en cualquier otro servicio» se rompía en el arranque: Hetzner Cloud arranca
BIOS y otros proveedores sólo ofrecen UEFI. Tener dos imágenes hermanas obliga a elegir por proveedor
y a que diverjan — y ya divergían: la hermana EFI documenta etiquetas `takana-*` que su propio código
no escribe (corregido en este commit; son `hammer-*` y están congeladas a propósito por el ADR 0016,
porque viven en sistemas YA INSTALADOS).

Layout nuevo: `p1` BIOS-boot · **`p2` ESP FAT32** · `p3` `/` · `p4` estado · `p5` store (última, la
que crece). El `core.img` de i386-pc y el `BOOTX64.EFI` de x86_64-efi apuntan **los dos** a
`(hd0,gpt3)/boot/grub`: **un solo `grub.cfg`, una sola línea de comando, un solo sitio donde
editarla.** La ESP se puebla con mtools, sin root y sin loop, como el resto del script.

No se usa EFI-stub directo acá (sí `install-image-efi.sh`, ADR 0010): el stub por la ruta fallback
recibe LoadOptions VACÍO y necesita la cmdline HORNEADA en el kernel; la de `linux-generic` sólo trae
la consola, sin `root=`. Hornearla ataría la línea de comando al ArtifactHash del kernel.

Verificado con LA MISMA imagen en los dos firmwares, hasta entrar por SSH:

    UEFI (OVMF)   → /sys/firmware/efi presente · PID1 arje-zero · raíz sda3 · store sda5
    BIOS (SeaBIOS)→ /sys/firmware/efi ausente  · PID1 arje-zero · raíz sda3 · store sda5

Si falta `grub-mkimage` con x86_64-efi o mtools, la ESP se saltea con un aviso que dice qué se pierde
—no en silencio—: la imagen sigue arrancando por BIOS, pero eso la ata a esos proveedores.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 16:47:54 +00:00
Sergio 62e6a14814 estado: cosecha granja 2026-09-11T16:32:14Z — avance del árbol KDE 2026-09-11 16:32:14 +00:00
SergioandClaude Opus 5 96dc488842 mudanza: recomendaciones CON MOTIVO, y la lista completa a la vista para elegir
Correr en el origen no significa que aplique en el destino. `planear.py --revisar` muestra todo
agrupado con su recomendación **y su razón**, y `--decide` deja aceptarlas en bloque o revisarlas una
por una.

Cuatro familias que no aplican en una caja remota, cada una con su motivo:

  · hardware local            → bluetoothd, ModemManager, upowerd, adb
  · escritorio o pantalla     → waypipe
  · la red del destino        → NetworkManager, dhcpcd: **pelearían** con el init de allá
  · lo provee el init destino → udevd, dbus-daemon, elogind, polkitd, agetty

11 de 37 servicios de gioser caen ahí. El motivo no es cortesía: una recomendación sin razón no se
puede discutir, así que o se acepta a ciegas o se ignora entera.

**Y la recomendación DERIVADA, que es la más fuerte**: si el binario vive en un árbol que no se muda,
el servicio no podría arrancar allá. Se calcula cruzando el `cmdline` leído de `/proc` contra las
decisiones de datos. Probado en los dos sentidos: con `/mnt/vvv` muriendo, `puerta-f6e393ff` sale
«no mudar — su binario vive en /mnt/vvv»; con `/mnt/vvv` mudándose, sale «mudar».

Por eso la revisión decide los DATOS PRIMERO: de ellos se deriva la recomendación de los servicios, y
al revés no se puede calcular. Y los datos salen SIN recomendación a propósito — qué datos valen no
se deduce de la máquina; `terapeuta.ec` eran 279 M sin DNS y sólo el usuario podía decidirlo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 16:27:58 +00:00
Sergio c4641bade8 estado: cosecha granja 2026-09-11T16:03:12Z — avance del árbol KDE 2026-09-11 16:03:13 +00:00
SergioandClaude Opus 5 d682b9b240 mudanza: el APLICADOR — idempotente, reanudable, y que no marca como hecho lo que no ejecutó
`aplicar.py` ejecuta un plan. La regla que lo define: **un paso que no se ejecutó no se marca como
hecho**. Un plan tiene pasos ejecutables y pasos MANUALES cuyo `cmd` son comentarios («instalá el
paquete», «cambiá el registro A»); un aplicador que ejecuta un bloque de comentarios obtiene exit 0 y
lo marca «ok» — la peor mentira posible, porque deja el servicio caído con el informe en verde.

Acá quedan `pendiente-humano`, la corrida sale con ≠0, y `--hecho N` los confirma — negándose si el
paso sí tenía comandos («corrélo, no lo marques»).

Se le cree a la VERIFICACIÓN, no al exit code: el rsync que llenó el disco devolvió 0 y dejó 1367
artefactos vacíos. Si el comando sale bien y la verificación falla, queda `sospechoso`. Y las
verificaciones van TIPADAS (`cmd`/`humano`): «la columna Available debe ser > X» no es un comando.

Estado reanudable en `<plan>.estado.json`, escrito con temporal + fsync + rename — la lección que
costó un upgrade entero en el SDD 28.

**Y un fallo propio, encontrado en la primera corrida real**: la verificación del paso de datos
imprimía el número de directorios vacíos y devolvía 0 igual. Dio «✓ verificado: 2» donde ese 2 eran
dos vacíos en destino. Un guardián que siempre pasa no es un guardián. Ahora COMPARA los ficheros de
los dos lados y falla si difieren. Probado con rotura a propósito y con el control que debe pasar:
copia no hecha ⇒ origen=5 destino=0 ⇒ FALLA; copia hecha ⇒ 5 y 5 ⇒ PASA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 15:20:15 +00:00
SergioandClaude Opus 5 1a89592b72 mudanza etapa 3: declarar.py — de «corre y nadie sabe cómo» a una Semilla que arranca
Copiar un binario no lo levanta al arrancar. Esto toma la invocación que el censo leyó de `/proc` y
emite `seed.card.json`. Medido sobre gioser: **37 de 37 servicios vivos declarados, cero fallos**, y
la tarjeta de caddy sale con su invocación real — exactamente lo que faltaba para que no muriera en
el próximo reinicio.

Por qué no alcanza con `arje-absorb`: absorb lee la DECLARACIÓN del init ajeno, que es justo la que
miente (`rc-status` daba `stopped` para cinco servicios vivos), y los `no-declarado` no aparecen en
ninguna declaración por definición. Absorber la declaración reproduce el agujero. Se complementan.

Tres reglas:
1. Un servicio sin `cmdline` legible NO se emite y se dice: una tarjeta que no arranca es peor que
   una ausente — la ausente falla ruidosamente, la rota deja el servicio caído en silencio.
2. El `cwd` se preserva ENVOLVIENDO, porque el payload `Native` acepta `exec`/`argv`/`envp` y no
   `cwd` (comprobado sobre la semilla real). 9 de 37 servicios de gioser dependen de su directorio.
3. IDs deterministas ⇒ misma entrada, misma semilla BYTE A BYTE (verificado con sha256). Un fichero
   generado que cambia en cada corrida no se puede revisar con `diff`.

**Y un bug del censo que costaba el 70 % del dato**: `cmdline` y `cwd` se leían en un solo comando, y
como `readlink /proc/<pid>/cwd` exige permiso de ptrace, en cualquier proceso de root el comando
entero salía ≠0 y se descartaba TAMBIÉN el `cmdline`. 26 de 37 servicios quedaban sin invocación.
Sondas separadas. **Una sonda que falla es un dato; no puede arrastrar a las que funcionaron.**

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 15:13:53 +00:00
SergioandClaude Opus 5 6f12c231fa mudanza etapa 2: el PLAN — exportable, con el comando literal de cada paso
`planear.py` convierte un censo decidido en un plan ejecutable. Cada paso lleva su COMANDO LITERAL y
su verificación, así que el fichero se ejecuta a mano, línea por línea, sin la herramienta y sin este
repo. Eso es lo que el usuario pidió como «pasos exportables».

Las tres reglas, cada una pagada en el SDD 28:

1. **Nada sin decidir se ejecuta**: aborta con código 2 listando qué falta. El silencio no es
   consentimiento — sin decisión, ni mudar ni matar es correcto. Probado en los dos sentidos.
2. **Lo que muere se dice por su nombre y con su tamaño ANTES de borrar**, y el paso no borra nada:
   es una lista para leer antes de apagar el origen.
3. **Cada copia se verifica EN DESTINO**: el `rsync` que llenó el disco devolvió 0 y dejó 1367
   artefactos vacíos; sólo se vio contando del otro lado.

Y el preflight dimensiona con el tamaño de COPIA (hardlinks expandidos, 60 G → 85 G medidos), no con
`du`; si un tamaño resulta ilegible lo dice en vez de contarlo como 0.

**El añadido que cambia el valor: la invocación real.** Decir «este servicio no está declarado»
nombra el problema; lo que hace falta para resolverlo es cómo corre AHORA. El censo lo lee de
`/proc` (`cmdline` + `cwd`, nunca `environ`: el entorno trae tokens) y el plan lo emite:

    #   cmdline: /mnt/vvv/tawasuyu/target/debug/deps/puerta-f6e393ffbef3999e
    #   cwd    : /mnt/vvv/tawasuyu/shared/tejido
    #   puertos: 34221, 44961

Ese servicio de gioser es un binario de `target/debug/deps/` corriendo en producción, con dos
puertos, que ninguna declaración conoce. Apagada la máquina vieja, eso no se reconstruye de memoria.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 15:03:33 +00:00
SergioandClaude Opus 5 5d1120e583 censar: la sonda de dominios dejó a gioser incomunicado — «sólo lee» era falso
Censar gioser en `--local` disparó 29 peticiones HTTPS a sus propios dominios. Varios devuelven 502,
y su `fail2ban` (jaula `caddy-backend-down`) **baneó la propia IP de la máquina**: gioser dejó de
poder hablar con su propio gitea y los `git push` empezaron a fallar con «Could not read from remote
repository». Un censo que deja la máquina incomunicada no es de sólo lectura.

**La frase del script era la mentira**: «sólo lecturas, no se toca nada». No escribe nada, cierto —
pero leer POR RED tiene efectos. Corregida.

Tres arreglos:

1. **La sonda no se hace contra uno mismo.** Por defecto sólo con `--host`; en `--local` se saltea y
   se avisa por qué, con `--probe` para forzarlo. Además es metodológicamente mejor: lo que interesa
   es qué ve EL MUNDO, no qué ve la máquina de sí misma.
2. **Espaciada** medio segundo entre dominios, para no parecerle un escaneo al fail2ban del objetivo.
3. Clase nueva `sin-sondear`, para que un dominio sin datos no se confunda con un fósil.

Desbaneado 204.168.193.248 de `caddy-backend-down`. Y queda la regla: **antes de sondear una máquina,
pensar qué defensa propia le estás disparando.**

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 14:53:05 +00:00
SergioandClaude Opus 5 95c16097e4 SDD 29 + censar.py: la mudanza como PRODUCTO — censo, plan, aplicación. Sin IA.
Corrección de rumbo pedida por el usuario: el SDD 28 derivó hacia «poner ESTA caja a punto» —
instalar caddy, copiar claves, montar discos—, y eso es configurar un servidor, no construir algo.
**caddy no es un hueco de la imagen: es una instalación particular del usuario, y el programa tiene
que DESCUBRIRLA, no traerla.**

Y la consecuencia de método: todo lo que hice a mano en el SDD 28 ES la especificación de este
programa. Cada paso fue determinista; el único juicio fue «¿esto se muda o muere?», que es justo lo
que se le pregunta al usuario. No hace falta IA: hace falta que esté escrito.

**Etapa 1 implementada: `scripts/mudanza/censar.py`.** Sólo lee. Cruza lo DECLARADO contra lo VIVO y
reporta tres clases, cada una justificada por un error medido:

- `rc-status` decía `stopped` de cinco servicios que estaban VIVOS ⇒ la verdad es `ppid==1`, no el init.
- De 29 dominios, 10 vivos: 13 sin DNS, 2 en 502 y **4 que ya resuelven a otra máquina** — por eso se
  compara la IP del DNS contra las de la máquina, o `ya-mudado` se lee como `vivo`.
- `du -sh` no dice cuánto ocupa COPIAR con hardlinks (60 G vs 85 G): se reportan los dos.
- Los nombres no coinciden (`act-runner`/`act_runner`, `crond`/`cronie`, `dbus-daemon`/`dbus`): sin
  normalizar, el mismo servicio sale a la vez como `no-declarado` y `declarado-muerto`.
- Lo descartado se CUENTA: un `ppid==1` que no es servicio suele ser un huérfano, y eso es hallazgo.

Probado contra gioser, donde las respuestas ya se sabían a mano: encuentra MÁS (29 dominios contra
los 19 que probé; apareció `hifas.gioser.net`). Y afinarlo importó: la primera versión daba 28
`no-declarado` con ruido, la segunda da 18 y son reales (`matilda`, `pacha`, `puerta-…`, `adb`).
Un guardián con hallazgos falsos se ignora entero.

El SDD deja escritas las etapas 2 (plan exportable: el fichero ES la interfaz) y 3 (aplicación
idempotente que verifica EN DESTINO y deja el server corriendo), y mide qué ata la imagen a Hetzner:
menos de lo que parece — BIOS vs UEFI y los metadatos de red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 14:51:08 +00:00
Sergio 62f1c2b198 estado: cosecha granja 2026-09-11T14:32:06Z — avance del árbol KDE 2026-09-11 14:32:06 +00:00
SergioandClaude Opus 5 e5858d23e9 SDD 28 §6.13: upgrade apply no es un gestor de paquetes — cada generación ES un árbol
Lo destapó usarlo dos veces seguidas: tras aplicar `zstd-cli` y después `rsync`, el informe dice
`- 1 retirados` y `zstd` desaparece. Aplicar un árbol nuevo RETIRA los ficheros del anterior, porque
una generación es el estado completo y no un incremento. Es coherente con la ayuda («aplica un árbol
Stage1/PRODUCTO») y con que exista `rollback`: lo que se revierte es un sistema, no un paquete.

⇒ Usarlo para paquetes sueltos funciona una vez y se deshace a la siguiente. La forma correcta de
poner una caja al día es componer el árbol del perfil —lo que ya hace `servidor-image.sh`—, sellarlo
y aplicar ESO. Que además sería la «actualización en sitio probada» del SDD 19 §5.2 de verdad: una
imagen entera con rollback sobre una caja viva.

La caja quedó con `zstd` y `rsync` puestos a mano: funciona y no es el camino, igual que el `git`.

De paso, verificado que el arreglo de `recipes/rsync.toml` sirve: el respaldo ya no imprime el aviso
de compresión degradada — `compress list` pasó de `zlibx zlib none` a `zstd zlibx zlib none`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 14:03:02 +00:00
Sergio 90fade56f1 estado: cosecha granja 2026-09-11T14:02:19Z — avance del árbol KDE 2026-09-11 14:02:19 +00:00
SergioandClaude Opus 5 77cda9a865 install-image: un filesystem etiquetado hammer-work se monta en /work
Convención opcional, no requisito: la imagen no crea esa partición y si la etiqueta no existe el
arranque no hace nada ni avisa.

Existe por un caso concreto. Al mudar el store de la caja de producción al volumen, la partición
local del store —69,8 G de un disco de 76,3— quedó huérfana: re-etiquetada para que el `findfs` del
arranque no la confundiera con el volumen, y sin montar. La caja tenía 70 G de disco ocioso mientras
`/store` iba al 80 %.

`/work` es el destino natural: es donde un HUB pone lo pesado —`work/sources`, los tarballs, el
`CARGO_HOME`—, que en gioser son 85 G y que no cabe ni conviene en una raíz de 6 G.

En la caja: `sda4` re-etiquetada `hammer-work`, REFORMATEADA (traía los 61,5 G del store viejo,
anterior a la cosecha; control antes de borrar: el store vivo tiene 1579 artefactos y el respaldo
3140) y `/opt/takana/work` pasa a ser un enlace a `/work`. Verificado tras reiniciar: se monta solo,
68,1 G con 64,6 G libres, y los manifiestos siguen donde `build-state.py` los busca.

La caja usa ahora su disco entero: `/` 5,8 G · `/var/lib/hammer` 487 M · `/work` 68,1 G local ·
`/store` 97,9 G en el volumen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 14:01:23 +00:00
Sergio 8ebcc99930 estado: cosecha granja 2026-09-11T13:32:05Z — avance del árbol KDE 2026-09-11 13:32:05 +00:00
SergioandClaude Opus 5 426093cd20 SDD 28 §6.13: takana upgrade probado de verdad — apply, rollback y un corte duro
Probado con un paquete que la caja necesitaba (`zstd-cli`): apply deja generación, la caja pasa a
desempacar su propio lab, `rollback` devuelve el estado anterior fichero a fichero (9 restaurados,
1 borrado), y tras el arreglo de durabilidad **sobrevive a un `hcloud server reset`, que es un corte
DURO y no un apagado limpio**.

Y cruza la frontera que `hydrate` no puede: store en el volumen, `/` en el disco local. Es el único
camino de actualización que funciona en una caja instalada.

El bug que destapó está en el commit anterior: `write_atomic` era atómico frente a otros procesos y
no frente a un corte, así que el primer upgrade se perdió al reiniciar y `recover` abortaba con la
huella misma del corte. Verificado en la máquina en los dos sentidos: binario viejo ⇒ estado en 0
bytes; binario nuevo ⇒ estado íntegro.

`recipes/takana.toml` re-pineado al commit del arreglo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 13:31:56 +00:00
SergioandClaude Opus 5 68d052136c upgrade: write_atomic era atómico pero NO DURABLE — un corte lo dejaba en 0 bytes
La máquina de estados que promete sobrevivir a un corte dependía de escrituras que no sobreviven a
un corte.

`write_atomic` hacía temporal + `rename`. Eso es atómico frente a OTROS PROCESOS, no frente a un
corte de luz: `rename` sobre un fichero cuyos datos siguen en la caché de página deja, tras el corte,
la entrada nueva apuntando a bloques que nunca se escribieron — un fichero de **CERO BYTES**.

Medido en la caja de producción (SDD 28 §6.14). Apliqué un upgrade y reinicié con
`hcloud server reset`, que es un corte DURO y no un apagado limpio. La caja volvió con:

    pending.json                      0 bytes
    generations/2/manifest.json       0 bytes
    upgrade status   → Error: json: EOF while parsing a value
    upgrade recover  → Error: json: EOF while parsing a value

`recover` existe EXACTAMENTE para «un apply interrumpido por un corte/reinicio» y abortaba con la
huella más probable de ese corte.

Dos arreglos:

1. **`write_atomic` ahora es durable**: `fsync` del temporal ANTES del rename (los datos) y `fsync`
   del DIRECTORIO después (la entrada). Hacen falta los dos; con uno solo sigue habiendo ventana.

2. **Un `pending.json` vacío se reporta como lo que es**: `Error::PendingCorrupt`, que nombra el
   corte, dice que el plan se perdió y apunta al árbol de RESPALDOS, que es lo que sí queda para
   restaurar a mano. Un `json: EOF while parsing a value` crudo manda a mirar el JSON en vez del
   corte.

Tests con su control: sin fichero ⇒ `Ok(None)`; vacío o sólo espacios ⇒ `PendingCorrupt` nombrando
fichero y respaldos; y —el control que hace que valga— un `pending.json` VÁLIDO se sigue leyendo. 23
en verde.

Queda anotado lo que NO se arregló: cuando el manifiesto se pierde, `recover` no puede deshacer solo
(no sabe qué se tocó). El árbol de respaldos tiene la información; reconstruir desde ahí es su propia
unidad de trabajo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 13:24:46 +00:00
SergioandClaude Opus 5 d3b42f892d zstd-cli: la distro comprime todo con zstd y no traía con qué descomprimirlo
`recipes/zstd.toml` construye **sólo `lib/`** —lo dice su propia cabecera, «build de lib/ (no CLI)»—
porque lo que necesitan sus 13 consumidores (mesa, libadwaita, appstream, rsync, los `zstd-sys`) es
`libzstd.a`. El binario nunca se construyó, y el agujero sólo se ve al instalar la distro en una
máquina de verdad: **un takana recién instalado no puede desempacar su propio laboratorio**, porque
el `tar` del rootfs es el de busybox (`tar: unrecognized option: zstd`) y `zstd` no existe. Hubo que
extraer por tubería desde otro hub — y un hub que se instala solo no puede depender de eso.

⚠ **Y mi arreglo anterior estaba mal**: declaré `zstd` en `perfil.base` dando por hecho que traía el
binario. Se destapó aplicándolo con `takana upgrade` en la caja: «✓ generación 1 aplicada, + 8
añadidos» y `zstd` seguía ausente, porque los 8 ficheros eran headers y `libzstd.a`. **El upgrade
hizo exactamente lo que debía; lo que estaba mal era lo que le pedí que aplicara.** Corregido a
`zstd-cli`.

Variante y no ampliación de la canónica: `zstd` es dep de 13 recetas y `mesa` está entre ellas;
re-hashearla arrastraría esa torre por un binario de 1 M que ninguna usa. Mismo criterio que las 23
variantes `*-shared`, con el eje en librería/herramienta en vez de estático/dinámico. Radio cero.

`HAVE_ZLIB/LZMA/LZ4=0` a propósito: sin eso el Makefile las detecta del sysroot del LAB y el binario
sale pidiendo sonames que el artefacto no publica. Verificado: estático, 0 intérpretes requeridos,
`Zstandard CLI v1.5.7`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 13:18:41 +00:00
Sergio be1187e2d1 estado: cosecha granja 2026-09-11T13:02:35Z — avance del árbol KDE 2026-09-11 13:02:36 +00:00
SergioandClaude Opus 5 19576e0039 SDD 28 §6.12: la caja dejó de ser desechable, y hydrate no puede cruzar el store
Dos consecuencias de que la caja ya sea un hub:

1. **`dd` de la imagen ya no es actualizar: es perder.** Sobrescribe el disco local, donde ahora
   viven el clon, las dos claves, las credenciales del respaldo, `/srv/repo` y caddy. El store se
   salva sólo por estar en el volumen. Y peor: la imagen etiqueta su partición local como
   `hammer-store`, así que tras un `dd` habría DOS filesystems con esa etiqueta y el `findfs` del
   arranque elegiría cualquiera. El `dd` es para PROVISIONAR; actualizar es `takana upgrade`, que
   existe con generaciones y rollback y está sin probar acá.

2. **`takana hydrate` no puede proyectar al root vivo**: hidrata con hardlinks y en una caja
   instalada el store es SIEMPRE otra partición que `/` (`Cross-device link`). No es culpa del
   volumen — en el layout original `/store` es sda4 y `/` es sda2. Hidratar al FHS vivo nunca fue
   posible en una caja instalada; funciona en el hub porque ahí store y destino comparten filesystem.

El `git` arreglado se instaló copiando el artefacto a mano: funciona, y no es el camino.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 13:01:42 +00:00
SergioandClaude Opus 5 9afe5afecc SDD 28: PUERTA 4 CERRADA — la granja late en la caja nueva
Con el volumen `takana-store` montado, la cosecha completa del worker corrió: **1369 → 1575
artefactos, 0 vacíos**, exactamente los 206 que faltaban. 12,4 G por la red para 39 G lógicos
(`speedup 3.14`, el ahorro de `-H`); `/store` queda en 73,7 G de 97,9.

Anotado: el enlace con el worker va a 11 MB/s y no a los 90 de gioser↔caja, porque el LXC vive en el
Proxmox de gioser, fuera de la red de Hetzner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 12:56:50 +00:00
SergioandClaude Opus 5 40002523b2 SDD 28 §6.11: la distro traía un git que no podía clonar — y lo destapó querer ser un hub
`git ls-remote` andaba y `git clone` moría con `invalid index-pack output`, así que parecía un fallo
de red. El informe COMPLETO —no la última línea— decía qué y dónde: sha1dc leyendo un `uint32_t`
desalineado, abortado por el runtime ubsan que `-fsanitize=undefined` en CFLAGS había enlazado.

Arreglado por los dos lados: el sanitizador vuelve a ser sólo de enlace, y
`-DSHA1DC_FORCE_ALIGNED_ACCESS` quita el UB en la fuente. `git` es raíz de `perfil.base`, así que
esto arregla la distro entera y no sólo al hub.

De paso queda anotado que la clave del gitea es `~/.ssh/tawasuyu`, no la de la granja.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 12:50:30 +00:00
SergioandClaude Opus 5 ae1b0c9fe8 git: la distro traía un git que NO PODÍA CLONAR — sha1dc leía desalineado y ubsan lo abortaba
Encontrado intentando que la caja de producción clonara el repo para ser un hub de verdad
(SDD 28 §6.11). `git ls-remote` funciona; `git clone` —de cualquier repo, incluso `--depth 1`— muere:

    fatal: fetch-pack: invalid index-pack output

El informe completo, que la traza corta escondía:

    panic: load of misaligned address 0x… for type 'const uint32_t',
           which requires 4 byte alignment
      in sha1_compression_states → sha1_process → SHA1DCUpdate → git_SHA1DCUpdate
      → git_hash_update → unpack_entry_data → cmd_index_pack

Dos cosas, y las dos son de fondo:

1. **`-fsanitize=undefined` estaba en CFLAGS**, no sólo en LDFLAGS. El motivo original era legítimo
   —las `libz.a` etc. materializadas traen referencias `__ubsan_handle_*` que bajo `-static` no se
   resuelven solas, y el flag AL ENLAZAR trae el runtime de zig— pero en CFLAGS **instrumenta el
   código de git**. Un arreglo de ENLACE se había vuelto una mina en RUNTIME, y justo en la ruta de
   hash, que es por donde pasa todo lo que git recibe. Ahora va sólo en LDFLAGS.

2. **`-DSHA1DC_FORCE_ALIGNED_ACCESS`**, que es la causa real. `sha1collisiondetection` —el backend
   SHA1 por defecto, el que detecta SHAttered— lee palabras de 32 bits SIN alinear. En x86 eso
   funciona y por eso nadie lo nota nunca; según el estándar es UB, y basta con que el runtime ubsan
   esté enlazado para que aborte. El define hace que lea byte a byte: quita el UB en la FUENTE en vez
   de esconderlo. Sin él, cualquier build que arrastre el runtime vuelve a romper `clone`.

Probado: `git clone --depth 1` del propio repo desde la caja ⇒ **877 recetas, commit 543676e1**.
Antes fallaba con y sin `--depth`.

Radio cero: `git` no es dep de ninguna receta (medido). Pero SÍ es raíz de `perfil.base`, así que
esto arregla la distro entera, no sólo el hub.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 12:49:30 +00:00
SergioandClaude Opus 5 543676e1e9 SDD 28 §6.9: PUERTA 8 CERRADA — los fósiles, decididos uno por uno
Decisión del usuario (2026-09-11): **`terapeuta.ec` y `andino.ec` MUEREN**. Los 279 M de
`/var/www/terapeuta` se van con la caja y no se archivan. Los otros fósiles (aura, sigma, kosmofono,
gitea-redir, api.gioser, mail.sigma) también mueren: ni DNS ni ficheros ni dueño.

`summa`/`api.summa`/`dev.summa` no se mudan porque ya viven en otra máquina — sólo hay que sacar sus
bloques del Caddyfile al apagar gioser.

De las 19 entradas, **13 no se mudan**; la superficie real son 6 sitios y el que pesa es el gitea.

Que quede escrito ES la puerta: el día que se borre gioser, `terapeuta.ec` no se va a poder
recuperar, y la diferencia entre "lo decidimos" y "se nos pasó" es ese párrafo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 12:32:59 +00:00
Sergio 7529b4fde2 estado: cosecha granja 2026-09-11T10:31:58Z — avance del árbol KDE 2026-09-11 10:31:58 +00:00
Sergio e8adb57b77 estado: cosecha granja 2026-09-11T10:02:21Z — avance del árbol KDE 2026-09-11 10:02:21 +00:00
Sergio 71963464d2 estado: cosecha granja 2026-09-11T09:31:41Z — avance del árbol KDE 2026-09-11 09:31:41 +00:00
Sergio 2bea908767 estado: cosecha granja 2026-09-11T09:02:21Z — avance del árbol KDE 2026-09-11 09:02:21 +00:00
Sergio 25f2c7e75c estado: cosecha granja 2026-09-11T07:01:57Z — avance del árbol KDE 2026-09-11 07:01:57 +00:00
Sergio 47edbcd092 estado: cosecha granja 2026-09-11T06:01:47Z — avance del árbol KDE 2026-09-11 06:01:47 +00:00
Sergio 4d55ff8240 estado: cosecha granja 2026-09-11T05:31:52Z — avance del árbol KDE 2026-09-11 05:31:53 +00:00
Sergio 451cb198f6 estado: cosecha granja 2026-09-11T03:31:56Z — avance del árbol KDE 2026-09-11 03:31:56 +00:00
SergioandClaude Opus 5 bcd1cdefaf SDD 26 §6.7: el motor de inferencia local, y la corrección al propio documento
La tabla del §6 le ponía costo «bajo» a la IA local porque `rimay`/`iniy` parecían alcanzar. Se
miró antes de empezar y no alcanzan: los backends de `pluma-llm` son todos de nube, y
`rimay-verbo-fastembed` descarga onnxruntime (glibc) y el modelo de HuggingFace en el primer
arranque. Queda escrito, porque es lo que evita que alguien vuelva a estimarlo en «bajo».

Y de paso junta dos filas que el plan llevaba separadas: el §6.7 y la mitad semántica del §6.3 no
eran dos problemas — era que el corpus no tenía con qué correr un modelo. Un solo muro.

Queda documentado el hallazgo caro, con las dos líneas de cmake enfrentadas: SOURCE_DATE_EPOCH
—que exportamos para que los builds REPRODUZCAN— apagaba INS_ENB y con él las seis perillas de ISA
de ggml, sellando un motor de inferencia con CERO instrucciones vectoriales que corría igual.
Tabla 0/0/0 vs 42.356/1.298/86, medida sobre artefactos sellados.

Y las dos trampas del arnés que van a volver cuando se pinee el modelo de producción: `/health`
contesta 503 mientras carga, y un GGUF sin tipo de pooling hace que el endpoint compatible con
OpenAI conteste 400 — un error que parece del cliente.

⚠ Escrito también lo que esto NO es: el motor no es la función. Falta el modelo (fuente pineada,
como el perfil de PGO), los verbos del host y quién levanta el servidor. Y `llama-cpp` no se
declara en ningún perfil todavía: una imagen no crece 199 M por una función que aún no existe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqBhowvFe3aiieGCKgvxwa
2026-09-11 03:05:20 +00:00
SergioandClaude Opus 5 fb8bbba0b9 SDD 28 §6.10: el respaldo desde otro hub destapó CINCO bloqueos, uno de ellos destructivo
El más grave: el paso del repo va con `--delete`, y el `/opt/takana` de la caja llegó por
`rsync --exclude=.git`. Una corrida real desde ahí habría BORRADO `.git` del Storage Box — el
historial entero — en silencio, porque rsync haría exactamente lo que se le pidió.

La regla que sale y que vale más allá de este script: **un `--delete` convierte «respaldar» en
«sincronizar», y sincronizar desde un origen incompleto no sube menos: BORRA.** Cualquier respaldo
con `--delete` necesita una guarda de completitud del ORIGEN, no sólo del destino.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 03:05:12 +00:00
SergioandClaude Opus 5 d1d6645bd0 respaldo: una guarda que evita BORRAR el historial de git del Storage Box
Encontrado corriendo el respaldo desde la caja de producción (SDD 28, puerta 7).

El paso [2/3] sube el repo **con `--delete`** —correcto: para eso está git—. Pero el `/opt/takana` de
la caja llegó por `rsync --exclude=.git`: es una COPIA, no un clon. Una corrida real desde ahí no
habría subido menos cosas: **habría borrado del respaldo todo lo que le falta al origen, empezando
por `.git`** — el historial entero. Y en silencio, porque rsync haría exactamente lo que se le pidió.

Ahora falla ruidosamente si la raíz no tiene `.git`, con `REPO_INCOMPLETO=1` como escotilla para el
caso deliberado. Probado en los tres sentidos: en gioser (con `.git`) pasa; en un árbol sin `.git`
aborta; con la escotilla pasa igual.

**Y el store tampoco estaba donde el script creía.** Usaba `$RAIZ/store` cableado — en gioser es un
bind-mount dentro del repo, pero en una caja takana instalada es una partición en `/store`, así que
el paso [3/3] moría con `change_dir "/opt/takana/store" failed`. Peor: rsync devuelve 23, que está en
la lista de reintentables, así que el bucle lo reintentaba — exactamente el cuadro que la cabecera de
este script ya documenta («un error reintentable que se repite 40 veces no es un corte de red: es
algo estructural»). Ahora `STORE` es env y hay una guarda ANTES del bucle que aborta si no existe.

Con eso el `--seco` completa los tres pasos desde la caja y lee la ocupación del box (111 G de 1 T).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 03:04:24 +00:00
SergioandClaude Opus 5 4190c7b43e atuq §6.7: entra el motor de inferencia local — y SOURCE_DATE_EPOCH le había apagado el AVX2
La IA local de la barra lateral (§6.7) y la mitad semántica del archivo personal (§6.3) figuraban
como dos pendientes distintos. Son uno: el corpus no tenía con qué correr un modelo. `pluma-llm`
sólo trae backends de NUBE y `rimay-verbo-fastembed` DESCARGA onnxruntime (glibc) y el modelo de
HuggingFace en el primer arranque. `recipes/llama-cpp.toml` (b10901, estática, 199 M) derriba el
muro entero: el mismo binario sirve `/v1/chat/completions` y `/v1/embeddings`.

⚠ Lo que este commit deja medido, y es lo que no se podía deducir: el PRIMER sello llevaba sólo
`-DGGML_NATIVE=OFF` —leído en `ggml/CMakeLists.txt:141`, que con NATIVE=OFF debería ENCENDER las
perillas explícitas de ISA— y salió con CERO instrucciones vectoriales. La causa está 36 líneas
más arriba: `if (CMAKE_CROSSCOMPILING OR DEFINED ENV{SOURCE_DATE_EPOCH})` apaga
`GGML_NATIVE_DEFAULT`, y el sandbox de takana exporta `SOURCE_DATE_EPOCH=1` (`sandbox.rs:453`)
justamente para que los builds REPRODUZCAN. O sea: la variable que nos da reproducibilidad apagaba
todas las instrucciones vectoriales del motor de inferencia — y no falló nada. El artefacto selló,
`--version` contestaba, y el binario era x86-64 pelado: 0 `%ymm`, 0 `vfmadd`, 0 `roundps` en
1.634.770 líneas de `objdump -d`. Ahora las seis van declaradas una por una y el `install` LAS
COMPRUEBA en el binario: 42.356 `%ymm` / 1.298 `vfmadd` / 86 `roundps`.

`strip_debug = true` porque zig cc emite debug_info por defecto y son 16 binarios estáticos:
1,8 G → 199 M.

Guardián `scripts/test-llama-cpp.py`, sobre el artefacto VIGENTE (resuelto por `takana hash`, no
por `ls store/*`) y con control negativo vivo (`--sin-modelo`, que TIENE que fallar): ISA,
hermético (0 NEEDED), una pasada de inferencia REAL con `stories260K` pineado por sha256, y el
endpoint de embeddings — vector de verdad, el mismo texto da el mismo vector, dos textos distintos
dan vectores distintos, y no son ceros. Y `verificar-repro.sh`: REPRODUCE, 0 no-determinismos.

⚠ Esto es el MOTOR, no la función. Un motor sin modelo no contesta nada: el modelo de producción
es fuente pineada aparte, como el perfil de PGO de firefox, y es su propia unidad de trabajo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqBhowvFe3aiieGCKgvxwa
2026-09-11 03:03:31 +00:00
Sergio 9bbd2e42c9 estado: cosecha granja 2026-09-11T03:02:03Z — avance del árbol KDE 2026-09-11 03:02:03 +00:00
SergioandClaude Opus 5 52144e040f SDD 28 §6.9-6.10: los 19 dominios sondeados uno por uno, y el respaldo corriendo desde la caja
**Puerta 8 (fósiles), sondeada por DNS Y por HTTP**: de las 19 entradas del Caddyfile, gioser sólo
sirve **6** de verdad (las dos landings, el gitea x2, sergio x2). Las otras 13 NO hay que mudarlas:
8 no tienen DNS, 2 dan 502, y **3 ya viven en otra máquina** (`summa`, `api.summa`, `dev.summa` →
154.197.1.2) aunque el bloque de Caddy siga en gioser. O sea que la superficie real a mudar son 6
sitios, y el que pesa es el gitea porque aloja el `origin` de takana.

⚠ `terapeuta.ec` es el único fósil CON DATOS (279 M en /var/www) y sin DNS: hay que decidir
explícitamente si se archiva o muere con la caja.

**Puerta 7**: la caja alcanza el Storage Box y refresca el manifiesto — 3140 artefactos, con 2
VACÍOS detectados y excluidos (`gnome-desktop` ×2). Para llegar ahí hubo que arreglar tres bloqueos
de la misma familia —*el instrumental asume que el hub es gioser*—: la raíz cableada, el binario de
desarrollo, y el rsync sin zstd que hacía que el respaldo no se ejecutara en absoluto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 03:00:54 +00:00
SergioandClaude Opus 5 01b1dcfefe respaldo: el rsync del propio corpus no podía correr el respaldo del propio corpus
Corriendo el respaldo desde la caja de producción (SDD 28, puerta 7):

    ==> [1/3] estado (el grafo: qué había construido y con qué hash)
    unknown compress name: zstd
    !!  estado: error 4 que NO es de red — no reintento

El script exige `--compress-choice=zstd` (medido: el doble de rendimiento efectivo, porque el 79 %
del store son secciones `.debug_*` y comprimen como texto) y **`recipes/rsync.toml` lo construía con
`--disable-zstd`**. El comentario de la receta decía por qué: «deps externas quitadas (no en
catálogo)». Ya no es cierto — `zstd` tiene receta y está sellada.

Dos arreglos, y los dos hacen falta:

1. **La receta enciende zstd** (dep `zstd`, fuera el `--disable-zstd`). Control en los dos sentidos:
   el rsync nuevo lista `zstd zlibx zlib none`, el anterior `zlibx zlib none`. Radio cero: `rsync` no
   es dep de ninguna receta (medido), así que no re-hashea nada más.
2. **El script DEGRADA en vez de morir.** Detecta el soporte (`rsync --version` → «Compress list»)
   y cae a zlib avisando. Un respaldo que no corre por un algoritmo de compresión es peor que un
   respaldo lento — y el fallo era especialmente malo porque el error 4 se clasifica como "no de red"
   y el script no reintenta: el respaldo simplemente no se hace.

Y de paso queda cubierto el caso de un hub que todavía no reconstruyó su rsync.

**También la raíz cableada**: el script hacía `cd /mnt/vvv/takana` por defecto —la ruta de gioser—
así que desde cualquier otro hub moría con `No such file or directory`. Ahora se deriva de la
ubicación del script, como el resto de `scripts/`; el override por `RAIZ` se conserva. Control: en
gioser sigue resolviendo a `/mnt/vvv/takana`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 02:59:13 +00:00
SergioandClaude Opus 5 6d90323fb7 SDD 28 §6.8: puerta 4 — el camino de la granja probado de punta a punta; falta disco, no ingeniería
Desde la caja, sin tocar el latido de gioser: llevada la clave de la granja y el `.fleet` (los dos
fuera de git a propósito), la caja alcanza al worker `PruebasIA`, `estado-granja.sh` corre allá y lee
bien el avance KDE, y —lo que cierra el lazo— se cosechó un artefacto REAL del worker
(`speedtest-go`, 12 M) con el transporte de la granja: llegó con su `.hammer/recipe.toml` y **su
binario CORRE en la caja**. Worker construye → hub cosecha → binario ejecuta.

Lo que falta es CAPACIDAD: 206 artefactos del worker que la caja no tiene, ~23 G a la media del
corpus, contra 3,7 G libres (94 % de ocupación). Forzarlo repetiría el llenado que dejó 1367
artefactos vacíos.

Las tres salidas quedan escritas: enganchar `harkaq-cosecha` (= el cutover, gioser deja de construir
en ese instante y hoy está moliendo KDE), un volumen nuevo para la caja (~€5/mes, no interrumpe
nada), o podar (deshace la mudanza). Es decisión del usuario, no de ingeniería.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 02:45:09 +00:00
Sergio 953706909d estado: cosecha granja 2026-09-11T02:32:11Z — avance del árbol KDE 2026-09-11 02:32:11 +00:00
SergioandClaude Opus 5 9a62f452d3 SDD 28 §6.7: PUERTA 3 EN VERDE — los dos grafos idénticos byte a byte
gioser: 7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605
    caja:   7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605

`base 61/61 · cli 84/84 · mirada 41/41 · servidor 96/96 · falta 0`, `wanted 3` (chrony, cronie,
logrotate: la deuda declarada), grafo CIERRA, topo-sort OK, en las dos máquinas. Es la prueba de que
un segundo hub está bien montado — no que "funcione".

Costó tres intentos y los dos fallos son la parte que vale:

1. **`unhashable 875`**, todas, con el lab bien y `takana hash` andando a mano. Los scripts asumen un
   árbol de DESARROLLO (`HAMMER = ROOT/target/release/takana`); en una caja instalada el binario es
   `/usr/bin/takana` y `target/` ni existe. No falla ruidosamente: sale el corpus entero como
   `unhashable`, que se lee como "este corpus no se puede hashear".
2. **Dos nodos divergentes** (`aichat`, `zola`): `sealed` en gioser, `never` en la caja. No era deriva
   ni pérdida — **tampoco están en disco en gioser**: son los 2 sellados avalados por los MANIFIESTOS
   (`work/farm-sellados.txt`, `work/respaldo-sellados.txt`). Y `work/` está gitignored, así que un hub
   recién sembrado no los tiene y degrada esos nodos en silencio.

Misma familia que `.fleet`: un fichero fuera de git del que depende una medición compartida.
**Sembrar un hub no es clonar el repo: es repo + store + lab + manifiestos.**

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 02:28:19 +00:00
SergioandClaude Opus 5 0fd87745c6 SDD 28 §6.6: el lab es parte de la IDENTIDAD, y copiar un store con rsync -a lo revienta
**Un hub no es repo+store: es repo+store+LAB.** Con python3 ya vivo, `takana hash` en la caja seguía
fallando por el apk db del lab: el toolchain entra en el ArtifactHash, así que una máquina sin lab no
puede ni preguntar cuál es el hash vigente de una receta — no puede computar el grafo. Sembrado el
lab, el hash testigo coincide byte a byte en las dos máquinas (`zlib` → `b3:dc363f26…`).

**Y la primera copia del store murió con el disco lleno, dejando 1367 de 1368 artefactos VACÍOS.**
La puerta 2 corrida EN EL DESTINO lo cazó en el acto — para eso está.

La causa, medida sobre el store de gioser:

    du -sh                  60 G   (hardlinks contados UNA vez)
    du -sh --count-links    85 G   (hardlinks EXPANDIDOS)
    .dmerge                 11 G   (la caché que hardlinkea al store)

`rsync` sin `-H` NO preserva hardlinks: los expande en copias enteras. O sea que «el store son 60 G»
—lo que dice `du` y lo que uno planifica— son 85 G al copiarlo, más lo que `.dmerge` expanda. La
partición de 68,7 G no tenía ninguna chance.

La copia correcta es `rsync -aH --exclude='.dmerge'`. Y la lección general: **`du -sh` sobre un árbol
con hardlinks no dice cuánto ocupa COPIARLO**; para dimensionar hay que medir con `--count-links`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 02:17:29 +00:00
SergioandClaude Opus 5 9a31857eaa targets: zstd en base — un takana recién instalado no podía desempacar su propio laboratorio
La distro comprime TODO con zstd: la imagen del lab (`lab-image.tar.zst`), el respaldo al Storage
Box, el `dd` remoto de una instalación. Y la imagen no traía el binario.

Medido sembrando el lab en la caja de producción: el `tar` del rootfs es el de busybox y contesta
`tar: unrecognized option: zstd`, así que hubo que extraer por tubería desde el hub
(`zstd -dc … | ssh caja 'tar -xf -'`). Un hub que se instala solo no puede depender de que otro hub
le descomprima las cosas.

La receta ya existía y está sellada (`b3:1ceb6215…`): sólo faltaba declararla.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 02:04:55 +00:00
Sergio 7bdb781352 estado: cosecha granja 2026-09-11T02:01:57Z — avance del árbol KDE 2026-09-11 02:01:57 +00:00
SergioandClaude Opus 5 7e41ffd0e6 SDD 28 §6.5: el muro derribado — de 23 binarios inertes a 1, y sin mover un ArtifactHash
Elegidas las salidas 1 y 2 del §6.4; hecha la 1, que resultó más barata de lo que parecía y desbloquea
la 2 en vez de competir con ella.

`musl-shared` publica `/usr/lib/libc.so` + `/lib/ld-musl-x86_64.so.1`. Como variante y no tocando la
canónica: `musl` es Stage 1 y su `of_tree` es el baseline del selfhost. Control: `takana hash
recipes/musl.toml` sigue en `b3:ce952f72…`. Y `musl` no es dep de nadie ⇒ cero re-hasheo del corpus.

En la caja: `sh: python3: not found` → `Python 3.12.10`. De 23 inertes quedó **1**, `sqlite3`, que
pedía `libz.so.1`; `zlib-shared` ya estaba sellado y sólo faltaba declararla en `base`.

La salida 2 (python/perl estáticos) sigue viva pero deja de ser urgente: con el cargador publicado el
sistema ya no cuelga del rootfs del lab.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 02:00:51 +00:00
SergioandClaude Opus 5 7e86459738 musl-shared: el corpus publica su propio CARGADOR — 23 binarios dejan de ser inertes
La imagen del perfil servidor traía 23 binarios de 726 que no podían ejecutarse:

    $ python3 -c "print(1)"
    sh: python3: not found        <- y `command -v python3` decía /usr/bin/python3

No faltaban: eran ELF dinámicos pidiendo `/lib/ld-musl-x86_64.so.1`, y **ningún artefacto del corpus
publicaba ese fichero**. Funcionaban en el hub sólo porque el rootfs Alpine del LAB lo presta — la
misma fuga que documentan `zlib-shared` y `expat-shared`, un piso más abajo, y peor: el lab no entra
en `hash_inputs`, así que la dependencia era invisible para el store.

Los 23 son la suite binutils entera + perl, python3, sqlite3, flex y nft. Entre ellos, TODO el
instrumental del proyecto, que es Python.

**Variante y no `--enable-shared` en la canónica**: `musl` es componente de Stage 1 y su `of_tree` es
el baseline de `selfhost-verify`. Re-hashearlo obliga a rehacer ese baseline a propósito, y eso es su
propia unidad de trabajo. Con el patrón `*-shared` (ya hay 23 en el corpus) la canónica no se mueve —
control: `takana hash recipes/musl.toml` sigue dando `b3:ce952f72…`, el mismo sellado de Stage 1. Y
además `musl` no es dep de NADIE (medido: sólo de sí misma; el enlace estático lo resuelve el musl de
zig), así que esto suma sin mover un solo ArtifactHash del corpus.

**⚠ `compiler = "gcc"` y no `zig-cc`, medido.** Con zig cc el `libc.so` sale con 1586 símbolos
dinámicos contra los 1654 de Alpine, y los 68 que faltan son EXACTAMENTE los que musl implementa en
ensamblador x86_64 (`memset memcpy memmove memcmp strlen` y toda la familia matemática) más los
`__stack_chk_*`. No es que no se compilen —en el `libc.a` canónico están—: zig los resuelve con su
propio musl y los deja `FUNC LOCAL HIDDEN` de tamaño 0, fuera de la tabla dinámica. El síntoma es un
cargador que arranca, reloca y muere con `memset: symbol not found`, que se lee como "el binario está
roto" y no como "a la libc le faltan símbolos". Con gcc: **1651 símbolos**, `memset` y `ceil`
presentes, y `python3 3.12.10` CORRE con el cargador del corpus.

De paso: el comentario de `musl.toml` decía que el artefacto aportaba «el loader, para uso dinámico
futuro». Era falso —`--disable-shared` no construye ninguno— y es justo la etiqueta que hizo que
nadie buscara el agujero. Corregido (los comentarios no entran en `hash_inputs`; verificado).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 01:47:54 +00:00
Sergio ef6164861e estado: cosecha granja 2026-09-11T01:32:07Z — avance del árbol KDE 2026-09-11 01:32:07 +00:00
Sergio 7039afdccf estado: cosecha granja 2026-09-11T01:02:16Z — avance del árbol KDE 2026-09-11 01:02:16 +00:00
Sergio 38338913eb estado: cosecha granja 2026-09-10T23:31:52Z — avance del árbol KDE 2026-09-10 23:31:52 +00:00
Sergio 2f930322cc estado: nftables verificada — REPRODUCE (el sello de tiempo lo neutraliza el parche) 2026-09-10 23:19:08 +00:00
Sergio 7c4617aeb2 SDD 26: fila 10 del plan — el foco (6.5) con su mitad medida y lo que falta nombrado 2026-09-10 23:18:22 +00:00
Sergio 10c499777e atuq §6.5.bis: la extensión foco — muestra el foco del sistema y no tiene con qué apagarlo
La mitad del navegador del §6.5, deliberadamente asimétrica: sólo lee. Pregunta `focus.state` (nuevo
en puriy-costura, commit 38815b5f3) y pinta tres estados — `foco`, nada, y `?` cuando nadie escribió
el estado. Ese tercero es el que importa: si «no sé» se redondeara a «apagado», la insignia afirmaría
que no hay foco sin haberlo mirado.

No hay verbo para apagarlo, y es la propiedad y no un pendiente: si el navegador pudiera levantar el
foco, valdría lo que vale un bloqueador de extensión. En tawasuyu hay un test que lo fija; acá el
guardián mide el EFECTO — tras una sesión entera, el fichero de estado quedó igual.

`scripts/test-atuq-foco.py` corre los tres estados sobre el path de PRODUCCIÓN (`/etc/takana/focus`),
no la escotilla de pruebas: una escotilla mide el código, no el contrato con la imagen. Verde sobre
el artefacto vigente (atuq 5d1afc50, puriy-costura 0de6b4ca), y verificado rompiéndolo — con una
extensión parcheada que pinta «foco» siempre (en una COPIA del artefacto, vía ATUQ_DIR), falla
nombrando la insignia.
2026-09-10 23:17:32 +00:00
SergioandClaude Opus 5 be368df070 SDD 28 §6.3-6.4: puerta 2 en verde, el disco arreglado, y la puerta 3 choca con un muro estructural
**Puerta 2 **: 1362 artefactos reales, 0 vacíos. (El primer conteo dio «3 vacíos» y eran
`.mirror-tmp`, `.bootstrap-tmp` y `.divergen` — directorios de trabajo, no artefactos: el chequeo
tiene que acotar por el patrón `<hash>-<nombre>`, no listar el directorio.)

**El disco**: la imagen ocupaba 7 G de 76,3 y los otros 69 eran inalcanzables. Arreglado; `/store`
en la caja pasó de 487 M a **68,7 G**. Con eso el store de 55 G entra en disco local y **la mudanza
ya no depende de desenganchar el volumen de gioser** — que era la parte que paraba la granja.

**Puerta 3 🚧 BLOQUEADA, y no por configuración.** Al ir a computar los grafos:

    $ python3 -c "print(1)"
    sh: python3: not found        <- y `command -v python3` decía /usr/bin/python3

Está y no arranca: es un ELF DINÁMICO que pide `/lib/ld-musl-x86_64.so.1`, y **en la imagen no hay
ningún cargador dinámico**. Tampoco en el store: el artefacto `musl` publica sólo lo estático
(`libc.a`, `crt*.o`, headers) — no hay `libc.so` ni `ld-musl` en NINGÚN artefacto del corpus. Lo que
hace que esto funcione en gioser es el rootfs Alpine del LAB.

Contado sobre la imagen: **23 binarios inertes de 726**. La suite binutils entera, más perl, python3,
sqlite3, flex y nft.

Frena la mudanza porque TODO el instrumental del proyecto es Python (build-state, targets, yupana,
hydrate-profile, drenar, triaje): un servidor takana no puede correr ni una de sus herramientas.

Las tres salidas —publicar el cargador desde musl (toca Stage 1 y mueve el baseline del selfhost),
python/perl estáticos (frente propio), o aceptar que el instrumental vive en el hub (contradice
apagar gioser)— son decisión de ADR. Queda escrito, medido, y sin tocar nada de Stage 1 de rebote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-10 23:04:23 +00:00
Sergio 9ef875005d estado: cosecha granja 2026-09-10T23:01:57Z — avance del árbol KDE 2026-09-10 23:01:58 +00:00
SergioandClaude Opus 5 63b1c42cc9 install-image: el store va ÚLTIMO y CRECE al disco entero en el primer arranque
Una imagen se escribe con `dd` sobre un disco casi siempre más grande que ella. La del perfil
servidor son 7 G; la caja hcloud tiene 76,3 G. **Sobraban 69 G que nadie podía usar**, y la caja
arrancaba perfecta con el disco a un décimo — el fallo que no falla, otra vez.

Dos cambios que van juntos:

1. **El store pasa a ser la ÚLTIMA partición** (p1 bios, p2 `/`, p3 `/var/lib/hammer`, p4 `/store`).
   Sólo la última puede extenderse sin mover datos, y el store es justamente la que crece con el uso.
   Con él en el medio, la partición extensible era la de ESTADO, de 512 M, que no le sirve a nadie.
   El cambio es seguro porque nada referencia números de partición: `root=PARTLABEL=hammer-root` y
   el wrapper monta por etiqueta con `findfs`.

2. **El wrapper de `/sbin/init` la extiende en el primer arranque**, antes de montarla:
   `sfdisk -N <n> ', +'` → `partx -u` → `e2fsck -pf` → `resize2fs`. Idempotente: si ya llega al
   final, los dos últimos no hacen nada. Guardado tras `-x /sbin/sfdisk` para no romper un rootfs
   que no lo traiga, y el aviso va a `/dev/kmsg`, que es donde se lee.

Probado en QEMU volcando la imagen de 7 G en un disco de 20 G: el arranque imprime
`init: store: /dev/sda4 extendido al final de /dev/sda` y `/store` queda en **13,3 G** con 12,6 G
libres, sin tocar nada a mano.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-10 22:59:39 +00:00
Sergio 212b304b60 atuq §6.5: el foco por cgroup MEDIDO — y corregido el propio documento
La fila de la tabla se leía como «bloquear sitios». El cortafuegos NO sabe de sitios: su política de
egress tiene UNA dimensión, qué cgroup sale, y ninguna de destino (leído en UnidadRed, no supuesto).
El foco que estas piezas dan es «el navegador no sale», con todo lo local andando — promesa más
honesta, además: una lista de dominios se esquiva con un espejo; un cgroup sin egress no.

Medido con nuestro propio nft, en el LXC donde somos root:
  linea-base exit=0 · control exit=0 · foco exit=1
Tres medidas y no una: la línea base porque un harness roto se lee igual que un foco que funciona, y
el control porque un reglaset que niega de más tampoco se distingue. `--broken-rules` abre egress al
cgroup en foco y el guardián falla nombrándolo (sale 1). Verificado en los dos sentidos.

Restricciones reales que salieron de medir: crear un cgroup pide root (EPERM incluso en un userns con
CAP_ALL), y `nft -c` no es sintaxis — resuelve el path del cgroup contra la máquina viva.

⚠ Y lo que rompí en el camino, documentado: `/sys/fs/cgroup` está montado SHARED, así que desmontar
la copia de un --rbind se propaga al montaje real. Dejé al worker sin cgroup2 dos veces, en silencio.
Remontado y jerarquía intacta. Ahora todo va en un mount namespace propio (--propagation private),
sin `umount -l` antes de un rm -rf, y con /proc/mounts consultado antes de borrar.

Tres falsos positivos más que cazó el harness, todos con cara de éxito: un gmp viejo tomado por
`ls store/*-gmp` (sin .so ⇒ nada conectaba, y el lab del hub lo tapaba), /sbin fuera del PATH del
chroot (sin `ip`, loopback caído), y un COMENTARIO que se ejecutó — backticks dentro de un heredoc
sin comillas corrieron `ip`/`ifconfig` en el host y pegaron su salida en el guión generado.
2026-09-10 22:55:10 +00:00
Sergio 6704ebe033 estado: cosecha granja 2026-09-10T22:32:06Z — avance del árbol KDE 2026-09-10 22:32:06 +00:00
Sergio 817cd65d41 corpus: la cadena nftables (libmnl + libnftnl + nft 1.1.6), que el grafo pedía y nadie había puesto
Entra por el §6.5 del SDD 26 —el «modo foco» de atuq lo sostiene el cortafuegos del sistema y no
una extensión que el navegador puede apagar— pero `nftables` estaba `wanted` en el grafo desde
antes: la cadena sirve igual al servidor de producción del SDD 28.

Selladas y MEDIDAS con el consumidor, no por presencia:
  nft --version → nftables v1.1.6, corriendo desde nuestro artefacto
  nft -c -f / nft -f  → aceptan Y APLICAN en un netns privado (bwrap --unshare-net + CAP_NET_ADMIN)
  socket cgroupv2 level 1 "<cgroup>" → la expresión EXACTA que genera cortafuegos-core: aplica

Dos hallazgos del camino, los dos en los comentarios de la receta:

1. nftables NO reproduce de fábrica: `MAKE_STAMP` es `$(shell date +%s)` y `nftversion.h` lo mete
   byte a byte en el binario — la familia del BuildID de waterfox. No mira SOURCE_DATE_EPOCH. Se
   fija en 0, que además es lo correcto: el sello sólo sirve para comparar qué nft creó una tabla,
   y dos builds de la misma versión SON el mismo nft.
2. su `config.status` trae un bashismo (`for ((i = 56; ...))`) que busybox rechaza con
   «bad for loop variable». Se arregla en el parche y no con CONFIG_SHELL=bash: el lab no entra en
   `hash_inputs`, así que apoyarse en su bash sería una dependencia invisible.

Y una medición que condiciona al guardián que viene: `nft -c` NO es un chequeo de sintaxis — resuelve
el path del cgroup contra la máquina viva y falla con «cgroupv2 path fails» si no existe. O sea que
verificar una política del cortafuegos exige crear los cgroups, y eso pide root.
2026-09-10 22:30:18 +00:00
Sergio e063fc16e9 estado: cosecha granja 2026-09-10T22:01:45Z — avance del árbol KDE 2026-09-10 22:01:45 +00:00