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 815Rust 56Shell 37Markdown 291229 Definitions

⎇ 68dc9cc2de628d0426a889a271e145bc78d39fb4 docs/30-servicios-de-paquete.md Markdown · 243 lines · 15.4 KB History raw

SDD 30 §4b: la seed de producto ya sale de la RECETA — y openssh reproduce bit a bit 68dc9cc2d · Sergio ·

1# SDD 30 — Los servicios que trae un paquete
2
3> **Estado:** §3, §4a y §4b IMPLEMENTADOS (2026-09-12/13), §4c y §5 abiertos. Nace de una pregunta directa:
4> *«los paquetes que tienen sus servicios para systemd, openrc u otros, ¿ya saben empaquetarse en
5> takana con arje?»*. La respuesta medida era **no**, y este documento dice exactamente qué faltaba,
6> qué se cerró y qué queda.
7>
8> Hermanos: [SDD 12](12-init-real.md) (arje como PID 1, contrato de la seed) ·
9> [SDD 06](06-swm-format.md) (la mutación `init_rule`) · `tawasuyu/03_ukupacha/arje/DE-SYSTEMD-A-ARJE.md`
10> (el manual de correspondencia de arje, autoritativo sobre el lado init).
11
12## 1. El hueco, medido
13
14Cuatro hallazgos, todos verificados en el árbol el 2026-09-12:
15
161. **La receta no tenía dónde declararlo.** `Recipe` era
17   `name, version, source, build, deps, evidence, slots, license, foreign`. Ninguna de las 979
18   recetas podía decir «yo traigo un demonio».
192. **Los dos servicios del producto vivían en constantes de Rust.**
20   `takana_bootstrap::STAGE1_SEED_CARD` (hammerd + console-getty) y `SSHD_SERVICE_CARD`, compuestas
21   por `product_seed_card()`. Añadir un tercero era editar Rust y recompilar takana.
223. **Los escritorios no usaban arje para esto en absoluto.** `scripts/gnome/gnome-start-qemu.sh`
23   lanza a mano `dbus-daemon --system --fork`, `arje-logind-compat &`, `accounts-daemon &`,
24   `upowerd &`, `pipewire &`, `pipewire-pulse &`, `wireplumber &` y `colord &`, con esperas de
25   socket por `sleep 0.25` en bucle; la imagen parchea la seed con python para que `console-getty`
26   ejecute `plasma-start` en vez de `/bin/sh`. Es **un card por IMAGEN, no por paquete**: esos ocho
27   demonios corren sin supervisión, sin backoff y sin el `CRASHED` real — justo lo que arje es PID 1
28   para dar.
294. **Las unidades upstream se sellan inertes.** 62 ficheros `.service` en el store (61 de usuario,
30   1 de sistema: `linux-pam`), que nadie lee ni traduce. Los `-Dsystemd=disabled` de las recetas son
31   por *dependencia*, no política de servicios.
32
33## 2. Por qué NO alcanza con `arje-absorb`
34
35arje **ya trae el traductor**: `init/arje-absorb`, con lectores de systemd, OpenRC, runit, dinit y
36sysvinit (`--from auto --root / --output seed.card.json`). Es la primera respuesta que uno da, y es
37la equivocada — por una razón exacta, escrita en su propio módulo de systemd:
38
39> *Se absorbe lo que está **habilitado**, no todo lo instalado: los symlinks de `<target>.wants/`.
40> Una unidad instalada pero no habilitada no arranca hoy, y meterla en el genesis la haría arrancar
41> mañana — cambiaría el sistema en vez de retratarlo.*
42
43`arje-absorb` **retrata un sistema vivo**. Un rootfs de takana no tiene ese estado que retratar:
44medido, hay **62 `.service` sellados y CERO directorios `.wants`** — nadie corrió nunca un
45`systemctl enable`, porque no hay systemd. Absorber un rootfs nuestro devuelve la lista vacía, y es
46la respuesta **correcta** a la pregunta que absorb contesta.
47
48⇒ **El «enable» de una distro construida desde fuente no se puede leer del árbol: hay que
49declararlo.** Ese es el trabajo que le toca a takana, y es el que faltaba.
50
51## 3. La decisión: declarar en dos mitades
52
53Se parte igual que systemd parte `[Service]` de `[Install]`, porque son dos hechos de dueño distinto:
54
55| hecho | dueño | dónde |
56|---|---|---|
57| **qué es** el servicio (exec, argv, supervisión, cgroup, red) | el PAQUETE | `[[service]]` en la receta ✅ |
58| **si arranca** en esta imagen | el PERFIL | `docs/state/targets.toml` ◑ (§4) |
59
60### 3.1 `[[service]]` en la receta (implementado)
61
62`crates/takana-core/src/service.rs`. Se traduce 1:1 a una Card de arje (`Service::card()`), con la
63forma del esquema validado de `card_core::Card`. Ejemplo real, `recipes/openssh.toml`:
64
65```toml
66[[service]]
67label      = "sshd"
68id         = "01HQAR53D4M2NBV8KZTYXFQA12"
69exec       = "/bin/busybox"
70argv       = ["sh", "-c", "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"]
71envp       = [["HOME", "/root"]]
72networking = "full"
73cgroup     = "arje.slice/sshd"
74restart    = { initial_ms = 500, max_ms = 20000 }
75```
76
77Tres propiedades que no son de estilo:
78
79- **Fuera de `hash_inputs`**, como `license`, `slots` y `evidence`: describe cómo se *supervisa* el
80  artefacto, no qué bytes tiene ⇒ se puebla en recetas YA SELLADAS sin mover un `ArtifactHash`.
81  **Medido**: el hash de openssh es `b3:938835e6…` con el binario viejo (que ignora el bloque) y el
82  mismo con el nuevo (que lo parsea y lo excluye). Si entrara al hash, declarar los servicios del
83  corpus costaría reconstruirlo entero y no se haría nunca.
84- **El `id` (ULID) se declara, no se genera.** Un id aleatorio haría que la seed —y con ella el
85  `product_rootfs_hash`— cambiara en cada corrida. La reproducibilidad del producto depende de que
86  esto sea estable, así que `validate()` lo exige y el de sshd es *el que la constante ya usaba*.
87- **Se valida al CARGAR la receta**, no al emitir la seed: un `id` mal puesto tiene que verse en la
88  receta, no tres pasos después en un rootfs que ya no reproduce y no dice por qué.
89
90**La prueba que autoriza el diseño** (`la_receta_de_openssh_reproduce_la_card_hardcodeada`): el card
91generado desde `recipes/openssh.toml` se compara **entero** contra `SSHD_SERVICE_CARD`, que es la
92card que el producto **ya bootea en QEMU** (e2e por `scripts/ssh-e2e-test.sh`). Empatan ⇒ mover el
93servicio de la constante a la receta no cambia un byte de la seed. No es «una card parecida»: es la
94misma.
95
96### 3.2 Los tres árboles que no se tocaban — decisión
97
98Había **tres convenciones de «dónde vive un servicio»**, ninguna conectada con las otras:
99
100| árbol | quién lo escribe | quién lo lee |
101|---|---|---|
102| `/ente/seed.card.json` (genesis) | `takana_bootstrap` | **arje-zero al boot** ✅ |
103| `/etc/arje/cards.d/*.json` | nadie | **arje, bajo demanda** (`ARJE_CARDS_DIR`) ✅ |
104| `/etc/hammer/init.d/<svc>.rule` | la mutación `init_rule` del `.swm` | **NADIE** ❌ |
105
106`docs/06-swm-format.md` afirma que «el init (arje) lee ese árbol». **No lo lee**: el único uso de
107`INIT_RULES_DIR` en todo el repo es escribirlo, y el contrato real de arje son los dos primeros.
108Para colmo `takana query service:<n>` busca en un CUARTO juego de rutas (`/etc/init.d/`,
109`/etc/service/<n>/run`).
110
111**Decisión:** los árboles canónicos son los de arje — **genesis de la seed** para lo que arranca al
112boot, **`cards.d/`** para lo que se levanta bajo demanda. `/etc/hammer/init.d/*.rule` queda
113**derogado**: o la mutación `init_rule` emite un card a `cards.d/`, o se retira del formato `.swm`.
114Es su propia unidad de trabajo (toca el esquema `.swm`, que es contrato firmado) y hasta entonces la
115afirmación del SDD 06 queda marcada como falsa aquí.
116
117## 4. Lo que queda: del card a la imagen
118
119**(a) El perfil habilita — HECHO.** `servicios = [...]` por perfil en `targets.toml` (se hereda como
120`paquetes`), y `scripts/targets.py --services <perfil>` resuelve label → receta → exec cruzando las
121declaraciones del corpus con la membresía de perfil de los grafos de estado (los CINCO: el del
122corpus más los de cada cola, porque las recetas de un escritorio viven en `recipes/incoming-<x>/` y
123su membresía está en el grafo de SU cola).
124
125Lo que más valió no fue resolver, fue **la comprobación inversa**: avisar de los paquetes que están
126en la imagen, TRAEN un demonio y el perfil no arranca. Y encontró algo el primer día:
127
128> **`arje-logind-compat` y `arje-polkit-compat` estaban en CERO perfiles** — y sin embargo
129> `scripts/gnome/qemu-desktop-image.sh` los copia al rootfs a mano y el de COSMIC hace `exit 1` si
130> falta logind-compat. Dos binarios imprescindibles, presentes en la imagen y ausentes del destino
131> declarado. Es la misma forma del agujero de `foot`, encontrada esta vez por una comprobación en
132> vez de por una imagen inusable. Corregido: son raíces de `escritorio-gnome` (los dos) y de
133> `escritorio-cosmic` (sólo logind-compat; sus scripts no nombran polkit-compat).
134
135**Precedencia, y no es un detalle:** una RAÍZ del perfil está en el perfil por definición, aunque el
136grafo todavía no lo diga. `build-state.json` es DERIVADO de `targets.toml` y lo regenera el cron cada
13730 min; preguntarle primero al derivado haría que añadir una raíz se leyera como error hasta la
138siguiente cosecha — castigar al que arregla el manifiesto.
139
140El guardián se prueba solo: `scripts/targets.py --selftest` corre 7 casos —**el primero es el
141CONTROL, que tiene que pasar en verde**— incluidos «dos recetas del mismo perfil declaran el mismo
142label» (ERROR) y «el mismo label en dos colas distintas» (NO es colisión: `upower` existe
143legítimamente en incoming-gnome y en incoming-kde, y son la misma unidad en dos imágenes).
144
145**(b) El emisor — HECHO, y la trampa quedó armada en un test.** `service_cards()` lee la receta que
146viaja DENTRO de cada artefacto sellado (el sidecar de provenance `.hammer/recipe.toml`, vía
147`Store::recipe_for_hash`) y filtra `scope = "system"`. Se lee de ahí y no de `recipes/` porque
148`seal_product_rootfs()` sirve a las DOS vetas —`product` desde recetas locales y `product_from_repo`
149desde el repo firmado— y **sólo una tiene `recipes/` a mano**: el sidecar es la única fuente que
150ambas comparten, que es lo que sostiene que las dos converjan al mismo `product-rootfs`.
151
152La trampa era real y sigue ahí: **el sidecar no entra en el `ArtifactHash`**, así que un artefacto
153sellado antes de que su receta declarara servicios es un cache-hit perfectamente válido y no se
154reconstruye nunca solo. Si el emisor devolviera lista vacía sin protestar, el producto saldría
155**booteando, verde y sin sshd**. Por eso `service_cards()` **falla ruidosamente** con un mensaje que
156explica la causa y da el arreglo, y hay un test
157(`service_cards_falla_ruidosamente_con_sidecar_rancio`) que exige que el mensaje siga diciendo las
158dos cosas. Es la regla 3 del CLAUDE.md aplicada antes de pagarla: un ausente falla ruidosamente; un
159vacío llega hasta el final diciendo que todo fue bien.
160
161### 4b.1 La migración de openssh, medida — y un hecho nuevo
162
163Re-sellar openssh era el paso que faltaba, y se hizo **con control** porque openssh no estaba
164certificado como reproducible (su propia receta dice «el criterio es construye y corre»). El
165procedimiento, por si hay que repetirlo con otro componente de servicio:
166
1671. **Manifiesto de control ANTES de tocar nada**: `sha256` + modo de los 33 ficheros.
1682. **Respaldo por hardlink** — y acá saltó el gotcha conocido: `store/` es un bind-mount de
169   `/dev/sdb` y `work/` vive en `/dev/sdc`, así que `cp -al` a `work/` muere con `Invalid
170   cross-device link`. El respaldo tiene que ir **dentro del mount del store**; `store/.respaldo-…`
171   sirve y es invisible para `store-gc.sh`, que sólo considera dirs `^[0-9a-f]{64}-`.
1723. Borrar el artefacto (los bytes sobreviven por el respaldo: 11 enlaces → 10) y reconstruir bajo
173   `flock -o`.
1744. Comparar árbol nuevo contra el manifiesto.
175
176**Resultado: openssh REPRODUCE BIT A BIT.** 33/33 ficheros, modos idénticos, y la única diferencia
177en todo el árbol es `.hammer/recipe.toml` — exactamente el sidecar que se quería migrar y nada más.
178Eso es un hecho nuevo sobre el corpus, no sólo un paso de esta campaña: openssh pasa de «no
179certificado» a «reproduce», comprobado contra un control tomado antes del borrado.
180
181Coste que conviene saber: los árboles ya hidratados compartían inodes con el artefacto viejo, y el
182nuevo tiene inodes propios (`%h = 1`). El contenido es el mismo, pero hasta que esos árboles se
183refresquen el disco lleva dos copias.
184
185**(c) Los de GNOME: DECLARADOS, no todavía arrancados por arje.** Las nueve unidades que
186`gnome-start-qemu.sh` levanta con `&` están declaradas en sus recetas y habilitadas en el perfil
187(`dbus-system`, `logind-compat`, `polkit-compat`, `accounts-daemon`, `upowerd`, `colord` de sistema;
188`pipewire`, `pipewire-pulse`, `wireplumber` de sesión). Verificado que ninguna movió su hash: 9/9
189idénticos a los que los grafos ya registraban.
190
191Dos cosas que NO son transcripción y hay que saber:
192
1931. **`dbus-daemon --fork` no se puede traducir tal cual.** arje supervisa al **hijo directo**:
194   `Type=forking` no existe en su modelo, así que un daemon que forkea y sale deja a arje viendo
195   morir al padre con éxito y reencarnándolo para siempre. La Card usa `--nofork --nopidfile`.
1962. **`scope = "system" | "session"`.** No es cosmético: decide DÓNDE va la Card. Las de sesión
197   necesitan `XDG_RUNTIME_DIR` y un usuario logueado, así que en el `genesis` arrancarían antes de
198   que exista ninguna de las dos cosas. Y su destino **no está resuelto fuera de mirada** — ahí las
199   arma el compositor (`mirada-compositor/src/session.rs`, `requires = [wayland_floor()]`, entregadas
200   a PID 1 por `RunCard`), pero mutter y kwin no tienen esa integración. `--services` avisa por cada
201   una: el hueco queda contado, no omitido.
202
203Falta el paso final: que el ensamblado de la imagen ponga las cards de sistema en el `genesis` en vez
204de que el script las lance con `&`. Eso ya es §4b (el emisor) y el arranque supervisado en QEMU.
205
206## 5. El hueco que no es nuestro: no hay readiness
207
208arje ordena el arranque por **capacidades**, con orden topológico real
209(`arje-zero/src/graph/resolve.rs::plan_spawn`: punto fijo de satisfacibilidad + Kahn, detecta ciclos
210y descarta insatisfacibles). Pero eso ordena el **spawn**, no el **estar listo**: no existe una
211primitiva «esperá a que aparezca este socket». El propio manual de arje lo lista como hueco abierto
212(§13: *«readiness real de `Type=notify` — el shim registra pero no ordena»*).
213
214Es lo que el script de GNOME resuelve con `while [ ! -S "$XDG_RUNTIME_DIR/pipewire-0" ]; sleep 0.25`.
215Mientras el hueco siga abierto, un card que necesita esperar lo hace **dentro de su propio `argv`**
216con `sh -c '…; exec …'` — que es lo que ya hace el de sshd (levanta la red y genera las host keys
217antes del `exec`). Este diseño **no lo esconde**: lo deja a la vista en el `argv` en vez de meterlo
218en un wrapper, para que se vea cuántos servicios están pagando el hueco y valga como argumento río
219arriba en arje.
220
221## 6. Estado
222
223| pieza | estado |
224|---|---|
225| `[[service]]` en la receta + traducción a Card + validación | ✅ `crates/takana-core/src/service.rs` |
226| Fuera de `hash_inputs` (medido, hash de openssh sin mover) | ✅ |
227| Equivalencia receta → card hardcodeada de sshd | ✅ test en `takana-bootstrap` |
228| `openssh` declara su servicio | ✅ `recipes/openssh.toml` |
229| El perfil habilita (`servicios` + `targets.py --services`) | ✅ §4a |
230| Guardián con roturas a propósito + control (`--selftest`) | ✅ 7/7 |
231| El vigía CORRE SOLO (cosecha-cron) → `docs/state/servicios.txt` | ✅ 0 errores, 18 avisos |
232| Los dos compat que no estaban en ningún perfil | ✅ corregido §4a |
233| Los 9 de GNOME declarados y habilitados (hash sin mover, 9/9) | ✅ §4c |
234| Emisor desde el sidecar (`service_cards`, falla ruidosa + test) | ✅ §4b |
235| Re-sellado de openssh con control → **reproduce bit a bit** | ✅ §4b.1 |
236| Que la imagen los arranque por arje y no por el script `&` | ◻ §4c, depende de §4b |
237| `escritorio-cosmic`: auditar su `cosmic-start.sh` y habilitar | ◻ hoy sale con 5 AVISO |
238
239**La primera lectura del vigía**, para que no haya que creerle a este documento: 0 errores y 18
240avisos. El más ruidoso es `dbus-system`, que viaja en **seis** perfiles y sólo GNOME lo arranca —
241que es exactamente el tipo de hecho que antes no tenía dónde verse.
242| Derogar `/etc/hammer/init.d/*.rule` en el `.swm` | ◻ §3.2 |
243| Readiness por unidad en arje | ◻ §5 — río arriba |

