tawasuyu

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…

⎇ main docs/30-servicios-de-paquete.md Markdown · 400 siq'ikuna · 24.6 KB Ñawpa willay ch'uya

[[user]] en la receta: la otra mitad del SDD 30 — un demonio que no corre como root necesita cuenta d611f8577 · Sergio ·

1# SDD 30 — Los servicios que trae un paquete
2
3> **Estado:** §3, §4a y §4b IMPLEMENTADOS y **VALIDADOS EN ARRANQUE REAL** (2026-09-12/14);
4> §4c CERRADO también para GNOME (2026-09-14, con control); §5 abierto. Nace de una pregunta directa:
5> *«los paquetes que tienen sus servicios para systemd, openrc u otros, ¿ya saben empaquetarse en
6> takana con arje?»*. La respuesta medida era **no**, y este documento dice exactamente qué faltaba,
7> qué se cerró y qué queda.
8>
9> Hermanos: [SDD 12](12-init-real.md) (arje como PID 1, contrato de la seed) ·
10> [SDD 06](06-swm-format.md) (la mutación `init_rule`) · `tawasuyu/03_ukupacha/arje/DE-SYSTEMD-A-ARJE.md`
11> (el manual de correspondencia de arje, autoritativo sobre el lado init).
12
13## 1. El hueco, medido
14
15Cuatro hallazgos, todos verificados en el árbol el 2026-09-12:
16
171. **La receta no tenía dónde declararlo.** `Recipe` era
18   `name, version, source, build, deps, evidence, slots, license, foreign`. Ninguna de las 979
19   recetas podía decir «yo traigo un demonio».
202. **Los dos servicios del producto vivían en constantes de Rust.**
21   `takana_bootstrap::STAGE1_SEED_CARD` (hammerd + console-getty) y `SSHD_SERVICE_CARD`, compuestas
22   por `product_seed_card()`. Añadir un tercero era editar Rust y recompilar takana.
233. **Los escritorios no usaban arje para esto en absoluto.** `scripts/gnome/gnome-start-qemu.sh`
24   lanza a mano `dbus-daemon --system --fork`, `arje-logind-compat &`, `accounts-daemon &`,
25   `upowerd &`, `pipewire &`, `pipewire-pulse &`, `wireplumber &` y `colord &`, con esperas de
26   socket por `sleep 0.25` en bucle; la imagen parchea la seed con python para que `console-getty`
27   ejecute `plasma-start` en vez de `/bin/sh`. Es **un card por IMAGEN, no por paquete**: esos ocho
28   demonios corren sin supervisión, sin backoff y sin el `CRASHED` real — justo lo que arje es PID 1
29   para dar.
304. **Las unidades upstream se sellan inertes.** 62 ficheros `.service` en el store (61 de usuario,
31   1 de sistema: `linux-pam`), que nadie lee ni traduce. Los `-Dsystemd=disabled` de las recetas son
32   por *dependencia*, no política de servicios.
33
34## 2. Por qué NO alcanza con `arje-absorb`
35
36arje **ya trae el traductor**: `init/arje-absorb`, con lectores de systemd, OpenRC, runit, dinit y
37sysvinit (`--from auto --root / --output seed.card.json`). Es la primera respuesta que uno da, y es
38la equivocada — por una razón exacta, escrita en su propio módulo de systemd:
39
40> *Se absorbe lo que está **habilitado**, no todo lo instalado: los symlinks de `<target>.wants/`.
41> Una unidad instalada pero no habilitada no arranca hoy, y meterla en el genesis la haría arrancar
42> mañana — cambiaría el sistema en vez de retratarlo.*
43
44`arje-absorb` **retrata un sistema vivo**. Un rootfs de takana no tiene ese estado que retratar:
45medido, hay **62 `.service` sellados y CERO directorios `.wants`** — nadie corrió nunca un
46`systemctl enable`, porque no hay systemd. Absorber un rootfs nuestro devuelve la lista vacía, y es
47la respuesta **correcta** a la pregunta que absorb contesta.
48
49⇒ **El «enable» de una distro construida desde fuente no se puede leer del árbol: hay que
50declararlo.** Ese es el trabajo que le toca a takana, y es el que faltaba.
51
52## 3. La decisión: declarar en dos mitades
53
54Se parte igual que systemd parte `[Service]` de `[Install]`, porque son dos hechos de dueño distinto:
55
56| hecho | dueño | dónde |
57|---|---|---|
58| **qué es** el servicio (exec, argv, supervisión, cgroup, red) | el PAQUETE | `[[service]]` en la receta ✅ |
59| **si arranca** en esta imagen | el PERFIL | `docs/state/targets.toml` ◑ (§4) |
60
61### 3.1 `[[service]]` en la receta (implementado)
62
63`crates/takana-core/src/service.rs`. Se traduce 1:1 a una Card de arje (`Service::card()`), con la
64forma del esquema validado de `card_core::Card`. Ejemplo real, `recipes/openssh.toml`:
65
66```toml
67[[service]]
68label      = "sshd"
69id         = "01HQAR53D4M2NBV8KZTYXFQA12"
70exec       = "/bin/busybox"
71argv       = ["sh", "-c", "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"]
72envp       = [["HOME", "/root"]]
73networking = "full"
74cgroup     = "arje.slice/sshd"
75restart    = { initial_ms = 500, max_ms = 20000 }
76```
77
78Tres propiedades que no son de estilo:
79
80- **Fuera de `hash_inputs`**, como `license`, `slots` y `evidence`: describe cómo se *supervisa* el
81  artefacto, no qué bytes tiene ⇒ se puebla en recetas YA SELLADAS sin mover un `ArtifactHash`.
82  **Medido**: el hash de openssh es `b3:938835e6…` con el binario viejo (que ignora el bloque) y el
83  mismo con el nuevo (que lo parsea y lo excluye). Si entrara al hash, declarar los servicios del
84  corpus costaría reconstruirlo entero y no se haría nunca.
85- **El `id` (ULID) se declara, no se genera.** Un id aleatorio haría que la seed —y con ella el
86  `product_rootfs_hash`— cambiara en cada corrida. La reproducibilidad del producto depende de que
87  esto sea estable, así que `validate()` lo exige y el de sshd es *el que la constante ya usaba*.
88- **Se valida al CARGAR la receta**, no al emitir la seed: un `id` mal puesto tiene que verse en la
89  receta, no tres pasos después en un rootfs que ya no reproduce y no dice por qué.
90
91**La prueba que autoriza el diseño** (`la_receta_de_openssh_reproduce_la_card_hardcodeada`): el card
92generado desde `recipes/openssh.toml` se compara **entero** contra `SSHD_SERVICE_CARD`, que es la
93card que el producto **ya bootea en QEMU** (e2e por `scripts/ssh-e2e-test.sh`). Empatan ⇒ mover el
94servicio de la constante a la receta no cambia un byte de la seed. No es «una card parecida»: es la
95misma.
96
97### 3.2 Los tres árboles que no se tocaban — decisión
98
99Había **tres convenciones de «dónde vive un servicio»**, ninguna conectada con las otras:
100
101| árbol | quién lo escribe | quién lo lee |
102|---|---|---|
103| `/ente/seed.card.json` (genesis) | `takana_bootstrap` | **arje-zero al boot** ✅ |
104| `/etc/arje/cards.d/*.json` | nadie | **arje, bajo demanda** (`ARJE_CARDS_DIR`) ✅ |
105| `/etc/hammer/init.d/<svc>.rule` | la mutación `init_rule` del `.swm` | **NADIE** ❌ |
106
107`docs/06-swm-format.md` afirma que «el init (arje) lee ese árbol». **No lo lee**: el único uso de
108`INIT_RULES_DIR` en todo el repo es escribirlo, y el contrato real de arje son los dos primeros.
109Para colmo `takana query service:<n>` busca en un CUARTO juego de rutas (`/etc/init.d/`,
110`/etc/service/<n>/run`).
111
112**Decisión:** los árboles canónicos son los de arje — **genesis de la seed** para lo que arranca al
113boot, **`cards.d/`** para lo que se levanta bajo demanda. `/etc/hammer/init.d/*.rule` queda
114**derogado**: o la mutación `init_rule` emite un card a `cards.d/`, o se retira del formato `.swm`.
115Es su propia unidad de trabajo (toca el esquema `.swm`, que es contrato firmado) y hasta entonces la
116afirmación del SDD 06 queda marcada como falsa aquí.
117
118## 4. Lo que queda: del card a la imagen
119
120**(a) El perfil habilita — HECHO.** `servicios = [...]` por perfil en `targets.toml` (se hereda como
121`paquetes`), y `scripts/targets.py --services <perfil>` resuelve label → receta → exec cruzando las
122declaraciones del corpus con la membresía de perfil de los grafos de estado (los CINCO: el del
123corpus más los de cada cola, porque las recetas de un escritorio viven en `recipes/incoming-<x>/` y
124su membresía está en el grafo de SU cola).
125
126Lo que más valió no fue resolver, fue **la comprobación inversa**: avisar de los paquetes que están
127en la imagen, TRAEN un demonio y el perfil no arranca. Y encontró algo el primer día:
128
129> **`arje-logind-compat` y `arje-polkit-compat` estaban en CERO perfiles** — y sin embargo
130> `scripts/gnome/qemu-desktop-image.sh` los copia al rootfs a mano y el de COSMIC hace `exit 1` si
131> falta logind-compat. Dos binarios imprescindibles, presentes en la imagen y ausentes del destino
132> declarado. Es la misma forma del agujero de `foot`, encontrada esta vez por una comprobación en
133> vez de por una imagen inusable. Corregido: son raíces de `escritorio-gnome` (los dos) y de
134> `escritorio-cosmic` (sólo logind-compat; sus scripts no nombran polkit-compat).
135
136**Precedencia, y no es un detalle:** una RAÍZ del perfil está en el perfil por definición, aunque el
137grafo todavía no lo diga. `build-state.json` es DERIVADO de `targets.toml` y lo regenera el cron cada
13830 min; preguntarle primero al derivado haría que añadir una raíz se leyera como error hasta la
139siguiente cosecha — castigar al que arregla el manifiesto.
140
141El guardián se prueba solo: `scripts/targets.py --selftest` corre 7 casos —**el primero es el
142CONTROL, que tiene que pasar en verde**— incluidos «dos recetas del mismo perfil declaran el mismo
143label» (ERROR) y «el mismo label en dos colas distintas» (NO es colisión: `upower` existe
144legítimamente en incoming-gnome y en incoming-kde, y son la misma unidad en dos imágenes).
145
146**(b) El emisor — HECHO, y la trampa quedó armada en un test.** `service_cards()` lee la receta que
147viaja DENTRO de cada artefacto sellado (el sidecar de provenance `.hammer/recipe.toml`, vía
148`Store::recipe_for_hash`) y filtra `scope = "system"`. Se lee de ahí y no de `recipes/` porque
149`seal_product_rootfs()` sirve a las DOS vetas —`product` desde recetas locales y `product_from_repo`
150desde el repo firmado— y **sólo una tiene `recipes/` a mano**: el sidecar es la única fuente que
151ambas comparten, que es lo que sostiene que las dos converjan al mismo `product-rootfs`.
152
153La trampa era real y sigue ahí: **el sidecar no entra en el `ArtifactHash`**, así que un artefacto
154sellado antes de que su receta declarara servicios es un cache-hit perfectamente válido y no se
155reconstruye nunca solo. Si el emisor devolviera lista vacía sin protestar, el producto saldría
156**booteando, verde y sin sshd**. Por eso `service_cards()` **falla ruidosamente** con un mensaje que
157explica la causa y da el arreglo, y hay un test
158(`service_cards_falla_ruidosamente_con_sidecar_rancio`) que exige que el mensaje siga diciendo las
159dos cosas. Es la regla 3 del CLAUDE.md aplicada antes de pagarla: un ausente falla ruidosamente; un
160vacío llega hasta el final diciendo que todo fue bien.
161
162### 4b.1 La migración de openssh, medida — y un hecho nuevo
163
164Re-sellar openssh era el paso que faltaba, y se hizo **con control** porque openssh no estaba
165certificado como reproducible (su propia receta dice «el criterio es construye y corre»). El
166procedimiento, por si hay que repetirlo con otro componente de servicio:
167
1681. **Manifiesto de control ANTES de tocar nada**: `sha256` + modo de los 33 ficheros.
1692. **Respaldo por hardlink** — y acá saltó el gotcha conocido: `store/` es un bind-mount de
170   `/dev/sdb` y `work/` vive en `/dev/sdc`, así que `cp -al` a `work/` muere con `Invalid
171   cross-device link`. El respaldo tiene que ir **dentro del mount del store**; `store/.respaldo-…`
172   sirve y es invisible para `store-gc.sh`, que sólo considera dirs `^[0-9a-f]{64}-`.
1733. Borrar el artefacto (los bytes sobreviven por el respaldo: 11 enlaces → 10) y reconstruir bajo
174   `flock -o`.
1754. Comparar árbol nuevo contra el manifiesto.
176
177**Resultado: openssh REPRODUCE BIT A BIT.** 33/33 ficheros, modos idénticos, y la única diferencia
178en todo el árbol es `.hammer/recipe.toml` — exactamente el sidecar que se quería migrar y nada más.
179Eso es un hecho nuevo sobre el corpus, no sólo un paso de esta campaña: openssh pasa de «no
180certificado» a «reproduce», comprobado contra un control tomado antes del borrado.
181
182Coste que conviene saber: los árboles ya hidratados compartían inodes con el artefacto viejo, y el
183nuevo tiene inodes propios (`%h = 1`). El contenido es el mismo, pero hasta que esos árboles se
184refresquen el disco lleva dos copias.
185
186### 4b.2 La cadena entera, probada en un arranque real
187
188No alcanza con que los tests pasen: el punto de todo esto es que **un servicio declarado en una
189receta termine supervisado por PID 1**. Medido el 2026-09-14, `scripts/product-boot-test.sh` sobre
190el `product-rootfs` ensamblado por la ruta real:
191
192```
193   ok  card sshd en la seed
194   ==> boot QEMU (mem 2048), hostfwd :2223->:22
195   PRODUCT_SSH_OK uid=0
196```
197
198La cadena completa, sin ningún eslabón escrito a mano:
199
200    recipes/openssh.toml `[[service]]`
201      → sidecar `.hammer/recipe.toml` DENTRO del artefacto sellado
202        → `service_cards()` (scope = system)
203          → `genesis` de `/ente/seed.card.json`
204            → arje-zero encarna sshd al boot
205              → la sesión SSH responde
206
207**Y el hash no se movió por esto.** El `product-rootfs` salió con hash distinto al anterior
208(`5011955a…` → `8639894332…`), y había que saber si lo movía este cambio: **no**. La seed de ambos
209árboles es **byte-idéntica** (`diff` sobre el JSON: sin diferencias); lo que cambió fueron `netup`
210—otro artefacto que cuando se selló el viejo— y, por arrastre, `ente/attest.json`, que registra su
211BLAKE3. La constante y la receta producen exactamente la misma seed, comprobado sobre el árbol real
212y no sólo en un test.
213
214**(c) Los de GNOME: DECLARADOS, no todavía arrancados por arje.** Las nueve unidades que
215`gnome-start-qemu.sh` levanta con `&` están declaradas en sus recetas y habilitadas en el perfil
216(`dbus-system`, `logind-compat`, `polkit-compat`, `accounts-daemon`, `upowerd`, `colord` de sistema;
217`pipewire`, `pipewire-pulse`, `wireplumber` de sesión). Verificado que ninguna movió su hash: 9/9
218idénticos a los que los grafos ya registraban.
219
220Dos cosas que NO son transcripción y hay que saber:
221
2221. **`dbus-daemon --fork` no se puede traducir tal cual.** arje supervisa al **hijo directo**:
223   `Type=forking` no existe en su modelo, así que un daemon que forkea y sale deja a arje viendo
224   morir al padre con éxito y reencarnándolo para siempre. La Card usa `--nofork --nopidfile`.
2252. **`scope = "system" | "session"`.** No es cosmético: decide DÓNDE va la Card. Las de sesión
226   necesitan `XDG_RUNTIME_DIR` y un usuario logueado, así que en el `genesis` arrancarían antes de
227   que exista ninguna de las dos cosas. Y su destino **no está resuelto fuera de mirada** — ahí las
228   arma el compositor (`mirada-compositor/src/session.rs`, `requires = [wayland_floor()]`, entregadas
229   a PID 1 por `RunCard`), pero mutter y kwin no tienen esa integración. `--services` avisa por cada
230   una: el hueco queda contado, no omitido.
231
232#### 4c.1 GNOME con los demonios supervisados por arje — medido contra un control
233
234El bloqueo que este documento daba por corpus (gnome-shell en deuda) **se levantó solo**: otro
235agente selló `evolution-data-server` y `gnome-shell` mientras esto se escribía. `escritorio-gnome`
236quedó 309/309 y el cierre hidrata completo (319 nodos, 8,2 G).
237
238**Se corrió un CONTROL primero**, y fue lo que hizo interpretable el resultado: la misma imagen sin
239inyectar cards, para saber si GNOME arranca hoy en este hub. Arranca — y trae un fallo propio que
240sirvió de sonda.
241
242| | control (el script con `&`) | con Cards en el `genesis` |
243|---|---|---|
244| `colord` | `!! colord MURIÓ al arrancar` | `ya vive (pid 175)` — lo encarnó PID 1 |
245| `org.freedesktop.ColorManager` | `NO apareció en 40s` | **`OK`** |
246| `login1` / `Accounts` / `UPower` | OK | OK |
247| compositor | `wayland-0`, shell vivo | `wayland-0`, shell vivo |
248
249**El fallo del control desapareció.** No es una mejora buscada: `colord` moría arrancado por el
250script y vive arrancado por arje, con el bus ya listo porque la espera está dentro de su `argv`. Es
251exactamente lo que un supervisor está para dar, y el control es lo único que permite decirlo — sin
252él, «ColorManager OK» sería un dato suelto en vez de una diferencia.
253
254Los PIDs lo confirman: `polkit-compat`=119, `colord`=175, `upowerd`=178, `accounts-daemon`=180 —
255números bajos, de antes de que el lanzador de sesión existiera.
256
257**Qué NO se comprobó:** no hay `screendump`. QEMU salió por timeout antes de poder pedirlo, y el
258control tampoco lo tuvo, así que la comparación es serial contra serial. Lo que se afirma acá es
259sobre los DEMONIOS (nombres del bus, PIDs, supervisión), no sobre el pintado; para afirmar que la
260imagen pinta sigue rigiendo la regla de validar escritorios con pantalla.
261
262#### Las piezas, y por qué cada una está donde está
263
264- **`takana service-cards <recetas…>`** — la traducción `[[service]]` → Card, con **una sola
265  implementación**. El script de imagen ya parchea la seed con python; si además compusiera el JSON
266  de la Card habría dos generadores del mismo formato divergiendo en silencio, y el formato es
267  contrato con `card_core::Card`, que no es nuestro.
268- **`targets.py --service-paths <perfil>`** — une las dos mitades: qué es el servicio (receta) y si
269  arranca (perfil). Sólo `scope = system`.
270- **`scripts/gnome/inyectar-cards.py`** — compone la seed, idempotente por `label`. Es un fichero y
271  no un heredoc porque **el script de imagen ya tiene uno de python y anidar dos falló en vivo**: el
272  terminador del interno cierra el externo y media cosa se ejecuta como shell.
273- **La espera del bus dentro del `argv`** de las cinco recetas de sistema. Sin ella un daemon puede
274  arrancar antes de que dbus escuche y quedarse en modo idle sin registrar su nombre — un fallo que
275  **no se ve**, porque el proceso vive y el bus no lo tiene. Los 5 hashes no se movieron.
276- **El guardia de `gnome-start` es por «¿está corriendo?», no por una perilla de la imagen.** Así es
277  correcto venga de donde venga el proceso, y la misma copia sirve en una imagen sin cards. Una
278  condición que consulta el estado real no puede desincronizarse de él; un flag sí.
279
280#### Lo que este frente se encontró, no lo que se propuso
281
282`--service-paths` devolvió **nueve** servicios de sistema y sólo seis eran míos: otro agente declaró
283`cupsd`, `bluetoothd` y `NetworkManager` en sus recetas y los habilitó en el perfil, con `-f`,
284`--nodetach` y `--no-daemon` — o sea que leyó y aplicó la regla de que arje supervisa al hijo directo
285y `Type=forking` no existe. El mecanismo se usó sin que nadie lo coordinara, que es la única prueba
286que vale de que el sitio donde se puso la declaración era el correcto.
287
288#### Lo que queda de §4c
289
290La base de la imagen es el `product-rootfs`, no `work/metal-rootfs`: ese fichero **no existe en este
291hub y ningún script del repo lo produce**. El producto sirve porque ya es arje-zero PID 1 + seed +
292busybox, pero el andamiaje de imagen de escritorio sigue dependiendo de un insumo sin dueño, y eso es
293su propia deuda.
294
295Y las tres cosas de siempre: EXDEV. `store/` es bind-mount de `/dev/sdb` y `work/` vive en
296`/dev/sdc`, así que **todo lo que enlaza en vez de copiar** se rompe al cruzar — lo pegaron
297`hydrate-profile.py` (que ya lo detecta y dice dónde poner el `--into`) y el staging de
298`install-image-efi.sh` (configurable por `STAGE`). Hidratado, fundido y staging tienen que vivir bajo
299el mismo montaje.
300
301## 4d. La otra mitad, que faltaba: `[[user]]` *(2026-09-14)*
302
303Declarar cómo se supervisa un demonio no alcanza cuando el demonio **se niega a correr como root**.
304`gitea` lo dice y sale:
305
306    [F] Gitea is not supposed to be run as root. If you need to use privileged TCP ports please
307        instead use `setcap` and the `cap_net_bind_service` permission.
308
309Su Card hace `setuidgid gitea`, y sin la cuenta el servicio arranca, muere y reintenta para siempre
310con un log que dice `unknown user` — no «a la imagen le falta una cuenta». El `/etc/passwd` de la
311imagen era **la constante `takana_bootstrap::PRODUCT_PASSWD`** (`root` y `sshd`, nada más): añadir
312un usuario era editar Rust y recompilar takana, que es **exactamente el hueco del §1 de este
313documento**, resuelto para las Cards y dejado abierto para las cuentas. Simetría, no elegancia.
314
315```toml
316[[user]]
317name = "gitea"
318uid  = 916
319home = "/var/lib/gitea"
320```
321
322**El `uid` se declara, no se asigna**, por la misma razón que el ULID de la Card: un «primero libre
323a partir de 1000» hace que dos imágenes del mismo perfil salgan con dueños distintos y el rootfs
324**deje de reproducir sin que nada falle** — los ficheros se ven iguales y `ls -l` dice otro número.
325
326Fuera de `hash_inputs`, como `[[service]]`: medido, el hash de `gitea` no se movió al declararlo.
327
328### La pregunta que NO es la misma que la de los servicios
329
330`--service-paths` lista las recetas de los servicios **habilitados**; `--user-paths` lista las de los
331paquetes **instalados** que declaran cuentas. No es el mismo conjunto y confundirlos rompe el caso
332normal: `postgres` instalado y sin levantar necesita su usuario igual, porque los ficheros que ya
333están en la imagen **ya son suyos**.
334
335### Fusionar, no escribir
336
337`takana users <recetas…> --merge <rootfs>` fusiona **por clave, no por línea entera**: componer la
338imagen dos veces no deja la cuenta dos veces (y `grep -q` sobre la línea completa no serviría — la
339misma cuenta con el GECOS cambiado se leería como nueva). Tres decisiones, las tres con su control:
340
3411. **Una cuenta ya presente con OTRA línea es un conflicto y no se pisa**: sale ≠0. Pisarla es
342   cambiarle el uid a ficheros que ya son de alguien, y eso se descubre dentro de la VM.
3432. **Se planea todo y sólo entonces se escribe.** Fichero a fichero, un conflicto en `passwd`
344   dejaría el `group` ya escrito: media cuenta es peor que ninguna, porque parece que está.
3453. **Un `passwd` ausente es un error, no un fichero a crear.** Crearlo dejaría una imagen **sin
346   `root`** y con el usuario del paquete.
347
348Y la validación rechaza lo que rompe tarde: `root`/`sshd`/`nobody` y sus uids (los reserva el
349producto), uid fuera de `100..=65533`, `home` relativo, y un `:` en cualquier campo — que partiría
350la línea en dos y fallaría lejos de acá.
351
352**Verificado**: 6 tests del módulo, merge idempotente (segunda pasada `+0`), y los dos controles
353negativos con el fichero **intacto** después. `scripts/servidor-image.sh` lo aplica entre hidratar el
354perfil y sellar la imagen.
355
356## 5. El hueco que no es nuestro: no hay readiness
357
358arje ordena el arranque por **capacidades**, con orden topológico real
359(`arje-zero/src/graph/resolve.rs::plan_spawn`: punto fijo de satisfacibilidad + Kahn, detecta ciclos
360y descarta insatisfacibles). Pero eso ordena el **spawn**, no el **estar listo**: no existe una
361primitiva «esperá a que aparezca este socket». El propio manual de arje lo lista como hueco abierto
362(§13: *«readiness real de `Type=notify` — el shim registra pero no ordena»*).
363
364Es lo que el script de GNOME resuelve con `while [ ! -S "$XDG_RUNTIME_DIR/pipewire-0" ]; sleep 0.25`.
365Mientras el hueco siga abierto, un card que necesita esperar lo hace **dentro de su propio `argv`**
366con `sh -c '…; exec …'` — que es lo que ya hace el de sshd (levanta la red y genera las host keys
367antes del `exec`). Este diseño **no lo esconde**: lo deja a la vista en el `argv` en vez de meterlo
368en un wrapper, para que se vea cuántos servicios están pagando el hueco y valga como argumento río
369arriba en arje.
370
371## 6. Estado
372
373| pieza | estado |
374|---|---|
375| `[[service]]` en la receta + traducción a Card + validación | ✅ `crates/takana-core/src/service.rs` |
376| Fuera de `hash_inputs` (medido, hash de openssh sin mover) | ✅ |
377| Equivalencia receta → card hardcodeada de sshd | ✅ test en `takana-bootstrap` |
378| `openssh` declara su servicio | ✅ `recipes/openssh.toml` |
379| El perfil habilita (`servicios` + `targets.py --services`) | ✅ §4a |
380| Guardián con roturas a propósito + control (`--selftest`) | ✅ 7/7 |
381| El vigía CORRE SOLO (cosecha-cron) → `docs/state/servicios.txt` | ✅ 0 errores, 18 avisos |
382| Los dos compat que no estaban en ningún perfil | ✅ corregido §4a |
383| Los 9 de GNOME declarados y habilitados (hash sin mover, 9/9) | ✅ §4c |
384| Emisor desde el sidecar (`service_cards`, falla ruidosa + test) | ✅ §4b |
385| Re-sellado de openssh con control → **reproduce bit a bit** | ✅ §4b.1 |
386| La cadena receta→sidecar→card→genesis→arje, **en arranque real** | ✅ §4b.2 (`PRODUCT_SSH_OK`) |
387| Que la imagen de ESCRITORIO los arranque y no el script `&` | ✅ §4c.1, con control (y arregló `colord`) |
388| `takana service-cards` + `--service-paths` + el inyector | ✅ §4c.1 |
389| `[[user]]` en la receta + validación + fusión idempotente | ✅ `crates/takana-core/src/user.rs`, §4d |
390| `takana users` (+`--merge`) y `targets.py --user-paths` | ✅ §4d |
391| `gitea` declara su servicio **y** su cuenta (hash sin mover) | ✅ `recipes/gitea.toml` |
392| El merge de cuentas en el camino de imagen | ✅ `scripts/servidor-image.sh` |
393| Que el resto de imágenes (escritorios) llamen al merge | ◻ hoy sólo `servidor-image.sh` |
394| `escritorio-cosmic`: auditar su `cosmic-start.sh` y habilitar | ◻ hoy sale con 5 AVISO |
395
396**La primera lectura del vigía**, para que no haya que creerle a este documento: 0 errores y 18
397avisos. El más ruidoso es `dbus-system`, que viaja en **seis** perfiles y sólo GNOME lo arranca —
398que es exactamente el tipo de hecho que antes no tenía dónde verse.
399| Derogar `/etc/hammer/init.d/*.rule` en el `.swm` | ◻ §3.2 |
400| 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 y VALIDADOS EN ARRANQUE REAL (2026-09-12/14); §4c CERRADO también para GNOME (2026-09-14, con control); §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:

  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.

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:

  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.

