gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1346Shell 97Rust 63Markdown 571650 Sutichaykuna

qorpa export: shims generados, y la clase `ajeno` para que nadie los cuente mal

Paso 5 del ADR 0015, sus dos mitades.

SHIMS. `hammer qorpa export <id>` genera lanzadores finos y `.desktop` en el
espacio del host, desde lo DECLARADO en `[export]` — nunca todo: exportar todo
haría que el `ls` de la imagen compita con el nuestro, que es la falla de
Bedrock (arbitra en tiempo de exec, por heurística). Gana en tres cosas contra
un FUSE: cero costo en runtime, `cat` al shim y ves qué hace, y se revoca
borrándolos.

Se GENERAN, no se copian. El `.desktop` se arma con lista BLANCA de claves, así
que `Exec`, `TryExec`, `Path` y `DBusActivatable` quedan fuera por definición y
no por enumeración — una lista negra dejaría entrar la próxima clave ejecutable
que invente el estándar. El Exec original se cita en un comentario del fichero
generado, para que se vea qué decía y qué no se copió. El icono se busca en la
vista merged (upper primero, imagen después: si no, se perdería lo que instaló
el gestor de paquetes) y se copia al host, porque un icono que el host no
resuelve se ve como un cuadrito gris. Y `exported.json` registra cada fichero
escrito, para que `--remove` borre EXACTAMENTE eso y no por patrón sobre el
~/.local/bin de alguien.

Probado de punta a punta con un .desktop ajeno real de la imagen de Arch: el
shim corre `pacman -Q` del huésped desde el host, el X-KDE-Wayland-Interfaces
quedó fuera, el icono viajó, y --remove dejó 0 ficheros con la instancia intacta.

CLASE `ajeno`. build-state.py inyecta los nodos declarados en el nuevo
docs/state/qorpa-ajenos.toml ANTES que los `wanted`, y ese orden es la mitad del
punto: un nodo que provee una imagen ajena no es una receta por escribir. Con
eso el `xwayland` de escritorio-kde deja de ser deuda y pasa a contarse aparte:

  escritorio-kde       187/188 listo   falta   1  (raíces 14, + 1 ajenas)

Dos decisiones que sostienen esa cifra: los ajenos se RESTAN del denominador (si
entraran, el número que se lee como "cuánto construimos" crecería solo cada vez
que alguien enjaula una app), y la declaración vive en el REPO y no se lee de
/var/lib/hammer — build-state.json se commitea y lo regenera el cron en dos
máquinas; si la clase saliera de las instancias instaladas, cada una diría algo
distinto y se pisarían en cada cosecha. Es el error que ya se cometió con
sealed_remoto. Qué provee una imagen ajena es diseño; qué tenés instalado, no.

Un ajeno tampoco se hashea, y no por comodidad: no tiene procedencia de fuente,
así que un hash afirmaría que lo reproducimos.

2 tests nuevos (que del .desktop ajeno no sobreviva nada ejecutable; que el shim
no se rompa con rutas raras). 47/47.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2

f64859bade6918ef3e167f0bb9ea0a2a53909f4a · ruwaq Sergio · (1 killa ñawpaq) · tayta 3448ca467 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 18

crates/hammer-cli/src/qorpa.rs

scripts/build-state.py

8 qillqakuna · +495 −65

diff --git a/Cargo.lock b/Cargo.lock
index 03cd2a78..200a35c0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -553,6 +553,7 @@ dependencies = [
  "serde_json",
  "sha2",
  "tempfile",
+ "toml",
  "tracing",
  "tracing-subscriber",
 ]
diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs
index b4702ace..cbc7b266 100644
--- a/crates/hammer-cli/src/qorpa.rs
+++ b/crates/hammer-cli/src/qorpa.rs
@@ -114,8 +114,22 @@ pub enum QorpaCmd {
         #[arg(last = true)]
         cmd: Vec<String>,
     },
-    /// [ADR 0015 §Orden 5 — SIN IMPLEMENTAR] Genera los shims y `.desktop` de lo declarado en `[exporta]`.
-    Export { id: String },
+    /// [ADR 0015 §Orden 5] Genera los shims y `.desktop` de lo declarado en `[export]`.
+    ///
+    /// **Se GENERAN, no se copian.** El `Exec=` de un `.desktop` ajeno es texto ajeno: copiarlo
+    /// pondría una línea de comando que no escribimos en el menú del usuario (D5).
+    Export {
+        id: String,
+        /// Prefijo donde escribir (`<into>/bin`, `<into>/share/applications`). Default: `~/.local`.
+        #[arg(long)]
+        into: Option<PathBuf>,
+        /// Borra exactamente lo que se generó. Revocable: sin shims, la instancia desaparece del
+        /// sistema sin desmontar nada.
+        #[arg(long)]
+        remove: bool,
+        #[arg(long)]
+        root: Option<PathBuf>,
+    },
     /// [ADR 0015 §Orden 7 — SIN IMPLEMENTAR] Poda imágenes sin instancia y `upper` descartables.
     Prune,
 }
