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…

paquetería: zsh se puede instalar — los parches múltiples viajaban unidos y el target_bin era una adivinanza

Cerrar el lazo servir→instalar sobre `zsh` (pedido del usuario) destapó dos fallos de la familia
del `strip_debug` del SDD 28 §5.2: cosas que el `.swm` no transportaba fiel y que sólo se ven
cerrando el lazo, no leyendo el código.

1. `pack` CONCATENABA los N parches en el `patch` inline único, y `hash_inputs` mete una entrada
   por parche (con `of_inputs` length-prefijado) ⇒ el receptor sellaba en otra dirección y el
   `expected_hash` no coincidía NUNCA. Medido: zsh anclado b3:0be3630d…, reproducido b3:d6329a62…
   (idéntico al de una copia de la receta con sus 6 parches concatenados a mano). Afectaba a las
   16 recetas del corpus con ≥2 parches. Ahora viajan como lista `patches`, en orden, un fichero
   por parche del lado del receptor; el `patch` único se sigue leyendo para no invalidar lo
   publicado. Regresión con control negativo en swm_bridge.

2. `target_bin` se adivinaba `/usr/bin/{name}`; zsh instala en `/bin` (--bindir=/bin), así que el
   paquete prometía un path que el artefacto no tiene y el error salía en el cliente DESPUÉS de
   hidratar 1331 ficheros. Con `--build` el artefacto está delante: se comprueba, se corrige
   diciéndolo, y si no hay ningún binario con ese nombre se ABORTA al publicar.

Y el verbo que faltaba para «avisame cuándo actualizar»: `takana outdated` compara la DB de
instalados con el índice firmado (por hash, no por versión: una re-publicación con la misma
etiqueta es otro artefacto). No construye, no baja .swm y no actualiza — instalar es verificar.

Medido tras el arreglo: install zsh ⇒ cache-hit del hash anclado, 1331 ficheros en 0,14 s, zsh 5.9
corriendo.

9fe1e010e21199c12896ff156f85884a83f04ce6 · by Sergio · (1 month ago) · parent c5a27d9b4 · browse the tree at this commit

Semantic delta · 34

crates/hammerd/src/bus.rs

crates/takana-agent/src/orchestrator.rs

crates/takana-agent/tests/orchestrator_evidence_gate.rs

crates/takana-cli/src/main.rs

crates/takana-core/src/compat.rs

8 files · +548 −62

diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs
index 5d036511..79e8fc6e 100644
--- a/crates/hammerd/src/bus.rs
+++ b/crates/hammerd/src/bus.rs
@@ -369,7 +369,10 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
         tarball: recipe.tarball,
         sha256: recipe.sha256,
         strip_components: None,
+        // El proto del bus transporta UN parche inline (`RecipeInline.patch`); el orquestador
+        // corta antes si el `.swm` traía varios (ver `Mutation::SourcePatch::patches`).
         patch: recipe.patch,
