Commit Graph
2477 Commits
Author SHA1 Message Date
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