@@ -133,10 +147,9 @@ pub fn run(cmd: QorpaCmd) -> Result<()> {
         QorpaCmd::Run { id, root, dry_run, no_jail, single_id, cmd } => {
             run_instance(&resolve_root(root), &id, &cmd, dry_run, no_jail, single_id)
         }
-        QorpaCmd::Export { .. } => bail!(
-            "sin implementar: los shims son el paso 5 del ADR 0015. Se GENERAN, nunca se copia el \
-             `Exec=` de un .desktop ajeno."
-        ),
+        QorpaCmd::Export { id, into, remove, root } => {
+            export(&resolve_root(root), &id, into.as_deref(), remove)
+        }
         QorpaCmd::Prune => bail!(
             "sin implementar: la poda es el paso 7 del ADR 0015. Nace con el subsistema y no \
              después — este repo ya tuvo tres emergencias de disco."
@@ -531,6 +544,21 @@ struct Instance {
     /// La política, autorada (D7). **Por defecto vacía**: una instancia nace sin ver nada.
     #[serde(default)]
     grants: Grants,
+    /// La capa de transparencia (D5). Vacía por defecto: **se exporta lo DECLARADO**, nunca todo.
+    /// Exportar todo es la falla de Bedrock — su `ls` compitiendo con el nuestro, arbitrado por
+    /// heurística en tiempo de `exec`. Acá la ambigüedad se resuelve al declarar.
+    #[serde(default)]
+    export: Export,
+}
+
+#[derive(Serialize, Deserialize, Default)]
+struct Export {
+    /// Binarios que aparecen en el `PATH` del host como shims.
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    binaries: Vec<String>,
+    /// Ficheros `.desktop` de la imagen cuyo equivalente se GENERA para el menú del host.
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    apps: Vec<String>,
 }
 
 #[derive(Serialize, Deserialize)]
@@ -1119,6 +1147,269 @@ fn run_instance(
     }
 }
 
+// ── export: la capa de transparencia (D5) ───────────────────────────────────────────────────────
+//
+// El objetivo es el poder de Bedrock Linux —apps de cualquier «stratum» disponibles en todos
+// lados— SIN sus formas: nada de un FUSE global en el camino de cada `exec`, nada de arbitrar por
+// heurística qué binario gana. Acá no hace falta, porque **el FHS de esta distro ya es una
+// proyección**: `hammer hydrate` proyecta artefactos a un árbol, y extender la proyección a
+// instancias es idiomático.
+//
+// Tres propiedades que un FUSE no da: **cero costo en runtime** (no hay proceso en el camino
+// crítico), **inspeccionable** (`cat` al shim y ves qué hace) y **revocable** (se borran los shims
+// y la instancia desaparece del sistema sin desmontar nada).
+
+/// Registro de lo escrito. Sin esto, `--remove` tendría que ADIVINAR qué ficheros son nuestros, y
+/// borrar por patrón en el `~/.local/bin` de alguien es exactamente lo que no se hace.
+const EXPORT_LEDGER: &str = "exported.json";
+/// Marca dentro de cada fichero generado. Redundante con el registro a propósito: si alguien mueve
+/// el shim, sigue diciendo de dónde salió.
+const MARCA: &str = "hammer-qorpa";
+
+/// Claves del `.desktop` ajeno que se copian: TEXTO DE PRESENTACIÓN y nada más.
+///
+/// Lista blanca, no negra. `Exec`, `TryExec`, `Path`, `DBusActivatable` y `Actions` quedan fuera
+/// **por definición**: son ejecución, y la ejecución la escribimos nosotros. Una lista negra
+/// dejaría entrar la próxima clave ejecutable que invente el estándar.
+const DESKTOP_OK: &[&str] = &[
+    "Name", "GenericName", "Comment", "Keywords", "Icon", "Categories",
+    "Terminal", "StartupWMClass", "StartupNotify", "MimeType", "NoDisplay", "Type",
+];
+
+/// Busca un fichero en la vista MERGED de la instancia: primero la capa mutable, después la imagen.
+/// Es el mismo orden que ve el proceso adentro; mirar sólo la imagen se perdería lo que instaló el
+/// gestor de paquetes, que es justo lo que se quiere exportar.
+fn resolver_en_instancia(dir: &Path, tree: &Path, rel: &str) -> Option<PathBuf> {
+    let rel = rel.trim_start_matches('/');
+    for base in [dir.join("upper"), tree.to_path_buf()] {
+        let p = base.join(rel);
+        if p.exists() {
+            return Some(p);
+        }
+    }
+    None
+}
+
+fn buscar_desktop(dir: &Path, tree: &Path, nombre: &str) -> Option<PathBuf> {
+    let nombre = if nombre.ends_with(".desktop") { nombre.to_string() } else { format!("{nombre}.desktop") };
+    for d in ["usr/share/applications", "usr/local/share/applications", "share/applications"] {
+        if let Some(p) = resolver_en_instancia(dir, tree, &format!("{d}/{nombre}")) {
+            return Some(p);
+        }
+    }
+    None
+}
+
+/// El icono referenciado por un `.desktop` puede ser una ruta o un NOMBRE de tema. Si es nombre, se
+/// busca en los sitios estándar de la imagen y se copia al host: un `.desktop` con un icono que el
+/// host no puede resolver se ve como un cuadrito gris, que es peor que no exportarlo.
+fn copiar_icono(dir: &Path, tree: &Path, icono: &str, destino_base: &Path) -> Option<(String, PathBuf)> {
+    if icono.starts_with('/') {
+        let src = resolver_en_instancia(dir, tree, icono)?;
+        let ext = src.extension().and_then(|e| e.to_str()).unwrap_or("png").to_string();
+        let nombre = format!("{MARCA}-{}", src.file_name()?.to_string_lossy());
+        let dst = destino_base.join(&nombre);
+        std::fs::create_dir_all(destino_base).ok()?;
+        std::fs::copy(&src, &dst).ok()?;
+        let _ = ext;
+        return Some((dst.display().to_string(), dst));
+    }
+    for tamano in ["scalable", "256x256", "128x128", "64x64", "48x48"] {
+        for ext in ["svg", "png"] {
+            let rel = format!("usr/share/icons/hicolor/{tamano}/apps/{icono}.{ext}");
+            if let Some(src) = resolver_en_instancia(dir, tree, &rel) {
+                let dst = destino_base.join(format!("{MARCA}-{icono}.{ext}"));
+                std::fs::create_dir_all(destino_base).ok()?;
+                if std::fs::copy(&src, &dst).is_ok() {
+                    return Some((dst.display().to_string(), dst));
+                }
+            }
+        }
+    }
+    for rel in [format!("usr/share/pixmaps/{icono}.png"), format!("usr/share/pixmaps/{icono}.svg")] {
+        if let Some(src) = resolver_en_instancia(dir, tree, &rel) {
+            let ext = if rel.ends_with("svg") { "svg" } else { "png" };
+            let dst = destino_base.join(format!("{MARCA}-{icono}.{ext}"));
+            std::fs::create_dir_all(destino_base).ok()?;
+            if std::fs::copy(&src, &dst).is_ok() {
+                return Some((dst.display().to_string(), dst));
+            }
+        }
+    }
+    None
+}
+
+fn export(root: &Path, id: &str, into: Option<&Path>, remove: bool) -> Result<()> {
+    let (dir, inst) = read_instance(root, id)?;
+    let ledger_path = dir.join(EXPORT_LEDGER);
+
+    if remove {
+        let previos: Vec<String> = std::fs::read_to_string(&ledger_path)
+            .ok()
+            .and_then(|s| serde_json::from_str(&s).ok())
+            .unwrap_or_default();
+        if previos.is_empty() {
+            println!("la instancia {id} no tiene nada exportado");
+            return Ok(());
+        }
+        for f in &previos {
+            match std::fs::remove_file(f) {
+                Ok(()) => println!("  borrado {f}"),
+                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+                Err(e) => eprintln!("  no pude borrar {f}: {e}"),
+            }
+        }
+        let _ = std::fs::remove_file(&ledger_path);
+        println!("{} fichero(s) revocados — la instancia sigue existiendo, ya no se ve", previos.len());
+        return Ok(());
+    }
+
+    if inst.export.binaries.is_empty() && inst.export.apps.is_empty() {
+        bail!(
+            "la instancia {id} no declara nada en `[export]`. Se exporta lo DECLARADO: exportar \
+             todo haría que el `ls` de la imagen compita con el nuestro, que es la falla de Bedrock."
+        );
+    }
+    let (_sha, tree) = resolve_image(root, &inst.base)?;
+
+    let prefijo = into.map(PathBuf::from).unwrap_or_else(|| {
+        PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into())).join(".local")
+    });
+    let bin_dir = prefijo.join("bin");
+    let app_dir = prefijo.join("share/applications");
+    let icon_dir = prefijo.join("share/icons/qorpa");
+    std::fs::create_dir_all(&bin_dir)?;
+
+    // La ruta ABSOLUTA de este mismo binario: un shim que dependiera del `PATH` del usuario haría
+    // que la app arranque o no según quién la lance.
+    let hammer = std::env::current_exe().context("no sé cuál es mi propia ruta")?;
+    let raiz_flag = if root == Path::new(DEFAULT_ROOT) {
+        String::new()
+    } else {
+        format!(" --root {}", shell_quote(&root.display().to_string()))
+    };
+
+    let mut escritos: Vec<String> = Vec::new();
+
+    for b in &inst.export.binaries {
+        let dst = bin_dir.join(b);
+        let cuerpo = format!(
+            "#!/bin/sh\n\
+             # GENERADO por `hammer qorpa export {id}` — {MARCA}. No editar: se reescribe.\n\
+             # Borralo (o corré `hammer qorpa export {id} --remove`) y la instancia deja de verse.\n\
+             exec {hammer} qorpa run{raiz_flag} {id} -- {b} \"$@\"\n",
+            hammer = shell_quote(&hammer.display().to_string()),
+            b = shell_quote(b),
+        );
+        std::fs::write(&dst, cuerpo)?;
+        std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o755))?;
+        escritos.push(dst.display().to_string());
+        println!("  bin      {}", dst.display());
+    }
+
+    for a in &inst.export.apps {
+        let src = buscar_desktop(&dir, &tree, a)
+            .with_context(|| format!("no encuentro {a} dentro de la instancia {id}"))?;
+        let texto = std::fs::read_to_string(&src)?;
+        let (campos, exec_original) = leer_desktop(&texto);
+
+        let nombre_bin = exec_original
+            .split_whitespace()
+            .next()
+            .map(|c| c.rsplit('/').next().unwrap_or(c).to_string())
+            .unwrap_or_else(|| a.trim_end_matches(".desktop").to_string());
+
+        let mut salida = String::from("[Desktop Entry]\n");
+        salida.push_str(&format!(
+            "# GENERADO por `hammer qorpa export {id}` — {MARCA}. El Exec de abajo es NUESTRO:\n\
+             # el original decía `{exec_original}` y no se copia (D5).\n"
+        ));
+        let mut icono_final = None;
+        for (k, v) in &campos {
+            if k.starts_with("Icon") {
+                if let Some((ruta, dst)) = copiar_icono(&dir, &tree, v, &icon_dir) {
+                    escritos.push(dst.display().to_string());
+                    icono_final = Some(ruta);
+                    continue; // se escribe abajo, ya resuelto a ruta del host
+                }
+            }
+            salida.push_str(&format!("{k}={v}\n"));
+        }
+        if let Some(i) = icono_final {
+            salida.push_str(&format!("Icon={i}\n"));
+        }
+        if !campos.iter().any(|(k, _)| k == "Type") {
+            salida.push_str("Type=Application\n");
+        }
+        salida.push_str(&format!(
+            "Exec={} qorpa run{raiz_flag} {id} -- {} %U\n",
+            shell_quote(&hammer.display().to_string()),
+            shell_quote(&nombre_bin),
+        ));
+        salida.push_str(&format!("X-Qorpa-Instance={id}\n"));
+
+        std::fs::create_dir_all(&app_dir)?;
+        let dst = app_dir.join(src.file_name().unwrap());
+        std::fs::write(&dst, salida)?;
+        escritos.push(dst.display().to_string());
+        println!("  desktop  {}", dst.display());
+    }
+
+    std::fs::write(&ledger_path, serde_json::to_string_pretty(&escritos)?)?;
+    println!(
+        "{} fichero(s) generados desde {} — inspeccionables con `cat`, revocables con --remove",
+        escritos.len(), src_label(&dir)
+    );
+    if !bin_dir.display().to_string().is_empty() {
+        println!("  ⚠ que `{}` esté en tu PATH es cosa tuya", bin_dir.display());
+    }
+    Ok(())
+}
+
+fn src_label(dir: &Path) -> String {
+    dir.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string())
+}
+
+/// Parsea el `[Desktop Entry]` de un `.desktop` y devuelve (campos permitidos, el `Exec` original).
+/// El `Exec` se devuelve SÓLO para nombrar el binario y para dejar dicho en el fichero generado qué
+/// decía el ajeno — nunca se emite.
+fn leer_desktop(texto: &str) -> (Vec<(String, String)>, String) {
+    let mut campos = Vec::new();
+    let mut exec = String::new();
+    let mut dentro = false;
+    for l in texto.lines() {
+        let l = l.trim();
+        if l.starts_with('[') {
+            dentro = l == "[Desktop Entry]";
+            continue;
+        }
+        if !dentro || l.is_empty() || l.starts_with('#') {
+            continue;
+        }
+        let Some((k, v)) = l.split_once('=') else { continue };
+        let (k, v) = (k.trim(), v.trim());
+        if k == "Exec" && exec.is_empty() {
+            exec = v.to_string();
+            continue;
+        }
+        // La clave puede venir localizada (`Name[es]`): se compara la parte antes del corchete.
+        let base = k.split('[').next().unwrap_or(k);
+        if DESKTOP_OK.contains(&base) {
+            campos.push((k.to_string(), v.to_string()));
+        }
+    }
+    (campos, exec)
+}
+
+/// Comillas para `sh`. Un shim se lee y se ejecuta: una ruta con espacios no puede romperlo.
+fn shell_quote(s: &str) -> String {
+    if !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"@%+=:,./-_".contains(&b)) {
+        s.to_string()
+    } else {
+        format!("'{}'", s.replace('\'', r"'\''"))
+    }
+}
+
 // ── list ────────────────────────────────────────────────────────────────────────────────────────
 
 fn list(root: &Path, json: bool) -> Result<()> {
@@ -1457,6 +1748,31 @@ mod tests {
         }
     }
 