4c.1 GNOME con los demonios supervisados por arje — medido contra un control

El bloqueo que este documento daba por corpus (gnome-shell en deuda) se levantó solo: otro agente selló evolution-data-server y gnome-shell mientras esto se escribía. escritorio-gnome quedó 309/309 y el cierre hidrata completo (319 nodos, 8,2 G).

Se corrió un CONTROL primero, y fue lo que hizo interpretable el resultado: la misma imagen sin inyectar cards, para saber si GNOME arranca hoy en este hub. Arranca — y trae un fallo propio que sirvió de sonda.

control (el script con &)con Cards en el genesis
colord!! colord MURIÓ al arrancarya vive (pid 175) — lo encarnó PID 1
org.freedesktop.ColorManagerNO apareció en 40sOK
login1 / Accounts / UPowerOKOK
compositorwayland-0, shell vivowayland-0, shell vivo

El fallo del control desapareció. No es una mejora buscada: colord moría arrancado por el script y vive arrancado por arje, con el bus ya listo porque la espera está dentro de su argv. Es exactamente lo que un supervisor está para dar, y el control es lo único que permite decirlo — sin él, «ColorManager OK» sería un dato suelto en vez de una diferencia.

Los PIDs lo confirman: polkit-compat=119, colord=175, upowerd=178, accounts-daemon=180 — números bajos, de antes de que el lanzador de sesión existiera.