30-servicios-de-paquete.md

SDD 30 — Los servicios que trae un paquete

Estado: §3, §4a y §4b IMPLEMENTADOS (2026-09-12/13), §4c y §5 abiertos. Nace de una pregunta directa: «los paquetes que tienen sus servicios para systemd, openrc u otros, ¿ya saben empaquetarse en takana con arje?». La respuesta medida era no, y este documento dice exactamente qué faltaba, qué se cerró y qué queda.

Hermanos: SDD 12 (arje como PID 1, contrato de la seed) · SDD 06 (la mutación init_rule) · tawasuyu/03_ukupacha/arje/DE-SYSTEMD-A-ARJE.md (el manual de correspondencia de arje, autoritativo sobre el lado init).

1. El hueco, medido

Cuatro hallazgos, todos verificados en el árbol el 2026-09-12:

  1. La receta no tenía dónde declararlo. Recipe era name, version, source, build, deps, evidence, slots, license, foreign. Ninguna de las 979 recetas podía decir «yo traigo un demonio».
  2. Los dos servicios del producto vivían en constantes de Rust. takana_bootstrap::STAGE1_SEED_CARD (hammerd + console-getty) y SSHD_SERVICE_CARD, compuestas por product_seed_card(). Añadir un tercero era editar Rust y recompilar takana.
  3. Los escritorios no usaban arje para esto en absoluto. scripts/gnome/gnome-start-qemu.sh lanza a mano dbus-daemon --system --fork, arje-logind-compat &, accounts-daemon &, upowerd &, pipewire &, pipewire-pulse &, wireplumber & y colord &, con esperas de socket por sleep 0.25 en bucle; la imagen parchea la seed con python para que console-getty ejecute plasma-start en vez de /bin/sh. Es un card por IMAGEN, no por paquete: esos ocho demonios corren sin supervisión, sin backoff y sin el CRASHED real — justo lo que arje es PID 1 para dar.
  4. Las unidades upstream se sellan inertes. 62 ficheros .service en el store (61 de usuario, 1 de sistema: linux-pam), que nadie lee ni traduce. Los -Dsystemd=disabled de las recetas son por dependencia, no política de servicios.