+    #[test]
+    fn del_desktop_ajeno_no_sobrevive_nada_ejecutable() {
+        let (campos, exec) = leer_desktop(
+            "[Desktop Entry]\nName=Pinentry\nName[es]=Pinentry\nExec=/usr/bin/pinentry-qt --raro\n\
+             TryExec=/usr/bin/pinentry-qt\nDBusActivatable=true\nPath=/tmp\nIcon=pinentry\n\
+             X-KDE-Wayland-Interfaces=org_kde_kwin_keystate\n[Desktop Action Otra]\nExec=/mal\n",
+        );
+        let claves: Vec<&str> = campos.iter().map(|(k, _)| k.as_str()).collect();
+        assert!(claves.contains(&"Name") && claves.contains(&"Name[es]") && claves.contains(&"Icon"));
+        // Lista BLANCA: todo lo que ejecuta queda fuera por definición, no por enumeración.
+        for prohibida in ["Exec", "TryExec", "Path", "DBusActivatable", "X-KDE-Wayland-Interfaces"] {
+            assert!(!claves.contains(&prohibida), "se coló {prohibida}: {claves:?}");
+        }
+        // El Exec se lee sólo para nombrar el binario y citarlo; nunca se emite.
+        assert_eq!(exec, "/usr/bin/pinentry-qt --raro");
+    }
+
+    #[test]
+    fn el_shim_no_se_rompe_con_rutas_raras() {
+        assert_eq!(shell_quote("/usr/bin/pacman"), "/usr/bin/pacman");
+        assert_eq!(shell_quote("/con espacio/x"), "'/con espacio/x'");
+        assert_eq!(shell_quote("a'b"), "'a'\\''b'"); // la comilla se cierra, se escapa y se reabre
+        assert_eq!(shell_quote("; rm -rf /"), "'; rm -rf /'");
+    }
+
     #[test]
     fn sha_mal_formado_falla_antes_de_tocar_la_red() {
         assert!(normalize_sha256("abc").is_err());
diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md
index 2a507bf3..8a2e569b 100644
--- a/docs/adr/0015-imagenes-ajenas.md
+++ b/docs/adr/0015-imagenes-ajenas.md
@@ -200,9 +200,20 @@ la hidratación del escritorio ya va por lista explícita (`TARGETS="…"`), nun
 `ajeno`**, y el reporte pasa a decir:
 
 ```
-escritorio-kde   171 nativas + 6 ajenas    (ajenas: sin procedencia de fuente, sin repro)
+escritorio-kde       187/188 listo   falta   1  (raíces 14, + 1 ajenas)
 ```
 
+Así sale hoy, de verdad: el `xwayland` que era `wanted` (deuda: receta por escribir) pasó a `ajeno`
+y **la deuda se disolvió sin escribir la receta**. Dos decisiones que sostienen la cifra:
+
+- **Los ajenos se restan del denominador.** Si entraran en la fracción, el número que todo el mundo
+  lee como «cuánto construimos» crecería solo cada vez que alguien enjaula una app ajena.
+- **La declaración vive en `docs/state/qorpa-ajenos.toml`, en el repo — no se lee de
+  `/var/lib/hammer`.** `build-state.json` se commitea y lo regenera el cron en dos máquinas: si la
+  clase saliera de las instancias instaladas, cada una diría algo distinto y se pisarían en cada
+  cosecha. Es el mismo error que ya se cometió con `sealed_remoto`. **Qué provee una imagen ajena es
+  diseño, compartido; qué instancias tiene tu disco, no.**
+
 Bedrock no puede decirte qué tenés. Nosotros sí, y esto cierra el riesgo real de todo el ADR: que en
 seis meses alguien cuente esas apps como parte del corpus. **En otra clase no se pueden contar mal.**
 
@@ -421,7 +432,12 @@ Se escriben acá para que no se descubran en producción.
    eslabón dentro de bwrap (cruza un binario **estático**, no una librería ⇒ D2 sigue en pie), y
    aporta lo que bwrap no da: **seccomp** —hoy una instancia podría `io_uring`, `bpf`, `ptrace`,
    `userfaultfd`, `keyctl`, `perf_event_open`—, `no_new_privs`, y el canal de evidencia. Ver D9.
-5. Shims + `.desktop` generados (`export`), y clase `ajeno` en `build-state.py`.
+5. ✅ **HECHO 2026-09-03.** Shims + `.desktop` generados (`export`), y clase `ajeno` en
+   `build-state.py`. El `.desktop` se genera con lista BLANCA de claves —`Exec`, `TryExec`, `Path`,
+   `DBusActivatable` quedan fuera **por definición**, no por enumeración— y el icono se copia al
+   host, porque un icono que el host no resuelve se ve como un cuadrito gris. Cada fichero escrito
+   queda en un registro (`exported.json`) para que `--remove` borre **exactamente** lo generado y no
+   por patrón sobre el `~/.local/bin` de alguien.
 6. **Steam de punta a punta**, con verificación explícita del bwrap anidado.
 7. Poda (`hammer qorpa prune`) y renglón en SDD 20 sobre licencias.
 8. Proxy filtrante de Wayland — ticket propio, el más valioso de la lista.
diff --git a/docs/state/build-state-kde.json b/docs/state/build-state-kde.json
index 624c0e3c..7347c865 100644
--- a/docs/state/build-state-kde.json
+++ b/docs/state/build-state-kde.json
@@ -5,15 +5,15 @@
   "nodes": 988,
   "sealed": 986,
   "debt": 1,
-  "wanted": 1
+  "ajeno": 1
  },
  "by_class": {
+  "ajeno": 1,
   "c": 354,
   "go": 362,
   "gui": 36,
   "kernel": 6,
-  "rust": 229,
-  "wanted": 1
+  "rust": 229
  },
  "debt_by_class": {
   "c": 1
@@ -25,7 +25,7 @@
   },
   "incoming-kde": {
    "sealed": 198,
-   "wanted": 1
+   "ajeno": 1
   }
  },
  "by_profile": {
@@ -41,9 +41,10 @@
   },
   "escritorio-kde": {
    "raices": 14,
-   "clausura": 188,
+   "clausura": 189,
    "sealed": 187,
-   "debt": 1
+   "debt": 1,
+   "ajeno": 1
   },
   "escritorio-mirada": {
    "raices": 14,
@@ -51,7 +52,7 @@
    "sealed": 29
   }
  },
