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 Definiciones

H4c: superficies OBSERVADAS — colisión de fichero en `install` sin declarar slots (SDD 15 §H4)

La misma subida de H1 (prometer -> verificar), ahora sobre topología: la superficie más
común de un paquete es el conjunto de paths que escribe, y esos paths ya están declarados
en el .swm (target_bin + file_drop.path), conocidos ANTES de hidratar.

- compat::output_paths(swm) lee esos paths; compat::path_collisions(db, name, paths) detecta
  cuáles ya posee OTRO paquete instalado (reusa InstalledDb.files + owner_of, cero declaración
  nueva). Reinstalar el mismo paquete sobre sus propios paths NO colisiona (upgrade).
- Gate en `install` corre el chequeo observado JUNTO al declarado (H4b): pisar el fichero de
  otro paquete = caso logo a nivel de fichero (elección) -> aborta salvo --force-slots.
- Verificado e2e REAL (tests/compat_gate.rs, shell-ea al binario hammer): dos paquetes
  escriben /share/logo.png; el 2do aborta con "COLISIÓN de fichero" sin escribir nada; con
  --force-slots la elección se respeta y el fichero se escribe.

Un paquete SIN declarar slots ya participa del gate por lo que de verdad toca.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

9be7bdd70751eec8a1b0b77b7440bd68b89ead65 · por sergio · (hace 3 meses) · padre 27066776b · ver el árbol en este commit

Delta semántico · 10

crates/hammer-cli/src/main.rs

crates/hammer-cli/tests/compat_gate.rs

crates/hammer-core/src/installed.rs

5 archivos · +270 −7

diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index 7c331af0..be2fe239 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -1666,10 +1666,37 @@ fn run_install(
         eprintln!("firma: no verificada (pasá --trust <dir> para validar la autoría)");
     }
 