2. Por qué NO alcanza con arje-absorb

arje ya trae el traductor: init/arje-absorb, con lectores de systemd, OpenRC, runit, dinit y sysvinit (--from auto --root / --output seed.card.json). Es la primera respuesta que uno da, y es la equivocada — por una razón exacta, escrita en su propio módulo de systemd:

Se absorbe lo que está habilitado, no todo lo instalado: los symlinks de <target>.wants/. Una unidad instalada pero no habilitada no arranca hoy, y meterla en el genesis la haría arrancar mañana — cambiaría el sistema en vez de retratarlo.

arje-absorb retrata un sistema vivo. Un rootfs de takana no tiene ese estado que retratar: medido, hay 62 .service sellados y CERO directorios .wants — nadie corrió nunca un systemctl enable, porque no hay systemd. Absorber un rootfs nuestro devuelve la lista vacía, y es la respuesta correcta a la pregunta que absorb contesta.

⇒ El «enable» de una distro construida desde fuente no se puede leer del árbol: hay que declararlo. Ese es el trabajo que le toca a takana, y es el que faltaba.

3. La decisión: declarar en dos mitades

Se parte igual que systemd parte [Service] de [Install], porque son dos hechos de dueño distinto:

hechodueñodónde
qué es el servicio (exec, argv, supervisión, cgroup, red)el PAQUETE[[service]] en la receta ✅
si arranca en esta imagenel PERFILdocs/state/targets.toml ◑ (§4)