Qué NO se comprobó: no hay screendump. QEMU salió por timeout antes de poder pedirlo, y el control tampoco lo tuvo, así que la comparación es serial contra serial. Lo que se afirma acá es sobre los DEMONIOS (nombres del bus, PIDs, supervisión), no sobre el pintado; para afirmar que la imagen pinta sigue rigiendo la regla de validar escritorios con pantalla.

Las piezas, y por qué cada una está donde está

Lo que este frente se encontró, no lo que se propuso

--service-paths devolvió nueve servicios de sistema y sólo seis eran míos: otro agente declaró cupsd, bluetoothd y NetworkManager en sus recetas y los habilitó en el perfil, con -f, --nodetach y --no-daemon — o sea que leyó y aplicó la regla de que arje supervisa al hijo directo y Type=forking no existe. El mecanismo se usó sin que nadie lo coordinara, que es la única prueba que vale de que el sitio donde se puso la declaración era el correcto.

Lo que queda de §4c

La base de la imagen es el product-rootfs, no work/metal-rootfs: ese fichero no existe en este hub y ningún script del repo lo produce. El producto sirve porque ya es arje-zero PID 1 + seed + busybox, pero el andamiaje de imagen de escritorio sigue dependiendo de un insumo sin dueño, y eso es su propia deuda.