-    // Gate de compatibilidad (H4b — configs compatibles, SDD 15 §H4). Antes de reconstruir o tocar
-    // nada, evaluamos las superficies que el paquete declara (`slots`) contra el estado INSTALADO.
-    // Es el chequeo local y reproducible de "cuando instalo, ¿es compatible con lo que ya tengo?".
-    let entrant_slots = source_patch_slots(&load_swm(swm_path_str)?);
+    // Gate de compatibilidad (H4b/H4c — configs compatibles, SDD 15 §H4). Antes de reconstruir o
+    // tocar nada, evaluamos las superficies que el paquete tocará contra el estado INSTALADO. Es el
+    // chequeo local y reproducible de "cuando instalo, ¿es compatible con lo que ya tengo?".
+    let entrant_swm = load_swm(swm_path_str)?;
+
+    // H4c — vía OBSERVADA: los paths que el paquete escribirá (declarados en el .swm, sin que el
+    // autor prometa `slots`). Si alguno lo posee OTRO paquete instalado, es una colisión de fichero
+    // (elección, no error) — salvo --force-slots. Se conoce antes de hidratar ⇒ aborta sin tocar nada.
+    {
+        let idb = hammer_core::InstalledDb::load(db_path)?;
+        let paths = hammer_core::compat::output_paths(&entrant_swm);
+        let cols = hammer_core::compat::path_collisions(&idb, name, &paths);
+        if !cols.is_empty() {
+            let detalle = cols
+                .iter()
+                .map(|c| format!("{} (de '{}')", c.path, c.propietario))
+                .collect::<Vec<_>>()
+                .join(", ");
+            if force_slots {
+                eprintln!("compat: {} colisión(es) de fichero: {detalle}; --force-slots ⇒ sobreescribo", cols.len());
+            } else {
+                anyhow::bail!(
+                    "compat: COLISIÓN de fichero — '{name}' pisaría {detalle}. Es una elección: \
+                     pasá --force-slots para sobreescribir el/los fichero(s) del otro paquete."
+                );
+            }
+        }
+    }
+
+    // H4b — vía DECLARADA: las superficies (`slots`) que el autor declara reclamar/requerir.
+    let entrant_slots = source_patch_slots(&entrant_swm);
     if !entrant_slots.is_empty() {
         entrant_slots
             .validate()
diff --git a/crates/hammer-cli/tests/compat_gate.rs b/crates/hammer-cli/tests/compat_gate.rs
new file mode 100644
index 00000000..47bac76a
--- /dev/null
+++ b/crates/hammer-cli/tests/compat_gate.rs
@@ -0,0 +1,77 @@
+//! H4c e2e: el gate de **colisión de fichero observada** en `hammer install` real.
+//!
+//! Reproduce el caso "logo" a nivel de fichero, sin declarar `slots`: dos paquetes escriben el
+//! mismo path. El primero ya está instalado (pre-seed de la DB); al instalar el segundo, el gate
+//! —que lee los paths que el `.swm` escribirá y los compara con lo instalado— debe ABORTAR antes
+//! de tocar nada. Con `--force-slots` la elección se respeta y el fichero se escribe.
+
+use std::process::Command;
+
+use base64::{engine::general_purpose::STANDARD, Engine as _};
+use hammer_core::ArtifactHash;
+
+#[test]
+fn install_aborta_ante_colision_de_fichero_y_force_slots_la_supera() {
+    let tmp = tempfile::tempdir().unwrap();
+    let repo = tmp.path().join("repo");
+    std::fs::create_dir_all(&repo).unwrap();
+    let prefix = tmp.path().join("prefix");
+    let db = tmp.path().join("installed.json");
+
+    // Pre-seed: 'tema-claro' YA posee /share/logo.png.
+    std::fs::write(
+        &db,
+        r#"{"packages":[{"name":"tema-claro","version":"1","files":["/share/logo.png"]}]}"#,
+    )
+    .unwrap();
+
+    // Paquete entrante 'tema-oscuro' = un file_drop que escribe el MISMO path.
+    let content = b"logo-oscuro";
+    // apply_file_drop verifica con `of_inputs` (length-prefixed), no `of_bytes` — mismamos eso.
+    let hash = ArtifactHash::of_inputs(&[content.as_slice()]);
+    let b64 = STANDARD.encode(content);
+    let swm = format!(
+        "swm_version: 1\nbase:\n  distro_version: dev\n  pins: {{}}\nmutations:\n\
+         - type: file_drop\n  path: /share/logo.png\n  content_hash: {}\n  content_b64: {}\n",
+        hash.as_str(),
+        b64
+    );
+    std::fs::write(repo.join("tema-oscuro-1.swm"), swm).unwrap();
+    let index = r#"{"packages":[{"name":"tema-oscuro","version":"1","file":"tema-oscuro-1.swm","distro_version":"dev","deps":[]}]}"#;
+    std::fs::write(repo.join("index.json"), index).unwrap();
+
+    let bin = env!("CARGO_BIN_EXE_hammer");
+    let base_args = [
+        "install",
+        "tema-oscuro",
+        "--repo",
+        repo.to_str().unwrap(),
+        "--db",
+        db.to_str().unwrap(),
+        "--prefix",
+        prefix.to_str().unwrap(),
+    ];
+
+    // 1) Sin --force-slots: colisión ⇒ aborta, y el fichero NO se escribe (aborta pre-hidratación).
+    let out = Command::new(bin).args(base_args).output().unwrap();
+    assert!(!out.status.success(), "debía abortar por colisión de fichero");
+    let err = String::from_utf8_lossy(&out.stderr);
+    assert!(err.contains("COLISIÓN de fichero"), "stderr inesperado: {err}");
+    assert!(
+        !prefix.join("share/logo.png").exists(),
+        "no debía escribir el fichero al abortar"
+    );
+
+    // 2) Con --force-slots: la elección se respeta y el fichero se escribe.
+    let out = Command::new(bin)
+        .args(base_args)
+        .arg("--force-slots")
+        .output()
+        .unwrap();
+    let err = String::from_utf8_lossy(&out.stderr);
+    assert!(out.status.success(), "con --force-slots debía instalar. stderr: {err}");
+    assert!(
+        prefix.join("share/logo.png").exists(),
+        "el fichero debía escribirse con --force-slots. stderr: {err}"
+    );
+}
diff --git a/crates/hammer-core/src/compat.rs b/crates/hammer-core/src/compat.rs
index fb24045c..cf63574d 100644
--- a/crates/hammer-core/src/compat.rs
+++ b/crates/hammer-core/src/compat.rs
@@ -21,7 +21,10 @@
 //! el eje NUEVO; "completa" y "segura" ya viven en el stack (cierre transitivo /
 //! reproducir el resultado) — ver el prototipo `wawa-memo` para la composición.
 
+use std::collections::BTreeSet;
+
 use crate::recipe::Slots;
+use crate::swm::{Mutation, Swm};
 
 /// El estado instalado como `slot → hash`. Alias de lo que produce
 /// [`crate::InstalledDb::system_state`].
@@ -94,6 +97,72 @@ pub fn evaluar(estado: &Estado, slots: &Slots) -> Veredicto {
     Veredicto::Compatible
 }
 
+// ─────────────────────────────────────────────────────────────────────────
+//  H4c — superficies OBSERVADAS: la vía "no prometas, observá"
+// ─────────────────────────────────────────────────────────────────────────
+//
+// H4b confía en que el AUTOR declare `slots` a mano. La misma subida que hizo H1
+// (de "prometer" a "verificar") aplica acá: la superficie más común y observable
+// de un paquete es **el conjunto de paths que escribe**, y esos paths ya están
+// DECLARADOS en el `.swm` (`target_bin` de cada source_patch, `path` de cada
+// file_drop) — se conocen ANTES de hidratar, así que el gate puede abortar sin
+// tocar nada. El "slot" es el path; la señal de colisión es que OTRO paquete
+// instalado ya lo posea. No hace falta que el autor prometa nada: se observa.
+
+/// Los paths ABSOLUTOS que un paquete **escribirá**, leídos de su `.swm`: el
+/// `target_bin` de cada `source_patch` y el `path` de cada `file_drop`. Es la
+/// superficie observada de H4c — conocida sin construir.
+pub fn output_paths(swm: &Swm) -> BTreeSet<String> {
+    let mut paths = BTreeSet::new();
+    for m in &swm.mutations {
+        match m {
+            Mutation::SourcePatch { target_bin, .. } => {
+                paths.insert(target_bin.clone());
+            }
+            Mutation::FileDrop { path, .. } => {
+                paths.insert(path.clone());
+            }
+            // config_edit MODIFICA un fichero ajeno (no lo crea/posee); init_rule escribe en el
+            // dir de reglas del init. Ninguno "reclama" una superficie propia — se omiten.
+            Mutation::ConfigEdit { .. } | Mutation::InitRule { .. } => {}
+        }
+    }
+    paths
+}
+
+/// Una colisión de path observada: `entrante` escribirá un `path` que YA posee otro
+/// paquete instalado (`propietario`). Sobreescribir el fichero de otro paquete es una
+/// **elección** (el caso *logo* a nivel de fichero), no un error.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ColisionPath {
+    pub path: String,
+    /// El paquete instalado que ya posee ese path.
+    pub propietario: String,
+}
+
+/// Colisiones de path del `entrante` (por nombre + los paths que escribirá) contra el estado
+/// instalado. Reinstalar el MISMO paquete sobre sus propios paths NO colisiona (es upgrade);
+/// sólo pisar un fichero de OTRO paquete cuenta. Reusa lo que `InstalledDb` ya registra — no
+/// necesita que la receta declare nada (H4c: observar, no prometer).
+pub fn path_collisions(
+    db: &crate::InstalledDb,
+    entrante_name: &str,
+    entrante_paths: &BTreeSet<String>,
+) -> Vec<ColisionPath> {
+    let mut cols = Vec::new();
+    for p in entrante_paths {
+        if let Some(dueno) = db.owner_of(p) {
+            if dueno != entrante_name {
+                cols.push(ColisionPath {
+                    path: p.clone(),
+                    propietario: dueno.to_string(),
+                });
+            }
+        }
+    }
+    cols
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -154,4 +223,67 @@ mod tests {
             otro => panic!("esperaba Incompatible/None, fue {otro:?}"),
         }
     }
