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…

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

SDD 30 §4a+§4c: los 9 demonios de GNOME declarados — y aparecieron dos que no estaban en NINGÚN perfil

Lo que el script de sesión lanza con `&` ahora está declarado en las recetas y
habilitado en el perfil. Ninguna receta movió su hash: 9/9 idénticos a los que
los grafos ya registraban.

EL HALLAZGO, y no lo buscaba: la comprobación inversa del resolutor rechazó
`arje-logind-compat` y `arje-polkit-compat` porque están en CERO perfiles — y
sin embargo 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: la misma forma del agujero de `foot`,
encontrada por una comprobación en vez de por una imagen inusable. Son raíces de
escritorio-gnome (los dos) y de escritorio-cosmic (sólo logind, verificado que
sus scripts no nombran polkit).

DOS COSAS QUE NO SON TRANSCRIPCIÓN:
- `dbus-daemon --fork` no se traduce tal cual: arje supervisa al HIJO DIRECTO y
  Type=forking no existe, 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.
- `scope = system|session` decide DÓNDE va la card. Las de sesión necesitan
  XDG_RUNTIME_DIR y usuario logueado; en el genesis arrancarían antes de que
  exista ninguno. Y fuera de mirada NADIE entrega cards de sesión todavía, así
  que salen con AVISO: el hueco queda contado, no omitido.

Correcciones propias: la unicidad del label es DENTRO del perfil, no del corpus
(upower vive legítimamente en dos colas); la membresía se lee de los CINCO
grafos, no sólo el del corpus; una RAÍZ manda sobre el grafo, que es derivado y
lo regenera el cron; y la flag nace en inglés (`--services`) como manda la
regla 4, aunque `--lista` sea deuda vieja del mismo fichero.

`--selftest`: 7 casos, el primero es el CONTROL que tiene que pasar en verde.

1b56b164028edeb196312e2fe051f7817211a27f · ruwaq Sergio · (1 killa ñawpaq) · tayta 0b5dae781 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 11

crates/takana-core/src/service.rs

scripts/targets.py

11 qillqakuna · +315 −27

diff --git a/crates/takana-core/src/service.rs b/crates/takana-core/src/service.rs
index 42252c6a..91e93fd3 100644
--- a/crates/takana-core/src/service.rs
+++ b/crates/takana-core/src/service.rs
@@ -67,6 +67,21 @@ pub struct Service {
     /// `daemon` (default) u `oneshot` — el `lifecycle` de la Card.
     #[serde(default)]
     pub kind: Kind,
+    /// **De sistema o de sesión** — y no es cosmético: decide DÓNDE va la Card.
+    ///
+    /// `system` (default) = arranca al boot, va en el `genesis` de la seed. `session` = pertenece a
+    /// una sesión gráfica (pipewire, wireplumber, el bus de sesión): necesita `XDG_RUNTIME_DIR` y un
+    /// usuario logueado, así que meterla en el genesis la arrancaría antes de que exista ninguna de
+    /// las dos cosas.
+    ///
+    /// El campo existe porque el destino de una Card de sesión **hoy no está resuelto fuera de
+    /// mirada**: ahí lo hace el compositor (`mirada-compositor/src/session.rs` arma
+    /// `session.{label}` con `requires = [wayland_floor()]` y la entrega a PID 1 por `RunCard`),
+    /// pero mutter y kwin no tienen esa integración y sus demonios de sesión los sigue lanzando el
+    /// script de arranque. Declararlo igual hace que el hueco se CUENTE en vez de omitirse —
+    /// `scripts/targets.py --servicios` avisa por cada uno.
+    #[serde(default)]
+    pub scope: Scope,
     /// Política de supervisión. **Ausente ⇒ `OneShot`** (no revive). Presente ⇒ `Restart` con
     /// backoff exponencial entre `initial_ms` y `max_ms`, y su muerte es el `CRASHED` real.
     #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -97,6 +112,24 @@ impl Kind {
     }
 }
 