- "sin_perfil": 712,
+ "sin_perfil": 711,
  "orphan_deps": [],
  "topo_ok": true,
  "nodes": {
@@ -21812,13 +21813,16 @@
    "link": null,
    "compiler": null,
    "deps": [],
-   "cls": "wanted",
+   "cls": "ajeno",
    "queue": "incoming-kde",
+   "imagen": "arch",
    "hash": null,
-   "state": "wanted",
+   "state": "ajeno",
    "blocked_by": [],
    "unblocks": 0,
-   "perfiles": [],
+   "perfiles": [
+    "escritorio-kde"
+   ],
    "dependientes_total": 0
   },
   "xwin": {
diff --git a/docs/state/build-state.json b/docs/state/build-state.json
index 9ccd5f90..b154ff5f 100644
--- a/docs/state/build-state.json
+++ b/docs/state/build-state.json
@@ -2,24 +2,23 @@
  "schema": "hammer-build-state/1",
  "totals": {
   "recipes": 803,
-  "nodes": 803,
-  "sealed": 802,
-  "debt": 1
+  "nodes": 804,
+  "sealed": 803,
+  "ajeno": 1
  },
  "by_class": {
+  "ajeno": 1,
   "c": 174,
   "go": 362,
   "gui": 32,
   "kernel": 6,
   "rust": 229
  },
- "debt_by_class": {
-  "c": 1
- },
+ "debt_by_class": {},
  "by_queue": {
   "corpus": {
-   "sealed": 802,
-   "debt": 1
+   "sealed": 803,
+   "ajeno": 1
   }
  },
  "by_profile": {
@@ -237,7 +236,7 @@
    "hash": "b3:73fb200aae06b0743035dc9f961121fc94295e80a12d39dc358920d698b7bfe0",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -3542,7 +3541,7 @@
    "hash": "b3:117ee7e8fd5a41bd936d723c72c95cd3f8290292b52ba8088cc0701be170597b",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "base",
     "cli",
@@ -3751,7 +3750,7 @@
    "hash": "b3:bfef7bf3a85032167ad45e40b420735d08ee13d7b27af46e2cf72768e8460f6e",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -3920,7 +3919,7 @@
    "hash": "b3:9c4ed8cd793d3ceb1b68e8878efd380f1565a54d728975d108fea7ef9da52a44",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -4041,7 +4040,7 @@
    "hash": "b3:6ce0c82fd108d262643a8c8fe395af309b7ceb78cfe91aa77c1693bfa8f339d8",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -4104,7 +4103,7 @@
    "hash": "b3:5503952cb0c35e16854f641873538580e56916c545682b1ed09a9ccaad3a2913",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -4754,7 +4753,7 @@
    "hash": "b3:74ade6a67d6a0938d6cba154d2a3d06e647efd872bdc02ada0860214c7b321c4",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -6005,7 +6004,7 @@
    "hash": "b3:846c900dad69f5435007cf11640bb97d3fde02b9bf5b8194994e127a74208058",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -7796,7 +7795,7 @@
    "hash": "b3:213df6b1501576c977489dc429bbd60aad9f5270ddb6b322666e816fa3b51787",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -7866,7 +7865,7 @@
    "hash": "b3:bbe561b7869d7ec30aa7f194e812df81e28a98e9f0ac5d114e0dc13d08829749",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -7954,7 +7953,7 @@
    "hash": "b3:1df7e7a3eac63694f8d094646cfc231f3843995f6f9f945cda138760f6bc9838",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -8160,7 +8159,7 @@
    "hash": "b3:4346feca8db5ed53a13f78ac34131eebb1b7bf70386b70965e92ea50fdbe6188",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -8183,7 +8182,7 @@
    "hash": "b3:dc75cb3d42f9c53425014999cc04586ee697fa790d9c28680b79f26bd85489bc",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -8394,7 +8393,7 @@
    "hash": "b3:a01fb6bd9a19847e9ec5361bfd22a5b32adbbee927e9c7c728d6f57fd8fcf258",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -8477,7 +8476,7 @@
    "hash": "b3:b7410a2bd1220d877954db9a19f94db322623506c7b558ff684a6390779c081b",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -8653,7 +8652,7 @@
    "hash": "b3:fd14c9c4d0081fd44503cbc762e8865413c240097448c5b1be236a285ea811b5",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "base",
     "cli",
@@ -8818,7 +8817,7 @@
    "hash": "b3:c63de2f3188d26b14f1de47a3044eeccd76a5d755b3599fbff8bd289cc2c6f21",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -9205,7 +9204,7 @@
    "hash": "b3:2ed75159165da8c1d96ee6b15a3cbc9850ff818f93add282fafd49d00a49a7d0",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -9293,7 +9292,7 @@
    "hash": "b3:6564ae63c23ca73b0738e0708dfde01cf965ff9bd1c5950d7376fa1659fd5058",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "base",
     "cli",
@@ -9625,7 +9624,7 @@
    "cls": "c",
    "queue": "corpus",
    "hash": "b3:392e96904b8f6c2b9aafee2b3b9354e7def0cf72cab91ac00215423952d1e61b",
-   "state": "debt",
+   "state": "sealed",
    "blocked_by": [],
    "unblocks": 0,
    "perfiles": [
@@ -10630,7 +10629,7 @@
    "hash": "b3:c4930964fbaf4f33365e6eb8d7e6069ae3056b5796fca83d254bee2a88a341bc",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "base",
     "cli",
@@ -10794,7 +10793,7 @@
    "hash": "b3:e240653adae656318d5c3445b36ffb37aa491589b5c34c8688515bc3806944ab",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -10857,7 +10856,7 @@
    "hash": "b3:cfb6a37eb990fb001dd66d5c2103e80cfa858b49aaa60a397ba0356830cc09b4",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "base",
     "cli",
@@ -11187,7 +11186,7 @@
    "hash": "b3:ff5cf5e5080f20587b26946d0dfff36a58c9096d30eace4ae9cece24594d171e",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "base",
     "cli",
@@ -11742,7 +11741,7 @@
    "hash": "b3:2f75c0e225a60fd56f7e415b7a739a461c97695c2e3f015c9af576d8a4a2492c",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "base",
     "cli",
@@ -13996,7 +13995,7 @@
    "hash": "b3:51116f72a799ceaf8bc9d5512ec5dc413865a6376e2285e550af10046572287b",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -14023,7 +14022,7 @@
    "hash": "b3:9f81e41cbe8cb202121d8b9d697c7334a2c5d58bbf7cf7734eded9878c5db303",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
@@ -14338,6 +14337,24 @@
    "perfiles": [],
    "dependientes_total": 0
   },
+  "xwayland": {
+   "iname": "xwayland",
+   "version": null,
+   "link": null,
+   "compiler": null,
+   "deps": [],
+   "cls": "ajeno",
+   "queue": "corpus",
+   "imagen": "arch",
+   "hash": null,
+   "state": "ajeno",
+   "blocked_by": [],
+   "unblocks": 0,
+   "perfiles": [
+    "escritorio-kde"
+   ],
+   "dependientes_total": 0
+  },
   "xwin": {
    "iname": "xwin",
    "version": "0.7.0",
@@ -14516,7 +14533,7 @@
    "hash": "b3:dc363f261a71239412f6090e50e8abc0c7f6e544ccefa48c5ee5f47d26e84ef3",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "base",
     "cli",
@@ -14558,7 +14575,7 @@
    "hash": "b3:b7d5397b96fc080a6e4ee9a19f5ea5ee32a63148ef87c63c795955676c5f344f",
    "state": "sealed",
    "blocked_by": [],
-   "unblocks": 1,
+   "unblocks": 0,
    "perfiles": [
     "escritorio-cosmic",
     "escritorio-gnome",
diff --git a/docs/state/qorpa-ajenos.toml b/docs/state/qorpa-ajenos.toml
new file mode 100644
index 00000000..545bd8c1
--- /dev/null
+++ b/docs/state/qorpa-ajenos.toml
@@ -0,0 +1,24 @@
+# Nodos que el corpus NO provee y NO va a proveer: los trae una imagen ajena enjaulada
+# (qorpa, ADR 0015 D5 capa 3). Este fichero existe para que NADIE los pueda contar mal.
+#
+# ── POR QUÉ ESTO ES UN FICHERO DEL REPO Y NO UNA LECTURA DE /var/lib/hammer ──────────────────────
+# `build-state.json` se commitea y lo regenera el cron en dos máquinas. Si la clase `ajeno` saliera
+# de las instancias que cada máquina tiene instaladas, el grafo diría cosas distintas en cada una y
+# se pisarían en cada cosecha — el mismo error que ya se cometió con `sealed_remoto`. Qué provee una
+# imagen ajena es una decisión de DISEÑO, compartida; qué instancias tiene tu disco, no.
+#
+# ── QUÉ HACE UN NODO ACÁ ────────────────────────────────────────────────────────────────────────
+# Deja de ser `wanted` (deuda: receta por escribir) y pasa a `ajeno`: presente, pero SIN procedencia
+# de fuente y SIN reproducibilidad. No cuenta como deuda ni como corpus. El reporte los suma aparte
+# («171 nativas + 6 ajenas») precisamente para que la cifra del corpus no se infle sola.
+#
+# NO entran al catálogo publicable ni al reporte de licencias (SDD 20): no podemos enumerarlas.
+
+[xwayland]
+razon = """
+X11 está fuera de alcance para toda la distro (Wayland-only, sin systemd) y Xwayland tampoco salva
+al herramental X11, que sólo ve clientes X. Con instancias, el Xwayland vive DENTRO de la imagen
+—Arch y Fedora ya lo traen— y se cuelga de nuestro kwin por el socket. La receta no se escribe.
+"""
+imagen = "arch"
+perfiles = ["escritorio-kde"]
diff --git a/docs/state/targets.toml b/docs/state/targets.toml
index ba6a668e..9683b22b 100644
--- a/docs/state/targets.toml
+++ b/docs/state/targets.toml
@@ -63,11 +63,13 @@ paquetes = [
   # roto: sin tipos de fichero, sin apps X11, sin diálogos de autorización y con los controles QML
   # en un estilo genérico. Ver docs/state/frontera-triaje.toml para el porqué de cada uno.
   "shared-mime-info", "xwayland", "polkit-qt-1", "qqc2-breeze-style",
-  # ⚠ `xwayland` sigue acá pero su receta NO se escribe todavía: [ADR 0015 — Imágenes ajenas]
-  # (../adr/0015-imagenes-ajenas.md, PROPUESTO 2026-09-03) propone que el Xwayland viva DENTRO de
-  # la imagen ajena —Fedora y Arch ya lo traen y se cuelga de kwin por el socket— con lo cual este
-  # hueco se disuelve sin receta y sin tocar la postura Wayland-only. Si el ADR se acepta, sale de
-  # esta lista. Los otros tres ya están sellados (2026-09-02/03).
+  # ⚠ `xwayland` sigue acá como RAÍZ pero su receta NO se va a escribir: lo provee una imagen ajena
+  # enjaulada, declarado en ../state/qorpa-ajenos.toml (ADR 0015 D5 capa 3, qorpa). El Xwayland vive
+  # DENTRO de la imagen —Arch y Fedora ya lo traen y se cuelga de kwin por el socket— así que el
+  # hueco se disuelve sin receta y sin tocar la postura Wayland-only. Queda como raíz A PROPÓSITO:
+  # la imagen lo NECESITA, y sacarlo de acá lo volvería invisible en vez de contado aparte. El grafo
+  # ya lo reporta con clase `ajeno` («+ 1 ajenas»), nunca sumado a las nativas. Los otros tres ya
+  # están sellados (2026-09-02/03).
   # ── LA PRIMERA APP DE USUARIO FINAL QUE COMPARTEN LOS CUATRO ESCRITORIOS ──────────────────────
   # `mpv` vive en el CORPUS, no en esta cola, justamente para poder listarse en las cuatro: una
   # receta resuelve sibling-first y después el catálogo padre, así que desde acá se alcanza.
diff --git a/scripts/build-state.py b/scripts/build-state.py
index a7292c17..af16b42e 100755
--- a/scripts/build-state.py
+++ b/scripts/build-state.py
@@ -240,6 +240,22 @@ def cargar_perfiles(colas_activas):
     return {n: p for n, p in todos.items() if p["cola"] in colas_activas}
 
 
+def cargar_ajenos():
+    """Nodos provistos por una imagen ajena (qorpa, ADR 0015 D5 capa 3).
+
+    Degrada igual que `cargar_perfiles`: este script lo corre el cron cada 30 minutos y no puede
+    caerse porque alguien dejó un TOML a medias."""
+    f = ROOT / "docs/state/qorpa-ajenos.toml"
+    if not f.exists():
+        return {}
+    try:
+        import tomllib
+        return tomllib.loads(f.read_text())
+    except Exception as e:
+        print(f"   ⚠ qorpa-ajenos.toml inválido ({e}) — grafo sin nodos ajenos", file=sys.stderr)
+        return {}
+
+
 def clausura(raices, recs):
     """Nodos alcanzables desde `raices` siguiendo las aristas name→dep. Un nodo `wanted` CORTA la
     exploración (no se conocen sus deps hasta construirlo): de ahí que la clausura sea cota inferior."""
@@ -261,6 +277,23 @@ def main():
     # de buscar huérfanas a propósito: un objetivo declarado no es una arista colgando.
     colas = {"corpus"} | {os.path.basename(q) for q in EXTRA_QUEUES}
     perfiles = cargar_perfiles(colas)
+
+    # AJENOS ANTES QUE WANTED, y el orden es la mitad del punto: un nodo que provee una imagen
+    # ajena NO es una receta por escribir. Si se inyectara después, `xwayland` quedaría contado
+    # como deuda para siempre — y la deuda fantasma es un error que este repo ya pagó caro.
+    ajenos = cargar_ajenos()
+    for nombre, a in sorted(ajenos.items()):
+        if nombre in recs:
+            # Hay receta de verdad: la receta gana y el declarado sobra. Decirlo, no taparlo.
+            print(f"   ⚠ {nombre} está declarado ajeno pero TIENE receta — gana la receta",
+                  file=sys.stderr)
+            continue
+        colas_de = {perfiles[pn]["cola"] for pn in a.get("perfiles", []) if pn in perfiles}
+        recs[nombre] = dict(path=None, ajeno=True, iname=nombre, version=None, link=None,
+                            compiler=None, deps=[], cls="ajeno",
+                            queue=(sorted(colas_de) or ["corpus"])[0],
+                            imagen=a.get("imagen"))
+
     for pn, p in sorted(perfiles.items()):
         for raiz in p["raices"]:
             if raiz not in recs:
@@ -273,12 +306,16 @@ def main():
             if dep not in recs:
                 orphans.add(dep)
 
-    n_recetas = sum(1 for r in recs.values() if not r.get("wanted"))
+    n_recetas = sum(1 for r in recs.values() if not r.get("wanted") and not r.get("ajeno"))
     print(f"== {n_recetas} recetas; computando hash vigente (~{n_recetas*18//1000}s)…", file=sys.stderr)
     for n, r in recs.items():
         # `wanted` no tiene fichero que hashear: es el objetivo, no una receta.
         if r.get("wanted"):
             r["hash"] = None; r["state"] = "wanted"; continue
+        # `ajeno` tampoco, y por una razón más fuerte: no tiene procedencia de fuente. Un hash
+        # afirmaría que lo reproducimos, y no lo reproducimos.
+        if r.get("ajeno"):
+            r["hash"] = None; r["state"] = "ajeno"; continue
         if "parse_error" in r:
             r["hash"] = None; r["state"] = "unhashable"; continue
         h = vigente_hash(r["path"])
@@ -311,6 +348,12 @@ def main():
     for n, r in recs.items():
         clave = (r.get("queue", "corpus"), n)
         r["perfiles"] = sorted(memb.get(clave, ()))
+        # yupana calcula la membresía sobre las RECETAS del disco, y un ajeno no tiene ninguna: sin
+        # esto quedaría fuera de la clausura de su imagen y el reporte no lo mencionaría, que es
+        # justo el descuido que la clase `ajeno` existe para impedir. La declaración dice a qué
+        # imagen sirve; es autoría, igual que la política de D7.
+        if r.get("ajeno"):
+            r["perfiles"] = sorted(set(r["perfiles"]) | set(ajenos.get(n, {}).get("perfiles", [])))
         # Dependientes inversos DEL GRAFO ENTERO: cuántas recetas (de cualquier cola) me consumen. Es
         # el radio real de un re-hash. `unblocks` (abajo) es local a la deuda de esta vista; este no.
         r["dependientes_total"] = len(rev.get(clave, ()))
@@ -354,7 +397,7 @@ def main():
         sin_perfil=sin_perfil,
         orphan_deps=sorted(orphans),
         topo_ok=order is not None,
-        nodes={n: {k: v for k, v in r.items() if k not in ("path", "wanted")}
+        nodes={n: {k: v for k, v in r.items() if k not in ("path", "wanted", "ajeno")}
                for n, r in sorted(recs.items())},
     )
     OUT.parent.mkdir(parents=True, exist_ok=True)
@@ -362,7 +405,7 @@ def main():
 
     # Resumen humano a stdout.
     print(f"\n== estado del corpus ({n_recetas} recetas) → {OUT.relative_to(ROOT)}")
-    for s in ("sealed", "debt", "never", "unhashable", "wanted"):
+    for s in ("sealed", "debt", "never", "unhashable", "wanted", "ajeno"):
         if by_state.get(s):
             print(f"   {s:11} {by_state[s]}")
     if REMOTO_ONLY:
@@ -381,9 +424,16 @@ def main():
         print("\n== objetivo por imagen (docs/state/targets.toml) — clausura = COTA INFERIOR")
         for pn, c in by_profile.items():
             falta = c.get("debt", 0) + c.get("never", 0) + c.get("unhashable", 0) + c.get("wanted", 0)
-            print(f"   {pn:20} {c.get('sealed',0):3}/{c['clausura']:3} listo   falta {falta:3}"
+            # El denominador es el CORPUS de la imagen: los ajenos se restan y se dicen aparte. Si
+            # entraran en la fracción, la cifra que todo el mundo lee como "cuánto construimos"
+            # crecería sola cada vez que alguien enjaula una app ajena.
+            propios = c["clausura"] - c.get("ajeno", 0)
+            print(f"   {pn:20} {c.get('sealed',0):3}/{propios:3} listo   falta {falta:3}"
                   f"  (raíces {c['raices']}"
-                  + (f", sin receta {c['wanted']}" if c.get("wanted") else "") + ")")
+                  + (f", sin receta {c['wanted']}" if c.get("wanted") else "")
+                  # Se dicen APARTE, nunca sumadas a las nativas: son la parte del sistema sin
+                  # procedencia de fuente y sin reproducibilidad (ADR 0015 §NO-resuelve 3).
+                  + (f", + {c['ajeno']} ajenas" if c.get("ajeno") else "") + ")")
         print(f"   {sin_perfil} recetas no las alcanza ninguna imagen (catálogo, no distro)")
     print(f"\n   grafo: {'CIERRA' if not orphans else f'{len(orphans)} deps huérfanas'} | "
           f"topo-sort: {'OK' if order else 'CICLO'}")