+
+    // ── H4c: superficies observadas (paths escritos, sin declarar slots) ──
+
+    fn pkg_con_files(name: &str, files: &[&str]) -> crate::InstalledPackage {
+        crate::InstalledPackage {
+            name: name.into(),
+            version: "1".into(),
+            hash: None,
+            files: files.iter().map(|s| s.to_string()).collect(),
+            claims: Default::default(),
+        }
+    }
+
+    #[test]
+    fn colision_de_path_observada_contra_otro_paquete() {
+        let mut db = crate::InstalledDb::default();
+        db.record(pkg_con_files("tema-claro", &["/usr/share/logo.png"]));
+
+        // 'tema-oscuro' escribirá el mismo path ⇒ colisión con 'tema-claro'.
+        let entrante: BTreeSet<String> = ["/usr/share/logo.png".to_string()].into_iter().collect();
+        let cols = path_collisions(&db, "tema-oscuro", &entrante);
+        assert_eq!(cols.len(), 1);
+        assert_eq!(cols[0].path, "/usr/share/logo.png");
+        assert_eq!(cols[0].propietario, "tema-claro");
+    }
+
+    #[test]
+    fn reinstalar_el_mismo_paquete_no_colisiona() {
+        let mut db = crate::InstalledDb::default();
+        db.record(pkg_con_files("tema-oscuro", &["/usr/share/logo.png"]));
+        // El mismo paquete sobre sus propios paths = upgrade, no colisión.
+        let paths: BTreeSet<String> = ["/usr/share/logo.png".to_string()].into_iter().collect();
+        assert!(path_collisions(&db, "tema-oscuro", &paths).is_empty());
+    }
+
+    #[test]
+    fn output_paths_lee_target_bin_y_file_drop() {
+        let swm = crate::Swm {
+            swm_version: 1,
+            base: crate::swm::Base {
+                distro_version: "2026-06-06".into(),
+                pins: Default::default(),
+            },
+            mutations: vec![
+                Mutation::FileDrop {
+                    path: "/usr/share/logo.png".into(),
+                    content_hash: "b3:x".into(),
+                    content_b64: Some("AA==".into()),
+                    content_url: None,
+                },
+                Mutation::ConfigEdit {
+                    file: "/etc/foo.conf".into(),
+                    inline_diff: "".into(),
+                },
+            ],
+            signature: None,
+        };
+        let paths = output_paths(&swm);
+        // El file_drop cuenta; el config_edit (modifica ajeno) NO.
+        assert!(paths.contains("/usr/share/logo.png"));
+        assert!(!paths.contains("/etc/foo.conf"));
+        assert_eq!(paths.len(), 1);
+    }
 }