+/// Dónde vive la Card: arranque del sistema o sesión gráfica. Ver [`Service::scope`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Scope {
+    #[default]
+    System,
+    Session,
+}
+
+impl Scope {
+    pub fn as_str(self) -> &'static str {
+        match self {
+            Scope::System => "system",
+            Scope::Session => "session",
+        }
+    }
+}
+
 /// `permissions.networking` de la Card.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
 #[serde(rename_all = "lowercase")]
@@ -243,6 +276,7 @@ mod tests {
                 .collect(),
             envp: vec![("HOME".into(), "/root".into())],
             kind: Kind::Daemon,
+            scope: Scope::System,
             restart: Some(Restart { initial_ms: 500, max_ms: 20000 }),
             networking: Networking::Full,
             cgroup: Some("arje.slice/sshd".into()),
@@ -311,6 +345,24 @@ restart = { initial_ms = 500, max_ms = 20000 }
         assert!(s.validate().unwrap_err().to_string().contains("initial_ms"));
     }
 
+    #[test]
+    fn el_scope_es_system_por_defecto_y_no_entra_en_la_card() {
+        // El scope decide DÓNDE se coloca la Card, no qué dice: la Card de un servicio de sesión
+        // tiene la misma forma. Si entrara en el JSON, cambiaría el esquema de `card_core::Card`,
+        // que no es nuestro.
+        let s: Service = toml::from_str(
+            "label='pipewire'\nid='01HQAR53D4M2NBV8KZTYXFQA20'\nexec='/usr/bin/pipewire'",
+        )
+        .unwrap();
+        assert_eq!(s.scope, Scope::System, "default");
+        let con_sesion: Service = toml::from_str(
+            "label='pipewire'\nid='01HQAR53D4M2NBV8KZTYXFQA20'\nexec='/usr/bin/pipewire'\nscope='session'",
+        )
+        .unwrap();
+        assert_eq!(con_sesion.scope, Scope::Session);
+        assert_eq!(s.card(), con_sesion.card(), "el scope no cambia la Card");
+    }
+
     #[test]
     fn validate_all_rechaza_duplicados() {
         let a = sshd();
diff --git a/docs/state/targets.toml b/docs/state/targets.toml
index 06195c68..ddf11a6b 100644
--- a/docs/state/targets.toml
+++ b/docs/state/targets.toml
@@ -582,6 +582,29 @@ paquetes = [
   # `wireplumber` es lo que le da DISPOSITIVOS a PipeWire (que ya entra por la clausura): sin él hay
   # socket de audio y nada que suene.
   "wireplumber",
+  # ── LOS DOS QUE LA IMAGEN INYECTABA A MANO Y NINGÚN PERFIL DECLARABA ─────────────────────────
+  # Hallados el 2026-09-12 por `targets.py --servicios escritorio-gnome`, que los rechazó con «lo
+  # declara `arje-logind-compat`, que NO pertenece al perfil». No era falso positivo: los dos
+  # 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. O sea: dos binarios
+  # imprescindibles, presentes en la imagen y ausentes del destino declarado — la misma forma del
+  # agujero de `foot`, encontrada esta vez por una comprobación en vez de por una imagen inusable.
+  "arje-logind-compat", "arje-polkit-compat",
+]
+
+# ── QUÉ ARRANCA (el «enable»; SDD 30 §3) ────────────────────────────────────────────────────────
+# Hoy estos ocho los lanza a mano `scripts/gnome/gnome-start-qemu.sh` con `&`, sin supervisión, sin
+# backoff y sin el `CRASHED` real — o sea sin nada de lo que arje es PID 1 para dar. Declararlos acá
+# no los mueve todavía: los pone en el mismo sitio que el resto del destino de la imagen, y hace que
+# `scripts/targets.py --servicios escritorio-gnome` pueda contradecir a la realidad en vez de que la
+# realidad viva sólo dentro de un script de 500 líneas.
+#
+# Los tres de SESIÓN (pipewire, pipewire-pulse, wireplumber) salen con AVISO a propósito: fuera de
+# mirada nadie entrega Cards de sesión, así que están declarados y NO arrancados por arje. Un hueco
+# escrito se ve; uno omitido, no.
+servicios = [
+  "dbus-system", "logind-compat", "polkit-compat", "accounts-daemon", "upowerd", "colord",
+  "pipewire", "pipewire-pulse", "wireplumber",
 ]
 
 [perfil.escritorio-cosmic]
@@ -765,6 +788,11 @@ paquetes = [
   # Arreglarlo de verdad pide re-sellar pipewire, que arrastra 7 artefactos —entre ellos dos Rust
   # que ya murieron por OOM— así que se paga el día que pipewire se re-selle por otro motivo.
   "expat-shared", "libffi-shared", "bzip2-shared", "ncurses-shared",
+  # `arje-logind-compat` (2026-09-12): mismo hallazgo que en `escritorio-gnome` — la imagen lo
+  # inyecta a mano y `scripts/cosmic/qemu-desktop-image.sh` hace `exit 1` si falta, pero no estaba
+  # en ningún perfil. COSMIC NO usa `arje-polkit-compat` (verificado: sus scripts no lo nombran),
+  # así que va sólo este.
+  "arje-logind-compat",
 ]
 
 [perfil.escritorio-sway]
diff --git a/recipes/arje-logind-compat.toml b/recipes/arje-logind-compat.toml
index 8bd9ab43..6f2782fb 100644
--- a/recipes/arje-logind-compat.toml
+++ b/recipes/arje-logind-compat.toml
@@ -78,3 +78,20 @@ cat > /out/usr/share/dbus-1/system.d/org.freedesktop.login1.conf <<'XML'
 </busconfig>
 XML
 '''
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+# `ARJE_LOGIN_STATE=1` NO es opcional y la razón está medida en `scripts/gnome/gnome-start-qemu.sh`:
+# es la perilla que hace que el daemon ESCRIBA `/run/systemd/{sessions,seats,users}`, que es lo que
+# libelogind LEE para resolver la C-ABI de sd-login. Su default es `off` porque en un host con
+# systemd real pisaría el estado del sistema; acá no hay systemd y el que la prende es el lanzador
+# de sesión. Sin ella mutter muere con "Failed to find any matching session".
+#
+# Va DESPUÉS del bus de sistema (registra `org.freedesktop.login1` ahí). Ese orden hoy lo garantiza
+# el script esperando el socket; en Cards lo daría el grafo de capacidades de arje, pero el
+# ordenamiento es por spawn y no por READINESS (SDD 30 §5) — el hueco está contado, no tapado.
+[[service]]
+label   = "logind-compat"
+id      = "01HQAR53D4M2NBV8KZTYXFQA21"
+exec    = "/usr/bin/arje-logind-compat"
+envp    = [["ARJE_LOGIN_STATE", "1"]]
+restart = { initial_ms = 500, max_ms = 20000 }
diff --git a/recipes/arje-polkit-compat.toml b/recipes/arje-polkit-compat.toml
index 9d7de65d..61dcc619 100644
--- a/recipes/arje-polkit-compat.toml
+++ b/recipes/arje-polkit-compat.toml
@@ -83,3 +83,10 @@ cat > /out/usr/share/dbus-1/system.d/zz-arje-polkit-compat.conf <<'XML'
 </busconfig>
 XML
 '''
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+[[service]]
+label   = "polkit-compat"
+id      = "01HQAR53D4M2NBV8KZTYXFQA22"
+exec    = "/usr/bin/arje-polkit-compat"
+restart = { initial_ms = 500, max_ms = 20000 }
diff --git a/recipes/dbus.toml b/recipes/dbus.toml
index b26696e0..f01cee85 100644
--- a/recipes/dbus.toml
+++ b/recipes/dbus.toml
@@ -47,3 +47,26 @@ rm -rf /out/run
 
 [deps]
 build = ["meson", "samurai", "python3", "pkgconf", "expat"]
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+# ⚠ ESTO NO ES UNA TRANSCRIPCIÓN DEL SCRIPT DE SESIÓN, Y LA DIFERENCIA IMPORTA. `gnome-start-qemu.sh`
+# lo lanza como `dbus-daemon --system --fork`; una Card NO PUEDE hacer eso. arje supervisa **al hijo
+# directo**: `Type=forking` no existe en su modelo (DE-SYSTEMD-A-ARJE.md §2), así que un daemon que
+# forkea y sale deja a arje viendo morir al padre con éxito y reencarnándolo para siempre. Por eso la
+# Card usa `--nofork --nopidfile`: el pidfile tampoco tiene sentido cuando quien sigue el proceso es
+# PID 1.
+#
+# El bus de SISTEMA es scope `system` (arranca al boot, antes de cualquier sesión). El bus de SESIÓN
+# que gnome-shell exige es otra cosa y no va acá: lo levanta el lanzador de sesión con
+# `--print-address`, porque su dirección es un dato que hay que devolverle al que lanza.
+#
+# La Card se declara en `dbus` y NO en `dbus-shared` a propósito: las dos publican `dbus-daemon`
+# —verificado en el store— pero la compartida existe para publicar la librería, no para ser el
+# daemon canónico. Dos recetas del mismo perfil declarando el mismo label es un error que
+# `scripts/targets.py --servicios` rechaza, y con razón: la imagen no sabría cuál arranca.
+[[service]]
+label   = "dbus-system"
+id      = "01HQAR53D4M2NBV8KZTYXFQA20"
+exec    = "/usr/bin/dbus-daemon"
+argv    = ["--system", "--nofork", "--nopidfile"]
+restart = { initial_ms = 200, max_ms = 10000 }
diff --git a/recipes/incoming-gnome/accountsservice.toml b/recipes/incoming-gnome/accountsservice.toml
index 5a9ce834..7ab7825f 100644
--- a/recipes/incoming-gnome/accountsservice.toml
+++ b/recipes/incoming-gnome/accountsservice.toml
@@ -99,3 +99,12 @@ install   = "PYTHONPATH=/usr/lib/python3.12/site-packages DESTDIR=/out meson ins
 
 [deps]
 build = ["meson", "samurai", "python3", "pkgconf", "gettext-tiny", "py3-setuptools", "gi-foreign-girs", "gobject-introspection", "glib", "glib-introspected", "polkit", "dbus-shared", "libelogind", "libffi", "pcre2", "zlib-shared", "expat"]
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+# Se lanza EXPLÍCITO, no por activación de D-Bus, y el motivo está medido en el script de sesión: la
+# activación por bus funciona, pero deja el arranque esperando el timeout y no deja log donde mirar.
+[[service]]
+label   = "accounts-daemon"
+id      = "01HQAR53D4M2NBV8KZTYXFQA23"
+exec    = "/usr/libexec/accounts-daemon"
+restart = { initial_ms = 500, max_ms = 20000 }
diff --git a/recipes/incoming-gnome/colord.toml b/recipes/incoming-gnome/colord.toml
index 4c406b9c..2021ec3d 100644
--- a/recipes/incoming-gnome/colord.toml
+++ b/recipes/incoming-gnome/colord.toml
@@ -70,3 +70,12 @@ install   = "PYTHONPATH=/usr/lib/python3.12/site-packages DESTDIR=/out meson ins
 # librería para leer la base de perfiles ICC). Ya estaba en el corpus y sellada, provee sqlite3.pc.
 [deps]
 build = ["meson", "samurai", "python3", "pkgconf", "gettext-tiny", "glib", "lcms2", "sqlite", "libgusb", "libgudev", "libudev-zero", "libusb", "json-glib", "hwdata", "polkit", "dbus-shared", "expat", "libffi", "pcre2", "zlib"]
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+# Mutter lo pide al arrancar y, si no está, se come 25 s de timeout de activación D-Bus. Por eso es
+# un servicio declarado y no algo que el bus levante cuando alguien pregunte.
+[[service]]
+label   = "colord"
+id      = "01HQAR53D4M2NBV8KZTYXFQA25"
+exec    = "/usr/libexec/colord"
+restart = { initial_ms = 500, max_ms = 20000 }
diff --git a/recipes/incoming-gnome/upower.toml b/recipes/incoming-gnome/upower.toml
index aa64d55b..088ce6d0 100644
--- a/recipes/incoming-gnome/upower.toml
+++ b/recipes/incoming-gnome/upower.toml
@@ -56,3 +56,10 @@ install = "PYTHONPATH=/usr/lib/python3.12/site-packages DESTDIR=/out meson insta
 
 [deps]
 build = ["meson", "samurai", "python3", "pkgconf", "gettext-tiny", "py3-setuptools", "gobject-introspection", "gi-foreign-girs", "glib", "glib-introspected", "libgudev", "udev-pc", "libudev-zero", "polkit", "libffi", "pcre2", "zlib-shared", "expat", "dbus-shared"]
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+[[service]]
+label   = "upowerd"
+id      = "01HQAR53D4M2NBV8KZTYXFQA24"
+exec    = "/usr/libexec/upowerd"
+restart = { initial_ms = 500, max_ms = 20000 }
diff --git a/recipes/incoming-gnome/wireplumber.toml b/recipes/incoming-gnome/wireplumber.toml
index 0e3d26d8..768c6c3e 100644
--- a/recipes/incoming-gnome/wireplumber.toml
+++ b/recipes/incoming-gnome/wireplumber.toml
@@ -74,3 +74,13 @@ install = "PYTHONPATH=/usr/lib/python3.12/site-packages DESTDIR=/out meson insta
 build = ["meson", "samurai", "python3", "pkgconf", "gettext-tiny", "lua", "pipewire",
   "glib", "dbus-shared", "libelogind", "alsa-lib", "libsndfile", "pulseaudio",
   "expat", "pcre2", "libffi", "libudev-zero", "zlib-shared"]
+
+# ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
+# El gestor de sesión de pipewire. Mismo `scope = "session"` y misma advertencia: fuera de mirada
+# nadie entrega Cards de sesión todavía.
+[[service]]
+label   = "wireplumber"
+id      = "01HQAR53D4M2NBV8KZTYXFQA28"
+exec    = "/usr/bin/wireplumber"
+scope   = "session"
+restart = { initial_ms = 500, max_ms = 20000 }
diff --git a/recipes/pipewire.toml b/recipes/pipewire.toml
index 45e39b6b..4c9b48f7 100644
--- a/recipes/pipewire.toml
+++ b/recipes/pipewire.toml
@@ -143,3 +143,30 @@ ln -sf /usr/share/alsa/alsa.conf.d/99-pipewire-default.conf /out/etc/alsa/conf.d
 [deps]
 build = ["meson", "samurai", "python3", "pkgconf", "dbus-shared", "expat", "alsa-lib", "libsndfile",
   "pulseaudio", "glib", "pcre2", "libffi", "libudev-zero", "zlib-shared", "gettext-tiny"]
+
+# ── LOS SERVICIOS QUE ESTE PAQUETE TRAE (SDD 30) ────────────────────────────────────────────────
+# `scope = "session"`, y eso es una afirmación con consecuencia: estos NO van en el `genesis` de la
+# seed. Necesitan `XDG_RUNTIME_DIR` y un usuario logueado, así que arrancarlos al boot los pondría a
+# correr antes de que exista ninguna de las dos cosas.
+#
+# ⚠ Y hoy NO HAY QUIÉN LOS ENTREGUE a arje fuera de mirada: ahí el compositor arma las Cards de
+# sesión (`mirada-compositor/src/session.rs`, `requires = [wayland_floor()]`) y las pasa a PID 1 por
+# `RunCard`; mutter y kwin no tienen esa integración y sus demonios de sesión los sigue lanzando el
+# script. Se declaran igual para que el hueco se CUENTE —`targets.py --servicios` avisa por cada
+# uno— en vez de quedar omitido, que es como se ve un hueco que nadie escribió.
+[[service]]
+label   = "pipewire"
+id      = "01HQAR53D4M2NBV8KZTYXFQA26"
+exec    = "/usr/bin/pipewire"
+scope   = "session"
+restart = { initial_ms = 500, max_ms = 20000 }
+
+# El puente que habla protocolo PulseAudio contra el servidor. Depende de que `pipewire` ya haya
+# creado su socket: hoy el script lo espera con un bucle sobre `$XDG_RUNTIME_DIR/pipewire-0`, que es
+# justo la readiness que arje no tiene por unidad (SDD 30 §5).
+[[service]]
+label   = "pipewire-pulse"
+id      = "01HQAR53D4M2NBV8KZTYXFQA27"
+exec    = "/usr/bin/pipewire-pulse"
+scope   = "session"
+restart = { initial_ms = 500, max_ms = 20000 }
diff --git a/scripts/targets.py b/scripts/targets.py
index 57ea812c..457b8d58 100755
--- a/scripts/targets.py
+++ b/scripts/targets.py
@@ -7,7 +7,13 @@
 # Uso:  scripts/targets.py cli               # raíces expandidas, una línea separada por espacios
 #       scripts/targets.py --lista           # perfiles disponibles, con su descripción
 #       scripts/targets.py --json            # todo el manifiesto expandido
-#       scripts/targets.py --servicios cli   # los servicios que ESA imagen arranca, resueltos
+#       scripts/targets.py --services cli    # los servicios que ESA imagen arranca, resueltos
+#       scripts/targets.py --selftest        # roturas a propósito + un control que TIENE que pasar
+#
+# ⚠ La FLAG va en inglés (regla 4 del CLAUDE.md: la superficie de CLI es contrato) aunque `--lista`
+# sea deuda vieja del mismo fichero. La CLAVE del TOML sí es `servicios`, en castellano, porque ahí
+# la convención local es esa (`paquetes`, `hereda`, `cola`, `descripcion`) y mezclar idiomas DENTRO
+# del manifiesto sería peor que la coherencia con el resto de la superficie.
 # Como módulo:  from targets import load  →  {perfil: {descripcion, cola, hereda, paquetes, raices}}
 #
 # `hereda` es transitivo y el orden se PRESERVA (primero las raíces de lo heredado, en el orden
@@ -96,14 +102,30 @@ def declaraciones(recipes_dir=None):
     return por_label
 
 
+# Los grafos por COLA. `build-state.json` es sólo el corpus: las recetas de un escritorio viven en
+# `recipes/incoming-<x>/` y `build-state.py` sólo las carga con su flag, así que su membresía de
+# perfil está en el grafo de SU cola. Mirar sólo el del corpus daría "no puedo comprobar" para todo
+# GNOME/KDE/COSMIC — que es peor que no comprobar: parece una respuesta.
+GRAFOS = ["build-state.json", "build-state-gnome.json", "build-state-kde.json",
+          "build-state-cosmic.json", "build-state-wlr.json"]
+
+
 def _membresia():
-    """{receta: [perfiles]} desde build-state.json. `{}` si no está (no es fatal: degrada a no
-    poder comprobar la pertenencia, y se dice)."""
-    try:
-        d = json.load(open(ROOT / "docs/state/build-state.json"))
-    except (FileNotFoundError, json.JSONDecodeError):
-        return {}
-    return {k: v.get("perfiles", []) for k, v in d.get("nodes", {}).items()}
+    """{receta: [perfiles]} fusionando TODOS los grafos de estado. `{}` si no hay ninguno (no es
+    fatal: degrada a no poder comprobar la pertenencia, y se dice)."""
+    memb = {}
+    for nombre in GRAFOS:
+        try:
+            d = json.load(open(ROOT / "docs/state" / nombre))
+        except (FileNotFoundError, json.JSONDecodeError):
+            continue
+        for k, v in d.get("nodes", {}).items():
+            # unión: una receta del corpus puede aparecer en varios grafos con perfiles distintos
+            ya = memb.setdefault(k, [])
+            for perf in v.get("perfiles", []):
+                if perf not in ya:
+                    ya.append(perf)
+    return memb
 
 
 def resolver_servicios(perfil, perfiles=None):
@@ -117,30 +139,54 @@ def resolver_servicios(perfil, perfiles=None):
     memb = _membresia()
     raices = set(p["raices"])
 
+    def en_el_perfil(receta):
+        """`True`/`False`/`None` (= no se puede saber).
+
+        Las RAÍCES mandan sobre el grafo, y el orden importa: `targets.toml` es la fuente de verdad
+        de lo que la imagen quiere y `build-state.json` es DERIVADO de él —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, que es castigar al que arregla el manifiesto."""
+        if receta in raices:
+            return True
+        perfs = memb.get(receta)
+        if perfs is None:
+            return None
+        return perfil in perfs
+
     habilitados, problemas = [], []
     for lbl in p["servicios_exp"]:
         cands = decl.get(lbl, [])
         if not cands:
             problemas.append(f"ERROR  {lbl}: habilitado por el perfil y NINGUNA receta lo declara")
             continue
-        if len(cands) > 1:
+        # La unicidad del label se juzga DENTRO del perfil, no en el corpus entero: el mismo
+        # servicio existe legítimamente en dos colas (p.ej. `upower` en incoming-gnome y en
+        # incoming-kde) y son la misma unidad en dos imágenes distintas. Exigir unicidad global
+        # convertiría esa convivencia normal en un error.
+        dentro = [(r, s) for r, s in cands if en_el_perfil(r) is True]
+        incierto = [(r, s) for r, s in cands if en_el_perfil(r) is None]
+        if not dentro and incierto:
+            quienes = ", ".join(r for r, _ in incierto)
+            problemas.append(f"AVISO  {lbl}: no puedo comprobar que `{quienes}` esté en el perfil "
+                             f"(sin build-state.json y no es raíz)")
+            receta, svc = incierto[0]
+        elif not dentro:
             quienes = ", ".join(r for r, _ in cands)
-            problemas.append(f"ERROR  {lbl}: lo declaran {len(cands)} recetas ({quienes})")
+            problemas.append(
+                f"ERROR  {lbl}: lo declara(n) {quienes}, que NO pertenece(n) al perfil ⇒ el card "
+                f"apuntaría a un binario que la imagen no trae")
             continue
-        receta, svc = cands[0]
-        habilitados.append((lbl, receta, svc.get("exec", "?")))
-        # La receta que lo trae tiene que estar EN la imagen. Si no, el card apuntaría a un binario
-        # que el rootfs no contiene: arje lo encarnaría con ENOENT en cada reintento del backoff.
-        perfs = memb.get(receta)
-        if perfs is None:
-            if receta not in raices:
-                problemas.append(
-                    f"AVISO  {lbl}: no puedo comprobar que `{receta}` esté en el perfil "
-                    f"(sin build-state.json y no es raíz)")
-        elif perfil not in perfs:
+        elif len(dentro) > 1:
+            quienes = ", ".join(r for r, _ in dentro)
+            problemas.append(f"ERROR  {lbl}: lo declaran {len(dentro)} recetas DEL PERFIL ({quienes})")
+            continue
+        else:
+            receta, svc = dentro[0]
+        if svc.get("scope", "system") != "system":
             problemas.append(
-                f"ERROR  {lbl}: lo declara `{receta}`, que NO pertenece al perfil "
-                f"(está en: {', '.join(perfs) or 'ningún perfil'})")
+                f"AVISO  {lbl}: es de SESIÓN (`{receta}`) — hoy lo lanza el script de sesión, no "
+                f"arje: fuera de mirada nadie entrega cards de sesión (SDD 30 §4c)")
+        habilitados.append((lbl, receta, svc.get("exec", "?")))
 
     # La inversa: paquetes del perfil que TRAEN demonio y nadie arranca.
     hab = {l for l, _, _ in habilitados}