3.1 [[service]] en la receta (implementado)

crates/takana-core/src/service.rs. Se traduce 1:1 a una Card de arje (Service::card()), con la forma del esquema validado de card_core::Card. Ejemplo real, recipes/openssh.toml:

[[service]]
label      = "sshd"
id         = "01HQAR53D4M2NBV8KZTYXFQA12"
exec       = "/bin/busybox"
argv       = ["sh", "-c", "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"]
envp       = [["HOME", "/root"]]
networking = "full"
cgroup     = "arje.slice/sshd"
restart    = { initial_ms = 500, max_ms = 20000 }

Tres propiedades que no son de estilo:

La prueba que autoriza el diseño (la_receta_de_openssh_reproduce_la_card_hardcodeada): el card generado desde recipes/openssh.toml se compara entero contra SSHD_SERVICE_CARD, que es la card que el producto ya bootea en QEMU (e2e por scripts/ssh-e2e-test.sh). Empatan ⇒ mover el servicio de la constante a la receta no cambia un byte de la seed. No es «una card parecida»: es la misma.

3.2 Los tres árboles que no se tocaban — decisión

Había tres convenciones de «dónde vive un servicio», ninguna conectada con las otras:

árbolquién lo escribequién lo lee
/ente/seed.card.json (genesis)takana_bootstraparje-zero al boot ✅
/etc/arje/cards.d/*.jsonnadiearje, bajo demanda (ARJE_CARDS_DIR) ✅
/etc/hammer/init.d/<svc>.rulela mutación init_rule del .swmNADIE ❌

docs/06-swm-format.md afirma que «el init (arje) lee ese árbol». No lo lee: el único uso de INIT_RULES_DIR en todo el repo es escribirlo, y el contrato real de arje son los dos primeros. Para colmo takana query service:<n> busca en un CUARTO juego de rutas (/etc/init.d/, /etc/service/<n>/run).

Decisión: los árboles canónicos son los de arje — genesis de la seed para lo que arranca al boot, cards.d/ para lo que se levanta bajo demanda. /etc/hammer/init.d/*.rule queda derogado: o la mutación init_rule emite un card a cards.d/, o se retira del formato .swm. Es su propia unidad de trabajo (toca el esquema .swm, que es contrato firmado) y hasta entonces la afirmación del SDD 06 queda marcada como falsa aquí.

4. Lo que queda: del card a la imagen

(a) El perfil habilita — HECHO. servicios = [...] por perfil en targets.toml (se hereda como paquetes), y scripts/targets.py --services <perfil> resuelve label → receta → exec cruzando las declaraciones del corpus con la membresía de perfil de los grafos de estado (los CINCO: el del corpus más los de cada cola, porque las recetas de un escritorio viven en recipes/incoming-<x>/ y su membresía está en el grafo de SU cola).

Lo que más valió no fue resolver, fue la comprobación inversa: avisar de los paquetes que están en la imagen, TRAEN un demonio y el perfil no arranca. Y encontró algo el primer día:

arje-logind-compat y arje-polkit-compat estaban en CERO perfiles — y sin embargo scripts/gnome/qemu-desktop-image.sh los copia al rootfs a mano y el de COSMIC hace exit 1 si falta logind-compat. Dos binarios imprescindibles, presentes en la imagen y ausentes del destino declarado. Es la misma forma del agujero de foot, encontrada esta vez por una comprobación en vez de por una imagen inusable. Corregido: son raíces de escritorio-gnome (los dos) y de escritorio-cosmic (sólo logind-compat; sus scripts no nombran polkit-compat).

Precedencia, y no es un detalle: una RAÍZ del perfil está en el perfil por definición, aunque el grafo todavía no lo diga. build-state.json es DERIVADO de targets.toml y lo regenera el cron cada 30 min; preguntarle primero al derivado haría que añadir una raíz se leyera como error hasta la siguiente cosecha — castigar al que arregla el manifiesto.

El guardián se prueba solo: scripts/targets.py --selftest corre 7 casos —el primero es el CONTROL, que tiene que pasar en verde— incluidos «dos recetas del mismo perfil declaran el mismo label» (ERROR) y «el mismo label en dos colas distintas» (NO es colisión: upower existe legítimamente en incoming-gnome y en incoming-kde, y son la misma unidad en dos imágenes).

(b) El emisor — HECHO, y la trampa quedó armada en un test. service_cards() lee la receta que viaja DENTRO de cada artefacto sellado (el sidecar de provenance .hammer/recipe.toml, vía Store::recipe_for_hash) y filtra scope = "system". Se lee de ahí y no de recipes/ porque seal_product_rootfs() sirve a las DOS vetas —product desde recetas locales y product_from_repo desde el repo firmado— y sólo una tiene recipes/ a mano: el sidecar es la única fuente que ambas comparten, que es lo que sostiene que las dos converjan al mismo product-rootfs.

La trampa era real y sigue ahí: el sidecar no entra en el ArtifactHash, así que un artefacto sellado antes de que su receta declarara servicios es un cache-hit perfectamente válido y no se reconstruye nunca solo. Si el emisor devolviera lista vacía sin protestar, el producto saldría booteando, verde y sin sshd. Por eso service_cards() falla ruidosamente con un mensaje que explica la causa y da el arreglo, y hay un test (service_cards_falla_ruidosamente_con_sidecar_rancio) que exige que el mensaje siga diciendo las dos cosas. Es la regla 3 del CLAUDE.md aplicada antes de pagarla: un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue bien.

4b.1 La migración de openssh, medida — y un hecho nuevo

Re-sellar openssh era el paso que faltaba, y se hizo con control porque openssh no estaba certificado como reproducible (su propia receta dice «el criterio es construye y corre»). El procedimiento, por si hay que repetirlo con otro componente de servicio:

  1. Manifiesto de control ANTES de tocar nada: sha256 + modo de los 33 ficheros.
  2. Respaldo por hardlink — y acá saltó el gotcha conocido: store/ es un bind-mount de /dev/sdb y work/ vive en /dev/sdc, así que cp -al a work/ muere con Invalid cross-device link. El respaldo tiene que ir dentro del mount del store; store/.respaldo-… sirve y es invisible para store-gc.sh, que sólo considera dirs ^[0-9a-f]{64}-.
  3. Borrar el artefacto (los bytes sobreviven por el respaldo: 11 enlaces → 10) y reconstruir bajo flock -o.
  4. Comparar árbol nuevo contra el manifiesto.

Resultado: openssh REPRODUCE BIT A BIT. 33/33 ficheros, modos idénticos, y la única diferencia en todo el árbol es .hammer/recipe.toml — exactamente el sidecar que se quería migrar y nada más. Eso es un hecho nuevo sobre el corpus, no sólo un paso de esta campaña: openssh pasa de «no certificado» a «reproduce», comprobado contra un control tomado antes del borrado.

Coste que conviene saber: los árboles ya hidratados compartían inodes con el artefacto viejo, y el nuevo tiene inodes propios (%h = 1). El contenido es el mismo, pero hasta que esos árboles se refresquen el disco lleva dos copias.

(c) Los de GNOME: DECLARADOS, no todavía arrancados por arje. Las nueve unidades que gnome-start-qemu.sh levanta con & están declaradas en sus recetas y habilitadas en el perfil (dbus-system, logind-compat, polkit-compat, accounts-daemon, upowerd, colord de sistema; pipewire, pipewire-pulse, wireplumber de sesión). Verificado que ninguna movió su hash: 9/9 idénticos a los que los grafos ya registraban.

Dos cosas que NO son transcripción y hay que saber:

  1. dbus-daemon --fork no se puede traducir tal cual. arje supervisa al hijo directo: Type=forking no existe en su modelo, así que un daemon que forkea y sale deja a arje viendo morir al padre con éxito y reencarnándolo para siempre. La Card usa --nofork --nopidfile.
  2. scope = "system" | "session". No es cosmético: decide DÓNDE va la Card. Las de sesión necesitan XDG_RUNTIME_DIR y un usuario logueado, así que en el genesis arrancarían antes de que exista ninguna de las dos cosas. Y su destino no está resuelto fuera de mirada — ahí las arma el compositor (mirada-compositor/src/session.rs, requires = [wayland_floor()], entregadas a PID 1 por RunCard), pero mutter y kwin no tienen esa integración. --services avisa por cada una: el hueco queda contado, no omitido.

Falta el paso final: que el ensamblado de la imagen ponga las cards de sistema en el genesis en vez de que el script las lance con &. Eso ya es §4b (el emisor) y el arranque supervisado en QEMU.

5. El hueco que no es nuestro: no hay readiness

arje ordena el arranque por capacidades, con orden topológico real (arje-zero/src/graph/resolve.rs::plan_spawn: punto fijo de satisfacibilidad + Kahn, detecta ciclos y descarta insatisfacibles). Pero eso ordena el spawn, no el estar listo: no existe una primitiva «esperá a que aparezca este socket». El propio manual de arje lo lista como hueco abierto (§13: «readiness real de Type=notify — el shim registra pero no ordena»).

Es lo que el script de GNOME resuelve con while [ ! -S "$XDG_RUNTIME_DIR/pipewire-0" ]; sleep 0.25. Mientras el hueco siga abierto, un card que necesita esperar lo hace dentro de su propio argv con sh -c '…; exec …' — que es lo que ya hace el de sshd (levanta la red y genera las host keys antes del exec). Este diseño no lo esconde: lo deja a la vista en el argv en vez de meterlo en un wrapper, para que se vea cuántos servicios están pagando el hueco y valga como argumento río arriba en arje.

6. Estado

piezaestado
[[service]] en la receta + traducción a Card + validación✅ crates/takana-core/src/service.rs
Fuera de hash_inputs (medido, hash de openssh sin mover)✅
Equivalencia receta → card hardcodeada de sshd✅ test en takana-bootstrap
openssh declara su servicio✅ recipes/openssh.toml
El perfil habilita (servicios + targets.py --services)✅ §4a
Guardián con roturas a propósito + control (--selftest)✅ 7/7
El vigía CORRE SOLO (cosecha-cron) → docs/state/servicios.txt✅ 0 errores, 18 avisos
Los dos compat que no estaban en ningún perfil✅ corregido §4a
Los 9 de GNOME declarados y habilitados (hash sin mover, 9/9)✅ §4c
Emisor desde el sidecar (service_cards, falla ruidosa + test)✅ §4b
Re-sellado de openssh con control → reproduce bit a bit✅ §4b.1
Que la imagen los arranque por arje y no por el script &◻ §4c, depende de §4b
escritorio-cosmic: auditar su cosmic-start.sh y habilitar◻ hoy sale con 5 AVISO

La primera lectura del vigía, para que no haya que creerle a este documento: 0 errores y 18 avisos. El más ruidoso es dbus-system, que viaja en seis perfiles y sólo GNOME lo arranca — que es exactamente el tipo de hecho que antes no tenía dónde verse. | Derogar /etc/hammer/init.d/*.rule en el .swm | ◻ §3.2 | | Readiness por unidad en arje | ◻ §5 — río arriba |