diff --git a/crates/hammer-core/src/installed.rs b/crates/hammer-core/src/installed.rs
index e98a4565..2e025402 100644
--- a/crates/hammer-core/src/installed.rs
+++ b/crates/hammer-core/src/installed.rs
@@ -98,6 +98,15 @@ impl InstalledDb {
         estado
     }
 
+    /// El primer paquete instalado que posee `file` (aportó ese path), si alguno. Lo usa el gate
+    /// de compatibilidad observada (H4c) para nombrar al propietario de una colisión de path.
+    pub fn owner_of(&self, file: &str) -> Option<&str> {
+        self.packages
+            .iter()
+            .find(|p| p.files.iter().any(|f| f == file))
+            .map(|p| p.name.as_str())
+    }
+
     /// ¿Algún OTRO paquete instalado (≠ `name`) aporta también `file`? Si sí, `uninstall` no debe
     /// borrarlo (lo seguiría necesitando ese tercero).
     pub fn owned_by_others(&self, name: &str, file: &str) -> bool {
diff --git a/docs/15-frontier-ai-native.md b/docs/15-frontier-ai-native.md
index 2ffb4044..93de023a 100644
--- a/docs/15-frontier-ai-native.md
+++ b/docs/15-frontier-ai-native.md
@@ -320,8 +320,25 @@ La definición dura de compatibilidad de una config `C` contra mi estado `E`:
     instalación detecte la colisión). `hammer install --help` expone `--force-slots`.
   Frontera que queda: definir el **espacio de slots** del sistema (qué es una "superficie": un
   fichero, un módulo wasm de wawa, un componente) — ahí está el diseño real, no en el álgebra