Y las tres cosas de siempre: EXDEV. store/ es bind-mount de /dev/sdb y work/ vive en /dev/sdc, así que todo lo que enlaza en vez de copiar se rompe al cruzar — lo pegaron hydrate-profile.py (que ya lo detecta y dice dónde poner el --into) y el staging de install-image-efi.sh (configurable por STAGE). Hidratado, fundido y staging tienen que vivir bajo el mismo montaje.

4d. La otra mitad, que faltaba: [[user]] (2026-09-14)

Declarar cómo se supervisa un demonio no alcanza cuando el demonio se niega a correr como root. gitea lo dice y sale:

[F] Gitea is not supposed to be run as root. If you need to use privileged TCP ports please
    instead use `setcap` and the `cap_net_bind_service` permission.

Su Card hace setuidgid gitea, y sin la cuenta el servicio arranca, muere y reintenta para siempre con un log que dice unknown user — no «a la imagen le falta una cuenta». El /etc/passwd de la imagen era la constante takana_bootstrap::PRODUCT_PASSWD (root y sshd, nada más): añadir un usuario era editar Rust y recompilar takana, que es exactamente el hueco del §1 de este documento, resuelto para las Cards y dejado abierto para las cuentas. Simetría, no elegancia.

[[user]]
name = "gitea"
uid  = 916
home = "/var/lib/gitea"

