> 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…
H4e: requires OBSERVADOS — dep de runtime divergida = incompatible (SDD 15 §H4)
Cierra la simetría de la vía observada: además de observar lo que un paquete escribe (H4c),
observa de qué depende A UNA VERSIÓN. Fuente observable sin declaración: el paquete se
construyó contra la versión de sus deps que hay en el repo (deps.runtime del .swm +
expected_hash de cada dep en el índice); si el usuario tiene esa dep instalada a OTRO hash,
la divergió -> rechazo duro. Es el caso wayland DERIVADO (el que H4b captura cuando el autor
declara requires, ahora leído del cierre).
- compat::observed_requires(swm, index) + version_conflicts(db, req) — reusan deps del .swm,
expected_hash del índice e InstalledDb.hash (cero declaración nueva).
- Cableado en install (rechazo duro, no lo salva --force-slots) y en `hammer compat`.
- Verificado e2e real: `hammer compat` marca app INCOMPATIBLE por su dep wayland-protocol
instalada a un hash divergido del repo (read-only, ve el source_patch sin construirlo).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index 11e6fb16..ba79e4cd 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -1707,6 +1707,26 @@ fn run_install(
}
}
+ // H4e — vía OBSERVADA de los requisitos: por cada dep de runtime, la versión (expected_hash)
+ // que hay en el repo (contra la que el paquete se construyó). Si la tengo instalada a OTRO
+ // hash, la diverguí ⇒ rechazo duro (el caso wayland, derivado — no lo salva --force-slots).
+ {
+ let idb = hammer_core::InstalledDb::load(db_path)?;
+ let req = hammer_core::compat::observed_requires(&entrant_swm, &index);
+ let conflicts = hammer_core::compat::version_conflicts(&idb, &req);
+ if !conflicts.is_empty() {
+ let d = conflicts
+ .iter()
+ .map(|c| format!("{} necesita {} pero tenés {}", c.dep, c.requerido, c.instalado))
+ .collect::<Vec<_>>()
+ .join("; ");
+ anyhow::bail!(
+ "compat: INCOMPATIBLE — '{name}' se construyó contra deps que divergiste: {d}. \
+ No encaja con tu sistema (rechazo duro, no lo fuerza --force-slots)."
+ );
+ }
+ }
+
// 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() {
@@ -1999,7 +2019,15 @@ fn run_compat(repo: &str, db_path: &std::path::Path) -> anyhow::Result<()> {
continue;
}
};
- // 1) Vía DECLARADA (slots): una incompatibilidad domina todo.
+ // 1a) Vía OBSERVADA de requisitos (H4e): una dep de runtime divergida ⇒ incompatible.
+ let req = hammer_core::compat::observed_requires(&swm, &index);
+ let vconf = hammer_core::compat::version_conflicts(&idb, &req);
+ if !vconf.is_empty() {
+ let d = vconf.iter().map(|c| format!("{}@{} (tenés {})", c.dep, c.requerido, c.instalado)).collect::<Vec<_>>().join(", ");
+ incompatibles.push(format!("{:<16} deps divergidas: {d}", entry.name));
+ continue;
+ }
+ // 1b) Vía DECLARADA (slots): una incompatibilidad domina todo.
let slots = source_patch_slots(&swm);
let veredicto = hammer_core::compat::evaluar(&estado, &slots);
if let hammer_core::CompatVeredicto::Incompatible { slot, requerido, presente } = &veredicto {
diff --git a/crates/hammer-cli/tests/compat_gate.rs b/crates/hammer-cli/tests/compat_gate.rs
index 7bf025d0..8d043496 100644
--- a/crates/hammer-cli/tests/compat_gate.rs
+++ b/crates/hammer-cli/tests/compat_gate.rs
@@ -122,3 +122,44 @@ fn compat_particiona_el_repo_contra_lo_instalado() {
assert!(stdout.contains("requieren elección (1)"), "stdout: {stdout}");
assert!(stdout.contains("tema-oscuro"), "stdout: {stdout}");
}
+
+/// H4e e2e: `hammer compat` marca INCOMPATIBLE un paquete cuya dep de runtime divergiste
+/// (el caso wayland, DERIVADO — sin que el paquete declare `requires`). Read-only: ve el
+/// source_patch sin construirlo.
+#[test]
+fn compat_marca_incompatible_una_dep_divergida() {
+ let tmp = tempfile::tempdir().unwrap();
+ let repo = tmp.path().join("repo");
+ std::fs::create_dir_all(&repo).unwrap();
+ let db = tmp.path().join("installed.json");
+
+ // Tengo wayland-protocol instalado a b3:mine (lo diverguí del stock del repo).
+ std::fs::write(
+ &db,
+ r#"{"packages":[{"name":"wayland-protocol","version":"1","hash":"b3:mine","files":[]}]}"#,
+ )
+ .unwrap();
+
+ // 'app' es un source_patch que depende (runtime) de wayland-protocol.
+ let app_swm = "swm_version: 1\nbase:\n distro_version: dev\n pins: {}\nmutations:\n\
+ - type: source_patch\n repo: git://x/app.git\n commit: abc\n target_bin: /usr/bin/app\n\
+ \x20 build:\n compiler: zig-cc\n deps:\n runtime:\n - wayland-protocol\n";
+ std::fs::write(repo.join("app-1.swm"), app_swm).unwrap();
+ // wayland-protocol también está en el repo, a b3:stock (la versión contra la que app se hizo).
+ let w_swm = "swm_version: 1\nbase:\n distro_version: dev\n pins: {}\nmutations: []\n";
+ std::fs::write(repo.join("w-1.swm"), w_swm).unwrap();
+ let index = r#"{"packages":[
+ {"name":"app","version":"1","file":"app-1.swm","distro_version":"dev","deps":["wayland-protocol"],"expected_hash":"b3:app"},
+ {"name":"wayland-protocol","version":"1","file":"w-1.swm","distro_version":"dev","deps":[],"expected_hash":"b3:stock"}
+ ]}"#;
+ std::fs::write(repo.join("index.json"), index).unwrap();
+
+ let out = Command::new(env!("CARGO_BIN_EXE_hammer"))
+ .args(["compat", "--repo", repo.to_str().unwrap(), "--db", db.to_str().unwrap()])
+ .output()
+ .unwrap();
+ assert!(out.status.success());
+ let stdout = String::from_utf8_lossy(&out.stdout);
+ assert!(stdout.contains("incompatibles (1)"), "stdout: {stdout}");
+ assert!(stdout.contains("app") && stdout.contains("deps divergidas"), "stdout: {stdout}");
+}
diff --git a/crates/hammer-core/src/compat.rs b/crates/hammer-core/src/compat.rs
index cf63574d..452f7d6f 100644
--- a/crates/hammer-core/src/compat.rs
+++ b/crates/hammer-core/src/compat.rs
@@ -163,6 +163,76 @@ pub fn path_collisions(
cols
}
+// ─────────────────────────────────────────────────────────────────────────
+// H4e — `requires` OBSERVADOS: la versión contra la que el paquete se construyó
+// ─────────────────────────────────────────────────────────────────────────
+//
+// H4c observó lo que un paquete *escribe*; falta observar de qué *depende a una
+// versión* (el caso wayland automático). La fuente observable: un paquete se
+// construyó contra la versión de sus deps que hay EN EL REPO. Si el usuario tiene
+// esa dep instalada a OTRO hash, la diverguió ⇒ el paquete no encaja. No hace
+// falta que el autor prometa `requires`: se lee del cierre (deps del `.swm` +
+// `expected_hash` de cada dep en el índice). Es el análogo de sistema a un
+// requisito que no resuelve — el mismo rechazo duro de H4b, ahora derivado.
+
+/// [H4e] Los `requires` OBSERVADOS de un paquete: por cada dep de **runtime**
+/// declarada en su `.swm`, la versión (`expected_hash`) que esa dep tiene **en el
+/// índice** — o sea, contra la que el paquete se construyó. Deps sin `expected_hash`
+/// en el índice se omiten (no hay versión observable que exigir).
+pub fn observed_requires(
+ swm: &Swm,
+ index: &crate::RepoIndex,
+) -> std::collections::BTreeMap<String, String> {
+ let mut req = std::collections::BTreeMap::new();
+ for m in &swm.mutations {
+ if let Mutation::SourcePatch { deps, .. } = m {
+ for dep in &deps.runtime {
+ if let Some(h) = index.find(dep).and_then(|e| e.expected_hash.clone()) {
+ req.insert(dep.clone(), h);
+ }
+ }
+ }
+ }
+ req
+}
+
+/// Un conflicto de versión observado: una dep de runtime está instalada a un hash
+/// **distinto** del que el paquete espera (el que hay en el repo). Es el caso
+/// *wayland* derivado: divergiste una superficie de la que este paquete depende.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ConflictoVersion {
+ pub dep: String,
+ /// El hash que el paquete espera (la versión del repo contra la que se construyó).
+ pub requerido: String,
+ /// El hash al que la dep está instalada AHORA (divergido).
+ pub instalado: String,
+}
+
+/// Conflictos de versión del entrante contra el estado instalado: por cada
+/// `requires` observado, si la dep está instalada a otro hash. Una dep que NO está
+/// instalada no cuenta acá (es un asunto de presencia, no de divergencia) — sólo
+/// pisar una versión distinta rechaza. Reusa lo que `InstalledDb` ya registra.
+pub fn version_conflicts(
+ db: &crate::InstalledDb,
+ requires: &std::collections::BTreeMap<String, String>,
+) -> Vec<ConflictoVersion> {
+ let mut out = Vec::new();
+ for (dep, requerido) in requires {
+ if let Some(pkg) = db.find(dep) {
+ if let Some(instalado) = &pkg.hash {
+ if instalado != requerido {
+ out.push(ConflictoVersion {
+ dep: dep.clone(),
+ requerido: requerido.clone(),
+ instalado: instalado.clone(),
+ });
+ }
+ }
+ }
+ }
+ out
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -258,6 +328,73 @@ mod tests {
assert!(path_collisions(&db, "tema-oscuro", &paths).is_empty());
}
+ // ── H4e: requires observados (versión de la dep contra la que se construyó) ──
+
+ #[test]
+ fn wayland_divergido_observado_da_conflicto_de_version() {
+ // El paquete 'app' depende (runtime) de 'wayland-protocol'. En el repo, wayland está a
+ // b3:stock (la versión contra la que app se construyó).
+ let swm = crate::Swm {
+ swm_version: 1,
+ base: crate::swm::Base { distro_version: "dev".into(), pins: Default::default() },
+ mutations: vec![Mutation::SourcePatch {
+ repo: Some("git://x/app.git".into()),
+ commit: Some("abc".into()),
+ tarball: None,
+ sha256: None,
+ strip_components: None,
+ patch: None,
+ patch_url: None,
+ build: crate::swm::SwmBuild {
+ compiler: "zig-cc".into(),
+ target: "x86_64-linux-musl".into(),
+ link: "static".into(),
+ flags: vec![],
+ phases: Default::default(),
+ zig_version: None,
+ },
+ target_bin: "/usr/bin/app".into(),
+ expected_hash: Some("b3:app".into()),
+ deps: crate::recipe::Deps { build: vec![], runtime: vec!["wayland-protocol".into()] },
+ evidence: Default::default(),
+ slots: Default::default(),
+ }],
+ signature: None,
+ };
+ let index: crate::RepoIndex = serde_json::from_str(
+ r#"{"packages":[{"name":"wayland-protocol","version":"1","file":"w.swm","distro_version":"dev","deps":[],"expected_hash":"b3:stock"}]}"#,
+ )
+ .unwrap();
+
+ let req = observed_requires(&swm, &index);
+ assert_eq!(req.get("wayland-protocol").unwrap(), "b3:stock");
+
+ // Tengo wayland instalado a b3:mine (divergido) ⇒ conflicto.
+ let mut db = crate::InstalledDb::default();
+ db.record(crate::InstalledPackage {
+ name: "wayland-protocol".into(),
+ version: "1".into(),
+ hash: Some("b3:mine".into()),
+ files: vec![],
+ claims: Default::default(),
+ });
+ let cs = version_conflicts(&db, &req);
+ assert_eq!(cs.len(), 1);
+ assert_eq!(cs[0].requerido, "b3:stock");
+ assert_eq!(cs[0].instalado, "b3:mine");
+
+ // Si tengo la versión stock, no hay conflicto.
+ let mut ok = crate::InstalledDb::default();
+ ok.record(crate::InstalledPackage {
+ name: "wayland-protocol".into(),
+ version: "1".into(),
+ hash: Some("b3:stock".into()),
+ files: vec![],
+ claims: Default::default(),
+ });
+ assert!(version_conflicts(&ok, &req).is_empty());
+ }
+
#[test]
fn output_paths_lee_target_bin_y_file_drop() {
let swm = crate::Swm {
diff --git a/docs/15-frontier-ai-native.md b/docs/15-frontier-ai-native.md
index 9f7afa2f..4c946d4a 100644
--- a/docs/15-frontier-ai-native.md
+++ b/docs/15-frontier-ai-native.md
@@ -344,9 +344,23 @@ La definición dura de compatibilidad de una config `C` contra mi estado `E`:
colisión (de slot o de fichero) ⇒ elección, si no compatible. **Verificado e2e real**
(`tests/compat_gate.rs`): un repo de dos paquetes se parte correctamente (uno choca de fichero
con lo instalado ⇒ elección, otro limpio ⇒ compatible).
- Lo que queda abierto: **derivar la otra mitad** (los `requires` observados: de qué depende el
- paquete, no sólo qué escribe), subir el grano del path-slot a superficies más ricas (un módulo
- wasm de wawa, un componente), y el **colapso** con el lado proceso.
+- **H4e** ✅ — **`requires` OBSERVADOS: la versión contra la que el paquete se construyó.** Cierra
+ la simetría de H4c: además de observar lo que un paquete *escribe*, observamos de qué *depende a
+ una versión*. La fuente observable, sin que el autor prometa nada: un paquete se construyó contra
+ la versión de sus deps que hay **en el repo** (`deps.runtime` del `.swm` + `expected_hash` de cada
+ dep en el índice); si el usuario tiene esa dep instalada a **otro** hash, la divergió ⇒ rechazo
+ duro. Es el **caso wayland derivado** — el mismo que H4b captura cuando el autor declara
+ `requires`, ahora leído del cierre.
+ - `compat::observed_requires(swm, index)` + `compat::version_conflicts(db, req)` (reusan `deps`
+ del `.swm`, `expected_hash` del índice y `InstalledDb.hash` — cero declaración nueva).
+ - Cableado en `install` (rechazo duro, no lo salva `--force-slots`: es un requisito, no una
+ elección) y en `hammer compat` (bucket incompatibles).
+ - **Verificado e2e real** (`tests/compat_gate.rs`): `hammer compat` marca `app` INCOMPATIBLE
+ porque su dep `wayland-protocol` está instalada a un hash divergido del repo — read-only, ve
+ el `source_patch` sin construirlo.
+ Con H4e, la vía observada es simétrica: **lo que el paquete escribe** (H4c) *y* **de qué depende**
+ (H4e), ambas sin declaración. Lo que queda abierto: subir el grano del path-slot a superficies
+ más ricas (un módulo wasm de wawa, un componente) y el **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
@@ -409,7 +423,8 @@ H3a (design-doc) ──► registrar la visión, barato
└► H4b ✅ (slots en la receta/.swm real + gate en `hammer install`)
└► H4c ✅ (superficies OBSERVADAS: colisión de fichero, sin declarar slots)
└► H4d ✅ (`hammer compat <repo>`: la búsqueda que particiona un repo)
- └► [proceso] replay del MonotonicLog ──► plan OS-CRDT (otro agente)
+ └► H4e ✅ (requires OBSERVADOS: dep divergida = incompatible)
+ └► [proceso] replay del MonotonicLog ──► plan OS-CRDT (otro agente)
```
Recomendación: **H1 primero** (empuja la frontera que ya tenemos, sin apuestas). **H2a** en