-  (que ya está). Hoy el productor de recetas declara los slots a mano; falta la **convención**
-  y, más allá, **derivarlos** de lo que el paquete de verdad toca.
+  (que ya está).
+- **H4c** ✅ — **Superficies OBSERVADAS: "no prometas, observá".** H4b confía en que el AUTOR
+  declare `slots`; la misma subida que hizo H1 (de *prometer* a *verificar*) aplica acá: la
+  superficie más común y observable de un paquete es **el conjunto de paths que escribe**, y
+  esos paths YA están declarados en el `.swm` (`target_bin` de cada source_patch, `path` de
+  cada file_drop) — se conocen **antes de hidratar**, así que el gate aborta sin tocar nada.
+  - `compat::output_paths(swm)` lee esos paths; `compat::path_collisions(db, name, paths)`
+    detecta cuáles ya posee **otro** paquete instalado (reusa `InstalledDb.files` + el nuevo
+    `owner_of` — cero declaración nueva en la receta). Reinstalar el mismo paquete sobre sus
+    propios paths NO colisiona (es upgrade).
+  - `hammer install` corre este chequeo **junto** al declarado: pisar el fichero de otro
+    paquete es el caso *logo* a nivel de fichero (elección) ⇒ aborta salvo `--force-slots`.
+  - **Verificado e2e real** (`tests/compat_gate.rs`, shell-ea al binario `hammer`): dos paquetes
+    escriben `/share/logo.png`; el segundo aborta con "COLISIÓN de fichero" **y no escribe nada**;
+    con `--force-slots` la elección se respeta y el fichero se escribe.
+  Con H4c, un paquete **sin declarar slots** ya participa del gate por lo que de verdad toca.
+  Lo que queda abierto es **derivar la otra mitad** (los `requires` observados: de qué depende
+  el paquete) y subir el grano del path-slot a superficies más ricas (un módulo wasm de wawa,
+  un componente), además del **colapso** con el lado proceso.
 
 **Frontera honesta.** H4a demuestra el *álgebra* sobre hashes abstractos; lo que **no**
 resuelve es la **granularidad de los slots** — dos configs pueden no colisionar en el slot
@@ -382,7 +399,8 @@ H3a (design-doc)  ──► registrar la visión, barato
         └► H3c ✅ (linker de contenido: imports por hash, Merkle-DAG intra-función)
              └► H4a ✅ (config = conjunto de slots por hash: compatible/completa/segura)
                   └► H4b ✅ (slots en la receta/.swm real + gate en `hammer install`)
-                       └► [proceso] replay del MonotonicLog  ──► plan OS-CRDT (otro agente)
+                       └► H4c ✅ (superficies OBSERVADAS: colisión de fichero, sin declarar slots)
+                            └► [proceso] replay del MonotonicLog  ──► plan OS-CRDT (otro agente)
 ```
 
 Recomendación: **H1 primero** (empuja la frontera que ya tenemos, sin apuestas). **H2a** en