@@ -148,14 +194,65 @@ def resolver_servicios(perfil, perfiles=None):
         if lbl in hab:
             continue
         for receta, _ in cands:
-            en_perfil = perfil in memb.get(receta, []) if memb else receta in raices
-            if en_perfil:
+            if en_el_perfil(receta) is True:
                 problemas.append(
                     f"AVISO  {lbl}: `{receta}` está en la imagen y TRAE este servicio, "
                     f"pero el perfil no lo arranca")
     return habilitados, problemas
 
 
+def autotest():
+    """Roturas A PROPÓSITO + un control que TIENE que pasar.
+
+    Un guardián que nunca falló no se sabe si sirve, y uno que sólo se prueba con casos rotos puede
+    estar gritando siempre. Por eso el primer caso es el BUENO: si ese no pasa en verde, el resto de
+    los ✓ no prueban nada."""
+    global declaraciones, _membresia
+    orig_d, orig_m = declaraciones, _membresia
+    SVC = {"label": "sshd", "exec": "/bin/busybox"}
+    SES = {"label": "pipewire", "exec": "/usr/bin/pipewire", "scope": "session"}
+
+    def perfil(svcs, raices=("openssh",)):
+        return dict(descripcion="", cola="corpus", hereda=[], paquetes=list(raices),
+                    servicios=list(svcs), raices=list(raices), servicios_exp=list(svcs))
+
+    casos = [
+        # (nombre, perfil, declaraciones, membresía, ¿espera ERROR?)
+        ("CONTROL: declarado, habilitado y la receta en el perfil",
+         perfil(["sshd"]), {"sshd": [("openssh", SVC)]}, {"openssh": ["prueba"]}, False),
+        ("habilitado y NADIE lo declara",
+         perfil(["sshd"]), {}, {}, True),
+        ("dos recetas DEL PERFIL declaran el mismo label",
+         perfil(["sshd"], ("openssh", "dropbear")),
+         {"sshd": [("openssh", SVC), ("dropbear", SVC)]}, {}, True),
+        ("lo declara una receta que NO está en el perfil",
+         perfil(["sshd"], ()), {"sshd": [("openssh", SVC)]}, {"openssh": ["otro"]}, True),
+        ("mismo label en DOS COLAS, sólo una en el perfil ⇒ NO es colisión",
+         perfil(["sshd"]), {"sshd": [("openssh", SVC), ("openssh-kde", SVC)]},
+         {"openssh": ["prueba"], "openssh-kde": ["otro"]}, False),
+        ("servicio de SESIÓN ⇒ avisa pero no rompe",
+         perfil(["pipewire"], ("pipewire",)), {"pipewire": [("pipewire", SES)]}, {}, False),
+        ("la INVERSA: en la imagen, trae demonio, nadie lo arranca ⇒ AVISO",
+         perfil([]), {"sshd": [("openssh", SVC)]}, {"openssh": ["prueba"]}, False),
+    ]
+    todo_ok = True
+    try:
+        for nombre, p_, decl, memb, espera in casos:
+            declaraciones = lambda *a, **k: decl
+            _membresia = lambda: memb
+            _, prob = resolver_servicios("prueba", {"prueba": p_})
+            hay = any(x.startswith("ERROR") for x in prob)
+            ok = hay == espera
+            todo_ok &= ok
+            print(f"{'✓' if ok else '✗'} {nombre}")
+            for x in prob:
+                print(f"      {x}")
+    finally:
+        declaraciones, _membresia = orig_d, orig_m
+    print("TODAS OK" if todo_ok else "HAY FALLOS")
+    return todo_ok
+
+
 def main():
     args = sys.argv[1:]
     try:
@@ -169,9 +266,11 @@ def main():
         for n, p in sorted(perfiles.items()):
             print(f"{n:20} {len(p['raices']):3} raíces  [{p['cola']}]  {p['descripcion']}")
         return 0
-    if args[0] in ("--servicios", "-s"):
+    if args[0] == "--selftest":
+        return 0 if autotest() else 1
+    if args[0] in ("--services", "-s"):
         if len(args) < 2 or args[1] not in perfiles:
-            print(f"uso: targets.py --servicios <perfil>  (hay: {', '.join(sorted(perfiles))})",
+            print(f"uso: targets.py --services <perfil>  (hay: {', '.join(sorted(perfiles))})",
                   file=sys.stderr)
             return 1
         habilitados, problemas = resolver_servicios(args[1], perfiles)