El uid se declara, no se asigna, por la misma razón que el ULID de la Card: un «primero libre a partir de 1000» hace que dos imágenes del mismo perfil salgan con dueños distintos y el rootfs deje de reproducir sin que nada falle — los ficheros se ven iguales y ls -l dice otro número.

Fuera de hash_inputs, como [[service]]: medido, el hash de gitea no se movió al declararlo.

La pregunta que NO es la misma que la de los servicios

--service-paths lista las recetas de los servicios habilitados; --user-paths lista las de los paquetes instalados que declaran cuentas. No es el mismo conjunto y confundirlos rompe el caso normal: postgres instalado y sin levantar necesita su usuario igual, porque los ficheros que ya están en la imagen ya son suyos.

Fusionar, no escribir

takana users <recetas…> --merge <rootfs> fusiona por clave, no por línea entera: componer la imagen dos veces no deja la cuenta dos veces (y grep -q sobre la línea completa no serviría — la misma cuenta con el GECOS cambiado se leería como nueva). Tres decisiones, las tres con su control:

  1. Una cuenta ya presente con OTRA línea es un conflicto y no se pisa: sale ≠0. Pisarla es cambiarle el uid a ficheros que ya son de alguien, y eso se descubre dentro de la VM.
  2. Se planea todo y sólo entonces se escribe. Fichero a fichero, un conflicto en passwd dejaría el group ya escrito: media cuenta es peor que ninguna, porque parece que está.
  3. Un passwd ausente es un error, no un fichero a crear. Crearlo dejaría una imagen sin root y con el usuario del paquete.

Y la validación rechaza lo que rompe tarde: root/sshd/nobody y sus uids (los reserva el producto), uid fuera de 100..=65533, home relativo, y un : en cualquier campo — que partiría la línea en dos y fallaría lejos de acá.

Verificado: 6 tests del módulo, merge idempotente (segunda pasada +0), y los dos controles negativos con el fichero intacto después. scripts/servidor-image.sh lo aplica entre hidratar el perfil y sellar la imagen.

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
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.1, con control (y arregló colord)
takana service-cards + --service-paths + el inyector✅ §4c.1
[[user]] en la receta + validación + fusión idempotente✅ crates/takana-core/src/user.rs, §4d
takana users (+--merge) y targets.py --user-paths✅ §4d
gitea declara su servicio y su cuenta (hash sin mover)✅ recipes/gitea.toml
El merge de cuentas en el camino de imagen✅ scripts/servidor-image.sh
Que el resto de imágenes (escritorios) llamen al merge◻ hoy sólo servidor-image.sh
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 |