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
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::>()
+ .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 {
+ 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,
+) -> Vec {
+ 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 = ["/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 = ["/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