+        patches: Vec::new(),
         patch_url: None,
         build: takana_core::swm::SwmBuild {
             compiler: recipe.compiler,
diff --git a/crates/takana-agent/src/orchestrator.rs b/crates/takana-agent/src/orchestrator.rs
index 5afd4159..8f8bbaff 100644
--- a/crates/takana-agent/src/orchestrator.rs
+++ b/crates/takana-agent/src/orchestrator.rs
@@ -165,6 +165,7 @@ impl<T: IntentTranslator> Orchestrator<T> {
                     sha256,
                     strip_components: _,
                     patch,
+                    patches,
                     patch_url: _,
                     build,
                     target_bin,
@@ -185,13 +186,28 @@ impl<T: IntentTranslator> Orchestrator<T> {
                         .and_then(|s| s.to_str())
                         .unwrap_or("swm-bin")
                         .to_string();
+                    // El proto del bus (`RecipeInline.patch`) transporta UN parche. Con dos o más
+                    // no se puede reproducir por esta vía: mandarlos concatenados sellaría en otra
+                    // dirección (ver `Mutation::SourcePatch::patches`), así que se corta diciéndolo
+                    // en vez de compilar algo que va a fallar contra el `expected_hash`.
+                    let patch_inline = match (patches.len(), patch) {
+                        (0, p) => p.clone(),
+                        (1, _) => Some(patches[0].clone()),
+                        (n, _) => {
+                            return Err(Error::Orchestrate(format!(
+                                "source_patch {target_bin}: el .swm trae {n} parches y el bus de \
+                                 compilación sólo transporta uno; reproducilo con `takana apply` / \
+                                 `takana install`, que los materializan uno a uno"
+                            )))
+                        }
+                    };
                     let recipe = RecipeInline {
                         name: name.clone(),
                         repo: repo.clone(),
                         commit: commit.clone(),
                         tarball: tarball.clone(),
                         sha256: sha256.clone(),
-                        patch: patch.clone(),
+                        patch: patch_inline,
                         compiler: build.compiler.clone(),
                         target: build.target.clone(),
                         link: build.link.clone(),
@@ -625,6 +641,7 @@ mod tests {
                 sha256: None,
                 strip_components: None,
                 patch: None,
+                patches: Vec::new(),
                 patch_url: None,
                 build: takana_core::swm::SwmBuild {
                     compiler: "zig-cc".into(),
@@ -633,6 +650,7 @@ mod tests {
                     flags: vec![],
                     phases: Default::default(),
                     zig_version: None,
+                    strip_debug: None,
                 },
                 target_bin: "/bin/x".into(),
                 expected_hash: None,
diff --git a/crates/takana-agent/tests/orchestrator_evidence_gate.rs b/crates/takana-agent/tests/orchestrator_evidence_gate.rs
index f5831b9c..478be996 100644
--- a/crates/takana-agent/tests/orchestrator_evidence_gate.rs
+++ b/crates/takana-agent/tests/orchestrator_evidence_gate.rs
@@ -98,6 +98,7 @@ fn swm_with_evidence() -> Swm {
             sha256: None,
             strip_components: None,
             patch: None,
+            patches: Vec::new(),
             patch_url: None,
             build: SwmBuild {
                 compiler: "zig-cc".into(),
@@ -106,6 +107,7 @@ fn swm_with_evidence() -> Swm {
                 flags: vec![],
                 phases: Default::default(),
                 zig_version: None,
+                strip_debug: None,
             },
             target_bin: "/usr/bin/grep".into(),
             expected_hash: None,
diff --git a/crates/takana-build/src/swm_bridge.rs b/crates/takana-build/src/swm_bridge.rs
index 97c7bdfb..29c6bc49 100644
--- a/crates/takana-build/src/swm_bridge.rs
+++ b/crates/takana-build/src/swm_bridge.rs
@@ -73,13 +73,13 @@ pub fn recipe_from_source_patch(
     name: Option<&str>,
     catalog_dir: &Path,
 ) -> takana_core::Result<Recipe> {
-    let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, deps, evidence, slots) =
+    let (repo, commit, tarball, sha256, strip_components, patch, patch_list, patch_url, build_cfg, target_bin, deps, evidence, slots) =
         match mutation {
             Mutation::SourcePatch {
-                repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
+                repo, commit, tarball, sha256, strip_components, patch, patches, patch_url, build,
                 target_bin, deps, evidence, slots, ..
             } => (
-                repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
+                repo, commit, tarball, sha256, strip_components, patch, patches, patch_url, build,
                 target_bin, deps, evidence, slots,
             ),
             _ => {
@@ -115,23 +115,37 @@ pub fn recipe_from_source_patch(
         }
     };
 
+    // Los parches se materializan **uno por fichero y en el orden del manifiesto**: el lab los
+    // aplica en ese orden y `Recipe::hash_inputs` mete una entrada por fichero. Unirlos en uno
+    // solo sella en otra dirección — ver `Mutation::SourcePatch::patches`.
     let mut patches: Vec<String> = Vec::new();
-    if patch.is_some() || patch_url.is_some() {
+    if !patch_list.is_empty() || patch.is_some() || patch_url.is_some() {
         // Patch bajo un nombre derivado del nombre-de-receta + clave de origen, para no colisionar
         // entre `{dep}.patch` distintos en el mismo catálogo.
         let label = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
         // `source.patches` guarda el NOMBRE relativo a `base_dir` (= catalog_dir); el lab resuelve
         // `base_dir.join(nombre)` UNA vez al construir (fetch::apply_patches / Recipe hash). Guardar el
         // path completo acá lo haría re-unir a base_dir aguas abajo (doble/triple prefijo).
-        let filename = format!("{label}-{source_key}.patch");
-        let patch_path = catalog_dir.join(&filename);
-        if let Some(text) = patch {
-            std::fs::write(&patch_path, text.as_bytes())?;
-        } else if let Some(url) = patch_url {
-            tracing::info!(%url, "fetch: descargando patch remoto");
-            crate::download::fetch_url_to_file(url, &patch_path)?;
+        if !patch_list.is_empty() {
+            // El índice va en el nombre para conservar el orden Y para no pisar un parche con otro
+            // cuando la receta trae varios del mismo origen.
+            for (i, text) in patch_list.iter().enumerate() {
+                let filename = format!("{label}-{source_key}-{i:02}.patch");
+                std::fs::write(catalog_dir.join(&filename), text.as_bytes())?;
+                patches.push(filename);
+            }
+        } else {
+            // Formato histórico: un único parche inline (o remoto).
+            let filename = format!("{label}-{source_key}.patch");
+            let patch_path = catalog_dir.join(&filename);
+            if let Some(text) = patch {
+                std::fs::write(&patch_path, text.as_bytes())?;
+            } else if let Some(url) = patch_url {
+                tracing::info!(%url, "fetch: descargando patch remoto");
+                crate::download::fetch_url_to_file(url, &patch_path)?;
+            }
+            patches.push(filename);
         }
-        patches.push(filename);
     }
 
     let name = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
@@ -290,7 +304,7 @@ logo = "b3:azul"
 "#;
         let recipe = takana_core::Recipe::from_toml(toml).unwrap();
         let swm =
-            takana_core::Swm::from_recipe(&recipe, "/usr/bin/tema", None, None, "2026-06-06").unwrap();
+            takana_core::Swm::from_recipe(&recipe, "/usr/bin/tema", Vec::new(), None, "2026-06-06").unwrap();
         // El .swm YAML debe llevar el bloque slots.
         let yaml = swm.to_yaml().unwrap();
         assert!(yaml.contains("slots"), "el .swm debe transportar slots: {yaml}");
@@ -388,6 +402,98 @@ logo = "b3:azul"
         }
     }
 
+    /// Regresión del 2026-09-21: **una receta con VARIOS parches tiene que reproducir su hash**.
+    ///
+    /// `pack` concatenaba los N parches en el `patch` inline único, y `Recipe::hash_inputs` mete
+    /// **una entrada por parche** (con `of_inputs` length-prefijado) ⇒ el receptor sellaba en otra
+    /// dirección y el `expected_hash` anclado no coincidía NUNCA. Medido sobre `zsh` (6 parches):
+    /// anclado `b3:0be3630d…`, reproducido `b3:d6329a62…`. Afectaba a 16 recetas del corpus.
+    ///
+    /// El test compara los `hash_inputs` —no el hash final— porque así, cuando falle, dice *qué*
+    /// entrada se movió en vez de sólo que dos hex difieren. Lleva su **control negativo**: con
+    /// los parches concatenados (el formato viejo) los inputs TIENEN que diferir, o el test
+    /// pasaría por la razón equivocada.
+    #[test]
+    fn varios_parches_viajan_sueltos_y_el_hash_reproduce() {
+        let d = tempfile::tempdir().unwrap();
+        let recipes = d.path().join("recipes");
+        std::fs::create_dir_all(&recipes).unwrap();
+        std::fs::write(recipes.join("uno.patch"), b"--- a/uno\n+++ b/uno\n").unwrap();
+        std::fs::write(recipes.join("dos.patch"), b"--- a/dos\n+++ b/dos\n").unwrap();
+
+        let toml = r#"
+name    = "multi"
+version = "1.0"
+[source]
+tarball = "https://x/multi-1.0.tar.gz"
+sha256  = "abcdef0123456789"
+patches = ["uno.patch", "dos.patch"]
+[build]
+compiler = "zig-cc"
+target   = "x86_64-linux-musl"
+link     = "static"
+"#;
+        let mut original = Recipe::from_toml(toml).unwrap();
+        original.base_dir = recipes.clone();
+
+        let textos: Vec<String> = original
+            .source
+            .patches
+            .iter()
+            .map(|p| std::fs::read_to_string(recipes.join(p)).unwrap())
+            .collect();
+        let swm =
+            takana_core::Swm::from_recipe(&original, "/usr/bin/multi", textos.clone(), None, "dev")
+                .unwrap();
+
+        let catalog = d.path().join("swm-recipes");
+        let rehecha = recipe_from_source_patch(&swm.mutations[0], Some("multi"), &catalog).unwrap();
+        assert_eq!(
+            rehecha.source.patches.len(),
+            2,
+            "los parches tienen que llegar SUELTOS, no unidos: {:?}",
+            rehecha.source.patches
+        );
+
+        let lab = takana_core::lab::LabFingerprint::for_tests();
+        assert_eq!(
+            original.hash_inputs(&[], &lab).unwrap(),
+            rehecha.hash_inputs(&[], &lab).unwrap(),
+            "la receta rehecha desde el .swm debe hashear igual que la del corpus"
+        );
+
+        // Control negativo: el formato viejo (los dos parches en un solo `patch`) NO reproduce.
+        let Mutation::SourcePatch { tarball, sha256, build, deps, evidence, slots, .. } =
+            swm.mutations[0].clone()
+        else {
+            panic!("esperaba source_patch");
+        };
+        let concatenado = Mutation::SourcePatch {
+            repo: None,
+            commit: None,
+            tarball,
+            sha256,
+            strip_components: None,
+            patch: Some(textos.concat()),
+            patches: Vec::new(),
+            patch_url: None,
+            build,
+            target_bin: "/usr/bin/multi".into(),
+            expected_hash: None,
+            deps,
+            evidence,
+            slots,
+        };
+        let viejo = d.path().join("swm-recipes-viejo");
+        let rehecha_vieja =
+            recipe_from_source_patch(&concatenado, Some("multi"), &viejo).unwrap();
+        assert_ne!(
+            original.hash_inputs(&[], &lab).unwrap(),
+            rehecha_vieja.hash_inputs(&[], &lab).unwrap(),
+            "control: concatenar los parches TIENE que mover el hash (si no, este test no prueba nada)"
+        );
+    }
+
     #[test]
     fn inline_patch_resuelve_con_un_solo_join_de_base_dir() {
         // Regresión: `source.patches` debe guardar el NOMBRE relativo a base_dir, de modo que el
@@ -402,6 +508,7 @@ logo = "b3:azul"
             sha256: Some("2be64e7129cecb11d5906290eba10af694fb9e3e7f9fc208a311dc33ca837eb0".into()),
             strip_components: None,
             patch: Some("--- a\n+++ b\n".into()),
+            patches: Vec::new(),
             patch_url: None,
             build: fake_swm_build(),
             target_bin: "/usr/bin/jq".into(),
@@ -458,6 +565,7 @@ logo = "b3:azul"
             sha256: None,
             strip_components: None,
             patch: None,
+            patches: Vec::new(),
             patch_url: Some(format!("file://{}", patch_src.display())),
             build: fake_swm_build(),
             target_bin: "/bin/x".into(),
diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs
index cf7da82e..9ef2638d 100644
--- a/crates/takana-cli/src/main.rs
+++ b/crates/takana-cli/src/main.rs
@@ -340,6 +340,24 @@ enum Cmd {
         #[arg(long, default_value = takana_core::installed::DEFAULT_DB)]
         db: PathBuf,
     },
+    /// [Etapa F] Dice qué paquetes INSTALADOS quedaron atrás respecto del catálogo de un repo.
+    /// No construye, no descarga `.swm` y no toca nada: sólo lee el índice (firmado) y la DB de
+    /// instalados. Es lo que un disparador periódico corre para AVISAR de que hay novedades; la
+    /// actualización en sí la hace `install <nombre>`, que es quien verifica y reproduce.
+    Outdated {
+        /// Repositorio: directorio local (con `index.json`) o lista de orígenes HTTP(S).
+        #[arg(long, default_value = DEFAULT_REPO)]
+        repo: String,
+        /// Base de datos de instalados a comparar.
+        #[arg(long, default_value = takana_core::installed::DEFAULT_DB)]
+        db: PathBuf,
+        /// Directorio de claves de confianza para validar la firma del release.
+        #[arg(long)]
+        trust: Option<PathBuf>,
+        /// Salida JSON, para que un guion decida si avisar (y de qué) sin parsear texto.
+        #[arg(long)]
+        json: bool,
+    },
     /// [H4d] BÚSQUEDA de compatibilidad: particiona un repo entero contra el estado instalado en
     /// {compatibles, requieren-elección, incompatibles} — sin construir ni tocar nada. Responde
     /// "cuando busco, ¿cuáles puedo adoptar?" (SDD 15 §H4).
@@ -1319,6 +1337,9 @@ fn main() -> anyhow::Result<()> {
         }
         Cmd::Uninstall { name, db } => run_uninstall(&name, &db)?,
         Cmd::Installed { db } => run_installed_list(&db)?,
+        Cmd::Outdated { repo, db, trust, json } => {
+            run_outdated(&repo, &db, trust.as_deref(), json)?
+        }
         Cmd::Compat { repo, db } => run_compat(&repo, &db)?,
         Cmd::ImportNix { file, out } => run_import_nix(&file, out.as_deref())?,
         Cmd::ImportAlpine { file, out } => run_import_alpine(&file, out.as_deref())?,
@@ -2429,28 +2450,24 @@ fn run_pack(
     }
 
     // Texto de los patches: cada uno se resuelve relativo al directorio de la receta (igual que
-    // el lab) y se concatena. El `source_patch` del .swm modela un único `patch` inline; un diff
-    // unificado multi-fichero concatenado se aplica hunk-a-hunk igual (git apply / patch -p1).
-    let patch_text = if recipe.source.patches.is_empty() {
-        None
-    } else {
-        let mut buf = String::new();
-        for p in &recipe.source.patches {
-            let path = std::path::Path::new(p);
-            let abs = if path.is_absolute() {
-                path.to_path_buf()
-            } else {
-                recipe.base_dir.join(path)
-            };
-            let txt = std::fs::read_to_string(&abs)
-                .map_err(|e| anyhow::anyhow!("leyendo patch {}: {e}", abs.display()))?;
-            if !buf.is_empty() && !buf.ends_with('\n') {
-                buf.push('\n');
-            }
-            buf.push_str(&txt);
-        }
-        Some(buf)
-    };
+    // el lab) y viaja COMO UNA ENTRADA POR PARCHE, en el orden de la receta.
+    //
+    // ⚠ Antes se concatenaban en un único `patch` inline, y eso hacía IMPOSIBLE reproducir
+    // cualquier receta con dos o más parches: `Recipe::hash_inputs` mete una entrada por parche y
+    // `of_inputs` va con longitud prefijada, así que el receptor sellaba en otra dirección y el
+    // `expected_hash` anclado no coincidía nunca. Ver `Mutation::SourcePatch::patches`.
+    let mut patch_texts: Vec<String> = Vec::new();
+    for p in &recipe.source.patches {
+        let path = std::path::Path::new(p);
+        let abs = if path.is_absolute() {
+            path.to_path_buf()
+        } else {
+            recipe.base_dir.join(path)
+        };
+        let txt = std::fs::read_to_string(&abs)
+            .map_err(|e| anyhow::anyhow!("leyendo patch {}: {e}", abs.display()))?;
+        patch_texts.push(txt);
+    }
 
     // target_bin: `--target-bin` manda. Si no, y la receta Cargo declara `--bin <X>`, el binario
     // instalado es X (no el nombre del paquete: ripgrep→rg, repgrep→rgr) ⇒ derivamos de ahí. Sólo
@@ -2481,10 +2498,19 @@ fn run_pack(
         None
     };
 
+    // `target_bin` es una ADIVINANZA (`/usr/bin/{name}`) y el que instala la cree. Si el artefacto
+    // está sellado acá, la comprobamos contra él en vez de publicar un paquete que sólo falla del
+    // otro lado: `zsh` se instala en `/bin/zsh` (su receta pasa `--bindir=/bin`) y el paquete salía
+    // diciendo `/usr/bin/zsh`, así que el `install` hidrataba sus 1331 ficheros y MORÍA al final.
+    let target_bin = match expected_hash.as_deref() {
+        Some(h) => target_bin_verificado(store_path, h, &recipe.name, target_bin)?,
+        None => target_bin,
+    };
+
     let mut swm = takana_core::Swm::from_recipe(
         &recipe,
         target_bin,
-        patch_text,
+        patch_texts,
         expected_hash.clone(),
         distro_version,
     )?;
@@ -3105,6 +3131,155 @@ fn run_installed_list(db_path: &std::path::Path) -> anyhow::Result<()> {
     Ok(())
 }
 
+/// ¿El paquete instalado quedó atrás respecto de lo que el repo publica? `None` ⇒ al día.
+///
+/// Manda el **hash** cuando los dos lados lo tienen: es la identidad real del artefacto, y una
+/// re-publicación con la misma versión upstream (otra flag, otro lab, otro parche) cambia el hash
+/// sin tocar la etiqueta. Si a alguno le falta el ancla, sólo queda comparar versiones **por
+/// desigualdad** — acá no hay semver y "distinta" es lo único afirmable.
+fn motivo_novedad(
+    hash_local: Option<&str>,
+    version_local: &str,
+    hash_repo: Option<&str>,
+    version_repo: &str,
+) -> Option<&'static str> {
+    match (hash_local, hash_repo) {
+        (Some(local), Some(remoto)) if local != remoto => Some("hash distinto"),
+        (Some(_), Some(_)) => None,
+        _ if version_local != version_repo => Some("versión distinta (sin hash que comparar)"),
+        _ => None,
+    }
+}
+
+/// Compara la DB de instalados con el índice del repo y dice qué quedó atrás. No construye, no
+/// baja `.swm` y no escribe nada: el índice firmado ya trae `version` y `expected_hash` de cada
+/// paquete, que es todo lo que hace falta para contestar «¿hay novedades?».
+///
+/// **Por qué el hash y no sólo la versión.** Un repo se re-publica sin que cambie la versión
+/// upstream cada vez que se mueve algo que entra en `hash_inputs` (una flag, el lab, un parche):
+/// el paquete es OTRO artefacto con la misma etiqueta. Comparar sólo `version` diría «al día»
+/// sobre un binario que ya no es el que el repo sirve. La versión se usa como respaldo cuando
+/// alguno de los dos lados no lleva ancla (`expected_hash` ausente ⇒ paquete sin verificar).
+///
+/// **Lo que NO hace, a propósito:** actualizar. Instalar es reproducir y verificar
+/// (`run_install`), y eso no puede colgarse de un aviso que corre solo en un cron.
+fn run_outdated(
+    repo: &str,
+    db_path: &std::path::Path,
+    trust: Option<&std::path::Path>,
+    json: bool,
+) -> anyhow::Result<()> {
+    let source = RepoSource::parse(repo);
+    let index = source.read_index()?;
+    let idb = takana_core::InstalledDb::load(db_path)?;
+
+    // Estado de la firma del release. Un índice MANIPULADO aborta —decidir "hay que actualizar"
+    // a partir de un catálogo falseado es justo la forma de que te instalen lo que quieran—; sin
+    // firma o con clave desconocida se informa y se sigue, igual que en el resto de la CLI.
+    let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
+    let release = match index.verify_signature(&takana_core::TrustStore::load(trust_dir)?) {
+        takana_core::SigStatus::Trusted { by } => format!("trusted (by {by})"),
+        takana_core::SigStatus::UnknownKey { by } => format!("unknown-key (by {by})"),
+        takana_core::SigStatus::Unsigned => "sin firmar".to_string(),
+        takana_core::SigStatus::BadSig { by, reason } => {
+            anyhow::bail!("release: BAD-SIG (by {by}): {reason} — el índice fue manipulado")
+        }
+    };
+
+    struct Novedad {
+        name: String,
+        instalada: String,
+        publicada: String,
+        motivo: &'static str,
+    }
+    let (mut novedades, mut sin_origen) = (Vec::new(), Vec::new());
+    for p in &idb.packages {
+        let Some(e) = index.find(&p.name) else {
+            sin_origen.push(p.name.clone());
+            continue;
+        };
+        let Some(motivo) = motivo_novedad(
+            p.hash.as_deref(),
+            &p.version,
+            e.expected_hash.as_deref(),
+            &e.version,
+        ) else {
+            continue;
+        };
+        novedades.push(Novedad {
+            name: p.name.clone(),
+            instalada: p.version.clone(),
+            publicada: e.version.clone(),
+            motivo,
+        });
+    }
+    let no_instalados = index
+        .packages
+        .iter()
+        .filter(|e| !idb.packages.iter().any(|p| p.name == e.name))
+        .count();
+
+    if json {
+        let v = serde_json::json!({
+            "repo": source.label(),
+            "release": release,
+            "instalados": idb.packages.len(),
+            "novedades": novedades.iter().map(|n| serde_json::json!({
+                "name": n.name,
+                "instalada": n.instalada,
+                "publicada": n.publicada,
+                "motivo": n.motivo,
+            })).collect::<Vec<_>>(),
+            "sin_origen": sin_origen,
+            "no_instalados": no_instalados,
+        });
+        println!("{}", serde_json::to_string_pretty(&v)?);
+        return Ok(());
+    }
+
+    println!("repo {} — release: {release}", source.label());
+    if idb.packages.is_empty() {
+        println!("nada instalado (DB {})", db_path.display());
+        return Ok(());
+    }
+    if novedades.is_empty() {
+        println!("{} paquete(s) instalado(s), todos al día.", idb.packages.len());
+    } else {
+        println!(
+            "{} paquete(s) instalado(s) · {} con novedad:",
+            idb.packages.len(),
+            novedades.len()
+        );
+        for n in &novedades {
+            println!(
+                "  {:<16} {:<10} → {:<10} {}",
+                n.name, n.instalada, n.publicada, n.motivo
+            );
+        }
+        println!(
+            "\nactualizá con: {}",
+            novedades
+                .iter()
+                .map(|n| format!("takana install {}", n.name))
+                .collect::<Vec<_>>()
+                .join(" · ")
+        );
+    }
+    if !sin_origen.is_empty() {
+        println!(
+            "\n{} instalado(s) que el repo NO publica: {} — nadie los va a actualizar",
+            sin_origen.len(),
+            sin_origen.join(", ")
+        );
+    }
+    if no_instalados > 0 {
+        println!(
+            "\n({no_instalados} paquete(s) del repo sin instalar; `takana compat` dice cuáles podés adoptar)"
+        );
+    }
+    Ok(())
+}
+
 /// [H4d] La BÚSQUEDA de compatibilidad: evalúa cada paquete del repo contra el estado instalado
 /// y lo clasifica. Es `compat::filtrar` del prototipo `wawa-memo`, ahora sobre el repo real —
 /// read-only, no construye ni toca nada. Combina la vía DECLARADA (slots, H4b) con la OBSERVADA
@@ -3711,9 +3886,10 @@ fn build_export_mutations(
             }
         };
 
-        // Patches inline: si la receta tenía patches, los leemos y concatenamos. El
-        // hash del artefacto incluye su contenido (ver `Recipe::hash_inputs`), así que
-        // el receptor reconstruye byte-a-byte si y sólo si el patch coincide.
+        // Patches inline: si la receta tenía patches, los leemos UNO A UNO (nunca concatenados —
+        // ver `Mutation::SourcePatch::patches`). El hash del artefacto incluye su contenido (ver
+        // `Recipe::hash_inputs`), así que el receptor reconstruye byte-a-byte si y sólo si los
+        // parches y su orden coinciden.
         let patch_inline = collect_patches_inline(&recipe);
 
         if paths.len() > 1 {
@@ -3732,7 +3908,8 @@ fn build_export_mutations(
             sha256,
             strip_components: (recipe.source.strip_components != 1)
                 .then_some(recipe.source.strip_components),
-            patch: patch_inline,
+            patch: None,
+            patches: patch_inline,
             patch_url: None,
             build: takana_core::SwmBuild {
                 compiler: recipe.build.compiler.as_str().to_string(),
@@ -3786,16 +3963,67 @@ fn build_export_mutations(
     (mutations, stats)
 }
 
-/// Concatena los contenidos de todos los patches declarados por la receta en un único
-/// blob (separado por una línea con `--- /dev/null` falsa). Si la receta no tiene
-/// patches, devuelve `None`. Si algún patch no se puede leer, devuelve `None` y deja un
-/// warning en stderr — el receptor reconstruirá sin patches y verificará con
-/// `expected_hash` que el binario coincide, así que el error es detectable.
-fn collect_patches_inline(recipe: &takana_core::Recipe) -> Option<String> {
-    if recipe.source.patches.is_empty() {
-        return None;
+/// Comprueba que el `target_bin` que va a viajar en el paquete EXISTE dentro del artefacto
+/// sellado, y lo corrige si el binario quedó en otro bindir.
+///
+/// `target_bin` se adivina como `/usr/bin/{name}` (o del `--bin` de cargo). La adivinanza falla en
+/// cuanto una receta elige otro prefijo: `zsh` configura `--bindir=/bin`, así que su binario queda
+/// en `/bin/zsh` — y el paquete publicado prometía `/usr/bin/zsh`. Eso no se nota al publicar: se
+/// nota en la máquina que instala, **después** de hidratar el artefacto entero, con un error que
+/// habla de un path que nadie escribió a mano. Con el artefacto delante, esto es comprobable en el
+/// momento de empaquetar, y un paquete que promete un binario que no trae no se publica.
+///
+/// Sin artefacto en el store (p. ej. `--expected` sin `--build`) no hay nada contra qué comprobar:
+/// se devuelve la adivinanza tal cual.
+fn target_bin_verificado(
+    store_path: &str,
+    hash: &str,
+    recipe_name: &str,
+    target_bin: String,
+) -> anyhow::Result<String> {
+    let Ok(store) = takana_core::Store::open(store_path) else {
+        return Ok(target_bin);
+    };
+    let h = takana_core::ArtifactHash::from_hex(hash.trim_start_matches("b3:"));
+    let root = store.path_of(&h, recipe_name);
+    if !root.is_dir() {
+        return Ok(target_bin);
+    }
+    // `symlink_metadata` y no `exists()`: dentro del artefacto un `/bin` puede ser un symlink
+    // ABSOLUTO a `/usr/bin`, y `exists()` lo resolvería contra el root de ESTA máquina.
+    let presente = |rel: &str| root.join(rel).symlink_metadata().is_ok();
+    if presente(target_bin.trim_start_matches('/')) {
+        return Ok(target_bin);
+    }
+    let bin = std::path::Path::new(&target_bin)
+        .file_name()
+        .and_then(|s| s.to_str())
+        .ok_or_else(|| anyhow::anyhow!("target_bin sin nombre de fichero: {target_bin}"))?;
+    for dir in ["bin", "usr/bin", "sbin", "usr/sbin", "usr/local/bin"] {
+        if presente(&format!("{dir}/{bin}")) {
+            let corregido = format!("/{dir}/{bin}");
+            eprintln!(
+                "target_bin: '{target_bin}' no está en el artefacto; el binario quedó en \
+                 '{corregido}' — publico ése (pasá --target-bin para fijarlo a mano)"
+            );
+            return Ok(corregido);
+        }
     }
-    let mut all = String::new();
+    anyhow::bail!(
+        "el artefacto {} no trae ningún '{bin}' en bin/, usr/bin/, sbin/, usr/sbin/ ni \
+         usr/local/bin/: el paquete prometería un binario que no existe y el `install` fallaría \
+         DESPUÉS de hidratar. Fijá el path real con --target-bin",
+        root.display()
+    )
+}
+
+/// Lee los patches declarados por la receta **en orden y uno por entrada** (nunca concatenados:
+/// ver `Mutation::SourcePatch::patches` — unirlos cambia el hash del artefacto). Receta sin
+/// patches ⇒ vector vacío. Si algún patch no se puede leer, devuelve el vector VACÍO y deja un
+/// warning en stderr — el receptor reconstruirá sin patches y verificará con `expected_hash` que
+/// el binario coincide, así que el error es detectable.
+fn collect_patches_inline(recipe: &takana_core::Recipe) -> Vec<String> {
+    let mut all = Vec::new();
     for p in &recipe.source.patches {
         let path = if std::path::Path::new(p).is_absolute() {
             std::path::PathBuf::from(p)
@@ -3803,17 +4031,17 @@ fn collect_patches_inline(recipe: &takana_core::Recipe) -> Option<String> {
             recipe.base_dir.join(p)
         };
         match std::fs::read_to_string(&path) {
-            Ok(text) => all.push_str(&text),
+            Ok(text) => all.push(text),
             Err(e) => {
                 eprintln!(
                     "warning: no pude leer patch {} para inline: {e}",
                     path.display()
                 );
-                return None;
+                return Vec::new();
             }
         }
     }
-    Some(all)
+    all
 }
 
 /// Ejecuta el bucle agéntico de Fase 6 vía `takana-agent`. Acepta una intención NL +
@@ -4334,3 +4562,39 @@ flags    = ["--enable-foo"]
     }
 
 }
+
+#[cfg(test)]
+mod outdated_tests {
+    use super::motivo_novedad;
+
+    /// El caso que motiva el verbo: el repo re-publicó el paquete con la MISMA versión upstream
+    /// (cambió una flag, el lab, un parche) y el artefacto es otro. Mirar sólo la etiqueta diría
+    /// "al día" sobre un binario que ya no es el que el repo sirve.
+    #[test]
+    fn misma_version_distinto_hash_es_novedad() {
+        assert_eq!(
+            motivo_novedad(Some("b3:aaa"), "5.9", Some("b3:bbb"), "5.9"),
+            Some("hash distinto")
+        );
+    }
+
+    #[test]
+    fn mismo_hash_es_al_dia_aunque_la_version_cambie_de_etiqueta() {
+        assert_eq!(motivo_novedad(Some("b3:aaa"), "5.9", Some("b3:aaa"), "5.9-r1"), None);
+    }
+
+    /// Sin ancla de alguno de los dos lados no hay identidad que comparar: queda la etiqueta, y se
+    /// dice explícitamente que es un juicio más débil.
+    #[test]
+    fn sin_hash_cae_a_la_version() {
+        assert_eq!(
+            motivo_novedad(None, "0.9.1", None, "1.0.0"),
+            Some("versión distinta (sin hash que comparar)")
+        );
+        assert_eq!(
+            motivo_novedad(Some("b3:aaa"), "0.9.1", None, "1.0.0"),
+            Some("versión distinta (sin hash que comparar)")
+        );
+        assert_eq!(motivo_novedad(None, "0.9.1", None, "0.9.1"), None);
+    }
+}
diff --git a/crates/takana-core/src/compat.rs b/crates/takana-core/src/compat.rs
index 8ed53a45..c535046a 100644
--- a/crates/takana-core/src/compat.rs
+++ b/crates/takana-core/src/compat.rs
@@ -344,6 +344,7 @@ mod tests {
                 sha256: None,
                 strip_components: None,
                 patch: None,
+                patches: Vec::new(),
                 patch_url: None,
                 build: crate::swm::SwmBuild {
                     compiler: "zig-cc".into(),
diff --git a/crates/takana-core/src/swm.rs b/crates/takana-core/src/swm.rs
index c952d378..ccf5501f 100644
--- a/crates/takana-core/src/swm.rs
+++ b/crates/takana-core/src/swm.rs
@@ -87,8 +87,27 @@ pub enum Mutation {
         /// del lab (1, un único top-level `proyecto-version/`). Sólo se emite si difiere.
         #[serde(default, skip_serializing_if = "Option::is_none")]
         strip_components: Option<usize>,
+        /// UN parche inline. **Formato histórico**: sólo sirve para recetas de un solo parche, y
+        /// se sigue leyendo para no invalidar los `.swm` ya publicados. Lo que `pack` emite hoy es
+        /// [`patches`](#variant.SourcePatch.field.patches) — ver por qué ahí.
         #[serde(default, skip_serializing_if = "Option::is_none")]
         patch: Option<String>,
+        /// Los parches inline **en orden y por separado**, tal como los listaba la receta.
+        ///
+        /// ⚠ **No es un lujo de formato: concatenarlos cambia el hash del artefacto.**
+        /// [`Recipe::hash_inputs`](crate::Recipe::hash_inputs) mete **una entrada por parche** y
+        /// `of_inputs` va con longitud prefijada, así que N parches unidos en uno solo hashean
+        /// distinto que los N sueltos. Mientras `pack` los concatenaba en `patch`, el receptor
+        /// reconstruía en otra dirección y el `expected_hash` anclado **no coincidía nunca**:
+        /// medido sobre `zsh` (6 parches) el 2026-09-21 — anclado `b3:0be3630d…`, reproducido
+        /// `b3:d6329a62…` tras recompilar entero. Afectaba a las 16 recetas del corpus con dos o
+        /// más parches (waterfox 12, firefox y gnupg 11, parted/zsh/strace 6…). Es la misma
+        /// familia que el `strip_debug` que no viajaba (SDD 28 §5.2): un campo que ENTRA en
+        /// `hash_inputs` y no sobrevivía al viaje.
+        ///
+        /// Vacío ⇒ el paquete no trae parches inline (o los trae en el `patch` histórico).
+        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+        patches: Vec<String>,
         #[serde(default, skip_serializing_if = "Option::is_none")]
         patch_url: Option<String>,
         build: SwmBuild,
@@ -257,12 +276,13 @@ impl Swm {
     /// Es la inversa de `takana_build::swm_bridge` (que va `source_patch` → `Recipe` → build).
     ///
     /// `takana-core` no toca disco: el caller resuelve y **lee** los patches de la receta
-    /// (relativos a `recipe.base_dir`) y pasa su texto ya concatenado en `patch_text`. El
+    /// (relativos a `recipe.base_dir`) y pasa sus textos **en orden y uno por parche** en
+    /// `patch_texts` — ver [`Mutation::SourcePatch::patches`] para por qué no van concatenados. El
     /// `expected_hash`, si se da, ancla "verificar, no confiar" (el receptor rehace y compara).
     pub fn from_recipe(
         recipe: &crate::Recipe,
         target_bin: impl Into<String>,
-        patch_text: Option<String>,
+        patch_texts: Vec<String>,
         expected_hash: Option<String>,
         distro_version: impl Into<String>,
     ) -> crate::Result<Swm> {
@@ -317,7 +337,8 @@ impl Swm {
                 tarball,
                 sha256,
                 strip_components,
-                patch: patch_text,
+                patch: None,
+                patches: patch_texts,
                 patch_url: None,
                 build,
                 target_bin: target_bin.into(),
@@ -612,6 +633,7 @@ mutations:
                 sha256: None,
                 strip_components: None,
                 patch: None,
+                patches: Vec::new(),
                 patch_url: None,
                 build: SwmBuild {
                     compiler: "zig-cc".into(),
@@ -645,6 +667,7 @@ mutations:
                 sha256: Some("deadbeef".into()),
                 strip_components: None,
                 patch: None,
+                patches: Vec::new(),
                 patch_url: None,
                 build: SwmBuild {
                     compiler: "zig-cc".into(),
@@ -675,6 +698,7 @@ mutations:
             sha256: Some("deadbeef".into()),
             strip_components: None,
             patch: None,
+            patches: Vec::new(),
             patch_url: None,
             build: SwmBuild {
                 compiler: "zig-cc".into(),
@@ -716,7 +740,7 @@ install = "cp target/release/rg /out/usr/bin/rg"
         let swm = Swm::from_recipe(
             &recipe,
             "/usr/bin/rg",
-            Some("PATCH-TEXT".into()),
+            vec!["PATCH-TEXT".to_string()],
             Some("b3:cafe".into()),
             "2026-06-21",
         )
@@ -756,7 +780,7 @@ compiler = "zig-cc"
 zig_version = "0.13.0"
 "#;
         let recipe = crate::Recipe::from_toml(toml).unwrap();
-        let swm = Swm::from_recipe(&recipe, "/usr/bin/openssl", None, None, "dev").unwrap();
+        let swm = Swm::from_recipe(&recipe, "/usr/bin/openssl", Vec::new(), None, "dev").unwrap();
         match &swm.mutations[0] {
             Mutation::SourcePatch { tarball, sha256, strip_components, build, expected_hash, .. } => {
                 assert_eq!(tarball.as_deref(), Some("https://example/openssl-3.5.4.tar.gz"));
@@ -779,7 +803,7 @@ zig_version = "0.13.0"
             "name = \"takana\"\nversion = \"0.0.1\"\n[source]\nrepo = \"ssh://x/y.git\"\ncommit = \"abc\"\n[build]\nstrip_debug = true\n",
         )
         .unwrap();
-        let swm = Swm::from_recipe(&con, "/usr/bin/takana", None, None, "dev").unwrap();
+        let swm = Swm::from_recipe(&con, "/usr/bin/takana", Vec::new(), None, "dev").unwrap();
         match &swm.mutations[0] {
             Mutation::SourcePatch { build, .. } => {
                 assert_eq!(build.strip_debug, Some(true), "strip_debug tiene que viajar en el .swm");
@@ -792,7 +816,7 @@ zig_version = "0.13.0"
             "name = \"tree\"\nversion = \"2.3.2\"\n[source]\nrepo = \"ssh://x/y.git\"\ncommit = \"abc\"\n[build]\n",
         )
         .unwrap();
-        let swm2 = Swm::from_recipe(&sin, "/usr/bin/tree", None, None, "dev").unwrap();
+        let swm2 = Swm::from_recipe(&sin, "/usr/bin/tree", Vec::new(), None, "dev").unwrap();
         match &swm2.mutations[0] {
             Mutation::SourcePatch { build, .. } => {
                 assert_eq!(build.strip_debug, None, "una receta sin strip_debug no debe ganar uno");
@@ -810,6 +834,7 @@ zig_version = "0.13.0"
             sha256: None,
             strip_components: None,
             patch: None,
+            patches: Vec::new(),
             patch_url: None,
             build: SwmBuild {
                 compiler: "zig-cc".into(),
@@ -850,7 +875,7 @@ cmd  = "cd /src && cargo test proptest_"
 expected_exit = 0
 "#;
         let recipe = crate::Recipe::from_toml(toml).unwrap();
-        let swm = Swm::from_recipe(&recipe, "/usr/bin/grep", None, None, "dev").unwrap();
+        let swm = Swm::from_recipe(&recipe, "/usr/bin/grep", Vec::new(), None, "dev").unwrap();
         // La evidencia llegó a la mutación.
         match &swm.mutations[0] {
             Mutation::SourcePatch { evidence, .. } => {
@@ -905,7 +930,7 @@ commit = "deadbeef"
 [build]
 "#;
         let recipe = crate::Recipe::from_toml(toml).unwrap();
-        let swm = Swm::from_recipe(&recipe, "/usr/bin/x", None, None, "dev").unwrap();
+        let swm = Swm::from_recipe(&recipe, "/usr/bin/x", Vec::new(), None, "dev").unwrap();
         let yaml = swm.to_yaml().unwrap();
         assert!(!yaml.contains("evidence"), "no debería emitir evidence: {yaml}");
     }
diff --git a/docs/28-servidor-de-produccion.md b/docs/28-servidor-de-produccion.md
index c05ff491..0d22e81a 100644
--- a/docs/28-servidor-de-produccion.md
+++ b/docs/28-servidor-de-produccion.md
@@ -483,6 +483,71 @@ el Storage Box o gioser. Si no, el primer origen roto es el último.
 ([SDD 19 §5.2](19-lanzamiento-publico.md)), hecho sobre una máquina que importa pero que **no es el
 laptop de nadie**.
 
+### 5.5 ⚠ Dos bugs más del lazo, que sólo aparecen si el paquete NO es un binario de Rust *(2026-09-21)*
+
+Pedido del usuario: «instalar zsh con el comando takana». `zsh` es el primer paquete que se instala
+**con varios parches** y **con el binario fuera de `/usr/bin`**, y cada una de esas dos cosas era un
+fallo distinto. Los dos son de la familia del `strip_debug` del §5.2 —algo que el `.swm` no
+transportaba fiel— y los dos **se descubren sólo cerrando el lazo**, no leyendo el código.
+
+**1. Los parches viajaban CONCATENADOS, y eso cambia el hash.** `pack` unía los N parches de la
+receta en el `patch` inline único; `Recipe::hash_inputs` mete **una entrada por parche** y
+`of_inputs` va con longitud prefijada ⇒ N sueltos y N unidos hashean distinto. Medido sin esperar al
+build, con una copia de `recipes/zsh.toml` con sus 6 parches concatenados en uno:
+
+```
+b3:0be3630d…   ← expected_hash anclado (y lo que hay en /store)
+b3:d6329a62…   ← lo que reconstruye el receptor  (= la copia concatenada, exacto)
+```
+
+O sea: `install zsh` recompilaba zsh **entero** y recién entonces moría con `expected_hash no
+coincide`. **Alcance: las 16 recetas del corpus con ≥2 parches** — waterfox 12, firefox y gnupg 11,
+parted/zsh/strace 6, doas/mandoc/giflib 4, freetype 3, libxml2/file/brotli 2…
+
+Arreglado con un campo `patches` (lista, en orden) en el `source_patch`; el `patch` único se sigue
+leyendo para no invalidar lo ya publicado. El receptor materializa **un fichero por parche**.
+Regresión con su **control negativo** en `swm_bridge`: si se concatenan, los `hash_inputs` TIENEN
+que diferir, o el test pasaría por la razón equivocada.
+
+⚠ La tentación era arreglarlo del otro lado —hashear los parches concatenados— y habría sido mucho
+peor: mueve el hash de las **50** recetas con parches e invalida sus artefactos sellados.
+
+**2. `target_bin` era una adivinanza que nadie comprobaba.** `pack` publica `/usr/bin/{name}`;
+`zsh` configura `--bindir=/bin`, así que su binario queda en `/bin/zsh`. El paquete se publicaba
+prometiendo un path que el artefacto no tiene, y el error salía **en la máquina que instala,
+después de hidratar los 1331 ficheros**:
+
+```
+Error: source_patch declara target_bin=/usr/bin/zsh pero no quedó en <prefix>/usr/bin/zsh
+```
+
+Con `--build` el artefacto está delante: ahora `pack` comprueba el path contra él, lo corrige si el
+binario quedó en otro bindir (diciéndolo) y **aborta al publicar** si no hay ningún binario con ese
+nombre. Un paquete que promete algo que no trae no debería llegar a existir.
+
+**El lazo, después:** `takana install zsh --repo http://… --require-signed` ⇒ cache-hit del hash
+anclado, **1331 ficheros hidratados en 0,14 s**, y el binario corre (`zsh 5.9`). Antes: recompilar
+zsh entero para morir al final.
+
+### 5.6 `takana outdated` — el aviso que faltaba *(2026-09-21)*
+
+Ningún verbo comparaba lo instalado con el catálogo (`upgrade` es de **árboles/generaciones**, no de
+paquetes), así que «avisame cuándo hay que actualizar» no tenía respuesta. `outdated` la da leyendo
+sólo el índice firmado y la DB de instalados — no construye, no baja `.swm`, no escribe nada.
+
+**Compara por hash, no por versión**, y eso es lo único que lo hace útil: un repo se re-publica sin
+que cambie la versión upstream cada vez que se mueve algo que entra en `hash_inputs` (una flag, el
+lab, un parche), y mirar la etiqueta diría «al día» sobre un artefacto que ya no es el que el repo
+sirve. La versión es el respaldo para cuando falta el ancla, y se dice que el juicio es más débil.
+
+`scripts/servidor/avisar-actualizaciones.sh` lo corre por cron y **sólo grita cuando la lista de
+novedades cambia** (un guardián que repite lo mismo cada 30 min deja de leerse). Un repo caído no se
+disfraza de «al día»: lo dice, sale ≠ 0 y **conserva el último estado bueno**.
+
+⚠ **Lo que sigue sin poder hacerse, y no lo arregla ningún verbo:** actualizar en una máquina sin
+lab. El aviso llega a cualquiera; el `install` que lo resuelve sigue siendo reproducir desde fuente
+(§5.4). Por eso `outdated` **no actualiza**: instalar es verificar, y eso no se cuelga de un cron.
+
 ---
 
 ## 6. La mudanza como experimento: el criterio de borrado