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:
- 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».
- 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.
- 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.
- 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:
| hecho | dueño | dónde |
| qué es el servicio (exec, argv, supervisión, cgroup, red) | el PAQUETE | [[service]] en la receta ✅ |
| si arranca en esta imagen | el PERFIL | docs/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:
- Fuera de
hash_inputs, como license, slots y evidence: describe cómo se supervisa el
artefacto, no qué bytes tiene ⇒ se puebla en recetas YA SELLADAS sin mover un ArtifactHash.
Medido: el hash de openssh es b3:938835e6… con el binario viejo (que ignora el bloque) y el
mismo con el nuevo (que lo parsea y lo excluye). Si entrara al hash, declarar los servicios del
corpus costaría reconstruirlo entero y no se haría nunca.
- El
id (ULID) se declara, no se genera. Un id aleatorio haría que la seed —y con ella el
product_rootfs_hash— cambiara en cada corrida. La reproducibilidad del producto depende de que
esto sea estable, así que validate() lo exige y el de sshd es el que la constante ya usaba.
- Se valida al CARGAR la receta, no al emitir la seed: un
id mal puesto tiene que verse en la
receta, no tres pasos después en un rootfs que ya no reproduce y no dice por qué.
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:
| árbol | quién lo escribe | quién lo lee |
/ente/seed.card.json (genesis) | takana_bootstrap | arje-zero al boot ✅ |
/etc/arje/cards.d/*.json | nadie | arje, bajo demanda (ARJE_CARDS_DIR) ✅ |
/etc/hammer/init.d/<svc>.rule | la mutación init_rule del .swm | NADIE ❌ |
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:
- Manifiesto de control ANTES de tocar nada:
sha256 + modo de los 33 ficheros.
- 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}-.
- Borrar el artefacto (los bytes sobreviven por el respaldo: 11 enlaces → 10) y reconstruir bajo
flock -o.
- 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:
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.
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
| pieza | estado |
[[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 |