gioser

sergio/takana

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

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ 13769aa7b41bf597cf8d0ff840d0ecfae76fdb5c docs/28-servidor-de-produccion.md Markdown · 3982 líneas · 239.2 KB Historia crudo

docs(servidor): y el 6.51 también estaba tomado — el del latido es el 6.56 e0f4e6fd4 · Sergio ·

1# SDD 28 — El servidor de producción: instalar takana puro en una caja remota y mudarse a ella
2
3Escrito 2026-09-09 a pedido del usuario («qué tan lejos estamos de montar un server takana en
4Hetzner y que sea producción, y mover allá todo lo que tengo acá, y de una vez mantenerlo por repo
5takana»). Todo número marcado *(medido)* salió de un comando corrido ese día sobre `gioser`.
6
7Continúa [SDD 19](19-lanzamiento-publico.md) (lanzamiento público) y
8[SDD 27](27-perfiles-instalables-y-el-instalador.md) (metapaquetes e instalador). **No repite el
9instalador**: SDD 27 decide QUÉ se instala y con qué verbo; esto decide DÓNDE, y cómo se llega.
10
11---
12
13## 0. La corrección de encuadre: ya estamos en Hetzner
14
15`gioser` **es** un servidor Hetzner (hcloud id `126324694`, hel1, 4 cores / 7,6 GiB) *(medido)*. La
16pregunta no era «cómo llegar a Hetzner» sino **cómo tener una caja de takana** en vez de ser un
17inquilino más de una máquina compartida que:
18
19- sirve **19 dominios** por Caddy,
20- tiene `/mnt/vvv` al **89 %** (27 G libres) *(medido)*,
21- es **fija y sin backup** por pedido explícito del usuario (blindaje anti-borrado en `deadman.sh` y
22  `farm-down.sh`, ver [ADR 0014](adr/0014-distribucion-multiorigen.md) y la memoria del proyecto),
23- y **no publica nada de takana**: cero entradas de takana en el Caddyfile *(medido)*.
24
25### 0.1 El hallazgo que cambia el tamaño del problema
26
27```
28$ ps -p 1 -o comm=,args=
29arje-zero  /usr/local/sbin/arje-zero
30$ cat /proc/cmdline
31BOOT_IMAGE=/vmlinuz-linux root=UUID=54de62df-… rw net.ifnames=0 splash \
32  init=/usr/local/sbin/arje-zero loglevel=4
33```
34
35**El init de takana ya gobierna un servidor de producción real.** Caddy, gitea, sshd, qdrant y
36act-runner cuelgan directo de PID 1 *(medido: `ps -eo pid,ppid,comm | awk '$2==1'`)*.
37
38Pero **eso no es takana puro**: es Artix con nuestro init inyectado por `init=` en el cmdline. El
39kernel, la libc y los 19 servicios son ajenos. Se probó la mitad difícil sobre userland prestado.
40
41### 0.2 ⚠ La declaración del arranque no existe en ningún sitio legible
42
43`rc-status` de OpenRC dice `stopped` para **caddy, gitea, sshd, cronie y act-runner**, y los cinco
44están **vivos** *(medido, los dos comandos)*. `/etc/arje/cards.d` tiene **4 tarjetas** (`acpid`,
45`openrc-openclaw`, `squid`, `zram-swap`) y ninguna de ellas los levanta.
46
47⇒ **La secuencia de arranque de esta caja no está escrita.** Es lo menos reproducible de toda la
48mudanza y lo primero que hay que capturar — ver §4.
49
50---
51
52## 1. Inventario de lo que hay que mudar *(medido 2026-09-09)*
53
54### Datos en `/mnt/vvv` (217 G de 255)
55
56| | |
57|---|---|
58| `tawasuyu` | **124 G** |
59| `takana` | **91 G** — incluye el store de 55 G bind-mounteado desde el otro volumen ⇒ ~36 G propios |
60| `rustup` | 29 G |
61| `act-runner` · `gioserv` · `_respaldo` | 6,5 · 5,4 · 4,1 G |
62| ~20 dirs `*-standalone` | agora, card, chasqui, cosmos, dominium, khipu, minga, mirada, nakui, nahual, pata, pineal, pluma, shuma, takiy, llimphi… |
63
64### El volumen `harkaq-cosecha` (250 G, 148 G usados)
65
66| | |
67|---|---|
68| `store` | **55 G**, **1327 artefactos** — lo irreemplazable |
69| `work-sources` + `cargo-home` + `gopath` | ~85 G — **caché regenerable**, no se muda |
70| `work-tarballs` · `devfs-cache` · `work-out` | 3,2 G · 4,5 G · 397 M |
71
72**El store no se copia por red.** Los dos volúmenes están en `hel1`; un volumen hcloud se desengancha
73de una caja y se engancha a otra en la misma ubicación. La mudanza del store son dos comandos.
74
75### Servicios vivos, todos hijos de arje-zero
76
77`caddy` :80/:443 · `gitea` :3002 · `sshd` :2345 · `act_runner` (el CI de tawasuyu) · `qdrant`
78:6333/:6334 · `openclaw` · `zeroclaw` · `shuma-gateway` :7378 · `tejido` :4102 y :33097 ·
79`puerta-f6e393ff` · `uvicorn` :8771 · `python3` :8770 · `squid` :1137 · `fail2ban` · `glances` ·
80`php-fpm` · `metalog` · **`adb` :5037**.
81
82### Lo que no está en ningún repo
83
84`/var/lib/gitea` (**2,2 G**: SQLite + los repos de git de todo, **incluido el `origin` de takana**) ·
85`/var/www` (287 M) · `/home/sergio/gioser-web/` · `/etc/caddy/Caddyfile` **+ 20 ficheros `.bak`** ·
86los certificados de letsencrypt · los crontabs · `/etc/arje/cards.d` · las 3 credenciales de
87`~/.config/hammer/*.env` · las llaves `~/.ssh/{github5,sergio}` · el token hcloud · y **la memoria
88del agente en `~/.claude/`**.
89
90### 1.1 La basura, contada — porque una mudanza fiel copia también la basura
91
92De los 19 dominios del Caddyfile *(probados uno por uno desde la propia caja)*:
93
94| responden 200 | rotos |
95|---|---|
96| gioser.net · tawasuyu.net · git.gioser.net · summa.gioser.net · sergio.gioser.net | **api.gioser.net → 502** (backend caído) |
97| | **aura · sigma · kosmofono · terapeuta.ec → sin DNS** |
98
99Y sus raíces **no existen en disco**: `/var/www` sólo tiene `terapeuta`, `goaccess`, `ogisoer`,
100`git-tawasuyu`, `letsencrypt` y un `.bak` — no hay `aura_frontend`, ni `sigma`, ni `summa/frontend`,
101ni `kosmofono`. **La mitad del Caddyfile describe un servidor que ya no existe.**
102
103⇒ **Regla de la mudanza: no se muda lo que no responde.** Lo que no tiene DNS ni ficheros se anota y
104se deja morir con la caja vieja. La utilidad del §4 tiene que saber decir *declarado / vivo / fósil*.
105
106---
107
108## 2. La caja: qué, cuánto y por qué
109
110Precios consultados a la API de hcloud el 2026-09-09 (hel1, x86, tipos no deprecados) *(medido)*:
111
112| tipo | recursos | €/mes |
113|---|---|---:|
114| **`cx53`** | **16 c / 32 G / 320 G** | **29,49** |
115| `cpx41` | 8 c / 16 G / 240 G | 32,49 |
116| `ccx23` (dedicado) | 4 c / 16 G / 160 G | 85,99 |
117
118**Elegido `cx53`**: es el mejor punto de la tabla y entra holgado el conjunto tawasuyu + takana +
119rustup (~244 G) si algún día se muda todo.
120
121**Decisiones del usuario (2026-09-09):**
122
1231. **La caja PUBLICA; el LXC prestado sigue moliendo.** `dev.gioser.net` cuesta €0 y es la máquina
124   con más RAM (6 c / 16 G + 8 G swap). Separar *servir* de *compilar* mantiene la caja chica y
125   barata. La caja nueva puede además compilar, con el LXC enchufado como segundo worker.
1262. **«Producción» = operativo pero privado.** Sin descarga pública todavía ⇒ **no** se activan los
127   bloqueantes legales del [SDD 19 §2](19-lanzamiento-publico.md) (espejo de fuentes público por
128   HTTP, marca de terceros). Lo que sí hace falta igual es la **clave de release estable** (§5).
1293. **gioser se BORRA** cuando la mudanza esté comprobada. Eso convierte esto en un experimento con
130   fecha de caducidad, y cambia el diseño entero: ver §6.
131
132⚠ **El blindaje anti-borrado de gioser es deliberado y hay que levantarlo a mano y a propósito.**
133`deadman.sh` y `farm-down.sh` lo protegen por lista negra de nombre **y** por ausencia del label
134`role=hammer-worker`. Ninguna automatización debe poder borrarlo: cuando llegue el día, se borra a
135mano, con la comprobación del §6 en verde y no antes.
136
137---
138
139## 3. Instalar takana puro en una caja remota
140
141### 3.1 Lo que ya está, y por qué está más cerca de lo que parece
142
143**El dato que lo destraba: Hetzner Cloud arranca por BIOS, no por EFI.** *(medido en gioser: no
144existe `/sys/firmware/efi`; GPT + GRUB; `/boot` es vfat de 1 G.)* Y `scripts/takana-install.sh`
145escribe **exactamente ese layout**: GPT + GRUB BIOS, `SeaBIOS → GRUB → kernel → arje-zero PID1`.
146El camino EFI —que fue el que más trabajo costó ([ADR 0010](adr/0010-arranque-grafo-mirada.md))— es
147el que Hetzner **no** necesita.
148
149| pieza | estado *(medido)* |
150|---|---|
151| kernel con virtio | `recipes/linux.toml` compila `VIRTIO`, `VIRTIO_PCI`, `VIRTIO_BLK`, `VIRTIO_NET`, `EXT4_FS` **`=y`** ⇒ sin initramfs |
152| red | `crates/netup` = cliente **DHCPv4 propio** en Rust sobre netlink, autodetecta interfaz y espera carrier. gioser toma su IP por **DHCP en `eth0`** (`/32`, `net.ifnames=0`, DNS `185.12.64.2`) ⇒ es justo su caso |
153| acceso | `recipes/openssh.toml` sellado; el `product-rootfs` vigente **ya trae `sshd`**; `takana-install.sh` acepta `AUTHKEYS` |
154| servir | `recipes/caddy.toml` existe |
155| escritura a disco | `scripts/takana-install.sh` (no interactivo) y `scripts/takana-live-install.sh` (TUI), probado por `scripts/install-tui-test.sh` |
156
157### 3.2 El camino remoto: rescue → `dd` → reboot
158
159Hetzner Cloud tiene un **sistema de rescate** (Debian por SSH) que se activa desde la API sin tocar
160la caja. Desde ahí se escribe la imagen al disco y se reinicia. **No hace falta ISO, ni consola, ni
161medio físico** — que es justo lo que hace innecesario el 90 % del trabajo de ISO/USB para este caso.
162
163### 3.3 Los cuatro huecos, nombrados
164
1651. **`perfil.servidor`** — **HECHO hoy** en `docs/state/targets.toml`. Ver §3.4.
1662. **`console=ttyS0` en el cmdline** — el único salvavidas si el arranque falla y no hay teclado.
167   Hetzner da consola web; sin serial en el kernel, esa consola no muestra nada útil.
1683. **IPv6** — `netup` hace DHCPv4 y Hetzner entrega un `/64` **estático**. La caja arrancaría con
169   IPv4 sola. Aceptable para empezar, pero hay que saberlo **antes**, no descubrirlo.
1704. **Un `.img` del perfil servidor** que el rescue pueda escribir. Es `hydrate-profile.py` +
171   `install-image.sh`, los dos ya existentes — ver §7.
172
173### 3.4 El perfil, y la deuda que declara *(hecho 2026-09-09)*
174
175`[perfil.servidor]` hereda `cli` (que hereda `base`, [SDD 27 §7.1](27-perfiles-instalables-y-el-instalador.md))
176y añade **5 raíces que existen** (`openssh`, `caddy`, `curl`, `wget`, `tmux`) y **5 que NO**:
177
178| raíz que no resuelve | por qué está declarada igual |
179|---|---|
180| **`takana`** | **el propio takana no tiene receta.** El corpus construye 869 recetas y no la suya ⇒ el binario sólo existe como `cargo build` sobre un clon. Es EL bloqueante del §5 |
181| `chrony` | sin NTP, un reloj que deriva rompe TLS y las firmas — y falla diciendo «firma inválida», no «la hora» |
182| `nftables` | `dev.gioser.net` está hoy expuesto con fuerza bruta a root en el journal; una caja nueva no debería nacer así |
183| `cronie` | el latido son tres líneas de crontab, y sin systemd no hay timers: o hay cron, o el latido es una tarjeta de arje-zero. **Decisión pendiente** |
184| `logrotate` | en una caja que sirve, el log de acceso crece hasta llenar el disco |
185
186Quedan **`wanted`** en el grafo, y **`wanted` no es `debt`** ⇒ `drenaje.json` seguirá diciendo
187`deuda=0`. Se declaran igual porque **una deuda escrita se ve y una omitida no**: sin ellas el perfil
188saldría N/N —completo y verde— describiendo un servidor sin hora, sin cortafuegos y sin latido. Es
189la lección de `foot` en `escritorio-sway` (121/121 sellado y sin emulador de terminal), pagada por
190adelantado. **Cuenta esperada: 5.** Un sexto `wanted` es un error de tipeo, no una receta nueva.
191
192### 3.5 La caja, creada *(2026-09-10)*
193
194| | |
195|---|---|
196| nombre / id | `takana` / `165447050` |
197| tipo | **`cx33`** — 4 c / 7,6 GiB / 76,3 G — **€8,49/mes** |
198| ubicación | **hel1** — la misma que los volúmenes `vvv` y `harkaq-cosecha` y que el Storage Box |
199| IPv4 / IPv6 | `2.29.29.217` · `2a01:4f9:c014:2f9c::/64` |
200| imagen inicial | `debian-13` — **desechable**: existe sólo para recibir ficheros hasta el `dd` del §3.2 |
201| label | `role=takana-server` |
202| protección de borrado | **NO**, a propósito: es la caja de sacrificio |
203
204⚠ **El label importa**: `farm-down.sh` y `deadman.sh` borran **sólo** lo que lleva
205`role=hammer-worker`. Esta caja no lo tiene ⇒ ninguna automatización de la granja puede tocarla.
206Es la misma capa fuerte que protege a gioser, aplicada al revés: no se protege por nombre, se
207protege por no haber nacido de `farm-up`.
208
209⚠ **`cx53` no se pudo comprar: sale `Available: no` en las TRES ubicaciones.** Y la disponibilidad
210**cambia por día** — `cx33` salió `no` en hel1 el 2026-09-09 y `yes` el 2026-09-10, con la misma
211consulta. ⇒ **el precio de la tabla no dice que la máquina exista**: `hcloud server-type list`
212devuelve precios para tipos que no se pueden crear. Consultar `Available` por ubicación, y volver a
213consultarlo el día que se compra.
214
215#### Lo que la caja confirma, medido EN ELLA y no inferido de gioser
216
2171. **Arranca por BIOS** — no existe `/sys/firmware/efi`… **y sin embargo la imagen trae un ESP**
218   (`sda15`, 244 M vfat en `/boot/efi`). O sea: **la presencia de una partición EFI no indica
219   arranque EFI**. Si el instalador decidiera la rama mirando particiones en vez de
220   `/sys/firmware/efi`, elegiría mal en esta caja exacta. `takana-live-install.sh` mira lo correcto.
2212. **`console=tty1 console=ttyS0` viene en el cmdline de Debian.** Confirma que el serial es lo que
222   Hetzner espera, y que el hueco 2 del §3.3 es real: nuestra imagen tiene que traerlo o la consola
223   web no muestra nada.
2243. **`eth0` con `/32` dinámico por DHCP** — idéntico a gioser ⇒ es exactamente el caso de `netup`.
225
226#### Dos cosas pendientes en la caja, anotadas
227
228- **7,6 GiB de RAM y CERO swap** — la misma clase de máquina que ya provocó `global_oom` en gioser.
229  Coherente con la decisión de §2.1 (la caja **publica**, el LXC muele), pero significa que **no se
230  construye pesado acá** mientras siga así.
231- **`sshd` trae `PasswordAuthentication yes`** (con `PermitRootLogin without-password`, así que root
232  por clave igual). El journal del LXC muestra fuerza bruta a root desde IPs random; esta caja está
233  igual de expuesta. Se cierra cuando se instale takana puro, o antes si se queda días con Debian.
234
235### 3.6 La imagen del `perfil.servidor`, armada y validada *(2026-09-10)*
236
237**Receta del ensamblado** (todo con scripts que ya existían, §7):
238
239```sh
240# 1. base con init: el product-rootfs sellado (trae /sbin/init -> /usr/bin/arje-zero)
241cp -al /mnt/cosecha/store/5011955a…-product-rootfs/. $D/
242# 2. userland del perfil encima
243scripts/hydrate-profile.py servidor --into $D --store /mnt/cosecha/store --keep
244# 3. ⚠ restaurar el init (ver abajo) y meter la clave
245ln -sf /usr/bin/arje-zero $D/sbin/init ; cp github5.pub $D/root/.ssh/authorized_keys
246# 4. imagen
247ROOTFS=$D STAGE=/mnt/cosecha/.install-stage KERNEL=store/23f1cc43…-linux-generic/boot/bzImage \
248CMDLINE="console=ttyS0,115200 console=tty0 root=/dev/sda2 rw init=/usr/bin/arje-zero" \
249  ./scripts/install-image.sh
250```
251
252Resultado: **86 nodos, 30.500 ficheros, 2,0 G de rootfs → imagen de 7.172 M (2,1 G en disco, sparse)**.
253
254#### El kernel correcto es `linux-generic`, y `linux.toml` NO sirve para hcloud
255
256El disco de una caja hcloud lo maneja **`virtio_scsi`** (driver `sd`, disco `sda`) — no `virtio_blk`.
257`recipes/linux.toml` **apaga SCSI explícitamente** (`-d SCSI`) ⇒ su kernel arranca y **no ve el
258disco**. `linux-generic`, `linux-metal` y `linux-metal-dual` sí sirven.
259
260⚠ **Y ninguna receta nombra `SCSI_VIRTIO`.** Está encendido igual, porque `make defconfig` lo trae y
261`-e SCSI -e BLK_DEV_SD` lo mantiene. Se supo mirando el `.config` que `linux-generic` **publica en su
262artefacto** (`/boot/config-7.1.2-generic`), no leyendo la receta. La receta dice lo que se cambió;
263sólo el `.config` sellado dice lo que quedó.
264
265#### ⚠ La hidratación PISA `/sbin/init`, y el resultado arranca igual
266
267`busybox` entra en la clausura del perfil y su `/sbin/init -> ../bin/busybox` **gana por llegar
268después**, borrando el `-> /usr/bin/arje-zero` del product-rootfs. La imagen habría arrancado
269perfecta **con el init de busybox**: no takana puro, y sin un solo error. Se tapa por las dos puntas
270— restaurando el symlink al ensamblar **y** con `init=/usr/bin/arje-zero` explícito en el cmdline,
271que es lo que hace gioser y es autoritativo pase lo que pase con el symlink.
272
273**Y la guarda que existía para esto estaba INVERTIDA** (arreglada en el mismo commit): `[ -e
274"$ROOTFS/sbin/init" ]` sigue el symlink y lo resuelve contra el *host*, así que el symlink ABSOLUTO
275a arje-zero daba falso y **el rootfs correcto era rechazado**, mientras que el relativo a busybox
276—el equivocado— pasaba. Ver [[guardian-que-nunca-fallo]]: una guarda que nunca falló no se sabe si
277sirve; ésta fallaba al revés.
278
279#### EXDEV, dos veces en la misma tarde
280
281`store` y `work/out` son bind-mounts de `/dev/sdb`; `work/` está en `/dev/sdc`. Un hardlink no cruza
282un montaje **aunque sea el mismo disco**, así que reventaron (1) `hydrate-profile.py` con el destino
283fuera del montaje del store —que avisa RUIDOSO y bien— y (2) el staging de `install-image.sh`, que
284lo tenía cableado a `work/`. Ahora `STAGE` es env. Ver [[hardlink-cruza-mount-exdev]].
285
286#### La validación: arrancó en QEMU reproduciendo virtio-scsi, y se entró por SSH
287
288```
289SeaBIOS → GRUB → kernel 7.1.2 propio → EXT4-fs (sda2): mounted → Pivoted into new rootfs
290→ Run /usr/bin/arje-zero as init process → "arje-zero: despierta como PID 1"
291→ netup (DHCP) → ssh-keygen -A → sshd
292```
293
294Y desde fuera: `ssh -p 2299 root@127.0.0.1` **entra**, `/proc/1/comm` dice `arje-zero`, `ip addr` da
295la IP que negoció `netup`. Es la puerta 1 del §6 ensayada en local antes de gastar la caja.
296
297El card `sshd` de `/ente/seed.card.json` ya hacía el trabajo entero sin que hubiera que tocar nada:
298`sh -c "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"` — red, claves de host y
299demonio en una línea. Lo único que hubo que inyectar fue `authorized_keys`.
300
301#### Un resto del renombre, encontrado por el arranque
302
303```
304arje-zero: hammer no corre, sin menú de arranque: No such file or directory (os error 2)
305```
306
307**`arje-zero` invoca el binario por el nombre VIEJO (`hammer`).** No es fatal —sigue arrancando— pero
308el menú de arranque por grafo del [ADR 0010](adr/0010-arranque-grafo-mirada.md) queda muerto. Y viene
309de tawasuyu, pineado por `commit` en `recipes/arje-zero.toml`, así que **no se arregla desde este
310repo**: hay que subirlo allá y re-pinear. Se suma a que la imagen tampoco trae `takana` (§3.4).
311
312### 3.7 ⚠ El muro real: la puerta de enlace de Hetzner está FUERA del prefijo
313
314La imagen validada en QEMU se escribió a la caja, arrancó… y **no se pudo entrar**. `hcloud` decía
315`running`, el puerto 22 cerrado, y desde fuera `Connection timed out` — el modo de fallo más caro de
316todos, porque no hay consola serie en hcloud para mirar.
317
318La causa, medida entrando al rescue de la propia caja:
319
320```
321inet 2.29.29.217/32 scope global eth0
322default via 172.31.1.1 dev eth0
323172.31.1.1 dev eth0 scope link      ← ESTA es la que faltaba
324```
325
326**La IPv4 viene con prefijo `/32` y la puerta (`172.31.1.1`) no pertenece a ninguna red conectada.**
327`netup` mandaba el default con `RTA_GATEWAY` y scope UNIVERSE y nada más, así que el kernel lo
328rechazaba con `ENETUNREACH`: la máquina se quedaba **con IP y sin salida**, contestando el DHCP y
329levantando sshd, sin nada visiblemente roto por dentro.
330
331**Control causal**, en un netns con una dummy y una `/32`:
332
333```
334ip route add default via 172.31.1.1 dev dummy0   ⇒ Error: Nexthop has invalid gateway.
335ip route add 172.31.1.1 dev dummy0 scope link    ⇒ ok
336ip route add default via 172.31.1.1 dev dummy0   ⇒ ACEPTADO
337```
338
339Arreglado con `add_onlink_host_route` (dst `/32`, scope LINK) antes del default.
340
341**Por qué no había aparecido nunca**: `netup` se validaba contra el DHCP de QEMU slirp, que entrega
342un `/24` con la puerta dentro de la subred. El escenario que rompe sólo existe en una nube de verdad.
343Es [[cache-congela-regresiones]] en su otra forma: no es que el test estuviera mal, es que el ÚNICO
344entorno donde se probaba no contenía el caso.
345
346**Y destapó un segundo problema de fondo**: el binario de `netup` venía dentro del `product-rootfs`,
347o sea **congelado en un artefacto sellado del bootstrap** ⇒ arreglar netup no llegaba a la imagen.
348Por eso `netup` pasa a ser **raíz de `perfil.servidor`**: así la hidratación lo proyecta encima y la
349imagen lleva el vigente. Verificado por sha256 (imagen `a23df20e…`, product-rootfs `a84b0a0d…`).
350
351### 3.8 ✅ Puerta 1, pasada *(2026-09-10)*
352
353```
354PID 1    : arje-zero          kernel : 7.1.2 (linux-generic propio)
355IP       : 2.29.29.217        rutas  : default via 172.31.1.1 + 172.31.1.1 scope link
356montajes : /dev/sda3 -> /store      /dev/sda4 -> /var/lib/hammer
357salida   : ping 1.1.1.1 0% pérdida · curl http://1.1.1.1 -> 301 · resolv.conf escrito por netup
358```
359
360**Una caja Hetzner Cloud corriendo takana puro**: nuestro kernel, nuestro init, nuestro cliente DHCP,
361nuestro sshd — sin una línea de Debian debajo. El camino entero es
362`scripts/servidor-image.sh` + rescue + `dd`, y la escritura de los 7,1 G comprimidos tarda **~17 s**
363(gioser y la caja están las dos en hel1).
364
365Lo que la caja **todavía no** tiene, y es el paso 2: `takana` (sin receta, §3.4), el store real (el
366volumen no se engancha hasta acá) y las otras cuatro raíces `wanted`.
367
368---
369
370## 4. La utilidad de migración: absorber lo VIVO, no lo declarado
371
372`arje-absorb` ya existe, está sellado (`recipes/arje-absorb.toml`) y su `--help` dice: lee
373`sysvinit | runit | dinit | openrc | auto` y **emite una Tarjeta Semilla** con cada servicio del init
374ajeno como hija de arje-zero. Tiene parsers para los cinco (`systemd.rs`, `openrc.rs`, `runit.rs`,
375`dinit.rs`, `sysvinit.rs`).
376
377**Pero lee la declaración, y en gioser la declaración miente** (§0.2). Un `arje-absorb --from openrc`
378sobre esta caja produciría una Semilla que **no levanta el servidor**, y lo haría en silencio: la
379Semilla sería sintácticamente perfecta.
380
381⇒ Lo que falta es un **segundo lector**: absorber del **proceso vivo** (`/proc`: `ppid==1`, `cmdline`,
382`cwd`, entorno, fds en LISTEN) y **reconciliar** ambas lecturas marcando dónde discrepan. Eso es lo
383que el usuario nombró como «lo aprendido de matilda» — absorber lo que hay, no lo que está escrito.
384
385La utilidad debe clasificar en tres, no en dos:
386
387| clase | criterio | qué se hace |
388|---|---|---|
389| **vivo** | proceso corriendo **y** declarado | se muda |
390| **declarado, muerto** | en la config, sin proceso ni ficheros (los 4 dominios sin DNS) | **fósil: se anota y se deja morir** |
391| **vivo, no declarado** | proceso con `ppid==1` que ningún init declara (caddy, gitea, sshd, act-runner) | **se escribe la tarjeta que faltaba** — es el trabajo real |
392
393**Se construye al final**, estrenándose con la migración gioser → caja nueva, con el caso del
394`rc-status` que miente como test de aceptación. Su especificación es el §1 de este documento.
395
396---
397
398## 5. Que el servidor sirva sus propios paquetes a su propio host
399
400Existe el camino entero: `scripts/build-repo.sh` (corpus → `.swm` firmados + índice),
401`takana install --repo` que **ya acepta lista HTTP multi-origen** y verifica cada paquete contra el
402`digest` del índice firmado ([ADR 0014](adr/0014-distribucion-multiorigen.md)), `takana upgrade` con
403rollback, y caddy para servir `dist/repo` con un `file_server`.
404
405Lo que falta **no es servir**, son dos cosas:
406
4071. **La clave.** `build-repo.sh` firma hoy con una **clave efímera** por defecto (`keygen release` si
408   no se le pasa `KEY=`) *(medido, línea 33)*. Sin clave estable, «firmado» no significa nada. Es el
409   [SDD 19 §3.2](19-lanzamiento-publico.md) y es requisito aunque el repo sea privado.
4102. **La receta de `takana`** (§3.4). El host necesita `takana` instalado para consumir los paquetes,
411   y hoy no puede instalarlo desde el repo porque takana no está en el repo.
412
413### 5.1 Hecho *(2026-09-10)*: clave estable, repo firmado, y la caja sirviéndolo
414
415- **`trust/`** — la clave PÚBLICA de release en el repo; la privada en `~/.config/takana/keys/`
416  (0600, fuera de git). `scripts/repo-perfil.sh` **aborta si no la encuentra** en vez de inventar una
417  efímera, y tras firmar **verifica** el índice contra `trust/`.
418- **88/88** paquetes del perfil `servidor` publicados con `expected_hash` anclado.
419- **La caja los sirve** con `caddy file-server` sobre `/srv/repo`.
420
421**El control de la firma, en los dos sentidos**, contra el repo servido por la caja:
422
423| `--trust` | resultado |
424|---|---|
425| `./trust` | `release: trusted (by release)` ⇒ instala |
426| un directorio vacío | `release: unknown-key … clave no confiada` ⇒ **ABORTA** |
427
428### 5.2 ⚠ Lo que el lazo destapó: `strip_debug` no viajaba en el paquete
429
430La primera instalación real de `takana` desde su propio repo falló:
431
432```
433Error: expected_hash no coincide:
434  declarado = b3:941d1857…   obtenido = b3:2727050e…
435```
436
437`why-differs` lo nombró: los dos binarios pesaban **exactamente lo mismo** y sólo divergían en
438`.shstrtab` — la firma de un `strip` que corrió una vez y la otra no. `swm_bridge` re-inyecta con
439cuidado `flags`, `phases`, `zig_version`, `strip_components`, `patches`, `deps`… y **se olvidaba de
440`strip_debug`**, que ni siquiera existía en `SwmBuild`. Como **entra en `hash_inputs`**, el receptor
441reconstruía en otra dirección.
442
443**Alcance: 40 recetas del corpus, 18 de las 88 de este perfil.** Una quinta parte del repo no se
444podía instalar, y nadie lo sabía porque **nunca se había cerrado el lazo**. Arreglado, con test de
445regresión y su control. Tras el arreglo, `install takana` da cache-hit en el hash anclado.
446
447### 5.3 ⚠ Y el loopback nunca se levantaba
448
449Al intentar que la caja instalara de sí misma por `http://127.0.0.1`: **timeout de 30 s**, con caddy
450escuchando en `0.0.0.0:80`. `lo` estaba **`DOWN` y sin dirección** — con systemd o OpenRC lo levanta
451el init; con arje-zero no lo hacía **nadie**. El síntoma no era «connection refused», que se
452diagnostica en un minuto: era un cuelgue que parecía del servidor. Arreglado en `netup` (es quien
453configura la red) y verificado desde la imagen, sin tocar la caja a mano.
454
455### 5.4 🚧 Lo que NO se puede todavía, y por qué importa
456
457Con todo lo anterior en verde, la caja **sigue sin poder instalar de su propio repo**:
458
459```
460release: trusted (by release)              ← la firma verifica
461repo: bajados 2 .swm de http://127.0.0.1   ← descarga de sí misma
462Error: no pude leer el apk db del lab en /.dev-fs/alpine/lib/apk/db/installed.
463       El toolchain entra en el ArtifactHash, así que sin rootfs no se puede
464       calcular un hash comparable
465```
466
467O sea: **la mitad de servir está cerrada y probada; la de consumir, no** — y no por un detalle de
468instalación, sino porque `install` **reproduce desde fuente** y eso exige el LAB ENTERO en el
469cliente. No es «falta un compilador»: el lab entra en el `ArtifactHash`, así que sin él no se puede
470ni calcular el hash a comparar.
471
472Es exactamente el punto que [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md) dejó planteado
473y sin decidir —*reproducir o hidratar*— visto desde el otro lado: para un servidor, o para
474cualquiera que no sea un hub de build, **reproducir no es una opción**. El camino normal tiene que
475ser hidratar artefactos firmados, con `--reproduce` como camino auditable. Mientras esa decisión no
476se tome, el repo firmado sirve para distribuir **a hubs**, no a usuarios.
477
478⚠ **Y una caja que se sirve a sí misma es un solo origen** — justo lo que el ADR 0014 existe para no
479tener. El camino normal puede ser el local, pero la lista de orígenes del host debe incluir también
480el Storage Box o gioser. Si no, el primer origen roto es el último.
481
482**Lo que esto vale**: cerrar el bucle es el ensayo de «actualización en sitio probada de verdad»
483([SDD 19 §5.2](19-lanzamiento-publico.md)), hecho sobre una máquina que importa pero que **no es el
484laptop de nadie**.
485
486### 5.5 ⚠ Dos bugs más del lazo, que sólo aparecen si el paquete NO es un binario de Rust *(2026-09-21)*
487
488Pedido del usuario: «instalar zsh con el comando takana». `zsh` es el primer paquete que se instala
489**con varios parches** y **con el binario fuera de `/usr/bin`**, y cada una de esas dos cosas era un
490fallo distinto. Los dos son de la familia del `strip_debug` del §5.2 —algo que el `.swm` no
491transportaba fiel— y los dos **se descubren sólo cerrando el lazo**, no leyendo el código.
492
493**1. Los parches viajaban CONCATENADOS, y eso cambia el hash.** `pack` unía los N parches de la
494receta en el `patch` inline único; `Recipe::hash_inputs` mete **una entrada por parche** y
495`of_inputs` va con longitud prefijada ⇒ N sueltos y N unidos hashean distinto. Medido sin esperar al
496build, con una copia de `recipes/zsh.toml` con sus 6 parches concatenados en uno:
497
498```
499b3:0be3630d…   ← expected_hash anclado (y lo que hay en /store)
500b3:d6329a62…   ← lo que reconstruye el receptor  (= la copia concatenada, exacto)
501```
502
503O sea: `install zsh` recompilaba zsh **entero** y recién entonces moría con `expected_hash no
504coincide`. **Alcance: las 16 recetas del corpus con ≥2 parches** — waterfox 12, firefox y gnupg 11,
505parted/zsh/strace 6, doas/mandoc/giflib 4, freetype 3, libxml2/file/brotli 2…
506
507Arreglado con un campo `patches` (lista, en orden) en el `source_patch`; el `patch` único se sigue
508leyendo para no invalidar lo ya publicado. El receptor materializa **un fichero por parche**.
509Regresión con su **control negativo** en `swm_bridge`: si se concatenan, los `hash_inputs` TIENEN
510que diferir, o el test pasaría por la razón equivocada.
511
512⚠ La tentación era arreglarlo del otro lado —hashear los parches concatenados— y habría sido mucho
513peor: mueve el hash de las **50** recetas con parches e invalida sus artefactos sellados.
514
515**2. `target_bin` era una adivinanza que nadie comprobaba.** `pack` publica `/usr/bin/{name}`;
516`zsh` configura `--bindir=/bin`, así que su binario queda en `/bin/zsh`. El paquete se publicaba
517prometiendo un path que el artefacto no tiene, y el error salía **en la máquina que instala,
518después de hidratar los 1331 ficheros**:
519
520```
521Error: source_patch declara target_bin=/usr/bin/zsh pero no quedó en <prefix>/usr/bin/zsh
522```
523
524Con `--build` el artefacto está delante: ahora `pack` comprueba el path contra él y lo **corrige**
525si el binario quedó en otro bindir, diciéndolo.
526
527⚠ **Y el primer intento fue abortar, que era lo natural y estaba MAL.** Al publicar el perfil
528entero con esa versión, **65 de 175 paquetes se quedaron fuera del catálogo**: son las LIBRERÍAS
529—zlib, ncurses, openssl, musl-\*, gmp…—, que no traen ningún binario y existen para que otros las
530resuelvan como dep. Un campo que su consumidor ni mira habría tirado un tercio del repo. Así que
531no aborta: avisa, y el paquete se publica. Lo mide el barrido de abajo, no el razonamiento — esa
532versión pasaba los tests igual.
533
534**Publicando el perfil `servidor` entero con el `pack` arreglado** *(medido, store del hub)*:
535
536| | |
537|---|---|
538| publicados y firmados | **162** (el índice verifica contra su `trust`) |
539| **`target_bin` corregidos** | **8: `bash` `sed` `tar` `parted` `dhcpcd` `squid` `wpa_supplicant` `zsh`** |
540| sin binario (librerías) | 64 — se publican, con aviso |
541| sin sellar en ESTE store | 12 |
542| fallaron | 1 (`os-release`, el mismo del §6.46) |
543
544Esos 8 son paquetes que **estaban publicados prometiendo un path que no existe**: `install bash`
545habría hidratado y muerto al final. Comprobado después del arreglo, contra el repo firmado servido
546por HTTP: `parted` (6 parches **y** binario en `/usr/sbin`) instala en 0,17 s y responde
547`parted (GNU parted) 3.7`; `bash` instala y responde `5.3.0(1)-release`.
548
549### 5.7 El nombre ya existe: `repo.gioser.net` *(2026-09-21)*
550
551Dado de alta en la zona de Hetzner (la DNS de `gioser.net` está ahí: `helium`/`hydrogen`/`oxygen`),
552con la convención que ya usaban `takana`, `git`, `gitea`, `sergio` y `www`: **`repo` A →
5532.29.29.217, ttl 60**. Resuelve *(comprobado contra Cloudflare; el resolutor de Google todavía
554servía el NXDOMAIN cacheado — negativo de 1 h por el `minimum` del SOA, no es un fallo del alta)*.
555
556⚠ **La API vieja de DNS de Hetzner ya no es la buena.** `dns.hetzner.com/api/v1/zones` con
557`Auth-API-Token` devuelve **HTML de la consola web** (301 → 200 de una SPA), que es lo peor que
558puede devolver: no falla, contesta. Las zonas viven hoy en la **API de cloud** y las lee el MISMO
559token de `~/.config/hcloud/cli.toml`:
560
561```sh
562curl -H "Authorization: Bearer $TOKEN" https://api.hetzner.cloud/v1/zones          # gioser.net = 986350
563curl -H "Authorization: Bearer $TOKEN" https://api.hetzner.cloud/v1/zones/986350/rrsets
564```
565
566Durante unas horas el nombre resolvió y **no sirvió nada** —`http://` daba el 308 global de Caddy y
567el HTTPS moría en el handshake (`tlsv1 alert internal error`) porque sin site block no hay
568certificado—, hasta que se pudo entrar como root a la caja. **Cerrado el 2026-09-21** con
569`scripts/servidor/publicar-repo.sh --install-vhost`, que publica y firma en `/srv/repo`, añade el
570bloque y lo aplica; por defecto **imprime el bloque sin tocar el Caddyfile**, porque en esta caja
571Caddy sirve 19 dominios y su config ya acumula 20 `.bak`.
572
573⚠ **Publicar no es construir, y `repo-perfil.sh` no lo acotaba.** `pack --build` es instantáneo con
574cache-hit y una **compilación entera** si el artefacto no está sellado. Medido al ensayar el guion:
575la corrida se puso a compilar `libnftnl` —y después habría seguido con `rust`— en una caja de 4
576cores que ya estaba a **load 46**. `build-repo.sh` llevaba `timeout` desde siempre; el que se corre
577en producción, no. Ahora `BUILD_TIMEOUT` (120 s) y `SKIP_UNSEALED=1`: un vencimiento **no es un
578error**, significa «ése no estaba sellado». Con eso, el perfil entero publica **173 anclados, 0 sin
579ancla, 2 saltados (`os-release`, `rust`)** sin compilar nada.
580
581**El `--install-vhost`, probado en sus dos caminos** *(con un Caddy de juguete, porque el de la caja
582no tiene el admin abierto)*:
583
584| camino | qué pasó |
585|---|---|
586| reload OK | `✓ la config valida` → `✓ recargado`; el admin muestra el `srv1` nuevo sirviendo el repo **y el sitio que ya estaba sigue en pie** |
587| reload FALLA | copia restaurada **byte a byte** (`b"repo.local" not in` el fichero final), y no recarga nada |
588
589El orden es lo único que lo hace seguro: copia → añadir → `caddy validate` → **sólo entonces**
590`reload`. Un reload con la config rota no tira el sitio nuevo: tira los 19.
591
592⚠ **Y root no escribe en el árbol del usuario.** `work_root` sale de `dirname(store)/work` ⇒ con el
593store en `/store` es `/work`, donde `sources` y `out` son **enlaces a `/work/sergio/work/…`**, que es
594del usuario (CLAUDE.md §3 bis). Publicar como root dejaría ahí directorios de root y el siguiente
595build suyo moriría con un permiso denegado que no menciona la causa — es exactamente lo que ya pasó
596cuando root hizo `git pull` en el clon de tawasuyu y lo congeló para el dueño (`publicar-webs.sh`).
597El guion, si corre como root y nadie fijó `TAKANA_WORK`, lo manda a `/var/tmp/takana-publicar-work`
598y lo dice. Comprobado que el override no mueve el hash: `zsh` sella en `b3:0be3630d…` igual.
599
600⚠ **Y trae su propia guarda contra el error más fácil de cometer:** si el `takana` de la caja es
601anterior al §5.5 (se detecta porque no conoce `outdated`), **aborta antes de publicar**. Con uno
602viejo el repo sale con las 16 recetas multi-parche irreproducibles y 8 paquetes prometiendo un
603binario que no existe — y eso no se nota hasta que alguien instala.
604
605⚠ **Lo que queda vivo y no se tocó:** un `install <librería>` DIRECTO sigue fallando tras hidratar,
606porque el `.swm` exige que el `target_bin` exista y para una librería no hay respuesta buena. Hacer
607`target_bin` opcional toca el formato ([SDD 06](06-swm-format.md)) y es su propia unidad de trabajo.
608Como dep funcionan, que es para lo que están en el catálogo.
609
610**El lazo, después:** `takana install zsh --repo http://… --require-signed` ⇒ cache-hit del hash
611anclado, **1331 ficheros hidratados en 0,14 s**, y el binario corre (`zsh 5.9`). Antes: recompilar
612zsh entero para morir al final.
613
614### 5.6 `takana outdated` — el aviso que faltaba *(2026-09-21)*
615
616Ningún verbo comparaba lo instalado con el catálogo (`upgrade` es de **árboles/generaciones**, no de
617paquetes), así que «avisame cuándo hay que actualizar» no tenía respuesta. `outdated` la da leyendo
618sólo el índice firmado y la DB de instalados — no construye, no baja `.swm`, no escribe nada.
619
620**Compara por hash, no por versión**, y eso es lo único que lo hace útil: un repo se re-publica sin
621que cambie la versión upstream cada vez que se mueve algo que entra en `hash_inputs` (una flag, el
622lab, un parche), y mirar la etiqueta diría «al día» sobre un artefacto que ya no es el que el repo
623sirve. La versión es el respaldo para cuando falta el ancla, y se dice que el juicio es más débil.
624
625`scripts/servidor/avisar-actualizaciones.sh` lo corre por cron y **sólo grita cuando la lista de
626novedades cambia** (un guardián que repite lo mismo cada 30 min deja de leerse). Un repo caído no se
627disfraza de «al día»: lo dice, sale ≠ 0 y **conserva el último estado bueno**.
628
629⚠ **Lo que sigue sin poder hacerse, y no lo arregla ningún verbo:** actualizar en una máquina sin
630lab. El aviso llega a cualquiera; el `install` que lo resuelve sigue siendo reproducir desde fuente
631(§5.4). Por eso `outdated` **no actualiza**: instalar es verificar, y eso no se cuelga de un cron.
632
633### 5.8 Puerta 5, de verdad: `repo.gioser.net` sirve el repo firmado *(2026-09-21)*
634
635```
636$ takana install zsh --repo https://repo.gioser.net --trust ./trust --require-signed
637release: trusted (by release)
638repo: bajados 6 .swm de https://repo.gioser.net
639caché: artefacto ya en el store hash=b3:0be3630d… name=zsh
640  → hidratados 1331 archivo(s)                                    real 0m0,286s
641```
642
643**173 paquetes anclados, 0 sin ancla**, índice firmado con la clave de release de la caja y
644certificado `CN=repo.gioser.net` de Let's Encrypt. Los 2 que faltan (`os-release`, `rust`) no
645estaban sellados y el `SKIP_UNSEALED` los saltó en vez de ponerse a compilar.
646
647**Lo que había que descubrir para poder aplicarlo, y no estaba escrito en ningún sitio:**
648
649- **`caddy reload` NO sirve en esta caja.** El Caddyfile lleva `admin off` (por eso el `:2019`
650  siempre estuvo cerrado) y el `reload` de Caddy habla justamente por ese admin. La recarga real es
651  **`arjectl restart caddy`**: caddy es un Ente de arje (`/etc/arje/cards.d/caddy.json`,
652  `Restart{initial:1000,max:30000}`), no un proceso suelto.
653- **Un restart no es un reload: levanta los 19 dominios o ninguno.** Por eso el guion saca un
654  **testigo** de la config PREVIA —el primer dominio con nombre propio, acá `gitea.gioser.net`— y
655  espera a que vuelva a responder; si no vuelve, restaura la copia y revierte. Comprobado después:
656  los 8 dominios vivos siguen en pie (`gitea` 301 a `git`, el resto 200).
657- **El certificado tarda más que la comprobación.** El guion dio `✗ todavía no responde` y la misma
658  URL respondía **200 unos segundos después**: Caddy pide el certificado en la primera petición.
659  El mensaje ahora dice «reintentá el curl antes de buscar nada» en vez de mandar a leer logs.
660
661**El espejo provisional se quitó.** Mientras no hubo root, el catálogo estuvo servido **sin firmar**
662bajo `takana.gioser.net/repo` (un árbol escribible sin root, con certificado válido). Funcionó
663—`install zsh` en 0,22 s— y sirvió para probar el mecanismo, pero un origen sin firma no es un
664origen: con `repo.gioser.net` firmado en pie, se borró. Lo que quedó de aquello vale la pena
665recordarlo: **firmar con una clave que no es la de release habría sido peor que no firmar**, porque
666una autoría inventada se lee igual que una real; se publicó sin firma y con `--require-signed`
667rechazándolo, que es lo único honesto que se podía hacer sin la clave.
668
669⚠ **Sigue siendo un solo origen** (ADR 0014). La caja se sirve a sí misma; la lista de `--repo` de
670los clientes debería llevar también el Storage Box. El primer origen roto, si es el único, es el
671último.
672
673### 5.9 El aviso, enchufado en la caja *(2026-09-21)*
674
675`41 * * * *` en el crontab de root → `/usr/local/bin/avisar-actualizaciones`, un envoltorio de tres
676líneas que **sólo fija las rutas de la caja** (`REPO`, `DB`, `TRUST`, `STATE`, `TAKANA`) y llama al
677guion del repo. La lógica no se copia a propósito: el día que el guion cambie, el cron no se queda
678con una versión vieja — y se comprobó en vivo, arreglando el guion en el clon y viendo cambiar la
679salida del envoltorio **sin volver a desplegar nada**.
680
681Tres piezas que faltaban en la caja y ahora están:
682
683| | |
684|---|---|
685| `/var/lib/hammer/trust/release.ed25519.pub` | el almacén de confianza **por defecto** de la CLI, que no existía ⇒ `install`/`outdated` verifican la firma **sin pasar `--trust`** |
686| ~~`/usr/local/bin/takana`~~ → **`/usr/bin/takana`** | ⚠ **CORREGIDO el 2026-10-07: `/usr/local/bin/takana` NUNCA EXISTIÓ**, y este párrafo lo daba por puesto. El cron falló **cada hora durante dos semanas y media** con `!! falta /usr/local/bin/takana (cargo build --release)`, una línea por hora en un log que nadie lee. Y la otra mitad de la frase también era falsa: el binario de la caja **SÍ conoce `outdated`** (comprobado: `/usr/bin/takana outdated --help` imprime su ayuda). El envoltorio apunta ahora a `/usr/bin/takana` y el aviso contesta: *«al día contra https://repo.gioser.net (2 paquete(s) comprobado(s))»*. Sigue siendo cierto que el `PATH` de la caja no incluye `/usr/local/bin` y que el cron usa rutas absolutas |
687| `/var/log/takana-actualizaciones.log` | una línea por hora |
688
689⚠ **Lección de la corrección de arriba, que vale más que la corrección.** Este documento afirmaba
690que una pieza estaba «en la caja» cuando no estaba, y el aviso que debía delatarlo escribía su
691queja en `/var/log/takana-actualizaciones.log`: **una línea por hora, durante 18 días, en un
692fichero que nadie abre**. Un guardián cuyo único canal es un log sin lector no es un guardián —
693es un registro. Es la misma forma de fallo que el latido («NO pude publicar» cada 30 min, 12 días)
694y la que este documento ya describe tres párrafos más abajo sobre el «al día» vacío. Al enchufar un
695aviso hay que decidir **dónde grita cuando ÉL mismo está roto**, no sólo qué dice cuando funciona.
696
697
698⚠ **Y la primera corrida real destapó un defecto del propio aviso**: con la DB de instalados vacía
699decía **«al día»**. Es verdad y no dice nada — peor, se lee como «comprobado y correcto» cuando lo
700cierto es que *no había nada que comprobar*. Un aviso que suena igual cuando vigila que cuando no
701vigila nada es exactamente cómo se deja de mirar un aviso. Ahora distingue: *«nada instalado por
702takana en esta máquina (DB …) — no hay qué comparar»*. Control en la caja con una DB de juguete
703(un `zsh` a otro hash): grita `1 con novedad · hash distinto`, y en la segunda corrida calla.
704
705Hoy la caja no instala de su propio repo —viene de imágenes—, así que el aviso dirá esa línea hasta
706que lo haga; se pone igual para que el día que instale algo ya esté, y porque una línea por hora no
707es ruido. Cada hora y no cada 30 minutos: el repo cambia cuando alguien publica.
708
709### 5.10 `takana install zsh` en el sistema de verdad — cinco cosas que faltaban *(2026-09-21)*
710
711El usuario escribió `sudo takana install zsh` en la caja y le contestó **«paquete 'zsh' no está en
712el repo /var/lib/hammer/repo. Disponibles: (ninguno)»**. Reproducido tal cual. Debajo de ese
713mensaje había cinco cosas distintas, y ninguna se ve hasta que alguien instala de verdad.
714
715**1. El repo por defecto no existía.** `DEFAULT_REPO` está cableado a `/var/lib/hammer/repo` y el
716repo publicado vive en `/srv/repo`. Un enlace (`/var/lib/hammer/repo → /srv/repo`) hace que
717`install <nombre>` sin `--repo` funcione **y sin pasar por la red**: un directorio local sirve
718igual que la URL.
719
720**2. El lab no se encontraba desde `/root`.** `install` reproduce desde fuente y el toolchain entra
721en el `ArtifactHash`, así que sin `.dev-fs` ni siquiera puede calcular el hash a comparar (§5.4).
722La resolución es `TAKANA_LAB` → hermano del store → hacia arriba desde el CWD; con el store en
723`/store` y el CWD en `/root`, ninguna acertaba. Enlace `/.dev-fs → /work/dev-fs` y la regla del
724**hermano del store** acierta desde cualquier directorio y para cualquier usuario. *(Los dos labs
725de la caja tienen el mismo `apk db` byte a byte, así que el `LabFingerprint` es el mismo.)*
726
727**3. El `takana` del sistema no podía leer los paquetes nuevos.** Los `.tkn` de hoy llevan los
728parches como **lista** (§5.5) y un cliente anterior ignora ese campo —serde salta lo desconocido—,
729así que reconstruiría **sin parches** y moriría contra el `expected_hash`. Se reemplazó por el
730build release. ⚠ Y se comprobó ANTES de tocar que `/usr/bin/takana` tenía `enlaces=1` e inodo
731distinto al del store: es una copia, no un hardlink. **Un `cp` encima de un fichero hidratado que
732SÍ fuera hardlink reescribiría el artefacto del store por debajo** — se borra y se pone uno nuevo,
733nunca se sobrescribe.
734
735**4. ⚠ La instalación quedó PARTIDA, y eso no lo dice ningún mensaje.** Sin `--prefix`, `install`
736abre un overlay sobre el FHS… pero sólo sobre **siete** directorios clásicos (`/bin`, `/usr/bin`,
737`/sbin`, `/usr/sbin`, `/lib`, `/usr/lib`, `/etc`). **`/usr/share` no está entre ellos**, así que de
738los 1331 ficheros de zsh, **1296 fueron directos al FHS real** y sólo los 2 binarios quedaron en el
739upper esperando el `commit`. El mensaje final dice `apply OK` igual. Un `discard` en ese estado
740habría borrado el binario y dejado 1296 ficheros huérfanos: *el experimento no era reversible y
741parecía que sí*.
742
743**5. ⚠ `commit` no puede desmontar `/bin` en una máquina viva.** Los procesos tienen su ejecutable
744**mapeado** desde ahí —empezando por PID 1, `/usr/bin/arje-zero`— y un ejecutable mapeado pin-ea el
745montaje. Resultado: desmontó 5 de 7 targets, murió con `target is busy` en `/bin`, y dejó **dos
746overlays montados con el estado sin promocionar**, que es el peor de los finales. El comentario del
747propio `do_umount_lenient` prometía el desmontaje perezoso desde la Fase 2 y **el código nunca
748pasaba `-l`**. Arreglado: ante `busy` reintenta perezoso, que desengancha el montaje ya mismo (las
749rutas vuelven a resolver al directorio real, que es lo que el promote necesita) y libera cuando el
750último proceso que lo miraba se muere.
751
752**El arreglo, probado en la caja viva** con un segundo paquete instalado de cero:
753
754```
755$ takana install tree           ⇒ overlay 1790022033-1575-000000
756$ takana commit 1790022033-1575-000000
757WARN takana_overlay: umount: el target estaba ocupado (procesos con ejecutables mapeados ahí,
758     normal en un FHS vivo) ⇒ desmontaje perezoso  target=/usr/bin
759commit OK — 1 archivo(s) promocionados, 0 eliminado(s) (anotado en el diario)
760⇒ overlays montados: 0 · /usr/bin/tree real · `tree v2.3.2` corriendo
761```
762
763Mientras tanto la caja se dejó limpia a mano: los dos binarios copiados al `/bin` REAL —visto a
764través de un `mount --bind /` no recursivo, porque el overlay tapaba el destino—, los dos montajes
765sueltos en perezoso y el estado borrado. Ahora: `zsh 5.9` en `/bin/zsh`, 1296 ficheros en
766`/usr/share/zsh`, **cero overlays**, y `takana installed` lo lista.
767
768**Y la trampa del §5.7 se cobró su pieza**: root dejó un `/work/swm-recipes` **suyo** dentro del
769`/work` del usuario (el catálogo de recetas que `install` materializa; `work_root` sale de
770`dirname(store)/work`). Devuelto a `sergio`. El guion de publicar ya exporta `TAKANA_WORK` para
771esto; `install` a secas no tiene esa defensa, y es deuda anotada.
772
773### 5.11 `chsh` no funcionaba, y las dos razones eran distintas *(2026-09-21)*
774
775Pedido del usuario después de instalar zsh. Dos capas, y sólo la primera es un arreglo.
776
777**1. `/etc/shells` no existía.** `chsh` de shadow rechaza cualquier shell que no esté en esa lista
778—para un usuario normal es un rechazo duro, no un aviso— y el fichero **no estaba en la caja**.
779Escrito con lo que EXISTE, comprobado `-x` uno por uno (`/bin/sh`, `/bin/ash`, `/bin/bash`,
780`/bin/zsh`) y no copiado de otra distro: *un shell listado que no está deja a quien lo elija sin
781poder entrar, y eso no se descubre hasta el siguiente login*. Con eso, `chsh -s /bin/zsh sergio`
782funciona y `su - sergio` entra con `zsh 5.9`.
783
784**2. ⚠ Pero `sergio` sigue sin poder cambiárselo él mismo, y eso no es un fichero que falte.**
785
786```
787$ su sergio -c "chsh -s /bin/bash"
788Cannot change ID to root.
789```
790
791Los cinco binarios de shadow que necesitan setuid root —`chsh`, `passwd`, `su`, `chfn`,
792`newgrp`— salen del store como **`r-xr-xr-x`**. No es que la hidratación pierda el bit: la receta
793lleva **`--disable-account-tools-setuid`**, heredado del APKBUILD de Alpine (donde esos binarios
794los hace setuid el *empaquetado*, no el configure — y acá no hay quien se lo reponga).
795
796Y hay una segunda capa debajo: **`ArtifactHash::of_tree` sólo hashea el bit de EJECUCIÓN**
797(`mode & 0o111`). O sea que hoy el modelo de artefactos **no puede ni expresar ni garantizar** un
798binario setuid: aunque la receta lo pusiera, el hash no lo cubriría y nadie notaría si se pierde o
799si aparece.
800
801⇒ **Quitar la bandera no alcanza y no es un arreglo de receta: es una decisión de seguridad.** Un
802setuid root en una distro de artefactos sellados necesita contestar quién lo pone, quién lo
803verifica y qué significa que el hash no lo cubra. Anotado, sin decidir.
804
805⚠⚠ **Y lo que de verdad hay que llevarse de esto: un overlay abierto se traga TODA la máquina.**
806Mientras el `/etc/shells` y el `chsh` se escribían, había un overlay abierto sobre el FHS —de un
807`takana install` sin `--prefix` que alguien había dejado sin commitear— y **las dos escrituras
808cayeron dentro de él**, aunque no tenían nada que ver con ese paquete ni pasaron por takana (fueron
809un `cat >` y el `chsh` de shadow). Medido con un `mount --bind /` no recursivo, que enseña el
810directorio real sin el overlay encima:
811
812| | `/etc` real | lo que se veía |
813|---|---|---|
814| `/etc/shells` | **no existe** | existe |
815| shell de `sergio` | **`/bin/bash`** | `/bin/zsh` |
816
817O sea: el trabajo estaba hecho «según la pantalla» y **un `discard` o un reinicio lo borraba**. Se
818resolvió con `commit` (5 ficheros promocionados: `passwd`, `passwd-`, `shells`, `zsh`, `zsh-5.9`) y
819ahí el desmontaje perezoso del §5.10 se ganó el sueldo: saltó en `/bin` **y** en `/usr/bin`.
820
821⇒ **`install` sin `--prefix` no es «un experimento para ese paquete»: cambia la semántica de
822escritura de la máquina entera hasta que alguien commitea o descarta.** Cualquiera que toque `/etc`
823mientras tanto —otro agente, un servicio, un humano por ssh— escribe en el upper sin enterarse. El
824overlay no avisa: no hay nada en el prompt, ni en `mount` a simple vista, ni un aviso al entrar.
825Como mínimo `takana status` tendría que salir en el arranque de sesión, y `install` debería decir
826**qué queda pendiente** en vez de un `overlay listo` que se lee como «terminé». Anotado.
827
828Consecuencia práctica mientras tanto: **los cambios de shell y de contraseña en esta caja los hace
829root**. Y una observación que sale de mirar esto: no hay `/etc/shadow`; las contraseñas viven en
830`/etc/passwd`, que es legible por todos, con hash DES clásico. No lo toqué — es su propia unidad de
831trabajo y afecta al arranque de la caja.
832
833---
834
835## 6. La mudanza como experimento: el criterio de borrado
836
837El usuario decidió que **gioser se borra** al final. Eso no es un detalle de calendario: es lo que
838convierte la mudanza en un experimento con criterio de aceptación, y hay que escribirlo **antes**.
839
840**Regla base**: un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue
841bien. Aplicado acá: *«lo copié»* no es prueba de nada. **La prueba es que el servicio responde en la
842caja nueva y que el dato se verifica por contenido.**
843
844### 6.1 Las puertas, en orden. Ninguna se salta.
845
846| # | puerta | cómo se comprueba |
847|---|---|---|
848| 1 | la caja nueva arranca **takana puro** y se entra por SSH | `ssh` a la IP nueva tras el reboot del rescue |
849| 2 | el **store** está entero | `1327` artefactos **y ninguno vacío** — `respaldo-storagebox.sh --listar` separa los vacíos; un directorio vacío **no** es un artefacto |
850| 3 | los **grafos** salen idénticos | `build-state.py` en las dos máquinas ⇒ los cuatro grafos **byte a byte**. Es la prueba de que un hub está bien montado, no que «funcione» |
851| 4 | la **granja** late en la caja nueva | una cosecha completa con el LXC enchufado por `.fleet` y artefactos que vuelven |
852| 5 | el **repo** se sirve y el host se actualiza de sí mismo | §5 en verde, con la clave estable |
853| 6 | cada dominio **vivo** responde 200 desde fuera | la tabla del §1.1, repetida contra la IP nueva |
854| 7 | el **respaldo** al Storage Box corre desde la caja nueva | una corrida completa, no un `--seco` |
855| 8 | lo **fósil** está anotado y decidido | lista del §1.1 con «muere» o «revive» al lado de cada uno |
856
857⚠ **Puerta 3 tiene una trampa conocida**: `sealed_remoto` dependía de la máquina (laptop 700, gioser
858751 sobre el MISMO corpus) y por eso se sacó del JSON. Si los grafos difieren, mirar primero si la
859diferencia es «cuánto de esto tengo en disco» y no «qué existe».
860
861### 6.3 Estado de las puertas *(2026-09-10)*
862
863| # | puerta | estado |
864|---|---|---|
865| 1 | arranca takana puro y se entra por SSH | ✅ §3.8 |
866| 2 | el store está entero | ✅ **1362 artefactos reales, 0 vacíos** |
867| 3 | los cuatro grafos idénticos | ✅ **byte a byte** — §6.7 |
868| 4 | la granja late en la caja nueva | ✅ **cosecha completa: 1369→1575, 0 vacíos** — §6.8 |
869| 5 | el repo se sirve y el host se actualiza de sí mismo | ⬖ mitad: sirve (§5.1), no consume (§5.4) |
870| 6 | cada dominio vivo responde 200 | ⬖ sondeados: **6 vivos de 19** — §6.9 |
871| 7 | el respaldo corre desde la caja | ⬖ alcanza el box y lista 3140; arreglados 3 bloqueos — §6.10 |
872| 8 | lo fósil, anotado y decidido | ✅ **decidido** — §6.9 |
873
874**Puerta 2, medida acotando al patrón `<hash>-<nombre>`**: 1362 artefactos, **0 vacíos**. Los 3 sin
875`.hammer/recipe.toml` son `stage1-rootfs`, `product-rootfs` y `seed-zig` — productos de bootstrap,
876que por diseño no salen de una receta. (El primer conteo dio «3 vacíos» y eran `.mirror-tmp`,
877`.bootstrap-tmp` y `.divergen`: directorios de trabajo con punto inicial, no artefactos. El chequeo
878tiene que acotar por el patrón, no listar el directorio.)
879
880**De paso, el disco**: la imagen ocupaba 7 G de los 76,3 G de la caja y los otros 69 eran
881inalcanzables. Arreglado en `install-image.sh` — el store pasa a ser la ÚLTIMA partición (la única
882que puede crecer sin mover datos) y el wrapper de `/sbin/init` la extiende en el primer arranque.
883Medido en la caja: **`/store` = 68,7 G**, contra los 487 M de antes. Con eso el store de 55 G entra
884en el disco local y **la mudanza no depende de mover el volumen de gioser**.
885
886### 6.4 ⚠ El muro de la puerta 3: la imagen trae 23 binarios que NO PUEDEN CORRER
887
888Al ir a computar los grafos en la caja:
889
890```
891$ python3 -c "print(1)"
892sh: python3: not found          ← y `command -v python3` decía /usr/bin/python3
893```
894
895No es que falte: **está y no arranca**. `/usr/bin/python3.12` son 23 MB y es un ELF **dinámico**:
896
897```
898Requesting program interpreter: /lib/ld-musl-x86_64.so.1
899NEEDED  libc.so
900```
901
902**Y en toda la imagen no hay ningún cargador dinámico.** Tampoco en el store: el artefacto `musl`
903publica sólo lo estático (`libc.a`, `crt*.o`, headers) — **no hay `libc.so` ni
904`ld-musl-x86_64.so.1` en ningún artefacto del corpus**. Lo que hace que estos binarios funcionen en
905gioser es el **rootfs Alpine del LAB**, que sí lo trae (`.dev-fs/alpine/lib/ld-musl-x86_64.so.1`).
906
907Es la fuga de [[needed-colgante-libstdcxx]] otra vez, y esta vez en el piso de abajo: *sella,
908reproduce y no corre*, porque pide algo que sólo existe en el laboratorio.
909
910**Contado sobre la imagen: 23 binarios inertes de 726 (703 estáticos están bien).** Son la suite
911`binutils` entera (`ar as ld nm objcopy objdump ranlib readelf strip addr2line c++filt elfedit gprof
912size ld.bfd`), más **`perl`**, **`python3`**, **`sqlite3`**, **`flex`** y **`nft`**.
913
914**Por qué frena la mudanza y no es un detalle**: *todo* el instrumental del proyecto es Python —
915`build-state.py`, `targets.py`, `yupana.py`, `hydrate-profile.py`, `drenar.py`, `triaje.py`. Un
916servidor takana no puede correr ni una de sus propias herramientas. La puerta 3 (los cuatro grafos)
917y la 4 (la granja) dependen de eso.
918
919**Y no se arregla de paso.** Las salidas son tres y ninguna es barata:
920
9211. **Publicar el cargador** — que `musl` emita `libc.so` + `ld-musl-x86_64.so.1`. Es lo correcto y lo
922   más chico en concepto, pero toca un componente de **Stage 1**: mueve el baseline `of_tree` del
923   selfhost y hay que rehacerlo a propósito, no de rebote.
9242. **Construir python/perl estáticos** — el camino que evita el cargador, y es un frente propio:
925   Python estático con módulos de extensión es notoriamente arisco.
9263. **Aceptar que el instrumental vive en el HUB** y que el servidor sólo sirve. Es la respuesta
927   honesta a corto plazo, pero contradice «mover allá todo lo que tengo acá»: el hub seguiría siendo
928   gioser, que es justo lo que se quiere apagar.
929
930Es decisión de ADR, y de las que conviene tomar mirando también el [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md):
931las dos preguntas —«¿el cliente reproduce o hidrata?» y «¿el cliente puede correr lo que le
932mandamos?»— son la misma pregunta sobre qué es un sistema takana **sin laboratorio**.
933
934### 6.5 ✅ El muro, derribado: el corpus publica su propio cargador *(2026-09-11)*
935
936De las tres salidas del §6.4 el usuario eligió la 1 y la 2. Hecha la **1**, que resultó ser más barata
937de lo que parecía y desbloquea la 2 en vez de competir con ella.
938
939**`recipes/musl-shared.toml`** — variante dinámica de musl que publica `/usr/lib/libc.so` (4,2 M) y
940`/lib/ld-musl-x86_64.so.1`. En musl el cargador Y la libc son el mismo objeto.
941
942**Por qué una variante y no `--enable-shared` en la canónica**, con su control: `musl` es componente
943de **Stage 1** y su `of_tree` es el baseline de `selfhost-verify`. Tocarla obliga a rehacer ese
944baseline a propósito. Con el patrón `*-shared` —que el corpus ya usa 23 veces— la canónica no se
945mueve: `takana hash recipes/musl.toml` sigue dando `b3:ce952f72…`, el mismo sellado de Stage 1. Y
946además **`musl` no es dep de nadie** (medido: sólo de sí misma; el enlace estático lo resuelve el
947musl que trae zig), así que esto **suma sin mover un solo ArtifactHash del corpus**.
948
949#### ⚠ Y hubo que cambiar de compilador, medido
950
951Con `zig-cc` el `libc.so` sale con **1586 símbolos dinámicos** contra los **1654** del musl de Alpine,
952y los **68 que faltan son exactamente** los que musl implementa en ensamblador x86_64 — `memset`,
953`memcpy`, `memmove`, `memcmp`, `strlen` y toda la familia matemática (`ceil floor sqrt fmod exp log
954sin cos tan fma round trunc`) — más los `__stack_chk_*`.
955
956No es que no se compilen: en el `libc.a` del musl canónico **están**. Lo que pasa es que **zig los
957resuelve con su propio musl y los deja `FUNC LOCAL HIDDEN` de tamaño 0**, fuera de la tabla dinámica.
958El síntoma es un cargador que arranca, reloca y muere con `memset: symbol not found` — que se lee
959como «el binario está roto», no como «a la libc le faltan símbolos».
960
961Con `compiler = "gcc"`: **1651 símbolos**, `memset` y `ceil` presentes.
962
963#### El resultado, en la caja
964
965| antes | después |
966|---|---|
967| `sh: python3: not found` (con `command -v` contestando `/usr/bin/python3`) | `Python 3.12.10` |
968| 23 binarios inertes de 726 | **1** |
969
970`perl 5.040002`, `readelf`/`objdump`/`nm` (GNU Binutils 2.45.1) y el resto corren. El único que
971quedaba era **`sqlite3`**, con `Error loading shared library libz.so.1`: la zlib canónica también es
972`--disable-shared` y nadie publicaba ese soname. `zlib-shared` ya existía sellado en el corpus —
973sólo faltaba declararla en `base`.
974
975**La salida 2 (python/perl estáticos) sigue viva y ahora es opcional, no urgente**: con el cargador
976publicado el sistema ya no depende del rootfs del lab. Estático seguiría siendo mejor (un binario
977menos que puede quedar colgado de un soname), pero es un frente propio y ya no bloquea la mudanza.
978
979### 6.6 Sembrar el hub: un hub no es repo+store, es repo+store+LAB
980
981Con `python3` vivo, `takana hash` en la caja **seguía fallando**:
982
983```
984Error: no pude leer el apk db del lab en /.dev-fs/alpine/lib/apk/db/installed.
985       El toolchain entra en el ArtifactHash ⇒ sin rootfs no hay hash comparable.
986```
987
988**El lab es parte de la IDENTIDAD, no del entorno.** Una máquina sin él no puede ni preguntar «¿cuál
989es el hash vigente de esta receta?», así que no puede computar el grafo ni decidir qué falta. Sembrado
990el lab (tarball pineado + `tools/`, bajo `/store/dev-fs` porque la raíz son 6 G), el **hash testigo
991coincide byte a byte**: `takana hash recipes/zlib.toml` da `b3:dc363f26…` en las dos máquinas.
992
993⚠ **Y la caja no podía desempacar su propio lab**: el `tar` del rootfs es el de busybox y contesta
994`tar: unrecognized option: zstd`. La distro comprime todo con zstd —la imagen del lab, el respaldo,
995el `dd` remoto— y la imagen no traía el binario. `zstd` ya tenía receta sellada; declarado en `base`.
996
997#### ⚠⚠ Copiar un store con `rsync -a` es una fábrica de vacíos, y ahora se sabe POR QUÉ
998
999La primera copia murió con `No space left on device` en un `.so` de Qt, dejando el destino al 100 %
1000y **1367 de 1368 artefactos VACÍOS**. La puerta 2 corrida EN EL DESTINO lo cazó en el acto.
1001
1002La causa, medida:
1003
1004| medición sobre el store de gioser | |
1005|---|---:|
1006| `du -sh` (hardlinks contados UNA vez) | **60 G** |
1007| `du -sh --count-links` (hardlinks EXPANDIDOS) | **85 G** |
1008| `.dmerge` (la caché que hardlinkea al store) | **11 G** |
1009
1010**`rsync` sin `-H` no preserva hardlinks: los expande en copias enteras.** Así que «el store son 60 G»
1011—que es lo que dice `du` y lo que uno planifica— se convierte en 85 G + lo que `.dmerge` expanda al
1012otro lado. La partición de 68,7 G no tenía ninguna chance, y el modo de fallar es el peor: el disco se
1013llena a mitad y quedan cientos de nombres de artefacto sin contenido, que el store da por presentes.
1014
1015La copia correcta es **`rsync -aH --exclude='.dmerge'`**: `-H` preserva los enlaces (60 G reales) y
1016`.dmerge` no se copia porque es caché regenerable — la misma que ya llenó un disco en el worker.
1017
1018**La lección de método**: `du -sh` sobre un árbol con hardlinks **no** dice cuánto ocupa copiarlo.
1019Para dimensionar una copia hay que medir con `--count-links`, o preservar los enlaces.
1020
1021#### Y el instrumental apunta al binario de DESARROLLO, no al instalado
1022
1023La primera corrida de `build-state.py` en la caja devolvió **875 recetas `unhashable`** — todas —
1024con el lab bien sembrado y `takana hash` funcionando a mano. La causa:
1025
1026```python
1027HAMMER = os.environ.get("HAMMER", str(ROOT / "target/release/takana"))
1028```
1029
1030Los scripts asumen un **árbol de desarrollo**: `target/release/takana`. En una caja **instalada** el
1031binario es `/usr/bin/takana` y `target/` ni existe (la siembra lo excluye a propósito). No falla
1032ruidosamente: cada `hash` falla y el grafo sale entero como `unhashable`, que se lee como «este
1033corpus no se puede hashear» y no como «no encontré el binario».
1034
1035Se destraba con `HAMMER=/usr/bin/takana STORE=/store`, que la propia cabecera del script documenta.
1036Pero la deuda queda anotada: **el instrumental debería caer a `command -v takana`** antes de rendirse,
1037porque el caso «hub instalado» es justamente el que esta mudanza quiere que exista.
1038
1039### 6.7 ✅ PUERTA 3 EN VERDE: los dos grafos, idénticos byte a byte
1040
1041```
1042gioser: 7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605
1043caja:   7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605
1044```
1045
1046`base 61/61 · cli 84/84 · escritorio-mirada 41/41 · servidor 96/96 · falta 0`, `wanted 3`
1047(`chrony`, `cronie`, `logrotate` — la deuda declarada), grafo CIERRA, topo-sort OK. Las dos máquinas.
1048
1049**Costó tres intentos, y los dos fallos son la parte que vale.**
1050
1051**Intento 1 — `unhashable 875`**, todas. Ver arriba: los scripts apuntan a `target/release/takana`.
1052
1053**Intento 2 — dos nodos divergentes**: `aichat` y `zola`, `sealed` en gioser y `never` en la caja. No
1054era deriva ni pérdida: **ninguno de los dos está en disco en gioser tampoco**. Son los «2 sellados
1055que no están en esta máquina, avalados por los manifiestos» que el propio resumen imprime.
1056`build-state.py` lee `work/farm-sellados.txt` y `work/respaldo-sellados.txt` — y **`work/` está
1057gitignored**, así que un hub recién sembrado no los tiene y degrada esos nodos a `never` sin que nada
1058falle.
1059
1060Es la misma familia que `scripts/farm/.fleet`: **un fichero fuera de git del que depende una medición
1061compartida**. La regla que sale: *sembrar un hub no es clonar el repo — es repo + store + lab +
1062los manifiestos*. Copiados los dos ficheros, los grafos coinciden exactamente.
1063
1064### 6.8 Puerta 4: el camino de la granja, PROBADO; la cosecha completa, sin disco
1065
1066**Probado desde la caja** (sin tocar el latido de gioser, todo de sólo lectura salvo el último paso):
1067
1068- llevada la clave de la granja (`~/.ssh/github5` → `/root/.ssh/github5`, 0600) y escrito
1069  `scripts/farm/.fleet` — los dos están fuera de git a propósito y hay que copiarlos a mano;
1070- la caja **alcanza al worker**: `PruebasIA`, load 3, 775 artefactos;
1071- `estado-granja.sh` corre allá y lee bien el worker y el avance KDE (198/199);
1072- y el paso que cierra el lazo: se **cosechó un artefacto real del worker** (`speedtest-go`, 12 M)
1073  con el mismo transporte de la granja, llegó **con su `.hammer/recipe.toml`** y **su binario CORRE
1074  en la caja** (`speedtest-go v1.7.10`). Worker construye → hub cosecha → binario ejecuta.
1075
1076#### ✅ CERRADA (2026-09-11) con el volumen `takana-store`
1077
1078Resuelto el disco (§6.12), la cosecha completa corrió: **1369 → 1575 artefactos, 0 vacíos**, que son
1079exactamente los 206 que faltaban. 12,4 G por la red para 39 G lógicos —`speedup 3.14`, el ahorro de
1080`-H`— y `/store` queda en 73,7 G de 97,9 (79 %).
1081
1082⚠ El enlace con el worker va a **11 MB/s**, no a los 90 MB/s de gioser↔caja: el LXC vive en el
1083Proxmox de gioser, fuera de la red de Hetzner. Media hora para una cosecha de este tamaño.
1084
1085⚠ **Lo que faltaba no era mecanismo: era DISCO.**
1086
1087| | |
1088|---|---:|
1089| artefactos en el worker que la caja no tiene | **206** |
1090| a ~113 MB de media (el artefacto medio del corpus) | **~23 G** |
1091| libre en `/store` de la caja | **3,7 G** (61,5 de 68,7 · 94 %) |
1092
1093Una cosecha completa **no entra**, y forzarla repetiría exactamente el llenado que dejó 1367
1094artefactos vacíos hace un rato. La puerta 4 queda pendiente de una decisión de CAPACIDAD, no de una
1095de ingeniería:
1096
10971. **Enganchar `harkaq-cosecha` (250 G) a la caja.** Es el plan original y no cuesta nada extra —
1098   pero hay que DESENGANCHARLO de gioser, y ahí vive el store, `work/sources`, los tarballs y el
1099   `CARGO_HOME` de la granja. O sea: **es el cutover**. gioser deja de construir en ese mismo
1100   instante, y hoy está moliendo el árbol KDE.
11012. **Un volumen nuevo para la caja** (€0,052/GB/mes: 100 G ≈ €5,2/mes). No interrumpe nada y
1102   destraba la cosecha ya; se paga por duplicado hasta que gioser muera, y entonces se suelta.
11033. **Podar el store de la caja** — deshace parte de la mudanza. No.
1104
1105### 6.9 Puerta 8: los 19 dominios, sondeados uno por uno *(2026-09-11)*
1106
1107El Caddyfile de gioser declara 19 sitios. Sondeados por DNS **y** por HTTP desde fuera, sólo **6**
1108los sirve gioser de verdad:
1109
1110| dominio | DNS | HTTP | qué es |
1111|---|---|---|---|
1112| `gioser.net` · `tawasuyu.net` | gioser | **200** | **VIVO** — las dos landings |
1113| `git.gioser.net` · `git.tawasuyu.net` | gioser | **200** | **VIVO** — el gitea (aloja el `origin` de takana) |
1114| `sergio.gioser.net` · `api.sergio.gioser.net` | gioser | **200** | **VIVO** |
1115| `summa` · `api.summa` · `dev.summa` | **154.197.1.2** | 200 | **YA MUDADOS** a otra máquina: el bloque de Caddy en gioser es fósil aunque el sitio viva |
1116| `api.gioser.net` | gioser | **502** | backend caído |
1117| `mail.sigma.gioser.net` | gioser | **502** | backend caído |
1118| `aura` · `api.aura` · `sigma` · `kosmofono` · `api.kosmofono` | **SIN DNS** | — | fósil: ni DNS ni ficheros en `/var/www` |
1119| `terapeuta.ec` · `andino.ec` | **SIN DNS** | — | fósil con datos: 279 M en `/var/www/terapeuta` |
1120| `gitea.gioser.net` | **SIN DNS** | — | era un `redir` a `git.gioser.net`; inofensivo |
1121
1122**Lo que esto cambia respecto del §1.1**: no son «4 fósiles» sino **13 de 19 entradas que no hay que
1123mudar** — 8 sin DNS, 2 con el backend caído y 3 que ya viven en otra máquina. La superficie real a
1124mudar son **6 sitios**, y de esos el que pesa es el **gitea**, porque aloja el `origin` de takana.
1125
1126#### ✅ DECIDIDO (usuario, 2026-09-11)
1127
1128| fósil | decisión |
1129|---|---|
1130| `aura` · `api.aura` · `sigma` · `kosmofono` · `api.kosmofono` | **mueren** — ni DNS ni ficheros |
1131| `gitea.gioser.net` | **muere** — era un `redir`, y `git.gioser.net` lo cubre |
1132| `api.gioser.net` · `mail.sigma.gioser.net` | **mueren** — backend caído, sin dueño |
1133| `summa` · `api.summa` · `dev.summa` | **no se mudan**: ya viven en 154.197.1.2. Sólo hay que sacar sus bloques del Caddyfile al apagar gioser |
1134| **`terapeuta.ec` · `andino.ec`** | **MUEREN** — decisión explícita del usuario. Los 279 M de `/var/www/terapeuta` se van con la caja; **no se archivan** |
1135
1136⇒ De las 19 entradas del Caddyfile, **13 no se mudan**. La superficie real es de **6 sitios**, y el
1137que pesa es el gitea porque aloja el `origin` de takana.
1138
1139⚠ Que esto quede escrito ES la puerta: el día que se borre gioser, `terapeuta.ec` no va a poder
1140recuperarse, y la diferencia entre «lo decidimos» y «se nos pasó» es este párrafo.
1141
1142### 6.10 Puerta 7: el respaldo desde la caja — y tres bloqueos que sólo se ven corriéndolo
1143
1144Llevadas las credenciales (`~/.config/hammer/*.env`, 0600) y la clave, **la caja alcanza el Storage
1145Box** por el puerto 23 y lista su contenido. El manifiesto se refresca desde allá:
1146**3140 artefactos**, y de paso el script detecta **2 VACÍOS en el respaldo** (`gnome-desktop` ×2) que
1147excluye del manifiesto — su propia guarda, funcionando.
1148
1149Para llegar ahí hubo que arreglar **cinco** cosas, y todas son de la misma familia: *el instrumental
1150asume que el hub es gioser*. El `--seco` ahora completa los tres pasos desde la caja y lee la
1151ocupación del box (111 G de 1 T).
1152
11531. **La raíz cableada.** `RAIZ="${RAIZ:-/mnt/vvv/takana}"` ⇒ desde cualquier otra máquina el script
1154   moría con `cd: /mnt/vvv/takana: No such file or directory`. Ahora se deriva de dónde está el
1155   script, como el resto de `scripts/`.
11562. **El binario de desarrollo.** `HAMMER = ROOT/target/release/takana` (§6.7).
11573. **El store no estaba donde el script creía.** Usaba `$RAIZ/store` cableado: en gioser es un
1158   bind-mount DENTRO del repo, pero en una caja instalada es una partición en `/store` ⇒
1159   `change_dir "/opt/takana/store" failed`. Y rsync devuelve **23**, que está en la lista de
1160   reintentables, así que el bucle insistía — el cuadro que la propia cabecera del script documenta
1161   («un error reintentable que se repite 40 veces no es un corte de red: es algo estructural»).
1162   Ahora `STORE` es env, con una guarda que aborta ANTES del bucle si la ruta no existe.
11634. **🚨 Y una que habría costado el historial.** El paso [2/3] sube el repo **con `--delete`** —
1164   correcto, para eso está git. Pero el `/opt/takana` de la caja llegó por `rsync --exclude=.git`:
1165   es una COPIA, no un clon. **Una corrida real desde ahí habría borrado `.git` del Storage Box**, o
1166   sea el historial entero, y en silencio: rsync haría exactamente lo que se le pidió. Añadida una
1167   guarda que aborta si la raíz no tiene `.git` (escotilla `REPO_INCOMPLETO=1`), probada en los tres
1168   sentidos.
1169
1170   **La regla que sale**: un `--delete` convierte «respaldar» en «sincronizar», y sincronizar desde
1171   un origen incompleto no sube menos — BORRA. Cualquier respaldo con `--delete` necesita una guarda
1172   de completitud del ORIGEN, no sólo del destino.
11735. **El rsync del corpus no puede correr el respaldo del corpus.** El script exige
1174   `--compress-choice=zstd` y `recipes/rsync.toml` lo construía con `--disable-zstd` — porque cuando
1175   se escribió, `zstd` no estaba en el catálogo. Hoy sí. Peor aún: el error 4 que devuelve rsync el
1176   script lo clasifica como «no de red» y **no reintenta**, así que el respaldo simplemente no se
1177   hacía. Arreglado por los dos lados: la receta enciende zstd (control: `zstd zlibx zlib none` vs
1178   `zlibx zlib none`; radio cero, `rsync` no es dep de nadie) y **el script degrada a zlib avisando**
1179   en vez de morir.
1180
1181### 6.11 ⚠ La distro traía un `git` que NO PODÍA CLONAR
1182
1183Para cerrar la puerta 7 la caja necesita un **clon de verdad** (el `--delete` del respaldo exige un
1184origen completo, §6.10). Llevada la clave del gitea —que es `~/.ssh/tawasuyu`, **no** la de la
1185granja— `git ls-remote` contesta… y `git clone` muere:
1186
1187```
1188fatal: fetch-pack: invalid index-pack output
1189```
1190
1191El informe completo, que la traza corta escondía:
1192
1193```
1194panic: load of misaligned address 0x… for type 'const uint32_t',
1195       which requires 4 byte alignment
1196  in sha1_compression_states → sha1_process → SHA1DCUpdate → git_SHA1DCUpdate
1197  → git_hash_update → unpack_entry_data → cmd_index_pack
1198```
1199
1200**Dos causas, encadenadas:**
1201
12021. **`-fsanitize=undefined` estaba en `CFLAGS`**, no sólo en `LDFLAGS`. El motivo original era
1203   legítimo —las `libz.a` materializadas traen referencias `__ubsan_handle_*` que bajo `-static` no
1204   se resuelven solas, y el flag *al enlazar* trae el runtime de zig— pero en `CFLAGS` **instrumenta
1205   el código de git**. Un arreglo de ENLACE convertido en una mina de RUNTIME, justo en la ruta de
1206   hash: por donde pasa todo lo que git recibe.
12072. **`sha1collisiondetection` lee palabras de 32 bits SIN alinear.** Es el backend SHA1 por defecto
1208   de git, el que detecta SHAttered. En x86 la lectura desalineada funciona y por eso nadie lo nota
1209   jamás; según el estándar es UB, y basta con que el runtime ubsan esté enlazado para que aborte.
1210   `-DSHA1DC_FORCE_ALIGNED_ACCESS` lo hace leer byte a byte: quita el UB **en la fuente** en vez de
1211   esconderlo.
1212
1213Con las dos: `git clone --depth 1` del propio repo desde la caja ⇒ **877 recetas**. Radio cero
1214(`git` no es dep de nadie), pero **sí es raíz de `perfil.base`** ⇒ esto arregla la distro entera.
1215
1216**La lección**: `ls-remote` andaba, así que el fallo parecía de red. Lo que lo destapó fue leer el
1217informe COMPLETO en vez de la última línea — la primera decía exactamente qué y dónde.
1218
1219### 6.12 ⚠ La caja dejó de ser desechable — y `hydrate` no cruza el store
1220
1221Dos consecuencias de que la caja ya sea un hub, y las dos cambian el procedimiento:
1222
1223**1. Re-escribir la imagen con `dd` ya NO es una actualización, es una pérdida.** El `dd` sobrescribe
1224el disco local, y ahí viven ahora `/opt/takana` (el clon), `/root/.ssh` (las dos claves),
1225`/root/.config/hammer` (las credenciales del respaldo), `/srv/repo` y la configuración de caddy. El
1226store se salva sólo porque está en el volumen. **Y hay una trampa peor**: la imagen etiqueta su
1227partición local como `hammer-store`, así que tras un `dd` habría **dos filesystems con esa etiqueta**
1228y el `findfs` del arranque elegiría cualquiera — con suerte el volumen, con mala suerte una partición
1229vacía. ⇒ el `dd` es para PROVISIONAR; actualizar es `takana upgrade` (que existe, con generaciones y
1230rollback) y está sin probar en esta caja.
1231
1232**2. `takana hydrate` no puede proyectar al root vivo.** Hidratar enlaza en DURO, y en una caja
1233instalada el store es **siempre** otra partición que `/`:
1234
1235```
1236Error: store: hardlink /store/51aa5e13…-git/.hammer/recipe.toml → /.hammer/…: Cross-device link
1237```
1238
1239No es culpa del volumen: en el layout original `/store` es `sda4` y `/` es `sda2`, también distintos.
1240**Hidratar al FHS vivo nunca fue posible en una caja instalada** — funciona en el hub porque ahí
1241store y destino están en el mismo filesystem. Es la misma familia de EXDEV que ya mordió dos veces
1242(§6.6), ahora a nivel de sistema y no de herramienta.
1243
1244Para instalar el `git` arreglado se copió el artefacto a mano. Eso **funciona y no es el camino**: el
1245camino es `takana upgrade`, y probarlo es su propia unidad de trabajo.
1246
1247### 6.13 ✅ `takana upgrade`, probado de verdad — y el bug de durabilidad que destapó
1248
1249§6.12 dejó dicho que el camino de actualización de una caja instalada es `takana upgrade`, no `dd` ni
1250`hydrate`. Probado con un paquete que la caja **realmente necesitaba**: `zstd`.
1251
1252| paso | resultado |
1253|---|---|
1254| `upgrade apply …-zstd-cli` | `✓ generación 2 aplicada · + 1 añadidos, ~ 1 pisados, - 8 retirados` |
1255| la prueba de uso | `zstd -dc lab-image.tar.zst \| tar -xf -` ⇒ **la caja desempaca su propio lab** |
1256| `upgrade rollback` | `✓ generación 2 revertida · 9 restaurados, 1 borrados` — `zstd` se va, `libzstd.a` vuelve |
1257| re-`apply` + **corte duro sin `sync`** | sobrevive: generación viva 1, manifiesto íntegro |
1258
1259✔ **Y cruza la frontera que `hydrate` no puede**: el store está en el volumen y `/` en el disco local,
1260y `upgrade` copia en vez de enlazar. Es, de hecho, el único camino que funciona en una caja instalada.
1261
1262#### ⚠ El bug: `write_atomic` era atómico pero NO DURABLE
1263
1264El primer intento pareció funcionar y **se perdió al reiniciar**. La caja volvió con:
1265
1266```
1267pending.json                   0 bytes
1268generations/2/manifest.json    0 bytes
1269upgrade status  → Error: json: EOF while parsing a value
1270upgrade recover → Error: json: EOF while parsing a value
1271```
1272
1273`write_atomic` hacía temporal + `rename`: atómico frente a otros PROCESOS, no frente a un CORTE.
1274`rename` sobre un fichero cuyos datos siguen en caché deja, tras el corte, la entrada apuntando a
1275bloques nunca escritos — cero bytes. Y `hcloud server reset` **es un corte duro**, no un apagado
1276limpio: ahí está la mitad operativa de la lección.
1277
1278Lo grave no es perder un upgrade: es que **`recover` —que existe exactamente para «un apply
1279interrumpido por un corte»— abortaba con la huella más probable de ese corte**.
1280
1281Arreglado: `fsync` del temporal antes del `rename` y `fsync` del directorio después (hacen falta los
1282dos), y un `pending.json` vacío se reporta como `PendingCorrupt`, nombrando el corte y apuntando al
1283árbol de respaldos. Con tests y su control.
1284
1285**Verificado en la máquina, en los dos sentidos**: con el binario viejo, apply + reset duro ⇒ estado
1286en 0 bytes; con el nuevo, la misma secuencia ⇒ estado íntegro y el paquete en su sitio.
1287
1288#### ⚠⚠ `upgrade apply` NO es un gestor de paquetes: cada generación ES UN ÁRBOL
1289
1290Lo destapó usarlo dos veces seguidas. Tras aplicar `zstd-cli` y después `rsync`:
1291
1292```
1293✓ generación 2 aplicada
1294  + 0 añadidos, ~ 8 pisados, - 1 retirados      ← el `zstd` de la generación 1
1295$ zstd --version
1296AUSENTE
1297```
1298
1299**Aplicar un árbol nuevo RETIRA los ficheros del anterior**, porque una generación es el estado
1300completo, no un incremento. Es coherente con lo que la ayuda dice —«aplica un árbol Stage1/**producto**
1301del store»— y con que exista `rollback`: lo que se revierte es un sistema, no un paquete.
1302
1303⇒ Usarlo para instalar paquetes sueltos, como hice acá, funciona una vez y se deshace a la siguiente.
1304**La forma correcta de poner una caja al día es componer el árbol del perfil —lo que ya hace
1305`servidor-image.sh`— sellarlo, y aplicar ESO como una generación.** Eso además sería, de verdad, la
1306«actualización en sitio probada» del [SDD 19 §5.2](19-lanzamiento-publico.md): una imagen entera, con
1307rollback, sobre una caja viva.
1308
1309Mientras tanto la caja quedó con `zstd` y `rsync` puestos a mano — funciona y **no es el camino**,
1310igual que el `git`.
1311
1312⚠ Lo que NO se arregló: si el manifiesto se pierde, `recover` no puede deshacer solo — no sabe qué se
1313tocó. El árbol de respaldos tiene la información; reconstruir desde ahí es su propia unidad.
1314
1315### 6.2 Lo que la mudanza tiene que producir, además de la mudanza
1316
1317El usuario lo pidió explícito: **que este experimento saque recetas y las pruebe**. La caja vieja es
1318un catálogo de software real en producción, y cada servicio que hoy corre sobre binarios de Artix es
1319una receta candidata con un caso de uso comprobado detrás:
1320
1321| lo que corre hoy en Artix | receta |
1322|---|---|
1323| `caddy` | ✅ existe (`recipes/caddy.toml`) |
1324| `openssh` | ✅ existe |
1325| `gitea` | ✅ **SÍ existe** (`recipes/gitea.toml`) **y está sellada** (`35bb4f04…`) — ver la corrección abajo |
1326| `qdrant`, `squid`, `fail2ban`, `php-fpm`, `metalog`, `glances` | ✗ |
1327| `chrony`, `nftables`, `cronie`, `logrotate` | ✗ — ya declaradas `wanted` en `perfil.servidor` |
1328
1329> **CORRECCIÓN (2026-09-11).** Esta tabla decía que `gitea` NO tenía receta y que «es la que más
1330> peso tiene». **Las dos cosas eran falsas**: `recipes/gitea.toml` existe desde el 2026-09-09 y su
1331> artefacto está sellado. Lo escribí de memoria, sin mirar el catálogo.
1332>
1333> Lo destapó `planear.py` al cruzar cada binario contra `recipes/`: de los servicios de gioser, **4
1334> ya tienen receta takana y están sellados** (`caddy`, `gitea`, `python3`…), 4 vienen de un paquete
1335> ajeno y **11 son binarios sueltos sin dueño**. Ésos últimos son el trabajo real, no gitea.
1336>
1337> Es exactamente para esto que la herramienta existe: yo afirmé de memoria y el programa fue a mirar.
1338
1339**Cada una que se cierre se prueba sola**: el servicio o levanta en la caja nueva o no, y eso se ve
1340el mismo día. Es la diferencia entre una receta «sellada» y una receta *usada*.
1341
1342---
1343
1344### 6.11 🗄️ La imagen del perfil, con gitea, ARRANCA y SIRVE *(2026-09-14)*
1345
1346`servidor-image.sh` + QEMU: **PID 1 = `arje-zero`, gitea encarnado por él (`ppid=1`), corriendo como
1347`uid=916`, escuchando en :3000 y devolviendo `200` con `<title>takana git</title>`**, con su
1348`gitea.db` creada por él mismo. Es el primer servicio de PAQUETE que arranca en una imagen de takana:
1349los que había venían horneados en el bootstrap.
1350
1351La cadena entera, eslabón por eslabón: `[[user]]` → `/etc/passwd` de la imagen · `[[service]]` →
1352`takana service-cards` → `genesis` de la seed → arje lo encarna → `setuidgid` → sirve.
1353
1354#### Los tres fallos que sólo aparecieron AL PROBAR LA IMAGEN
1355
1356Los tres pasan el sellado, el `hash`, el resolutor de servicios y el armado. Ninguno se ve sin
1357arrancar la imagen.
1358
13591. **⚠⚠ Escribir en el rootfs hidratado es escribir DENTRO del store.** `takana users --merge` hacía
1360   `fs::write` sobre `<rootfs>/etc/passwd`, y ese fichero y el del artefacto `product-rootfs` son
1361   **el mismo inode** (medido: 1225824, `2 links`, modo `444`) — el rootfs se arma con hardlinks.
1362   Habría mutado un artefacto sellado, y toda imagen futura habría salido con la cuenta metida dentro
1363   del producto. Se salvó porque el store es de sólo lectura y salió `Permission denied`: confiar en
1364   eso es confiar en un permiso. Ahora escribe por temporal + `rename`, con un control que comprueba
1365   que el fichero del store conserva su contenido y baja a 1 link.
1366
13672. **La imagen traía el binario, la cuenta… y nadie lo arrancaba.** El `genesis` sólo tenía lo que
1368   hornea `takana-bootstrap` (`sshd`, `console-getty`, `hammerd`): `servidor-image.sh` **no inyectaba
1369   las Cards** — eso sólo existía en el camino de las imágenes de escritorio. Y ninguna métrica lo
1370   dice: `targets.py --services` responde que el perfil lo habilita, y lo habilita; lo que faltaba era
1371   el paso que lleva esa declaración a la imagen.
1372
13733. **⚡ `trap invalid opcode`: el binario sólo corría en la CPU que lo compiló.** Con la card ya en el
1374   genesis, gitea moría al instante dentro de QEMU-TCG. El sandbox exporta `CC` apuntando a un wrapper
1375   con `-mcpu=baseline` (y su comentario ya avisaba: «de paso cierra el SIGILL de AVX en qemu64»), y
1376   **la fase Go del propio builder lo pisaba** con `CC="zig cc"` a secas, que es `-mcpu=native`. No
1377   falla al compilar ni al sellar: falla al ejecutar en otra CPU. Y el `ArtifactHash` no puede
1378   cazarlo, porque la CPU del builder no entra en `hash_inputs` — **dos workers distintos sellan bytes
1379   distintos bajo la misma dirección**. Arreglado en el builder y en la receta; re-hashea las cinco
1380   recetas `cgo = true` (`gitea`, `usql`, `sq`, `gocryptfs`, `naabu`).
1381
1382#### Lo que NO está probado, y falta para la mudanza
1383
1384El `app.ini`, el usuario del sitio y los datos se pusieron **a mano** dentro de la VM para llegar al
1385200. Eso es exactamente lo que el plan de mudanza tiene que traer de gioser — `/etc/gitea` ya está en
1386`scripts/mudanza/rutas-fuera-de-git.txt`.
1387
1388Y **la imagen no trae `arjectl`**: tras poner la config hubo que REINICIAR para que arje volviera a
1389intentarlo. El backoff de `restart` se agota y no hay forma de pedirle a PID 1 que relance un ente en
1390caliente. Para una caja de producción eso es una pieza que falta, no una comodidad.
1391
1392### 6.12 🎛️ `arjectl` en la imagen: control en caliente *(2026-09-14)*
1393
1394El §6.11 cerró con «la imagen no trae `arjectl`, hubo que reiniciar la máquina para arrancar un
1395servicio». Ya lo trae, y **no hubo que escribir nada**: el cliente existía en tawasuyu y se llama
1396**`arje-ctl`** (el crate; el binario es `arjectl`). Buscarlo por `arjectl` no lo encontraba, y de ahí
1397salió la conclusión falsa de que faltaba implementarlo — el protocolo (`arje-bus::BusRequest`) ya
1398traía `ListEntes`, `SpawnCardFromDisk`, `StopCardFromDisk`, `KillEnte` y `EnteStatus`.
1399
1400`recipes/arjectl.toml` lo construye **del mismo commit que `arje-zero`** (`98db584f`), y eso no es
1401comodidad: el bus es un protocolo entre dos binarios, y un cliente de otro árbol puede conectar y no
1402entenderse con el init. Publica **sólo `arjectl`**; el crate también produce un `systemctl` de
1403camuflaje que acá no se instala — un `systemctl` en el PATH de una distro sin systemd invita a
1404escribir runbooks con el verbo ajeno.
1405
1406#### El hueco que sólo se ve usándolo: el genesis no alcanza
1407
1408Con `arjectl` en la imagen, el primer intento falló con un mensaje que nombra la causa:
1409
1410    $ arjectl start gitea
1411    Error: arje-zero rechazó: card gitea: No such file or directory
1412           (buscada en /etc/arje/cards.d/gitea.json)
1413
1414`start`/`restart` usan `SpawnCardFromDisk`, que lee **`/etc/arje/cards.d/<label>.json`** — y el
1415armado sólo escribía el `genesis` de la seed. Son dos preguntas distintas: *qué arranca solo* y *qué
1416se puede encarnar a pedido*. Con sólo la primera, un servicio que agota su backoff **no se puede
1417relanzar sin reiniciar la máquina**. `inyectar-cards.py` escribe ahora los dos árboles (y en
1418`cards.d` escribe TODAS, no sólo las nuevas: `sshd` viene del product-rootfs y tampoco era
1419relanzable).
1420
1421#### Medido, con la VM arrancada UNA sola vez
1422
1423| | |
1424|---|---|
1425| la imagen trae | `/etc/arje/cards.d/{gitea,sshd}.json` |
1426| `arjectl list-units` | tabla con PID, CPU%, MEM, HILOS y reinicios de cada Ente |
1427| poner `/etc/gitea/app.ini` + `arjectl start gitea` | **`GET /` → 200**, sin reiniciar (`uptime` 6 min) |
1428| `arjectl restart gitea` | el PID cambia (118 → 192) y sigue sirviendo 200 |
1429
1430#### ⚠ `arjectl start` sobre un Ente YA VIVO lo DUPLICA
1431
1432`SpawnCardFromDisk` no deduplica por label: encarna otra instancia y arje le da un ULID nuevo. En
1433`gitea` el síntoma fue `unable to lock level db … resource temporarily unavailable` seguido de `[F]`
1434— dos servidores peleando por el mismo estado, con el HTTP intermitente mientras duraba. **Para
1435relanzar se usa `restart`**, o se mira `list-units` antes. Un `start` idempotente es trabajo de arje,
1436no de esta imagen; queda anotado río arriba.
1437
1438### 6.13 🧪 Ensayo con los DATOS REALES de gioser — el gitea de la caja vieja corriendo en takana
1439
1440Sin tocar gioser (todo lectura) y en la VM desechable. Es el ensayo que faltaba antes de cualquier
1441cutover, y trajo cuatro cosas que no se ven en el papel.
1442
1443**El snapshot de la DB se saca en caliente y sale consistente.** `sqlite3 gitea.db ".backup …"` con
1444el servidor VIVO: **1,5 s para 314 M**, `pragma integrity_check` → `ok`, 44 filas en `repository`.
1445Copiar el fichero a pelo con el servidor escribiendo es lo que NO hay que hacer; la API de backup de
1446sqlite existe justo para esto.
1447
1448**Resultado, medido dentro de la VM:** `<title>GioSer Gitea: Git with a cup of tea</title>`, 200, y
1449`/explore/repos` listando los repos de verdad (`sergio/takana`, `tawasuyu/agora`, `card`, `chasqui`,
1450`cosmos`, `khipu`, `llimphi`…). Y el clon de uno de los repos copiados: **478 commits**, HEAD
1451correcto, ficheros reales.
1452
1453#### Los tres detalles que sólo aparecen con los datos puestos
1454
14551. **El uid del origen no es el del destino.** Los ficheros llegan con su uid NUMÉRICO (`1001` en el
1456   rsync de prueba; el gitea de gioser es **969**) y el `[[user]]` declaraba 916. O se chownean 2 G
1457   —lento, y hay que acordarse— o gitea no puede leer sus datos, **y eso no falla al copiar: falla
1458   al arrancar**. La receta pasa a declarar **969**, alineado con el origen.
14592. **El `app.ini` de gioser escucha en `127.0.0.1:3002`**, porque allá caddy hace de proxy. Copiado
1460   tal cual, la caja sirve — pero sólo desde dentro. El `HTTP_ADDR`/`HTTP_PORT` y el `ROOT_URL` son
1461   parte de la adaptación, no del copiado.
14623. 🧨 **El `git` de la distro NO puede clonar por HTTP.** `git clone http://…` dentro de la imagen:
1463
1464       git: 'remote-http' is not a git command. See 'git --help'.
1465       fatal: remote helper 'http' aborted session
1466
1467   Medido sobre el ARTEFACTO sellado: `/usr/libexec/git-core/` trae `git-remote-ext`, `git-remote-fd`
1468   y `git-http-backend` (el lado SERVIDOR), y **no** `git-remote-http`/`-https`; `strings` del binario
1469   da **0** referencias a libcurl, pese a que `curl` está en `[deps] build`. Nadie lo había notado
1470   porque en el hub el `git` que se usa es el de Artix, no el sellado. Es
1471   [[subcomando-sin-driver]] otra vez: instalado, con contenido, reproducible… y sin el camino que
1472   hace falta. **Un hub nuevo no podría clonar el repo por HTTPS.** Arreglarlo re-sella `git`, que es
1473   raíz de `perfil.base` ⇒ unidad propia, no de paso.
1474
1475### 6.14 ✅ CUTOVER DEL GITEA — sirve desde la caja, con TLS y por SSH *(2026-09-14)*
1476
1477**Hecho y verificado desde fuera:** `git.gioser.net` y `git.tawasuyu.net` responden **200 con TLS
1478válido desde `2.29.29.217`**, `git clone` funciona **por HTTPS y por SSH:2345**, y `gitea` y `caddy`
1479corren supervisados por `arje-zero` en la caja. El gitea de gioser está **parado**.
1480
1481**Mudanza incremental, no big-bang.** Los DNS de gioser son casi todos `CNAME → www`, así que mover
1482`www` habría mudado quince dominios cuyos backends siguen allá: 502 en todos. Se convirtió sólo
1483`git` (en las dos zonas) de CNAME a **A propio con TTL 60**, que es reversible en un minuto.
1484
1485#### La secuencia que funcionó, en orden
1486
14871. Caja actualizada: `arjectl` + `gitea` + la cuenta `gitea:969` + las cards en `genesis` y `cards.d`.
14882. Datos en dos fases: **1,66 GB / 18 047 ficheros** con el origen VIVO (17 s), y en el corte el
1489   `rsync --delete` incremental + `sqlite3 ".backup"` (`integrity_check ok`).
14903. **Parar el origen de verdad.** `rc-service gitea stop` dice *«already stopped»* con el proceso
1491   vivo (el `rc-status` que miente, §Los tres hechos), y matarlo **no alcanza**: lo revive
1492   `arje-zero`, que lo tiene como card `openrc-gitea` con `Restart` — **9001 reinicios** acumulados
1493   en el contador. Se paró con **`arjectl stop openrc-gitea`**, y para eso hubo que extraer su card
1494   del `genesis` y escribirla en `/etc/arje/cards.d/` (§6.12: `SpawnCardFromDisk`/`StopCardFromDisk`
1495   leen de ahí, no del genesis).
14964. DNS por la **API de Hetzner Cloud** (`/v1/zones`): el token de `hcloud` **también gestiona el
1497   DNS** desde la unificación — la API vieja `dns.hetzner.com/api/v1` redirige a la consola. Y un
1498   `PUT` sobre el rrset **no puede cambiar el tipo**: hay que `DELETE` del CNAME y `POST` del A.
14995. TLS: el primer intento de ACME **falló contra gioser** (`502`) porque el challenge salió antes de
1500   que propagara; con el DNS ya al día, `arjectl restart caddy` y *certificate obtained successfully*.
1501
1502#### La identidad SSH se muda con el servicio
1503
1504`git clone ssh://…:2345` daba **`REMOTE HOST IDENTIFICATION HAS CHANGED`**: es otra máquina. Se
1505copiaron las **claves de host** de gioser (`/etc/ssh/ssh_host_*`) a la caja ⇒ los clones existentes
1506no notan nada. El precio es que cambia también la identidad del `:22` de administración, y hay que
1507limpiar el `known_hosts` propio — a los clientes del servicio no les afecta, que es lo que importa.
1508
1509Y el `sshd` del producto escucha sólo en `:22`: el git por SSH necesitó `Port 2345` y que el usuario
1510`gitea` tenga **shell real** (su `authorized_keys` fuerza `command="gitea serv …"`, y con
1511`/bin/false` no se ejecuta nada).
1512
1513#### Lo que esto cambia para la granja
1514
1515Las 23 recetas que ahora clonan por `https://git.tawasuyu.net/…` **apuntan a la caja**. Comprobado
1516tras el cambio: el worker resuelve `2.29.29.217` y `git ls-remote` responde. La granja depende ya
1517del servidor nuevo, que es el sentido de la mudanza.
1518
1519#### Cómo se revierte, si hiciera falta
1520
1521`arjectl start openrc-gitea` en gioser y devolver los dos `git` a `CNAME → www`. Con TTL 60, minutos.
1522
1523### 6.15 Cerrar el intermedio: el viejo apagado de verdad, el `:22` cerrado y los datos respaldados
1524
1525#### ⚠ El gitea viejo había VUELTO a arrancar — y lo arrancó OTRO supervisor
1526
1527Media hora después del corte, gioser volvía a servir en `:3002`. `arjectl list-units` **ya no lo
1528mostraba** (el `stop` de arje seguía en pie) y el proceso tenía **`ppid ≠ 1`**: lo había levantado
1529**OpenRC**, que lo tenía en el runlevel `default`. O sea **dos supervisores para el mismo servicio**
1530—la card `openrc-gitea` de arje y el runlevel de OpenRC— y parar uno no para el otro.
1531
1532    rc-update del gitea default   # que no vuelva
1533    rc-service gitea stop         # ahora sí: OpenRC lo arrancó, OpenRC sabe pararlo
1534
1535Antes del corte pasaba lo contrario (`rc-service … stop` decía *«already stopped»* con el proceso
1536vivo, porque quien lo tenía era arje). **La pregunta no es «¿está parado?» sino «¿quién lo tiene?»**,
1537y hay que responderla dos veces.
1538
1539#### El origen deja de servir git, y se comprueba que no se rompió lo demás
1540
1541Los tres bloques (`git.gioser.net`, `git.tawasuyu.net`, `gitea.gioser.net`) salen del Caddyfile de
1542gioser —con respaldo previo, `caddy validate` y `reload`—, y quedan 16. Control inmediato:
1543`sergio.gioser.net` y `hifas.gioser.net` siguen en **200**. `gitea.gioser.net` pasa a `A` → la caja,
1544donde ya está su `redir`.
1545
1546#### El `:22` cerrado, sin perder el acceso
1547
1548Administración en **22022**, git por SSH en **2345**, y el **22 cerrado** (`Connection refused`) para
1549sacarse de encima el barrido de bots. La secuencia importa y es la única segura: **añadir** el puerto
1550nuevo → reiniciar → **entrar por él** → sólo entonces quitar el 22. Comprobado en ese orden, y el
1551`git clone ssh://…:2345` sigue funcionando después.
1552
1553#### 🧨 Los datos del gitea NUNCA estuvieron respaldados
1554
1555`respaldo-storagebox.sh` respalda el **store de artefactos**, no `/var/lib/gitea`. O sea que los 44
1556repos vivían en una sola copia — en gioser antes, en la caja ahora. La mudanza no lo empeoró, pero sí
1557lo hace urgente: **gioser se borra**. Hecho hoy desde la caja: snapshot de la DB con el servicio vivo
1558+ `rsync` a `u647150:gitea/` — **1,7 G** (`datos/` + `gitea.db` de 314 M).
1559
1560⚠ **Al Storage Box se entra por el puerto 23**, no el 22: el 22 da SFTP/SCP restringido y contesta
1561`Permission denied (publickey,password)`, que se lee como *«no tengo la clave»* cuando la clave está
1562perfecta. Pasó en las dos máquinas antes de mirar el script (`SB_PORT=23`).
1563
1564**Pendiente**: que ese respaldo sea periódico, no de una vez. Es un renglón en el cron de la caja.
1565
1566### 6.16 ⚠ SÍ se perdieron dos commits en la ventana — y cómo se detectó
1567
1568La pregunta correcta después de un cutover no es «¿arrancó?» sino **«¿entró algo en el viejo
1569mientras tanto?»**. Se contesta comparando **todos los refs de los dos lados**:
1570
1571```sh
1572for r in <repos>/*/*.git; do git -C "$r" for-each-ref --format="$r %(refname) %(objectname)"; done | sort
1573```
1574
1575845 refs a cada lado, y **dos diferencias**. Una era esperada (`sergio/takana` main, el nuevo por
1576delante con los commits posteriores al corte — `merge-base --is-ancestor` confirma fast-forward). La
1577otra **no**: `tawasuyu/tawasuyu` tenía en el VIEJO dos commits que el nuevo no tenía —
1578`b69008eb` (freebsd) y `02a9535f` (shuma, **19:48**)—, o sea **quince minutos después del snapshot
1579final**.
1580
1581**Por qué entraron**: el gitea viejo había resucitado (§6.15, OpenRC) y un cliente con el DNS
1582**cacheado** —el CNAME anterior tenía TTL 600— lo empujó a gioser aunque el autoritativo ya dijera
1583la caja. Las dos condiciones a la vez: un origen que revive y una caché que todavía apunta.
1584
1585**Recuperados** con un `git push` del ref exacto desde el clon local al gitea nuevo; la UI del nuevo
1586ya muestra `02a9535f`. Segunda pasada de comparación: **845/845 y una sola diferencia, la esperada**.
1587Y del lado de los metadatos, `action` tenía tres filas posteriores al snapshot (los mismos dos
1588repos) y **cero** issues, comentarios o releases: no se perdió nada que no fueran esos commits.
1589
1590**La lección para el resto de la mudanza**, en orden: (1) bajar el TTL **antes** del corte, no
1591durante; (2) apagar el origen de verdad —los DOS supervisores— **antes** de tocar el DNS; (3)
1592comparar refs después, siempre, porque es la única prueba de que no se perdió nada; y (4) dejar el
1593origen apagado un rato **antes** de borrarlo, precisamente para poder hacer esta comparación.
1594
1595### 6.17 ⚠ En gioser conviven arje Y OpenRC — y el prefijo `openrc-` de las cards ENGAÑA
1596
1597El usuario avisó: *«esa máquina la tengo con arje desde hace meses»*. Tiene razón, **y las dos cosas
1598son ciertas a la vez**:
1599
1600| medido | |
1601|---|---|
1602| PID 1 | **`arje-zero`** ✓ |
1603| la card `openrc-gitea` | ejecuta `sh -c "cd /var/lib/gitea && exec /usr/bin/gitea web …"` — **no llama a OpenRC** |
1604| `/run/openrc/started/` | **existe y tiene servicios**: NetworkManager, dbus, dhcpcd, fsck, hostname, hwclock, local, localmount… |
1605| `rc-update show default` | listaba `gitea` |
1606
1607⇒ **El prefijo `openrc-` es herencia del NOMBRE**, de cuando `arje-absorb` tradujo los servicios de
1608OpenRC a cards: la card no pasa por `rc-service`, encarna el binario directo. Pero **OpenRC quedó
1609como residuo ACTIVO** y puede arrancar servicios por su cuenta — que es lo que revivió al gitea con
1610`ppid ≠ 1` después de que arje lo hubiera detenido.
1611
1612**La regla para el resto de la mudanza**: antes de dar un servicio de gioser por apagado, preguntar a
1613los **dos**:
1614
1615```sh
1616arjectl list-units | grep <svc>          # la capa de arje (PID 1)
1617rc-update show default | grep <svc>      # el residuo de OpenRC, que también arranca
1618```
1619
1620Es [[la-etiqueta-no-es-el-hecho]] en su forma más cara: un nombre que dice `openrc-` y no es OpenRC,
1621junto a un OpenRC real que nadie esperaba que siguiera operando.
1622
1623### 6.18 Mudados `takana.gioser.net` y `hifas.gioser.net` *(estáticos puros)*
1624
1625Los dos son ficheros y nada más —sin backend—, así que mudarlos fue copiar 52 K, añadir su vhost al
1626Caddyfile de la caja, mover el DNS de CNAME a **A** y quitarlos del origen. **200 con TLS válido
1627desde `2.29.29.217`**, y control de que no se rompió lo demás: `sergio.gioser.net` sigue en 200 y
1628gioser baja de 16 a **14 vhosts**.
1629
1630Se repitió el patrón de ACME: el primer intento de certificado sale ANTES de que propague el DNS,
1631falla contra el origen y hay que forzar el reintento con `arjectl restart caddy`. **Conviene mover
1632el DNS y recién entonces añadir el vhost** — o asumir un `restart` de más.
1633
1634**Quedan en gioser 14 vhosts**, todos con backend propio (uvicorn, php-fpm, `tejido`, `shuma`…) o
1635fósiles sin DNS. Ésos no se mudan copiando un directorio: necesitan su servicio del otro lado.
1636
1637### 6.19 Los fósiles, barridos: de 19 vhosts a **2**
1638
1639Se midió cada dominio que quedaba —DNS por DoH **y** HTTP contra la IP del origen— antes de tocar
1640nada. El resultado deja la mudanza mucho más chica de lo que parecía:
1641
1642| clase | cuántos | qué son |
1643|---|---|---|
1644| **fósiles sin DNS** | 5 | `aura`, `api.aura`, `sigma`, `kosmofono`, `api.kosmofono` |
1645| **ya viven en OTRA máquina** | 3 | `summa`, `dev.summa`, `api.dev.summa` → `154.197.1.2` |
1646| **502 permanente** | 2 | `mail.sigma` (:9000 caído), `api.gioser.net` (:8000 caído) |
1647| **mudados hoy** | 5 | los tres de git + `takana` + `hifas` |
1648| **VIVOS y por mudar** | **2** | `sergio.gioser.net` y `api.sergio.gioser.net` |
1649
1650**Y ninguna de las raíces estáticas de los fósiles existe en disco**: `/var/www/{aura_frontend,
1651sigma,kosmofono,summa,summa-dev}` no están. El Caddyfile servía directorios ausentes — la
1652configuración sobrevivió a sus datos. De los backends, sólo `:8770` sigue escuchando, y su dominio
1653ya apunta a otra máquina; `:8766 :8765 :9000 :8000 :8768` están caídos.
1654
1655Los 10 bloques muertos salen del Caddyfile (con respaldo previo, `validate` y `reload`), y el control
1656confirma que los vivos siguen: `sergio` y `api.sergio` en **200**, y los mudados respondiendo desde
1657la caja. **gioser pasa de 19 vhosts a 2.**
1658
1659⚠ **El único fósil CON datos es `terapeuta.ec`: 279 M en `/var/www/terapeuta`**, sin DNS y sin vhost.
1660No se borra acá — se anota para la decisión de borrado de la máquina, que es del §6.
1661
1662**Lo que esto cambia**: mudar los dos que quedan es un frente acotado —un backend Python en `:7378`
1663y una API en `:8771`— y no las quince cosas que la lista sugería.
1664
1665### 6.20 🛟 Cinco binarios rescatados de la nada — el paso que CADUCABA
1666
1667Antes de tocar `sergio.gioser.net` apareció lo urgente: **cinco procesos vivos cuyo binario YA NO
1668EXISTE en disco** (`/proc/<pid>/exe` → `… (deleted)`). Sólo existían como inode huérfano: si el
1669proceso muere o la máquina se reinicia, se pierden. Es el paso que el SDD 29 §4.0 undecies llamó *«el
1670que caduca»*, y caducaba de verdad.
1671
1672    tejido · sandokan-mcp · pacha-secretos · puerta-f6e393ffbef3999e · shuma-gateway   (240 M)
1673
1674Se recuperaron leyendo `/proc/<pid>/exe` con el proceso vivo, y ya están **fuera de gioser**: en
1675`/work/rescate-binarios/` de la caja **y** en `rescate-binarios/` del Storage Box. Cinco de los trece
1676binarios que nadie provee dejaron de depender de que nadie reinicie nada.
1677
1678### 6.21 🧱 `sergio.gioser.net`: el muro no es el sitio, es **glibc**
1679
1680El sitio son tres piezas, y sólo una se puede mudar hoy:
1681
1682| pieza | qué es | mudable a takana |
1683|---|---|---|
1684| el frontend | **117 M** de estático (`dist/`) | ✅ copiado ya a `/work/www/sergioh` |
1685| `/shuma/*` | `shuma-gateway` en `:7378` — **ELF dinámico, y su binario estaba BORRADO** | ⚠ hay que construirlo desde tawasuyu (es Rust del monorepo) |
1686| `api.sergio` | **uvicorn** con un venv de **405 M** | 🧱 **no, hoy no** |
1687
1688El backend es `fastapi + uvicorn + pydantic + google-generativeai + **langchain**`, con extensiones
1689compiladas `…cpython-314-x86_64-linux-**gnu**.so` — o sea **glibc**. Reempaquetar ese árbol de PyPI
1690como recetas no es realista, y correrlo en musl no es posible. **Es el caso de uso canónico del
1691[ADR 0015](adr/0015-imagenes-ajenas.md) (`qorpa`: imágenes ajenas glibc enjauladas)**, que está
1692PROPUESTO y sin implementar del todo.
1693
1694⇒ **El DNS de `sergio` NO se movió**: mover el nombre sin el backend deja el sitio servido y la
1695consola rota. El frontend queda copiado y esperando; el corte se hace cuando exista el camino para
1696las otras dos piezas.
1697
1698**Esto es lo que decide la fecha de borrado de gioser**: no es «mudar dominios», es que **un
1699servicio vivo no tiene hoy camino a takana**. Las salidas son tres y hay que elegir: (1) `qorpa`, que
1700es para exactamente esto; (2) que ese servicio viva en otra máquina —`summa` ya lo hace, y su DNS lo
1701demuestra—; o (3) que muera. Ninguna es técnica: es una decisión.
1702
1703### 6.22 🧨 El `curl` de la distro NO PUEDE VALIDAR TLS — y por eso nada se baja por HTTPS
1704
1705Al ir a traer la primera imagen ajena (`qorpa pull`), la caja falló con `curl failed to verify the
1706legitimacy of the server`. El certificado del servidor estaba bien; lo que falta es de este lado:
1707
1708```
1709* CApath: /etc/ssl/certs
1710* SSL certificate ... unable to get local issuer certificate (20)
1711$ curl --cacert /etc/ssl/certs/ca-certificates.crt ...   → 200
1712```
1713
1714El `curl` del corpus está compilado **con `--with-ca-path=/etc/ssl/certs` y sin CAfile**, y ese
1715directorio contiene **un solo fichero**: el bundle. Un *capath* necesita **hash-links**
1716(`c_rehash`) — el índice — y no los hay. Los certificados están, el índice no.
1717
1718**Por qué faltan**: `recipes/ca-certificates.toml` deja escrito el hook
1719`/etc/ca-certificates/update.d/certhash`, que en Alpine ejecuta `apk` al instalar. **En takana no lo
1720ejecuta nadie**: la distro proyecta artefactos, no corre post-install. Es la misma familia que
1721[[subcomando-sin-driver]] — todo presente, y el paso que lo conecta no existe.
1722
1723**Alcance**: no es sólo `qorpa pull`. Es `takana install --repo https://…`, el mirror, y cualquier
1724`curl`/`wget` de un script en una caja instalada. **No falla al construir ni al instalar: falla la
1725primera vez que la máquina intenta bajar algo.** Nadie lo había visto porque el hub usa el `curl` de
1726Artix.
1727
1728**Arreglado en la receta**: los hash-links se generan **en el build** —donde `c_rehash` y perl ya
1729existen— y viajan dentro del artefacto, con una guarda que **falla ruidosamente si el rehash no
1730produce ninguno**. Re-sella `ca-certificates`, que es raíz de `perfil.base`.
1731
1732**Apaño mientras tanto**, para invocaciones puntuales: `CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`
1733(comprobado: 200).
1734
1735### 6.23 `qorpa` en la caja: preflight en verde, y el segundo muro es `tar`
1736
1737Con el backend de `sergio` sin camino a musl (§6.21), la salida elegida es **qorpa**. Estado medido
1738en la caja:
1739
1740| paso | resultado |
1741|---|---|
1742| userns anidado · overlayfs sin privilegios | ✅ ya estaban |
1743| **disco** | ❌→✅ `/var/lib/hammer` vive en la raíz (3 G) ⇒ `qorpa` **se movió a `/work`** (62 G) por symlink |
1744| `subuid`/`subgid` | ❌→✅ rango escrito para root |
1745| `newuidmap`/`newgidmap` sin capability | ❌→✅ `setcap` aplicado — **y el artefacto del store NO se tocó** (inodes distintos: en una caja instalada esos binarios son copias, no hardlinks) |
1746| veredicto del preflight | **de «BLOQUEA 1 · LIMITA 4» a «LIMITA 1»** |
1747| `qorpa pull` de Arch bootstrap | baja y **verifica el sha256**, y falla al desempacar |
1748
1749El segundo muro: el rootfs de Arch es `.tar.zst` y **el `tar` de la imagen es el de busybox**, que no
1750lo entiende — el mismo tropiezo que ya costó el lab (`tar: unrecognized option: zstd`). `zstd` SÍ
1751está instalado. ⇒ `qorpa pull` tiene que descomprimir con `zstd -dc | tar -x` cuando el archivo es
1752`.zst`, en vez de dárselo entero a `tar`. Es un arreglo pequeño en el CLI, y hasta entonces la
1753alternativa es una imagen `.tar.gz` (Ubuntu base) — que además obliga a reinstalar las deps del venv,
1754porque el de gioser trae extensiones de **CPython 3.14** y Ubuntu 24.04 lleva 3.12.
1755
1756### 6.24 El TLS arreglado, la distro con nombre, y **por qué `upgrade` se quedaba sin espacio**
1757
1758**El `curl` ya valida.** Con `ca-certificates` re-sellado —**119 hash-links en el capath**, generados
1759en el build— la caja baja por HTTPS **sin ningún apaño**: `curl -I https://…` → **200**. El arreglo
1760tardó tres intentos y los dos primeros los cazó la guarda que puse en la propia receta: primero
1761`c_rehash` no estaba en el `PATH` (lo instala esa misma receta en `/out/usr/bin`), y después los
1762certificados **no estaban sueltos** en `usr/share/ca-certificates/` sino en su subdirectorio
1763`mozilla/`, así que `c_rehash` sólo veía el bundle y lo saltaba —correctamente— con «does not
1764contain exactly one certificate». Sin la guarda, las tres veces habría sellado un capath vacío.
1765
1766**`fastfetch` (2.68.1) corre en la caja** — y al correrlo apareció otra ausencia:
1767
1768```
1769OS: takana x86_64          ← ahora
1770Host: vServer (20171111)
1771Kernel: Linux 7.1.2 · CPU: AMD EPYC-Rome (4) · Memory: 393 MiB / 7.58 GiB
1772```
1773
1774…porque **`/etc/os-release` NO EXISTÍA**. La distro era anónima para cualquier programa que no fuera
1775suyo. Se resolvió con una receta propia (`os-release`, `source.dir`) en vez de una constante en
1776`takana-bootstrap`: meterlo ahí re-sellaría el `product-rootfs` —el baseline del selfhost— por cinco
1777líneas. Sin `VERSION_ID` ni fecha a propósito: un sello con la fecha del build cambiaría el hash del
1778artefacto, y con él la clausura de toda imagen que lo lleve, cada día y sin motivo.
1779
1780#### 🧨 `/var/lib/hammer` es una partición de 487 MB — y ahí viven las generaciones
1781
1782`upgrade apply` falló dos veces con `No space left on device` teniendo **3 G libres en la raíz**. Ni
1783el espacio de `/` ni los inodos (9% usados): el estado de generaciones vive en
1784**`/var/lib/hammer`, que es `sda3` y mide 487 MB** (la partición «estado» del layout). Cada
1785generación guarda una copia del árbol aplicado — el nuestro son **272 MB** — así que **dos no caben**.
1786
1787⇒ El estado se movió a `/work` por symlink, igual que `qorpa`. Tras eso: `✓ generación 6 aplicada`.
1788**El layout de la imagen necesita revisión**: 512 MB de «estado» no alcanzan para un mecanismo que
1789guarda un árbol entero por generación, y el síntoma (`ENOSPC` con la raíz medio vacía) apunta al
1790sitio equivocado.
1791
1792### 6.25 ✅ CUTOVER DE `api.sergio.gioser.net` — el primer servicio AJENO que sirve takana *(2026-09-15)*
1793
1794**`https://api.sergio.gioser.net` responde 200 con certificado público válido desde `2.29.29.217`**,
1795y su `/api/chat/` contesta con Gemini de verdad. El backend corre **dentro de una jaula `qorpa`**
1796(rootfs de Arch pineado por sha256), supervisado por `arje-zero` como el ente `sergioh-api`.
1797
1798Es el servicio que §6.21 declaró sin camino a musl —su venv trae extensiones
1799`cpython-314-x86_64-linux-**gnu**.so`— y por eso «lo que decide la fecha de borrado de gioser». El
1800camino elegido era `qorpa` (ADR 0015) y ahora está recorrido de punta a punta.
1801
1802**La coincidencia que lo hizo barato:** el Arch bootstrap pineado trae **python 3.14.7** y el venv de
1803gioser es **3.14.6**. Misma serie ⇒ las extensiones compiladas no son un problema: el venv se rehace
1804adentro con `pip`, no se copia.
1805
1806#### 🧨 EL MURO: la jaula no viajaba en NINGUNA imagen
1807
1808`takana qorpa provision` abortó con un mensaje correcto y una instrucción imposible:
1809
1810    Error: no encuentro harkaq-exec en /root/.cache/harkaq/harkaq-exec
1811      construilo:  gcc -O1 -Wall -static -o … scripts/harkaq/harkaq-exec.c
1812
1813**En una caja instalada no hay `gcc`, ni `scripts/`, ni árbol de desarrollo.** El binario sólo existía
1814como un `gcc` a mano en la caché de `$HOME` del hub — o sea que la jaula del ADR 0015 funcionaba
1815**únicamente en la máquina donde alguien la había compilado**. Y su compañero estaba igual, medido en
1816`build-state.json`: **`bwrap` sellado desde hace meses con `"perfiles": []`**, CERO perfiles. Los dos
1817se invocan por PATH, y no sólo desde `qorpa`: `takana-build/src/sandbox.rs` llama a `bwrap` en cada
1818build. ⇒ **ninguna imagen de takana podía enjaular nada, ni construir**. Es
1819[[subcomando-sin-driver]] un piso más abajo: el CLI que los llama viaja en todas las imágenes y sus
1820herramientas en ninguna.
1821
1822Arreglado en la misma unidad de trabajo: `recipes/harkaq-exec.toml` (nueva, estática musl,
1823`b3:cd34954f…`, 269 K), `bwrap` + `harkaq-exec` declarados en **`perfil.base`**, y `qorpa.rs`
1824buscando el binario también en `/usr/bin` —el orden es `HARKAQ_BIN` → árbol de desarrollo → paquete,
1825para que un cambio en la jaula se pruebe sin instalar nada—. ⚠ El pin de la receta va **al commit que
1826tocó la fuente** (`1d9ddcee`, del 2026-09-03) y no a HEAD: el repo commitea cada media hora por el
1827cron de la cosecha, y pinear HEAD re-hashearía la receta cada media hora sin que su fuente se moviera.
1828
1829#### Los tres tropiezos del camino, todos de la misma familia
1830
18311. **`chown`**: el `rsync` preservó el uid 1001 de gioser, y dentro del userns ese uid queda FUERA del
1832   rango mapeado ⇒ ni siendo root adentro se puede escribir. `python -m venv` fallaba con
1833   `Permission denied` en el directorio concedido mientras andaba perfecto en `/tmp`. El árbol del
1834   sitio va a `root:root` del anfitrión, que es lo que el mapa de la jaula sí alcanza.
18352. **Una instancia, un `run`**: el segundo `qorpa run` sobre la misma instancia es rechazado por el
1836   kernel («dos overlays con el mismo `upper` corromperían la capa mutable»). Correcto y hay que
1837   saberlo: con el servicio arriba **no se entra a la instancia a hacer mantenimiento**.
18383. **`ps` de la caja no ve los procesos y `dig` no existe en el hub**. Dos herramientas ausentes que
1839   se leen como diagnóstico: el primero dice «no hay nada corriendo» y el segundo dejó un `until` de
1840   espera de DNS girando cinco minutos contra una condición que **nunca podía cumplirse**. Un
1841   guardián que no puede pasar nunca se ve igual que uno que todavía no pasó.
1842
1843#### Lo que quedó declarado, y lo que no
1844
1845- `instance.toml` es la verdad: `packages = ["python","python-pip"]`, `network = true` (pacman, pip y
1846  la propia API de Gemini la necesitan) y **un solo dir concedido** (`/work/sergioh` → `/srv/sergioh`,
1847  rw). Ni sockets, ni dispositivos, ni nada más.
1848- El código y el venv viven **fuera** de la instancia, en el anfitrión: el `upper` es caché (D3) y se
1849  tira. Y las 84 dependencias quedaron pineadas en `requirements.lock` — el `requirements.txt` del
1850  sitio no pinea nada, así que sin el lock un `recreate` traería otras versiones.
1851- La tarjeta `sergioh-api` va en `/etc/arje/cards.d/` **y** en el `genesis` de `/ente/seed.card.json`
1852  (son dos preguntas distintas: qué arranca solo y qué se puede encarnar a pedido, §6.12), con dos
1853  guardas que salen 78 nombrando el arreglo si falta la instancia o el venv.
1854- ⚠ **Todavía invoca con `HARKAQ_BIN=/usr/bin`** en la tarjeta: el `takana` de la caja es el del
1855  `commit` pineado en `recipes/takana.toml` y no trae aún la búsqueda en `/usr/bin`. Sale solo cuando
1856  se suba ese pin.
1857
1858#### El estado de la mudanza tras esto
1859
1860| | |
1861|---|---|
1862| **mudado hoy** | `api.sergio.gioser.net` (DNS: CNAME→`www` **borrado**, A→`2.29.29.217` TTL 60) |
1863| **pendiente inmediato** | apagar el `sergioh_backend` de gioser **cuando propague** (OpenRC *y* arje: `rc-update del` + `rc-service stop` + `arjectl stop openrc-…`, §6.17). Hasta entonces sirve a los resolutores que aún tengan el CNAME cacheado |
1864| **lo que falta de `sergio.gioser.net`** | el frontend ya está copiado en `/work/www/sergioh`; falta `/shuma/*` → `shuma-gateway` (:7378), que es glibc dinámico y **cabe en esta misma jaula** (el binario está rescatado en `/work/rescate-binarios/`) pero cuelga de `shuma-daemon`, que es un sistema entero, no un binario |
1865| **y los otros dos vivos** | `gioser.net`/`www` (estáticos + `/hooks/*` en :8770 + `/reencuentro` con php-fpm) y `tawasuyu.net` (estático, raíz en `/mnt/vvv/tawasuyu`) |
1866
1867### 6.26 ✅ `tawasuyu.net` y `gioser.net` MUDADOS — y el PHP que no existe en el corpus *(2026-09-15)*
1868
1869**Los dos sitios que quedaban con tráfico real sirven desde la caja `takana`**, con TLS público y
1870verificados desde fuera. Los servicios viejos **siguen corriendo en gioser a propósito** (decisión del
1871usuario: mudar el DNS y dejar el origen vivo, para poder volver en un minuto).
1872
1873| dominio | qué es | estado |
1874|---|---|---|
1875| `tawasuyu.net`, `www.` | landing + `/pkg` wasm + `/descargas` (3,8 G) | **200 desde 2.29.29.217** |
1876| `gioser.net`, `www.` | 5 raíces estáticas (428 M) + `/reencuentro` con PHP | **200 desde 2.29.29.217** |
1877| `sergio.gioser.net` | frontend + `/shuma/*` | **se queda en gioser**: su consola no tiene camino todavía |
1878
1879**`sergio` dejó de ser un `CNAME → www` y tiene su A propio** (apuntando a gioser, sin cambio
1880visible). Sin eso, mover `www` se lo habría llevado puesto — es la trampa del §6.14 vista a tiempo:
1881en esta zona **casi todo cuelga de `www`**. Lo que sí quedó colgando son tres fósiles (`api`,
1882`mail.sigma`, `monitor`): antes daban 502 desde gioser y ahora fallan el TLS contra la caja. Están
1883muertos hace meses; se anotan, no se mudan.
1884
1885**Dos cosas que el log de accesos decidió mejor que cualquier intuición:**
1886- La raíz de `tawasuyu.net` en gioser era **el monorepo entero (145 G) servido por HTTP**. El log
1887  dice que las únicas rutas con tráfico son `/`, `/descargas` y `/web` ⇒ se replicaron **sólo** los
1888  dos subárboles que el sitio usa (1,2 M + 3,8 G), con la MISMA estructura para que cada `rewrite`
1889  siga siendo el mismo.
1890- **`/hooks/*` NO se muda**: lo atiende `webhook-deploy.py`, que redespliega `aura_*`, `sigma_*`,
1891  `summa_*` y `brahman` — **los cuatro son fósiles del §6.19**, y tiene cero peticiones. Muere con la
1892  caja vieja. Un servicio vivo que sólo sirve a muertos es basura, no deuda.
1893
1894#### PHP: la segunda jaula, y por qué no entra al corpus
1895
1896`/reencuentro/` es una página con **un** `guardar.php`. PHP no está en el corpus y no tiene por qué
1897estar: lo sirve **`php-fpm 8.5.10` dentro de la instancia `gioser-php`** (ADR 0015), por
1898`127.0.0.1:9000`, supervisado por arje. Control contra el original, en los dos sentidos: `POST` con la
1899trampa anti-robots → `{"ok":true}` 200 igual que en gioser; `GET` → **405** igual que en gioser.
1900
1901⚠ **El webroot entra a la jaula EN LA MISMA RUTA** (`/work/www/gioser-web`): el `SCRIPT_FILENAME` de
1902FastCGI es una ruta absoluta del anfitrión, y si adentro viviera en otro sitio php-fpm contesta
1903«File not found» sin decir cuál. Y la config de php-fpm vive en el ANFITRIÓN
1904(`/work/etc/gioser-php/php-fpm.d/`) y entra por concesión: el `upper` de la instancia es caché y
1905`recreate` se la llevaría.
1906
1907#### 🧨 Los padres que inventa bwrap son **0700** — y eso deja la concesión fuera de alcance
1908
1909El muro real del día, y es general, no de PHP:
1910
1911    $ curl -X POST https://gioser.net/reencuentro/guardar.php
1912    File not found.          ← y el fichero ESTÁ, montado, con sus modos correctos
1913
1914Para montar en `/work/www/gioser-web`, bwrap tiene que crear `/work` y `/work/www` —que la imagen de
1915Arch no trae— y **los crea `drwx------ root`**. Adentro somos root, así que todo a mano funciona; pero
1916**un servicio que baja de privilegio** (php-fpm a `http`, o cualquier instancia con `run_as`) no puede
1917ni atravesarlos. Medido con el control que lo separa de una confusión de permisos del anfitrión: como
1918`http`, leer un fichero **de la imagen** funciona y leer el **directorio concedido** da
1919`Permission denied`.
1920
1921⇒ `grants_to_args` crea ahora los ancestros que faltan con `--perms 0755 --dir`, y **sólo los que la
1922imagen no trae**: hacerlo sobre `/etc` o `/home` le cambiaría los modos a la imagen. Con test que
1923falla a propósito si la condición se rompe (`left: ["/etc","/etc/php"]` contra `right: ["/etc/php"]`).
1924Misma familia que el `--perms 1777` que ya hacía falta antes del `--tmpfs /tmp`.
1925
1926⚠ La caja corre el `takana` del `commit` pineado en `recipes/takana.toml`, así que **allá el arreglo
1927todavía no está**: el camino se abrió a mano en el `upper` de la instancia (`chmod 755 /work
1928/work/www`). Es exactamente lo que el arreglo vuelve innecesario en el próximo `takana upgrade`.
1929
1930#### Y el estado de `rust` y de `claude`, que no es el que parece
1931
1932- **`rust` está en la caja pero no instalado**: `rust-toolchain-bin` está **sellado en el store** y
1933  declarado en `perfil.servidor`, y `rustc 1.97.0` corre invocándolo por su ruta del store — pero
1934  **no está en el `PATH`** porque la caja se instaló antes de esa declaración. Proyectarlo son 798 M
1935  sobre una raíz de **5,8 G con 2,9 G libres**: entra, pero el layout de la imagen (§6.24) merece la
1936  revisión primero.
1937- **`claude` no está mudado**, y es otro caso de jaula: el binario de gioser es **glibc dinámico**
1938  (`/lib64/ld-linux-x86-64.so.2`), o sea el mismo montón que el backend de `sergio`. Lo que pesa no
1939  es el binario sino su estado: **6,8 G de `~/.claude`**, de los cuales 5,3 G son `jobs` y 976 M
1940  `projects` — y ahí vive la memoria, **indexada POR RUTA del repo**. Mudarlo es una decisión sobre
1941  qué se lleva, no una copia.
1942
1943### 6.27 🧦 `squid` mudado — y la jaula que arrancaba DEGRADADA cuando la arranca un init *(2026-09-15)*
1944
1945**El proxy de salida sirve desde la caja**: `squid 7.7` en `2.29.29.217:1137`, con la config, el
1946`passwd` de los tres usuarios y las dos ACL de gioser tal cual, dentro de la instancia `squid` y
1947supervisado por arje. **El de gioser sigue vivo e intacto** (7.6), así que los clientes que apuntan
1948a la IP vieja no se enteraron.
1949
1950Es un servicio con gente encima: el `access.log` del origen mostraba `CONNECT api.anthropic.com` y
1951`CONNECT claude.ai` **en el minuto anterior a mudarlo**, desde IPs de Venezuela y con tres usuarios
1952autenticados. Por eso se mudó entero y sin reescribir nada.
1953
1954Control en los dos sentidos: desde una IP no autorizada, **407 Proxy Authentication Required**
1955—igual que el original—; desde `localhost`, que su propia config permite, **TCP_TUNNEL/200 a
1956claude.ai y a api.anthropic.com**.
1957
1958#### Los cuatro muros, y el cuarto es el que vale
1959
19601. **`provision` con la config ya montada aborta**: pacman dice «squid.conf.default exists in
1961   filesystem» porque el paquete quiere escribir sus defaults donde está la concesión. ⇒ provisionar
1962   **primero**, montar **después**. Queda escrito en el manifiesto.
19632. **`/dev/shm` lo crea bwrap 0755 root**, y squid —que baja a `proxy`— muere con
1964   `FATAL: shm_open(...): (13) Permission denied`. Un `chmod 1777` en el arranque.
19653. **El fichero de PID sobrevive entre corridas y SIEMPRE parece fresco**: cada `run` tiene su propio
1966   namespace de PIDs, así que el `/run/squid.pid` de la corrida anterior dice `PID 2`… y en la nueva
1967   hay un PID 2 vivo. Squid concluye «Squid is already running» y se niega. ⇒ `rm -f` en el arranque.
1968   Es una trampa general de cualquier servicio con pidfile dentro de una jaula con `--unshare-pid`.
19694. 🧨 **El rango de subuid se buscaba por `$USER` — y un init no lo pone.** Bajo arje la tarjeta
1970   arranca con `PATH` y `HOME` y poco más; el nombre salía **vacío**, no había rango, y la jaula caía
1971   al **userns de un solo id**. Squid moría en `setgid(15): (22) Invalid argument` y el supervisor lo
1972   reintentaba 80 veces. El aviso de qorpa era correcto y estaba impreso —«no hay rango para `""` en
1973   /etc/subuid»— pero **lo que se ve en el bucle es el error de squid, no el nuestro: la degradación
1974   silenciosa la paga el de más abajo**. Y el mismo comando a mano, desde una shell con `$USER`,
1975   funcionaba perfecto: *el síntoma dependía de quién lo arrancaba*.
1976   ⇒ `nombre_de_usuario()` deriva el nombre del **uid real** leyendo `/etc/passwd`, y el entorno pasa
1977   a ser el último recurso. El uid es el hecho; `$USER` es una etiqueta ([[la-etiqueta-no-es-el-hecho]]).
1978   Las tarjetas llevan además `USER`/`LOGNAME` explícitos, que era el arreglo del día.
1979
1980⚠ **`arjectl start` sobre un Ente ya vivo lo DUPLICA** (se vio con `gioser-php`: dos entes, uno
1981peleando por el mismo `upper`). Y matar el proceso de adentro **no libera la instancia**: el
1982`takana qorpa run` de afuera sigue vivo y el siguiente `run` choca. Para relanzar, `stop` y después
1983`start`, mirando `status` en el medio.
1984
1985#### Y la pregunta que corresponde: ¿no va en una receta?
1986
1987**Sí, y sigue pendiente.** El propio §6.2 pone a `squid` en la lista de recetas que esta mudanza
1988tiene que producir. Lo de hoy fue la vía URGENTE —el servicio estaba en uso y la jaula lo levanta en
1989minutos, sin compilar nada—, no la respuesta final. La diferencia con `php-fpm` importa:
1990**PHP no queremos que entre al corpus** (es un intérprete ajeno al proyecto, y la jaula es su lugar
1991definitivo), mientras que **`squid` es C, se compila con el lab y su sitio natural es una receta**
1992como la de `gitea`. Cuando esté, la instancia se tira: el manifiesto es descartable a propósito.
1993
1994### 6.28 📦 `squid` YA NO ESTÁ ENJAULADO: corre desde el corpus *(2026-09-15)*
1995
1996La jaula del §6.27 duró unas horas, que es lo que tenía que durar. **Hoy el proxy de producción es
1997el artefacto sellado `b3:932ba096…`**, proyectado en la caja y arrancado por arje sin bwrap de por
1998medio: `pid 11548 · uid=968 · exe=/usr/sbin/squid`. La instancia `qorpa/squid` se borró — el
1999manifiesto es descartable por diseño (D3) y esto es exactamente para lo que existía esa salida.
2000
2001`recipes/squid.toml`: 7.7, estático de verdad (binario **y** los cuatro helpers), `[[user]] proxy`
2002con uid 968 y `[[service]]` del SDD 30. Declarado en `perfil.servidor` como paquete y en `servicios`.
2003
2004**La config NO viaja en el paquete y es a propósito**: `squid.conf`, el `passwd` de los tres usuarios
2005y las dos ACL (412 K, 40 IPs autorizadas) son datos del SITIO. El servicio los comprueba al arrancar
2006y sale **78 nombrando cuál falta**, en vez de dejar un bucle de reinicios.
2007
2008#### Los tres muros de la receta, en orden
2009
20101. **La URL de upstream devolvía una página HTML de 8985 bytes con un 200.**
2011   `squid-cache.org/Versions/v7/squid-7.7.tar.xz` no es el tarball. Pinear su sha256 habría anclado
2012   la receta a una página de error — y habría «funcionado» hasta el primer build. Va el release de
2013   GitHub, que además trae `configure` generado.
20142. 🧨 **`--export-dynamic` desactivaba el enlace estático, y el binario sellaba igual.** El primer
2015   artefacto tenía `usr/sbin/squid` **dinámico con `NEEDED libc.so`** mientras sus helpers salían
2016   estáticos: inerte en cualquier imagen sin cargador ([[needed-colgante-libstdcxx]]) pese a
2017   `link = "static"`. La cadena tiene tres eslabones y ninguno es obvio: `squid_LDFLAGS` trae
2018   `-export-dynamic` (para módulos eCAP, apagados) → **el wrapper del lab quita `-static` de
2019   cualquier enlace que mencione `--export-dynamic`**, a propósito, para poder enlazar las `.so`
2020   dlopen-ables de Python → y **libtool vuelve a añadirlo solo** en cuanto hay un `-dlopen`, así que
2021   sacarlo de la variable no alcanza. Se vacía `export_dynamic_flag_spec` en el `libtool` GENERADO,
2022   con guarda que aborta si el campo no aparece: *un `sed` que no acierta deja pasar el problema y
2023   el binario sale dinámico sin que nada falle*.
2024   ⚠ Y `-dlopen force` **no sobra**: vaciar `squid_LDFLAGS` entero rompe el enlace con
2025   `undefined symbol: lt__PROGRAM__LTX_preloaded_symbols`, que emite el propio libtool porque hay un
2026   `-dlopen`. De los dos flags estorba uno; distinguirlos costó un build y adivinar cuesta lo mismo.
20273. **`--disable-log-daemon-helpers` compilaba perfecto y no arrancaba.** El default de `access_log`
2028   es `daemon:`, así que sin `log_file_daemon` squid muere con
2029   `FATAL: logfile_daemon /usr/lib/squid/log_file_daemon: (2) No such file or directory`. **Sólo se
2030   ve levantando el servicio con la config real; el sellado, el hash y el `-k parse` pasan los tres.**
2031   Es la familia de [[subcomando-sin-driver]] otra vez.
2032
2033#### El control que hay que correr antes de tocar producción
2034
2035Con la config REAL del origen, en el hub y en un puerto aparte: `-k parse` sin FATAL, arranque
2036completo, **`CONNECT claude.ai` y `CONNECT api.anthropic.com` con TCP_TUNNEL/200**, el `access.log`
2037escrito por el log daemon, y el `basic_ncsa_auth` leyendo el `passwd` de verdad (control negativo:
2038con una clave mala contesta `ERR Wrong password`). Los dos primeros intentos de esta receta habrían
2039llegado a producción sin ese control: el binario estaba sellado y el hash era estable las dos veces.
2040
2041### 6.29 ⏱️ La caja tiene HORA, LATIDO y ROTACIÓN — y el respaldo del gitea dejó de ser a mano *(2026-09-15)*
2042
2043`planear.py --revisar` sobre el censo del día ordena lo que queda, y lo primero de la lista no eran
2044los servicios del usuario sino **tres capacidades que ninguna métrica reclama**: el disparador
2045periódico, la hora y la rotación de logs. Los tres paquetes ya estaban sellados y declarados en
2046`perfil.servidor` desde que ese perfil nació — **lo que faltaba era el eslabón que los ARRANCA**.
2047
2048| | antes | ahora |
2049|---|---|---|
2050| `chronyd` | la caja iba **15 s atrasada** y nadie la corregía | stratum 3 contra `ntp{1,2,3}.hetzner.de`, **0,000005 s** de NTP |
2051| `crond` | no existía el latido | ejecuta el crontab real, verificado con una entrada `* * * * *` |
2052| `logrotate` | `/var/log/squid` sin rotar | diario con `squid -k rotate`, 14 copias |
2053| respaldo del gitea | **a mano, una vez** (§6.15) | `17 3 * * *`, con el snapshot consistente de la sqlite |
2054
2055**El respaldo, medido:** `scripts/respaldo-gitea.sh` sube **44 repos (23 `sergio` + 21 `tawasuyu`,
20561,5 G)** y un `gitea.db` de **332 M** tomado con `.backup` —consistente con el servidor vivo— al
2057Storage Box. El control no es que el script salga 0: es **contar los repos de los dos lados**, que
2058es lo que se hizo.
2059
2060⚠ **`chrony` y `cronie` NO declaraban `[[service]]`**, como `squid` antes: el paquete llegaba a la
2061imagen y no lo arrancaba nadie. Ahora lo declaran (fuera de `hash_inputs`: los hashes no se movieron)
2062y `perfil.servidor` los habilita en `servicios`.
2063
2064⚠ **La config va aparte y el servicio sale 78 si falta**, igual que gitea: a qué NTP se pregunta y
2065qué logs se guardan son decisiones del SITIO. Una caja que se sincroniza contra un pool que nadie
2066eligió es peor que una sin hora, porque nadie lo mira.
2067
2068#### Tres cosas medidas que valen más que el resultado
2069
20701. **La deriva era de gioser, no de la caja.** Antes de chrony la caja iba 15 s atrás; después, la
2071   caja quedó en hora de NTP y **el hub pasó a estar 3 s adelantado**. La máquina de referencia era
2072   la equivocada — medir «contra el otro» sin un tercero no dice quién está mal.
20732. **El spool de cronie es `/var/spool/cron/<usuario>`, no `…/crontabs/<usuario>`.** `crontab -l`
2074   mostraba las entradas y `crontabs/` estaba vacío. El día que alguien restaure un crontab a mano
2075   en el sitio equivocado va a tener un fichero perfecto que nadie lee. Lo decide un control y no la
2076   intuición: meter un `* * * * *` en el crontab REAL y ver si dispara.
20773. 🧨 **`sqlite3` seguía inerte en la caja** (`Error relocating: compressBound: symbol not found`):
2078   es el último de los 23 binarios del §6.4, y le faltaba `zlib-shared` — **sellado y declarado en
2079   `perfil.base` desde el 2026-09-11**, pero la caja se instaló antes. Sin él no había snapshot
2080   consistente, o sea que el respaldo habría copiado la sqlite a lo bruto. *Un paquete declarado no
2081   es un paquete instalado: en una caja viva, lo que vale es lo que el `upgrade` proyectó.*
2082
2083### 6.30 ⚰️ Tres servicios que MUEREN — y el cortafuegos que no puede correr todavía *(2026-09-15)*
2084
2085Decisión del usuario sobre lo que quedaba del censo, y las tres son «no se muda»:
2086
2087| servicio | por qué muere |
2088|---|---|
2089| **`qdrant`** (`:6333`, `:6334`) | es la base vectorial de `gioser_api`, y **ese backend es un fósil**: `api.gioser.net` da 502 permanente desde el §6.9. Una base sin consumidor no se muda. La receta `recipes/qdrant.toml` queda en el catálogo —**catálogo ≠ imagen**, y eso no es deuda—, pero no entra en ningún perfil. |
2090| **`act_runner`** (`:41027`) | el runner de CI del gitea, un binario suelto en `~/.local/bin` que nadie provee. Muere con la caja; si mañana hace falta CI, entra por receta y no por un binario copiado. |
2091| **`fail2ban-server`** | **tawasuyu ya tiene el sustituto**, y es mejor: `shared/cortafuegos` (§1 de su SDD-ENTRADA) no mira logs con un daemon, usa **dos sets dinámicos del kernel** (`ban4`/`ban6`, `flags dynamic`, `timeout`) con `limit rate over N/minute` **por IP de origen**. El ban lo aplica nftables sin proceso y sin latencia; fail2ban es un daemon de Python leyendo ficheros para hacer lo mismo peor. |
2092
2093#### 🧨 Pero el sustituto NO PUEDE CORRER: el kernel de la caja no trae `nf_tables`
2094
2095    $ nft list ruleset
2096    netlink: Error: cache initialization failed: Invalid argument
2097
2098`nft 1.1.6` **está instalado** (`nftables` es raíz de `perfil.servidor` y está sellado). Lo que falta
2099está un piso más abajo, en el `.config` que el artefacto publica:
2100
2101    CONFIG_NETFILTER=y          ← sí
2102    CONFIG_NF_CONNTRACK=y       ← sí
2103    # CONFIG_NF_TABLES is not set   ← AQUÍ
2104    # CONFIG_NETFILTER_ADVANCED is not set   ← y ésta es la causa: NF_TABLES cuelga de ella
2105
2106Es otra vez la lección del §6.4 muro 1: **la receta dice lo que se CAMBIÓ; sólo el `.config` sellado
2107dice lo que QUEDÓ**. `linux-generic` sale de un `make defconfig`, y defconfig deja `NETFILTER_ADVANCED`
2108apagado, que arrastra a `NF_TABLES` con él.
2109
2110⇒ **Hoy la caja no tiene cortafuegos de ninguna clase**: ni fail2ban (que no se muda), ni el
2111sustituto (que no arranca), ni una regla. Con `:22022`, `:2345`, `:1137`, `:80` y `:443` públicos,
2112eso hay que decirlo entero y no dejarlo implícito. La cura es un kernel con
2113`CONFIG_NETFILTER_ADVANCED=y` + `NF_TABLES` (y `NFT_CT`, `NFT_LIMIT`, `NFT_COUNTER`, `NFT_LOG`),
2114que es trabajo del SDD 22 y termina en un **reinicio de la caja de producción**.
2115
2116#### La mitigación que NO depende del kernel, hecha hoy
2117
2118El `sshd` de la caja ofrecía `password` y `keyboard-interactive` — su config eran **cuatro líneas**
2119y ninguna decía nada de autenticación, así que regía el default de OpenSSH. Sin cortafuegos y sin
2120fail2ban, eso es exactamente la puerta que aquéllos cerraban. Ahora es **sólo claves**, con los
2121cuatro controles corridos: la clave entra ✅, `:22022` contesta `Permission denied (publickey)` sin
2122ofrecer contraseña ✅, `:2345` igual ✅, y el `git ls-remote` por SSH sigue funcionando ✅.
2123
2124**Lo que enseña:** *una defensa que se muda tiene que mudarse con su capacidad, no con su nombre*.
2125Decidir «fail2ban muere porque tenemos algo mejor» es correcto **y** deja un hueco abierto hasta que
2126ese algo mejor pueda ejecutarse. Entre la decisión y la capacidad hay un kernel de por medio.
2127
2128### 6.31 ✅ La caja arrancó con `nf_tables` — y el init la levantó ENTERA sola *(2026-09-15)*
2129
2130    $ nft list ruleset
2131    $                       ← sin error: vacío, que es lo correcto en una caja sin reglas
2132
2133El kernel `b3:63fdd57c…` bootea, y con él **`nft` funciona por primera vez en una máquina takana**.
2134Comprobado con el mecanismo exacto que reemplaza a fail2ban, no con una regla de adorno: un
2135`set ... flags dynamic; timeout 10s` con `add @ban4 { ip saddr timeout 10s limit rate over 5/minute }
2136drop` — aceptado por el kernel (`rc=0`) y releído tal cual. Eso es el ban por IP **dentro del
2137kernel**, sin daemon.
2138
2139#### Lo que el reinicio probó de paso, y vale más que el kernel
2140
2141**La Semilla levantó los OCHO entes sola, todos con `↻ 0`:** `sshd`, `gitea`, `caddy`, `squid`,
2142`crond`, `chronyd`, `sergioh-api` y `gioser-php` — o sea los dos servicios enjaulados en `qorpa`
2143(con su `newuidmap`, su overlay y su `harkaq`) **arrancan desde el genesis igual que un binario
2144nativo**. Todo lo que se fue editando en `/ente/seed.card.json` a lo largo del día era, hasta este
2145momento, una declaración sin probar: un reinicio es el único examen que toma.
2146
2147Desde fuera, al minuto: los siete dominios en **200**, el proxy en **407** y `git ls-remote` por
2148SSH:2345 devolviendo el commit. Cero intervención.
2149
2150#### ⚠ `reboot` NO reinicia una caja con arje-zero
2151
2152`reboot` de busybox le pide a PID 1 que lo haga, y **arje-zero no implementa ese protocolo**: el
2153comando vuelve, no dice nada, y la máquina sigue arriba (`up 4 days` después de «reiniciar»). Lo que
2154sí funciona es **`reboot -f`**, que llama a `reboot(2)` directo saltándose al init. Con `sync` antes,
2155claro: el corte es duro por definición.
2156
2157⇒ **Y falta la pieza**: un init que gobierna un servidor de producción tiene que atender un apagado
2158ordenado. Hoy la única vía es el corte duro, que ya costó el `write_atomic` no durable del §6.13.
2159
2160#### 🧨 Casi reinicio el HUB por un backtick en un `echo`
2161
2162Escribiendo el aviso del reinicio, el mensaje decía ``… no atiende el `reboot` normal …`` dentro de
2163comillas DOBLES. La shell ejecutó lo que había entre backticks: **`reboot`, en gioser**. No pasó nada
2164por una razón que no es mérito de nadie —la sesión no es root y OpenRC contestó «you must be root»—
2165y el hub lleva 11 días arriba, con la granja y el cron encima.
2166
2167Es la **tercera vez en el día** que un backtick dentro de un texto se ejecuta
2168([[heredoc-sin-comillas-ejecuta-comentarios]]), y las dos anteriores sólo se comieron un comentario.
2169La regla deja de ser de estilo: **nunca un backtick dentro de comillas dobles en una shell** —
2170comillas simples, o `$(printf '%s' …)`, o directamente no poner el acento grave en el texto.
2171
2172#### Lo que queda para cerrar el frente del cortafuegos
2173
2174El kernel ya no estorba; falta **la política**: `PoliticaEntrada` (.ron) → `generate-input` →
2175`apply-input` del cortafuegos de tawasuyu, con los puertos reales de la caja (`22022`, `2345`, `80`,
2176`443`, `1137`). ⚠ Y hay que aplicarla con **interruptor de hombre muerto** (aplicar, esperar, y
2177`flush` automático si nadie confirma): una regla mal puesta en una caja **sin consola serie** deja
2178la única salida en `hcloud server enable-rescue`.
2179
2180### 6.32 🧱 El cortafuegos, puesto — y el primer baneado fui yo *(2026-09-15)*
2181
2182La caja filtra: `table inet tawasuyu_input`, **`policy drop`**, 15 reglas de puerto, y el set `ban4`
2183llenándose solo con lo que golpea desde internet. A los pocos minutos: **27 IPs baneadas y paquetes
2184tirados por el contador**. Eso es el reemplazo de fail2ban funcionando sobre tráfico real, dentro del
2185kernel y sin daemon.
2186
2187La política vive en `scripts/servidor/cortafuegos-entrada.ron` (formato `PoliticaEntrada` del
2188cortafuegos de tawasuyu) y declara los cinco puertos públicos: `22022` admin, `2345` git, `80`, `443`
2189y `1137` el proxy. El reglaset se genera con `cortafuegos generate-input` **en el hub** —el binario es
2190glibc y la caja es musl— y se aplica allá con `nft -f`.
2191
2192#### 🧨 El primer baneado fui yo, en segundos
2193
2194Aplicada la primera versión, desde fuera: `ssh` ✅, `https` ✅… y **`http`, el proxy y el git por SSH
2195muertos**. Al minuto, la caja entera dejó de contestarme.
2196
2197La causa no es una regla mal escrita: **el set `@ban4` es UNO SOLO para todos los servicios**, y la
2198regla `ip saddr @ban4 drop` está antes que todo. O sea que pasarse de tasa en *un* puerto te tira en
2199*todos*. Y el `nuevas_por_min: 5` del puerto de administración —correcto contra internet— es
2200exactamente lo que **me** pasa a mí: cada comando remoto de esta sesión es una conexión nueva.
2201
2202Lo que devolvió la máquina fue el **interruptor de hombre muerto**: antes de aplicar se deja
2203programado un `nft flush ruleset` a 180 s, y sólo se desarma si la verificación desde fuera sale
2204bien. Sin consola serie, esa es la diferencia entre un susto y un rescue.
2205
2206⚠ **Y el tipo ya lo avisaba.** `PoliticaEntrada` tiene un campo `confiables` cuyo comentario dice,
2207palabra por palabra, lo que me acababa de pasar: *«un `nuevas_por_min` chico en el puerto de SSH es
2208exactamente lo que se quiere contra internet y exactamente lo que te deja afuera cuando el que abre
2209seis sesiones en un minuto sos vos»*. Copié el `.ron` de ejemplo en vez de leer el tipo. Es
2210[[lei-hasta-donde-me-daba-la-razon]] otra vez, y la cura fue una línea:
2211`confiables: ["204.168.193.248", "154.197.1.13"]` — el hub y el worker, que se aceptan **antes** de
2212la lógica de tasa y por lo tanto no pueden entrar al set.
2213
2214#### Lo que este kernel todavía no puede, dicho en voz alta
2215
2216`nft -c` rechazó **cinco reglas** antes de aplicar nada:
2217
2218    tcp dport 22022 ct count over 10 drop
2219                    ^^^^^^^^  Error: Could not process rule: No such file or directory
2220
2221`ct count` es `CONFIG_NFT_CONNLIMIT`, que el kernel de hoy no trae (el `.config` lo dice:
2222`# CONFIG_NFT_CONNLIMIT is not set`). Es el tope de conexiones **concurrentes** por servicio — el ban
2223por tasa, que es lo que reemplaza a fail2ban, no depende de él. Las cinco reglas quedan
2224**comentadas, no borradas**, en el fichero que se aplica: *lo que la política declara y el kernel no
2225puede tiene que verse*. El símbolo ya entró a `recipes/linux-generic.toml` y vuelve con el próximo
2226kernel; hasta entonces el mensaje —que no nombra la opción que falta— queda explicado ahí mismo.
2227
2228#### Que sobreviva al reinicio: un servicio `oneshot`
2229
2230`recipes/nftables.toml` declara ahora su `[[service]]` con `lifecycle = "oneshot"`: **cargar un
2231reglaset no es un daemon, es un acto**. Va **primero en el genesis**, antes que sshd y los demás:
2232entre que la red está y que las reglas cargan, la caja está abierta, y ese hueco se hace lo más
2233corto posible. Probado de verdad — `nft flush ruleset` y después `arjectl start cortafuegos`: las 15
2234reglas vuelven.
2235
2236Su guarda sale **78 nombrando el fichero** si el reglaset no está, en vez de dejar la caja sin
2237filtrar creyendo que está protegida: *un cortafuegos ausente y uno vacío se ven igual desde afuera
2238hasta que alguien prueba*.
2239
2240### 6.33 🧨 EL CORTAFUEGOS BANEÓ A LOS USUARIOS DEL PROXY — y squid nunca se cayó *(2026-09-16)*
2241
2242El usuario preguntó si el proxy estaba caído. **No lo estaba**: el ente `squid` llevaba horas
2243`corriendo` con `↻ 0`, escuchando en `:1137` y contestando `407` a quien preguntara. Lo que estaba
2244caído era **el acceso**: el cortafuegos que puse ayer estaba baneando a los usuarios autorizados.
2245
2246La prueba, en una línea: **`154.197.1.2` —una IP que está en la ACL de squid, o sea gente con
2247contraseña— apareció en `@ban4`**. Y el contador del ban iba en **38.246 paquetes tirados**.
2248
2249#### La causa no es la tasa: es la RÁFAGA
2250
2251    tcp dport 1137 ct state new add @ban4 { ip saddr timeout 300s limit rate over 300/minute burst 5 packets } drop
2252
2253`burst 5` significa «tolero cinco conexiones por encima del promedio». **Un navegador o un agente
2254detrás de un proxy abre decenas de CONNECT en el mismo instante**, aunque su promedio sea bajísimo:
2255seis de golpe y ya estás en el set. Y como **`@ban4` es UN SOLO set consultado antes que todo**,
2256caer por el puerto del proxy te tira también el web y el git.
2257
2258⚠ El tipo del cortafuegos ya traía el campo (`rafaga`, default 5) con un comentario que decía
2259«para un servicio web va alto (100-200)». Otra vez: **leí el `.ron` de ejemplo y no el tipo**.
2260
2261#### Lo que se hizo, en orden de urgencia
2262
22631. **`nft flush set … ban4/ban6`** — servicio restaurado en el acto.
22642. **Los usuarios del proxy a `confiables`**: la misma lista que la ACL de squid (21 IPs + 5 redes).
2265   Un proxy que autentica no necesita que el kernel le cuente las conexiones a un usuario conocido;
2266   lo que el ban tiene que parar son las fuentes DESCONOCIDAS.
22673. **`rafaga` por servicio, según su clientela**: ssh 5 · git 20 · web 100 · **proxy 200**. Y el
2268   proxy pasa a `1200/min` con ban de 60 s: red de contención contra un flood, no control de uso.
22694. Control después: **cero autorizados en el set**, 24 baneados (escáneres, que es lo que se quiere),
2270   y tráfico real pasando — `154.194.14.37 … CONNECT api.deepseek.com:443 sigma`, 298 K tunelados.
2271
2272⚠⚠ **Y un fallo de método que casi lo tapa: `nft -f` SUMA a la tabla existente.** Cargué el reglaset
2273corregido encima del viejo y quedaron **30 reglas**: las viejas ADELANTE, así que los `confiables`
2274nuevos no llegaban a evaluarse nunca. Se vio contando (`grep -c dport` daba 30 y no 15) y mirando
2275qué `saddr` salía primero. Por eso `cortafuegos apply-input` **borra la tabla antes de cargar** —
2276usar `nft -f` a pelo se saltea esa mitad.
2277
2278#### La hora exacta, reconstruida del `access.log`
2279
2280El usuario preguntó a qué hora se bloqueó, y el log lo dice sin ambigüedad. Su IP —`45.234.61.160`,
2281autenticada como `sergio`, **2709 peticiones**— tuvo estos silencios:
2282
2283| silencio | desde | hasta |
2284|---:|---|---|
2285| **93,2 min** | 16-09 **11:59:37** UTC | **13:32:49** UTC ← cuando vacié los sets |
2286| 50,1 min | 10:44:58 | 11:35:06 |
2287| 10,0 min | 11:45:06 | 11:55:05 |
2288
2289Los tres son el **`timeout` de 10 minutos del ban disparándose una y otra vez**: cada vez que volvía,
2290su primera ráfaga lo baneaba de nuevo. `154.197.1.2`, que usa el proxy de forma sostenida pero sin
2291ráfagas, sólo perdió huecos de 5 minutos.
2292
2293🧨 **Y la exención que tenía no servía: su IP no estaba en la red eximida.** La ACL histórica de squid
2294exime `45.234.60.0/24` y él entra desde `45.234.61.160` — **una red de al lado**. Parecía cubierto y
2295no lo estaba: la pertenencia se MIDE (`ip in red`), no se deduce del parecido del prefijo. Con el
2296`/23` que nft dedujo al unir los dos `/24`, ahora sí.
2297
2298#### Y la decisión de fondo: en un servicio que YA AUTORIZA, no va límite por tasa
2299
2300El uso real de esta caja es **intensivo** —varias sesiones de agente en paralelo, cada una abriendo
2301túneles a la vez—, y un límite calibrado para «una persona normal navegando» no protege de nada: el
2302atacante ajusta su ritmo, el usuario no puede trabajar más despacio. Squid ya pide usuario y
2303contraseña y tiene su ACL; el ban por tasa ahí no agrega seguridad, agrega cortes. Queda apagado
2304(60000/min, ráfaga 1000: números que ningún cliente real alcanza) y contra un flood queda el tope
2305GLOBAL `syn_nuevas_por_seg`, que no distingue usuarios ni banea a nadie por ser rápido.
2306
2307#### Lo que esto enseña del cortafuegos como herramienta
2308
2309Un cortafuegos correcto y un cortafuegos usable no son lo mismo, y la diferencia **no se ve al
2310aplicarlo**: las reglas cargan, el `nft -c` pasa, los sitios responden, y el daño aparece sólo cuando
2311un cliente REAL hace lo que los clientes reales hacen. El único control que lo habría cazado antes es
2312el que no corrí: **una ráfaga de conexiones desde una IP que NO esté en `confiables`**.
2313
2314### 6.34 📋 Censo del 2026-09-16: queda UN vhost — y el censo sin privilegio no veía 572 M
2315
2316Censo fresco de gioser (`censar.py --local --probe-dns`, sólo lee), para saber **qué falta de
2317verdad** en vez de arrastrar la lista del 11.
2318
2319```
2320  servicios   vivo 23 · no-declarado 17 · declarado-muerto 81 · descartados 6
2321  dominios    apunta-aca 1 · ya-mudado 6 · fosil-sin-dns 7
2322  datos       /home 25 G (copiar 27 G) · /var/lib 8,1 G · /opt 1,2 G · /var/www 288 M
2323```
2324
2325**De los 29 dominios del Caddyfile sólo UNO sigue sirviéndose desde gioser: `sergio.gioser.net`.**
2326Comprobado desde fuera, resolución + HTTP, no por el fichero de config:
2327
2328| dominio | resuelve a | HTTP |
2329|---|---|---|
2330| `tawasuyu.net` · `gioser.net` · `git.tawasuyu.net` · `takana.gioser.net` · `hifas.gioser.net` · `api.sergio.gioser.net` | `2.29.29.217` | 200 |
2331| `gitea.gioser.net` | `2.29.29.217` | 301 |
2332| **`sergio.gioser.net`** | **`204.168.193.248` (gioser)** | 200 |
2333| `api.summa.gioser.net` | `154.197.1.2` (otra máquina) | — |
2334| `terapeuta.ec`, `andino.ec`, `api.sigma…` y 4 más | **sin DNS** | — |
2335
2336⇒ la lista de «tres vhosts» del §9 quedó vieja: `gioser.net` y `tawasuyu.net` ya están mudados
2337(§6.26) con su origen vivo a propósito. **Lo único que ata el DNS a gioser es la consola de `sergio`.**
2338
2339#### El rescate del §6.20 sigue vigente — y caduca de verdad
2340
2341Seis procesos corren hoy desde un **inodo borrado**. Cuatro tienen su copia en
2342`work/mudanza/rescate/` y el sha256 del `/proc/<pid>/exe` vivo **coincide bit a bit** con lo
2343rescatado el 12. Los otros dos (`/usr/local/bin/tejido` y `/usr/local/bin/shuma-daemon`) **no están
2344en el rescate**, pero en disco hay una versión **más nueva** de cada uno (hoy 11:13 y 14:20): no se
2345perdió nada porque el usuario los está reconstruyendo. La lección es la del §6.20 y no cambió: *ese
2346paso caduca, y quién caducó se ve mirando `/proc`, no el directorio*.
2347
2348#### 🕳️ Y el hallazgo del día: `-e` contesta «no existe» cuando lo que pasa es «no puedo mirar»
2349
2350El censo tiene tres estados a propósito (`ausente` / `sin_permiso` / `ok`) porque un `ls` sin
2351permiso se lee igual que un directorio vacío. Pero el tercer estado sólo se detectaba **sobre la
2352propia ruta**: cuando el que no deja pasar es un **ANCESTRO**, `[ -e "$p" ]` contesta que no, y la
2353ruta caía en `ausente` — que el censo **descarta sin decir nada**.
2354
2355```
2356  /root/.local        drwx------ root root        ← 0700
2357  /root/.local/bin    572 M de binarios a mano que NINGÚN paquete provee
2358```
2359
2360Corrido como `sergio`, el censo decía que `/root/.local/bin` no existe. Corrido como root, son
2361**572 M** de exactamente la clase que la mudanza no puede reconstruir: binarios puestos a mano.
2362Arreglado —si un ancestro existe y no es atravesable, el estado es `sin_permiso`— y probado en los
2363dos sentidos, que es lo que distingue un guardián de un adorno:
2364
2365```
2366  ancestro 0700 de otro dueño   →  sin_permiso   (y sale en el ⚠ del resumen)
2367  ruta que NO existe de verdad  →  ausente       (y NO se reporta)   ← el control que tiene que pasar
2368```
2369
2370Con el arreglo, el censo como `sergio` pasa de **2 rutas ciegas a 13** — entre ellas el home entero
2371de `artix`, que antes no figuraba de ninguna manera. **El censo se corre como root**; la lista de
2372ciegas es el recibo de lo que una corrida sin privilegio no puede ver.
2373
2374#### Lo que queda, y por qué no lo decide el programa
2375
2376Descontados los que ya se decidieron (§6.30: `qdrant`, `act_runner`, `fail2ban` mueren) y los que
2377provee el init del destino, lo que sigue vivo en gioser es **un montón de daemons de tawasuyu que
2378ningún paquete provee** — binarios a mano en `/usr/local/bin` (1,2 G) y `~/.local/bin` (765 M + 572 M
2379en el home de root):
2380
2381```
2382  shuma-daemon · shuma-gateway (:7378)   la consola de sergio.gioser.net — el único vhost que queda
2383  willay-daemon · willay-crosscheck · tejido (:4102,:33097) · pacha · pacha-secretos
2384  matilda · tupu · thasnuna · sandokan-watch · openclaw · zeroclaw
2385  puerta-f6e393ffbef3999e (:34221,:44961)   binario de test en target/debug, BORRADO del disco
2386  webhook-deploy.py
2387```
2388
2389`planear.py --revisar` los saca con su motivo, pero **la decisión es del usuario y el programa no la
2390va a inventar**: son las herramientas propias del usuario, no servicios de un catálogo. Y los
2391**datos** tampoco se recomiendan solos (§3): `/home` son 25 G que incluyen 6,8 G de `.claude` y
23921,3 G de binarios sueltos.
2393
2394### 6.35 🧳 Los binarios sueltos, desglosados — y qué de `.claude` viaja *(2026-09-16)*
2395
2396Decisión del usuario sobre el §6.34: **shuma se construye desde tawasuyu** (ya está encolado:
2397`recipes/incoming/shuma-{gateway,daemon}.toml`), **los daemons de tawasuyu viven todos**, y de
2398`.claude` decide el criterio de quien lo usa. Falta el detalle de los sueltos, que es esto.
2399
2400#### Son 2,7 G en tres directorios, y casi nada de eso es irreemplazable
2401
2402| clase | cuánto | qué es | se reconstruye |
2403|---|---|---|---|
2404| **tawasuyu** | 57 ficheros · **1003 M** | `pata-llimphi` 105 M, `nahual-shell-llimphi` 98 M, `shuma-shell-llimphi` 76 M, los 14 `mirada-*`, `pata-*`, `shuma-*`, `sandokan-*`, `willay-*`, `pacha`, `tejido`, `tupu`, `matilda`, `thasnuna`… | **sí**: son salida del monorepo, que ya vive en el gitea de la caja |
2405| **terceros** | 10 ficheros · **841 M** | `kiro-cli*` 571 M (tres binarios), `kcl` 95 M, `qdrant` 91 M (muere, §6.30), `gitea-runner` 21 M, `act_runner` 21 M (muere), `listmonk` 19 M, `hcloud` 18 M, `goaccess` 3,4 M | no, pero **se vuelven a bajar de su origen** |
2406| **respaldo / duplicado** | 21 ficheros · **876 M** | `/root/.local/bin` es **una copia entera de `kiro-cli*`** (571 M) más el CLI de `claude` (219 M); el resto son `.respaldo-FECHA`, `.previo` y `.bak` | no hace falta |
2407| **guiones** | 27 ficheros · **< 1 M** | `git-deploy`, `webhook-deploy.py`, `update-squid-allowed-ips.sh`, `mirada-session*`, `huellita-aviso`… | **NO** ← lo único de verdad frágil |
2408
2409⇒ **lo caro no es lo valioso**. De los 2,7 G, 1,8 G son reconstruibles o duplicados, y lo que no
2410está en ningún repo ni en ningún paquete son **27 guiones que suman menos de un megabyte** — el
2411pegamento que nadie recuerda hasta que falta. Van al plan como una sola entrada, y con nombre.
2412
2413Y los **572 M de `/root/.local/bin`** que el censo no veía (§6.34) resultaron ser exactamente eso:
2414la copia de `kiro-cli*` que ya está en el home de `sergio`. La lección no cambia —un censo que dice
2415«no hay nada» de lo que no pudo mirar es peor que uno que falla—, pero el contenido, esta vez, era
2416duplicado.
2417
2418#### `.claude`: 6,8 G en disco, **15 M es lo que hace falta para seguir funcionando igual**
2419
2420Criterio, porque es lo que me deja trabajar allá como acá:
2421
2422| viaja | qué | tamaño |
2423|---|---|---|
2424| ✅ | `projects/*/memory/` de los 18 proyectos — la memoria destilada | **6,2 M** |
2425| ✅ | `settings.json`, `plugins/`, `history.jsonl` | ~8,6 M |
2426| ⛔ | `jobs/` (5,3 G), `downloads/` (228 M), `archivo-sesiones/` (286 M), `file-history/`, `shell-snapshots/`, `cache/`, `paste-cache/` | 6,1 G |
2427| ⚠ | `projects/*/*.jsonl` — los transcripts crudos (973 M) | ver abajo |
2428
2429Los **transcripts no hacen falta para funcionar**: la memoria es su destilado y es lo que se lee al
2430arrancar una sesión. Lo que se pierde sin ellos es `--resume` de una sesión vieja y la posibilidad
2431de releer una conversación que nadie resumió. Por eso no van a la caja —que tiene 42 G libres en
2432`/work` y los quiere para el store— sino al **Storage Box del respaldo**, que es donde vive lo que
2433se guarda por si acaso. La memoria, en cambio, viaja con la máquina.
2434
2435#### ⚠ Y una trampa de ruta que haría todo esto inútil
2436
2437**La memoria se indexa POR RUTA**: el directorio se llama `-mnt-vvv-takana` porque el repo vive en
2438`/mnt/vvv/takana`. En la caja el repo está en **`/opt/takana`** ⇒ copiar `memory/` tal cual deja los
2439163 ficheros en disco y **ninguno se encuentra**, sin que nada falle. Las dos salidas son renombrar
2440el directorio a la ruta de destino (`-opt-takana`) o darle al repo la misma ruta que acá. Se elige
2441al copiar, no después.
2442
2443#### 🕳️ La caja no tiene usuario `sergio`
2444
2445`/etc/passwd` de `2.29.29.217`: `root`, `sshd`, `gitea`, `proxy`. Nada más. En gioser,
2446`shuma-daemon` corre como `sergio` —y `shuma-gateway` también—, así que la tarjeta de arje que los
2447declare tiene que **crear la cuenta primero**; y el payload `Native` de arje **no tiene campo de
2448usuario** (SDD 29 §4.0 quinquies), así que declararlos sin más los pone a correr como root. Es la
2449misma advertencia que ya lleva `recipes/incoming/shuma-daemon.toml` en su cabecera, escrita donde se
2450va a leer.
2451
2452### 6.36 🚚 Lo que se movió hoy — y las cuatro verificaciones que se hicieron EN DESTINO *(2026-09-16)*
2453
2454Con las decisiones del §6.35 puestas, se movió lo que no dependía de nada más. **Cada copia se
2455verificó del lado del destino**, que es la regla que el `rsync` con exit 0 y 1367 artefactos vacíos
2456dejó escrita (§6.13, y la regla 2 de `aplicar.py`).
2457
2458| qué | a dónde | verificación en destino |
2459|---|---|---|
2460| **los 27 guiones** (75 KB) | `takana:/work/rescate-guiones/` | `sha256sum -c SHA256SUMS` ⇒ **27 OK** |
2461| **la memoria de Claude** (733 ficheros, 6,3 M) + `settings.json`, `plugins`, `history.jsonl` | `takana:/root/.claude/` | 733 ficheros en origen y **733 en destino** |
2462| **los transcripts crudos** (1 966 ficheros, 1,3 G) | Storage Box `claude-gioser/` | `rsync -an` de vuelta: **1 solo fichero pendiente**, y es el transcript de la sesión que estaba escribiéndolo |
2463| **la clave PRIVADA de release** | `takana:/root/.config/takana/keys/` 0600 | sha256 idéntico en los dos lados **y** su pública == `trust/release.ed25519.pub` |
2464| credenciales y config (gitconfig, npmrc, gh, ssh, crontabs, Caddyfile) | `takana:/work/mudanza-secretos/` 0700 | 27 ficheros en origen y **27 en destino** |
2465| estado de los daemons que viven (`sigma`, `tupu`, `willay`, `sandokan-watch.json`) + `/var/www/para-respaldar` | `takana:/work/mudanza-estado/` | 351 ficheros y 68 M en los dos lados |
2466
2467#### ⚠ Las credenciales NO se instalaron en su sitio, a propósito
2468
2469La caja **ya tiene** su `/root/.ssh` (con `github5`), su crontab (el latido de la caja, §6.29) y su
2470`/etc/caddy/Caddyfile` con los siete vhosts que ya sirve. Pisar cualquiera de los tres con el de
2471gioser rompe lo que hoy funciona. Quedan en `/work/mudanza-secretos/` con su `LEEME.txt`, y se
2472instalan **cuando se mude el hub**, que es otra unidad de trabajo. El `.gitconfig` es el caso más
2473claro: trae los `insteadOf` que **reescriben remotos**, así que instalarlo cambia a dónde empuja
2474`git push` sin que nadie lo pida.
2475
2476La **única** excepción es la clave de release, que sí fue a su ruta canónica: sin ella nadie puede
2477volver a firmar el repo, y ese fallo no aparece el día que se borra gioser sino la próxima vez que
2478alguien publica.
2479
2480#### 🔍 Un «576 M contra 1,3 G» que parecía una copia a medias
2481
2482`du -sh` en el Storage Box decía **576 M** de los 1,3 G subidos. Con el antecedente del rsync que
2483llenó el disco, eso se lee como copia truncada. No lo era: el Storage Box **comprime**, y su shell
2484restringida no tiene `find` ni acepta tuberías, así que el conteo de ficheros —la verificación
2485obvia— ahí no se puede hacer. Lo que sí se puede es **preguntarle a rsync**: una corrida `-an`
2486compara tamaño y fecha contra el destino real y dijo **1 fichero pendiente**, el que estaba
2487creciendo mientras se copiaba.
2488
2489⇒ *la verificación tiene que poder correrse en el destino que hay, no en el que uno imagina*. Y un
2490número que no cuadra merece una segunda medición antes de un diagnóstico: `du` medía otra cosa.
2491
2492#### Lo que NO se movió, y no es olvido
2493
2494**~10 G de árboles personales en `/home/sergio`** —`android-sdk` 1 G, `maps`, `imm`, `humanoid`,
2495`hifas`, `fdroid`, `valens-conversion`, `pyswisseph`, `accordion`…— y `/opt` (1,2 G, que es
2496`android-sdk` otra vez y `google`). **Qué datos valen no se deduce de la máquina** (§3), y éstos son
2497proyectos del usuario, no servicios de un censo: van a la lista de decisiones, no a una copia hecha
2498por criterio ajeno.
2499
2500De `/var/lib` (8,1 G) se movió sólo el estado de los daemons que viven: **6,1 G son swap** y 1,9 G
2501el gitea, que ya está mudado desde el §6.14.
2502
2503### 6.37 ✅ CUTOVER DE `sergio.gioser.net` — el último vhost, y gioser deja de servir *(2026-09-16)*
2504
2505**Ya no queda ningún dominio sirviéndose desde gioser.** El último era éste, y lo que lo ataba no
2506era el sitio sino su consola: `/shuma/*`. Verificado desde fuera, contra la IP nueva:
2507
2508```
2509  https://sergio.gioser.net/           → 200   408 bytes, la SPA
2510  https://sergio.gioser.net/shuma/     → 200   «shuma-gateway ok»   ← el gateway, no la SPA
2511  https://sergio.gioser.net/ruta-inventada → 200  la SPA (el fallback, que es lo correcto)
2512```
2513
2514⚠ **El `200` de `/shuma/` había que mirarlo dos veces.** El bloque termina en
2515`try_files {path} {path}/ /index.html`, así que **cualquier ruta inexistente devuelve 200 con la
2516SPA**: un `curl -o /dev/null` que sólo mira el código habría dado el mismo verde con el proxy mal
2517puesto. Lo que decide es el CUERPO — 17 bytes que dicen `shuma-gateway ok` — y el control es
2518compararlo con lo que el origen viejo sigue sirviendo, byte por byte. Igual con `/shuma/rpc`: las
2519dos máquinas contestan el MISMO `400 {"error":"bad json: …"}`.
2520
2521#### Lo que hubo que construir, en orden
2522
25231. **Las dos recetas** (§6.34) sellaron en el worker: `b3:107dfb54…-shuma-gateway` (6,3 M) y
2524   `b3:f9b92acc…-shuma-daemon` (5,6 M) — los hashes que `takana hash` había anticipado antes de
2525   encolarlas. `file` dice **`statically linked`**: nada de cargador, que era exactamente lo que le
2526   faltaba al binario glibc de gioser (y cuyo inodo, además, ya estaba borrado del disco).
25272. **La cuenta, declarada y sin privilegio.** `[[user]] shuma` (uid 967) en la receta del daemon, y
2528   el descenso con `setuidgid` dentro del `argv` de la Card, igual que gitea — porque el payload
2529   `Native` de arje **no tiene campo de usuario**. Declararlo «como estaba» lo habría puesto de
2530   root, y esto es un demonio que abre PTYs y lanza shells.
25313. **⚠ Y la shell de la cuenta NO es un detalle.** El default de `[[user]]` es `/bin/false`, que es
2532   lo correcto para gitea o squid y **exactamente lo contrario** para éste: con la shell inerte el
2533   servicio arranca, se supervisa, contesta 200… y cada pestaña que alguien abra muere al instante.
2534   Un fallo que no se ve al desplegar: se ve al usarlo. Va `shell = "/bin/sh"`.
25354. **La config que no se regenera.** El `gateway-token` (32 B) y `keys/identity.x25519` viajaron a
2536   `/var/lib/shuma/.config/shuma/`: generar unos nuevos deja fuera a los clientes ya emparejados.
2537   Por eso la guarda de la Card **exige** el token y no lo crea, y dice de dónde sale.
25385. **Las dos Cards** entran en `/etc/arje/cards.d/` **y** en el `genesis` de `/ente/seed.card.json`
2539   (son dos preguntas distintas: qué se puede encarnar a pedido y qué arranca solo). La semilla
2540   quedó con 13 entradas y vuelve a parsear.
25416. **Declarado en `perfil.servidor`**: los dos paquetes y los dos labels en `servicios`. Entran como
2542   PAR — el gateway sin el daemon es un puente a ninguna parte.
2543
2544`[[user]]` y `[[service]]` están **fuera de `hash_inputs`**, así que declarar todo eso no movió
2545ningún ArtifactHash: comprobado en los dos, antes y después.
2546
2547#### 🔁 El DNS: la API tiene una acción para esto y el §6.25 no la había encontrado
2548
2549Aquel cutover dejó escrito «un `PUT` sobre el rrset no puede cambiar el tipo: hay que `DELETE` del
2550CNAME y `POST` del A». Es cierto para un cambio de TIPO. Para cambiar el VALOR, el `PUT` tampoco
2551sirve —`can't update records with this endpoint`— pero existe una acción que lo hace **atómica**:
2552
2553```
2554POST /v1/zones/<id>/rrsets/<nombre>/<tipo>/actions/set_records   {"records":[{"value":"…"}]}
2555```
2556
2557Sin ventana sin registro, que es lo que sí tiene el `DELETE`+`POST`. Con TTL 60 la resolución
2558pública cambió **en menos de 8 segundos**, y volver atrás es la misma llamada con la IP vieja.
2559
2560#### El origen sigue vivo, a propósito
2561
2562Como en §6.26: en gioser `shuma-daemon`, `shuma-gateway` y su bloque de caddy **siguen corriendo**.
2563Mudar el nombre y dejar el origen encendido permite volver en un minuto, y no cuesta nada mientras
2564la máquina exista. Lo que ya no existe es la dependencia: **ningún dominio resuelve a gioser**.
2565
2566⚠ Pendiente menor, anotado donde se va a leer: el daemon avisa al arrancar que no encuentra
2567`shuma-askpass` («sudo/ssh pedirán la clave por el TTY»). Es otro crate del mismo workspace y es
2568otra receta — no bloquea la consola.
2569
2570### 6.38 🧩 Los primeros cuatro daemons propios, corriendo en la caja *(2026-09-16)*
2571
2572De las diez recetas encoladas, cuatro sellaron y ya corren supervisadas por arje:
2573
2574| | cuenta | qué hace, comprobado |
2575|---|---|---|
2576| `matilda` | **root** | lee el access log de caddy y archivó **78 ficheros** en `/var/lib/tupu/takana/` |
2577| `pacha` | `pacha` (965) | vivo, 0 reinicios, con SUS reglas (`reglas.ron`), no las de fábrica |
2578| `pacha-secretos` | `pacha` | vivo, 0 reinicios — y su binario en gioser era un **inodo borrado** |
2579| `sandokan-watch` | root | **frenado a propósito**: no puede trabajar acá (ver abajo) |
2580
2581#### ⚠ Tres corren como ROOT, y está declarado en vez de heredado
2582
2583Lo natural sería una cuenta sin privilegio por servicio, como `shuma` o `gitea`. Con el trío de la
2584telemetría **no se puede**, y las dos razones son medidas:
2585
25861. `matilda` lee `/var/log/caddy/requests.json`, que caddy crea **`-rw------- root root`** y recrea
2587   igual en cada rotación: un grupo con permiso de lectura se pierde en la próxima.
25882. `tupu`, `matilda` y `sandokan-watch` escriben en el MISMO `/var/lib/tupu`, y en gioser sus 322
2589   ficheros son `root:root`. Bajar uno solo deja **mezcla de dueños en un árbol compartido**: escribe
2590   el primero y los otros fallan al modificar.
2591
2592⇒ o los tres como root, o ninguno. La salida limpia está anotada en la receta para el día que se
2593quiera: que caddy escriba con `mode 640` y que el árbol sea de una cuenta propia. `pacha` y
2594`pacha-secretos` sí bajan a una cuenta propia, porque en gioser tampoco corrían como root.
2595
2596#### 🕳️ La caja se llamaba «(none)» — y eso se archivaba
2597
2598`matilda` archiva POR MÁQUINA, y sus primeros 72 ficheros fueron a `/var/lib/tupu/**none**/`:
2599
2600```
2601  $ hostname          →  (none)
2602  $ cat /etc/hostname →  no existe
2603```
2604
2605**Ninguna caja takana fija su hostname**: no lo hace `netup`, no está en el product-rootfs, no hay
2606`/etc/hostname`. Nunca importó porque nada archivaba por nombre — hasta que llegó algo que sí. Y no
2607falla: archiva bajo `none`, y dos máquinas distintas archivarían en la misma carpeta.
2608
2609Puesto `/etc/hostname` + una Card **OneShot** `hostname` en el genesis, para que sobreviva al
2610reinicio sin depender de que alguien se acuerde. Con el nombre puesto, matilda re-archiva en
2611`/var/lib/tupu/takana/`. Quedan 72 ficheros huérfanos bajo `none/`: son los 20 minutos anteriores.
2612
2613⇒ **lo correcto es que lo haga `netup`**, que es quien configura la identidad de red de la máquina;
2614la Card es la mitigación de hoy, y está dicho en su propia guarda.
2615
2616#### 🛡️ Un vigía que no vigila es PEOR que uno caído, porque el caído se ve
2617
2618`sandokan-watch` arrancó, quedó «corriendo · 0 reinicios»… y no guardaba una línea. Lo dice él mismo
2619si uno lo corre a mano:
2620
2621```
2622sandokan-watch · sin fuente de accesos (ni /var/log/auth.log ni journalctl): no se guarda nada
2623```
2624
2625En gioser lee el `auth.log` de un syslog que **en takana no existe** — no hay systemd ni syslog. Su
2626Card ahora lleva una guarda que sale **78** nombrando el problema, así que el servicio no queda
2627verde mintiendo; y **no entra al `genesis`**, sólo a `cards.d`, porque no tiene sentido que arranque
2628al boot para fallar. Destrabarlo es darle el diario de arje como fuente, y eso es trabajo de su repo.
2629
2630#### Dos herramientas que no existen donde se prueba
2631
2632Dos veredictos falsos en una tarde, los dos por probar con algo que la caja no tiene:
2633
2634- **`/dev/tcp/host/puerto` no existe en busybox `ash`** ⇒ un barrido de puertos contra gioser dio
2635  «cerrado» para los cinco, incluidos `:80` y `:443`, que sirven. Con `curl telnet://` el resultado
2636  es el contrario: `:2345` y `:443` **abiertos**, `:22` cerrado.
2637- **La expansión de llaves (`{a,b}`) tampoco** ⇒ un `ls /store/*-{matilda,pacha}` dijo que los
2638  artefactos no habían llegado. Habían llegado.
2639
2640Es la misma lección del §6.23 (`ps` y `dig` ausentes) y merece repetirse: *una prueba que no puede
2641pasar se ve igual que una que todavía no pasó*.
2642
2643### 6.39 🕸️ Los nueve daemons propios, y la línea que separa «se muda» de «se INTERCAMBIA» *(2026-09-16)*
2644
2645Nueve de las diez recetas sellaron. Cinco corren; cuatro están instaladas y **frenadas a propósito**,
2646cada una por un motivo distinto y con su guarda diciéndolo.
2647
2648| corriendo | | frenado | por qué |
2649|---|---|---|---|
2650| `matilda` · `tupu` | root, escriben `/var/lib/tupu/takana/` | `sandokan-watch` | en takana no hay `auth.log` ni journalctl |
2651| `pacha` · `pacha-secretos` | cuenta `pacha` (965) | `tejido` | **misma identidad libp2p que el de gioser** |
2652| `willay-daemon` | root, con su índice mudado | `willay-crosscheck` | necesita el roster de tejido |
2653| `shuma-daemon` · `shuma-gateway` | cuenta `shuma` (§6.37) | `thasnuna` | pide `claude` autenticado y `sandokan-mcp` |
2654
2655#### ⚠ La regla que sirvió para los vhosts NO sirve para una identidad
2656
2657Con los sitios web la pauta fue: **mudá el DNS y dejá el origen encendido**, así volver cuesta un
2658minuto (§6.26). Con `tejido` eso es exactamente lo que NO hay que hacer, y el README del propio
2659tejido lo dice: *«la clave que el roster atesta ES la identidad de transporte libp2p»*
2660(`~/.tejido/device.seed`). Dos máquinas con la misma semilla no son dos réplicas del mismo servicio:
2661son **el mismo PeerId en dos sitios**, o sea dos impostores mutuos para el resto de la flota.
2662
2663⇒ `tejido` se **intercambia**: se apaga allá, se enciende acá, en ese orden. Está todo listo para el
2664intercambio —binario, cuenta `tejido` (964), identidad instalada 700 en `/var/lib/tejido/.tejido/`—
2665y su Card vive en `cards.d` pero **no en el `genesis`**, para que un reinicio no lo encienda solo.
2666
2667Y arrastra a otro: `willay-crosscheck` **no es independiente**. Corriendo su línea a mano:
2668
2669```
2670Error: no hay roster en /root/.tejido/roster.postcard — emparejá primero (`tejido serve` / `tejido join`)
2671```
2672
2673Vive dentro de la red de tejido, así que hereda su condición. Se enciende en el mismo movimiento.
2674
2675#### Lo que cada guarda evita
2676
2677- `thasnuna`: su `INSTALAR.md` —escrito hoy por el frente tawasuyu para esta misma mudanza— pide
2678  tres cosas, y dos no están: `sandokan-mcp` en el PATH («el agente contesta pero no tiene
2679  herramientas») y **`claude` instalado y AUTENTICADO** («no hay anfitrión»). El CLI de claude es un
2680  binario **glibc de ~300 M**: en una caja musl pide jaula qorpa, como `sergioh-api`.
2681- Y de ahí sale un hallazgo que vale para el respaldo entero: ese documento explica por qué el token
2682  no va en la Card, y el motivo está medido en la máquina vieja — **la Card `openrc-openclaw` de
2683  gioser lleva la API key de su proveedor EN CLARO dentro del JSON de `/etc/arje/cards.d/`**, que es
2684  un directorio que se respalda y se copia. Un secreto dentro de una Card viaja a todas partes.
2685- `willay-crosscheck`: `--label` **nombra a la máquina**. En gioser decía `momento`; copiarlo tal
2686  cual habría hecho que la caja nueva se anunciara con el nombre de la que se borra. Ahora sale de
2687  `hostname`, y si no hay, la guarda lo dice.
2688
2689#### 🔁 Tres veredictos falsos en una tarde, los tres por probar con lo que la caja no tiene
2690
2691Van con el §6.38 y ya son patrón, no anécdota:
2692
2693| la prueba | lo que dijo | lo que pasaba |
2694|---|---|---|
2695| `/dev/tcp/host/puerto` | los 5 puertos de gioser «cerrados» | busybox `ash` no tiene `/dev/tcp` |
2696| `ls /store/*-{a,b}` | «los artefactos no llegaron» | busybox no expande llaves |
2697| `find … -newermt "-2 minutes"` | «tupu no escribe hace 2 min» | tupu **sí** escribe: su `mtime` avanza cada 10 s |
2698
2699El de `tupu` es el más instructivo porque casi produce un diagnóstico al revés: el fichero tiene
2700**tamaño constante** (es una serie de tamaño fijo), así que «no cambió de bytes» tampoco probaba
2701nada. Lo que decide es el `mtime`, medido dos veces con 40 s de por medio.
2702
2703### 6.40 🚪 Las ocho puertas, medidas de nuevo *(2026-09-16)* — y la que falta es UNA
2704
2705La tabla del §6.3 es del 10 de septiembre. Re-medida hoy, entera:
2706
2707| # | puerta | estado | medido |
2708|---|---|---|---|
2709| 1 | arranca takana puro, se entra por SSH | ✅ | sigue en pie |
2710| 2 | el store está entero | ✅ | **1632 artefactos, 0 vacíos** (eran 1362) · ⚠ `/store` al 88 %, 11,3 G libres |
2711| 3 | los cuatro grafos idénticos | ✅ | §6.7 |
2712| 4 | **la granja late en la caja nueva** | ❌ | **el latido sigue en gioser**: la caja no tiene el cron |
2713| 5 | el repo se sirve y el host se actualiza solo | ⬖ | sirve; consumir exige el lab entero (SDD 27 §4) |
2714| 6 | cada dominio vivo responde 200 desde fuera | ✅ | **los 8 contra `2.29.29.217`** — y `sergio` ya entre ellos |
2715| 7 | el respaldo corre desde la caja | ⬖ | el script **corre y lista** el Storage Box desde la caja; falta una corrida completa |
2716| 8 | lo fósil, anotado y decidido | ✅ | §6.9 + `docs/state/mudanza-decisiones.txt` |
2717
2718#### La puerta 4 es ahora una línea de crontab — y por qué no la puse
2719
2720La caja ya tiene **todo** lo que el latido necesita, comprobado uno por uno: `git`, `python3`,
2721`rsync`, `ssh`, `takana`, `flock`, la llave del worker en `/root/.ssh/github5` y el `.fleet` con
2722`dev.gioser.net`. Le faltaban dos costuras y están hechas:
2723
2724```
2725  /opt/takana/store              -> /store            (el script espera `./store`; acá es partición)
2726  /opt/takana/target/release/takana -> /usr/bin/takana (no hay árbol de build en una caja instalada)
2727```
2728
2729Y el repo de `/opt/takana`, que estaba **5 días atrasado**, quedó en `origin/main` exacto (0
2730diferencias contra el remoto), con un tar de respaldo de los 92 ficheros que el árbol tenía antes.
2731⚠ Esos 92 no eran trabajo local: eran **contenido que ya estaba río arriba** —el cambio de SSH a
2732HTTPS de las recetas de tawasuyu, entre otros— metido en el árbol sin commitear. Un árbol «sucio»
2733que en realidad está *atrasado*, que es el mismo espejismo que el §5 del informe de tawasuyu
2734describe para el monorepo.
2735
2736Prueba de sólo lectura desde la caja, que es lo más lejos que se puede llegar sin cambiar nada:
2737
2738```
2739  ▶ dev.gioser.net (154.197.1.13)   load: 2.72 · store: 895 artefactos
2740  ══ AVANCE KDE ══  197/198 sellado
2741  ══ CRON DE COSECHA ══  ⚠ NO instalado en el crontab
2742```
2743
2744**No lo instalé, y el motivo es el mismo que el de `tejido`**: con el cron puesto en las dos
2745máquinas, las dos harían `rsync --delete` del repo al worker y las dos cosecharían y commitearían.
2746El latido **se intercambia, no se duplica** — se apaga en gioser y se enciende acá, en ese orden, y
2747es el último movimiento antes del borrado.
2748
2749⇒ **de las ocho, una sola está en rojo, y es una decisión de corte, no trabajo pendiente.**
2750
2751### 6.41 🔀 INTERCAMBIO de `tejido` — la identidad cambió de máquina, no de dueño *(2026-09-17)*
2752
2753Hecho, y en este orden, que es el único posible: **apagar allá → encender acá**.
2754
2755```
2756  gioser   tejido relay (964) · tejido serve (29240) · willay-crosscheck (15334)   → detenidos
2757  takana   tejido        4102   ·  willay-crosscheck  4103                         → corriendo
2758```
2759
2760**La prueba de que la identidad VIAJÓ y no se generó otra** no es que el servicio arranque —eso
2761pasaría igual con semillas nuevas, y sería el fallo silencioso que esto existe para evitar— sino que
2762la semilla sea la misma:
2763
2764```
2765  device.seed     gioser cad6f35b633e8637…   caja cad6f35b633e8637…   ✓ idéntica
2766  roster.postcard gioser 590f2713075ac934…   caja 590f2713075ac934…   ✓ idéntica
2767```
2768
2769El `PeerId` sale de esa semilla, así que sigue siendo `12D3KooWBw2u8mt2ciUjX69qpVAZiJZ8sZMGxz8eHJEnz4rzg37L`.
2770
2771#### ⚠ LO QUE SÍ CAMBIA, Y HAY QUE TOCARLO A MANO: el multiaddr
2772
2773Los clientes tienen configurada la dirección COMPLETA del relay, IP incluida — se leyó del proceso
2774que corría en gioser:
2775
2776```
2777  antes:  /ip4/204.168.193.248/tcp/4102/p2p/12D3KooWBw2u…
2778  ahora:  /ip4/2.29.29.217/tcp/4102/p2p/12D3KooWBw2u…        ← mismo PeerId, otra IP
2779```
2780
2781Un cliente con el viejo no falla «mal»: reintenta contra una IP que pronto no existirá. **El PeerId
2782no hay que tocarlo; la IP sí, en cada equipo del roster.**
2783
2784#### 🧱 Sin el cortafuegos, el intercambio habría sido un verde falso
2785
2786El reglaset de la caja abría `80, 443, 1137, 2345, 22022` **y nada más**: `4102` estaba cerrado. Con
2787el relay encendido y el puerto filtrado, `arjectl status` diría «corriendo · 0 reinicios» y la flota
2788no llegaría — el peor de los verdes. Se abrieron `4102` (relay) y `4103` (crosscheck), con su ban
2789por tasa como el resto. **No se abrió `33097`**: ése era el puerto efímero de un `tejido serve`
2790(cliente), no un servicio que alguien busque.
2791
2792Y el reglaset quedó **idempotente**, que era deuda del §6.33: sus dos primeras líneas crean y borran
2793la tabla antes de definirla, así que `nft -f` **reemplaza** en vez de acumular. Comprobado contando:
279415 reglas `dport` antes, 21 después (no 36).
2795
2796⚠ **La política que genera ese fichero NO EXISTE EN NINGÚN DISCO.** Su cabecera dice «GENERADO, no
2797editar a mano — regenerar con `cortafuegos generate-input --policy <ruta>`», pero esa ruta no está
2798ni en la caja, ni en gioser, ni en el repo (sólo dos `politica_*_ejemplo.ron` en tawasuyu), y el
2799binario `cortafuegos` tampoco está instalado en ninguna de las dos. **El generado sobrevivió a su
2800fuente**: hasta que la política se escriba y se versione, esto se edita a mano — justo lo que su
2801primera línea pide no hacer.
2802
2803#### 🆔 `takana` acepta ULIDs que `arje-zero` RECHAZA
2804
2805La Card de tejido no encarnaba: `arje-zero rechazó: card tejido JSON: invalid character at line 7`.
2806La línea 7 es el `id`, y el id era `01M2EKDA00TEJ1D0RELAY00001` — que tiene una **`L`**, carácter
2807**excluido del alfabeto Crockford** de ULID (junto con `I`, `O`, `U`). `takana service-cards` lo
2808validó como bueno («26 caracteres alfanuméricos») y el consumidor lo tiró.
2809
2810Dos cosas de esto: el validador del PRODUCTOR es más laxo que el del consumidor —y eso siempre
2811termina en un fallo lejos de donde se escribió—, y el barrido propio que ya corrí sobre todos los
2812`id` del repo con el alfabeto correcto había marcado justo ése. Era el único con `L` (venía de
2813«RELAY»); ahora es `01M2EKDA00TEJ1D0RE1AY00001`.
2814
2815#### Estabilidad, medida y no supuesta
2816
2817`willay-crosscheck` marcaba **4296 reinicios**, que asusta hasta mirar de dónde vienen: son las
2818horas en que su guarda lo frenó a propósito por falta de roster. Con el mismo pid a los 70 s y
28191 m 53 s de vida, está estable. `tejido`: 0 reinicios. Los dos puertos aceptan conexión desde fuera.
2820
2821⚠ De paso, un detalle feo: `arjectl status | head -2` hace que **arjectl paniquee** con
2822`failed printing to stdout: Broken pipe`. No rompe nada, pero un `EPIPE` sin manejar en una
2823herramienta de operación aparece justo cuando alguien la encadena con `head`/`grep -q`.
2824
2825#### Cómo volver, si hiciera falta
2826
2827Está escrito en `work/mudanza/volver-atras-tejido.txt`: apagar los dos de la caja y relanzar los tres
2828de gioser con las líneas exactas que se leyeron de `/proc/<pid>/environ` y `cmdline`. **Nunca las dos
2829a la vez**: es la misma identidad.
2830
2831### 6.42 ❌ El latido NO se puede mudar todavía — y el motivo redefine qué es un «hub» *(2026-09-17)*
2832
2833Se intentó el último intercambio: apagar el cron de la cosecha en gioser y encenderlo en la caja.
2834**Salió mal, se vio en el primer ciclo, y se volvió atrás en minutos.** Queda escrito porque el
2835motivo no era ninguno de los que se habían previsto.
2836
2837#### Lo que sí funcionó
2838
2839La caja tiene todo lo del latido y el ciclo corrió de punta a punta: sembró el repo al worker, leyó
2840su manifiesto, regeneró los ocho ficheros de estado, pasó los vigías, **se puso al día por
2841fast-forward y commiteó+empujó**. El `git config` de identidad y el `pull --ff-only` nuevo hicieron
2842su parte.
2843
2844#### 🕳️ Lo que publicó, que es el problema
2845
2846```
2847  totals que publicó la CAJA :  {recipes: 1112, nodes: 1115, unhashable: 1112, ajeno: 3}
2848  totals de gioser           :  {recipes: 1112, nodes: 1115, sealed: 1105, never: 5, debt: 2}
2849```
2850
2851**`unhashable: 1112`** — no es «no está sellado»: es que **no se pudo CALCULAR el hash de ninguna
2852receta**. Y la causa es la misma que el §5.2 documenta desde el otro lado: **el lab entra en el
2853ArtifactHash**, así que una máquina sin el rootfs del lab no puede hashear nada. La caja de
2854producción no lo tiene — a propósito, no es un olvido.
2855
2856El daño era real y silencioso: el commit `45b5685a` dejó en `main` un `build-state*.json` que dice
2857que **el corpus entero no existe**. Cualquiera que lo lea —o cualquier vigía que lo compare— vería
28581112 recetas en cero.
2859
2860#### La lección, que vale más que el intento
2861
2862**Un hub no es «la máquina que tiene el repo y la llave»: es la que tiene el LAB.** El latido parece
2863tarea de cron —siembra, cosecha, commitea— pero su tercer paso es *pensar sobre el corpus*, y eso
2864exige el toolchain. Por eso la puerta 4 **no** era una línea de crontab, aunque todas las piezas que
2865se miraron (git, python3, rsync, ssh, takana, flock, `.fleet`, la llave) estuvieran.
2866
2867⇒ Las salidas son tres, y ninguna es «poner el cron»:
28681. **darle el lab a la caja** (`/.dev-fs/…`) — deja de ser sólo servidor y pasa a ser también hub;
28692. **partir el latido**: siembra+cosecha en la caja, regeneración del grafo donde haya lab;
28703. **que el hub sea otra máquina** (el LXC prestado ya tiene lab y muele 24/7).
2871
2872Es decisión del usuario, y es **lo único que queda entre esto y borrar gioser**.
2873
2874#### Vuelta atrás, en minutos
2875
2876Cron de la caja borrado, cron de gioser reactivado (estaba comentado, no borrado). El propio latido
2877de gioser **repara el daño solo** en su siguiente ciclo: regenera el grafo desde SU store —que sí
2878tiene el lab— y vuelve a publicar los números buenos.
2879
2880#### 🧰 Y de paso, tres cosas que el ciclo destapó en la caja
2881
2882- **`poda-fuentes.sh` no corre en takana**: `/dev/fd/63: No such file or directory` y
2883  `viejos: unbound variable`. Es **sustitución de procesos** (`<(...)`), que busybox `ash` no tiene —
2884  el cuarto tropiezo del día con la misma forma. El podador de `work/sources` es justo lo que un hub
2885  necesita para no llenarse.
2886- Los vigías corrieron y hablaron: **1 ofensor nuevo de raíces (`bzip2`)**, **3 herramientas
2887  selladas que no se pueden invocar**, y `servicios.txt` con 7 errores y 30 avisos. Son hallazgos del
2888  corpus, no de la mudanza, y quedan anotados donde el humano los mira.
2889- `arjectl status | head -2` **hace paniquear a arjectl** (`failed printing to stdout: Broken pipe`).
2890  No rompe nada, pero un `EPIPE` sin manejar en una herramienta de operación aparece justo cuando
2891  alguien la encadena.
2892
2893### 6.43 🧪 La caja ya es un HUB: el lab viajó, y con él la puerta 4 *(2026-09-17)*
2894
2895El §6.42 dejó dicho qué faltaba y el usuario eligió: **darle el lab a la caja**. Hecho, y el latido
2896ya late desde allá.
2897
2898#### El lab no se copia: se PINEA y se trae
2899
2900`scripts/lab-image.sh` empaqueta `.dev-fs/alpine` de forma **determinista** (`--sort=name
2901--mtime=@1 --owner=0 --group=0 --numeric-owner`, excluyendo `var/log/apk.log`, que es lo único que
2902difiere entre dos labs por lo demás iguales), lo publica al Storage Box con el sha en el nombre, y
2903del otro lado `--traer` **verifica el sha antes de extraer**. Eso es lo que hace que las dos máquinas
2904hasheen igual, y es un contrato, no una copia.
2905
2906**Y el empaquetado de hoy devolvió el sha que el repo ya pineaba:**
2907
2908```
2909  empaquetado hoy en gioser : d1e341d5dd434a11e2290b0340069ce4a83563eac8e4481f379b4951b1162045
2910  pineado en bootstrap-devfs.sh: d1e341d5dd434a11e2290b0340069ce4a83563eac8e4481f379b4951b1162045
2911```
2912
2913O sea dos cosas de una: **el lab de gioser nunca derivó** desde que se publicó esa imagen, y **el
2914empaquetado es reproducible de verdad** — no era una promesa del comentario.
2915
2916#### La prueba que decide, y no es que arranque
2917
2918Con el lab instalado, la caja calcula los MISMOS `ArtifactHash` que gioser:
2919
2920```
2921  zlib          b3:dc363f26…   busybox   b3:2a2b1280…
2922  caddy         b3:c915987d…   shuma-daemon b3:f9b92acc…
2923```
2924
2925Cuatro de cuatro. Si el lab fuera otro, estos números serían otros y el store no lo notaría — es el
2926agujero que `hash_inputs` cerró (§lab en el hash) y la razón por la que esto se verifica con hashes
2927y no con un «arrancó bien».
2928
2929#### Los stores, convergidos — y una trampa que me tendí solo
2930
2931El grafo seguía discrepando porque el store de la caja no tenía 24 artefactos vigentes de gioser
2932(3,0 G). Al copiarlos con `rsync -a --files-from=<lista de DIRECTORIOS>` pasó lo peor posible:
2933
2934```
2935  sent 2,281 bytes … total size is 0        ← no copió NADA
2936  artefactos en la caja: 1631 → 1655        ← pero creó los 24 DIRECTORIOS
2937```
2938
2939**`--files-from` no recursa sin `-r`**: creó 24 directorios vacíos en el store. Y un directorio vacío
2940en el store **es un cache-hit** (§3 de CLAUDE.md, [[artefacto-vacio-envenena-cache]]): `build` lo
2941habría dado por sellado y habría salido OK sin construir. Se detectó al contar, se borraron los 24 y
2942se repitió con `-r`: 3,18 GB transferidos, **0 vacíos de 1655**.
2943
2944Después faltaban dos más (`obs-studio` y `spectacle`, justo los que el worker no logra construir),
2945copiados también. Resultado:
2946
2947| | gioser | caja |
2948|---|---|---|
2949| corpus | sealed 929 · debt 2 | **sealed 930 · debt 1** |
2950| KDE | sealed 1105 · debt 2 | **sealed 1106 · debt 1** |
2951
2952La caja no empata: **gana por uno** en los dos grafos.
2953
2954#### El latido, mudado
2955
2956Cron apagado en gioser, encendido en la caja, y un ciclo completo corrido a mano para mirar lo que
2957publica: `==> repo al día (ff)` → `==> estado commiteado+pusheado`, con los números buenos. El
2958`build-state` que hay en `main` ahora lo escribió la caja.
2959
2960⚠ **Lo que queda apretado es el disco**: `/store` de la caja está al **91 %, 8,2 G libres**. Un hub
2961sella y cosecha, así que ése es el próximo muro — `scripts/store-gc.sh` es la herramienta y todavía
2962no se corrió allá.
2963
2964⇒ **puerta 4 en verde.** Con eso, de las ocho quedan sólo las dos a medias (5 y 7) y ninguna en rojo.
2965
2966### 6.44 🧹 Primer `store-gc` en la caja: 26,8 G — y dos bugs que sólo salen fuera de GNU *(2026-09-17)*
2967
2968El store estaba al **91 %** (8,2 G libres) justo cuando la caja pasó a ser hub, que es la peor
2969combinación: un hub sella y cosecha.
2970
2971```
2972  antes:  84,7 G usados · 8,2 G libres · 91 %  · 1657 artefactos
2973  después: 57,9 G usados · 35,0 G libres · 62 % · 1320 artefactos
2974```
2975
2976**337 artefactos superados borrados y verificados (0 sobrevivientes), 26,8 G.** El default del gc no
2977toca los **150 huérfanos** —`11,4 G`, los que son el único ejemplar de su nombre— y eso está bien:
2978borrarlos es la diferencia entre «se reconstruye en dos minutos» y «hay que rehacer la torre».
2979
2980#### El control que hay que correr DESPUÉS de un gc en esta caja, y que no es obvio
2981
2982**11 binarios de `/usr/bin` son symlinks que apuntan DENTRO del store** — los daemons que se
2983instalaron estos dos días. Un gc que borre el artefacto equivocado no rompe el store: rompe el
2984`/usr/bin` de una máquina en producción. Antes de `--aplicar` se comprobó que los 11 enlazan al hash
2985**vigente** (y por lo tanto están en el conjunto protegido); después, que los 11 siguen resolviendo y
2986que los 21 entes siguen corriendo. Los dos controles pasaron.
2987
2988⚠ Y la advertencia que el propio gc imprime y que en esa caja **no se puede satisfacer**: «sin
2989`.config` legible (ni `/proc/config.gz` ni `/boot/config-…`) ⇒ NO se protege ningún kernel por esta
2990vía». Con el default da igual; con `--huerfanos` en una máquina que arranca de su store, no.
2991
2992#### Los dos números que el gc no sabía decir
2993
2994Funcionó, pero sus dos cifras salieron vacías — y son las que uno mira para decidir si vale la pena:
2995
2996```
2997  ==> espacio: superados  · huérfanos
2998  ==> 337 artefactos borrados y VERIFICADOS · libres: Available → Available
2999```
3000
30011. **`du -sch --files0-from=-` es de GNU y busybox no lo tiene** ⇒ `espacio()` devolvía vacío. *Un
3002   número que falta se lee como un número chico.*
30032. **`df -h /home` estaba cableado** y el store no vive ahí: en gioser es un bind-mount del volumen,
3004   en la caja es `/store`. Sin `/home`, `tail -1` se quedó con la CABECERA — de ahí
3005   «Available → Available».
3006
3007Arreglados los dos… y el primer arreglo **también estaba mal**: `xargs -d '\n'` es otra extensión
3008GNU. Eso imprimió `huérfanos ?`, y ese `?` es deliberado: cuando el total no se puede calcular, la
3009función lo DICE en vez de escribir 0 — un cero inventado habría dicho «no hay nada que ganar» con
301011,4 G en huérfanos. Con `tr '\n' '\0' | xargs -0`, que busybox sí tiene, la caja ya contesta
3011**`espacio: superados 0 · huérfanos 11.4G`**.
3012
3013⇒ Van **cinco** tropiezos del mismo día con la misma forma (`/dev/tcp`, expansión de llaves,
3014`find -newermt`, sustitución de procesos, y estos dos). El patrón ya no es anécdota: **un script del
3015hub escrito en una máquina GNU no corre en la caja hasta que se prueba ahí.**
3016
3017### 6.45 🧯 `poda-fuentes` tampoco corría — y la causa no era el script: **la caja no tiene `/dev/fd`** *(2026-09-17)*
3018
3019El latido, desde la caja, moría así en su paso de poda:
3020
3021```
3022  poda-fuentes.sh: line 72: /dev/fd/63: No such file or directory
3023  poda-fuentes.sh: line 73: viejos: unbound variable
3024  ⚠ poda de fuentes falló (rc=1) — sigo
3025```
3026
3027El segundo error es consecuencia del primero (el `mapfile` nunca corrió) y el primero **no nombra la
3028causa**: la sustitución de procesos de bash (`<(...)`) necesita `/dev/fd`, y **una caja takana no lo
3029tiene**. gioser sí: `/dev/fd -> /proc/self/fd`. O sea que no era este script: **ningún `<(...)` de
3030ningún script del hub funcionaba allá**, y éste fue el primero en toparse.
3031
3032Por eso los arreglos son dos, y los dos hacían falta:
3033
30341. **En la caja**: `/dev/fd -> /proc/self/fd`, con una Card **OneShot** en el `genesis` para que
3035   sobreviva al reinicio (como la de `hostname`). Comprobado después:
3036   `bash -c 'cat <(echo funciona)'` responde. ⇒ *lo correcto a futuro es que lo haga el init o el
3037   product-rootfs, no una Card.* Van dos parches de esta forma; es una lista que conviene no alargar.
30382. **En el script**: fuera `<(...)` (fichero temporal) y fuera `df -B1 --output=avail`, que es GNU —
3039   con `df -k` + awk. Sin eso el script corría pero decía **`libres 0.0 G`**, porque el `df` fallaba
3040   en silencio y el 0 se lee como un disco lleno.
3041
3042Con las dos cosas, la poda hizo su trabajo por primera vez en la caja:
3043
3044```
3045   2 árbol(es) por encima del suelo · borrados 2 · libres 46.1 G · LIBERADO 5.4 G
3046```
3047
3048⇒ **En total, 32,2 G recuperados hoy en la caja**: 26,8 G de artefactos superados (§6.44) y 5,4 G de
3049árboles de fuentes. `/store` al 62 %, `/work` al 29 %.
3050
3051### 6.46 🚪 Puerta 5 verde: la caja publica su repo firmado **y se instala de él** *(2026-09-17)*
3052
3053La puerta 5 estaba a medias desde el §5.4 y el motivo era el de siempre: *el host no se puede
3054actualizar de sí mismo porque `install` reproduce desde fuente y eso exige el LAB*. Con el lab ya
3055instalado (§6.43), se cerró en dos movimientos, los dos corridos **en la caja**:
3056
3057**1. Publicar** — con su propia clave de release, la que se mudó el 2026-09-16 a
3058`/root/.config/takana/keys/`:
3059
3060```
3061  ==> ✓ el índice verifica contra ./trust
3062  publicado: 171 con expected_hash anclado, 3 sin ancla. FALLARON: os-release
3063```
3064
3065**2. Instalar de ese repo, en modo estricto** (`--require-signed`, que exige autoría del catálogo
3066verificada y no se conforma con que el `.swm` reproduzca):
3067
3068```
3069  release: trusted (by release)
3070  install duf 0.9.1 ← dist/repo/duf-0.9.1.tkn
3071  deps: resolviendo catálogo para 1 dep(s): go
3072  caché: artefacto ya en el store hash=b3:abb3527d… name=duf
3073  apply OK — source_patch=1 config_edit=0 init_rule=0 file_drop=0
3074  registrado duf (2 fichero(s))
3075```
3076
3077Se instaló bajo `--prefix /tmp/prueba-install` y con `--db` propio, para no tocar el sistema.
3078
3079⚠ **Y el matiz honesto, que vale más que el verde**: el artefacto **ya estaba en el store**, así que
3080el camino que se ejercitó fue *resolver + verificar firma + hidratar*, **no** *reproducir desde
3081fuente*. Lo que el lab destrabó y sí quedó probado aparte es el **hash** (§6.43: cuatro de cuatro
3082iguales a gioser), que era exactamente donde moría antes —«no pude leer el apk db del lab»—. Queda
3083sin ejercitar el caso «paquete que NO está en el store», que en una caja de producción además es
3084discutible que se quiera: un servidor no tiene por qué compilar.
3085
3086⇒ `os-release` es la única receta del perfil que no se pudo publicar; es la que lleva el logo y la
3087identidad de la distro, y su fallo queda anotado para mirarlo aparte.
3088
3089### 6.47 🚪 Las ocho, al cierre del 2026-09-17
3090
3091| # | puerta | estado |
3092|---|---|---|
3093| 1 | arranca takana puro, se entra por SSH | ✅ |
3094| 2 | el store está entero | ✅ **1320 artefactos, 0 vacíos** tras el gc · `/store` al 62 % |
3095| 3 | los cuatro grafos idénticos | ✅ |
3096| 4 | **la granja late en la caja** | ✅ **§6.43** — con el lab pineado; la caja gana por uno en los dos grafos |
3097| 5 | **el repo se sirve y el host se instala de él** | ✅ **§6.46** — 171 paquetes publicados y `install --require-signed` en verde |
3098| 6 | cada dominio vivo responde 200 | ✅ los 8 |
3099| 7 | el respaldo corre desde la caja | ✅ **§6.48** — corrida completa, 0 errores, y **0 artefactos de la caja sin respaldar** |
3100| 8 | lo fósil, anotado y decidido | ✅ |
3101
3102⇒ **LAS OCHO EN VERDE** (§6.48 cerró la última). Lo que separa a esto del borrado de gioser ya no es
3103técnico: es la decisión del usuario, que es como el §6 dice que tiene que ser — a mano, con las ocho
3104en verde, **nunca por automatización**.
3105
3106Y lo que queda vivo en gioser es el trabajo de la mudanza, no servicios: los ~10 G de árboles
3107personales de `/home` sin decidir (§6.35) y el montón de `[[datos]]` que el censo dejó sin decisión.
3108
3109### 6.48 ✅ Puerta 7: el respaldo corre desde la caja — y el parcial que la envenenaba *(2026-09-17)*
3110
3111```
3112  respaldados (manifiesto del Storage Box) : 3686
3113  en el store de la caja                   : 1320
3114  de la caja, NO respaldados               : 0        ← la cuenta que decide
3115  errores y reintentos en la corrida       : 0 y 0
3116```
3117
3118**Las ocho puertas quedan en verde.**
3119
3120#### ⚠ Pero la primera corrida NO terminó, y el motivo vale por sí solo
3121
3122Se cayó **40 veces seguidas con el MISMO fichero**:
3123
3124```
3125  open ".../fonts/dejavu/.rsync-partial/DejaVuSans-Oblique.ttf" failed: No such file… (2)
3126  ..  store: corte de red (rsync 23). Intento 39; reanudo en 60s.
3127  !!  store: 40 intentos y sigue cayéndose. Dejo lo subido y paro.
3128```
3129
3130No era red. Una transferencia interrumpida **el 11 de septiembre** había dejado ese parcial en el
3131destino con el modo del origen —`-r--r--r--`, porque el store es de sólo lectura por diseño— y
3132**ningún intento posterior puede reescribirlo**. El fallo es ETERNO: esa ruta quedaba envenenada
3133para siempre, y el bucle la golpeaba cada 60 segundos llamándola «corte de red». Es exactamente la
3134pared que la cabecera de esa función dice que no hay que golpear cuarenta veces.
3135
3136Dos arreglos, y el segundo importa más que el primero:
3137
31381. **`--chmod=Fu+w`** en el rsync: los ficheros del respaldo quedan escribibles por su dueño, así que
3139   un corte a mitad se RETOMA en vez de trabarse. El precio —no conservar el bit de sólo-lectura en
3140   la copia— es bajo: lo que se respalda es el contenido, y los permisos los reconstruye el store.
31412. **Al `rsync 23` se le dan TRES intentos, no cuarenta**, y al tercero se NOMBRA la causa probable.
3142   El 23 es «some files were not transferred», que tanto puede ser un cable como una pared; tratarlo
3143   siempre como cable es lo que convirtió un fallo permanente en cuarenta minutos de ruido.
3144
3145#### 🪤 Y dos veces el mismo error mío: `pgrep -f <patrón>` se encuentra a SÍ MISMO
3146
3147Para saber si el respaldo seguía vivo usé `pgrep -f respaldo-storagebox`… y el patrón está en la
3148línea de comando del propio `pgrep` (y del `ssh` que lo lanza). Resultado: **decía «sigue corriendo»
3149para siempre**, incluso con el script muerto hacía rato — y el vigía que armé con esa condición
3150nunca podía dispararse. Pasó dos veces en la misma tarde, en las dos máquinas.
3151
3152La forma correcta es el truco del corchete —`ps ... | grep "[r]espaldo-storagebox"`—, que no coincide
3153consigo mismo porque el patrón literal `[r]espaldo` no aparece en la línea del grep. Es hermano del
3154`pkill -f` que mata tu propia shell, ya anotado en las memorias.
3155
3156### 6.49 📋 Lo único que queda: 150 decisiones sobre datos *(2026-09-17)*
3157
3158Con las ocho puertas en verde, de la mudanza no queda trabajo técnico: queda **decidir**. La hoja
3159está en `docs/state/mudanza-decisiones-pendientes.txt` — 150 entradas, **31,9 G**, ordenadas por
3160tamaño, con lo que la herramienta se anima a sugerir por un HECHO entre corchetes y **en blanco lo
3161que sólo sabe el dueño**:
3162
3163```
3164  /home/sergio/.claude         6.8G  [     ]  lo más nuevo: 2026-09-16
3165  /home/sergio/.local          6.2G  [muda ]  lo usa act_runner, tejido, thasnuna
3166  /var/lib/swap                6.1G  [muere]  (es swap)
3167  /home/sergio/.cache          4.3G  [muere]  caché
3168  /var/lib/gitea               1.9G  [     ]  ← ya mudado en el §6.14; la entrada es un fósil del censo
3169  /home/sergio/imm             200M  [     ]  lo más nuevo: 2026-03-23
3170```
3171
3172Se aplica en una pasada con `planear.py --decidir`, que ya no necesita teclas unitarias (§3 bis).
3173
3174⚠ Y la hoja **dice qué no cubre**, porque una lista titulada «lo que falta» que omite 329 G se lee
3175mal: no están `/mnt/vvv` —el monorepo, este repo, el store, `rustup`— ni los servicios y dominios.
3176Lo de `/mnt/vvv` no necesita decisión: **el store está respaldado (puerta 7) y los repos viven en el
3177gitea de la caja**.
3178
3179#### Lo último que se rescató antes de cerrar
3180
3181`/mnt/vvv/gioserv` tenía **26 entradas sin commitear** y su remoto era
3182`/var/lib/gitea/repositories/sergio/gioserv.git` — una ruta LOCAL de la máquina que se borra. Lo
3183commiteado está a salvo (su `HEAD` es el mismo que el del gitea nuevo); lo que no estaba en ningún
3184lado —el `git diff HEAD` completo (`api/main.py`, +92/−23, y 25 borrados) más los 4 no rastreados—
3185quedó en `takana:/work/mudanza-secretos/gioserv-pendiente/`, verificado: **0 ficheros del origen
3186faltan en el destino**.
3187
3188*No se reapuntó el remoto ni se commiteó nada*: cambiar el remoto de un clon ajeno y decidir si ese
3189`api/main.py` se publica es del dueño del repo, no de la mudanza. El `LEEME.txt` del paquete lleva el
3190comando exacto para reapuntarlo.
3191
3192### 6.50 🤖 El agente corre en la caja — y puede construir *(2026-09-17)*
3193
3194Pedido del usuario, y es la condición que él mismo puso para borrar gioser: *«no borramos gioser
3195hasta tenerte corriendo de aquel lado, con capacidad de compilar tawasuyu y las recetas de takana»*.
3196
3197```
3198  $ claude -p "Decí exactamente: CLAUDE CORRIENDO EN LA CAJA"
3199  CLAUDE CORRIENDO EN LA CAJA
3200```
3201
3202Eso es un turno REAL —API, credenciales, respuesta— desde `2.29.29.217`.
3203
3204#### Por qué va enjaulado, y qué se le concedió
3205
3206El CLI es un **ELF glibc de 360 M** (`interpreter /lib64/ld-linux-x86-64.so.2`, `NEEDED libc.so.6`)
3207y la caja es musl: no hay camino nativo. Es el caso del ADR 0015, el mismo de `sergioh-api`. La
3208instancia `claude` declara, y **conviene decir que es ANCHA en vez de disimularlo**: red, `nesting`,
3209y seis directorios —`/opt/takana` (el trabajo), `/work/dev-fs` (lab + zig/go), `/store`,
3210`/root/.claude` (memoria y credenciales), `/root/.ssh` (ro) y el CLI (ro)—. Un agente que construye,
3211commitea y empuja necesita lo mismo que un humano haciendo ese trabajo; lo que la jaula compra es
3212que ese alcance esté **escrito y acotado**, no que sea pequeño.
3213
3214⚠ El `takana` que construye **no se instala adentro**: sale del store, que ya está concedido. Es
3215estático musl y corre igual dentro de una imagen glibc — comprobado (`takana 0.0.1`).
3216
3217#### La caja construye: dos recetas selladas hoy
3218
3219Antes faltaba una pieza que el lab no traía: **`.dev-fs/tools`** (zig 0.13/0.16 y go, 1 G). El
3220primer intento murió con `zig no encontrado en .dev-fs/tools/zig/zig` — la imagen del lab empaqueta
3221`alpine/` y **nada más**. Copiado eso:
3222
3223```
3224  intel-ucode   → sellado: 156 signatures + GenuineIntel.bin (17 MB)
3225  sof-firmware  → sellado: 2 firmware + 8 topologías (IPC3 v2.2, TigerLake)
3226```
3227
3228#### 🧱 Lo que NO funciona, y su rodeo
3229
3230**`takana build` NO corre DENTRO de la jaula**, aunque la instancia declare `nesting = true`:
3231
3232```
3233  bwrap: Can't mount proc on /proc: Operation not permitted
3234```
3235
3236Y no es que el anidamiento esté roto: **un `bwrap --dev-bind / / --proc /proc` a mano SÍ funciona
3237adentro**, con y sin `--unshare-pid`. Lo que falla es el userns anidado que arma el sandbox del
3238build. Probado también con `root = false` —la sospecha que el propio código documenta (§`grants.root`
3239⚠ del 2026-09-03)— y falla igual: no es el remapeo a root.
3240
3241El rodeo que sí anda, y quedó escrito en el lanzador para que el agente lo use:
3242
3243```sh
3244  ssh -p 22022 -i /root/.ssh/github5 root@127.0.0.1 \
3245    'cd /opt/takana && flock -o work/.farm-build.lock takana --store /store build <receta>'
3246```
3247
3248Con eso, **desde dentro de la jaula se selló `sof-firmware`**. El agente vive enjaulado y le pide
3249los builds al anfitrión, que es donde el sandbox del build tiene los permisos que necesita.
3250
3251#### Cómo se entra
3252
3253`claude` en el PATH de la caja (`/usr/bin/claude`), que envuelve
3254`takana qorpa run claude -- env HOME=/root …`. El manifiesto y el lanzador están versionados en
3255`scripts/servidor/`, porque una instancia que sólo existe en la caja se pierde con la caja.
3256
3257⚠ Pendiente menor: la memoria del agente está en `/root/.claude/projects/-mnt-vvv-takana/`, y allá el
3258repo vive en `/opt/takana` ⇒ **el índice por ruta no coincide**. Se resuelve renombrando el
3259directorio a `-opt-takana` o dándole al repo la misma ruta; hasta entonces el agente arranca sin su
3260memoria del proyecto aunque los 733 ficheros estén en disco.
3261
3262### 6.50 bis 🔑 La cuenta necesitaba sus LLAVES, no su contraseña *(2026-09-17)*
3263
3264Creada la cuenta, el primer intento del usuario falló:
3265
3266```
3267  $ ssh -i ~/.ssh/tawasuyu sergio@gioser.net -p 2345
3268  sergio@gioser.net: Permission denied (publickey).
3269```
3270
3271Y era exacto: **el `sshd` de la caja tiene `PasswordAuthentication no`**, así que la contraseña
3272`tawasuyu` sirve para `su` y para la consola, **no para entrar por SSH**. Lo que faltaba era su
3273`~/.ssh/authorized_keys`, que se creó vacío con la cuenta. Copiado el de gioser —dos llaves,
3274`tawasuyu` y `sergio@tatata`— entra:
3275
3276```
3277  $ ssh -i ~/.ssh/tawasuyu sergio@gioser.net -p 2345
3278  ENTRA como sergio en takana · /home/sergio
3279  $ claude -p "…"   →  ENTRÉ POR SSH Y ABRÍ CLAUDE
3280```
3281
3282Dos detalles que hacen que esto se sienta como la máquina de siempre: **`gioser.net` ya resuelve a la
3283caja**, y **la llave de host es la MISMA que la de gioser** (se copiaron en el cutover del gitea,
3284§6.25) — comprobado: `SHA256:ltSl+rgr2Uc…` en las dos. Por eso no aparece el aviso rojo de
3285«REMOTE HOST IDENTIFICATION HAS CHANGED» al entrar al nombre de siempre.
3286
3287⚠ El `sshd` escucha en **22022 y 2345**, los dos generales: `2345` es el puerto que los clones de
3288git ya usaban, y sirve igual para entrar.
3289
3290### 6.50 ter 🧰 `sudo` y el taller: dónde se programa en la caja *(2026-09-17)*
3291
3292**`sudo` no andaba por dos cosas a la vez, y una era el corte de energía.** `/usr/bin/sudo` **no
3293tenía el bit setuid** (`sudo: must be owned by uid 0 and have the setuid bit set`), y además
3294`/etc/sudoers.d/sergio` **había desaparecido**: lo escribí minutos antes del `reset` del §6.51 y se
3295fue con el mismo corte que dejó el cortafuegos en NULs. Repuestos los dos —y con `sync`—,
3296`sudo id` devuelve `uid=0(root)`. `visudo` no está instalado, pero **no hace falta**: el fichero
3297suelto en `/etc/sudoers.d/` es la forma correcta, y `sudo` lo valida solo.
3298
3299⚠ `doas` sigue dando `Operation not permitted` aun estando setuid. No hizo falta perseguirlo —`sudo`
3300resolvió— pero queda anotado como rareza sin explicar.
3301
3302#### Dónde están los repositorios
3303
3304**Los 44 están en la caja**, en el gitea que se mudó el 14-sep: `/var/lib/gitea/repositories/`
3305(`sergio/` 939 M, `tawasuyu/` 547 M). Lo que NO había era un **clon de trabajo**: sólo `/opt/takana`,
3306que es el del hub.
3307
3308```
3309  ~/src  →  /work/sergio          ← el taller
3310  /work/sergio/tawasuyu  478 M    ← clonado, origin al gitea de la caja
3311```
3312
3313⚠ **El taller va en `/work`, no en el home.** La raíz de esta caja tiene **2,4 G libres** y `/work`
3314**43 G**: un clon del monorepo en `/home` llena la raíz y tumba la máquina.
3315
3316⚠ **Y clonar acá tiene dos trampas medidas:**
33171. **El `git` del corpus no tiene `remote-https`** (`git: 'remote-https' is not a git command`), así
3318   que **no se puede clonar por HTTPS** desde la propia caja. Es la deuda ya anotada del §bloqueantes.
33192. Clonar por **SSH** (`ssh://gitea@git.gioser.net:2345/…`) exige que la llave esté registrada **en
3320   la cuenta de gitea**, no basta con el `authorized_keys` del sistema. Hasta que se registre, el
3321   camino es el **sistema de ficheros** (`git clone /var/lib/gitea/repositories/<org>/<repo>.git`) —
3322   con dos detalles: el directorio de cada organización es `0700` del usuario `gitea`, así que hay
3323   que clonar como root y `chown` después; y git rechaza el repo por `dubious ownership` hasta que se
3324   le dice `git config --global --add safe.directory "*"`.
3325
3326Y el agente se abre sobre cualquiera de ellos:
3327
3328```
3329  PROY=/work/sergio/tawasuyu claude
3330  → «Es tawasuyu (/work/sergio/tawasuyu, origin git.gioser.net:2345/…), último commit 55e5f0f27»
3331```
3332
3333### 6.50 quater 📦 Cómo se instala software de tawasuyu en la caja — y por qué NO con `actualizar-servidor.sh` *(2026-09-17)*
3334
3335Pregunta del usuario: *«¿ahí tengo la confianza de `install-tawasuyu` y `actualizar-servidor`? ¿o
3336cómo es la forma de instalar, por ejemplo, `shuma-tui`?»*
3337
3338**Esos guiones existen y siguen ahí** —el monorepo está clonado en `/work/sergio/tawasuyu`— pero lo
3339que hacen es:
3340
3341```
3342  cargo build --release <crates>        …y después…        install -m755 <nuevo> /usr/local/bin/<x>
3343```
3344
3345O sea: **producen exactamente la clase de binario que la mudanza encontró irreproducible** — 2,7 G
3346de sueltos que ningún paquete provee (§6.35), de los cuales tres corrían desde un inodo BORRADO.
3347Además `actualizar-servidor.sh` escribe servicios en `/etc/init.d/`, que en esta caja no existe: el
3348init es arje.
3349
3350#### La forma de acá, hecha de punta a punta con `shuma-tui`
3351
3352```
3353  1. receta      recipes/shuma-tui.toml   — pinneada al commit del monorepo, zig-cc, musl, estática
3354  2. build       takana build             ⇒ b3:f795ee0f… sellado en el store
3355  3. viaja       worker → hub → caja      (rsync del artefacto, que es content-addressed)
3356  4. instalar    ln -s /store/<hash>-shuma-tui/usr/bin/shuma-tui /usr/bin/
3357  5. control     shuma-tui --help  →  contesta
3358```
3359
3360Los pasos 3 y 4 son el atajo honesto de hoy; el camino completo es el del §6.46 —publicar en el repo
3361firmado y `takana install <nombre> --require-signed`—, que ya está probado y es lo que convierte
3362«copiar un binario» en «instalar un paquete».
3363
3364⚠ **El paso 2 NO se puede hacer en la caja todavía**, y el motivo es la deuda ya conocida: su `git`
3365**no tiene `remote-https`**.
3366
3367```
3368  git: 'remote-https' is not a git command
3369  Error: git ["clone","--mirror",…,"https://git.tawasuyu.net/…"] falló
3370```
3371
3372Las recetas del corpus que bajan tarballs con `curl` **sí** construyen ahí (hoy se sellaron
3373`intel-ucode` y `sof-firmware`); las que clonan por HTTPS —todas las de tawasuyu— hay que
3374construirlas en el worker, que sí tiene git completo. Arreglar eso es re-sellar `git` con libcurl, y
3375es raíz de `perfil.base`: unidad propia.
3376
3377### 6.50 quinquies 🔑 Las fuentes git se traen por SSH — y la raíz de 5,9 G tiró la caja entera *(2026-09-17)*
3378
3379El §6.50 quater dejó anotado el muro: la caja no tiene `git-remote-http`, así que las 36 recetas que
3380clonan de `git.tawasuyu.net` —y las ~90 de GitHub— no se podían construir ahí. La salida es la que
3381pidió el usuario: **usar SSH**. Y sale gratis en hashes, porque **la URL es un LOCALIZADOR y no entra
3382en `hash_inputs`** (ADR 0013): el mismo commit por otro transporte sella el mismo artefacto.
3383
3384No se toca ninguna receta —el worker sigue clonando por HTTPS, que es lo que puede— sino la caja:
3385
3386```
3387  git config --global url."ssh://gitea@git.gioser.net:2345/".insteadOf "https://git.tawasuyu.net/"
3388  git config --global url."ssh://git@github.com/".insteadOf        "https://github.com/"
3389```
3390
3391Tres cosas que costaron una vuelta cada una, las tres MEDIDAS:
3392
33931. **`takana` clona bien, pero `cargo vendor` clona aparte.** cargo honra el `insteadOf`, pero usa su
3394   propio cliente SSH: primero se plantó por la clave del host —la tenía como `[git.gioser.net]:2345`
3395   y cargo la busca **sin puerto**, misma clave reexpresada— y después por autenticación, porque no
3396   sabe leer `IdentityFile` de `~/.ssh/config`. Se resuelve delegando en el git de verdad:
3397
3398   ```toml
3399   # ~/.cargo/config.toml
3400   [net]
3401   git-fetch-with-cli = true
3402   ```
3403
34042. **La huella de `github.com` se PINEA, no se acepta a ciegas.** `ssh-keyscan` dio
3405   `SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU`, idéntica a la que GitHub publica, y **por eso**
3406   entró a `known_hosts`. La clave de esta caja autentica como deploy key de otro repo y aun así lee
3407   repos públicos ajenos: comprobado con `uutils/coreutils`.
3408
34093. **Y la que dolió: la raíz son 5,9 G y `cargo` la llenó.** `CARGO_HOME` estaba en `/root/.cargo`, o
3410   sea en `sda2`. El vendor del monorepo bajó 2,7 G, la raíz llegó al **100 %**, y la caja **no se
3411   degradó: se cayó entera** — sin HTTP, sin SSH y **sin responder al ping**, con Hetzner informando
3412   `running`. Hubo que entrar por rescate otra vez.
3413
3414   Lo que el disco lleno deja como rastro, y conviene saber leer:
3415
3416   ```
3417     caddy   → «write error: write /var/log/caddy/requests.json: no space left on device»
3418     mis     → /root/.ssh/config quedó con 105 BYTES NULOS al final: el append entró a medias
3419     escrituras  (el mismo daño que el corte de luz le hizo al reglaset del cortafuegos, §6.51)
3420   ```
3421
3422   **Un `>>` que falla por ENOSPC no deja el fichero como estaba: lo deja extendido y relleno de
3423   ceros.** Es exactamente la clase de avería que el §3 de `CLAUDE.md` describe para los artefactos
3424   vacíos — llega hasta el final diciendo que todo fue bien.
3425
3426**Reparado en el rescate, y las tres piezas son permanentes:**
3427
3428| qué | dónde | por qué |
3429|---|---|---|
3430| `/root/.cargo` → `/work/cargo-home` | symlink | 2,7 G de caché no caben en una raíz de 5,9 G |
3431| poda horaria de logs | `/usr/local/sbin/poda-logs.sh` + cron `17 * * * *` | `ente-gitea.log` había llegado **solo** a 114 M y **nadie lo rotaba** |
3432| `.ssh/config` y `.gitconfig` | reescritos | tenían NULs de las escrituras a medias |
3433
3434Tras el arranque: raíz al **54 %**, los 18 entes corriendo y **los 7 dominios de la caja en 200**
3435(`terapeuta.ec` sigue en gioser a propósito — se decidió respaldar, no mudar).
3436
3437> **La lección, que no es sobre cargo.** La raíz de esta caja es **5,9 G** y todo lo que crece sin
3438> dueño vive ahí: logs, cachés, spool. No hay margen para «ya lo limpio después», y el modo de fallo
3439> no es un error: es la máquina entera apagándose en silencio. Cualquier cosa que acumule bytes en la
3440> caja **se declara dónde vive antes de correrla por primera vez**.
3441
3442#### El control que cierra esto: la caja construyó una receta de tawasuyu de punta a punta
3443
3444`puriy-costura` era la única de las 36 que **no** estaba en el store de la caja — o sea, un fallo de
3445caché de verdad, no un cache-hit disfrazado de éxito ([[cache-congela-regresiones]] es justo esto).
3446La caja la hizo entera y sola: clonó por SSH (111,7 M de mirror), vendoreó, compiló y selló.
3447
3448```
3449  hash que predice el HUB  b3:7d63655aa5fea3cbc08724887617d7f58b325fcb2e00ac050b1937966c2459e6
3450  hash que selló la CAJA   b3:7d63655aa5fea3cbc08724887617d7f58b325fcb2e00ac050b1937966c2459e6
3451  contenido                2,2 M · usr/bin/puriy-costura · corre y muestra su ayuda
3452```
3453
3454**Idéntico**, que es lo único que prueba que el transporte no contamina el artefacto. Con esto la
3455caja cumple la condición que puso el usuario para poder borrar gioser —«con capacidad de compilar
3456tawasuyu y las recetas de takana»— **desde fuente y por su cuenta**, no por artefactos que le llegan
3457del worker.
3458
3459El guion `scripts/servidor/fuentes-por-ssh.sh` deja esto reproducible: idempotente, con la huella de
3460github pineada (si el host anuncia otra, **sale con error en vez de aceptarla**) y con un control
3461final que CLONA de los dos orígenes. Probados sus dos negativos: un repo inexistente lo hace fallar,
3462y una huella cambiada la rechaza.
3463
3464### 6.50 sexies ✅ Pagada la deuda: el `git` del corpus vuelve a clonar por HTTPS *(2026-09-17)*
3465
3466El §6.50 quinquies dejó la caja trayendo fuentes por SSH porque su `git` no tenía `remote-https`.
3467Eso era el síntoma; **la causa estaba un paso antes de donde todos mirábamos** —incluida la nota que
3468yo mismo había escrito, que culpaba al Makefile—.
3469
3470El tarball de git trae `configure`, y takana lo corre. Su test de libcurl es `AC_CHECK_LIB`, que
3471enlaza `conftest.c -lcurl` **y nada más**. Con libcurl **estática** eso no resuelve: faltan
3472`-lssl -lcrypto -lz`, que son sus privadas.
3473
3474```
3475  checking for SHA1_Init in -lcrypto...       yes
3476  checking for curl_global_init in -lcurl...  no      ← acá se decidía todo
3477  checking for XML_ParserCreate in -lexpat... no
3478```
3479
3480El test falla, `config.mak.autogen` se lleva `NO_CURL=YesPlease`, el Makefile excluye
3481`git-remote-http`, `git-http-fetch` y `git-http-push` —**y conserva `git-http-backend`, que es el
3482lado servidor y no usa curl**: esa asimetría en el artefacto era la firma del fallo, y estuvo a la
3483vista desde el 2026-09-14—. El build **termina en verde**. Es el §3 de `CLAUDE.md` en su forma más
3484cara: un ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien.
3485
3486**La cura, tres líneas en la receta**, cada una con su motivo:
3487
3488| línea | por qué |
3489|---|---|
3490| `LIBS="$(curl-config --libs)"` al `configure` | le da al test lo que el enlace estático necesita — y sale de `curl-config`, no escrito a mano: si curl gana privadas mañana, las trae solo |
3491| guardián sobre `config.mak.autogen` | el modo de fallo es **silencioso** ⇒ se comprueba el HECHO (`NO_CURL=YesPlease`) y se corta, en vez de enterarse meses después |
3492| `NO_EXPAT=1` | con curl encendido el Makefile agrega `http-push.o`, que pide `-lexpat`, y expat no está en deps. `http-push` es el push por WebDAV: clonar y traer no pasa por ahí |
3493
3494**Medido sobre el artefacto, nunca sobre el `git` del host** (`ls $(git --exec-path) | grep remote-http`):
3495`b3:215742cb…` trae `git-remote-http`, `git-remote-https`, `git-http-fetch` y los `ftp`. Y **clona**
3496—no `ls-remote`: `clone`, que es donde murió la vez del ubsan—: árbol en disco y `rev-list` contesta.
3497En la caja, hidratado y con la reescritura a SSH **apagada**, `git clone https://…` funciona, y
3498`git clone --mirror` —el comando exacto que usa `takana build` para traer fuentes— también.
3499
3500> **El radio, medido en vez de temido.** La nota vieja decía que arreglar esto «re-sella `git`, que es
3501> raíz de `perfil.base` ⇒ radio grande». `yupana radio git` dice otra cosa: **cero** dependientes
3502> directos y **cero** transitivos. Toca las 7 imágenes que lo incluyen —que es re-ensamblar, no
3503> reconstruir—. El miedo escrito a mano había sobrestimado el costo de arreglarlo, y eso lo mantuvo
3504> roto tres días.
3505
3506Las reescrituras `url.insteadOf` quedaron **retiradas** de la caja: ya no hacen falta.
3507`scripts/servidor/fuentes-por-ssh.sh` se conserva como salida de emergencia, no como el camino normal.
3508
3509### 6.52 🧩 Las proc-macros SÍ se pueden compilar en la caja — falta un `cc`, no un parche *(2026-09-18)*
3510
3511Con `rust` sellado, la caja compila y corre binarios **estáticos** sin tocar nada. Lo que no compilaba
3512es cualquier **dylib**, y eso incluye **todas las proc-macros** (`serde_derive`, `clap_derive`…), o sea
3513media ecología de Rust:
3514
3515```
3516  ld.lld: error: unable to find library -lgcc_s
3517  ld.lld: error: unable to find library -lc
3518```
3519
3520#### Por qué el `-L` obvio empeora las cosas
3521
3522La reacción natural es darle el camino: `-L /usr/lib`. **Y eso rompe los estáticos**, que hoy
3523funcionan. Cuatro controles lo aíslan:
3524
3525| | enlace | resultado |
3526|---|---|---|
3527| A | estático, sin `-L` | ✅ enlaza — es lo que hay hoy |
3528| B | estático, con `libgcc.a` a la vista | ✅ enlaza — el arreglo de `gcc-libs` no rompe nada |
3529| C | estático, con `-L /usr/lib` + `libgcc.a` | ❌ rompe |
3530| D | estático, **sólo** con `-L /usr/lib` | ❌ rompe ← la culpable |
3531
3532```
3533  ld.lld: error: relocation R_X86_64_32S cannot be used against symbol '__malloc_size_classes';
3534          recompile with -fPIC            ← sale de /usr/lib/libc.a, que NO es PIC
3535```
3536
3537rustc enlaza musl **static-PIE** con su copia *self-contained*; meter `/usr/lib` en el camino le hace
3538preferir la `libc.a` del sistema, que no sirve para PIE. **El camino de librerías no puede ser
3539global: el estático lo quiere fuera y el dinámico lo quiere dentro.** Ningún `RUSTFLAGS` global
3540arregla esto — apaga un caso y enciende el otro.
3541
3542#### Lo que sí funciona, medido de punta a punta
3543
3544Quien sabe distinguir los dos casos es un **driver de C**. La caja no tiene `gcc` ni `clang`
3545—`clang18` en el store son sólo cabeceras y librerías, sin binario— **pero sí tiene `zig`**: el
3546artefacto `seed-zig` del bootstrap trae `zig 0.16.0` y **corre**. `zig cc` es un driver de C completo
3547con musl adentro.
3548
3549```
3550  cc        →  zig cc
3551  RUSTFLAGS →  -C linker=cc -C linker-flavor=gcc -C link-self-contained=no
3552```
3553
3554Las tres banderas hacen falta y cada una por su motivo: `linker`+`linker-flavor` para que el enlace
3555pase por el driver, y `link-self-contained=no` porque si no rustc pide su propio `rust-lld` —que este
3556toolchain **no tiene**, ya que `rust.lld = true` es incompatible con el `llvm-config` externo (§ la
3557receta lo documenta)— y muere con *«the self-contained linker was requested… it wasn't found»*.
3558⚠ La variante fina `-C link-self-contained=-linker` **no sirve**: es inestable en este triple y exige
3559`-Z unstable-options`. La gruesa, `=no`, es estable.
3560
3561Con eso, la cadena completa que nunca había compilado:
3562
3563```
3564  1. la proc-macro            ⇒ libpm.so
3565  2. el programa que la usa   ⇒ imprime «PROC-MACRO VIVA»
3566  3. y el estático de siempre ⇒ sigue enlazando
3567```
3568
3569#### Entregado: `scripts/servidor/cc-por-zig.sh`
3570
3571El envoltorio existe y está puesto en la caja. **No mete nada nuevo en el corpus**: usa el `seed-zig`
3572que YA está en el store —producto del bootstrap (SDD 11), como `stage1-rootfs`— y sólo lo envuelve en
3573`/usr/bin/cc` y `/usr/bin/c++`, más las tres banderas en `/etc/profile.d/`. Volver a zig el
3574compilador de C **oficial** de la distro es otra cosa —ahí sí haría falta receta y pin propios— y es
3575decisión de ADR, no de un guion de puesta a punto.
3576
3577Es idempotente y sus cuatro controles preguntan por el HECHO:
3578
3579```
3580  ✓ C: C en takana                                  ← compila y EJECUTA un hola mundo
3581  ✓ rust: proc-macro: 42                            ← la macro enlaza, el que la usa corre
3582  ✓ rust estático (sin banderas): estático ok       ← el remedio no rompió lo que funcionaba
3583  ✓ entorno: una shell de login trae RUSTFLAGS puesto
3584```
3585
3586Ese último control encontró algo de paso, y es la clase de cosa que este repo persigue: **en la caja
3587`/etc/profile.d` no lo leía NADIE**, porque `/etc/profile` no existía. El fichero de las banderas
3588habría quedado presente, con contenido y sin ningún efecto — y el `bash_completion.sh` que ya estaba
3589ahí llevaba quién sabe cuánto igual de mudo. Se creó el `/etc/profile` con el bucle estándar. La
3590caché de `zig cc` vive en `/work/zig-cache` (54 M) y **no** en la raíz de 5,9 G, por lo aprendido en
3591§6.50 quinquies.
3592
3593### 6.53 🔧 Borré `/usr/bin/id` probando el setuid *(2026-09-18)*
3594
3595Para saber si el bit setuid elevaba en esta caja copié `busybox` sobre **`/usr/bin/id`** —porque
3596busybox elige el applet por `argv[0]` y necesitaba un nombre válido— y después borré «mis» copias de
3597prueba. Una de ellas **era el `id` del sistema**. Resultado, del lado del usuario:
3598
3599```
3600  $ claude --dangerously-skip-permissions
3601  /usr/bin/claude: line 23: id: not found
3602  $ id
3603  -bash: /usr/bin/id: No such file or directory
3604```
3605
3606No quedaba ninguno: `/bin/id` tampoco existía. Se restauró como sus hermanos de identidad —`whoami`
3607y `groups` apuntan a `../../bin/busybox`— y se comprobó con `id` de root, de `sergio` y con el
3608`id -un` que usa el lanzador. Barrido posterior: **0 enlaces colgados** en `/bin`, `/usr/bin`,
3609`/sbin` y `/usr/sbin`, y presentes los ocho comandos de los que dependen el arranque y el lanzador.
3610
3611> **La lección, que es de método:** para probar una propiedad del sistema —¿eleva el setuid?— **no se
3612> usa un nombre que el sistema ya ocupa**. La sonda correcta era la que terminé escribiendo: cinco
3613> líneas de C compiladas con el `cc` nuevo a un nombre inventado (`probe-suid`). Copiar sobre `id`
3614> fue elegir el camino corto, y el camino corto se llevó un binario del que depende hasta el script
3615> que abre al agente. Es la misma familia que el `/etc/shadow` del §6.51: **la caja no avisa cuando
3616> le falta algo esencial — se entera el que lo usa**.
3617
3618### 6.54 🧭 Trabajar DESDE la jaula: lo que el agente de adentro reportó *(2026-09-18)*
3619
3620El agente enjaulado pasó un informe de defectos —el primero hecho **desde adentro**— y vale más que
3621cualquier inspección desde el anfitrión, porque son las cosas con las que se choca al usarla.
3622
3623#### Lo que se arregló, y dónde vive cada arreglo
3624
3625| # | defecto | dónde queda el arreglo |
3626|---|---|---|
3627| 1 | sin entrada en `/etc/passwd` para el uid de adentro ⇒ `ssh`/`git` mueren con *No user exists for uid 1001* | `jaula-preparar.sh` (upper) |
3628| 2 | `/etc/ssh/ssh_config.d` es de la capa inferior, dueño fuera del mapa ⇒ `ssh` **aborta**, no degrada | `jaula-preparar.sh` (upper) |
3629| 3 | `~/.ssh` en `ro` ⇒ `ssh` no puede escribir `known_hosts` | **manifiesto**: pasa a `rw` + semilla desde el anfitrión |
3630| 4 | sin clave para el worker ⇒ `Permission denied (publickey)`, cero granja | clave **propia** de la caja autorizada en el worker |
3631| 5 | sin `rsync`/`python`/`jq`/`b3sum`, y sin `takana` ejecutable | **manifiesto**: `packages`; y un `takana` que sale del store |
3632
3633El **1 y el 2 viven en el `upper`**, que por el D3 es caché descartable: al primer `recreate` se
3634pierden. Por eso existe `scripts/servidor/jaula-preparar.sh`, que los repone —escribiendo como la
3635persona, no como root, para no dejarle ficheros que después no puede tocar—.
3636
3637Sobre el **4**: se autorizó en el worker la clave **de la caja** (`sergio@caja`), no la de root. Es
3638una credencial distinta y revocable: quitarla del `authorized_keys` del worker corta ese acceso sin
3639tocar ningún otro. Comprobado: `ssh root@dev.gioser.net hostname` desde la caja contesta `PruebasIA`.
3640
3641Sobre el **5**: `takana` **no se compila adentro**. El binario es el musl estático del store y corre
3642igual dentro de una imagen glibc; lo que fallaba es que
3643`/opt/takana/target/release/takana` del anfitrión es un enlace a `/usr/bin/takana`, y **adentro
3644`/usr/bin` es el de Arch**. Queda `/work/sergio/bin/takana`, que elige el más reciente del store —no
3645un hash cableado, para que sobreviva a un re-sellado— y el `target/release/takana` del clon apunta
3646ahí.
3647
3648#### Las tres trampas de orientación, que no son defectos pero cuestan una hora
3649
3650- **El store real es `/store`**, 61 G y 1333 artefactos. Los runbooks y el `CLAUDE.md` dicen
3651  `takana build --store ./store` porque están escritos para el HUB; copiado literal en la caja, eso
3652  apunta a un store vacío dentro del clon. Desde la caja va `--store /store`.
3653- **Hay dos checkouts y no son intercambiables**: `/work/sergio/takana` es el de trabajo —suyo, con
3654  push propio—, y `/opt/takana` es el del sistema: el que usan el cron, la granja y los servicios.
3655  Editar el segundo por costumbre es pisarle el árbol al latido.
3656- **`scripts/farm/.fleet` ausente NO es un defecto** —el propio informe lo retiró—: `flota-permanente`
3657  está versionado y `cosecha-cron` lo repone.
3658
3659### 6.55 🔓 «Una sola sesión» es del KERNEL; «un solo Claude» era nuestro *(2026-09-18)*
3660
3661El usuario chocó con `Resource busy` al abrir un segundo agente y lo llamó por su nombre: *«no quiero
3662restricciones arbitrarias»*. Tiene razón en la queja y conviene separar qué es qué, porque las tres
3663cosas se sienten igual desde afuera y son muy distintas:
3664
3665**1. Lo que impone el kernel.** Dos overlays sobre el **mismo `upper`** los niega `overlayfs`, no
3666nosotros: la capa mutable se corrompería. `qorpa` reintenta seis veces y después explica la causa en
3667vez de dejar el error crudo de bwrap. **Eso no se toca.**
3668
3669**2. Lo que era empaquetado nuestro** — y sí se toca: *una instancia por persona*. De ahí salía «un
3670solo Claude». Instancias distintas tienen `upper` distintos y conviven sin problema. Medido: crear la
3671segunda cuesta **0 s y 16 K**, porque la imagen se comparte de sólo lectura.
3672
3673**3. Lo que era un accidente**, y se arregló al encontrarlo:
3674
3675| | estaba | ahora |
3676|---|---|---|
3677| `/var/lib/hammer/qorpa/instances` | `root:root 755` ⇒ una persona **no podía crear su propia instancia** | `root:sergio 2775` (setgid) |
3678| `~/.ssh` en la jaula | `ro` ⇒ `ssh` no escribe `known_hosts` | `rw` |
3679| `/store` en la jaula | `ro` ⇒ el agente lee y **no puede sellar** | `rw` |
3680| `claude` | abría `/opt/takana` siempre | abre `$PWD` |
3681| `/usr/bin/claude` | **copia** congelada del guion | enlace al del repo |
3682
3683⚠ **Y queda un defecto de verdad, que hoy bloquea el punto 2**: aprovisionar una instancia nueva
3684muere con `bwrap: Can't mkdir parents for /run/user/0: Permission denied`. La causa está medida:
3685**esta caja no tiene `/run/user`** —no hay logind— y `qorpa.rs:1542` arma la ruta del runtime dir
3686como `/run/user/<uid>` sin alternativa. `XDG_RUNTIME_DIR` no alcanza: lo honra otra rama del código
3687(`1244`/`1549`), no ésta. Es arreglo en el crate, no en un manifiesto.
3688
3689> ⚠ **ESE PÁRRAFO YA NO DESCRIBE LO QUE PASA — el muro se mudó** *(medido el 2026-09-23)*. Se
3690> reprodujo el escenario **exacto** que describe: instancia recién creada, manifiesto tal como lo
3691> deja `create` (`root = true`), red abierta, `provision` de un paquete. **Pasa bwrap entero** —la
3692> jaula arranca y contesta `uid=0(root)`— y el `/run/user/0` no aparece por ningún lado. Muere
3693> después, en otro sitio:
3694>
3695> ```
3696> error: failed to chown temporary download directory /var/lib/pacman/sync/download-rC58yQ: Invalid argument
3697> error: failed to synchronize all databases (failed to initialize download)
3698> ```
3699>
3700> O sea: el bloqueo de esta sección **está arreglado** (el candidato es `d52824ef`, «los padres que
3701> inventa bwrap eran 0700») y lo que queda es **otra cosa** — el mapa de **un solo id**, que es el
3702> ADR 0015 §NO-resuelve 2, la primera cosa que ese ADR dijo que iba a fallar. `pacman` no puede
3703> chownear su descarga porque adentro existe un id y nada más. El `preflight` ya lo nombra y lo
3704> mide: `newuidmap: open of uid_map failed: Permission denied`, veredicto `LIMITA 1`.
3705>
3706> **La ruta cableada de `qorpa.rs:1542` sigue en el código** —no se tocó—, así que lo que cambió es
3707> el síntoma, no esa línea. Se deja el párrafo de arriba en pie a propósito: tacharlo escondería que
3708> durante cinco días el arreglo se buscó en el crate equivocado.
3709>
3710> **Mientras tanto, en producción las jaulas se CLONAN, no se aprovisionan** — `cp -a upper`, ~1 s y
3711> 169 M, que no depende del mapeo. Lo hace solo `claude-caja.sh`. La consecuencia que muerde: si
3712> alguien corre `qorpa recreate` sobre una jaula de agente, **tiene que volver a clonar el `upper`**
3713> o la jaula vuelve sin `git`, sin `ssh` y sin `python`. Y un detalle que delata que esto viene de
3714> lejos: `b3sum` está declarado en `packages` y **no existe en ninguna jaula**, la plantilla
3715> incluida.
3716
3717⚠ **Y había un SEXTO límite accidental, de esta misma tanda, que tardó cinco días en salir**
3718*(2026-09-23)*. La pregunta llegó otra vez y casi con las mismas palabras: *«¿qué me limita levantar
3719más de 8 claudes, una política random o una arquitectura?»*. Era una política, y de una línea —
3720`claude-caja.sh`, el guardia del bucle que elige instancia:
3721
3722```sh
3723[ "$_n" -gt 9 ] && { echo "claude: hay 8 sesiones abiertas de $_base — cerrá alguna" >&2; exit 1; }
3724```
3725
3726Nada más lo sostenía: el bucle **ya crea la jaula siguiente** cuando encuentra ocupada la anterior,
3727y lo arquitectónico —dos overlays sobre el mismo `upper`— lo resuelve ese mismo clonado. O sea, el
3728punto 2 de arriba estaba resuelto y este número lo volvía a cerrar ocho sesiones más allá.
3729
3730Por qué se escapó al barrido del 18-sep, que es lo que importa: los cinco de la tabla eran
3731**permisos y modos** —`755`, `ro`, una copia congelada—, y se los encontró mirando el borde de la
3732jaula. Éste era un **literal adentro de un `while`**. Un barrido que busca límites accidentales
3733tiene que mirar las dos formas, porque desde afuera se sienten igual.
3734
3735Subido a 10, con el techo real escrito **al lado del número**, que era lo que faltaba. El techo es la
3736RAM, medido acá ese día:
3737
3738| | medido | |
3739|---|---|---|
3740| RAM | 8 sesiones = **2,5 G de RSS** (147–493 M cada una, ~310 M de media) | sobre 7,7 G, con 8 G de swap y 4,1 ya comidos |
3741| Núcleos | load **1,07** con las 8 vivas | no limitan: esperan la API, no CPU — y `taller` ya serializa los `cargo` |
3742| Disco | **169 M** por jaula | la imagen (587 M) se comparte de sólo lectura |
3743
3744Pasado ~14 se empieza a jugar con el OOM, que es el modo de falla documentado
3745(`INFORME-CAIDA-2026-08-17`: la caída fue con ocho adentro). **Un número sin su porqué es lo que
3746hace que la próxima vez se lea otra vez como una ley del sistema** — que es, literalmente, lo que
3747pasó con éste.
3748
3749> **Lo que conviene no confundir.** La jaula restringe al **agente**, no a la persona: `sergio` tiene
3750> `sudo`, entra por ssh y manda en la caja entera. Y dentro de la jaula el alcance es **declarativo**
3751> (D7): lo que no está en `grants` no se ve, pero agregarlo es editar un fichero, no pedir permiso.
3752> Lo indefendible no es que haya límites — es que un límite **accidental** parezca de diseño, que es
3753> exactamente lo que pasó con los cinco de la tabla.
3754
3755### 6.51 🔥 Me dejé afuera de la caja — 20 minutos caída, y tres causas encadenadas *(2026-09-17)*
3756
3757Crear una cuenta de usuario tiró el servidor de producción. Queda escrito entero porque **ninguna de
3758las tres causas era la que parecía**, y las tres siguen ahí para quien venga.
3759
3760#### 1. `/etc/shadow` no existía, y crearlo cerró el SSH de root
3761
3762La caja nunca tuvo `/etc/shadow`: todas las cuentas llevan `x` en `/etc/passwd` y entran por llave.
3763Para darle contraseña a `sergio` creé el fichero con **todas las cuentas bloqueadas (`!`)** y la suya
3764con su hash. Efecto inmediato: **`ssh` de root empezó a dar `Permission denied (publickey)`** con la
3765misma llave de siempre. Este `sshd` **rechaza la llave de una cuenta cuyo hash está bloqueado**.
3766
3767⇒ La forma correcta acá es la vieja: **el hash va en el campo 2 de `/etc/passwd`** y no hay
3768`/etc/shadow`. Así quedó, y así funciona `su - sergio`.
3769
3770#### 2. La caja NO reinicia por ACPI
3771
3772Con el SSH cerrado, `hcloud server reboot` (ACPI) **no hizo absolutamente nada**: arje-zero no
3773atiende esa señal. La web siguió sirviendo como si nada. Para un mantenimiento remoto eso importa:
3774**la única forma de reiniciar esta caja desde fuera es `reset`, que es un corte de energía.**
3775
3776#### 3. Y el corte de energía dejó el cortafuegos a medio escribir — eso fue lo que la aisló
3777
3778Tras el `reset` la caja **arrancaba** —los logs de arje lo probaban: gitea y caddy escribiendo— pero
3779**nadie llegaba**. La causa, encontrada mirando el fichero:
3780
3781```
3782  $ head -c 90 /etc/takana/cortafuegos-entrada.nft | od -c
3783  0000000  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0
3784```
3785
3786**El reglaset era TODO NULs.** Se había escrito minutos antes del corte: ext4 journaló el tamaño y
3787los datos nunca llegaron al disco. Un fichero de 5 KB de ceros — o, peor y más probable en el primer
3788arranque, **medio fichero válido**: la tabla con `policy drop` y las reglas de `accept` cortadas.
3789Eso es exactamente una caja que arranca, corre todo, y no deja entrar a nadie.
3790
3791⇒ **Después de escribir algo que el arranque necesita, `sync` ANTES de cualquier `reset`.** Y el
3792corolario amargo: un `reset` no es un `reboot`, y lo que se perdió no avisa.
3793
3794#### Lo que el rodeo destapó, y vale más que el susto
3795
3796- **El cortafuegos NO se estaba aplicando en ningún arranque.** Su card sale 78 si `nft -c` falla, y
3797  con el fichero corrupto fallaba siempre: la caja llevaba días **arrancando sin filtrar**, en
3798  silencio, porque «no aplicar nada» es su modo seguro y nadie mira un 78 en un log de boot.
3799  Reparado el fichero, ahora el arranque deja **21 reglas `dport`**, comprobado tras un reinicio.
3800- **`netup` es DHCP-only y falló dos veces seguidas** («sin respuesta DHCP (tipo 2)»). Una caja con
3801  IP fija no debería depender de que el DHCP conteste: si no contesta no hay segundo intento, y el
3802  síntoma —desde fuera— es idéntico al de una máquina que no bootea. Se le añadió al init una
3803  **dirección estática de respaldo** con la forma que pide Hetzner (IP `/32` + puerta `scope link`).
3804- **Esa red de respaldo no corría**: su guarda era `[ -d /sys/class/net/eth0 ]` y el init **no monta
3805  `/sys`**. El diagnóstico que lo probó tuvo que escribirse a FICHERO, porque `/dev/kmsg` no
3806  sobrevive al reinicio y sin red no hay forma de mirar. *Cuando no hay red, el log tiene que estar
3807  en el disco.*
3808- Del rescate salió el mapa real de particiones, que no era el que yo creía:
3809  `sda2 hammer-root` · `sda3 hammer-state` · `sda4 hammer-work` · `sdb hammer-store`.
3810
3811#### El costo, dicho sin adornos
3812
3813**~20 minutos de caída** de los ocho dominios, cuatro ciclos rescate↔sistema y tres `reset` duros —
3814uno de ellos, el que corrompió el fichero, disparado por mí a los 400 s de espera **mientras la caja
3815estaba haciendo su trabajo**. La lección que queda no es «hay que tener cuidado»: es que **en esta
3816caja el diagnóstico remoto cuesta un ciclo de 6 a 8 minutos**, así que conviene gastar el primero en
3817dejar evidencia en disco antes que en probar una corazonada.
3818
3819## 7. Reusar los scripts que ya existen, y no escribir de nuevo
3820
3821Pedido explícito del usuario. El inventario de lo que ya hace el trabajo:
3822
3823| para | script existente | qué le falta |
3824|---|---|---|
3825| armar el rootfs de un perfil | `scripts/hydrate-profile.py` | nada — es el camino bueno (los `hydrate-*.sh` por escritorio **divergen** de `targets.toml`) |
3826| producir el `.img` | `scripts/install-image.sh` / `scripts/product-image.sh` | apuntar al `perfil.servidor` |
3827| escribir a disco | `scripts/takana-install.sh` | el envoltorio de rescue del §3.2 |
3828| instalador con TUI | `scripts/takana-live-install.sh` | ser el **mismo motor** que el remoto, con las respuestas por fichero |
3829| provisionar un worker | `scripts/farm/vps-setup.sh` | ya soporta apt **y** dnf; le falta la rama «takana puro» (sin gestor de paquetes ajeno) |
3830| enchufar a la granja | `scripts/farm/.fleet` | **está en `.gitignore`** ⇒ un hub recién clonado nace con la flota vacía **en silencio** |
3831| latido | `scripts/farm/cosecha-cron.sh` | decidir cron vs tarjeta de arje (§3.4) |
3832| respaldo | `scripts/respaldo-storagebox.sh` | nada — es interrumpible y continuable |
3833| absorber el init ajeno | `arje-absorb` | el lector de procesos vivos (§4) |
3834
3835⚠ **Un motor, tres frentes.** La tentación es escribir el instalador remoto aparte del TUI. Ahí es
3836donde divergen: ya pasó con los `hydrate-*.sh` contra `targets.toml`, y con la unidad systemd del
3837worker que quedó vieja mientras el repo estaba al día. La instalación remota tiene que ser **la misma
3838que hace la persona**, grabada en un fichero de respuestas.
3839
3840---
3841
3842### 6.56 🫀 El latido latía y no publicaba — 12 días, y el espejo nunca existió *(2026-10-07)*
3843
3844El latido vive acá desde el §6.42. El 2026-10-07 se descubrió que llevaba **12 días publicando
3845nada**: 580 commits locales atascados en `/opt/takana` y un «NO pude publicar el estado — queda
3846local» cada 30 minutos en `work/cosecha-cron.log`. Nadie lo leía, que es justo el riesgo de mudar un
3847guardián a una caja donde no trabaja nadie.
3848
3849**Causa doble, y ninguna era el cron.** Publicar dependía de dos estados que el latido NO controla:
3850
38511. **el árbol de trabajo** — el plan B (`git-sincronizar.sh`) hace `pull --rebase`, que se niega con
3852   cualquier fichero modificado. Acá era un `scripts/servidor/claude-caja.sh` que resultó ser **byte
3853   a byte lo que ya estaba en origin**: trabajo duplicado de otro clon, bloqueando por nada.
38542. **la rama local** — divergida, y `pull --ff-only` no puede volver NUNCA. El `--ff-only` es
3855   deliberado (regla 2 ter de `CLAUDE.md`), así que la deriva es de ida.
3856
3857Los 580 commits contenían, en total, **cuatro líneas de timestamp**.
3858
3859⇒ **`scripts/farm/publicar-estado.sh`** arma el commit con *plumbing* sobre el `origin/main` recién
3860traído, en un índice TEMPORAL (`GIT_INDEX_FILE`), y empuja el sha a `refs/heads/main`. No toca el
3861árbol, no toca el índice compartido (regla 2), no le hace falta que la rama esté al día, y reintenta
3862si otro empuja. Nunca fuerza: el rechazo ES la señal.
3863
3864**Y el espejo: el latido NUNCA lo empujó, ni ahora ni antes.** `/opt/takana` tenía `origin` sólo a
3865gitea — `espejo-setup.sh` nunca se había corrido acá, aunque su propio comentario da por hecho que
3866el latido espeja solo («para que TODO `git push origin main` que ya existe en los scripts (p.ej.
3867cosecha-cron.sh) espeje solo»). Con 48 commits de estado al día, el espejo derivaba por ese número.
3868
3869Arreglarlo no era añadir la `pushurl`: el latido corre como **root** con `HOME=/root` y `gh` está
3870logueado como **sergio**. El camino SSH se descartó midiéndolo (ninguna clave de `/root/.ssh` abre
3871el repo del espejo). Pieza nueva de la caja:
3872
3873| | |
3874|---|---|
3875| `/usr/local/bin/credencial-espejo-github` | envoltorio de rutas sobre `scripts/servidor/credencial-espejo-github.sh`. Ayudante de credencial de git que pide el token con `su - sergio -c 'gh auth token'` **en el momento de empujar** y no lo escribe en ningún sitio. Declarado en el clon con `git config credential.https://github.com.helper` |
3876
3877Se descartaron las alternativas por lo que dejan **en reposo**: el token en `.git/config` o en
3878`~/.git-credentials` es un secreto en claro en una caja con root compartido, y la URL con el token
3879adentro sale además en cada mensaje de error de git. Si `gh` pierde la sesión, el push falla
3880ruidosamente en vez de empujar con un token muerto.
3881
3882⚠ **De paso, `espejo-setup.sh` MENTÍA en esta caja**: daba `gh auth status` por única credencial
3883válida, así que decía «el push a GitHub va a fallar» mientras el push funcionaba. Ahora mira primero
3884el ayudante, que es lo que git usa de hecho. Un guardián que grita en falso se deja de leer, y el
3885día que grite de verdad tampoco se lee — es el mismo defecto que el «al día» vacío del §5.9.
3886
3887**Verificado en producción:** `estado publicado … · espejo al día · rama local al día`, los dos
3888remotos en el mismo sha, y el clon del latido queda **limpio** tras cada ciclo.
3889
3890## 8. `churay` como instalador centralizado — la dirección, no la decisión
3891
3892El usuario lo planteó el 2026-09-09: **un instalador único, con CLI y TUI**, que sirva para
3893
3894- las **apps de tawasuyu**,
3895- **paquetes sueltos en cualquier distro**, al estilo nix (instalar sin ser root, sin tocar el
3896  sistema anfitrión),
3897- **takana** mismo,
3898- y **`llimphi`**.
3899
3900Encaja con lo que ya hay y por eso vale escribirlo: `takana install` ya resuelve por nombre contra un
3901índice firmado y **ya acepta múltiples orígenes**; `qorpa` ([ADR 0015](adr/0015-imagenes-ajenas.md))
3902ya corre imágenes ajenas enjauladas sobre el mismo kernel; y el vigía de espejos de churay ya corre
3903en cron desde gioser.
3904
3905✅ **DECIDIDO el 2026-09-23 — [ADR 0021](adr/0021-dueno-del-verbo-de-instalar.md).** Las tres
3906preguntas de abajo están contestadas ahí: **takana pone el motor y expone `takana metal
3907{check,survey,install}`; churay pone la cara y lo llama**, consumiendo `survey --json` en vez de leer
3908los discos por su cuenta. La (1) se partía en dos y por eso no se dejaba contestar —para instalar el
3909SISTEMA no se hidrata ni se reproduce: el instalador lleva el rootfs adentro, en RAM—; sigue abierta
3910para *paquetes sueltos en una distro ajena*, que es otro verbo. La (3) no era un problema: lo que
3911corre en el host ajeno es un ELF **estático**, así que su libc no participa.
3912
3913**No se decide acá.** Es un ADR propio, y tiene al menos tres preguntas abiertas antes de escribir
3914una línea: (1) si el instalador «en cualquier distro» hidrata binarios o reproduce desde fuente —la
3915misma pregunta del [SDD 27 §4](27-perfiles-instalables-y-el-instalador.md), y la respuesta puede no
3916ser la misma fuera de takana; (2) quién es el dueño del verbo, si `takana install` crece o si
3917`churay` lo envuelve; (3) qué pasa con un host glibc, que es exactamente lo que el ADR 0015 aparta
3918del store a propósito. Queda anotado para que se **decida**, no para que se descubra a mitad.
3919
3920---
3921
3922## 9. Estado y orden
3923
3924**Decisión del usuario (2026-09-14): la mudanza NO pasa por terceros.** Nada de espejar a GitHub
3925para «tener copia fuera»: la copia fuera **la da la propia mudanza** — en cuanto el gitea vive en la
3926caja nueva, los 26 repos dejan de existir sólo en la máquina que se borra. El objetivo es dejar de
3927pagar dos máquinas, no montar una dependencia más. `scripts/mudanza/espejar-repos.sh` queda como
3928herramienta disponible, no como paso del plan.
3929
3930**Y el worker no necesita credenciales: las fuentes de tawasuyu se sirven por HTTPS público.**
3931Las 23 recetas que apuntaban a `gitea@git.tawasuyu.net` (SSH) pasaron a `https://git.tawasuyu.net/…`
3932— la URL es locator y no entra en `hash_inputs` (ADR 0013), así que se cambió **con control y sin
3933mover un solo ArtifactHash**. Medido en el worker: clona y `git archive` extrae el árbol (155 M) sin
3934ninguna clave. Un usuario propio en gitea sólo haría falta para un repo PRIVADO, y hoy ninguna
3935receta usa uno.
3936
3937### Lo que YA está, y no hay que volver a tocar
3938
3939| | |
3940|---|---|
3941| la caja `takana` (`2.29.29.217`) | arranca takana puro, PID 1 `arje-zero` — §3.8 |
3942| store, grafos, granja, respaldo | puertas 2, 3, 4 y 7 — §6.3, §6.7, §6.8, §6.10 |
3943| repo firmado servido por la caja | §5.1 |
3944| **la imagen del perfil `servidor` con gitea** | **arranca y sirve 200, supervisado por arje** — §6.11 |
3945| **`arjectl` para operar sin reiniciar** | §6.12 |
3946| **el gitea de gioser corriendo sobre takana, con sus datos** | ensayo completo — §6.13 |
3947
3948### Lo que falta para el cutover, en este orden
3949
3950⚠ **La lista de abajo es del 2026-09-11 y los pasos 1-5 YA ESTÁN** (gitea mudado y sirviendo, §6.14;
3951estáticos mudados, §6.18; fósiles barridos, §6.19; `api.sergio` mudado, §6.25; `tawasuyu.net` y
3952`gioser.net` mudados, §6.26; squid, §6.27-28; hora, latido y rotación, §6.29; cortafuegos, §6.31-33).
3953
3954**El estado vigente está medido en el §6.34 (2026-09-16), y es más corto de lo que decía esta lista:
3955queda UN solo vhost sirviéndose desde gioser — `sergio.gioser.net` — y el montón de daemons propios
3956del usuario que ningún paquete provee.** Los pasos 6 y 7 son lo único que sigue abierto.
3957
3958
39591. **Actualizar la caja a la imagen nueva** (gitea + `arjectl` + las cuentas de `[[user]]`).
3960   `takana upgrade` está probado (§6.13 del SDD 27 y §5.4 acá); el `dd` es para provisionar, no para
3961   actualizar — sobrescribe el disco local y re-etiqueta la partición.
39622. **Mudar los datos del gitea**: `sqlite3 … ".backup"` (1,5 s, consistente con el servidor vivo) +
3963   `rsync -aH` de `/var/lib/gitea` y `/etc/gitea`. El corte final se hace con el gitea del origen
3964   **parado**, para que el último snapshot no pierda escrituras.
39653. **Caddy en la caja**: los **6 dominios vivos** del §6.9, con su TLS. Los otros 13 no se mudan.
39664. **DNS**: apuntar esos 6 a la IP nueva. El resto de los clones y remotos no se toca — siguen
3967   resolviendo al mismo nombre.
39685. **Verificar desde fuera**: cada dominio vivo responde 200 contra la IP nueva **y** un `git clone`
3969   real contra el gitea mudado.
39706. **El resto de servicios vivos** del censo (qdrant, squid, act-runner, php-fpm…), por el plan que
3971   `planear.py` ya emite.
39727. **Borrar gioser** — sólo con las 8 puertas en verde, a mano, nunca por automatización (§6).
3973
3974### Bloqueantes conocidos, con su tamaño
3975
3976- **`git` sellado sin `remote-http`** (§6.13): afecta a clonar POR HTTPS *desde* una caja takana, no
3977  al gitea que sirve. Re-sella `git`, raíz de `perfil.base` ⇒ unidad propia.
3978- **Puerta 5** sigue a medias: la caja sirve su repo pero no se instala de él (`install` reproduce y
3979  eso exige el lab entero). No bloquea la mudanza; bloquea «el servidor se sirve a sí mismo».
3980
3981**El volumen del store NO se engancha a la caja hasta que pase la puerta 1** — ya pasó, y el store
3982local de 68,7 G alcanza, así que la mudanza no depende de mover el volumen de gioser.

28-servidor-de-produccion.md

SDD 28 — El servidor de producción: instalar takana puro en una caja remota y mudarse a ella

Escrito 2026-09-09 a pedido del usuario («qué tan lejos estamos de montar un server takana en Hetzner y que sea producción, y mover allá todo lo que tengo acá, y de una vez mantenerlo por repo takana»). Todo número marcado (medido) salió de un comando corrido ese día sobre gioser.

Continúa SDD 19 (lanzamiento público) y SDD 27 (metapaquetes e instalador). No repite el instalador: SDD 27 decide QUÉ se instala y con qué verbo; esto decide DÓNDE, y cómo se llega.


0. La corrección de encuadre: ya estamos en Hetzner

gioser es un servidor Hetzner (hcloud id 126324694, hel1, 4 cores / 7,6 GiB) (medido). La pregunta no era «cómo llegar a Hetzner» sino cómo tener una caja de takana en vez de ser un inquilino más de una máquina compartida que:

0.1 El hallazgo que cambia el tamaño del problema

$ ps -p 1 -o comm=,args=
arje-zero  /usr/local/sbin/arje-zero
$ cat /proc/cmdline
BOOT_IMAGE=/vmlinuz-linux root=UUID=54de62df-… rw net.ifnames=0 splash \
  init=/usr/local/sbin/arje-zero loglevel=4

El init de takana ya gobierna un servidor de producción real. Caddy, gitea, sshd, qdrant y act-runner cuelgan directo de PID 1 (medido: ps -eo pid,ppid,comm | awk '$2==1').

Pero eso no es takana puro: es Artix con nuestro init inyectado por init= en el cmdline. El kernel, la libc y los 19 servicios son ajenos. Se probó la mitad difícil sobre userland prestado.

0.2 ⚠ La declaración del arranque no existe en ningún sitio legible

rc-status de OpenRC dice stopped para caddy, gitea, sshd, cronie y act-runner, y los cinco están vivos (medido, los dos comandos). /etc/arje/cards.d tiene 4 tarjetas (acpid, openrc-openclaw, squid, zram-swap) y ninguna de ellas los levanta.

⇒ La secuencia de arranque de esta caja no está escrita. Es lo menos reproducible de toda la mudanza y lo primero que hay que capturar — ver §4.


1. Inventario de lo que hay que mudar (medido 2026-09-09)

Datos en /mnt/vvv (217 G de 255)

tawasuyu124 G
takana91 G — incluye el store de 55 G bind-mounteado desde el otro volumen ⇒ ~36 G propios
rustup29 G
act-runner · gioserv · _respaldo6,5 · 5,4 · 4,1 G
~20 dirs *-standaloneagora, card, chasqui, cosmos, dominium, khipu, minga, mirada, nakui, nahual, pata, pineal, pluma, shuma, takiy, llimphi…

El volumen harkaq-cosecha (250 G, 148 G usados)

store55 G, 1327 artefactos — lo irreemplazable
work-sources + cargo-home + gopath~85 G — caché regenerable, no se muda
work-tarballs · devfs-cache · work-out3,2 G · 4,5 G · 397 M

El store no se copia por red. Los dos volúmenes están en hel1; un volumen hcloud se desengancha de una caja y se engancha a otra en la misma ubicación. La mudanza del store son dos comandos.

Servicios vivos, todos hijos de arje-zero

caddy :80/:443 · gitea :3002 · sshd :2345 · act_runner (el CI de tawasuyu) · qdrant :6333/:6334 · openclaw · zeroclaw · shuma-gateway :7378 · tejido :4102 y :33097 · puerta-f6e393ff · uvicorn :8771 · python3 :8770 · squid :1137 · fail2ban · glances · php-fpm · metalog · adb :5037.

Lo que no está en ningún repo

/var/lib/gitea (2,2 G: SQLite + los repos de git de todo, incluido el origin de takana) · /var/www (287 M) · /home/sergio/gioser-web/ · /etc/caddy/Caddyfile + 20 ficheros .bak · los certificados de letsencrypt · los crontabs · /etc/arje/cards.d · las 3 credenciales de ~/.config/hammer/*.env · las llaves ~/.ssh/{github5,sergio} · el token hcloud · y la memoria del agente en ~/.claude/.

1.1 La basura, contada — porque una mudanza fiel copia también la basura

De los 19 dominios del Caddyfile (probados uno por uno desde la propia caja):

responden 200rotos
gioser.net · tawasuyu.net · git.gioser.net · summa.gioser.net · sergio.gioser.netapi.gioser.net → 502 (backend caído)
aura · sigma · kosmofono · terapeuta.ec → sin DNS

Y sus raíces no existen en disco: /var/www sólo tiene terapeuta, goaccess, ogisoer, git-tawasuyu, letsencrypt y un .bak — no hay aura_frontend, ni sigma, ni summa/frontend, ni kosmofono. La mitad del Caddyfile describe un servidor que ya no existe.

⇒ Regla de la mudanza: no se muda lo que no responde. Lo que no tiene DNS ni ficheros se anota y se deja morir con la caja vieja. La utilidad del §4 tiene que saber decir declarado / vivo / fósil.


2. La caja: qué, cuánto y por qué

Precios consultados a la API de hcloud el 2026-09-09 (hel1, x86, tipos no deprecados) (medido):

tiporecursos€/mes
cx5316 c / 32 G / 320 G29,49
cpx418 c / 16 G / 240 G32,49
ccx23 (dedicado)4 c / 16 G / 160 G85,99

Elegido cx53: es el mejor punto de la tabla y entra holgado el conjunto tawasuyu + takana + rustup (~244 G) si algún día se muda todo.

Decisiones del usuario (2026-09-09):

  1. La caja PUBLICA; el LXC prestado sigue moliendo. dev.gioser.net cuesta €0 y es la máquina con más RAM (6 c / 16 G + 8 G swap). Separar servir de compilar mantiene la caja chica y barata. La caja nueva puede además compilar, con el LXC enchufado como segundo worker.
  2. «Producción» = operativo pero privado. Sin descarga pública todavía ⇒ no se activan los bloqueantes legales del SDD 19 §2 (espejo de fuentes público por HTTP, marca de terceros). Lo que sí hace falta igual es la clave de release estable (§5).
  3. gioser se BORRA cuando la mudanza esté comprobada. Eso convierte esto en un experimento con fecha de caducidad, y cambia el diseño entero: ver §6.

⚠ El blindaje anti-borrado de gioser es deliberado y hay que levantarlo a mano y a propósito. deadman.sh y farm-down.sh lo protegen por lista negra de nombre y por ausencia del label role=hammer-worker. Ninguna automatización debe poder borrarlo: cuando llegue el día, se borra a mano, con la comprobación del §6 en verde y no antes.


3. Instalar takana puro en una caja remota

3.1 Lo que ya está, y por qué está más cerca de lo que parece

El dato que lo destraba: Hetzner Cloud arranca por BIOS, no por EFI. (medido en gioser: no existe /sys/firmware/efi; GPT + GRUB; /boot es vfat de 1 G.) Y scripts/takana-install.sh escribe exactamente ese layout: GPT + GRUB BIOS, SeaBIOS → GRUB → kernel → arje-zero PID1. El camino EFI —que fue el que más trabajo costó (ADR 0010)— es el que Hetzner no necesita.

piezaestado (medido)
kernel con virtiorecipes/linux.toml compila VIRTIO, VIRTIO_PCI, VIRTIO_BLK, VIRTIO_NET, EXT4_FS =y ⇒ sin initramfs
redcrates/netup = cliente DHCPv4 propio en Rust sobre netlink, autodetecta interfaz y espera carrier. gioser toma su IP por DHCP en eth0 (/32, net.ifnames=0, DNS 185.12.64.2) ⇒ es justo su caso
accesorecipes/openssh.toml sellado; el product-rootfs vigente ya trae sshd; takana-install.sh acepta AUTHKEYS
servirrecipes/caddy.toml existe
escritura a discoscripts/takana-install.sh (no interactivo) y scripts/takana-live-install.sh (TUI), probado por scripts/install-tui-test.sh

3.2 El camino remoto: rescue → dd → reboot

Hetzner Cloud tiene un sistema de rescate (Debian por SSH) que se activa desde la API sin tocar la caja. Desde ahí se escribe la imagen al disco y se reinicia. No hace falta ISO, ni consola, ni medio físico — que es justo lo que hace innecesario el 90 % del trabajo de ISO/USB para este caso.

3.3 Los cuatro huecos, nombrados

  1. perfil.servidor — HECHO hoy en docs/state/targets.toml. Ver §3.4.
  2. console=ttyS0 en el cmdline — el único salvavidas si el arranque falla y no hay teclado. Hetzner da consola web; sin serial en el kernel, esa consola no muestra nada útil.
  3. IPv6 — netup hace DHCPv4 y Hetzner entrega un /64 estático. La caja arrancaría con IPv4 sola. Aceptable para empezar, pero hay que saberlo antes, no descubrirlo.
  4. Un .img del perfil servidor que el rescue pueda escribir. Es hydrate-profile.py + install-image.sh, los dos ya existentes — ver §7.

3.4 El perfil, y la deuda que declara (hecho 2026-09-09)

[perfil.servidor] hereda cli (que hereda base, SDD 27 §7.1) y añade 5 raíces que existen (openssh, caddy, curl, wget, tmux) y 5 que NO:

raíz que no resuelvepor qué está declarada igual
takanael propio takana no tiene receta. El corpus construye 869 recetas y no la suya ⇒ el binario sólo existe como cargo build sobre un clon. Es EL bloqueante del §5
chronysin NTP, un reloj que deriva rompe TLS y las firmas — y falla diciendo «firma inválida», no «la hora»
nftablesdev.gioser.net está hoy expuesto con fuerza bruta a root en el journal; una caja nueva no debería nacer así
cronieel latido son tres líneas de crontab, y sin systemd no hay timers: o hay cron, o el latido es una tarjeta de arje-zero. Decisión pendiente
logrotateen una caja que sirve, el log de acceso crece hasta llenar el disco

Quedan wanted en el grafo, y wanted no es debt ⇒ drenaje.json seguirá diciendo deuda=0. Se declaran igual porque una deuda escrita se ve y una omitida no: sin ellas el perfil saldría N/N —completo y verde— describiendo un servidor sin hora, sin cortafuegos y sin latido. Es la lección de foot en escritorio-sway (121/121 sellado y sin emulador de terminal), pagada por adelantado. Cuenta esperada: 5. Un sexto wanted es un error de tipeo, no una receta nueva.

3.5 La caja, creada (2026-09-10)

nombre / idtakana / 165447050
tipocx33 — 4 c / 7,6 GiB / 76,3 G — €8,49/mes
ubicaciónhel1 — la misma que los volúmenes vvv y harkaq-cosecha y que el Storage Box
IPv4 / IPv62.29.29.217 · 2a01:4f9:c014:2f9c::/64
imagen inicialdebian-13 — desechable: existe sólo para recibir ficheros hasta el dd del §3.2
labelrole=takana-server
protección de borradoNO, a propósito: es la caja de sacrificio

⚠ El label importa: farm-down.sh y deadman.sh borran sólo lo que lleva role=hammer-worker. Esta caja no lo tiene ⇒ ninguna automatización de la granja puede tocarla. Es la misma capa fuerte que protege a gioser, aplicada al revés: no se protege por nombre, se protege por no haber nacido de farm-up.

⚠ cx53 no se pudo comprar: sale Available: no en las TRES ubicaciones. Y la disponibilidad cambia por día — cx33 salió no en hel1 el 2026-09-09 y yes el 2026-09-10, con la misma consulta. ⇒ el precio de la tabla no dice que la máquina exista: hcloud server-type list devuelve precios para tipos que no se pueden crear. Consultar Available por ubicación, y volver a consultarlo el día que se compra.

Lo que la caja confirma, medido EN ELLA y no inferido de gioser

  1. Arranca por BIOS — no existe /sys/firmware/efi… y sin embargo la imagen trae un ESP (sda15, 244 M vfat en /boot/efi). O sea: la presencia de una partición EFI no indica arranque EFI. Si el instalador decidiera la rama mirando particiones en vez de /sys/firmware/efi, elegiría mal en esta caja exacta. takana-live-install.sh mira lo correcto.
  2. console=tty1 console=ttyS0 viene en el cmdline de Debian. Confirma que el serial es lo que Hetzner espera, y que el hueco 2 del §3.3 es real: nuestra imagen tiene que traerlo o la consola web no muestra nada.
  3. eth0 con /32 dinámico por DHCP — idéntico a gioser ⇒ es exactamente el caso de netup.

Dos cosas pendientes en la caja, anotadas

3.6 La imagen del perfil.servidor, armada y validada (2026-09-10)

Receta del ensamblado (todo con scripts que ya existían, §7):

# 1. base con init: el product-rootfs sellado (trae /sbin/init -> /usr/bin/arje-zero)
cp -al /mnt/cosecha/store/5011955a…-product-rootfs/. $D/
# 2. userland del perfil encima
scripts/hydrate-profile.py servidor --into $D --store /mnt/cosecha/store --keep
# 3. ⚠ restaurar el init (ver abajo) y meter la clave
ln -sf /usr/bin/arje-zero $D/sbin/init ; cp github5.pub $D/root/.ssh/authorized_keys
# 4. imagen
ROOTFS=$D STAGE=/mnt/cosecha/.install-stage KERNEL=store/23f1cc43…-linux-generic/boot/bzImage \
CMDLINE="console=ttyS0,115200 console=tty0 root=/dev/sda2 rw init=/usr/bin/arje-zero" \
  ./scripts/install-image.sh

Resultado: 86 nodos, 30.500 ficheros, 2,0 G de rootfs → imagen de 7.172 M (2,1 G en disco, sparse).

El kernel correcto es linux-generic, y linux.toml NO sirve para hcloud

El disco de una caja hcloud lo maneja virtio_scsi (driver sd, disco sda) — no virtio_blk. recipes/linux.toml apaga SCSI explícitamente (-d SCSI) ⇒ su kernel arranca y no ve el disco. linux-generic, linux-metal y linux-metal-dual sí sirven.

⚠ Y ninguna receta nombra SCSI_VIRTIO. Está encendido igual, porque make defconfig lo trae y -e SCSI -e BLK_DEV_SD lo mantiene. Se supo mirando el .config que linux-generic publica en su artefacto (/boot/config-7.1.2-generic), no leyendo la receta. La receta dice lo que se cambió; sólo el .config sellado dice lo que quedó.

⚠ La hidratación PISA /sbin/init, y el resultado arranca igual

busybox entra en la clausura del perfil y su /sbin/init -> ../bin/busybox gana por llegar después, borrando el -> /usr/bin/arje-zero del product-rootfs. La imagen habría arrancado perfecta con el init de busybox: no takana puro, y sin un solo error. Se tapa por las dos puntas — restaurando el symlink al ensamblar y con init=/usr/bin/arje-zero explícito en el cmdline, que es lo que hace gioser y es autoritativo pase lo que pase con el symlink.

Y la guarda que existía para esto estaba INVERTIDA (arreglada en el mismo commit): [ -e "$ROOTFS/sbin/init" ] sigue el symlink y lo resuelve contra el host, así que el symlink ABSOLUTO a arje-zero daba falso y el rootfs correcto era rechazado, mientras que el relativo a busybox —el equivocado— pasaba. Ver [[guardian-que-nunca-fallo]]: una guarda que nunca falló no se sabe si sirve; ésta fallaba al revés.

EXDEV, dos veces en la misma tarde

store y work/out son bind-mounts de /dev/sdb; work/ está en /dev/sdc. Un hardlink no cruza un montaje aunque sea el mismo disco, así que reventaron (1) hydrate-profile.py con el destino fuera del montaje del store —que avisa RUIDOSO y bien— y (2) el staging de install-image.sh, que lo tenía cableado a work/. Ahora STAGE es env. Ver [[hardlink-cruza-mount-exdev]].

La validación: arrancó en QEMU reproduciendo virtio-scsi, y se entró por SSH

SeaBIOS → GRUB → kernel 7.1.2 propio → EXT4-fs (sda2): mounted → Pivoted into new rootfs
→ Run /usr/bin/arje-zero as init process → "arje-zero: despierta como PID 1"
→ netup (DHCP) → ssh-keygen -A → sshd

Y desde fuera: ssh -p 2299 root@127.0.0.1 entra, /proc/1/comm dice arje-zero, ip addr da la IP que negoció netup. Es la puerta 1 del §6 ensayada en local antes de gastar la caja.

El card sshd de /ente/seed.card.json ya hacía el trabajo entero sin que hubiera que tocar nada: sh -c "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e" — red, claves de host y demonio en una línea. Lo único que hubo que inyectar fue authorized_keys.

Un resto del renombre, encontrado por el arranque

arje-zero: hammer no corre, sin menú de arranque: No such file or directory (os error 2)

arje-zero invoca el binario por el nombre VIEJO (hammer). No es fatal —sigue arrancando— pero el menú de arranque por grafo del ADR 0010 queda muerto. Y viene de tawasuyu, pineado por commit en recipes/arje-zero.toml, así que no se arregla desde este repo: hay que subirlo allá y re-pinear. Se suma a que la imagen tampoco trae takana (§3.4).

3.7 ⚠ El muro real: la puerta de enlace de Hetzner está FUERA del prefijo

La imagen validada en QEMU se escribió a la caja, arrancó… y no se pudo entrar. hcloud decía running, el puerto 22 cerrado, y desde fuera Connection timed out — el modo de fallo más caro de todos, porque no hay consola serie en hcloud para mirar.

La causa, medida entrando al rescue de la propia caja:

inet 2.29.29.217/32 scope global eth0
default via 172.31.1.1 dev eth0
172.31.1.1 dev eth0 scope link      ← ESTA es la que faltaba

La IPv4 viene con prefijo /32 y la puerta (172.31.1.1) no pertenece a ninguna red conectada. netup mandaba el default con RTA_GATEWAY y scope UNIVERSE y nada más, así que el kernel lo rechazaba con ENETUNREACH: la máquina se quedaba con IP y sin salida, contestando el DHCP y levantando sshd, sin nada visiblemente roto por dentro.

Control causal, en un netns con una dummy y una /32:

ip route add default via 172.31.1.1 dev dummy0   ⇒ Error: Nexthop has invalid gateway.
ip route add 172.31.1.1 dev dummy0 scope link    ⇒ ok
ip route add default via 172.31.1.1 dev dummy0   ⇒ ACEPTADO

Arreglado con add_onlink_host_route (dst /32, scope LINK) antes del default.

Por qué no había aparecido nunca: netup se validaba contra el DHCP de QEMU slirp, que entrega un /24 con la puerta dentro de la subred. El escenario que rompe sólo existe en una nube de verdad. Es [[cache-congela-regresiones]] en su otra forma: no es que el test estuviera mal, es que el ÚNICO entorno donde se probaba no contenía el caso.

Y destapó un segundo problema de fondo: el binario de netup venía dentro del product-rootfs, o sea congelado en un artefacto sellado del bootstrap ⇒ arreglar netup no llegaba a la imagen. Por eso netup pasa a ser raíz de perfil.servidor: así la hidratación lo proyecta encima y la imagen lleva el vigente. Verificado por sha256 (imagen a23df20e…, product-rootfs a84b0a0d…).

3.8 ✅ Puerta 1, pasada (2026-09-10)

PID 1    : arje-zero          kernel : 7.1.2 (linux-generic propio)
IP       : 2.29.29.217        rutas  : default via 172.31.1.1 + 172.31.1.1 scope link
montajes : /dev/sda3 -> /store      /dev/sda4 -> /var/lib/hammer
salida   : ping 1.1.1.1 0% pérdida · curl http://1.1.1.1 -> 301 · resolv.conf escrito por netup

Una caja Hetzner Cloud corriendo takana puro: nuestro kernel, nuestro init, nuestro cliente DHCP, nuestro sshd — sin una línea de Debian debajo. El camino entero es scripts/servidor-image.sh + rescue + dd, y la escritura de los 7,1 G comprimidos tarda ~17 s (gioser y la caja están las dos en hel1).

Lo que la caja todavía no tiene, y es el paso 2: takana (sin receta, §3.4), el store real (el volumen no se engancha hasta acá) y las otras cuatro raíces wanted.


4. La utilidad de migración: absorber lo VIVO, no lo declarado

arje-absorb ya existe, está sellado (recipes/arje-absorb.toml) y su --help dice: lee sysvinit | runit | dinit | openrc | auto y emite una Tarjeta Semilla con cada servicio del init ajeno como hija de arje-zero. Tiene parsers para los cinco (systemd.rs, openrc.rs, runit.rs, dinit.rs, sysvinit.rs).

Pero lee la declaración, y en gioser la declaración miente (§0.2). Un arje-absorb --from openrc sobre esta caja produciría una Semilla que no levanta el servidor, y lo haría en silencio: la Semilla sería sintácticamente perfecta.

⇒ Lo que falta es un segundo lector: absorber del proceso vivo (/proc: ppid==1, cmdline, cwd, entorno, fds en LISTEN) y reconciliar ambas lecturas marcando dónde discrepan. Eso es lo que el usuario nombró como «lo aprendido de matilda» — absorber lo que hay, no lo que está escrito.

La utilidad debe clasificar en tres, no en dos:

clasecriterioqué se hace
vivoproceso corriendo y declaradose muda
declarado, muertoen la config, sin proceso ni ficheros (los 4 dominios sin DNS)fósil: se anota y se deja morir
vivo, no declaradoproceso con ppid==1 que ningún init declara (caddy, gitea, sshd, act-runner)se escribe la tarjeta que faltaba — es el trabajo real

Se construye al final, estrenándose con la migración gioser → caja nueva, con el caso del rc-status que miente como test de aceptación. Su especificación es el §1 de este documento.


5. Que el servidor sirva sus propios paquetes a su propio host

Existe el camino entero: scripts/build-repo.sh (corpus → .swm firmados + índice), takana install --repo que ya acepta lista HTTP multi-origen y verifica cada paquete contra el digest del índice firmado (ADR 0014), takana upgrade con rollback, y caddy para servir dist/repo con un file_server.

Lo que falta no es servir, son dos cosas:

  1. La clave. build-repo.sh firma hoy con una clave efímera por defecto (keygen release si no se le pasa KEY=) (medido, línea 33). Sin clave estable, «firmado» no significa nada. Es el SDD 19 §3.2 y es requisito aunque el repo sea privado.
  2. La receta de takana (§3.4). El host necesita takana instalado para consumir los paquetes, y hoy no puede instalarlo desde el repo porque takana no está en el repo.

5.1 Hecho (2026-09-10): clave estable, repo firmado, y la caja sirviéndolo

El control de la firma, en los dos sentidos, contra el repo servido por la caja:

--trustresultado
./trustrelease: trusted (by release) ⇒ instala
un directorio vacíorelease: unknown-key … clave no confiada ⇒ ABORTA

5.2 ⚠ Lo que el lazo destapó: strip_debug no viajaba en el paquete

La primera instalación real de takana desde su propio repo falló:

Error: expected_hash no coincide:
  declarado = b3:941d1857…   obtenido = b3:2727050e…

why-differs lo nombró: los dos binarios pesaban exactamente lo mismo y sólo divergían en .shstrtab — la firma de un strip que corrió una vez y la otra no. swm_bridge re-inyecta con cuidado flags, phases, zig_version, strip_components, patches, deps… y se olvidaba de strip_debug, que ni siquiera existía en SwmBuild. Como entra en hash_inputs, el receptor reconstruía en otra dirección.

Alcance: 40 recetas del corpus, 18 de las 88 de este perfil. Una quinta parte del repo no se podía instalar, y nadie lo sabía porque nunca se había cerrado el lazo. Arreglado, con test de regresión y su control. Tras el arreglo, install takana da cache-hit en el hash anclado.

5.3 ⚠ Y el loopback nunca se levantaba

Al intentar que la caja instalara de sí misma por http://127.0.0.1: timeout de 30 s, con caddy escuchando en 0.0.0.0:80. lo estaba DOWN y sin dirección — con systemd o OpenRC lo levanta el init; con arje-zero no lo hacía nadie. El síntoma no era «connection refused», que se diagnostica en un minuto: era un cuelgue que parecía del servidor. Arreglado en netup (es quien configura la red) y verificado desde la imagen, sin tocar la caja a mano.

5.4 🚧 Lo que NO se puede todavía, y por qué importa

Con todo lo anterior en verde, la caja sigue sin poder instalar de su propio repo:

release: trusted (by release)              ← la firma verifica
repo: bajados 2 .swm de http://127.0.0.1   ← descarga de sí misma
Error: no pude leer el apk db del lab en /.dev-fs/alpine/lib/apk/db/installed.
       El toolchain entra en el ArtifactHash, así que sin rootfs no se puede
       calcular un hash comparable

O sea: la mitad de servir está cerrada y probada; la de consumir, no — y no por un detalle de instalación, sino porque install reproduce desde fuente y eso exige el LAB ENTERO en el cliente. No es «falta un compilador»: el lab entra en el ArtifactHash, así que sin él no se puede ni calcular el hash a comparar.

Es exactamente el punto que SDD 27 §4 dejó planteado y sin decidir —reproducir o hidratar— visto desde el otro lado: para un servidor, o para cualquiera que no sea un hub de build, reproducir no es una opción. El camino normal tiene que ser hidratar artefactos firmados, con --reproduce como camino auditable. Mientras esa decisión no se tome, el repo firmado sirve para distribuir a hubs, no a usuarios.

⚠ Y una caja que se sirve a sí misma es un solo origen — justo lo que el ADR 0014 existe para no tener. El camino normal puede ser el local, pero la lista de orígenes del host debe incluir también el Storage Box o gioser. Si no, el primer origen roto es el último.

Lo que esto vale: cerrar el bucle es el ensayo de «actualización en sitio probada de verdad» (SDD 19 §5.2), hecho sobre una máquina que importa pero que no es el laptop de nadie.

5.5 ⚠ Dos bugs más del lazo, que sólo aparecen si el paquete NO es un binario de Rust (2026-09-21)

Pedido del usuario: «instalar zsh con el comando takana». zsh es el primer paquete que se instala con varios parches y con el binario fuera de /usr/bin, y cada una de esas dos cosas era un fallo distinto. Los dos son de la familia del strip_debug del §5.2 —algo que el .swm no transportaba fiel— y los dos se descubren sólo cerrando el lazo, no leyendo el código.

1. Los parches viajaban CONCATENADOS, y eso cambia el hash. pack unía los N parches de la receta en el patch inline único; Recipe::hash_inputs mete una entrada por parche y of_inputs va con longitud prefijada ⇒ N sueltos y N unidos hashean distinto. Medido sin esperar al build, con una copia de recipes/zsh.toml con sus 6 parches concatenados en uno:

b3:0be3630d…   ← expected_hash anclado (y lo que hay en /store)
b3:d6329a62…   ← lo que reconstruye el receptor  (= la copia concatenada, exacto)

O sea: install zsh recompilaba zsh entero y recién entonces moría con expected_hash no coincide. Alcance: las 16 recetas del corpus con ≥2 parches — waterfox 12, firefox y gnupg 11, parted/zsh/strace 6, doas/mandoc/giflib 4, freetype 3, libxml2/file/brotli 2…

Arreglado con un campo patches (lista, en orden) en el source_patch; el patch único se sigue leyendo para no invalidar lo ya publicado. El receptor materializa un fichero por parche. Regresión con su control negativo en swm_bridge: si se concatenan, los hash_inputs TIENEN que diferir, o el test pasaría por la razón equivocada.

⚠ La tentación era arreglarlo del otro lado —hashear los parches concatenados— y habría sido mucho peor: mueve el hash de las 50 recetas con parches e invalida sus artefactos sellados.

2. target_bin era una adivinanza que nadie comprobaba. pack publica /usr/bin/{name}; zsh configura --bindir=/bin, así que su binario queda en /bin/zsh. El paquete se publicaba prometiendo un path que el artefacto no tiene, y el error salía en la máquina que instala, después de hidratar los 1331 ficheros:

Error: source_patch declara target_bin=/usr/bin/zsh pero no quedó en <prefix>/usr/bin/zsh

Con --build el artefacto está delante: ahora pack comprueba el path contra él y lo corrige si el binario quedó en otro bindir, diciéndolo.

⚠ Y el primer intento fue abortar, que era lo natural y estaba MAL. Al publicar el perfil entero con esa versión, 65 de 175 paquetes se quedaron fuera del catálogo: son las LIBRERÍAS —zlib, ncurses, openssl, musl-*, gmp…—, que no traen ningún binario y existen para que otros las resuelvan como dep. Un campo que su consumidor ni mira habría tirado un tercio del repo. Así que no aborta: avisa, y el paquete se publica. Lo mide el barrido de abajo, no el razonamiento — esa versión pasaba los tests igual.

Publicando el perfil servidor entero con el pack arreglado (medido, store del hub):

publicados y firmados162 (el índice verifica contra su trust)
target_bin corregidos8: bash sed tar parted dhcpcd squid wpa_supplicant zsh
sin binario (librerías)64 — se publican, con aviso
sin sellar en ESTE store12
fallaron1 (os-release, el mismo del §6.46)

Esos 8 son paquetes que estaban publicados prometiendo un path que no existe: install bash habría hidratado y muerto al final. Comprobado después del arreglo, contra el repo firmado servido por HTTP: parted (6 parches y binario en /usr/sbin) instala en 0,17 s y responde parted (GNU parted) 3.7; bash instala y responde 5.3.0(1)-release.

5.7 El nombre ya existe: repo.gioser.net (2026-09-21)

Dado de alta en la zona de Hetzner (la DNS de gioser.net está ahí: helium/hydrogen/oxygen), con la convención que ya usaban takana, git, gitea, sergio y www: repo A → 2.29.29.217, ttl 60. Resuelve (comprobado contra Cloudflare; el resolutor de Google todavía servía el NXDOMAIN cacheado — negativo de 1 h por el minimum del SOA, no es un fallo del alta).

⚠ La API vieja de DNS de Hetzner ya no es la buena. dns.hetzner.com/api/v1/zones con Auth-API-Token devuelve HTML de la consola web (301 → 200 de una SPA), que es lo peor que puede devolver: no falla, contesta. Las zonas viven hoy en la API de cloud y las lee el MISMO token de ~/.config/hcloud/cli.toml:

curl -H "Authorization: Bearer $TOKEN" https://api.hetzner.cloud/v1/zones          # gioser.net = 986350
curl -H "Authorization: Bearer $TOKEN" https://api.hetzner.cloud/v1/zones/986350/rrsets

Durante unas horas el nombre resolvió y no sirvió nada —http:// daba el 308 global de Caddy y el HTTPS moría en el handshake (tlsv1 alert internal error) porque sin site block no hay certificado—, hasta que se pudo entrar como root a la caja. Cerrado el 2026-09-21 con scripts/servidor/publicar-repo.sh --install-vhost, que publica y firma en /srv/repo, añade el bloque y lo aplica; por defecto imprime el bloque sin tocar el Caddyfile, porque en esta caja Caddy sirve 19 dominios y su config ya acumula 20 .bak.

⚠ Publicar no es construir, y repo-perfil.sh no lo acotaba. pack --build es instantáneo con cache-hit y una compilación entera si el artefacto no está sellado. Medido al ensayar el guion: la corrida se puso a compilar libnftnl —y después habría seguido con rust— en una caja de 4 cores que ya estaba a load 46. build-repo.sh llevaba timeout desde siempre; el que se corre en producción, no. Ahora BUILD_TIMEOUT (120 s) y SKIP_UNSEALED=1: un vencimiento no es un error, significa «ése no estaba sellado». Con eso, el perfil entero publica 173 anclados, 0 sin ancla, 2 saltados (os-release, rust) sin compilar nada.

El --install-vhost, probado en sus dos caminos (con un Caddy de juguete, porque el de la caja no tiene el admin abierto):

caminoqué pasó
reload OK✓ la config valida → ✓ recargado; el admin muestra el srv1 nuevo sirviendo el repo y el sitio que ya estaba sigue en pie
reload FALLAcopia restaurada byte a byte (b"repo.local" not in el fichero final), y no recarga nada

El orden es lo único que lo hace seguro: copia → añadir → caddy validate → sólo entonces reload. Un reload con la config rota no tira el sitio nuevo: tira los 19.

⚠ Y root no escribe en el árbol del usuario. work_root sale de dirname(store)/work ⇒ con el store en /store es /work, donde sources y out son enlaces a /work/sergio/work/…, que es del usuario (CLAUDE.md §3 bis). Publicar como root dejaría ahí directorios de root y el siguiente build suyo moriría con un permiso denegado que no menciona la causa — es exactamente lo que ya pasó cuando root hizo git pull en el clon de tawasuyu y lo congeló para el dueño (publicar-webs.sh). El guion, si corre como root y nadie fijó TAKANA_WORK, lo manda a /var/tmp/takana-publicar-work y lo dice. Comprobado que el override no mueve el hash: zsh sella en b3:0be3630d… igual.

⚠ Y trae su propia guarda contra el error más fácil de cometer: si el takana de la caja es anterior al §5.5 (se detecta porque no conoce outdated), aborta antes de publicar. Con uno viejo el repo sale con las 16 recetas multi-parche irreproducibles y 8 paquetes prometiendo un binario que no existe — y eso no se nota hasta que alguien instala.

⚠ Lo que queda vivo y no se tocó: un install <librería> DIRECTO sigue fallando tras hidratar, porque el .swm exige que el target_bin exista y para una librería no hay respuesta buena. Hacer target_bin opcional toca el formato (SDD 06) y es su propia unidad de trabajo. Como dep funcionan, que es para lo que están en el catálogo.

El lazo, después: takana install zsh --repo http://… --require-signed ⇒ cache-hit del hash anclado, 1331 ficheros hidratados en 0,14 s, y el binario corre (zsh 5.9). Antes: recompilar zsh entero para morir al final.

5.6 takana outdated — el aviso que faltaba (2026-09-21)

Ningún verbo comparaba lo instalado con el catálogo (upgrade es de árboles/generaciones, no de paquetes), así que «avisame cuándo hay que actualizar» no tenía respuesta. outdated la da leyendo sólo el índice firmado y la DB de instalados — no construye, no baja .swm, no escribe nada.

Compara por hash, no por versión, y eso es lo único que lo hace útil: un repo se re-publica sin que cambie la versión upstream cada vez que se mueve algo que entra en hash_inputs (una flag, el lab, un parche), y mirar la etiqueta diría «al día» sobre un artefacto que ya no es el que el repo sirve. La versión es el respaldo para cuando falta el ancla, y se dice que el juicio es más débil.

scripts/servidor/avisar-actualizaciones.sh lo corre por cron y sólo grita cuando la lista de novedades cambia (un guardián que repite lo mismo cada 30 min deja de leerse). Un repo caído no se disfraza de «al día»: lo dice, sale ≠ 0 y conserva el último estado bueno.

⚠ Lo que sigue sin poder hacerse, y no lo arregla ningún verbo: actualizar en una máquina sin lab. El aviso llega a cualquiera; el install que lo resuelve sigue siendo reproducir desde fuente (§5.4). Por eso outdated no actualiza: instalar es verificar, y eso no se cuelga de un cron.

5.8 Puerta 5, de verdad: repo.gioser.net sirve el repo firmado (2026-09-21)

$ takana install zsh --repo https://repo.gioser.net --trust ./trust --require-signed
release: trusted (by release)
repo: bajados 6 .swm de https://repo.gioser.net
caché: artefacto ya en el store hash=b3:0be3630d… name=zsh
  → hidratados 1331 archivo(s)                                    real 0m0,286s

173 paquetes anclados, 0 sin ancla, índice firmado con la clave de release de la caja y certificado CN=repo.gioser.net de Let's Encrypt. Los 2 que faltan (os-release, rust) no estaban sellados y el SKIP_UNSEALED los saltó en vez de ponerse a compilar.

Lo que había que descubrir para poder aplicarlo, y no estaba escrito en ningún sitio:

El espejo provisional se quitó. Mientras no hubo root, el catálogo estuvo servido sin firmar bajo takana.gioser.net/repo (un árbol escribible sin root, con certificado válido). Funcionó —install zsh en 0,22 s— y sirvió para probar el mecanismo, pero un origen sin firma no es un origen: con repo.gioser.net firmado en pie, se borró. Lo que quedó de aquello vale la pena recordarlo: firmar con una clave que no es la de release habría sido peor que no firmar, porque una autoría inventada se lee igual que una real; se publicó sin firma y con --require-signed rechazándolo, que es lo único honesto que se podía hacer sin la clave.

⚠ Sigue siendo un solo origen (ADR 0014). La caja se sirve a sí misma; la lista de --repo de los clientes debería llevar también el Storage Box. El primer origen roto, si es el único, es el último.

5.9 El aviso, enchufado en la caja (2026-09-21)

41 * * * * en el crontab de root → /usr/local/bin/avisar-actualizaciones, un envoltorio de tres líneas que sólo fija las rutas de la caja (REPO, DB, TRUST, STATE, TAKANA) y llama al guion del repo. La lógica no se copia a propósito: el día que el guion cambie, el cron no se queda con una versión vieja — y se comprobó en vivo, arreglando el guion en el clon y viendo cambiar la salida del envoltorio sin volver a desplegar nada.

Tres piezas que faltaban en la caja y ahora están:

/var/lib/hammer/trust/release.ed25519.pubel almacén de confianza por defecto de la CLI, que no existía ⇒ install/outdated verifican la firma sin pasar --trust
/usr/local/bin/takana → /usr/bin/takana⚠ CORREGIDO el 2026-10-07: /usr/local/bin/takana NUNCA EXISTIÓ, y este párrafo lo daba por puesto. El cron falló cada hora durante dos semanas y media con !! falta /usr/local/bin/takana (cargo build --release), una línea por hora en un log que nadie lee. Y la otra mitad de la frase también era falsa: el binario de la caja SÍ conoce outdated (comprobado: /usr/bin/takana outdated --help imprime su ayuda). El envoltorio apunta ahora a /usr/bin/takana y el aviso contesta: «al día contra https://repo.gioser.net (2 paquete(s) comprobado(s))». Sigue siendo cierto que el PATH de la caja no incluye /usr/local/bin y que el cron usa rutas absolutas
/var/log/takana-actualizaciones.loguna línea por hora

⚠ Lección de la corrección de arriba, que vale más que la corrección. Este documento afirmaba que una pieza estaba «en la caja» cuando no estaba, y el aviso que debía delatarlo escribía su queja en /var/log/takana-actualizaciones.log: una línea por hora, durante 18 días, en un fichero que nadie abre. Un guardián cuyo único canal es un log sin lector no es un guardián — es un registro. Es la misma forma de fallo que el latido («NO pude publicar» cada 30 min, 12 días) y la que este documento ya describe tres párrafos más abajo sobre el «al día» vacío. Al enchufar un aviso hay que decidir dónde grita cuando ÉL mismo está roto, no sólo qué dice cuando funciona.

⚠ Y la primera corrida real destapó un defecto del propio aviso: con la DB de instalados vacía decía «al día». Es verdad y no dice nada — peor, se lee como «comprobado y correcto» cuando lo cierto es que no había nada que comprobar. Un aviso que suena igual cuando vigila que cuando no vigila nada es exactamente cómo se deja de mirar un aviso. Ahora distingue: «nada instalado por takana en esta máquina (DB …) — no hay qué comparar». Control en la caja con una DB de juguete (un zsh a otro hash): grita 1 con novedad · hash distinto, y en la segunda corrida calla.

Hoy la caja no instala de su propio repo —viene de imágenes—, así que el aviso dirá esa línea hasta que lo haga; se pone igual para que el día que instale algo ya esté, y porque una línea por hora no es ruido. Cada hora y no cada 30 minutos: el repo cambia cuando alguien publica.

5.10 takana install zsh en el sistema de verdad — cinco cosas que faltaban (2026-09-21)

El usuario escribió sudo takana install zsh en la caja y le contestó «paquete 'zsh' no está en el repo /var/lib/hammer/repo. Disponibles: (ninguno)». Reproducido tal cual. Debajo de ese mensaje había cinco cosas distintas, y ninguna se ve hasta que alguien instala de verdad.

1. El repo por defecto no existía. DEFAULT_REPO está cableado a /var/lib/hammer/repo y el repo publicado vive en /srv/repo. Un enlace (/var/lib/hammer/repo → /srv/repo) hace que install <nombre> sin --repo funcione y sin pasar por la red: un directorio local sirve igual que la URL.

2. El lab no se encontraba desde /root. install reproduce desde fuente y el toolchain entra en el ArtifactHash, así que sin .dev-fs ni siquiera puede calcular el hash a comparar (§5.4). La resolución es TAKANA_LAB → hermano del store → hacia arriba desde el CWD; con el store en /store y el CWD en /root, ninguna acertaba. Enlace /.dev-fs → /work/dev-fs y la regla del hermano del store acierta desde cualquier directorio y para cualquier usuario. (Los dos labs de la caja tienen el mismo apk db byte a byte, así que el LabFingerprint es el mismo.)

3. El takana del sistema no podía leer los paquetes nuevos. Los .tkn de hoy llevan los parches como lista (§5.5) y un cliente anterior ignora ese campo —serde salta lo desconocido—, así que reconstruiría sin parches y moriría contra el expected_hash. Se reemplazó por el build release. ⚠ Y se comprobó ANTES de tocar que /usr/bin/takana tenía enlaces=1 e inodo distinto al del store: es una copia, no un hardlink. Un cp encima de un fichero hidratado que SÍ fuera hardlink reescribiría el artefacto del store por debajo — se borra y se pone uno nuevo, nunca se sobrescribe.

4. ⚠ La instalación quedó PARTIDA, y eso no lo dice ningún mensaje. Sin --prefix, install abre un overlay sobre el FHS… pero sólo sobre siete directorios clásicos (/bin, /usr/bin, /sbin, /usr/sbin, /lib, /usr/lib, /etc). /usr/share no está entre ellos, así que de los 1331 ficheros de zsh, 1296 fueron directos al FHS real y sólo los 2 binarios quedaron en el upper esperando el commit. El mensaje final dice apply OK igual. Un discard en ese estado habría borrado el binario y dejado 1296 ficheros huérfanos: el experimento no era reversible y parecía que sí.

5. ⚠ commit no puede desmontar /bin en una máquina viva. Los procesos tienen su ejecutable mapeado desde ahí —empezando por PID 1, /usr/bin/arje-zero— y un ejecutable mapeado pin-ea el montaje. Resultado: desmontó 5 de 7 targets, murió con target is busy en /bin, y dejó dos overlays montados con el estado sin promocionar, que es el peor de los finales. El comentario del propio do_umount_lenient prometía el desmontaje perezoso desde la Fase 2 y el código nunca pasaba -l. Arreglado: ante busy reintenta perezoso, que desengancha el montaje ya mismo (las rutas vuelven a resolver al directorio real, que es lo que el promote necesita) y libera cuando el último proceso que lo miraba se muere.

El arreglo, probado en la caja viva con un segundo paquete instalado de cero:

$ takana install tree           ⇒ overlay 1790022033-1575-000000
$ takana commit 1790022033-1575-000000
WARN takana_overlay: umount: el target estaba ocupado (procesos con ejecutables mapeados ahí,
     normal en un FHS vivo) ⇒ desmontaje perezoso  target=/usr/bin
commit OK — 1 archivo(s) promocionados, 0 eliminado(s) (anotado en el diario)
⇒ overlays montados: 0 · /usr/bin/tree real · `tree v2.3.2` corriendo

Mientras tanto la caja se dejó limpia a mano: los dos binarios copiados al /bin REAL —visto a través de un mount --bind / no recursivo, porque el overlay tapaba el destino—, los dos montajes sueltos en perezoso y el estado borrado. Ahora: zsh 5.9 en /bin/zsh, 1296 ficheros en /usr/share/zsh, cero overlays, y takana installed lo lista.

Y la trampa del §5.7 se cobró su pieza: root dejó un /work/swm-recipes suyo dentro del /work del usuario (el catálogo de recetas que install materializa; work_root sale de dirname(store)/work). Devuelto a sergio. El guion de publicar ya exporta TAKANA_WORK para esto; install a secas no tiene esa defensa, y es deuda anotada.

5.11 chsh no funcionaba, y las dos razones eran distintas (2026-09-21)

Pedido del usuario después de instalar zsh. Dos capas, y sólo la primera es un arreglo.

1. /etc/shells no existía. chsh de shadow rechaza cualquier shell que no esté en esa lista —para un usuario normal es un rechazo duro, no un aviso— y el fichero no estaba en la caja. Escrito con lo que EXISTE, comprobado -x uno por uno (/bin/sh, /bin/ash, /bin/bash, /bin/zsh) y no copiado de otra distro: un shell listado que no está deja a quien lo elija sin poder entrar, y eso no se descubre hasta el siguiente login. Con eso, chsh -s /bin/zsh sergio funciona y su - sergio entra con zsh 5.9.

2. ⚠ Pero sergio sigue sin poder cambiárselo él mismo, y eso no es un fichero que falte.

$ su sergio -c "chsh -s /bin/bash"
Cannot change ID to root.

Los cinco binarios de shadow que necesitan setuid root —chsh, passwd, su, chfn, newgrp— salen del store como r-xr-xr-x. No es que la hidratación pierda el bit: la receta lleva --disable-account-tools-setuid, heredado del APKBUILD de Alpine (donde esos binarios los hace setuid el empaquetado, no el configure — y acá no hay quien se lo reponga).

Y hay una segunda capa debajo: ArtifactHash::of_tree sólo hashea el bit de EJECUCIÓN (mode & 0o111). O sea que hoy el modelo de artefactos no puede ni expresar ni garantizar un binario setuid: aunque la receta lo pusiera, el hash no lo cubriría y nadie notaría si se pierde o si aparece.

⇒ Quitar la bandera no alcanza y no es un arreglo de receta: es una decisión de seguridad. Un setuid root en una distro de artefactos sellados necesita contestar quién lo pone, quién lo verifica y qué significa que el hash no lo cubra. Anotado, sin decidir.

⚠⚠ Y lo que de verdad hay que llevarse de esto: un overlay abierto se traga TODA la máquina. Mientras el /etc/shells y el chsh se escribían, había un overlay abierto sobre el FHS —de un takana install sin --prefix que alguien había dejado sin commitear— y las dos escrituras cayeron dentro de él, aunque no tenían nada que ver con ese paquete ni pasaron por takana (fueron un cat > y el chsh de shadow). Medido con un mount --bind / no recursivo, que enseña el directorio real sin el overlay encima:

/etc reallo que se veía
/etc/shellsno existeexiste
shell de sergio/bin/bash/bin/zsh

O sea: el trabajo estaba hecho «según la pantalla» y un discard o un reinicio lo borraba. Se resolvió con commit (5 ficheros promocionados: passwd, passwd-, shells, zsh, zsh-5.9) y ahí el desmontaje perezoso del §5.10 se ganó el sueldo: saltó en /bin y en /usr/bin.

⇒ install sin --prefix no es «un experimento para ese paquete»: cambia la semántica de escritura de la máquina entera hasta que alguien commitea o descarta. Cualquiera que toque /etc mientras tanto —otro agente, un servicio, un humano por ssh— escribe en el upper sin enterarse. El overlay no avisa: no hay nada en el prompt, ni en mount a simple vista, ni un aviso al entrar. Como mínimo takana status tendría que salir en el arranque de sesión, y install debería decir qué queda pendiente en vez de un overlay listo que se lee como «terminé». Anotado.

Consecuencia práctica mientras tanto: los cambios de shell y de contraseña en esta caja los hace root. Y una observación que sale de mirar esto: no hay /etc/shadow; las contraseñas viven en /etc/passwd, que es legible por todos, con hash DES clásico. No lo toqué — es su propia unidad de trabajo y afecta al arranque de la caja.


6. La mudanza como experimento: el criterio de borrado

El usuario decidió que gioser se borra al final. Eso no es un detalle de calendario: es lo que convierte la mudanza en un experimento con criterio de aceptación, y hay que escribirlo antes.

Regla base: un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue bien. Aplicado acá: «lo copié» no es prueba de nada. La prueba es que el servicio responde en la caja nueva y que el dato se verifica por contenido.

6.1 Las puertas, en orden. Ninguna se salta.

#puertacómo se comprueba
1la caja nueva arranca takana puro y se entra por SSHssh a la IP nueva tras el reboot del rescue
2el store está entero1327 artefactos y ninguno vacío — respaldo-storagebox.sh --listar separa los vacíos; un directorio vacío no es un artefacto
3los grafos salen idénticosbuild-state.py en las dos máquinas ⇒ los cuatro grafos byte a byte. Es la prueba de que un hub está bien montado, no que «funcione»
4la granja late en la caja nuevauna cosecha completa con el LXC enchufado por .fleet y artefactos que vuelven
5el repo se sirve y el host se actualiza de sí mismo§5 en verde, con la clave estable
6cada dominio vivo responde 200 desde fuerala tabla del §1.1, repetida contra la IP nueva
7el respaldo al Storage Box corre desde la caja nuevauna corrida completa, no un --seco
8lo fósil está anotado y decididolista del §1.1 con «muere» o «revive» al lado de cada uno

⚠ Puerta 3 tiene una trampa conocida: sealed_remoto dependía de la máquina (laptop 700, gioser 751 sobre el MISMO corpus) y por eso se sacó del JSON. Si los grafos difieren, mirar primero si la diferencia es «cuánto de esto tengo en disco» y no «qué existe».

6.3 Estado de las puertas (2026-09-10)

#puertaestado
1arranca takana puro y se entra por SSH✅ §3.8
2el store está entero✅ 1362 artefactos reales, 0 vacíos
3los cuatro grafos idénticos✅ byte a byte — §6.7
4la granja late en la caja nueva✅ cosecha completa: 1369→1575, 0 vacíos — §6.8
5el repo se sirve y el host se actualiza de sí mismo⬖ mitad: sirve (§5.1), no consume (§5.4)
6cada dominio vivo responde 200⬖ sondeados: 6 vivos de 19 — §6.9
7el respaldo corre desde la caja⬖ alcanza el box y lista 3140; arreglados 3 bloqueos — §6.10
8lo fósil, anotado y decidido✅ decidido — §6.9

Puerta 2, medida acotando al patrón <hash>-<nombre>: 1362 artefactos, 0 vacíos. Los 3 sin .hammer/recipe.toml son stage1-rootfs, product-rootfs y seed-zig — productos de bootstrap, que por diseño no salen de una receta. (El primer conteo dio «3 vacíos» y eran .mirror-tmp, .bootstrap-tmp y .divergen: directorios de trabajo con punto inicial, no artefactos. El chequeo tiene que acotar por el patrón, no listar el directorio.)

De paso, el disco: la imagen ocupaba 7 G de los 76,3 G de la caja y los otros 69 eran inalcanzables. Arreglado en install-image.sh — el store pasa a ser la ÚLTIMA partición (la única que puede crecer sin mover datos) y el wrapper de /sbin/init la extiende en el primer arranque. Medido en la caja: /store = 68,7 G, contra los 487 M de antes. Con eso el store de 55 G entra en el disco local y la mudanza no depende de mover el volumen de gioser.

6.4 ⚠ El muro de la puerta 3: la imagen trae 23 binarios que NO PUEDEN CORRER

Al ir a computar los grafos en la caja:

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

No es que falte: está y no arranca. /usr/bin/python3.12 son 23 MB y es un ELF dinámico:

Requesting program interpreter: /lib/ld-musl-x86_64.so.1
NEEDED  libc.so

Y en toda 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-x86_64.so.1 en ningún artefacto del corpus. Lo que hace que estos binarios funcionen en gioser es el rootfs Alpine del LAB, que sí lo trae (.dev-fs/alpine/lib/ld-musl-x86_64.so.1).

Es la fuga de [[needed-colgante-libstdcxx]] otra vez, y esta vez en el piso de abajo: sella, reproduce y no corre, porque pide algo que sólo existe en el laboratorio.

Contado sobre la imagen: 23 binarios inertes de 726 (703 estáticos están bien). Son la suite binutils entera (ar as ld nm objcopy objdump ranlib readelf strip addr2line c++filt elfedit gprof size ld.bfd), más perl, python3, sqlite3, flex y nft.

Por qué frena la mudanza y no es un detalle: todo el instrumental del proyecto es Python — build-state.py, targets.py, yupana.py, hydrate-profile.py, drenar.py, triaje.py. Un servidor takana no puede correr ni una de sus propias herramientas. La puerta 3 (los cuatro grafos) y la 4 (la granja) dependen de eso.

Y no se arregla de paso. Las salidas son tres y ninguna es barata:

  1. Publicar el cargador — que musl emita libc.so + ld-musl-x86_64.so.1. Es lo correcto y lo más chico en concepto, pero toca un componente de Stage 1: mueve el baseline of_tree del selfhost y hay que rehacerlo a propósito, no de rebote.
  2. Construir python/perl estáticos — el camino que evita el cargador, y es un frente propio: Python estático con módulos de extensión es notoriamente arisco.
  3. Aceptar que el instrumental vive en el HUB y que el servidor sólo sirve. Es la respuesta honesta a corto plazo, pero contradice «mover allá todo lo que tengo acá»: el hub seguiría siendo gioser, que es justo lo que se quiere apagar.

Es decisión de ADR, y de las que conviene tomar mirando también el SDD 27 §4: las dos preguntas —«¿el cliente reproduce o hidrata?» y «¿el cliente puede correr lo que le mandamos?»— son la misma pregunta sobre qué es un sistema takana sin laboratorio.

6.5 ✅ El muro, derribado: el corpus publica su propio cargador (2026-09-11)

De las tres salidas del §6.4 el usuario eligió la 1 y la 2. Hecha la 1, que resultó ser más barata de lo que parecía y desbloquea la 2 en vez de competir con ella.

recipes/musl-shared.toml — variante dinámica de musl que publica /usr/lib/libc.so (4,2 M) y /lib/ld-musl-x86_64.so.1. En musl el cargador Y la libc son el mismo objeto.

Por qué una variante y no --enable-shared en la canónica, con su control: musl es componente de Stage 1 y su of_tree es el baseline de selfhost-verify. Tocarla obliga a rehacer ese baseline a propósito. Con el patrón *-shared —que el corpus ya usa 23 veces— la canónica no se mueve: 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 que trae zig), así que esto suma sin mover un solo ArtifactHash del corpus.

⚠ Y hubo que cambiar de compilador, medido

Con zig-cc el libc.so sale con 1586 símbolos dinámicos contra los 1654 del musl 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 (ceil floor sqrt fmod exp log sin cos tan fma round trunc) — más los __stack_chk_*.

No es que no se compilen: en el libc.a del musl canónico están. Lo que pasa es que 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», no como «a la libc le faltan símbolos».

Con compiler = "gcc": 1651 símbolos, memset y ceil presentes.

El resultado, en la caja

antesdespués
sh: python3: not found (con command -v contestando /usr/bin/python3)Python 3.12.10
23 binarios inertes de 7261

perl 5.040002, readelf/objdump/nm (GNU Binutils 2.45.1) y el resto corren. El único que quedaba era sqlite3, con Error loading shared library libz.so.1: la zlib canónica también es --disable-shared y nadie publicaba ese soname. zlib-shared ya existía sellado en el corpus — sólo faltaba declararla en base.

La salida 2 (python/perl estáticos) sigue viva y ahora es opcional, no urgente: con el cargador publicado el sistema ya no depende del rootfs del lab. Estático seguiría siendo mejor (un binario menos que puede quedar colgado de un soname), pero es un frente propio y ya no bloquea la mudanza.

6.6 Sembrar el hub: un hub no es repo+store, es repo+store+LAB

Con python3 vivo, takana hash en la caja seguía fallando:

Error: no pude leer el apk db del lab en /.dev-fs/alpine/lib/apk/db/installed.
       El toolchain entra en el ArtifactHash ⇒ sin rootfs no hay hash comparable.

El lab es parte de la IDENTIDAD, no del entorno. Una máquina sin él no puede ni preguntar «¿cuál es el hash vigente de esta receta?», así que no puede computar el grafo ni decidir qué falta. Sembrado el lab (tarball pineado + tools/, bajo /store/dev-fs porque la raíz son 6 G), el hash testigo coincide byte a byte: takana hash recipes/zlib.toml da b3:dc363f26… en las dos máquinas.

⚠ Y la caja no podía desempacar su propio lab: el tar del rootfs es el de busybox y contesta tar: unrecognized option: zstd. La distro comprime todo con zstd —la imagen del lab, el respaldo, el dd remoto— y la imagen no traía el binario. zstd ya tenía receta sellada; declarado en base.

⚠⚠ Copiar un store con rsync -a es una fábrica de vacíos, y ahora se sabe POR QUÉ

La primera copia murió con No space left on device en un .so de Qt, dejando el destino al 100 % y 1367 de 1368 artefactos VACÍOS. La puerta 2 corrida EN EL DESTINO lo cazó en el acto.

La causa, medida:

medición sobre el store de gioser
du -sh (hardlinks contados UNA vez)60 G
du -sh --count-links (hardlinks EXPANDIDOS)85 G
.dmerge (la caché que hardlinkea al store)11 G

rsync sin -H no preserva hardlinks: los expande en copias enteras. Así que «el store son 60 G» —que es lo que dice du y lo que uno planifica— se convierte en 85 G + lo que .dmerge expanda al otro lado. La partición de 68,7 G no tenía ninguna chance, y el modo de fallar es el peor: el disco se llena a mitad y quedan cientos de nombres de artefacto sin contenido, que el store da por presentes.

La copia correcta es rsync -aH --exclude='.dmerge': -H preserva los enlaces (60 G reales) y .dmerge no se copia porque es caché regenerable — la misma que ya llenó un disco en el worker.

La lección de método: du -sh sobre un árbol con hardlinks no dice cuánto ocupa copiarlo. Para dimensionar una copia hay que medir con --count-links, o preservar los enlaces.

Y el instrumental apunta al binario de DESARROLLO, no al instalado

La primera corrida de build-state.py en la caja devolvió 875 recetas unhashable — todas — con el lab bien sembrado y takana hash funcionando a mano. La causa:

HAMMER = os.environ.get("HAMMER", str(ROOT / "target/release/takana"))

Los scripts asumen un árbol de desarrollo: target/release/takana. En una caja instalada el binario es /usr/bin/takana y target/ ni existe (la siembra lo excluye a propósito). No falla ruidosamente: cada hash falla y el grafo sale entero como unhashable, que se lee como «este corpus no se puede hashear» y no como «no encontré el binario».

Se destraba con HAMMER=/usr/bin/takana STORE=/store, que la propia cabecera del script documenta. Pero la deuda queda anotada: el instrumental debería caer a command -v takana antes de rendirse, porque el caso «hub instalado» es justamente el que esta mudanza quiere que exista.

6.7 ✅ PUERTA 3 EN VERDE: los dos grafos, idénticos byte a byte

gioser: 7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605
caja:   7435d33d62577e8ce154e8c5e354e0465139cfbc632afe710076ea2223cea605

base 61/61 · cli 84/84 · escritorio-mirada 41/41 · servidor 96/96 · falta 0, wanted 3 (chrony, cronie, logrotate — la deuda declarada), grafo CIERRA, topo-sort OK. Las dos máquinas.

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

Intento 1 — unhashable 875, todas. Ver arriba: los scripts apuntan a target/release/takana.

Intento 2 — dos nodos divergentes: aichat y zola, sealed en gioser y never en la caja. No era deriva ni pérdida: ninguno de los dos está en disco en gioser tampoco. Son los «2 sellados que no están en esta máquina, avalados por los manifiestos» que el propio resumen imprime. build-state.py lee work/farm-sellados.txt y work/respaldo-sellados.txt — y work/ está gitignored, así que un hub recién sembrado no los tiene y degrada esos nodos a never sin que nada falle.

Es la misma familia que scripts/farm/.fleet: un fichero fuera de git del que depende una medición compartida. La regla que sale: sembrar un hub no es clonar el repo — es repo + store + lab + los manifiestos. Copiados los dos ficheros, los grafos coinciden exactamente.

6.8 Puerta 4: el camino de la granja, PROBADO; la cosecha completa, sin disco

Probado desde la caja (sin tocar el latido de gioser, todo de sólo lectura salvo el último paso):

✅ CERRADA (2026-09-11) con el volumen takana-store

Resuelto el disco (§6.12), la cosecha completa corrió: 1369 → 1575 artefactos, 0 vacíos, que son exactamente los 206 que faltaban. 12,4 G por la red para 39 G lógicos —speedup 3.14, el ahorro de -H— y /store queda en 73,7 G de 97,9 (79 %).

⚠ El enlace con el worker va a 11 MB/s, no a los 90 MB/s de gioser↔caja: el LXC vive en el Proxmox de gioser, fuera de la red de Hetzner. Media hora para una cosecha de este tamaño.

⚠ Lo que faltaba no era mecanismo: era DISCO.

artefactos en el worker que la caja no tiene206
a ~113 MB de media (el artefacto medio del corpus)~23 G
libre en /store de la caja3,7 G (61,5 de 68,7 · 94 %)

Una cosecha completa no entra, y forzarla repetiría exactamente el llenado que dejó 1367 artefactos vacíos hace un rato. La puerta 4 queda pendiente de una decisión de CAPACIDAD, no de una de ingeniería:

  1. Enganchar harkaq-cosecha (250 G) a la caja. Es el plan original y no cuesta nada extra — pero hay que DESENGANCHARLO de gioser, y ahí vive el store, work/sources, los tarballs y el CARGO_HOME de la granja. O sea: es el cutover. gioser deja de construir en ese mismo instante, y hoy está moliendo el árbol KDE.
  2. Un volumen nuevo para la caja (€0,052/GB/mes: 100 G ≈ €5,2/mes). No interrumpe nada y destraba la cosecha ya; se paga por duplicado hasta que gioser muera, y entonces se suelta.
  3. Podar el store de la caja — deshace parte de la mudanza. No.

6.9 Puerta 8: los 19 dominios, sondeados uno por uno (2026-09-11)

El Caddyfile de gioser declara 19 sitios. Sondeados por DNS y por HTTP desde fuera, sólo 6 los sirve gioser de verdad:

dominioDNSHTTPqué es
gioser.net · tawasuyu.netgioser200VIVO — las dos landings
git.gioser.net · git.tawasuyu.netgioser200VIVO — el gitea (aloja el origin de takana)
sergio.gioser.net · api.sergio.gioser.netgioser200VIVO
summa · api.summa · dev.summa154.197.1.2200YA MUDADOS a otra máquina: el bloque de Caddy en gioser es fósil aunque el sitio viva
api.gioser.netgioser502backend caído
mail.sigma.gioser.netgioser502backend caído
aura · api.aura · sigma · kosmofono · api.kosmofonoSIN DNS—fósil: ni DNS ni ficheros en /var/www
terapeuta.ec · andino.ecSIN DNS—fósil con datos: 279 M en /var/www/terapeuta
gitea.gioser.netSIN DNS—era un redir a git.gioser.net; inofensivo

Lo que esto cambia respecto del §1.1: no son «4 fósiles» sino 13 de 19 entradas que no hay que mudar — 8 sin DNS, 2 con el backend caído y 3 que ya viven en otra máquina. La superficie real a mudar son 6 sitios, y de esos el que pesa es el gitea, porque aloja el origin de takana.

✅ DECIDIDO (usuario, 2026-09-11)

fósildecisión
aura · api.aura · sigma · kosmofono · api.kosmofonomueren — ni DNS ni ficheros
gitea.gioser.netmuere — era un redir, y git.gioser.net lo cubre
api.gioser.net · mail.sigma.gioser.netmueren — backend caído, sin dueño
summa · api.summa · dev.summano se mudan: ya viven en 154.197.1.2. Sólo hay que sacar sus bloques del Caddyfile al apagar gioser
terapeuta.ec · andino.ecMUEREN — decisión explícita del usuario. Los 279 M de /var/www/terapeuta se van con la caja; no se archivan

⇒ De las 19 entradas del Caddyfile, 13 no se mudan. La superficie real es de 6 sitios, y el que pesa es el gitea porque aloja el origin de takana.

⚠ Que esto quede escrito ES la puerta: el día que se borre gioser, terapeuta.ec no va a poder recuperarse, y la diferencia entre «lo decidimos» y «se nos pasó» es este párrafo.

6.10 Puerta 7: el respaldo desde la caja — y tres bloqueos que sólo se ven corriéndolo

Llevadas las credenciales (~/.config/hammer/*.env, 0600) y la clave, la caja alcanza el Storage Box por el puerto 23 y lista su contenido. El manifiesto se refresca desde allá: 3140 artefactos, y de paso el script detecta 2 VACÍOS en el respaldo (gnome-desktop ×2) que excluye del manifiesto — su propia guarda, funcionando.

Para llegar ahí hubo que arreglar cinco cosas, y todas son de la misma familia: el instrumental asume que el hub es gioser. El --seco ahora completa los tres pasos desde la caja y lee la ocupación del box (111 G de 1 T).

  1. La raíz cableada. RAIZ="${RAIZ:-/mnt/vvv/takana}" ⇒ desde cualquier otra máquina el script moría con cd: /mnt/vvv/takana: No such file or directory. Ahora se deriva de dónde está el script, como el resto de scripts/.

  2. El binario de desarrollo. HAMMER = ROOT/target/release/takana (§6.7).

  3. El store no estaba donde el script creía. Usaba $RAIZ/store cableado: en gioser es un bind-mount DENTRO del repo, pero en una caja instalada es una partición en /store ⇒ change_dir "/opt/takana/store" failed. Y rsync devuelve 23, que está en la lista de reintentables, así que el bucle insistía — el cuadro que la propia cabecera del script documenta («un error reintentable que se repite 40 veces no es un corte de red: es algo estructural»). Ahora STORE es env, con una guarda que aborta ANTES del bucle si la ruta no existe.

  4. 🚨 Y una que habría costado el historial. 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í habría borrado .git del Storage Box, o sea el historial entero, y en silencio: rsync haría exactamente lo que se le pidió. Añadida una guarda que aborta si la raíz no tiene .git (escotilla REPO_INCOMPLETO=1), probada en los tres sentidos.

    La regla que sale: 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.

  5. El rsync del corpus no puede correr el respaldo del corpus. El script exige --compress-choice=zstd y recipes/rsync.toml lo construía con --disable-zstd — porque cuando se escribió, zstd no estaba en el catálogo. Hoy sí. Peor aún: el error 4 que devuelve rsync el script lo clasifica como «no de red» y no reintenta, así que el respaldo simplemente no se hacía. Arreglado por los dos lados: la receta enciende zstd (control: zstd zlibx zlib none vs zlibx zlib none; radio cero, rsync no es dep de nadie) y el script degrada a zlib avisando en vez de morir.

6.11 ⚠ La distro traía un git que NO PODÍA CLONAR

Para cerrar la puerta 7 la caja necesita un clon de verdad (el --delete del respaldo exige un origen completo, §6.10). Llevada la clave del gitea —que es ~/.ssh/tawasuyu, no la de la granja— git ls-remote contesta… y git clone 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 causas, encadenadas:

  1. -fsanitize=undefined estaba en CFLAGS, no sólo en LDFLAGS. El motivo original era legítimo —las libz.a 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 convertido en una mina de RUNTIME, justo en la ruta de hash: por donde pasa todo lo que git recibe.
  2. sha1collisiondetection lee palabras de 32 bits SIN alinear. Es el backend SHA1 por defecto de git, el que detecta SHAttered. En x86 la lectura desalineada funciona y por eso nadie lo nota jamás; según el estándar es UB, y basta con que el runtime ubsan esté enlazado para que aborte. -DSHA1DC_FORCE_ALIGNED_ACCESS lo hace leer byte a byte: quita el UB en la fuente en vez de esconderlo.

Con las dos: git clone --depth 1 del propio repo desde la caja ⇒ 877 recetas. Radio cero (git no es dep de nadie), pero sí es raíz de perfil.base ⇒ esto arregla la distro entera.

La lección: ls-remote andaba, así que el fallo parecía de red. Lo que lo destapó fue leer el informe COMPLETO en vez de la última línea — la primera decía exactamente qué y dónde.

6.12 ⚠ La caja dejó de ser desechable — y hydrate no cruza el store

Dos consecuencias de que la caja ya sea un hub, y las dos cambian el procedimiento:

1. Re-escribir la imagen con dd ya NO es una actualización, es una pérdida. El dd sobrescribe el disco local, y ahí viven ahora /opt/takana (el clon), /root/.ssh (las dos claves), /root/.config/hammer (las credenciales del respaldo), /srv/repo y la configuración de caddy. El store se salva sólo porque está en el volumen. Y hay una trampa 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 — con suerte el volumen, con mala suerte una partición vacía. ⇒ el dd es para PROVISIONAR; actualizar es takana upgrade (que existe, con generaciones y rollback) y está sin probar en esta caja.

2. takana hydrate no puede proyectar al root vivo. Hidratar enlaza en DURO, y en una caja instalada el store es siempre otra partición que /:

Error: store: hardlink /store/51aa5e13…-git/.hammer/recipe.toml → /.hammer/…: Cross-device link

No es culpa del volumen: en el layout original /store es sda4 y / es sda2, también distintos. Hidratar al FHS vivo nunca fue posible en una caja instalada — funciona en el hub porque ahí store y destino están en el mismo filesystem. Es la misma familia de EXDEV que ya mordió dos veces (§6.6), ahora a nivel de sistema y no de herramienta.

Para instalar el git arreglado se copió el artefacto a mano. Eso funciona y no es el camino: el camino es takana upgrade, y probarlo es su propia unidad de trabajo.

6.13 ✅ takana upgrade, probado de verdad — y el bug de durabilidad que destapó

§6.12 dejó dicho que el camino de actualización de una caja instalada es takana upgrade, no dd ni hydrate. Probado con un paquete que la caja realmente necesitaba: zstd.

pasoresultado
upgrade apply …-zstd-cli✓ generación 2 aplicada · + 1 añadidos, ~ 1 pisados, - 8 retirados
la prueba de usozstd -dc lab-image.tar.zst | tar -xf - ⇒ la caja desempaca su propio lab
upgrade rollback✓ generación 2 revertida · 9 restaurados, 1 borrados — zstd se va, libzstd.a vuelve
re-apply + corte duro sin syncsobrevive: generación viva 1, manifiesto íntegro

✔ Y cruza la frontera que hydrate no puede: el store está en el volumen y / en el disco local, y upgrade copia en vez de enlazar. Es, de hecho, el único camino que funciona en una caja instalada.

⚠ El bug: write_atomic era atómico pero NO DURABLE

El primer intento pareció funcionar y se perdió al reiniciar. 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

write_atomic hacía temporal + rename: atómico frente a otros PROCESOS, no frente a un CORTE. rename sobre un fichero cuyos datos siguen en caché deja, tras el corte, la entrada apuntando a bloques nunca escritos — cero bytes. Y hcloud server reset es un corte duro, no un apagado limpio: ahí está la mitad operativa de la lección.

Lo grave no es perder un upgrade: es que recover —que existe exactamente para «un apply interrumpido por un corte»— abortaba con la huella más probable de ese corte.

Arreglado: fsync del temporal antes del rename y fsync del directorio después (hacen falta los dos), y un pending.json vacío se reporta como PendingCorrupt, nombrando el corte y apuntando al árbol de respaldos. Con tests y su control.

Verificado en la máquina, en los dos sentidos: con el binario viejo, apply + reset duro ⇒ estado en 0 bytes; con el nuevo, la misma secuencia ⇒ estado íntegro y el paquete en su sitio.

⚠⚠ 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:

✓ generación 2 aplicada
  + 0 añadidos, ~ 8 pisados, - 1 retirados      ← el `zstd` de la generación 1
$ zstd --version
AUSENTE

Aplicar un árbol nuevo RETIRA los ficheros del anterior, porque una generación es el estado completo, no un incremento. Es coherente con lo que la ayuda dice —«aplica un árbol Stage1/producto del store»— y con que exista rollback: lo que se revierte es un sistema, no un paquete.

⇒ Usarlo para instalar paquetes sueltos, como hice acá, 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 como una generación. Eso además sería, de verdad, la «actualización en sitio probada» del SDD 19 §5.2: una imagen entera, con rollback, sobre una caja viva.

Mientras tanto la caja quedó con zstd y rsync puestos a mano — funciona y no es el camino, igual que el git.

⚠ Lo que NO se arregló: si 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.

6.2 Lo que la mudanza tiene que producir, además de la mudanza

El usuario lo pidió explícito: que este experimento saque recetas y las pruebe. La caja vieja es un catálogo de software real en producción, y cada servicio que hoy corre sobre binarios de Artix es una receta candidata con un caso de uso comprobado detrás:

lo que corre hoy en Artixreceta
caddy✅ existe (recipes/caddy.toml)
openssh✅ existe
gitea✅ SÍ existe (recipes/gitea.toml) y está sellada (35bb4f04…) — ver la corrección abajo
qdrant, squid, fail2ban, php-fpm, metalog, glances✗
chrony, nftables, cronie, logrotate✗ — ya declaradas wanted en perfil.servidor

CORRECCIÓN (2026-09-11). Esta tabla decía que gitea NO tenía receta y que «es la que más peso tiene». Las dos cosas eran falsas: recipes/gitea.toml existe desde el 2026-09-09 y su artefacto está sellado. Lo escribí de memoria, sin mirar el catálogo.

Lo destapó planear.py al cruzar cada binario contra recipes/: de los servicios de gioser, 4 ya tienen receta takana y están sellados (caddy, gitea, python3…), 4 vienen de un paquete ajeno y 11 son binarios sueltos sin dueño. Ésos últimos son el trabajo real, no gitea.

Es exactamente para esto que la herramienta existe: yo afirmé de memoria y el programa fue a mirar.

Cada una que se cierre se prueba sola: el servicio o levanta en la caja nueva o no, y eso se ve el mismo día. Es la diferencia entre una receta «sellada» y una receta usada.


6.11 🗄️ La imagen del perfil, con gitea, ARRANCA y SIRVE (2026-09-14)

servidor-image.sh + QEMU: PID 1 = arje-zero, gitea encarnado por él (ppid=1), corriendo como uid=916, escuchando en :3000 y devolviendo 200 con <title>takana git</title>, con su gitea.db creada por él mismo. Es el primer servicio de PAQUETE que arranca en una imagen de takana: los que había venían horneados en el bootstrap.

La cadena entera, eslabón por eslabón: [[user]] → /etc/passwd de la imagen · [[service]] → takana service-cards → genesis de la seed → arje lo encarna → setuidgid → sirve.

Los tres fallos que sólo aparecieron AL PROBAR LA IMAGEN

Los tres pasan el sellado, el hash, el resolutor de servicios y el armado. Ninguno se ve sin arrancar la imagen.

  1. ⚠⚠ Escribir en el rootfs hidratado es escribir DENTRO del store. takana users --merge hacía fs::write sobre <rootfs>/etc/passwd, y ese fichero y el del artefacto product-rootfs son el mismo inode (medido: 1225824, 2 links, modo 444) — el rootfs se arma con hardlinks. Habría mutado un artefacto sellado, y toda imagen futura habría salido con la cuenta metida dentro del producto. Se salvó porque el store es de sólo lectura y salió Permission denied: confiar en eso es confiar en un permiso. Ahora escribe por temporal + rename, con un control que comprueba que el fichero del store conserva su contenido y baja a 1 link.

  2. La imagen traía el binario, la cuenta… y nadie lo arrancaba. El genesis sólo tenía lo que hornea takana-bootstrap (sshd, console-getty, hammerd): servidor-image.sh no inyectaba las Cards — eso sólo existía en el camino de las imágenes de escritorio. Y ninguna métrica lo dice: targets.py --services responde que el perfil lo habilita, y lo habilita; lo que faltaba era el paso que lleva esa declaración a la imagen.

  3. ⚡ trap invalid opcode: el binario sólo corría en la CPU que lo compiló. Con la card ya en el genesis, gitea moría al instante dentro de QEMU-TCG. El sandbox exporta CC apuntando a un wrapper con -mcpu=baseline (y su comentario ya avisaba: «de paso cierra el SIGILL de AVX en qemu64»), y la fase Go del propio builder lo pisaba con CC="zig cc" a secas, que es -mcpu=native. No falla al compilar ni al sellar: falla al ejecutar en otra CPU. Y el ArtifactHash no puede cazarlo, porque la CPU del builder no entra en hash_inputs — dos workers distintos sellan bytes distintos bajo la misma dirección. Arreglado en el builder y en la receta; re-hashea las cinco recetas cgo = true (gitea, usql, sq, gocryptfs, naabu).

Lo que NO está probado, y falta para la mudanza

El app.ini, el usuario del sitio y los datos se pusieron a mano dentro de la VM para llegar al 200. Eso es exactamente lo que el plan de mudanza tiene que traer de gioser — /etc/gitea ya está en scripts/mudanza/rutas-fuera-de-git.txt.

Y la imagen no trae arjectl: tras poner la config hubo que REINICIAR para que arje volviera a intentarlo. El backoff de restart se agota y no hay forma de pedirle a PID 1 que relance un ente en caliente. Para una caja de producción eso es una pieza que falta, no una comodidad.

6.12 🎛️ arjectl en la imagen: control en caliente (2026-09-14)

El §6.11 cerró con «la imagen no trae arjectl, hubo que reiniciar la máquina para arrancar un servicio». Ya lo trae, y no hubo que escribir nada: el cliente existía en tawasuyu y se llama arje-ctl (el crate; el binario es arjectl). Buscarlo por arjectl no lo encontraba, y de ahí salió la conclusión falsa de que faltaba implementarlo — el protocolo (arje-bus::BusRequest) ya traía ListEntes, SpawnCardFromDisk, StopCardFromDisk, KillEnte y EnteStatus.

recipes/arjectl.toml lo construye del mismo commit que arje-zero (98db584f), y eso no es comodidad: el bus es un protocolo entre dos binarios, y un cliente de otro árbol puede conectar y no entenderse con el init. Publica sólo arjectl; el crate también produce un systemctl de camuflaje que acá no se instala — un systemctl en el PATH de una distro sin systemd invita a escribir runbooks con el verbo ajeno.

El hueco que sólo se ve usándolo: el genesis no alcanza

Con arjectl en la imagen, el primer intento falló con un mensaje que nombra la causa:

$ arjectl start gitea
Error: arje-zero rechazó: card gitea: No such file or directory
       (buscada en /etc/arje/cards.d/gitea.json)

start/restart usan SpawnCardFromDisk, que lee /etc/arje/cards.d/<label>.json — y el armado sólo escribía el genesis de la seed. Son dos preguntas distintas: qué arranca solo y qué se puede encarnar a pedido. Con sólo la primera, un servicio que agota su backoff no se puede relanzar sin reiniciar la máquina. inyectar-cards.py escribe ahora los dos árboles (y en cards.d escribe TODAS, no sólo las nuevas: sshd viene del product-rootfs y tampoco era relanzable).

Medido, con la VM arrancada UNA sola vez

la imagen trae/etc/arje/cards.d/{gitea,sshd}.json
arjectl list-unitstabla con PID, CPU%, MEM, HILOS y reinicios de cada Ente
poner /etc/gitea/app.ini + arjectl start giteaGET / → 200, sin reiniciar (uptime 6 min)
arjectl restart giteael PID cambia (118 → 192) y sigue sirviendo 200

⚠ arjectl start sobre un Ente YA VIVO lo DUPLICA

SpawnCardFromDisk no deduplica por label: encarna otra instancia y arje le da un ULID nuevo. En gitea el síntoma fue unable to lock level db … resource temporarily unavailable seguido de [F] — dos servidores peleando por el mismo estado, con el HTTP intermitente mientras duraba. Para relanzar se usa restart, o se mira list-units antes. Un start idempotente es trabajo de arje, no de esta imagen; queda anotado río arriba.

6.13 🧪 Ensayo con los DATOS REALES de gioser — el gitea de la caja vieja corriendo en takana

Sin tocar gioser (todo lectura) y en la VM desechable. Es el ensayo que faltaba antes de cualquier cutover, y trajo cuatro cosas que no se ven en el papel.

El snapshot de la DB se saca en caliente y sale consistente. sqlite3 gitea.db ".backup …" con el servidor VIVO: 1,5 s para 314 M, pragma integrity_check → ok, 44 filas en repository. Copiar el fichero a pelo con el servidor escribiendo es lo que NO hay que hacer; la API de backup de sqlite existe justo para esto.

Resultado, medido dentro de la VM: <title>GioSer Gitea: Git with a cup of tea</title>, 200, y /explore/repos listando los repos de verdad (sergio/takana, tawasuyu/agora, card, chasqui, cosmos, khipu, llimphi…). Y el clon de uno de los repos copiados: 478 commits, HEAD correcto, ficheros reales.

Los tres detalles que sólo aparecen con los datos puestos

  1. El uid del origen no es el del destino. Los ficheros llegan con su uid NUMÉRICO (1001 en el rsync de prueba; el gitea de gioser es 969) y el [[user]] declaraba 916. O se chownean 2 G —lento, y hay que acordarse— o gitea no puede leer sus datos, y eso no falla al copiar: falla al arrancar. La receta pasa a declarar 969, alineado con el origen.

  2. El app.ini de gioser escucha en 127.0.0.1:3002, porque allá caddy hace de proxy. Copiado tal cual, la caja sirve — pero sólo desde dentro. El HTTP_ADDR/HTTP_PORT y el ROOT_URL son parte de la adaptación, no del copiado.

  3. 🧨 El git de la distro NO puede clonar por HTTP. git clone http://… dentro de la imagen:

    git: 'remote-http' is not a git command. See 'git --help'.
    fatal: remote helper 'http' aborted session
    

    Medido sobre el ARTEFACTO sellado: /usr/libexec/git-core/ trae git-remote-ext, git-remote-fd y git-http-backend (el lado SERVIDOR), y no git-remote-http/-https; strings del binario da 0 referencias a libcurl, pese a que curl está en [deps] build. Nadie lo había notado porque en el hub el git que se usa es el de Artix, no el sellado. Es [[subcomando-sin-driver]] otra vez: instalado, con contenido, reproducible… y sin el camino que hace falta. Un hub nuevo no podría clonar el repo por HTTPS. Arreglarlo re-sella git, que es raíz de perfil.base ⇒ unidad propia, no de paso.

6.14 ✅ CUTOVER DEL GITEA — sirve desde la caja, con TLS y por SSH (2026-09-14)

Hecho y verificado desde fuera: git.gioser.net y git.tawasuyu.net responden 200 con TLS válido desde 2.29.29.217, git clone funciona por HTTPS y por SSH:2345, y gitea y caddy corren supervisados por arje-zero en la caja. El gitea de gioser está parado.

Mudanza incremental, no big-bang. Los DNS de gioser son casi todos CNAME → www, así que mover www habría mudado quince dominios cuyos backends siguen allá: 502 en todos. Se convirtió sólo git (en las dos zonas) de CNAME a A propio con TTL 60, que es reversible en un minuto.

La secuencia que funcionó, en orden

  1. Caja actualizada: arjectl + gitea + la cuenta gitea:969 + las cards en genesis y cards.d.
  2. Datos en dos fases: 1,66 GB / 18 047 ficheros con el origen VIVO (17 s), y en el corte el rsync --delete incremental + sqlite3 ".backup" (integrity_check ok).
  3. Parar el origen de verdad. rc-service gitea stop dice «already stopped» con el proceso vivo (el rc-status que miente, §Los tres hechos), y matarlo no alcanza: lo revive arje-zero, que lo tiene como card openrc-gitea con Restart — 9001 reinicios acumulados en el contador. Se paró con arjectl stop openrc-gitea, y para eso hubo que extraer su card del genesis y escribirla en /etc/arje/cards.d/ (§6.12: SpawnCardFromDisk/StopCardFromDisk leen de ahí, no del genesis).
  4. DNS por la API de Hetzner Cloud (/v1/zones): el token de hcloud también gestiona el DNS desde la unificación — la API vieja dns.hetzner.com/api/v1 redirige a la consola. Y un PUT sobre el rrset no puede cambiar el tipo: hay que DELETE del CNAME y POST del A.
  5. TLS: el primer intento de ACME falló contra gioser (502) porque el challenge salió antes de que propagara; con el DNS ya al día, arjectl restart caddy y certificate obtained successfully.

La identidad SSH se muda con el servicio

git clone ssh://…:2345 daba REMOTE HOST IDENTIFICATION HAS CHANGED: es otra máquina. Se copiaron las claves de host de gioser (/etc/ssh/ssh_host_*) a la caja ⇒ los clones existentes no notan nada. El precio es que cambia también la identidad del :22 de administración, y hay que limpiar el known_hosts propio — a los clientes del servicio no les afecta, que es lo que importa.

Y el sshd del producto escucha sólo en :22: el git por SSH necesitó Port 2345 y que el usuario gitea tenga shell real (su authorized_keys fuerza command="gitea serv …", y con /bin/false no se ejecuta nada).

Lo que esto cambia para la granja

Las 23 recetas que ahora clonan por https://git.tawasuyu.net/… apuntan a la caja. Comprobado tras el cambio: el worker resuelve 2.29.29.217 y git ls-remote responde. La granja depende ya del servidor nuevo, que es el sentido de la mudanza.

Cómo se revierte, si hiciera falta

arjectl start openrc-gitea en gioser y devolver los dos git a CNAME → www. Con TTL 60, minutos.

6.15 Cerrar el intermedio: el viejo apagado de verdad, el :22 cerrado y los datos respaldados

⚠ El gitea viejo había VUELTO a arrancar — y lo arrancó OTRO supervisor

Media hora después del corte, gioser volvía a servir en :3002. arjectl list-units ya no lo mostraba (el stop de arje seguía en pie) y el proceso tenía ppid ≠ 1: lo había levantado OpenRC, que lo tenía en el runlevel default. O sea dos supervisores para el mismo servicio —la card openrc-gitea de arje y el runlevel de OpenRC— y parar uno no para el otro.

rc-update del gitea default   # que no vuelva
rc-service gitea stop         # ahora sí: OpenRC lo arrancó, OpenRC sabe pararlo

Antes del corte pasaba lo contrario (rc-service … stop decía «already stopped» con el proceso vivo, porque quien lo tenía era arje). La pregunta no es «¿está parado?» sino «¿quién lo tiene?», y hay que responderla dos veces.

El origen deja de servir git, y se comprueba que no se rompió lo demás

Los tres bloques (git.gioser.net, git.tawasuyu.net, gitea.gioser.net) salen del Caddyfile de gioser —con respaldo previo, caddy validate y reload—, y quedan 16. Control inmediato: sergio.gioser.net y hifas.gioser.net siguen en 200. gitea.gioser.net pasa a A → la caja, donde ya está su redir.

El :22 cerrado, sin perder el acceso

Administración en 22022, git por SSH en 2345, y el 22 cerrado (Connection refused) para sacarse de encima el barrido de bots. La secuencia importa y es la única segura: añadir el puerto nuevo → reiniciar → entrar por él → sólo entonces quitar el 22. Comprobado en ese orden, y el git clone ssh://…:2345 sigue funcionando después.

🧨 Los datos del gitea NUNCA estuvieron respaldados

respaldo-storagebox.sh respalda el store de artefactos, no /var/lib/gitea. O sea que los 44 repos vivían en una sola copia — en gioser antes, en la caja ahora. La mudanza no lo empeoró, pero sí lo hace urgente: gioser se borra. Hecho hoy desde la caja: snapshot de la DB con el servicio vivo

⚠ Al Storage Box se entra por el puerto 23, no el 22: el 22 da SFTP/SCP restringido y contesta Permission denied (publickey,password), que se lee como «no tengo la clave» cuando la clave está perfecta. Pasó en las dos máquinas antes de mirar el script (SB_PORT=23).

Pendiente: que ese respaldo sea periódico, no de una vez. Es un renglón en el cron de la caja.

6.16 ⚠ SÍ se perdieron dos commits en la ventana — y cómo se detectó

La pregunta correcta después de un cutover no es «¿arrancó?» sino «¿entró algo en el viejo mientras tanto?». Se contesta comparando todos los refs de los dos lados:

for r in <repos>/*/*.git; do git -C "$r" for-each-ref --format="$r %(refname) %(objectname)"; done | sort

845 refs a cada lado, y dos diferencias. Una era esperada (sergio/takana main, el nuevo por delante con los commits posteriores al corte — merge-base --is-ancestor confirma fast-forward). La otra no: tawasuyu/tawasuyu tenía en el VIEJO dos commits que el nuevo no tenía — b69008eb (freebsd) y 02a9535f (shuma, 19:48)—, o sea quince minutos después del snapshot final.

Por qué entraron: el gitea viejo había resucitado (§6.15, OpenRC) y un cliente con el DNS cacheado —el CNAME anterior tenía TTL 600— lo empujó a gioser aunque el autoritativo ya dijera la caja. Las dos condiciones a la vez: un origen que revive y una caché que todavía apunta.

Recuperados con un git push del ref exacto desde el clon local al gitea nuevo; la UI del nuevo ya muestra 02a9535f. Segunda pasada de comparación: 845/845 y una sola diferencia, la esperada. Y del lado de los metadatos, action tenía tres filas posteriores al snapshot (los mismos dos repos) y cero issues, comentarios o releases: no se perdió nada que no fueran esos commits.

La lección para el resto de la mudanza, en orden: (1) bajar el TTL antes del corte, no durante; (2) apagar el origen de verdad —los DOS supervisores— antes de tocar el DNS; (3) comparar refs después, siempre, porque es la única prueba de que no se perdió nada; y (4) dejar el origen apagado un rato antes de borrarlo, precisamente para poder hacer esta comparación.

6.17 ⚠ En gioser conviven arje Y OpenRC — y el prefijo openrc- de las cards ENGAÑA

El usuario avisó: «esa máquina la tengo con arje desde hace meses». Tiene razón, y las dos cosas son ciertas a la vez:

medido
PID 1arje-zero ✓
la card openrc-giteaejecuta sh -c "cd /var/lib/gitea && exec /usr/bin/gitea web …" — no llama a OpenRC
/run/openrc/started/existe y tiene servicios: NetworkManager, dbus, dhcpcd, fsck, hostname, hwclock, local, localmount…
rc-update show defaultlistaba gitea

⇒ El prefijo openrc- es herencia del NOMBRE, de cuando arje-absorb tradujo los servicios de OpenRC a cards: la card no pasa por rc-service, encarna el binario directo. Pero OpenRC quedó como residuo ACTIVO y puede arrancar servicios por su cuenta — que es lo que revivió al gitea con ppid ≠ 1 después de que arje lo hubiera detenido.

La regla para el resto de la mudanza: antes de dar un servicio de gioser por apagado, preguntar a los dos:

arjectl list-units | grep <svc>          # la capa de arje (PID 1)
rc-update show default | grep <svc>      # el residuo de OpenRC, que también arranca

Es [[la-etiqueta-no-es-el-hecho]] en su forma más cara: un nombre que dice openrc- y no es OpenRC, junto a un OpenRC real que nadie esperaba que siguiera operando.

6.18 Mudados takana.gioser.net y hifas.gioser.net (estáticos puros)

Los dos son ficheros y nada más —sin backend—, así que mudarlos fue copiar 52 K, añadir su vhost al Caddyfile de la caja, mover el DNS de CNAME a A y quitarlos del origen. 200 con TLS válido desde 2.29.29.217, y control de que no se rompió lo demás: sergio.gioser.net sigue en 200 y gioser baja de 16 a 14 vhosts.

Se repitió el patrón de ACME: el primer intento de certificado sale ANTES de que propague el DNS, falla contra el origen y hay que forzar el reintento con arjectl restart caddy. Conviene mover el DNS y recién entonces añadir el vhost — o asumir un restart de más.

Quedan en gioser 14 vhosts, todos con backend propio (uvicorn, php-fpm, tejido, shuma…) o fósiles sin DNS. Ésos no se mudan copiando un directorio: necesitan su servicio del otro lado.

6.19 Los fósiles, barridos: de 19 vhosts a 2

Se midió cada dominio que quedaba —DNS por DoH y HTTP contra la IP del origen— antes de tocar nada. El resultado deja la mudanza mucho más chica de lo que parecía:

clasecuántosqué son
fósiles sin DNS5aura, api.aura, sigma, kosmofono, api.kosmofono
ya viven en OTRA máquina3summa, dev.summa, api.dev.summa → 154.197.1.2
502 permanente2mail.sigma (:9000 caído), api.gioser.net (:8000 caído)
mudados hoy5los tres de git + takana + hifas
VIVOS y por mudar2sergio.gioser.net y api.sergio.gioser.net

Y ninguna de las raíces estáticas de los fósiles existe en disco: /var/www/{aura_frontend, sigma,kosmofono,summa,summa-dev} no están. El Caddyfile servía directorios ausentes — la configuración sobrevivió a sus datos. De los backends, sólo :8770 sigue escuchando, y su dominio ya apunta a otra máquina; :8766 :8765 :9000 :8000 :8768 están caídos.

Los 10 bloques muertos salen del Caddyfile (con respaldo previo, validate y reload), y el control confirma que los vivos siguen: sergio y api.sergio en 200, y los mudados respondiendo desde la caja. gioser pasa de 19 vhosts a 2.

⚠ El único fósil CON datos es terapeuta.ec: 279 M en /var/www/terapeuta, sin DNS y sin vhost. No se borra acá — se anota para la decisión de borrado de la máquina, que es del §6.

Lo que esto cambia: mudar los dos que quedan es un frente acotado —un backend Python en :7378 y una API en :8771— y no las quince cosas que la lista sugería.

6.20 🛟 Cinco binarios rescatados de la nada — el paso que CADUCABA

Antes de tocar sergio.gioser.net apareció lo urgente: cinco procesos vivos cuyo binario YA NO EXISTE en disco (/proc/<pid>/exe → … (deleted)). Sólo existían como inode huérfano: si el proceso muere o la máquina se reinicia, se pierden. Es el paso que el SDD 29 §4.0 undecies llamó «el que caduca», y caducaba de verdad.

tejido · sandokan-mcp · pacha-secretos · puerta-f6e393ffbef3999e · shuma-gateway   (240 M)

Se recuperaron leyendo /proc/<pid>/exe con el proceso vivo, y ya están fuera de gioser: en /work/rescate-binarios/ de la caja y en rescate-binarios/ del Storage Box. Cinco de los trece binarios que nadie provee dejaron de depender de que nadie reinicie nada.

6.21 🧱 sergio.gioser.net: el muro no es el sitio, es glibc

El sitio son tres piezas, y sólo una se puede mudar hoy:

piezaqué esmudable a takana
el frontend117 M de estático (dist/)✅ copiado ya a /work/www/sergioh
/shuma/*shuma-gateway en :7378 — ELF dinámico, y su binario estaba BORRADO⚠ hay que construirlo desde tawasuyu (es Rust del monorepo)
api.sergiouvicorn con un venv de 405 M🧱 no, hoy no

El backend es fastapi + uvicorn + pydantic + google-generativeai + **langchain**, con extensiones compiladas …cpython-314-x86_64-linux-**gnu**.so — o sea glibc. Reempaquetar ese árbol de PyPI como recetas no es realista, y correrlo en musl no es posible. Es el caso de uso canónico del ADR 0015 (qorpa: imágenes ajenas glibc enjauladas), que está PROPUESTO y sin implementar del todo.

⇒ El DNS de sergio NO se movió: mover el nombre sin el backend deja el sitio servido y la consola rota. El frontend queda copiado y esperando; el corte se hace cuando exista el camino para las otras dos piezas.

Esto es lo que decide la fecha de borrado de gioser: no es «mudar dominios», es que un servicio vivo no tiene hoy camino a takana. Las salidas son tres y hay que elegir: (1) qorpa, que es para exactamente esto; (2) que ese servicio viva en otra máquina —summa ya lo hace, y su DNS lo demuestra—; o (3) que muera. Ninguna es técnica: es una decisión.

6.22 🧨 El curl de la distro NO PUEDE VALIDAR TLS — y por eso nada se baja por HTTPS

Al ir a traer la primera imagen ajena (qorpa pull), la caja falló con curl failed to verify the legitimacy of the server. El certificado del servidor estaba bien; lo que falta es de este lado:

* CApath: /etc/ssl/certs
* SSL certificate ... unable to get local issuer certificate (20)
$ curl --cacert /etc/ssl/certs/ca-certificates.crt ...   → 200

El curl del corpus está compilado con --with-ca-path=/etc/ssl/certs y sin CAfile, y ese directorio contiene un solo fichero: el bundle. Un capath necesita hash-links (c_rehash) — el índice — y no los hay. Los certificados están, el índice no.

Por qué faltan: recipes/ca-certificates.toml deja escrito el hook /etc/ca-certificates/update.d/certhash, que en Alpine ejecuta apk al instalar. En takana no lo ejecuta nadie: la distro proyecta artefactos, no corre post-install. Es la misma familia que [[subcomando-sin-driver]] — todo presente, y el paso que lo conecta no existe.

Alcance: no es sólo qorpa pull. Es takana install --repo https://…, el mirror, y cualquier curl/wget de un script en una caja instalada. No falla al construir ni al instalar: falla la primera vez que la máquina intenta bajar algo. Nadie lo había visto porque el hub usa el curl de Artix.

Arreglado en la receta: los hash-links se generan en el build —donde c_rehash y perl ya existen— y viajan dentro del artefacto, con una guarda que falla ruidosamente si el rehash no produce ninguno. Re-sella ca-certificates, que es raíz de perfil.base.

Apaño mientras tanto, para invocaciones puntuales: CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt (comprobado: 200).

6.23 qorpa en la caja: preflight en verde, y el segundo muro es tar

Con el backend de sergio sin camino a musl (§6.21), la salida elegida es qorpa. Estado medido en la caja:

pasoresultado
userns anidado · overlayfs sin privilegios✅ ya estaban
disco❌→✅ /var/lib/hammer vive en la raíz (3 G) ⇒ qorpa se movió a /work (62 G) por symlink
subuid/subgid❌→✅ rango escrito para root
newuidmap/newgidmap sin capability❌→✅ setcap aplicado — y el artefacto del store NO se tocó (inodes distintos: en una caja instalada esos binarios son copias, no hardlinks)
veredicto del preflightde «BLOQUEA 1 · LIMITA 4» a «LIMITA 1»
qorpa pull de Arch bootstrapbaja y verifica el sha256, y falla al desempacar

El segundo muro: el rootfs de Arch es .tar.zst y el tar de la imagen es el de busybox, que no lo entiende — el mismo tropiezo que ya costó el lab (tar: unrecognized option: zstd). zstd SÍ está instalado. ⇒ qorpa pull tiene que descomprimir con zstd -dc | tar -x cuando el archivo es .zst, en vez de dárselo entero a tar. Es un arreglo pequeño en el CLI, y hasta entonces la alternativa es una imagen .tar.gz (Ubuntu base) — que además obliga a reinstalar las deps del venv, porque el de gioser trae extensiones de CPython 3.14 y Ubuntu 24.04 lleva 3.12.

6.24 El TLS arreglado, la distro con nombre, y por qué upgrade se quedaba sin espacio

El curl ya valida. Con ca-certificates re-sellado —119 hash-links en el capath, generados en el build— la caja baja por HTTPS sin ningún apaño: curl -I https://… → 200. El arreglo tardó tres intentos y los dos primeros los cazó la guarda que puse en la propia receta: primero c_rehash no estaba en el PATH (lo instala esa misma receta en /out/usr/bin), y después los certificados no estaban sueltos en usr/share/ca-certificates/ sino en su subdirectorio mozilla/, así que c_rehash sólo veía el bundle y lo saltaba —correctamente— con «does not contain exactly one certificate». Sin la guarda, las tres veces habría sellado un capath vacío.

fastfetch (2.68.1) corre en la caja — y al correrlo apareció otra ausencia:

OS: takana x86_64          ← ahora
Host: vServer (20171111)
Kernel: Linux 7.1.2 · CPU: AMD EPYC-Rome (4) · Memory: 393 MiB / 7.58 GiB

…porque /etc/os-release NO EXISTÍA. La distro era anónima para cualquier programa que no fuera suyo. Se resolvió con una receta propia (os-release, source.dir) en vez de una constante en takana-bootstrap: meterlo ahí re-sellaría el product-rootfs —el baseline del selfhost— por cinco líneas. Sin VERSION_ID ni fecha a propósito: un sello con la fecha del build cambiaría el hash del artefacto, y con él la clausura de toda imagen que lo lleve, cada día y sin motivo.

🧨 /var/lib/hammer es una partición de 487 MB — y ahí viven las generaciones

upgrade apply falló dos veces con No space left on device teniendo 3 G libres en la raíz. Ni el espacio de / ni los inodos (9% usados): el estado de generaciones vive en /var/lib/hammer, que es sda3 y mide 487 MB (la partición «estado» del layout). Cada generación guarda una copia del árbol aplicado — el nuestro son 272 MB — así que dos no caben.

⇒ El estado se movió a /work por symlink, igual que qorpa. Tras eso: ✓ generación 6 aplicada. El layout de la imagen necesita revisión: 512 MB de «estado» no alcanzan para un mecanismo que guarda un árbol entero por generación, y el síntoma (ENOSPC con la raíz medio vacía) apunta al sitio equivocado.

6.25 ✅ CUTOVER DE api.sergio.gioser.net — el primer servicio AJENO que sirve takana (2026-09-15)

https://api.sergio.gioser.net responde 200 con certificado público válido desde 2.29.29.217, y su /api/chat/ contesta con Gemini de verdad. El backend corre dentro de una jaula qorpa (rootfs de Arch pineado por sha256), supervisado por arje-zero como el ente sergioh-api.

Es el servicio que §6.21 declaró sin camino a musl —su venv trae extensiones cpython-314-x86_64-linux-**gnu**.so— y por eso «lo que decide la fecha de borrado de gioser». El camino elegido era qorpa (ADR 0015) y ahora está recorrido de punta a punta.

La coincidencia que lo hizo barato: el Arch bootstrap pineado trae python 3.14.7 y el venv de gioser es 3.14.6. Misma serie ⇒ las extensiones compiladas no son un problema: el venv se rehace adentro con pip, no se copia.

🧨 EL MURO: la jaula no viajaba en NINGUNA imagen

takana qorpa provision abortó con un mensaje correcto y una instrucción imposible:

Error: no encuentro harkaq-exec en /root/.cache/harkaq/harkaq-exec
  construilo:  gcc -O1 -Wall -static -o … scripts/harkaq/harkaq-exec.c

En una caja instalada no hay gcc, ni scripts/, ni árbol de desarrollo. El binario sólo existía como un gcc a mano en la caché de $HOME del hub — o sea que la jaula del ADR 0015 funcionaba únicamente en la máquina donde alguien la había compilado. Y su compañero estaba igual, medido en build-state.json: bwrap sellado desde hace meses con "perfiles": [], CERO perfiles. Los dos se invocan por PATH, y no sólo desde qorpa: takana-build/src/sandbox.rs llama a bwrap en cada build. ⇒ ninguna imagen de takana podía enjaular nada, ni construir. Es [[subcomando-sin-driver]] un piso más abajo: el CLI que los llama viaja en todas las imágenes y sus herramientas en ninguna.

Arreglado en la misma unidad de trabajo: recipes/harkaq-exec.toml (nueva, estática musl, b3:cd34954f…, 269 K), bwrap + harkaq-exec declarados en perfil.base, y qorpa.rs buscando el binario también en /usr/bin —el orden es HARKAQ_BIN → árbol de desarrollo → paquete, para que un cambio en la jaula se pruebe sin instalar nada—. ⚠ El pin de la receta va al commit que tocó la fuente (1d9ddcee, del 2026-09-03) y no a HEAD: el repo commitea cada media hora por el cron de la cosecha, y pinear HEAD re-hashearía la receta cada media hora sin que su fuente se moviera.

Los tres tropiezos del camino, todos de la misma familia

  1. chown: el rsync preservó el uid 1001 de gioser, y dentro del userns ese uid queda FUERA del rango mapeado ⇒ ni siendo root adentro se puede escribir. python -m venv fallaba con Permission denied en el directorio concedido mientras andaba perfecto en /tmp. El árbol del sitio va a root:root del anfitrión, que es lo que el mapa de la jaula sí alcanza.
  2. Una instancia, un run: el segundo qorpa run sobre la misma instancia es rechazado por el kernel («dos overlays con el mismo upper corromperían la capa mutable»). Correcto y hay que saberlo: con el servicio arriba no se entra a la instancia a hacer mantenimiento.
  3. ps de la caja no ve los procesos y dig no existe en el hub. Dos herramientas ausentes que se leen como diagnóstico: el primero dice «no hay nada corriendo» y el segundo dejó un until de espera de DNS girando cinco minutos contra una condición que nunca podía cumplirse. Un guardián que no puede pasar nunca se ve igual que uno que todavía no pasó.

Lo que quedó declarado, y lo que no

El estado de la mudanza tras esto

mudado hoyapi.sergio.gioser.net (DNS: CNAME→www borrado, A→2.29.29.217 TTL 60)
pendiente inmediatoapagar el sergioh_backend de gioser cuando propague (OpenRC y arje: rc-update del + rc-service stop + arjectl stop openrc-…, §6.17). Hasta entonces sirve a los resolutores que aún tengan el CNAME cacheado
lo que falta de sergio.gioser.netel frontend ya está copiado en /work/www/sergioh; falta /shuma/* → shuma-gateway (:7378), que es glibc dinámico y cabe en esta misma jaula (el binario está rescatado en /work/rescate-binarios/) pero cuelga de shuma-daemon, que es un sistema entero, no un binario
y los otros dos vivosgioser.net/www (estáticos + /hooks/* en :8770 + /reencuentro con php-fpm) y tawasuyu.net (estático, raíz en /mnt/vvv/tawasuyu)

6.26 ✅ tawasuyu.net y gioser.net MUDADOS — y el PHP que no existe en el corpus (2026-09-15)

Los dos sitios que quedaban con tráfico real sirven desde la caja takana, con TLS público y verificados desde fuera. Los servicios viejos siguen corriendo en gioser a propósito (decisión del usuario: mudar el DNS y dejar el origen vivo, para poder volver en un minuto).

dominioqué esestado
tawasuyu.net, www.landing + /pkg wasm + /descargas (3,8 G)200 desde 2.29.29.217
gioser.net, www.5 raíces estáticas (428 M) + /reencuentro con PHP200 desde 2.29.29.217
sergio.gioser.netfrontend + /shuma/*se queda en gioser: su consola no tiene camino todavía

sergio dejó de ser un CNAME → www y tiene su A propio (apuntando a gioser, sin cambio visible). Sin eso, mover www se lo habría llevado puesto — es la trampa del §6.14 vista a tiempo: en esta zona casi todo cuelga de www. Lo que sí quedó colgando son tres fósiles (api, mail.sigma, monitor): antes daban 502 desde gioser y ahora fallan el TLS contra la caja. Están muertos hace meses; se anotan, no se mudan.

Dos cosas que el log de accesos decidió mejor que cualquier intuición:

PHP: la segunda jaula, y por qué no entra al corpus

/reencuentro/ es una página con un guardar.php. PHP no está en el corpus y no tiene por qué estar: lo sirve php-fpm 8.5.10 dentro de la instancia gioser-php (ADR 0015), por 127.0.0.1:9000, supervisado por arje. Control contra el original, en los dos sentidos: POST con la trampa anti-robots → {"ok":true} 200 igual que en gioser; GET → 405 igual que en gioser.

⚠ El webroot entra a la jaula EN LA MISMA RUTA (/work/www/gioser-web): el SCRIPT_FILENAME de FastCGI es una ruta absoluta del anfitrión, y si adentro viviera en otro sitio php-fpm contesta «File not found» sin decir cuál. Y la config de php-fpm vive en el ANFITRIÓN (/work/etc/gioser-php/php-fpm.d/) y entra por concesión: el upper de la instancia es caché y recreate se la llevaría.

🧨 Los padres que inventa bwrap son 0700 — y eso deja la concesión fuera de alcance

El muro real del día, y es general, no de PHP:

$ curl -X POST https://gioser.net/reencuentro/guardar.php
File not found.          ← y el fichero ESTÁ, montado, con sus modos correctos

Para montar en /work/www/gioser-web, bwrap tiene que crear /work y /work/www —que la imagen de Arch no trae— y los crea drwx------ root. Adentro somos root, así que todo a mano funciona; pero un servicio que baja de privilegio (php-fpm a http, o cualquier instancia con run_as) no puede ni atravesarlos. Medido con el control que lo separa de una confusión de permisos del anfitrión: como http, leer un fichero de la imagen funciona y leer el directorio concedido da Permission denied.

⇒ grants_to_args crea ahora los ancestros que faltan con --perms 0755 --dir, y sólo los que la imagen no trae: hacerlo sobre /etc o /home le cambiaría los modos a la imagen. Con test que falla a propósito si la condición se rompe (left: ["/etc","/etc/php"] contra right: ["/etc/php"]). Misma familia que el --perms 1777 que ya hacía falta antes del --tmpfs /tmp.

⚠ La caja corre el takana del commit pineado en recipes/takana.toml, así que allá el arreglo todavía no está: el camino se abrió a mano en el upper de la instancia (chmod 755 /work /work/www). Es exactamente lo que el arreglo vuelve innecesario en el próximo takana upgrade.

Y el estado de rust y de claude, que no es el que parece

6.27 🧦 squid mudado — y la jaula que arrancaba DEGRADADA cuando la arranca un init (2026-09-15)

El proxy de salida sirve desde la caja: squid 7.7 en 2.29.29.217:1137, con la config, el passwd de los tres usuarios y las dos ACL de gioser tal cual, dentro de la instancia squid y supervisado por arje. El de gioser sigue vivo e intacto (7.6), así que los clientes que apuntan a la IP vieja no se enteraron.

Es un servicio con gente encima: el access.log del origen mostraba CONNECT api.anthropic.com y CONNECT claude.ai en el minuto anterior a mudarlo, desde IPs de Venezuela y con tres usuarios autenticados. Por eso se mudó entero y sin reescribir nada.

Control en los dos sentidos: desde una IP no autorizada, 407 Proxy Authentication Required —igual que el original—; desde localhost, que su propia config permite, TCP_TUNNEL/200 a claude.ai y a api.anthropic.com.

Los cuatro muros, y el cuarto es el que vale

  1. provision con la config ya montada aborta: pacman dice «squid.conf.default exists in filesystem» porque el paquete quiere escribir sus defaults donde está la concesión. ⇒ provisionar primero, montar después. Queda escrito en el manifiesto.
  2. /dev/shm lo crea bwrap 0755 root, y squid —que baja a proxy— muere con FATAL: shm_open(...): (13) Permission denied. Un chmod 1777 en el arranque.
  3. El fichero de PID sobrevive entre corridas y SIEMPRE parece fresco: cada run tiene su propio namespace de PIDs, así que el /run/squid.pid de la corrida anterior dice PID 2… y en la nueva hay un PID 2 vivo. Squid concluye «Squid is already running» y se niega. ⇒ rm -f en el arranque. Es una trampa general de cualquier servicio con pidfile dentro de una jaula con --unshare-pid.
  4. 🧨 El rango de subuid se buscaba por $USER — y un init no lo pone. Bajo arje la tarjeta arranca con PATH y HOME y poco más; el nombre salía vacío, no había rango, y la jaula caía al userns de un solo id. Squid moría en setgid(15): (22) Invalid argument y el supervisor lo reintentaba 80 veces. El aviso de qorpa era correcto y estaba impreso —«no hay rango para "" en /etc/subuid»— pero lo que se ve en el bucle es el error de squid, no el nuestro: la degradación silenciosa la paga el de más abajo. Y el mismo comando a mano, desde una shell con $USER, funcionaba perfecto: el síntoma dependía de quién lo arrancaba. ⇒ nombre_de_usuario() deriva el nombre del uid real leyendo /etc/passwd, y el entorno pasa a ser el último recurso. El uid es el hecho; $USER es una etiqueta ([[la-etiqueta-no-es-el-hecho]]). Las tarjetas llevan además USER/LOGNAME explícitos, que era el arreglo del día.

⚠ arjectl start sobre un Ente ya vivo lo DUPLICA (se vio con gioser-php: dos entes, uno peleando por el mismo upper). Y matar el proceso de adentro no libera la instancia: el takana qorpa run de afuera sigue vivo y el siguiente run choca. Para relanzar, stop y después start, mirando status en el medio.

Y la pregunta que corresponde: ¿no va en una receta?

Sí, y sigue pendiente. El propio §6.2 pone a squid en la lista de recetas que esta mudanza tiene que producir. Lo de hoy fue la vía URGENTE —el servicio estaba en uso y la jaula lo levanta en minutos, sin compilar nada—, no la respuesta final. La diferencia con php-fpm importa: PHP no queremos que entre al corpus (es un intérprete ajeno al proyecto, y la jaula es su lugar definitivo), mientras que squid es C, se compila con el lab y su sitio natural es una receta como la de gitea. Cuando esté, la instancia se tira: el manifiesto es descartable a propósito.

6.28 📦 squid YA NO ESTÁ ENJAULADO: corre desde el corpus (2026-09-15)

La jaula del §6.27 duró unas horas, que es lo que tenía que durar. Hoy el proxy de producción es el artefacto sellado b3:932ba096…, proyectado en la caja y arrancado por arje sin bwrap de por medio: pid 11548 · uid=968 · exe=/usr/sbin/squid. La instancia qorpa/squid se borró — el manifiesto es descartable por diseño (D3) y esto es exactamente para lo que existía esa salida.

recipes/squid.toml: 7.7, estático de verdad (binario y los cuatro helpers), [[user]] proxy con uid 968 y [[service]] del SDD 30. Declarado en perfil.servidor como paquete y en servicios.

La config NO viaja en el paquete y es a propósito: squid.conf, el passwd de los tres usuarios y las dos ACL (412 K, 40 IPs autorizadas) son datos del SITIO. El servicio los comprueba al arrancar y sale 78 nombrando cuál falta, en vez de dejar un bucle de reinicios.

Los tres muros de la receta, en orden

  1. La URL de upstream devolvía una página HTML de 8985 bytes con un 200. squid-cache.org/Versions/v7/squid-7.7.tar.xz no es el tarball. Pinear su sha256 habría anclado la receta a una página de error — y habría «funcionado» hasta el primer build. Va el release de GitHub, que además trae configure generado.
  2. 🧨 --export-dynamic desactivaba el enlace estático, y el binario sellaba igual. El primer artefacto tenía usr/sbin/squid dinámico con NEEDED libc.so mientras sus helpers salían estáticos: inerte en cualquier imagen sin cargador ([[needed-colgante-libstdcxx]]) pese a link = "static". La cadena tiene tres eslabones y ninguno es obvio: squid_LDFLAGS trae -export-dynamic (para módulos eCAP, apagados) → el wrapper del lab quita -static de cualquier enlace que mencione --export-dynamic, a propósito, para poder enlazar las .so dlopen-ables de Python → y libtool vuelve a añadirlo solo en cuanto hay un -dlopen, así que sacarlo de la variable no alcanza. Se vacía export_dynamic_flag_spec en el libtool GENERADO, con guarda que aborta si el campo no aparece: un sed que no acierta deja pasar el problema y el binario sale dinámico sin que nada falle. ⚠ Y -dlopen force no sobra: vaciar squid_LDFLAGS entero rompe el enlace con undefined symbol: lt__PROGRAM__LTX_preloaded_symbols, que emite el propio libtool porque hay un -dlopen. De los dos flags estorba uno; distinguirlos costó un build y adivinar cuesta lo mismo.
  3. --disable-log-daemon-helpers compilaba perfecto y no arrancaba. El default de access_log es daemon:, así que sin log_file_daemon squid muere con FATAL: logfile_daemon /usr/lib/squid/log_file_daemon: (2) No such file or directory. Sólo se ve levantando el servicio con la config real; el sellado, el hash y el -k parse pasan los tres. Es la familia de [[subcomando-sin-driver]] otra vez.

El control que hay que correr antes de tocar producción

Con la config REAL del origen, en el hub y en un puerto aparte: -k parse sin FATAL, arranque completo, CONNECT claude.ai y CONNECT api.anthropic.com con TCP_TUNNEL/200, el access.log escrito por el log daemon, y el basic_ncsa_auth leyendo el passwd de verdad (control negativo: con una clave mala contesta ERR Wrong password). Los dos primeros intentos de esta receta habrían llegado a producción sin ese control: el binario estaba sellado y el hash era estable las dos veces.

6.29 ⏱️ La caja tiene HORA, LATIDO y ROTACIÓN — y el respaldo del gitea dejó de ser a mano (2026-09-15)

planear.py --revisar sobre el censo del día ordena lo que queda, y lo primero de la lista no eran los servicios del usuario sino tres capacidades que ninguna métrica reclama: el disparador periódico, la hora y la rotación de logs. Los tres paquetes ya estaban sellados y declarados en perfil.servidor desde que ese perfil nació — lo que faltaba era el eslabón que los ARRANCA.

antesahora
chronydla caja iba 15 s atrasada y nadie la corregíastratum 3 contra ntp{1,2,3}.hetzner.de, 0,000005 s de NTP
crondno existía el latidoejecuta el crontab real, verificado con una entrada * * * * *
logrotate/var/log/squid sin rotardiario con squid -k rotate, 14 copias
respaldo del giteaa mano, una vez (§6.15)17 3 * * *, con el snapshot consistente de la sqlite

El respaldo, medido: scripts/respaldo-gitea.sh sube 44 repos (23 sergio + 21 tawasuyu, 1,5 G) y un gitea.db de 332 M tomado con .backup —consistente con el servidor vivo— al Storage Box. El control no es que el script salga 0: es contar los repos de los dos lados, que es lo que se hizo.

⚠ chrony y cronie NO declaraban [[service]], como squid antes: el paquete llegaba a la imagen y no lo arrancaba nadie. Ahora lo declaran (fuera de hash_inputs: los hashes no se movieron) y perfil.servidor los habilita en servicios.

⚠ La config va aparte y el servicio sale 78 si falta, igual que gitea: a qué NTP se pregunta y qué logs se guardan son decisiones del SITIO. Una caja que se sincroniza contra un pool que nadie eligió es peor que una sin hora, porque nadie lo mira.

Tres cosas medidas que valen más que el resultado

  1. La deriva era de gioser, no de la caja. Antes de chrony la caja iba 15 s atrás; después, la caja quedó en hora de NTP y el hub pasó a estar 3 s adelantado. La máquina de referencia era la equivocada — medir «contra el otro» sin un tercero no dice quién está mal.
  2. El spool de cronie es /var/spool/cron/<usuario>, no …/crontabs/<usuario>. crontab -l mostraba las entradas y crontabs/ estaba vacío. El día que alguien restaure un crontab a mano en el sitio equivocado va a tener un fichero perfecto que nadie lee. Lo decide un control y no la intuición: meter un * * * * * en el crontab REAL y ver si dispara.
  3. 🧨 sqlite3 seguía inerte en la caja (Error relocating: compressBound: symbol not found): es el último de los 23 binarios del §6.4, y le faltaba zlib-shared — sellado y declarado en perfil.base desde el 2026-09-11, pero la caja se instaló antes. Sin él no había snapshot consistente, o sea que el respaldo habría copiado la sqlite a lo bruto. Un paquete declarado no es un paquete instalado: en una caja viva, lo que vale es lo que el upgrade proyectó.

6.30 ⚰️ Tres servicios que MUEREN — y el cortafuegos que no puede correr todavía (2026-09-15)

Decisión del usuario sobre lo que quedaba del censo, y las tres son «no se muda»:

serviciopor qué muere
qdrant (:6333, :6334)es la base vectorial de gioser_api, y ese backend es un fósil: api.gioser.net da 502 permanente desde el §6.9. Una base sin consumidor no se muda. La receta recipes/qdrant.toml queda en el catálogo —catálogo ≠ imagen, y eso no es deuda—, pero no entra en ningún perfil.
act_runner (:41027)el runner de CI del gitea, un binario suelto en ~/.local/bin que nadie provee. Muere con la caja; si mañana hace falta CI, entra por receta y no por un binario copiado.
fail2ban-servertawasuyu ya tiene el sustituto, y es mejor: shared/cortafuegos (§1 de su SDD-ENTRADA) no mira logs con un daemon, usa dos sets dinámicos del kernel (ban4/ban6, flags dynamic, timeout) con limit rate over N/minute por IP de origen. El ban lo aplica nftables sin proceso y sin latencia; fail2ban es un daemon de Python leyendo ficheros para hacer lo mismo peor.

🧨 Pero el sustituto NO PUEDE CORRER: el kernel de la caja no trae nf_tables

$ nft list ruleset
netlink: Error: cache initialization failed: Invalid argument

nft 1.1.6 está instalado (nftables es raíz de perfil.servidor y está sellado). Lo que falta está un piso más abajo, en el .config que el artefacto publica:

CONFIG_NETFILTER=y          ← sí
CONFIG_NF_CONNTRACK=y       ← sí
# CONFIG_NF_TABLES is not set   ← AQUÍ
# CONFIG_NETFILTER_ADVANCED is not set   ← y ésta es la causa: NF_TABLES cuelga de ella

Es otra vez la lección del §6.4 muro 1: la receta dice lo que se CAMBIÓ; sólo el .config sellado dice lo que QUEDÓ. linux-generic sale de un make defconfig, y defconfig deja NETFILTER_ADVANCED apagado, que arrastra a NF_TABLES con él.

⇒ Hoy la caja no tiene cortafuegos de ninguna clase: ni fail2ban (que no se muda), ni el sustituto (que no arranca), ni una regla. Con :22022, :2345, :1137, :80 y :443 públicos, eso hay que decirlo entero y no dejarlo implícito. La cura es un kernel con CONFIG_NETFILTER_ADVANCED=y + NF_TABLES (y NFT_CT, NFT_LIMIT, NFT_COUNTER, NFT_LOG), que es trabajo del SDD 22 y termina en un reinicio de la caja de producción.

La mitigación que NO depende del kernel, hecha hoy

El sshd de la caja ofrecía password y keyboard-interactive — su config eran cuatro líneas y ninguna decía nada de autenticación, así que regía el default de OpenSSH. Sin cortafuegos y sin fail2ban, eso es exactamente la puerta que aquéllos cerraban. Ahora es sólo claves, con los cuatro controles corridos: la clave entra ✅, :22022 contesta Permission denied (publickey) sin ofrecer contraseña ✅, :2345 igual ✅, y el git ls-remote por SSH sigue funcionando ✅.

Lo que enseña: una defensa que se muda tiene que mudarse con su capacidad, no con su nombre. Decidir «fail2ban muere porque tenemos algo mejor» es correcto y deja un hueco abierto hasta que ese algo mejor pueda ejecutarse. Entre la decisión y la capacidad hay un kernel de por medio.

6.31 ✅ La caja arrancó con nf_tables — y el init la levantó ENTERA sola (2026-09-15)

$ nft list ruleset
$                       ← sin error: vacío, que es lo correcto en una caja sin reglas

El kernel b3:63fdd57c… bootea, y con él nft funciona por primera vez en una máquina takana. Comprobado con el mecanismo exacto que reemplaza a fail2ban, no con una regla de adorno: un set ... flags dynamic; timeout 10s con add @ban4 { ip saddr timeout 10s limit rate over 5/minute } drop — aceptado por el kernel (rc=0) y releído tal cual. Eso es el ban por IP dentro del kernel, sin daemon.

Lo que el reinicio probó de paso, y vale más que el kernel

La Semilla levantó los OCHO entes sola, todos con ↻ 0: sshd, gitea, caddy, squid, crond, chronyd, sergioh-api y gioser-php — o sea los dos servicios enjaulados en qorpa (con su newuidmap, su overlay y su harkaq) arrancan desde el genesis igual que un binario nativo. Todo lo que se fue editando en /ente/seed.card.json a lo largo del día era, hasta este momento, una declaración sin probar: un reinicio es el único examen que toma.

Desde fuera, al minuto: los siete dominios en 200, el proxy en 407 y git ls-remote por SSH:2345 devolviendo el commit. Cero intervención.

⚠ reboot NO reinicia una caja con arje-zero

reboot de busybox le pide a PID 1 que lo haga, y arje-zero no implementa ese protocolo: el comando vuelve, no dice nada, y la máquina sigue arriba (up 4 days después de «reiniciar»). Lo que sí funciona es reboot -f, que llama a reboot(2) directo saltándose al init. Con sync antes, claro: el corte es duro por definición.

⇒ Y falta la pieza: un init que gobierna un servidor de producción tiene que atender un apagado ordenado. Hoy la única vía es el corte duro, que ya costó el write_atomic no durable del §6.13.

🧨 Casi reinicio el HUB por un backtick en un echo

Escribiendo el aviso del reinicio, el mensaje decía … no atiende el `reboot` normal … dentro de comillas DOBLES. La shell ejecutó lo que había entre backticks: reboot, en gioser. No pasó nada por una razón que no es mérito de nadie —la sesión no es root y OpenRC contestó «you must be root»— y el hub lleva 11 días arriba, con la granja y el cron encima.

Es la tercera vez en el día que un backtick dentro de un texto se ejecuta ([[heredoc-sin-comillas-ejecuta-comentarios]]), y las dos anteriores sólo se comieron un comentario. La regla deja de ser de estilo: nunca un backtick dentro de comillas dobles en una shell — comillas simples, o $(printf '%s' …), o directamente no poner el acento grave en el texto.

Lo que queda para cerrar el frente del cortafuegos

El kernel ya no estorba; falta la política: PoliticaEntrada (.ron) → generate-input → apply-input del cortafuegos de tawasuyu, con los puertos reales de la caja (22022, 2345, 80, 443, 1137). ⚠ Y hay que aplicarla con interruptor de hombre muerto (aplicar, esperar, y flush automático si nadie confirma): una regla mal puesta en una caja sin consola serie deja la única salida en hcloud server enable-rescue.

6.32 🧱 El cortafuegos, puesto — y el primer baneado fui yo (2026-09-15)

La caja filtra: table inet tawasuyu_input, policy drop, 15 reglas de puerto, y el set ban4 llenándose solo con lo que golpea desde internet. A los pocos minutos: 27 IPs baneadas y paquetes tirados por el contador. Eso es el reemplazo de fail2ban funcionando sobre tráfico real, dentro del kernel y sin daemon.

La política vive en scripts/servidor/cortafuegos-entrada.ron (formato PoliticaEntrada del cortafuegos de tawasuyu) y declara los cinco puertos públicos: 22022 admin, 2345 git, 80, 443 y 1137 el proxy. El reglaset se genera con cortafuegos generate-input en el hub —el binario es glibc y la caja es musl— y se aplica allá con nft -f.

🧨 El primer baneado fui yo, en segundos

Aplicada la primera versión, desde fuera: ssh ✅, https ✅… y http, el proxy y el git por SSH muertos. Al minuto, la caja entera dejó de contestarme.

La causa no es una regla mal escrita: el set @ban4 es UNO SOLO para todos los servicios, y la regla ip saddr @ban4 drop está antes que todo. O sea que pasarse de tasa en un puerto te tira en todos. Y el nuevas_por_min: 5 del puerto de administración —correcto contra internet— es exactamente lo que me pasa a mí: cada comando remoto de esta sesión es una conexión nueva.

Lo que devolvió la máquina fue el interruptor de hombre muerto: antes de aplicar se deja programado un nft flush ruleset a 180 s, y sólo se desarma si la verificación desde fuera sale bien. Sin consola serie, esa es la diferencia entre un susto y un rescue.

⚠ Y el tipo ya lo avisaba. PoliticaEntrada tiene un campo confiables cuyo comentario dice, palabra por palabra, lo que me acababa de pasar: «un nuevas_por_min chico en el puerto de SSH es exactamente lo que se quiere contra internet y exactamente lo que te deja afuera cuando el que abre seis sesiones en un minuto sos vos». Copié el .ron de ejemplo en vez de leer el tipo. Es [[lei-hasta-donde-me-daba-la-razon]] otra vez, y la cura fue una línea: confiables: ["204.168.193.248", "154.197.1.13"] — el hub y el worker, que se aceptan antes de la lógica de tasa y por lo tanto no pueden entrar al set.

Lo que este kernel todavía no puede, dicho en voz alta

nft -c rechazó cinco reglas antes de aplicar nada:

tcp dport 22022 ct count over 10 drop
                ^^^^^^^^  Error: Could not process rule: No such file or directory

ct count es CONFIG_NFT_CONNLIMIT, que el kernel de hoy no trae (el .config lo dice: # CONFIG_NFT_CONNLIMIT is not set). Es el tope de conexiones concurrentes por servicio — el ban por tasa, que es lo que reemplaza a fail2ban, no depende de él. Las cinco reglas quedan comentadas, no borradas, en el fichero que se aplica: lo que la política declara y el kernel no puede tiene que verse. El símbolo ya entró a recipes/linux-generic.toml y vuelve con el próximo kernel; hasta entonces el mensaje —que no nombra la opción que falta— queda explicado ahí mismo.

Que sobreviva al reinicio: un servicio oneshot

recipes/nftables.toml declara ahora su [[service]] con lifecycle = "oneshot": cargar un reglaset no es un daemon, es un acto. Va primero en el genesis, antes que sshd y los demás: entre que la red está y que las reglas cargan, la caja está abierta, y ese hueco se hace lo más corto posible. Probado de verdad — nft flush ruleset y después arjectl start cortafuegos: las 15 reglas vuelven.

Su guarda sale 78 nombrando el fichero si el reglaset no está, en vez de dejar la caja sin filtrar creyendo que está protegida: un cortafuegos ausente y uno vacío se ven igual desde afuera hasta que alguien prueba.

6.33 🧨 EL CORTAFUEGOS BANEÓ A LOS USUARIOS DEL PROXY — y squid nunca se cayó (2026-09-16)

El usuario preguntó si el proxy estaba caído. No lo estaba: el ente squid llevaba horas corriendo con ↻ 0, escuchando en :1137 y contestando 407 a quien preguntara. Lo que estaba caído era el acceso: el cortafuegos que puse ayer estaba baneando a los usuarios autorizados.

La prueba, en una línea: 154.197.1.2 —una IP que está en la ACL de squid, o sea gente con contraseña— apareció en @ban4. Y el contador del ban iba en 38.246 paquetes tirados.

La causa no es la tasa: es la RÁFAGA

tcp dport 1137 ct state new add @ban4 { ip saddr timeout 300s limit rate over 300/minute burst 5 packets } drop

burst 5 significa «tolero cinco conexiones por encima del promedio». Un navegador o un agente detrás de un proxy abre decenas de CONNECT en el mismo instante, aunque su promedio sea bajísimo: seis de golpe y ya estás en el set. Y como @ban4 es UN SOLO set consultado antes que todo, caer por el puerto del proxy te tira también el web y el git.

⚠ El tipo del cortafuegos ya traía el campo (rafaga, default 5) con un comentario que decía «para un servicio web va alto (100-200)». Otra vez: leí el .ron de ejemplo y no el tipo.

Lo que se hizo, en orden de urgencia

  1. nft flush set … ban4/ban6 — servicio restaurado en el acto.
  2. Los usuarios del proxy a confiables: la misma lista que la ACL de squid (21 IPs + 5 redes). Un proxy que autentica no necesita que el kernel le cuente las conexiones a un usuario conocido; lo que el ban tiene que parar son las fuentes DESCONOCIDAS.
  3. rafaga por servicio, según su clientela: ssh 5 · git 20 · web 100 · proxy 200. Y el proxy pasa a 1200/min con ban de 60 s: red de contención contra un flood, no control de uso.
  4. Control después: cero autorizados en el set, 24 baneados (escáneres, que es lo que se quiere), y tráfico real pasando — 154.194.14.37 … CONNECT api.deepseek.com:443 sigma, 298 K tunelados.

⚠⚠ Y un fallo de método que casi lo tapa: nft -f SUMA a la tabla existente. Cargué el reglaset corregido encima del viejo y quedaron 30 reglas: las viejas ADELANTE, así que los confiables nuevos no llegaban a evaluarse nunca. Se vio contando (grep -c dport daba 30 y no 15) y mirando qué saddr salía primero. Por eso cortafuegos apply-input borra la tabla antes de cargar — usar nft -f a pelo se saltea esa mitad.

La hora exacta, reconstruida del access.log

El usuario preguntó a qué hora se bloqueó, y el log lo dice sin ambigüedad. Su IP —45.234.61.160, autenticada como sergio, 2709 peticiones— tuvo estos silencios:

silenciodesdehasta
93,2 min16-09 11:59:37 UTC13:32:49 UTC ← cuando vacié los sets
50,1 min10:44:5811:35:06
10,0 min11:45:0611:55:05

Los tres son el timeout de 10 minutos del ban disparándose una y otra vez: cada vez que volvía, su primera ráfaga lo baneaba de nuevo. 154.197.1.2, que usa el proxy de forma sostenida pero sin ráfagas, sólo perdió huecos de 5 minutos.

🧨 Y la exención que tenía no servía: su IP no estaba en la red eximida. La ACL histórica de squid exime 45.234.60.0/24 y él entra desde 45.234.61.160 — una red de al lado. Parecía cubierto y no lo estaba: la pertenencia se MIDE (ip in red), no se deduce del parecido del prefijo. Con el /23 que nft dedujo al unir los dos /24, ahora sí.

Y la decisión de fondo: en un servicio que YA AUTORIZA, no va límite por tasa

El uso real de esta caja es intensivo —varias sesiones de agente en paralelo, cada una abriendo túneles a la vez—, y un límite calibrado para «una persona normal navegando» no protege de nada: el atacante ajusta su ritmo, el usuario no puede trabajar más despacio. Squid ya pide usuario y contraseña y tiene su ACL; el ban por tasa ahí no agrega seguridad, agrega cortes. Queda apagado (60000/min, ráfaga 1000: números que ningún cliente real alcanza) y contra un flood queda el tope GLOBAL syn_nuevas_por_seg, que no distingue usuarios ni banea a nadie por ser rápido.

Lo que esto enseña del cortafuegos como herramienta

Un cortafuegos correcto y un cortafuegos usable no son lo mismo, y la diferencia no se ve al aplicarlo: las reglas cargan, el nft -c pasa, los sitios responden, y el daño aparece sólo cuando un cliente REAL hace lo que los clientes reales hacen. El único control que lo habría cazado antes es el que no corrí: una ráfaga de conexiones desde una IP que NO esté en confiables.

6.34 📋 Censo del 2026-09-16: queda UN vhost — y el censo sin privilegio no veía 572 M

Censo fresco de gioser (censar.py --local --probe-dns, sólo lee), para saber qué falta de verdad en vez de arrastrar la lista del 11.

  servicios   vivo 23 · no-declarado 17 · declarado-muerto 81 · descartados 6
  dominios    apunta-aca 1 · ya-mudado 6 · fosil-sin-dns 7
  datos       /home 25 G (copiar 27 G) · /var/lib 8,1 G · /opt 1,2 G · /var/www 288 M

De los 29 dominios del Caddyfile sólo UNO sigue sirviéndose desde gioser: sergio.gioser.net. Comprobado desde fuera, resolución + HTTP, no por el fichero de config:

dominioresuelve aHTTP
tawasuyu.net · gioser.net · git.tawasuyu.net · takana.gioser.net · hifas.gioser.net · api.sergio.gioser.net2.29.29.217200
gitea.gioser.net2.29.29.217301
sergio.gioser.net204.168.193.248 (gioser)200
api.summa.gioser.net154.197.1.2 (otra máquina)—
terapeuta.ec, andino.ec, api.sigma… y 4 mássin DNS—

⇒ la lista de «tres vhosts» del §9 quedó vieja: gioser.net y tawasuyu.net ya están mudados (§6.26) con su origen vivo a propósito. Lo único que ata el DNS a gioser es la consola de sergio.

El rescate del §6.20 sigue vigente — y caduca de verdad

Seis procesos corren hoy desde un inodo borrado. Cuatro tienen su copia en work/mudanza/rescate/ y el sha256 del /proc/<pid>/exe vivo coincide bit a bit con lo rescatado el 12. Los otros dos (/usr/local/bin/tejido y /usr/local/bin/shuma-daemon) no están en el rescate, pero en disco hay una versión más nueva de cada uno (hoy 11:13 y 14:20): no se perdió nada porque el usuario los está reconstruyendo. La lección es la del §6.20 y no cambió: ese paso caduca, y quién caducó se ve mirando /proc, no el directorio.

🕳️ Y el hallazgo del día: -e contesta «no existe» cuando lo que pasa es «no puedo mirar»

El censo tiene tres estados a propósito (ausente / sin_permiso / ok) porque un ls sin permiso se lee igual que un directorio vacío. Pero el tercer estado sólo se detectaba sobre la propia ruta: cuando el que no deja pasar es un ANCESTRO, [ -e "$p" ] contesta que no, y la ruta caía en ausente — que el censo descarta sin decir nada.

  /root/.local        drwx------ root root        ← 0700
  /root/.local/bin    572 M de binarios a mano que NINGÚN paquete provee

Corrido como sergio, el censo decía que /root/.local/bin no existe. Corrido como root, son 572 M de exactamente la clase que la mudanza no puede reconstruir: binarios puestos a mano. Arreglado —si un ancestro existe y no es atravesable, el estado es sin_permiso— y probado en los dos sentidos, que es lo que distingue un guardián de un adorno:

  ancestro 0700 de otro dueño   →  sin_permiso   (y sale en el ⚠ del resumen)
  ruta que NO existe de verdad  →  ausente       (y NO se reporta)   ← el control que tiene que pasar

Con el arreglo, el censo como sergio pasa de 2 rutas ciegas a 13 — entre ellas el home entero de artix, que antes no figuraba de ninguna manera. El censo se corre como root; la lista de ciegas es el recibo de lo que una corrida sin privilegio no puede ver.

Lo que queda, y por qué no lo decide el programa

Descontados los que ya se decidieron (§6.30: qdrant, act_runner, fail2ban mueren) y los que provee el init del destino, lo que sigue vivo en gioser es un montón de daemons de tawasuyu que ningún paquete provee — binarios a mano en /usr/local/bin (1,2 G) y ~/.local/bin (765 M + 572 M en el home de root):

  shuma-daemon · shuma-gateway (:7378)   la consola de sergio.gioser.net — el único vhost que queda
  willay-daemon · willay-crosscheck · tejido (:4102,:33097) · pacha · pacha-secretos
  matilda · tupu · thasnuna · sandokan-watch · openclaw · zeroclaw
  puerta-f6e393ffbef3999e (:34221,:44961)   binario de test en target/debug, BORRADO del disco
  webhook-deploy.py

planear.py --revisar los saca con su motivo, pero la decisión es del usuario y el programa no la va a inventar: son las herramientas propias del usuario, no servicios de un catálogo. Y los datos tampoco se recomiendan solos (§3): /home son 25 G que incluyen 6,8 G de .claude y 1,3 G de binarios sueltos.

6.35 🧳 Los binarios sueltos, desglosados — y qué de .claude viaja (2026-09-16)

Decisión del usuario sobre el §6.34: shuma se construye desde tawasuyu (ya está encolado: recipes/incoming/shuma-{gateway,daemon}.toml), los daemons de tawasuyu viven todos, y de .claude decide el criterio de quien lo usa. Falta el detalle de los sueltos, que es esto.

Son 2,7 G en tres directorios, y casi nada de eso es irreemplazable

clasecuántoqué esse reconstruye
tawasuyu57 ficheros · 1003 Mpata-llimphi 105 M, nahual-shell-llimphi 98 M, shuma-shell-llimphi 76 M, los 14 mirada-*, pata-*, shuma-*, sandokan-*, willay-*, pacha, tejido, tupu, matilda, thasnuna…sí: son salida del monorepo, que ya vive en el gitea de la caja
terceros10 ficheros · 841 Mkiro-cli* 571 M (tres binarios), kcl 95 M, qdrant 91 M (muere, §6.30), gitea-runner 21 M, act_runner 21 M (muere), listmonk 19 M, hcloud 18 M, goaccess 3,4 Mno, pero se vuelven a bajar de su origen
respaldo / duplicado21 ficheros · 876 M/root/.local/bin es una copia entera de kiro-cli* (571 M) más el CLI de claude (219 M); el resto son .respaldo-FECHA, .previo y .bakno hace falta
guiones27 ficheros · < 1 Mgit-deploy, webhook-deploy.py, update-squid-allowed-ips.sh, mirada-session*, huellita-aviso…NO ← lo único de verdad frágil

⇒ lo caro no es lo valioso. De los 2,7 G, 1,8 G son reconstruibles o duplicados, y lo que no está en ningún repo ni en ningún paquete son 27 guiones que suman menos de un megabyte — el pegamento que nadie recuerda hasta que falta. Van al plan como una sola entrada, y con nombre.

Y los 572 M de /root/.local/bin que el censo no veía (§6.34) resultaron ser exactamente eso: la copia de kiro-cli* que ya está en el home de sergio. La lección no cambia —un censo que dice «no hay nada» de lo que no pudo mirar es peor que uno que falla—, pero el contenido, esta vez, era duplicado.

.claude: 6,8 G en disco, 15 M es lo que hace falta para seguir funcionando igual

Criterio, porque es lo que me deja trabajar allá como acá:

viajaquétamaño
✅projects/*/memory/ de los 18 proyectos — la memoria destilada6,2 M
✅settings.json, plugins/, history.jsonl~8,6 M
⛔jobs/ (5,3 G), downloads/ (228 M), archivo-sesiones/ (286 M), file-history/, shell-snapshots/, cache/, paste-cache/6,1 G
⚠projects/*/*.jsonl — los transcripts crudos (973 M)ver abajo

Los transcripts no hacen falta para funcionar: la memoria es su destilado y es lo que se lee al arrancar una sesión. Lo que se pierde sin ellos es --resume de una sesión vieja y la posibilidad de releer una conversación que nadie resumió. Por eso no van a la caja —que tiene 42 G libres en /work y los quiere para el store— sino al Storage Box del respaldo, que es donde vive lo que se guarda por si acaso. La memoria, en cambio, viaja con la máquina.

⚠ Y una trampa de ruta que haría todo esto inútil

La memoria se indexa POR RUTA: el directorio se llama -mnt-vvv-takana porque el repo vive en /mnt/vvv/takana. En la caja el repo está en /opt/takana ⇒ copiar memory/ tal cual deja los 163 ficheros en disco y ninguno se encuentra, sin que nada falle. Las dos salidas son renombrar el directorio a la ruta de destino (-opt-takana) o darle al repo la misma ruta que acá. Se elige al copiar, no después.

🕳️ La caja no tiene usuario sergio

/etc/passwd de 2.29.29.217: root, sshd, gitea, proxy. Nada más. En gioser, shuma-daemon corre como sergio —y shuma-gateway también—, así que la tarjeta de arje que los declare tiene que crear la cuenta primero; y el payload Native de arje no tiene campo de usuario (SDD 29 §4.0 quinquies), así que declararlos sin más los pone a correr como root. Es la misma advertencia que ya lleva recipes/incoming/shuma-daemon.toml en su cabecera, escrita donde se va a leer.

6.36 🚚 Lo que se movió hoy — y las cuatro verificaciones que se hicieron EN DESTINO (2026-09-16)

Con las decisiones del §6.35 puestas, se movió lo que no dependía de nada más. Cada copia se verificó del lado del destino, que es la regla que el rsync con exit 0 y 1367 artefactos vacíos dejó escrita (§6.13, y la regla 2 de aplicar.py).

quéa dóndeverificación en destino
los 27 guiones (75 KB)takana:/work/rescate-guiones/sha256sum -c SHA256SUMS ⇒ 27 OK
la memoria de Claude (733 ficheros, 6,3 M) + settings.json, plugins, history.jsonltakana:/root/.claude/733 ficheros en origen y 733 en destino
los transcripts crudos (1 966 ficheros, 1,3 G)Storage Box claude-gioser/rsync -an de vuelta: 1 solo fichero pendiente, y es el transcript de la sesión que estaba escribiéndolo
la clave PRIVADA de releasetakana:/root/.config/takana/keys/ 0600sha256 idéntico en los dos lados y su pública == trust/release.ed25519.pub
credenciales y config (gitconfig, npmrc, gh, ssh, crontabs, Caddyfile)takana:/work/mudanza-secretos/ 070027 ficheros en origen y 27 en destino
estado de los daemons que viven (sigma, tupu, willay, sandokan-watch.json) + /var/www/para-respaldartakana:/work/mudanza-estado/351 ficheros y 68 M en los dos lados

⚠ Las credenciales NO se instalaron en su sitio, a propósito

La caja ya tiene su /root/.ssh (con github5), su crontab (el latido de la caja, §6.29) y su /etc/caddy/Caddyfile con los siete vhosts que ya sirve. Pisar cualquiera de los tres con el de gioser rompe lo que hoy funciona. Quedan en /work/mudanza-secretos/ con su LEEME.txt, y se instalan cuando se mude el hub, que es otra unidad de trabajo. El .gitconfig es el caso más claro: trae los insteadOf que reescriben remotos, así que instalarlo cambia a dónde empuja git push sin que nadie lo pida.

La única excepción es la clave de release, que sí fue a su ruta canónica: sin ella nadie puede volver a firmar el repo, y ese fallo no aparece el día que se borra gioser sino la próxima vez que alguien publica.

🔍 Un «576 M contra 1,3 G» que parecía una copia a medias

du -sh en el Storage Box decía 576 M de los 1,3 G subidos. Con el antecedente del rsync que llenó el disco, eso se lee como copia truncada. No lo era: el Storage Box comprime, y su shell restringida no tiene find ni acepta tuberías, así que el conteo de ficheros —la verificación obvia— ahí no se puede hacer. Lo que sí se puede es preguntarle a rsync: una corrida -an compara tamaño y fecha contra el destino real y dijo 1 fichero pendiente, el que estaba creciendo mientras se copiaba.

⇒ la verificación tiene que poder correrse en el destino que hay, no en el que uno imagina. Y un número que no cuadra merece una segunda medición antes de un diagnóstico: du medía otra cosa.

Lo que NO se movió, y no es olvido

~10 G de árboles personales en /home/sergio —android-sdk 1 G, maps, imm, humanoid, hifas, fdroid, valens-conversion, pyswisseph, accordion…— y /opt (1,2 G, que es android-sdk otra vez y google). Qué datos valen no se deduce de la máquina (§3), y éstos son proyectos del usuario, no servicios de un censo: van a la lista de decisiones, no a una copia hecha por criterio ajeno.

De /var/lib (8,1 G) se movió sólo el estado de los daemons que viven: 6,1 G son swap y 1,9 G el gitea, que ya está mudado desde el §6.14.

6.37 ✅ CUTOVER DE sergio.gioser.net — el último vhost, y gioser deja de servir (2026-09-16)

Ya no queda ningún dominio sirviéndose desde gioser. El último era éste, y lo que lo ataba no era el sitio sino su consola: /shuma/*. Verificado desde fuera, contra la IP nueva:

  https://sergio.gioser.net/           → 200   408 bytes, la SPA
  https://sergio.gioser.net/shuma/     → 200   «shuma-gateway ok»   ← el gateway, no la SPA
  https://sergio.gioser.net/ruta-inventada → 200  la SPA (el fallback, que es lo correcto)

⚠ El 200 de /shuma/ había que mirarlo dos veces. El bloque termina en try_files {path} {path}/ /index.html, así que cualquier ruta inexistente devuelve 200 con la SPA: un curl -o /dev/null que sólo mira el código habría dado el mismo verde con el proxy mal puesto. Lo que decide es el CUERPO — 17 bytes que dicen shuma-gateway ok — y el control es compararlo con lo que el origen viejo sigue sirviendo, byte por byte. Igual con /shuma/rpc: las dos máquinas contestan el MISMO 400 {"error":"bad json: …"}.

Lo que hubo que construir, en orden

  1. Las dos recetas (§6.34) sellaron en el worker: b3:107dfb54…-shuma-gateway (6,3 M) y b3:f9b92acc…-shuma-daemon (5,6 M) — los hashes que takana hash había anticipado antes de encolarlas. file dice statically linked: nada de cargador, que era exactamente lo que le faltaba al binario glibc de gioser (y cuyo inodo, además, ya estaba borrado del disco).
  2. La cuenta, declarada y sin privilegio. [[user]] shuma (uid 967) en la receta del daemon, y el descenso con setuidgid dentro del argv de la Card, igual que gitea — porque el payload Native de arje no tiene campo de usuario. Declararlo «como estaba» lo habría puesto de root, y esto es un demonio que abre PTYs y lanza shells.
  3. ⚠ Y la shell de la cuenta NO es un detalle. El default de [[user]] es /bin/false, que es lo correcto para gitea o squid y exactamente lo contrario para éste: con la shell inerte el servicio arranca, se supervisa, contesta 200… y cada pestaña que alguien abra muere al instante. Un fallo que no se ve al desplegar: se ve al usarlo. Va shell = "/bin/sh".
  4. La config que no se regenera. El gateway-token (32 B) y keys/identity.x25519 viajaron a /var/lib/shuma/.config/shuma/: generar unos nuevos deja fuera a los clientes ya emparejados. Por eso la guarda de la Card exige el token y no lo crea, y dice de dónde sale.
  5. Las dos Cards entran en /etc/arje/cards.d/ y en el genesis de /ente/seed.card.json (son dos preguntas distintas: qué se puede encarnar a pedido y qué arranca solo). La semilla quedó con 13 entradas y vuelve a parsear.
  6. Declarado en perfil.servidor: los dos paquetes y los dos labels en servicios. Entran como PAR — el gateway sin el daemon es un puente a ninguna parte.

[[user]] y [[service]] están fuera de hash_inputs, así que declarar todo eso no movió ningún ArtifactHash: comprobado en los dos, antes y después.

🔁 El DNS: la API tiene una acción para esto y el §6.25 no la había encontrado

Aquel cutover dejó escrito «un PUT sobre el rrset no puede cambiar el tipo: hay que DELETE del CNAME y POST del A». Es cierto para un cambio de TIPO. Para cambiar el VALOR, el PUT tampoco sirve —can't update records with this endpoint— pero existe una acción que lo hace atómica:

POST /v1/zones/<id>/rrsets/<nombre>/<tipo>/actions/set_records   {"records":[{"value":"…"}]}

Sin ventana sin registro, que es lo que sí tiene el DELETE+POST. Con TTL 60 la resolución pública cambió en menos de 8 segundos, y volver atrás es la misma llamada con la IP vieja.

El origen sigue vivo, a propósito

Como en §6.26: en gioser shuma-daemon, shuma-gateway y su bloque de caddy siguen corriendo. Mudar el nombre y dejar el origen encendido permite volver en un minuto, y no cuesta nada mientras la máquina exista. Lo que ya no existe es la dependencia: ningún dominio resuelve a gioser.

⚠ Pendiente menor, anotado donde se va a leer: el daemon avisa al arrancar que no encuentra shuma-askpass («sudo/ssh pedirán la clave por el TTY»). Es otro crate del mismo workspace y es otra receta — no bloquea la consola.

6.38 🧩 Los primeros cuatro daemons propios, corriendo en la caja (2026-09-16)

De las diez recetas encoladas, cuatro sellaron y ya corren supervisadas por arje:

cuentaqué hace, comprobado
matildarootlee el access log de caddy y archivó 78 ficheros en /var/lib/tupu/takana/
pachapacha (965)vivo, 0 reinicios, con SUS reglas (reglas.ron), no las de fábrica
pacha-secretospachavivo, 0 reinicios — y su binario en gioser era un inodo borrado
sandokan-watchrootfrenado a propósito: no puede trabajar acá (ver abajo)

⚠ Tres corren como ROOT, y está declarado en vez de heredado

Lo natural sería una cuenta sin privilegio por servicio, como shuma o gitea. Con el trío de la telemetría no se puede, y las dos razones son medidas:

  1. matilda lee /var/log/caddy/requests.json, que caddy crea -rw------- root root y recrea igual en cada rotación: un grupo con permiso de lectura se pierde en la próxima.
  2. tupu, matilda y sandokan-watch escriben en el MISMO /var/lib/tupu, y en gioser sus 322 ficheros son root:root. Bajar uno solo deja mezcla de dueños en un árbol compartido: escribe el primero y los otros fallan al modificar.

⇒ o los tres como root, o ninguno. La salida limpia está anotada en la receta para el día que se quiera: que caddy escriba con mode 640 y que el árbol sea de una cuenta propia. pacha y pacha-secretos sí bajan a una cuenta propia, porque en gioser tampoco corrían como root.

🕳️ La caja se llamaba «(none)» — y eso se archivaba

matilda archiva POR MÁQUINA, y sus primeros 72 ficheros fueron a /var/lib/tupu/**none**/:

  $ hostname          →  (none)
  $ cat /etc/hostname →  no existe

Ninguna caja takana fija su hostname: no lo hace netup, no está en el product-rootfs, no hay /etc/hostname. Nunca importó porque nada archivaba por nombre — hasta que llegó algo que sí. Y no falla: archiva bajo none, y dos máquinas distintas archivarían en la misma carpeta.

Puesto /etc/hostname + una Card OneShot hostname en el genesis, para que sobreviva al reinicio sin depender de que alguien se acuerde. Con el nombre puesto, matilda re-archiva en /var/lib/tupu/takana/. Quedan 72 ficheros huérfanos bajo none/: son los 20 minutos anteriores.

⇒ lo correcto es que lo haga netup, que es quien configura la identidad de red de la máquina; la Card es la mitigación de hoy, y está dicho en su propia guarda.

🛡️ Un vigía que no vigila es PEOR que uno caído, porque el caído se ve

sandokan-watch arrancó, quedó «corriendo · 0 reinicios»… y no guardaba una línea. Lo dice él mismo si uno lo corre a mano:

sandokan-watch · sin fuente de accesos (ni /var/log/auth.log ni journalctl): no se guarda nada

En gioser lee el auth.log de un syslog que en takana no existe — no hay systemd ni syslog. Su Card ahora lleva una guarda que sale 78 nombrando el problema, así que el servicio no queda verde mintiendo; y no entra al genesis, sólo a cards.d, porque no tiene sentido que arranque al boot para fallar. Destrabarlo es darle el diario de arje como fuente, y eso es trabajo de su repo.

Dos herramientas que no existen donde se prueba

Dos veredictos falsos en una tarde, los dos por probar con algo que la caja no tiene:

Es la misma lección del §6.23 (ps y dig ausentes) y merece repetirse: una prueba que no puede pasar se ve igual que una que todavía no pasó.

6.39 🕸️ Los nueve daemons propios, y la línea que separa «se muda» de «se INTERCAMBIA» (2026-09-16)

Nueve de las diez recetas sellaron. Cinco corren; cuatro están instaladas y frenadas a propósito, cada una por un motivo distinto y con su guarda diciéndolo.

corriendofrenadopor qué
matilda · tupuroot, escriben /var/lib/tupu/takana/sandokan-watchen takana no hay auth.log ni journalctl
pacha · pacha-secretoscuenta pacha (965)tejidomisma identidad libp2p que el de gioser
willay-daemonroot, con su índice mudadowillay-crosschecknecesita el roster de tejido
shuma-daemon · shuma-gatewaycuenta shuma (§6.37)thasnunapide claude autenticado y sandokan-mcp

⚠ La regla que sirvió para los vhosts NO sirve para una identidad

Con los sitios web la pauta fue: mudá el DNS y dejá el origen encendido, así volver cuesta un minuto (§6.26). Con tejido eso es exactamente lo que NO hay que hacer, y el README del propio tejido lo dice: «la clave que el roster atesta ES la identidad de transporte libp2p» (~/.tejido/device.seed). Dos máquinas con la misma semilla no son dos réplicas del mismo servicio: son el mismo PeerId en dos sitios, o sea dos impostores mutuos para el resto de la flota.

⇒ tejido se intercambia: se apaga allá, se enciende acá, en ese orden. Está todo listo para el intercambio —binario, cuenta tejido (964), identidad instalada 700 en /var/lib/tejido/.tejido/— y su Card vive en cards.d pero no en el genesis, para que un reinicio no lo encienda solo.

Y arrastra a otro: willay-crosscheck no es independiente. Corriendo su línea a mano:

Error: no hay roster en /root/.tejido/roster.postcard — emparejá primero (`tejido serve` / `tejido join`)

Vive dentro de la red de tejido, así que hereda su condición. Se enciende en el mismo movimiento.

Lo que cada guarda evita

🔁 Tres veredictos falsos en una tarde, los tres por probar con lo que la caja no tiene

Van con el §6.38 y ya son patrón, no anécdota:

la pruebalo que dijolo que pasaba
/dev/tcp/host/puertolos 5 puertos de gioser «cerrados»busybox ash no tiene /dev/tcp
ls /store/*-{a,b}«los artefactos no llegaron»busybox no expande llaves
find … -newermt "-2 minutes"«tupu no escribe hace 2 min»tupu sí escribe: su mtime avanza cada 10 s

El de tupu es el más instructivo porque casi produce un diagnóstico al revés: el fichero tiene tamaño constante (es una serie de tamaño fijo), así que «no cambió de bytes» tampoco probaba nada. Lo que decide es el mtime, medido dos veces con 40 s de por medio.

6.40 🚪 Las ocho puertas, medidas de nuevo (2026-09-16) — y la que falta es UNA

La tabla del §6.3 es del 10 de septiembre. Re-medida hoy, entera:

#puertaestadomedido
1arranca takana puro, se entra por SSH✅sigue en pie
2el store está entero✅1632 artefactos, 0 vacíos (eran 1362) · ⚠ /store al 88 %, 11,3 G libres
3los cuatro grafos idénticos✅§6.7
4la granja late en la caja nueva❌el latido sigue en gioser: la caja no tiene el cron
5el repo se sirve y el host se actualiza solo⬖sirve; consumir exige el lab entero (SDD 27 §4)
6cada dominio vivo responde 200 desde fuera✅los 8 contra 2.29.29.217 — y sergio ya entre ellos
7el respaldo corre desde la caja⬖el script corre y lista el Storage Box desde la caja; falta una corrida completa
8lo fósil, anotado y decidido✅§6.9 + docs/state/mudanza-decisiones.txt

La puerta 4 es ahora una línea de crontab — y por qué no la puse

La caja ya tiene todo lo que el latido necesita, comprobado uno por uno: git, python3, rsync, ssh, takana, flock, la llave del worker en /root/.ssh/github5 y el .fleet con dev.gioser.net. Le faltaban dos costuras y están hechas:

  /opt/takana/store              -> /store            (el script espera `./store`; acá es partición)
  /opt/takana/target/release/takana -> /usr/bin/takana (no hay árbol de build en una caja instalada)

Y el repo de /opt/takana, que estaba 5 días atrasado, quedó en origin/main exacto (0 diferencias contra el remoto), con un tar de respaldo de los 92 ficheros que el árbol tenía antes. ⚠ Esos 92 no eran trabajo local: eran contenido que ya estaba río arriba —el cambio de SSH a HTTPS de las recetas de tawasuyu, entre otros— metido en el árbol sin commitear. Un árbol «sucio» que en realidad está atrasado, que es el mismo espejismo que el §5 del informe de tawasuyu describe para el monorepo.

Prueba de sólo lectura desde la caja, que es lo más lejos que se puede llegar sin cambiar nada:

  ▶ dev.gioser.net (154.197.1.13)   load: 2.72 · store: 895 artefactos
  ══ AVANCE KDE ══  197/198 sellado
  ══ CRON DE COSECHA ══  ⚠ NO instalado en el crontab

No lo instalé, y el motivo es el mismo que el de tejido: con el cron puesto en las dos máquinas, las dos harían rsync --delete del repo al worker y las dos cosecharían y commitearían. El latido se intercambia, no se duplica — se apaga en gioser y se enciende acá, en ese orden, y es el último movimiento antes del borrado.

⇒ de las ocho, una sola está en rojo, y es una decisión de corte, no trabajo pendiente.

6.41 🔀 INTERCAMBIO de tejido — la identidad cambió de máquina, no de dueño (2026-09-17)

Hecho, y en este orden, que es el único posible: apagar allá → encender acá.

  gioser   tejido relay (964) · tejido serve (29240) · willay-crosscheck (15334)   → detenidos
  takana   tejido        4102   ·  willay-crosscheck  4103                         → corriendo

La prueba de que la identidad VIAJÓ y no se generó otra no es que el servicio arranque —eso pasaría igual con semillas nuevas, y sería el fallo silencioso que esto existe para evitar— sino que la semilla sea la misma:

  device.seed     gioser cad6f35b633e8637…   caja cad6f35b633e8637…   ✓ idéntica
  roster.postcard gioser 590f2713075ac934…   caja 590f2713075ac934…   ✓ idéntica

El PeerId sale de esa semilla, así que sigue siendo 12D3KooWBw2u8mt2ciUjX69qpVAZiJZ8sZMGxz8eHJEnz4rzg37L.

⚠ LO QUE SÍ CAMBIA, Y HAY QUE TOCARLO A MANO: el multiaddr

Los clientes tienen configurada la dirección COMPLETA del relay, IP incluida — se leyó del proceso que corría en gioser:

  antes:  /ip4/204.168.193.248/tcp/4102/p2p/12D3KooWBw2u…
  ahora:  /ip4/2.29.29.217/tcp/4102/p2p/12D3KooWBw2u…        ← mismo PeerId, otra IP

Un cliente con el viejo no falla «mal»: reintenta contra una IP que pronto no existirá. El PeerId no hay que tocarlo; la IP sí, en cada equipo del roster.

🧱 Sin el cortafuegos, el intercambio habría sido un verde falso

El reglaset de la caja abría 80, 443, 1137, 2345, 22022 y nada más: 4102 estaba cerrado. Con el relay encendido y el puerto filtrado, arjectl status diría «corriendo · 0 reinicios» y la flota no llegaría — el peor de los verdes. Se abrieron 4102 (relay) y 4103 (crosscheck), con su ban por tasa como el resto. No se abrió 33097: ése era el puerto efímero de un tejido serve (cliente), no un servicio que alguien busque.

Y el reglaset quedó idempotente, que era deuda del §6.33: sus dos primeras líneas crean y borran la tabla antes de definirla, así que nft -f reemplaza en vez de acumular. Comprobado contando: 15 reglas dport antes, 21 después (no 36).

⚠ La política que genera ese fichero NO EXISTE EN NINGÚN DISCO. Su cabecera dice «GENERADO, no editar a mano — regenerar con cortafuegos generate-input --policy <ruta>», pero esa ruta no está ni en la caja, ni en gioser, ni en el repo (sólo dos politica_*_ejemplo.ron en tawasuyu), y el binario cortafuegos tampoco está instalado en ninguna de las dos. El generado sobrevivió a su fuente: hasta que la política se escriba y se versione, esto se edita a mano — justo lo que su primera línea pide no hacer.

🆔 takana acepta ULIDs que arje-zero RECHAZA

La Card de tejido no encarnaba: arje-zero rechazó: card tejido JSON: invalid character at line 7. La línea 7 es el id, y el id era 01M2EKDA00TEJ1D0RELAY00001 — que tiene una L, carácter excluido del alfabeto Crockford de ULID (junto con I, O, U). takana service-cards lo validó como bueno («26 caracteres alfanuméricos») y el consumidor lo tiró.

Dos cosas de esto: el validador del PRODUCTOR es más laxo que el del consumidor —y eso siempre termina en un fallo lejos de donde se escribió—, y el barrido propio que ya corrí sobre todos los id del repo con el alfabeto correcto había marcado justo ése. Era el único con L (venía de «RELAY»); ahora es 01M2EKDA00TEJ1D0RE1AY00001.

Estabilidad, medida y no supuesta

willay-crosscheck marcaba 4296 reinicios, que asusta hasta mirar de dónde vienen: son las horas en que su guarda lo frenó a propósito por falta de roster. Con el mismo pid a los 70 s y 1 m 53 s de vida, está estable. tejido: 0 reinicios. Los dos puertos aceptan conexión desde fuera.

⚠ De paso, un detalle feo: arjectl status | head -2 hace que arjectl paniquee con failed printing to stdout: Broken pipe. No rompe nada, pero un EPIPE sin manejar en una herramienta de operación aparece justo cuando alguien la encadena con head/grep -q.

Cómo volver, si hiciera falta

Está escrito en work/mudanza/volver-atras-tejido.txt: apagar los dos de la caja y relanzar los tres de gioser con las líneas exactas que se leyeron de /proc/<pid>/environ y cmdline. Nunca las dos a la vez: es la misma identidad.

6.42 ❌ El latido NO se puede mudar todavía — y el motivo redefine qué es un «hub» (2026-09-17)

Se intentó el último intercambio: apagar el cron de la cosecha en gioser y encenderlo en la caja. Salió mal, se vio en el primer ciclo, y se volvió atrás en minutos. Queda escrito porque el motivo no era ninguno de los que se habían previsto.

Lo que sí funcionó

La caja tiene todo lo del latido y el ciclo corrió de punta a punta: sembró el repo al worker, leyó su manifiesto, regeneró los ocho ficheros de estado, pasó los vigías, se puso al día por fast-forward y commiteó+empujó. El git config de identidad y el pull --ff-only nuevo hicieron su parte.

🕳️ Lo que publicó, que es el problema

  totals que publicó la CAJA :  {recipes: 1112, nodes: 1115, unhashable: 1112, ajeno: 3}
  totals de gioser           :  {recipes: 1112, nodes: 1115, sealed: 1105, never: 5, debt: 2}

unhashable: 1112 — no es «no está sellado»: es que no se pudo CALCULAR el hash de ninguna receta. Y la causa es la misma que el §5.2 documenta desde el otro lado: el lab entra en el ArtifactHash, así que una máquina sin el rootfs del lab no puede hashear nada. La caja de producción no lo tiene — a propósito, no es un olvido.

El daño era real y silencioso: el commit 45b5685a dejó en main un build-state*.json que dice que el corpus entero no existe. Cualquiera que lo lea —o cualquier vigía que lo compare— vería 1112 recetas en cero.

La lección, que vale más que el intento

Un hub no es «la máquina que tiene el repo y la llave»: es la que tiene el LAB. El latido parece tarea de cron —siembra, cosecha, commitea— pero su tercer paso es pensar sobre el corpus, y eso exige el toolchain. Por eso la puerta 4 no era una línea de crontab, aunque todas las piezas que se miraron (git, python3, rsync, ssh, takana, flock, .fleet, la llave) estuvieran.

⇒ Las salidas son tres, y ninguna es «poner el cron»:

  1. darle el lab a la caja (/.dev-fs/…) — deja de ser sólo servidor y pasa a ser también hub;
  2. partir el latido: siembra+cosecha en la caja, regeneración del grafo donde haya lab;
  3. que el hub sea otra máquina (el LXC prestado ya tiene lab y muele 24/7).

Es decisión del usuario, y es lo único que queda entre esto y borrar gioser.

Vuelta atrás, en minutos

Cron de la caja borrado, cron de gioser reactivado (estaba comentado, no borrado). El propio latido de gioser repara el daño solo en su siguiente ciclo: regenera el grafo desde SU store —que sí tiene el lab— y vuelve a publicar los números buenos.

🧰 Y de paso, tres cosas que el ciclo destapó en la caja

6.43 🧪 La caja ya es un HUB: el lab viajó, y con él la puerta 4 (2026-09-17)

El §6.42 dejó dicho qué faltaba y el usuario eligió: darle el lab a la caja. Hecho, y el latido ya late desde allá.

El lab no se copia: se PINEA y se trae

scripts/lab-image.sh empaqueta .dev-fs/alpine de forma determinista (--sort=name --mtime=@1 --owner=0 --group=0 --numeric-owner, excluyendo var/log/apk.log, que es lo único que difiere entre dos labs por lo demás iguales), lo publica al Storage Box con el sha en el nombre, y del otro lado --traer verifica el sha antes de extraer. Eso es lo que hace que las dos máquinas hasheen igual, y es un contrato, no una copia.

Y el empaquetado de hoy devolvió el sha que el repo ya pineaba:

  empaquetado hoy en gioser : d1e341d5dd434a11e2290b0340069ce4a83563eac8e4481f379b4951b1162045
  pineado en bootstrap-devfs.sh: d1e341d5dd434a11e2290b0340069ce4a83563eac8e4481f379b4951b1162045

O sea dos cosas de una: el lab de gioser nunca derivó desde que se publicó esa imagen, y el empaquetado es reproducible de verdad — no era una promesa del comentario.

La prueba que decide, y no es que arranque

Con el lab instalado, la caja calcula los MISMOS ArtifactHash que gioser:

  zlib          b3:dc363f26…   busybox   b3:2a2b1280…
  caddy         b3:c915987d…   shuma-daemon b3:f9b92acc…

Cuatro de cuatro. Si el lab fuera otro, estos números serían otros y el store no lo notaría — es el agujero que hash_inputs cerró (§lab en el hash) y la razón por la que esto se verifica con hashes y no con un «arrancó bien».

Los stores, convergidos — y una trampa que me tendí solo

El grafo seguía discrepando porque el store de la caja no tenía 24 artefactos vigentes de gioser (3,0 G). Al copiarlos con rsync -a --files-from=<lista de DIRECTORIOS> pasó lo peor posible:

  sent 2,281 bytes … total size is 0        ← no copió NADA
  artefactos en la caja: 1631 → 1655        ← pero creó los 24 DIRECTORIOS

--files-from no recursa sin -r: creó 24 directorios vacíos en el store. Y un directorio vacío en el store es un cache-hit (§3 de CLAUDE.md, [[artefacto-vacio-envenena-cache]]): build lo habría dado por sellado y habría salido OK sin construir. Se detectó al contar, se borraron los 24 y se repitió con -r: 3,18 GB transferidos, 0 vacíos de 1655.

Después faltaban dos más (obs-studio y spectacle, justo los que el worker no logra construir), copiados también. Resultado:

giosercaja
corpussealed 929 · debt 2sealed 930 · debt 1
KDEsealed 1105 · debt 2sealed 1106 · debt 1

La caja no empata: gana por uno en los dos grafos.

El latido, mudado

Cron apagado en gioser, encendido en la caja, y un ciclo completo corrido a mano para mirar lo que publica: ==> repo al día (ff) → ==> estado commiteado+pusheado, con los números buenos. El build-state que hay en main ahora lo escribió la caja.

⚠ Lo que queda apretado es el disco: /store de la caja está al 91 %, 8,2 G libres. Un hub sella y cosecha, así que ése es el próximo muro — scripts/store-gc.sh es la herramienta y todavía no se corrió allá.

⇒ puerta 4 en verde. Con eso, de las ocho quedan sólo las dos a medias (5 y 7) y ninguna en rojo.

6.44 🧹 Primer store-gc en la caja: 26,8 G — y dos bugs que sólo salen fuera de GNU (2026-09-17)

El store estaba al 91 % (8,2 G libres) justo cuando la caja pasó a ser hub, que es la peor combinación: un hub sella y cosecha.

  antes:  84,7 G usados · 8,2 G libres · 91 %  · 1657 artefactos
  después: 57,9 G usados · 35,0 G libres · 62 % · 1320 artefactos

337 artefactos superados borrados y verificados (0 sobrevivientes), 26,8 G. El default del gc no toca los 150 huérfanos —11,4 G, los que son el único ejemplar de su nombre— y eso está bien: borrarlos es la diferencia entre «se reconstruye en dos minutos» y «hay que rehacer la torre».

El control que hay que correr DESPUÉS de un gc en esta caja, y que no es obvio

11 binarios de /usr/bin son symlinks que apuntan DENTRO del store — los daemons que se instalaron estos dos días. Un gc que borre el artefacto equivocado no rompe el store: rompe el /usr/bin de una máquina en producción. Antes de --aplicar se comprobó que los 11 enlazan al hash vigente (y por lo tanto están en el conjunto protegido); después, que los 11 siguen resolviendo y que los 21 entes siguen corriendo. Los dos controles pasaron.

⚠ Y la advertencia que el propio gc imprime y que en esa caja no se puede satisfacer: «sin .config legible (ni /proc/config.gz ni /boot/config-…) ⇒ NO se protege ningún kernel por esta vía». Con el default da igual; con --huerfanos en una máquina que arranca de su store, no.

Los dos números que el gc no sabía decir

Funcionó, pero sus dos cifras salieron vacías — y son las que uno mira para decidir si vale la pena:

  ==> espacio: superados  · huérfanos
  ==> 337 artefactos borrados y VERIFICADOS · libres: Available → Available
  1. du -sch --files0-from=- es de GNU y busybox no lo tiene ⇒ espacio() devolvía vacío. Un número que falta se lee como un número chico.
  2. df -h /home estaba cableado y el store no vive ahí: en gioser es un bind-mount del volumen, en la caja es /store. Sin /home, tail -1 se quedó con la CABECERA — de ahí «Available → Available».

Arreglados los dos… y el primer arreglo también estaba mal: xargs -d '\n' es otra extensión GNU. Eso imprimió huérfanos ?, y ese ? es deliberado: cuando el total no se puede calcular, la función lo DICE en vez de escribir 0 — un cero inventado habría dicho «no hay nada que ganar» con 11,4 G en huérfanos. Con tr '\n' '\0' | xargs -0, que busybox sí tiene, la caja ya contesta espacio: superados 0 · huérfanos 11.4G.

⇒ Van cinco tropiezos del mismo día con la misma forma (/dev/tcp, expansión de llaves, find -newermt, sustitución de procesos, y estos dos). El patrón ya no es anécdota: un script del hub escrito en una máquina GNU no corre en la caja hasta que se prueba ahí.

6.45 🧯 poda-fuentes tampoco corría — y la causa no era el script: la caja no tiene /dev/fd (2026-09-17)

El latido, desde la caja, moría así en su paso de poda:

  poda-fuentes.sh: line 72: /dev/fd/63: No such file or directory
  poda-fuentes.sh: line 73: viejos: unbound variable
  ⚠ poda de fuentes falló (rc=1) — sigo

El segundo error es consecuencia del primero (el mapfile nunca corrió) y el primero no nombra la causa: la sustitución de procesos de bash (<(...)) necesita /dev/fd, y una caja takana no lo tiene. gioser sí: /dev/fd -> /proc/self/fd. O sea que no era este script: ningún <(...) de ningún script del hub funcionaba allá, y éste fue el primero en toparse.

Por eso los arreglos son dos, y los dos hacían falta:

  1. En la caja: /dev/fd -> /proc/self/fd, con una Card OneShot en el genesis para que sobreviva al reinicio (como la de hostname). Comprobado después: bash -c 'cat <(echo funciona)' responde. ⇒ lo correcto a futuro es que lo haga el init o el product-rootfs, no una Card. Van dos parches de esta forma; es una lista que conviene no alargar.
  2. En el script: fuera <(...) (fichero temporal) y fuera df -B1 --output=avail, que es GNU — con df -k + awk. Sin eso el script corría pero decía libres 0.0 G, porque el df fallaba en silencio y el 0 se lee como un disco lleno.

Con las dos cosas, la poda hizo su trabajo por primera vez en la caja:

   2 árbol(es) por encima del suelo · borrados 2 · libres 46.1 G · LIBERADO 5.4 G

⇒ En total, 32,2 G recuperados hoy en la caja: 26,8 G de artefactos superados (§6.44) y 5,4 G de árboles de fuentes. /store al 62 %, /work al 29 %.

6.46 🚪 Puerta 5 verde: la caja publica su repo firmado y se instala de él (2026-09-17)

La puerta 5 estaba a medias desde el §5.4 y el motivo era el de siempre: el host no se puede actualizar de sí mismo porque install reproduce desde fuente y eso exige el LAB. Con el lab ya instalado (§6.43), se cerró en dos movimientos, los dos corridos en la caja:

1. Publicar — con su propia clave de release, la que se mudó el 2026-09-16 a /root/.config/takana/keys/:

  ==> ✓ el índice verifica contra ./trust
  publicado: 171 con expected_hash anclado, 3 sin ancla. FALLARON: os-release

2. Instalar de ese repo, en modo estricto (--require-signed, que exige autoría del catálogo verificada y no se conforma con que el .swm reproduzca):

  release: trusted (by release)
  install duf 0.9.1 ← dist/repo/duf-0.9.1.tkn
  deps: resolviendo catálogo para 1 dep(s): go
  caché: artefacto ya en el store hash=b3:abb3527d… name=duf
  apply OK — source_patch=1 config_edit=0 init_rule=0 file_drop=0
  registrado duf (2 fichero(s))

Se instaló bajo --prefix /tmp/prueba-install y con --db propio, para no tocar el sistema.

⚠ Y el matiz honesto, que vale más que el verde: el artefacto ya estaba en el store, así que el camino que se ejercitó fue resolver + verificar firma + hidratar, no reproducir desde fuente. Lo que el lab destrabó y sí quedó probado aparte es el hash (§6.43: cuatro de cuatro iguales a gioser), que era exactamente donde moría antes —«no pude leer el apk db del lab»—. Queda sin ejercitar el caso «paquete que NO está en el store», que en una caja de producción además es discutible que se quiera: un servidor no tiene por qué compilar.

⇒ os-release es la única receta del perfil que no se pudo publicar; es la que lleva el logo y la identidad de la distro, y su fallo queda anotado para mirarlo aparte.

6.47 🚪 Las ocho, al cierre del 2026-09-17

#puertaestado
1arranca takana puro, se entra por SSH✅
2el store está entero✅ 1320 artefactos, 0 vacíos tras el gc · /store al 62 %
3los cuatro grafos idénticos✅
4la granja late en la caja✅ §6.43 — con el lab pineado; la caja gana por uno en los dos grafos
5el repo se sirve y el host se instala de él✅ §6.46 — 171 paquetes publicados y install --require-signed en verde
6cada dominio vivo responde 200✅ los 8
7el respaldo corre desde la caja✅ §6.48 — corrida completa, 0 errores, y 0 artefactos de la caja sin respaldar
8lo fósil, anotado y decidido✅

⇒ LAS OCHO EN VERDE (§6.48 cerró la última). Lo que separa a esto del borrado de gioser ya no es técnico: es la decisión del usuario, que es como el §6 dice que tiene que ser — a mano, con las ocho en verde, nunca por automatización.

Y lo que queda vivo en gioser es el trabajo de la mudanza, no servicios: los ~10 G de árboles personales de /home sin decidir (§6.35) y el montón de [[datos]] que el censo dejó sin decisión.

6.48 ✅ Puerta 7: el respaldo corre desde la caja — y el parcial que la envenenaba (2026-09-17)

  respaldados (manifiesto del Storage Box) : 3686
  en el store de la caja                   : 1320
  de la caja, NO respaldados               : 0        ← la cuenta que decide
  errores y reintentos en la corrida       : 0 y 0

Las ocho puertas quedan en verde.

⚠ Pero la primera corrida NO terminó, y el motivo vale por sí solo

Se cayó 40 veces seguidas con el MISMO fichero:

  open ".../fonts/dejavu/.rsync-partial/DejaVuSans-Oblique.ttf" failed: No such file… (2)
  ..  store: corte de red (rsync 23). Intento 39; reanudo en 60s.
  !!  store: 40 intentos y sigue cayéndose. Dejo lo subido y paro.

No era red. Una transferencia interrumpida el 11 de septiembre había dejado ese parcial en el destino con el modo del origen —-r--r--r--, porque el store es de sólo lectura por diseño— y ningún intento posterior puede reescribirlo. El fallo es ETERNO: esa ruta quedaba envenenada para siempre, y el bucle la golpeaba cada 60 segundos llamándola «corte de red». Es exactamente la pared que la cabecera de esa función dice que no hay que golpear cuarenta veces.

Dos arreglos, y el segundo importa más que el primero:

  1. --chmod=Fu+w en el rsync: los ficheros del respaldo quedan escribibles por su dueño, así que un corte a mitad se RETOMA en vez de trabarse. El precio —no conservar el bit de sólo-lectura en la copia— es bajo: lo que se respalda es el contenido, y los permisos los reconstruye el store.
  2. Al rsync 23 se le dan TRES intentos, no cuarenta, y al tercero se NOMBRA la causa probable. El 23 es «some files were not transferred», que tanto puede ser un cable como una pared; tratarlo siempre como cable es lo que convirtió un fallo permanente en cuarenta minutos de ruido.

🪤 Y dos veces el mismo error mío: pgrep -f <patrón> se encuentra a SÍ MISMO

Para saber si el respaldo seguía vivo usé pgrep -f respaldo-storagebox… y el patrón está en la línea de comando del propio pgrep (y del ssh que lo lanza). Resultado: decía «sigue corriendo» para siempre, incluso con el script muerto hacía rato — y el vigía que armé con esa condición nunca podía dispararse. Pasó dos veces en la misma tarde, en las dos máquinas.

La forma correcta es el truco del corchete —ps ... | grep "[r]espaldo-storagebox"—, que no coincide consigo mismo porque el patrón literal [r]espaldo no aparece en la línea del grep. Es hermano del pkill -f que mata tu propia shell, ya anotado en las memorias.

6.49 📋 Lo único que queda: 150 decisiones sobre datos (2026-09-17)

Con las ocho puertas en verde, de la mudanza no queda trabajo técnico: queda decidir. La hoja está en docs/state/mudanza-decisiones-pendientes.txt — 150 entradas, 31,9 G, ordenadas por tamaño, con lo que la herramienta se anima a sugerir por un HECHO entre corchetes y en blanco lo que sólo sabe el dueño:

  /home/sergio/.claude         6.8G  [     ]  lo más nuevo: 2026-09-16
  /home/sergio/.local          6.2G  [muda ]  lo usa act_runner, tejido, thasnuna
  /var/lib/swap                6.1G  [muere]  (es swap)
  /home/sergio/.cache          4.3G  [muere]  caché
  /var/lib/gitea               1.9G  [     ]  ← ya mudado en el §6.14; la entrada es un fósil del censo
  /home/sergio/imm             200M  [     ]  lo más nuevo: 2026-03-23

Se aplica en una pasada con planear.py --decidir, que ya no necesita teclas unitarias (§3 bis).

⚠ Y la hoja dice qué no cubre, porque una lista titulada «lo que falta» que omite 329 G se lee mal: no están /mnt/vvv —el monorepo, este repo, el store, rustup— ni los servicios y dominios. Lo de /mnt/vvv no necesita decisión: el store está respaldado (puerta 7) y los repos viven en el gitea de la caja.

Lo último que se rescató antes de cerrar

/mnt/vvv/gioserv tenía 26 entradas sin commitear y su remoto era /var/lib/gitea/repositories/sergio/gioserv.git — una ruta LOCAL de la máquina que se borra. Lo commiteado está a salvo (su HEAD es el mismo que el del gitea nuevo); lo que no estaba en ningún lado —el git diff HEAD completo (api/main.py, +92/−23, y 25 borrados) más los 4 no rastreados— quedó en takana:/work/mudanza-secretos/gioserv-pendiente/, verificado: 0 ficheros del origen faltan en el destino.

No se reapuntó el remoto ni se commiteó nada: cambiar el remoto de un clon ajeno y decidir si ese api/main.py se publica es del dueño del repo, no de la mudanza. El LEEME.txt del paquete lleva el comando exacto para reapuntarlo.

6.50 🤖 El agente corre en la caja — y puede construir (2026-09-17)

Pedido del usuario, y es la condición que él mismo puso para borrar gioser: «no borramos gioser hasta tenerte corriendo de aquel lado, con capacidad de compilar tawasuyu y las recetas de takana».

  $ claude -p "Decí exactamente: CLAUDE CORRIENDO EN LA CAJA"
  CLAUDE CORRIENDO EN LA CAJA

Eso es un turno REAL —API, credenciales, respuesta— desde 2.29.29.217.

Por qué va enjaulado, y qué se le concedió

El CLI es un ELF glibc de 360 M (interpreter /lib64/ld-linux-x86-64.so.2, NEEDED libc.so.6) y la caja es musl: no hay camino nativo. Es el caso del ADR 0015, el mismo de sergioh-api. La instancia claude declara, y conviene decir que es ANCHA en vez de disimularlo: red, nesting, y seis directorios —/opt/takana (el trabajo), /work/dev-fs (lab + zig/go), /store, /root/.claude (memoria y credenciales), /root/.ssh (ro) y el CLI (ro)—. Un agente que construye, commitea y empuja necesita lo mismo que un humano haciendo ese trabajo; lo que la jaula compra es que ese alcance esté escrito y acotado, no que sea pequeño.

⚠ El takana que construye no se instala adentro: sale del store, que ya está concedido. Es estático musl y corre igual dentro de una imagen glibc — comprobado (takana 0.0.1).

La caja construye: dos recetas selladas hoy

Antes faltaba una pieza que el lab no traía: .dev-fs/tools (zig 0.13/0.16 y go, 1 G). El primer intento murió con zig no encontrado en .dev-fs/tools/zig/zig — la imagen del lab empaqueta alpine/ y nada más. Copiado eso:

  intel-ucode   → sellado: 156 signatures + GenuineIntel.bin (17 MB)
  sof-firmware  → sellado: 2 firmware + 8 topologías (IPC3 v2.2, TigerLake)

🧱 Lo que NO funciona, y su rodeo

takana build NO corre DENTRO de la jaula, aunque la instancia declare nesting = true:

  bwrap: Can't mount proc on /proc: Operation not permitted

Y no es que el anidamiento esté roto: un bwrap --dev-bind / / --proc /proc a mano SÍ funciona adentro, con y sin --unshare-pid. Lo que falla es el userns anidado que arma el sandbox del build. Probado también con root = false —la sospecha que el propio código documenta (§grants.root ⚠ del 2026-09-03)— y falla igual: no es el remapeo a root.

El rodeo que sí anda, y quedó escrito en el lanzador para que el agente lo use:

  ssh -p 22022 -i /root/.ssh/github5 root@127.0.0.1 \
    'cd /opt/takana && flock -o work/.farm-build.lock takana --store /store build <receta>'

Con eso, desde dentro de la jaula se selló sof-firmware. El agente vive enjaulado y le pide los builds al anfitrión, que es donde el sandbox del build tiene los permisos que necesita.

Cómo se entra

claude en el PATH de la caja (/usr/bin/claude), que envuelve takana qorpa run claude -- env HOME=/root …. El manifiesto y el lanzador están versionados en scripts/servidor/, porque una instancia que sólo existe en la caja se pierde con la caja.

⚠ Pendiente menor: la memoria del agente está en /root/.claude/projects/-mnt-vvv-takana/, y allá el repo vive en /opt/takana ⇒ el índice por ruta no coincide. Se resuelve renombrando el directorio a -opt-takana o dándole al repo la misma ruta; hasta entonces el agente arranca sin su memoria del proyecto aunque los 733 ficheros estén en disco.

6.50 bis 🔑 La cuenta necesitaba sus LLAVES, no su contraseña (2026-09-17)

Creada la cuenta, el primer intento del usuario falló:

  $ ssh -i ~/.ssh/tawasuyu sergio@gioser.net -p 2345
  sergio@gioser.net: Permission denied (publickey).

Y era exacto: el sshd de la caja tiene PasswordAuthentication no, así que la contraseña tawasuyu sirve para su y para la consola, no para entrar por SSH. Lo que faltaba era su ~/.ssh/authorized_keys, que se creó vacío con la cuenta. Copiado el de gioser —dos llaves, tawasuyu y sergio@tatata— entra:

  $ ssh -i ~/.ssh/tawasuyu sergio@gioser.net -p 2345
  ENTRA como sergio en takana · /home/sergio
  $ claude -p "…"   →  ENTRÉ POR SSH Y ABRÍ CLAUDE

Dos detalles que hacen que esto se sienta como la máquina de siempre: gioser.net ya resuelve a la caja, y la llave de host es la MISMA que la de gioser (se copiaron en el cutover del gitea, §6.25) — comprobado: SHA256:ltSl+rgr2Uc… en las dos. Por eso no aparece el aviso rojo de «REMOTE HOST IDENTIFICATION HAS CHANGED» al entrar al nombre de siempre.

⚠ El sshd escucha en 22022 y 2345, los dos generales: 2345 es el puerto que los clones de git ya usaban, y sirve igual para entrar.

6.50 ter 🧰 sudo y el taller: dónde se programa en la caja (2026-09-17)

sudo no andaba por dos cosas a la vez, y una era el corte de energía. /usr/bin/sudo no tenía el bit setuid (sudo: must be owned by uid 0 and have the setuid bit set), y además /etc/sudoers.d/sergio había desaparecido: lo escribí minutos antes del reset del §6.51 y se fue con el mismo corte que dejó el cortafuegos en NULs. Repuestos los dos —y con sync—, sudo id devuelve uid=0(root). visudo no está instalado, pero no hace falta: el fichero suelto en /etc/sudoers.d/ es la forma correcta, y sudo lo valida solo.

⚠ doas sigue dando Operation not permitted aun estando setuid. No hizo falta perseguirlo —sudo resolvió— pero queda anotado como rareza sin explicar.

Dónde están los repositorios

Los 44 están en la caja, en el gitea que se mudó el 14-sep: /var/lib/gitea/repositories/ (sergio/ 939 M, tawasuyu/ 547 M). Lo que NO había era un clon de trabajo: sólo /opt/takana, que es el del hub.

  ~/src  →  /work/sergio          ← el taller
  /work/sergio/tawasuyu  478 M    ← clonado, origin al gitea de la caja

⚠ El taller va en /work, no en el home. La raíz de esta caja tiene 2,4 G libres y /work 43 G: un clon del monorepo en /home llena la raíz y tumba la máquina.

⚠ Y clonar acá tiene dos trampas medidas:

  1. El git del corpus no tiene remote-https (git: 'remote-https' is not a git command), así que no se puede clonar por HTTPS desde la propia caja. Es la deuda ya anotada del §bloqueantes.
  2. Clonar por SSH (ssh://gitea@git.gioser.net:2345/…) exige que la llave esté registrada en la cuenta de gitea, no basta con el authorized_keys del sistema. Hasta que se registre, el camino es el sistema de ficheros (git clone /var/lib/gitea/repositories/<org>/<repo>.git) — con dos detalles: el directorio de cada organización es 0700 del usuario gitea, así que hay que clonar como root y chown después; y git rechaza el repo por dubious ownership hasta que se le dice git config --global --add safe.directory "*".

Y el agente se abre sobre cualquiera de ellos:

  PROY=/work/sergio/tawasuyu claude
  → «Es tawasuyu (/work/sergio/tawasuyu, origin git.gioser.net:2345/…), último commit 55e5f0f27»

6.50 quater 📦 Cómo se instala software de tawasuyu en la caja — y por qué NO con actualizar-servidor.sh (2026-09-17)

Pregunta del usuario: «¿ahí tengo la confianza de install-tawasuyu y actualizar-servidor? ¿o cómo es la forma de instalar, por ejemplo, shuma-tui?»

Esos guiones existen y siguen ahí —el monorepo está clonado en /work/sergio/tawasuyu— pero lo que hacen es:

  cargo build --release <crates>        …y después…        install -m755 <nuevo> /usr/local/bin/<x>

O sea: producen exactamente la clase de binario que la mudanza encontró irreproducible — 2,7 G de sueltos que ningún paquete provee (§6.35), de los cuales tres corrían desde un inodo BORRADO. Además actualizar-servidor.sh escribe servicios en /etc/init.d/, que en esta caja no existe: el init es arje.

La forma de acá, hecha de punta a punta con shuma-tui

  1. receta      recipes/shuma-tui.toml   — pinneada al commit del monorepo, zig-cc, musl, estática
  2. build       takana build             ⇒ b3:f795ee0f… sellado en el store
  3. viaja       worker → hub → caja      (rsync del artefacto, que es content-addressed)
  4. instalar    ln -s /store/<hash>-shuma-tui/usr/bin/shuma-tui /usr/bin/
  5. control     shuma-tui --help  →  contesta

Los pasos 3 y 4 son el atajo honesto de hoy; el camino completo es el del §6.46 —publicar en el repo firmado y takana install <nombre> --require-signed—, que ya está probado y es lo que convierte «copiar un binario» en «instalar un paquete».

⚠ El paso 2 NO se puede hacer en la caja todavía, y el motivo es la deuda ya conocida: su git no tiene remote-https.

  git: 'remote-https' is not a git command
  Error: git ["clone","--mirror",…,"https://git.tawasuyu.net/…"] falló

Las recetas del corpus que bajan tarballs con curl sí construyen ahí (hoy se sellaron intel-ucode y sof-firmware); las que clonan por HTTPS —todas las de tawasuyu— hay que construirlas en el worker, que sí tiene git completo. Arreglar eso es re-sellar git con libcurl, y es raíz de perfil.base: unidad propia.

6.50 quinquies 🔑 Las fuentes git se traen por SSH — y la raíz de 5,9 G tiró la caja entera (2026-09-17)

El §6.50 quater dejó anotado el muro: la caja no tiene git-remote-http, así que las 36 recetas que clonan de git.tawasuyu.net —y las ~90 de GitHub— no se podían construir ahí. La salida es la que pidió el usuario: usar SSH. Y sale gratis en hashes, porque la URL es un LOCALIZADOR y no entra en hash_inputs (ADR 0013): el mismo commit por otro transporte sella el mismo artefacto.

No se toca ninguna receta —el worker sigue clonando por HTTPS, que es lo que puede— sino la caja:

  git config --global url."ssh://gitea@git.gioser.net:2345/".insteadOf "https://git.tawasuyu.net/"
  git config --global url."ssh://git@github.com/".insteadOf        "https://github.com/"

Tres cosas que costaron una vuelta cada una, las tres MEDIDAS:

  1. takana clona bien, pero cargo vendor clona aparte. cargo honra el insteadOf, pero usa su propio cliente SSH: primero se plantó por la clave del host —la tenía como [git.gioser.net]:2345 y cargo la busca sin puerto, misma clave reexpresada— y después por autenticación, porque no sabe leer IdentityFile de ~/.ssh/config. Se resuelve delegando en el git de verdad:

    # ~/.cargo/config.toml
    [net]
    git-fetch-with-cli = true
    
  2. La huella de github.com se PINEA, no se acepta a ciegas. ssh-keyscan dio SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU, idéntica a la que GitHub publica, y por eso entró a known_hosts. La clave de esta caja autentica como deploy key de otro repo y aun así lee repos públicos ajenos: comprobado con uutils/coreutils.

  3. Y la que dolió: la raíz son 5,9 G y cargo la llenó. CARGO_HOME estaba en /root/.cargo, o sea en sda2. El vendor del monorepo bajó 2,7 G, la raíz llegó al 100 %, y la caja no se degradó: se cayó entera — sin HTTP, sin SSH y sin responder al ping, con Hetzner informando running. Hubo que entrar por rescate otra vez.

    Lo que el disco lleno deja como rastro, y conviene saber leer:

      caddy   → «write error: write /var/log/caddy/requests.json: no space left on device»
      mis     → /root/.ssh/config quedó con 105 BYTES NULOS al final: el append entró a medias
      escrituras  (el mismo daño que el corte de luz le hizo al reglaset del cortafuegos, §6.51)
    

    Un >> que falla por ENOSPC no deja el fichero como estaba: lo deja extendido y relleno de ceros. Es exactamente la clase de avería que el §3 de CLAUDE.md describe para los artefactos vacíos — llega hasta el final diciendo que todo fue bien.

Reparado en el rescate, y las tres piezas son permanentes:

quédóndepor qué
/root/.cargo → /work/cargo-homesymlink2,7 G de caché no caben en una raíz de 5,9 G
poda horaria de logs/usr/local/sbin/poda-logs.sh + cron 17 * * * *ente-gitea.log había llegado solo a 114 M y nadie lo rotaba
.ssh/config y .gitconfigreescritostenían NULs de las escrituras a medias

Tras el arranque: raíz al 54 %, los 18 entes corriendo y los 7 dominios de la caja en 200 (terapeuta.ec sigue en gioser a propósito — se decidió respaldar, no mudar).

La lección, que no es sobre cargo. La raíz de esta caja es 5,9 G y todo lo que crece sin dueño vive ahí: logs, cachés, spool. No hay margen para «ya lo limpio después», y el modo de fallo no es un error: es la máquina entera apagándose en silencio. Cualquier cosa que acumule bytes en la caja se declara dónde vive antes de correrla por primera vez.

El control que cierra esto: la caja construyó una receta de tawasuyu de punta a punta

puriy-costura era la única de las 36 que no estaba en el store de la caja — o sea, un fallo de caché de verdad, no un cache-hit disfrazado de éxito ([[cache-congela-regresiones]] es justo esto). La caja la hizo entera y sola: clonó por SSH (111,7 M de mirror), vendoreó, compiló y selló.

  hash que predice el HUB  b3:7d63655aa5fea3cbc08724887617d7f58b325fcb2e00ac050b1937966c2459e6
  hash que selló la CAJA   b3:7d63655aa5fea3cbc08724887617d7f58b325fcb2e00ac050b1937966c2459e6
  contenido                2,2 M · usr/bin/puriy-costura · corre y muestra su ayuda

Idéntico, que es lo único que prueba que el transporte no contamina el artefacto. Con esto la caja cumple la condición que puso el usuario para poder borrar gioser —«con capacidad de compilar tawasuyu y las recetas de takana»— desde fuente y por su cuenta, no por artefactos que le llegan del worker.

El guion scripts/servidor/fuentes-por-ssh.sh deja esto reproducible: idempotente, con la huella de github pineada (si el host anuncia otra, sale con error en vez de aceptarla) y con un control final que CLONA de los dos orígenes. Probados sus dos negativos: un repo inexistente lo hace fallar, y una huella cambiada la rechaza.

6.50 sexies ✅ Pagada la deuda: el git del corpus vuelve a clonar por HTTPS (2026-09-17)

El §6.50 quinquies dejó la caja trayendo fuentes por SSH porque su git no tenía remote-https. Eso era el síntoma; la causa estaba un paso antes de donde todos mirábamos —incluida la nota que yo mismo había escrito, que culpaba al Makefile—.

El tarball de git trae configure, y takana lo corre. Su test de libcurl es AC_CHECK_LIB, que enlaza conftest.c -lcurl y nada más. Con libcurl estática eso no resuelve: faltan -lssl -lcrypto -lz, que son sus privadas.

  checking for SHA1_Init in -lcrypto...       yes
  checking for curl_global_init in -lcurl...  no      ← acá se decidía todo
  checking for XML_ParserCreate in -lexpat... no

El test falla, config.mak.autogen se lleva NO_CURL=YesPlease, el Makefile excluye git-remote-http, git-http-fetch y git-http-push —y conserva git-http-backend, que es el lado servidor y no usa curl: esa asimetría en el artefacto era la firma del fallo, y estuvo a la vista desde el 2026-09-14—. El build termina en verde. Es el §3 de CLAUDE.md en su forma más cara: un ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien.

La cura, tres líneas en la receta, cada una con su motivo:

líneapor qué
LIBS="$(curl-config --libs)" al configurele da al test lo que el enlace estático necesita — y sale de curl-config, no escrito a mano: si curl gana privadas mañana, las trae solo
guardián sobre config.mak.autogenel modo de fallo es silencioso ⇒ se comprueba el HECHO (NO_CURL=YesPlease) y se corta, en vez de enterarse meses después
NO_EXPAT=1con curl encendido el Makefile agrega http-push.o, que pide -lexpat, y expat no está en deps. http-push es el push por WebDAV: clonar y traer no pasa por ahí

Medido sobre el artefacto, nunca sobre el git del host (ls $(git --exec-path) | grep remote-http): b3:215742cb… trae git-remote-http, git-remote-https, git-http-fetch y los ftp. Y clona —no ls-remote: clone, que es donde murió la vez del ubsan—: árbol en disco y rev-list contesta. En la caja, hidratado y con la reescritura a SSH apagada, git clone https://… funciona, y git clone --mirror —el comando exacto que usa takana build para traer fuentes— también.

El radio, medido en vez de temido. La nota vieja decía que arreglar esto «re-sella git, que es raíz de perfil.base ⇒ radio grande». yupana radio git dice otra cosa: cero dependientes directos y cero transitivos. Toca las 7 imágenes que lo incluyen —que es re-ensamblar, no reconstruir—. El miedo escrito a mano había sobrestimado el costo de arreglarlo, y eso lo mantuvo roto tres días.

Las reescrituras url.insteadOf quedaron retiradas de la caja: ya no hacen falta. scripts/servidor/fuentes-por-ssh.sh se conserva como salida de emergencia, no como el camino normal.

6.52 🧩 Las proc-macros SÍ se pueden compilar en la caja — falta un cc, no un parche (2026-09-18)

Con rust sellado, la caja compila y corre binarios estáticos sin tocar nada. Lo que no compilaba es cualquier dylib, y eso incluye todas las proc-macros (serde_derive, clap_derive…), o sea media ecología de Rust:

  ld.lld: error: unable to find library -lgcc_s
  ld.lld: error: unable to find library -lc

Por qué el -L obvio empeora las cosas

La reacción natural es darle el camino: -L /usr/lib. Y eso rompe los estáticos, que hoy funcionan. Cuatro controles lo aíslan:

enlaceresultado
Aestático, sin -L✅ enlaza — es lo que hay hoy
Bestático, con libgcc.a a la vista✅ enlaza — el arreglo de gcc-libs no rompe nada
Cestático, con -L /usr/lib + libgcc.a❌ rompe
Destático, sólo con -L /usr/lib❌ rompe ← la culpable
  ld.lld: error: relocation R_X86_64_32S cannot be used against symbol '__malloc_size_classes';
          recompile with -fPIC            ← sale de /usr/lib/libc.a, que NO es PIC

rustc enlaza musl static-PIE con su copia self-contained; meter /usr/lib en el camino le hace preferir la libc.a del sistema, que no sirve para PIE. El camino de librerías no puede ser global: el estático lo quiere fuera y el dinámico lo quiere dentro. Ningún RUSTFLAGS global arregla esto — apaga un caso y enciende el otro.

Lo que sí funciona, medido de punta a punta

Quien sabe distinguir los dos casos es un driver de C. La caja no tiene gcc ni clang —clang18 en el store son sólo cabeceras y librerías, sin binario— pero sí tiene zig: el artefacto seed-zig del bootstrap trae zig 0.16.0 y corre. zig cc es un driver de C completo con musl adentro.

  cc        →  zig cc
  RUSTFLAGS →  -C linker=cc -C linker-flavor=gcc -C link-self-contained=no

Las tres banderas hacen falta y cada una por su motivo: linker+linker-flavor para que el enlace pase por el driver, y link-self-contained=no porque si no rustc pide su propio rust-lld —que este toolchain no tiene, ya que rust.lld = true es incompatible con el llvm-config externo (§ la receta lo documenta)— y muere con «the self-contained linker was requested… it wasn't found». ⚠ La variante fina -C link-self-contained=-linker no sirve: es inestable en este triple y exige -Z unstable-options. La gruesa, =no, es estable.

Con eso, la cadena completa que nunca había compilado:

  1. la proc-macro            ⇒ libpm.so
  2. el programa que la usa   ⇒ imprime «PROC-MACRO VIVA»
  3. y el estático de siempre ⇒ sigue enlazando

Entregado: scripts/servidor/cc-por-zig.sh

El envoltorio existe y está puesto en la caja. No mete nada nuevo en el corpus: usa el seed-zig que YA está en el store —producto del bootstrap (SDD 11), como stage1-rootfs— y sólo lo envuelve en /usr/bin/cc y /usr/bin/c++, más las tres banderas en /etc/profile.d/. Volver a zig el compilador de C oficial de la distro es otra cosa —ahí sí haría falta receta y pin propios— y es decisión de ADR, no de un guion de puesta a punto.

Es idempotente y sus cuatro controles preguntan por el HECHO:

  ✓ C: C en takana                                  ← compila y EJECUTA un hola mundo
  ✓ rust: proc-macro: 42                            ← la macro enlaza, el que la usa corre
  ✓ rust estático (sin banderas): estático ok       ← el remedio no rompió lo que funcionaba
  ✓ entorno: una shell de login trae RUSTFLAGS puesto

Ese último control encontró algo de paso, y es la clase de cosa que este repo persigue: en la caja /etc/profile.d no lo leía NADIE, porque /etc/profile no existía. El fichero de las banderas habría quedado presente, con contenido y sin ningún efecto — y el bash_completion.sh que ya estaba ahí llevaba quién sabe cuánto igual de mudo. Se creó el /etc/profile con el bucle estándar. La caché de zig cc vive en /work/zig-cache (54 M) y no en la raíz de 5,9 G, por lo aprendido en §6.50 quinquies.

6.53 🔧 Borré /usr/bin/id probando el setuid (2026-09-18)

Para saber si el bit setuid elevaba en esta caja copié busybox sobre /usr/bin/id —porque busybox elige el applet por argv[0] y necesitaba un nombre válido— y después borré «mis» copias de prueba. Una de ellas era el id del sistema. Resultado, del lado del usuario:

  $ claude --dangerously-skip-permissions
  /usr/bin/claude: line 23: id: not found
  $ id
  -bash: /usr/bin/id: No such file or directory

No quedaba ninguno: /bin/id tampoco existía. Se restauró como sus hermanos de identidad —whoami y groups apuntan a ../../bin/busybox— y se comprobó con id de root, de sergio y con el id -un que usa el lanzador. Barrido posterior: 0 enlaces colgados en /bin, /usr/bin, /sbin y /usr/sbin, y presentes los ocho comandos de los que dependen el arranque y el lanzador.

La lección, que es de método: para probar una propiedad del sistema —¿eleva el setuid?— no se usa un nombre que el sistema ya ocupa. La sonda correcta era la que terminé escribiendo: cinco líneas de C compiladas con el cc nuevo a un nombre inventado (probe-suid). Copiar sobre id fue elegir el camino corto, y el camino corto se llevó un binario del que depende hasta el script que abre al agente. Es la misma familia que el /etc/shadow del §6.51: la caja no avisa cuando le falta algo esencial — se entera el que lo usa.

6.54 🧭 Trabajar DESDE la jaula: lo que el agente de adentro reportó (2026-09-18)

El agente enjaulado pasó un informe de defectos —el primero hecho desde adentro— y vale más que cualquier inspección desde el anfitrión, porque son las cosas con las que se choca al usarla.

Lo que se arregló, y dónde vive cada arreglo

#defectodónde queda el arreglo
1sin entrada en /etc/passwd para el uid de adentro ⇒ ssh/git mueren con No user exists for uid 1001jaula-preparar.sh (upper)
2/etc/ssh/ssh_config.d es de la capa inferior, dueño fuera del mapa ⇒ ssh aborta, no degradajaula-preparar.sh (upper)
3~/.ssh en ro ⇒ ssh no puede escribir known_hostsmanifiesto: pasa a rw + semilla desde el anfitrión
4sin clave para el worker ⇒ Permission denied (publickey), cero granjaclave propia de la caja autorizada en el worker
5sin rsync/python/jq/b3sum, y sin takana ejecutablemanifiesto: packages; y un takana que sale del store

El 1 y el 2 viven en el upper, que por el D3 es caché descartable: al primer recreate se pierden. Por eso existe scripts/servidor/jaula-preparar.sh, que los repone —escribiendo como la persona, no como root, para no dejarle ficheros que después no puede tocar—.

Sobre el 4: se autorizó en el worker la clave de la caja (sergio@caja), no la de root. Es una credencial distinta y revocable: quitarla del authorized_keys del worker corta ese acceso sin tocar ningún otro. Comprobado: ssh root@dev.gioser.net hostname desde la caja contesta PruebasIA.

Sobre el 5: takana no se compila adentro. El binario es el musl estático del store y corre igual dentro de una imagen glibc; lo que fallaba es que /opt/takana/target/release/takana del anfitrión es un enlace a /usr/bin/takana, y adentro /usr/bin es el de Arch. Queda /work/sergio/bin/takana, que elige el más reciente del store —no un hash cableado, para que sobreviva a un re-sellado— y el target/release/takana del clon apunta ahí.

Las tres trampas de orientación, que no son defectos pero cuestan una hora

6.55 🔓 «Una sola sesión» es del KERNEL; «un solo Claude» era nuestro (2026-09-18)

El usuario chocó con Resource busy al abrir un segundo agente y lo llamó por su nombre: «no quiero restricciones arbitrarias». Tiene razón en la queja y conviene separar qué es qué, porque las tres cosas se sienten igual desde afuera y son muy distintas:

1. Lo que impone el kernel. Dos overlays sobre el mismo upper los niega overlayfs, no nosotros: la capa mutable se corrompería. qorpa reintenta seis veces y después explica la causa en vez de dejar el error crudo de bwrap. Eso no se toca.

2. Lo que era empaquetado nuestro — y sí se toca: una instancia por persona. De ahí salía «un solo Claude». Instancias distintas tienen upper distintos y conviven sin problema. Medido: crear la segunda cuesta 0 s y 16 K, porque la imagen se comparte de sólo lectura.

3. Lo que era un accidente, y se arregló al encontrarlo:

estabaahora
/var/lib/hammer/qorpa/instancesroot:root 755 ⇒ una persona no podía crear su propia instanciaroot:sergio 2775 (setgid)
~/.ssh en la jaularo ⇒ ssh no escribe known_hostsrw
/store en la jaularo ⇒ el agente lee y no puede sellarrw
claudeabría /opt/takana siempreabre $PWD
/usr/bin/claudecopia congelada del guionenlace al del repo

⚠ Y queda un defecto de verdad, que hoy bloquea el punto 2: aprovisionar una instancia nueva muere con bwrap: Can't mkdir parents for /run/user/0: Permission denied. La causa está medida: esta caja no tiene /run/user —no hay logind— y qorpa.rs:1542 arma la ruta del runtime dir como /run/user/<uid> sin alternativa. XDG_RUNTIME_DIR no alcanza: lo honra otra rama del código (1244/1549), no ésta. Es arreglo en el crate, no en un manifiesto.

⚠ ESE PÁRRAFO YA NO DESCRIBE LO QUE PASA — el muro se mudó (medido el 2026-09-23). Se reprodujo el escenario exacto que describe: instancia recién creada, manifiesto tal como lo deja create (root = true), red abierta, provision de un paquete. Pasa bwrap entero —la jaula arranca y contesta uid=0(root)— y el /run/user/0 no aparece por ningún lado. Muere después, en otro sitio:

error: failed to chown temporary download directory /var/lib/pacman/sync/download-rC58yQ: Invalid argument
error: failed to synchronize all databases (failed to initialize download)

O sea: el bloqueo de esta sección está arreglado (el candidato es d52824ef, «los padres que inventa bwrap eran 0700») y lo que queda es otra cosa — el mapa de un solo id, que es el ADR 0015 §NO-resuelve 2, la primera cosa que ese ADR dijo que iba a fallar. pacman no puede chownear su descarga porque adentro existe un id y nada más. El preflight ya lo nombra y lo mide: newuidmap: open of uid_map failed: Permission denied, veredicto LIMITA 1.

La ruta cableada de qorpa.rs:1542 sigue en el código —no se tocó—, así que lo que cambió es el síntoma, no esa línea. Se deja el párrafo de arriba en pie a propósito: tacharlo escondería que durante cinco días el arreglo se buscó en el crate equivocado.

Mientras tanto, en producción las jaulas se CLONAN, no se aprovisionan — cp -a upper, ~1 s y 169 M, que no depende del mapeo. Lo hace solo claude-caja.sh. La consecuencia que muerde: si alguien corre qorpa recreate sobre una jaula de agente, tiene que volver a clonar el upper o la jaula vuelve sin git, sin ssh y sin python. Y un detalle que delata que esto viene de lejos: b3sum está declarado en packages y no existe en ninguna jaula, la plantilla incluida.

⚠ Y había un SEXTO límite accidental, de esta misma tanda, que tardó cinco días en salir (2026-09-23). La pregunta llegó otra vez y casi con las mismas palabras: «¿qué me limita levantar más de 8 claudes, una política random o una arquitectura?». Era una política, y de una línea — claude-caja.sh, el guardia del bucle que elige instancia:

[ "$_n" -gt 9 ] && { echo "claude: hay 8 sesiones abiertas de $_base — cerrá alguna" >&2; exit 1; }

Nada más lo sostenía: el bucle ya crea la jaula siguiente cuando encuentra ocupada la anterior, y lo arquitectónico —dos overlays sobre el mismo upper— lo resuelve ese mismo clonado. O sea, el punto 2 de arriba estaba resuelto y este número lo volvía a cerrar ocho sesiones más allá.

Por qué se escapó al barrido del 18-sep, que es lo que importa: los cinco de la tabla eran permisos y modos —755, ro, una copia congelada—, y se los encontró mirando el borde de la jaula. Éste era un literal adentro de un while. Un barrido que busca límites accidentales tiene que mirar las dos formas, porque desde afuera se sienten igual.

Subido a 10, con el techo real escrito al lado del número, que era lo que faltaba. El techo es la RAM, medido acá ese día:

medido
RAM8 sesiones = 2,5 G de RSS (147–493 M cada una, ~310 M de media)sobre 7,7 G, con 8 G de swap y 4,1 ya comidos
Núcleosload 1,07 con las 8 vivasno limitan: esperan la API, no CPU — y taller ya serializa los cargo
Disco169 M por jaulala imagen (587 M) se comparte de sólo lectura

Pasado ~14 se empieza a jugar con el OOM, que es el modo de falla documentado (INFORME-CAIDA-2026-08-17: la caída fue con ocho adentro). Un número sin su porqué es lo que hace que la próxima vez se lea otra vez como una ley del sistema — que es, literalmente, lo que pasó con éste.

Lo que conviene no confundir. La jaula restringe al agente, no a la persona: sergio tiene sudo, entra por ssh y manda en la caja entera. Y dentro de la jaula el alcance es declarativo (D7): lo que no está en grants no se ve, pero agregarlo es editar un fichero, no pedir permiso. Lo indefendible no es que haya límites — es que un límite accidental parezca de diseño, que es exactamente lo que pasó con los cinco de la tabla.

6.51 🔥 Me dejé afuera de la caja — 20 minutos caída, y tres causas encadenadas (2026-09-17)

Crear una cuenta de usuario tiró el servidor de producción. Queda escrito entero porque ninguna de las tres causas era la que parecía, y las tres siguen ahí para quien venga.

1. /etc/shadow no existía, y crearlo cerró el SSH de root

La caja nunca tuvo /etc/shadow: todas las cuentas llevan x en /etc/passwd y entran por llave. Para darle contraseña a sergio creé el fichero con todas las cuentas bloqueadas (!) y la suya con su hash. Efecto inmediato: ssh de root empezó a dar Permission denied (publickey) con la misma llave de siempre. Este sshd rechaza la llave de una cuenta cuyo hash está bloqueado.

⇒ La forma correcta acá es la vieja: el hash va en el campo 2 de /etc/passwd y no hay /etc/shadow. Así quedó, y así funciona su - sergio.

2. La caja NO reinicia por ACPI

Con el SSH cerrado, hcloud server reboot (ACPI) no hizo absolutamente nada: arje-zero no atiende esa señal. La web siguió sirviendo como si nada. Para un mantenimiento remoto eso importa: la única forma de reiniciar esta caja desde fuera es reset, que es un corte de energía.

3. Y el corte de energía dejó el cortafuegos a medio escribir — eso fue lo que la aisló

Tras el reset la caja arrancaba —los logs de arje lo probaban: gitea y caddy escribiendo— pero nadie llegaba. La causa, encontrada mirando el fichero:

  $ head -c 90 /etc/takana/cortafuegos-entrada.nft | od -c
  0000000  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0

El reglaset era TODO NULs. Se había escrito minutos antes del corte: ext4 journaló el tamaño y los datos nunca llegaron al disco. Un fichero de 5 KB de ceros — o, peor y más probable en el primer arranque, medio fichero válido: la tabla con policy drop y las reglas de accept cortadas. Eso es exactamente una caja que arranca, corre todo, y no deja entrar a nadie.

⇒ Después de escribir algo que el arranque necesita, sync ANTES de cualquier reset. Y el corolario amargo: un reset no es un reboot, y lo que se perdió no avisa.

Lo que el rodeo destapó, y vale más que el susto

El costo, dicho sin adornos

~20 minutos de caída de los ocho dominios, cuatro ciclos rescate↔sistema y tres reset duros — uno de ellos, el que corrompió el fichero, disparado por mí a los 400 s de espera mientras la caja estaba haciendo su trabajo. La lección que queda no es «hay que tener cuidado»: es que en esta caja el diagnóstico remoto cuesta un ciclo de 6 a 8 minutos, así que conviene gastar el primero en dejar evidencia en disco antes que en probar una corazonada.

7. Reusar los scripts que ya existen, y no escribir de nuevo

Pedido explícito del usuario. El inventario de lo que ya hace el trabajo:

parascript existentequé le falta
armar el rootfs de un perfilscripts/hydrate-profile.pynada — es el camino bueno (los hydrate-*.sh por escritorio divergen de targets.toml)
producir el .imgscripts/install-image.sh / scripts/product-image.shapuntar al perfil.servidor
escribir a discoscripts/takana-install.shel envoltorio de rescue del §3.2
instalador con TUIscripts/takana-live-install.shser el mismo motor que el remoto, con las respuestas por fichero
provisionar un workerscripts/farm/vps-setup.shya soporta apt y dnf; le falta la rama «takana puro» (sin gestor de paquetes ajeno)
enchufar a la granjascripts/farm/.fleetestá en .gitignore ⇒ un hub recién clonado nace con la flota vacía en silencio
latidoscripts/farm/cosecha-cron.shdecidir cron vs tarjeta de arje (§3.4)
respaldoscripts/respaldo-storagebox.shnada — es interrumpible y continuable
absorber el init ajenoarje-absorbel lector de procesos vivos (§4)

⚠ Un motor, tres frentes. La tentación es escribir el instalador remoto aparte del TUI. Ahí es donde divergen: ya pasó con los hydrate-*.sh contra targets.toml, y con la unidad systemd del worker que quedó vieja mientras el repo estaba al día. La instalación remota tiene que ser la misma que hace la persona, grabada en un fichero de respuestas.


6.56 🫀 El latido latía y no publicaba — 12 días, y el espejo nunca existió (2026-10-07)

El latido vive acá desde el §6.42. El 2026-10-07 se descubrió que llevaba 12 días publicando nada: 580 commits locales atascados en /opt/takana y un «NO pude publicar el estado — queda local» cada 30 minutos en work/cosecha-cron.log. Nadie lo leía, que es justo el riesgo de mudar un guardián a una caja donde no trabaja nadie.

Causa doble, y ninguna era el cron. Publicar dependía de dos estados que el latido NO controla:

  1. el árbol de trabajo — el plan B (git-sincronizar.sh) hace pull --rebase, que se niega con cualquier fichero modificado. Acá era un scripts/servidor/claude-caja.sh que resultó ser byte a byte lo que ya estaba en origin: trabajo duplicado de otro clon, bloqueando por nada.
  2. la rama local — divergida, y pull --ff-only no puede volver NUNCA. El --ff-only es deliberado (regla 2 ter de CLAUDE.md), así que la deriva es de ida.

Los 580 commits contenían, en total, cuatro líneas de timestamp.

⇒ scripts/farm/publicar-estado.sh arma el commit con plumbing sobre el origin/main recién traído, en un índice TEMPORAL (GIT_INDEX_FILE), y empuja el sha a refs/heads/main. No toca el árbol, no toca el índice compartido (regla 2), no le hace falta que la rama esté al día, y reintenta si otro empuja. Nunca fuerza: el rechazo ES la señal.

Y el espejo: el latido NUNCA lo empujó, ni ahora ni antes. /opt/takana tenía origin sólo a gitea — espejo-setup.sh nunca se había corrido acá, aunque su propio comentario da por hecho que el latido espeja solo («para que TODO git push origin main que ya existe en los scripts (p.ej. cosecha-cron.sh) espeje solo»). Con 48 commits de estado al día, el espejo derivaba por ese número.

Arreglarlo no era añadir la pushurl: el latido corre como root con HOME=/root y gh está logueado como sergio. El camino SSH se descartó midiéndolo (ninguna clave de /root/.ssh abre el repo del espejo). Pieza nueva de la caja:

/usr/local/bin/credencial-espejo-githubenvoltorio de rutas sobre scripts/servidor/credencial-espejo-github.sh. Ayudante de credencial de git que pide el token con su - sergio -c 'gh auth token' en el momento de empujar y no lo escribe en ningún sitio. Declarado en el clon con git config credential.https://github.com.helper

Se descartaron las alternativas por lo que dejan en reposo: el token en .git/config o en ~/.git-credentials es un secreto en claro en una caja con root compartido, y la URL con el token adentro sale además en cada mensaje de error de git. Si gh pierde la sesión, el push falla ruidosamente en vez de empujar con un token muerto.

⚠ De paso, espejo-setup.sh MENTÍA en esta caja: daba gh auth status por única credencial válida, así que decía «el push a GitHub va a fallar» mientras el push funcionaba. Ahora mira primero el ayudante, que es lo que git usa de hecho. Un guardián que grita en falso se deja de leer, y el día que grite de verdad tampoco se lee — es el mismo defecto que el «al día» vacío del §5.9.

Verificado en producción: estado publicado … · espejo al día · rama local al día, los dos remotos en el mismo sha, y el clon del latido queda limpio tras cada ciclo.

8. churay como instalador centralizado — la dirección, no la decisión

El usuario lo planteó el 2026-09-09: un instalador único, con CLI y TUI, que sirva para

Encaja con lo que ya hay y por eso vale escribirlo: takana install ya resuelve por nombre contra un índice firmado y ya acepta múltiples orígenes; qorpa (ADR 0015) ya corre imágenes ajenas enjauladas sobre el mismo kernel; y el vigía de espejos de churay ya corre en cron desde gioser.

✅ DECIDIDO el 2026-09-23 — ADR 0021. Las tres preguntas de abajo están contestadas ahí: takana pone el motor y expone takana metal {check,survey,install}; churay pone la cara y lo llama, consumiendo survey --json en vez de leer los discos por su cuenta. La (1) se partía en dos y por eso no se dejaba contestar —para instalar el SISTEMA no se hidrata ni se reproduce: el instalador lleva el rootfs adentro, en RAM—; sigue abierta para paquetes sueltos en una distro ajena, que es otro verbo. La (3) no era un problema: lo que corre en el host ajeno es un ELF estático, así que su libc no participa.

No se decide acá. Es un ADR propio, y tiene al menos tres preguntas abiertas antes de escribir una línea: (1) si el instalador «en cualquier distro» hidrata binarios o reproduce desde fuente —la misma pregunta del SDD 27 §4, y la respuesta puede no ser la misma fuera de takana; (2) quién es el dueño del verbo, si takana install crece o si churay lo envuelve; (3) qué pasa con un host glibc, que es exactamente lo que el ADR 0015 aparta del store a propósito. Queda anotado para que se decida, no para que se descubra a mitad.


9. Estado y orden

Decisión del usuario (2026-09-14): la mudanza NO pasa por terceros. Nada de espejar a GitHub para «tener copia fuera»: la copia fuera la da la propia mudanza — en cuanto el gitea vive en la caja nueva, los 26 repos dejan de existir sólo en la máquina que se borra. El objetivo es dejar de pagar dos máquinas, no montar una dependencia más. scripts/mudanza/espejar-repos.sh queda como herramienta disponible, no como paso del plan.

Y el worker no necesita credenciales: las fuentes de tawasuyu se sirven por HTTPS público. Las 23 recetas que apuntaban a gitea@git.tawasuyu.net (SSH) pasaron a https://git.tawasuyu.net/… — la URL es locator y no entra en hash_inputs (ADR 0013), así que se cambió con control y sin mover un solo ArtifactHash. Medido en el worker: clona y git archive extrae el árbol (155 M) sin ninguna clave. Un usuario propio en gitea sólo haría falta para un repo PRIVADO, y hoy ninguna receta usa uno.

Lo que YA está, y no hay que volver a tocar

la caja takana (2.29.29.217)arranca takana puro, PID 1 arje-zero — §3.8
store, grafos, granja, respaldopuertas 2, 3, 4 y 7 — §6.3, §6.7, §6.8, §6.10
repo firmado servido por la caja§5.1
la imagen del perfil servidor con giteaarranca y sirve 200, supervisado por arje — §6.11
arjectl para operar sin reiniciar§6.12
el gitea de gioser corriendo sobre takana, con sus datosensayo completo — §6.13

Lo que falta para el cutover, en este orden

⚠ La lista de abajo es del 2026-09-11 y los pasos 1-5 YA ESTÁN (gitea mudado y sirviendo, §6.14; estáticos mudados, §6.18; fósiles barridos, §6.19; api.sergio mudado, §6.25; tawasuyu.net y gioser.net mudados, §6.26; squid, §6.27-28; hora, latido y rotación, §6.29; cortafuegos, §6.31-33).

El estado vigente está medido en el §6.34 (2026-09-16), y es más corto de lo que decía esta lista: queda UN solo vhost sirviéndose desde gioser — sergio.gioser.net — y el montón de daemons propios del usuario que ningún paquete provee. Los pasos 6 y 7 son lo único que sigue abierto.

  1. Actualizar la caja a la imagen nueva (gitea + arjectl + las cuentas de [[user]]). takana upgrade está probado (§6.13 del SDD 27 y §5.4 acá); el dd es para provisionar, no para actualizar — sobrescribe el disco local y re-etiqueta la partición.
  2. Mudar los datos del gitea: sqlite3 … ".backup" (1,5 s, consistente con el servidor vivo) + rsync -aH de /var/lib/gitea y /etc/gitea. El corte final se hace con el gitea del origen parado, para que el último snapshot no pierda escrituras.
  3. Caddy en la caja: los 6 dominios vivos del §6.9, con su TLS. Los otros 13 no se mudan.
  4. DNS: apuntar esos 6 a la IP nueva. El resto de los clones y remotos no se toca — siguen resolviendo al mismo nombre.
  5. Verificar desde fuera: cada dominio vivo responde 200 contra la IP nueva y un git clone real contra el gitea mudado.
  6. El resto de servicios vivos del censo (qdrant, squid, act-runner, php-fpm…), por el plan que planear.py ya emite.
  7. Borrar gioser — sólo con las 8 puertas en verde, a mano, nunca por automatización (§6).

Bloqueantes conocidos, con su tamaño

El volumen del store NO se engancha a la caja hasta que pase la puerta 1 — ya pasó, y el store local de 68,7 G alcanza, así que la mudanza no depende de mover el volumen de gioser.