Los tests no eran el punto: el punto era que un servicio DECLARADO en una receta termine supervisado por PID 1. `product-boot-test.sh` sobre el product-rootfs de la ruta real da `ok card sshd en la seed` y `PRODUCT_SSH_OK uid=0`, sin ningún eslabón escrito a mano: recipes/openssh.toml [[service]] → sidecar dentro del artefacto sellado → service_cards() → genesis de /ente/seed.card.json → arje-zero encarna sshd → la sesión SSH responde. Y HABÍA QUE DESCARTAR QUE LO MOVIERA ESTE CAMBIO: el product-rootfs salió con hash distinto (5011955a… → 8639894332…). No fue la seed — las dos son BYTE-IDÉNTICAS (diff del JSON, cero diferencias). Cambiaron `netup`, que es otro artefacto que cuando se selló el viejo, y por arrastre `ente/attest.json`, que registra su BLAKE3. O sea: la constante y la receta producen la misma seed, comprobado sobre el árbol real y no sólo en un test. Y queda escrito por qué el ESCRITORIO no se puede arrancar supervisado todavía, que es corpus y no diseño: gnome-shell en deuda (bloqueado sólo por evolution-data-server, que tiene blocked_by vacío) y sway sin sellar. Sin compositor no hay sesión. NO se cablearon los scripts de imagen a ciegas: inyectar cards en un genesis que no se puede bootear es escribir código que nadie puede contradecir, y este mismo doc ya tiene el ejemplo de qué pasa entonces (el SDD 06 afirmando meses un lector que no existía).
18 KiB
SDD 30 — Los servicios que trae un paquete
Estado: §3, §4a y §4b IMPLEMENTADOS y VALIDADOS EN ARRANQUE REAL (2026-09-12/14); §4c cerrado para la imagen de producto y bloqueado para las de escritorio; §5 abierto. 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.
Recipeeraname, 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) ySSHD_SERVICE_CARD, compuestas porproduct_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.shlanza a manodbus-daemon --system --fork,arje-logind-compat &,accounts-daemon &,upowerd &,pipewire &,pipewire-pulse &,wireplumber &ycolord &, con esperas de socket porsleep 0.25en bucle; la imagen parchea la seed con python para queconsole-gettyejecuteplasma-starten vez de/bin/sh. Es un card por IMAGEN, no por paquete: esos ocho demonios corren sin supervisión, sin backoff y sin elCRASHEDreal — justo lo que arje es PID 1 para dar. - Las unidades upstream se sellan inertes. 62 ficheros
.serviceen el store (61 de usuario, 1 de sistema:linux-pam), que nadie lee ni traduce. Los-Dsystemd=disabledde 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, comolicense,slotsyevidence: describe cómo se supervisa el artefacto, no qué bytes tiene ⇒ se puebla en recetas YA SELLADAS sin mover unArtifactHash. Medido: el hash de openssh esb3: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 elproduct_rootfs_hash— cambiara en cada corrida. La reproducibilidad del producto depende de que esto sea estable, así quevalidate()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
idmal 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-compatyarje-polkit-compatestaban en CERO perfiles — y sin embargoscripts/gnome/qemu-desktop-image.shlos copia al rootfs a mano y el de COSMIC haceexit 1si falta logind-compat. Dos binarios imprescindibles, presentes en la imagen y ausentes del destino declarado. Es la misma forma del agujero defoot, encontrada esta vez por una comprobación en vez de por una imagen inusable. Corregido: son raíces deescritorio-gnome(los dos) y deescritorio-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/sdbywork/vive en/dev/sdc, así quecp -alawork/muere conInvalid cross-device link. El respaldo tiene que ir dentro del mount del store;store/.respaldo-…sirve y es invisible parastore-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.
4b.2 La cadena entera, probada en un arranque real
No alcanza con que los tests pasen: el punto de todo esto es que un servicio declarado en una
receta termine supervisado por PID 1. Medido el 2026-09-14, scripts/product-boot-test.sh sobre
el product-rootfs ensamblado por la ruta real:
ok card sshd en la seed
==> boot QEMU (mem 2048), hostfwd :2223->:22
PRODUCT_SSH_OK uid=0
La cadena completa, sin ningún eslabón escrito a mano:
recipes/openssh.toml `[[service]]`
→ sidecar `.hammer/recipe.toml` DENTRO del artefacto sellado
→ `service_cards()` (scope = system)
→ `genesis` de `/ente/seed.card.json`
→ arje-zero encarna sshd al boot
→ la sesión SSH responde
Y el hash no se movió por esto. El product-rootfs salió con hash distinto al anterior
(5011955a… → 8639894332…), y había que saber si lo movía este cambio: no. La seed de ambos
árboles es byte-idéntica (diff sobre el JSON: sin diferencias); lo que cambió fueron netup
—otro artefacto que cuando se selló el viejo— y, por arrastre, ente/attest.json, que registra su
BLAKE3. La constante y la receta producen exactamente la misma seed, comprobado sobre el árbol real
y no sólo en un test.
(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 --forkno se puede traducir tal cual. arje supervisa al hijo directo:Type=forkingno 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 necesitanXDG_RUNTIME_DIRy un usuario logueado, así que en elgenesisarrancarí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 porRunCard), pero mutter y kwin no tienen esa integración.--servicesavisa por cada una: el hueco queda contado, no omitido.
Por qué el escritorio NO se puede arrancar supervisado todavía (medido)
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 &— no está bloqueado por diseño sino por corpus, y conviene que
esté escrito para no volver a averiguarlo:
| imagen | estado | qué falta |
|---|---|---|
escritorio-gnome |
307/309 de la clausura sellada | gnome-shell en deuda, y su único bloqueante es evolution-data-server (que tiene blocked_by: [] — o sea que sus deps ya están: falta construirlo, no destrabarlo) |
escritorio-sway |
260/261 | sway mismo sin sellar |
Sin el compositor/shell no hay sesión que arrancar, así que el arranque supervisado de escritorio no
es verificable hoy por más cards que se inyecten. Y el andamiaje de la imagen tampoco está: en este
hub work/metal-rootfs no existe y work/gnome-rootfs son 388 K de residuo, no el cierre hidratado.
Lo que NO se hizo a propósito: cablear los scripts de imagen a ciegas. Inyectar cards en un
genesis que no se puede bootear es escribir código que nadie puede contradecir — y este documento
ya tiene un ejemplo de lo que pasa entonces (el SDD 06 afirmando durante meses un lector que no
existía). El mecanismo está probado donde SÍ se puede arrancar (§4b.2); el escritorio espera a que
su corpus cierre.
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 |
| La cadena receta→sidecar→card→genesis→arje, en arranque real | ✅ §4b.2 (PRODUCT_SSH_OK) |
Que la imagen de ESCRITORIO los arranque y no el script & |
⛔ §4c, bloqueado (abajo) |
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 |