From 27066776b33f233a29e733ca8f2f695a7d96f6f5 Mon Sep 17 00:00:00 2001 From: sergio Date: Sun, 5 Jul 2026 20:10:02 -0400 Subject: [PATCH] =?UTF-8?q?H4b:=20gate=20de=20compatibilidad=20de=20config?= =?UTF-8?q?s=20en=20la=20receta=20real=20+=20`hammer=20install`=20(SDD=201?= =?UTF-8?q?5=20=C2=A7H4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sube el modelo de slots de wawa-memo (prototipo host) a hammer: - Recipe + source_patch del .swm llevan bloque `slots` {claims, requires} (slot->b3:…), FUERA de hash_inputs (topología ≠ identidad, no mueve el artifact_hash). Viaja intacto por los dos sentidos del puente (Recipe->.swm->Recipe): test de round-trip. - InstalledDb registra claims por paquete + system_state() -> slot->hash (el Estado del gate). - hammer-core::compat::evaluar(estado, slots) -> Veredicto {Compatible, Colision, Incompatible} (el álgebra probada en wawa-memo, sobre tipos de hammer). - Gate en `hammer install`: antes de tocar nada evalúa el paquete contra el estado instalado. Incompatible (requisito sin resolver, caso wayland) -> aborta; Colisión (caso logo) -> aborta pidiendo elección salvo --force-slots; Compatible -> procede y registra los claims. Plumbing propagado por los 5 sitios de Mutation::SourcePatch (from_recipe, swm_bridge, export, bus, orchestrator). Tests: compat (4) + slots-no-en-hash/round-trip (2) + system_state (1) + puente receta<->swm (1). Workspace compila y verde. Co-Authored-By: Claude Opus 4.8 --- crates/hammer-agent/src/orchestrator.rs | 2 + .../tests/orchestrator_evidence_gate.rs | 1 + crates/hammer-build/src/swm_bridge.rs | 53 +++++- crates/hammer-cli/src/main.rs | 63 +++++++ crates/hammer-core/src/compat.rs | 157 ++++++++++++++++++ crates/hammer-core/src/installed.rs | 37 +++++ crates/hammer-core/src/lib.rs | 4 +- crates/hammer-core/src/recipe.rs | 86 ++++++++++ crates/hammer-core/src/swm.rs | 11 ++ crates/hammerd/src/bus.rs | 3 + docs/15-frontier-ai-native.md | 29 +++- 11 files changed, 433 insertions(+), 13 deletions(-) create mode 100644 crates/hammer-core/src/compat.rs diff --git a/crates/hammer-agent/src/orchestrator.rs b/crates/hammer-agent/src/orchestrator.rs index ef06647c..1cc8eada 100644 --- a/crates/hammer-agent/src/orchestrator.rs +++ b/crates/hammer-agent/src/orchestrator.rs @@ -171,6 +171,7 @@ impl Orchestrator { expected_hash, deps: _, evidence, // [H1c] viaja al lab; el veredicto vuelve en BuildReady + slots: _, // [H4b] topología del sistema; la consume el gate de install, no el build } => { let CompileMode::ViaBus { store, timeout, .. } = &self.compile else { skipped_source_patches += 1; @@ -637,6 +638,7 @@ mod tests { expected_hash: None, deps: Default::default(), evidence: Default::default(), + slots: Default::default(), }], signature: None, }; diff --git a/crates/hammer-agent/tests/orchestrator_evidence_gate.rs b/crates/hammer-agent/tests/orchestrator_evidence_gate.rs index f01251e6..9434d66b 100644 --- a/crates/hammer-agent/tests/orchestrator_evidence_gate.rs +++ b/crates/hammer-agent/tests/orchestrator_evidence_gate.rs @@ -118,6 +118,7 @@ fn swm_with_evidence() -> Swm { expected_output: None, }], }, + slots: Default::default(), }], signature: None, } diff --git a/crates/hammer-build/src/swm_bridge.rs b/crates/hammer-build/src/swm_bridge.rs index 7477659c..6093fdcd 100644 --- a/crates/hammer-build/src/swm_bridge.rs +++ b/crates/hammer-build/src/swm_bridge.rs @@ -73,14 +73,14 @@ pub fn recipe_from_source_patch( name: Option<&str>, catalog_dir: &Path, ) -> hammer_core::Result { - let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, deps, evidence) = + let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, deps, evidence, slots) = match mutation { Mutation::SourcePatch { repo, commit, tarball, sha256, strip_components, patch, patch_url, build, - target_bin, deps, evidence, .. + target_bin, deps, evidence, slots, .. } => ( repo, commit, tarball, sha256, strip_components, patch, patch_url, build, - target_bin, deps, evidence, + target_bin, deps, evidence, slots, ), _ => { return Err(hammer_core::Error::Recipe( @@ -134,6 +134,7 @@ pub fn recipe_from_source_patch( *strip_components, deps, evidence, + slots, patches, catalog_dir, ) @@ -158,6 +159,7 @@ fn synthesize_recipe( strip_components: Option, deps: &hammer_core::Deps, evidence: &hammer_core::Evidence, + slots: &hammer_core::Slots, patches: Vec, base_dir: &Path, ) -> hammer_core::Result { @@ -218,6 +220,9 @@ flags = [] // Evidencia de comportamiento (H1): viaja en el .swm y se reinyecta en la receta efímera para // que el checker la vea al reproducir. No afecta el hash del artefacto (comportamiento ≠ identidad). recipe.evidence = evidence.clone(); + // Superficies del sistema (H4b): viajan en el .swm y se reinyectan en la receta efímera para + // que el gate de compatibilidad de `install` las evalúe. Como la evidencia, no afectan el hash. + recipe.slots = slots.clone(); recipe.base_dir = base_dir.to_path_buf(); Ok(recipe) } @@ -247,6 +252,44 @@ fn parse_link(s: &str) -> hammer_core::Result { mod tests { use super::*; + #[test] + fn slots_sobreviven_receta_a_swm_a_receta() { + // H4b: el gate de compatibilidad depende de que las superficies declaradas en la receta + // viajen intactas por el `.swm` y se recuperen al reproducir. Ejercita AMBAS direcciones + // del puente (Swm::from_recipe → recipe_from_source_patch). + let toml = r#" +name = "tema-oscuro" +version = "1.0" +[source] +repo = "git://x/tema.git" +commit = "abc123" +[build] +compiler = "zig-cc" +[slots.claims] +logo = "b3:azul" +[slots.requires] +"wayland-protocol" = "b3:stock" +"#; + let recipe = hammer_core::Recipe::from_toml(toml).unwrap(); + let swm = + hammer_core::Swm::from_recipe(&recipe, "/usr/bin/tema", None, 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}"); + + // Vuelta: source_patch → Recipe recupera exactamente los slots. + let d = tempfile::tempdir().unwrap(); + let mutation = swm + .mutations + .iter() + .find(|m| matches!(m, Mutation::SourcePatch { .. })) + .unwrap(); + let back = recipe_from_source_patch(mutation, Some("tema-oscuro"), d.path()).unwrap(); + assert_eq!(back.slots, recipe.slots); + assert_eq!(back.slots.claims.get("logo").unwrap(), "b3:azul"); + assert_eq!(back.slots.requires.get("wayland-protocol").unwrap(), "b3:stock"); + } + fn fake_swm_build() -> SwmBuild { SwmBuild { compiler: "zig-cc".into(), @@ -278,6 +321,7 @@ mod tests { None, &hammer_core::Deps::default(), &hammer_core::Evidence::default(), + &hammer_core::Slots::default(), vec![], d.path(), ) @@ -307,6 +351,7 @@ mod tests { None, &hammer_core::Deps::default(), &hammer_core::Evidence::default(), + &hammer_core::Slots::default(), vec![], d.path(), ) @@ -342,6 +387,7 @@ mod tests { expected_hash: None, deps: Default::default(), evidence: Default::default(), + slots: Default::default(), }; let recipe = recipe_from_source_patch(&m, None, &catalog).unwrap(); assert_eq!(recipe.source.patches.len(), 1); @@ -397,6 +443,7 @@ mod tests { expected_hash: None, deps: Default::default(), evidence: Default::default(), + slots: Default::default(), }; let store = Store::open(d.path().join("store")).unwrap(); let cfg = BuildConfig::defaults_for_store(store.root()); diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index cf8b32b2..7c331af0 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -241,6 +241,11 @@ enum Cmd { /// (no basta con que el `.swm` reproduzca — exige autoría del catálogo verificada). #[arg(long)] require_signed: bool, + /// [H4b] Ante una COLISIÓN de superficie (el paquete reclama un slot que ya ocupa otra + /// config, p. ej. el logo), adopta la nueva en vez de abortar. No afecta a un requisito + /// insatisfecho (ese rechazo es duro: el paquete no encaja con lo que tenés). + #[arg(long)] + force_slots: bool, }, /// [Etapa F] Desinstala un paquete: borra los ficheros que registró (refcount: respeta los /// que otro paquete instalado también aporta) y lo quita de la DB de instalados. @@ -852,6 +857,7 @@ fn main() -> anyhow::Result<()> { state_root, db, require_signed, + force_slots, } => { run_install( &cli.store, @@ -864,6 +870,7 @@ fn main() -> anyhow::Result<()> { state_root.as_deref(), &db, require_signed, + force_slots, )?; } Cmd::Uninstall { name, db } => run_uninstall(&name, &db)?, @@ -1590,6 +1597,7 @@ fn run_install( state_root: Option<&std::path::Path>, db_path: &std::path::Path, require_signed: bool, + force_slots: bool, ) -> anyhow::Result<()> { let source = RepoSource::parse(repo); let index = source.read_index()?; @@ -1658,6 +1666,48 @@ 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)?); + if !entrant_slots.is_empty() { + entrant_slots + .validate() + .map_err(|e| anyhow::anyhow!("slots del paquete inválidos: {e}"))?; + let idb = hammer_core::InstalledDb::load(db_path)?; + let estado = idb.system_state(); + match hammer_core::compat::evaluar(&estado, &entrant_slots) { + hammer_core::CompatVeredicto::Compatible => { + eprintln!( + "compat: compatible ({} reclamo(s), {} requisito(s) resuelto(s))", + entrant_slots.claims.len(), + entrant_slots.requires.len() + ); + } + // Caso wayland: una dependencia sobre una superficie que el usuario divergió. Rechazo duro. + hammer_core::CompatVeredicto::Incompatible { slot, requerido, presente } => { + anyhow::bail!( + "compat: INCOMPATIBLE — '{name}' requiere la superficie '{slot}' @ {requerido}, \ + pero el sistema tiene {}. Divergiste esa superficie: no se instala sobre ella.", + presente.as_deref().unwrap_or("(ausente)") + ); + } + // Caso logo: dos configs escriben la misma superficie. Es una ELECCIÓN, no un error. + hammer_core::CompatVeredicto::Colision { slot, instalado, entrante } => { + if force_slots { + eprintln!( + "compat: colisión en '{slot}' ({instalado} → {entrante}); --force-slots ⇒ me quedo con la nueva" + ); + } else { + anyhow::bail!( + "compat: COLISIÓN en la superficie '{slot}' — ya la ocupa {instalado} y '{name}' \ + quiere {entrante}. Es una elección: pasá --force-slots para adoptar la nueva." + ); + } + } + } + } + // Dependencias: resolvemos el cierre transitivo de build-deps desde el índice (deps antes // que el dependiente) y poblamos el catálogo de recetas que el lab consultará al reproducir // el `source_patch`. Sin esto, un paquete con build-deps (openssh→zlib,openssl) no podría @@ -1729,6 +1779,9 @@ fn run_install( version: pkg_version, hash: pkg_hash, files, + // Las superficies reclamadas quedan en la DB ⇒ pasan a formar el `system_state` que el + // gate de la PRÓXIMA instalación consultará (H4b). Así una colisión se detecta después. + claims: entrant_slots.claims.clone(), }); idb.save(db_path)?; eprintln!("registrado {pkg_name} ({nfiles} fichero(s)) en {}", db_path.display()); @@ -1949,6 +2002,15 @@ fn source_patch_of(swm: &hammer_core::Swm) -> Option<&hammer_core::Mutation> { .find(|m| matches!(m, hammer_core::Mutation::SourcePatch { .. })) } +/// Las superficies del sistema (H4b) que declara el `source_patch` de un `.swm`, o `Slots` vacío +/// si el paquete no declara topología. Lo consume el gate de compatibilidad de `install`. +fn source_patch_slots(swm: &hammer_core::Swm) -> hammer_core::Slots { + match source_patch_of(swm) { + Some(hammer_core::Mutation::SourcePatch { slots, .. }) => slots.clone(), + _ => hammer_core::Slots::default(), + } +} + /// Firma el índice de un repo (release). Re-firmá tras publicar. fn run_repo_sign( repo_dir: &std::path::Path, @@ -2432,6 +2494,7 @@ fn build_export_mutations( expected_hash: Some(art_hash), deps: recipe.deps.clone(), evidence: recipe.evidence.clone(), + slots: recipe.slots.clone(), }); stats.source_patches += 1; } diff --git a/crates/hammer-core/src/compat.rs b/crates/hammer-core/src/compat.rs new file mode 100644 index 00000000..fb24045c --- /dev/null +++ b/crates/hammer-core/src/compat.rs @@ -0,0 +1,157 @@ +//! H4b — el gate de **compatibilidad de configs** sobre la receta real (SDD 15 §H4). +//! +//! Sube a hammer el modelo de slots que `wawa-memo/src/compat.rs` demostró como +//! prototipo (host/std, 52 tests verdes): una config/paquete declara qué +//! superficies del sistema **reclama** (`Slots::claims`) y **requiere** +//! (`Slots::requires`), ambas por hash. Antes de tocar el sistema, `install` +//! evalúa la config entrante contra el **estado instalado** +//! ([`crate::InstalledDb::system_state`]) y decide: +//! +//! - **Compatible** — requisitos resueltos y reclamos disjuntos (o coincidentes): +//! se puede instalar sin fricción. +//! - **Colisión** — reclama una superficie ya ocupada por OTRO contenido: es una +//! **elección** del usuario (el caso "ya cambié el logo y bajo otra config que +//! también lo cambia"), no un error. `install` la respeta salvo `--force-slots`. +//! - **Incompatible** — un requisito no resuelve contra el estado: **rechazo +//! duro** (el caso "modifiqué el protocolo de Wayland y bajo algo que depende +//! del Wayland stock"). Es el análogo de sistema a una referencia colgante. +//! +//! El chequeo es **local y reproducible** — el mismo "verificar, no confiar" de +//! SDD 09, ahora sobre la topología de superficies. "Compatible" (este módulo) es +//! 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 crate::recipe::Slots; + +/// El estado instalado como `slot → hash`. Alias de lo que produce +/// [`crate::InstalledDb::system_state`]. +pub type Estado = std::collections::BTreeMap; + +/// El veredicto de evaluar unos [`Slots`] contra un [`Estado`]. Tres resultados +/// **mecánicamente distintos**: la incompatibilidad domina a la colisión. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Veredicto { + /// Requisitos resueltos y reclamos disjuntos (o coincidentes en hash): instalable. + Compatible, + /// Un slot que la config reclama ya está ocupado por **otro** contenido. NO es + /// error: es una **elección** (caso *logo*). + Colision { + slot: String, + /// El hash que el slot tiene ahora en el estado. + instalado: String, + /// El hash que la config quiere poner. + entrante: String, + }, + /// Un requisito **no resuelve** contra el estado (caso *wayland*): rechazo duro. + /// `presente = None` si el estado ni ofrece el slot; `Some(otro)` si otra versión. + Incompatible { + slot: String, + /// El hash que la config exige para ese slot. + requerido: String, + /// Lo que el estado ofrece para ese slot (o `None`). + presente: Option, + }, +} + +impl Veredicto { + /// `true` sólo si es [`Veredicto::Compatible`]. + pub fn es_compatible(&self) -> bool { + matches!(self, Veredicto::Compatible) + } +} + +/// Evalúa unos `slots` contra el `estado` instalado. Primero **requisitos** +/// (incompatibilidad dura), luego **reclamos** (colisión = elección). Devuelve la +/// **primera** objeción en orden determinista (`BTreeMap`), o +/// [`Veredicto::Compatible`]. Un reclamo que coincide en hash es idempotente. +pub fn evaluar(estado: &Estado, slots: &Slots) -> Veredicto { + // 1. Requisitos: cada superficie requerida debe estar presente al hash exacto + // contra el que la config fue construida. Es la referencia colgante de sistema. + for (slot, requerido) in &slots.requires { + match estado.get(slot) { + Some(h) if h == requerido => {} + otro => { + return Veredicto::Incompatible { + slot: slot.clone(), + requerido: requerido.clone(), + presente: otro.cloned(), + }; + } + } + } + // 2. Reclamos: un slot ya ocupado por otro contenido es colisión (elección). + for (slot, entrante) in &slots.claims { + if let Some(instalado) = estado.get(slot) { + if instalado != entrante { + return Veredicto::Colision { + slot: slot.clone(), + instalado: instalado.clone(), + entrante: entrante.clone(), + }; + } + } + } + Veredicto::Compatible +} + +#[cfg(test)] +mod tests { + use super::*; + + fn estado(pares: &[(&str, &str)]) -> Estado { + pares.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect() + } + + fn slots(claims: &[(&str, &str)], requires: &[(&str, &str)]) -> Slots { + Slots { + claims: claims.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect(), + requires: requires.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect(), + } + } + + #[test] + fn logo_ya_cambiado_es_eleccion() { + let mio = estado(&[("logo", "b3:rojo")]); + let entrante = slots(&[("logo", "b3:azul")], &[]); + match evaluar(&mio, &entrante) { + Veredicto::Colision { slot, instalado, entrante } => { + assert_eq!(slot, "logo"); + assert_eq!(instalado, "b3:rojo"); + assert_eq!(entrante, "b3:azul"); + } + otro => panic!("esperaba Colision, fue {otro:?}"), + } + } + + #[test] + fn reclamar_lo_mismo_es_idempotente() { + let mio = estado(&[("logo", "b3:rojo")]); + assert!(evaluar(&mio, &slots(&[("logo", "b3:rojo")], &[])).es_compatible()); + } + + #[test] + fn wayland_divergido_rechaza_dependiente() { + let mio = estado(&[("wayland-protocol", "b3:mio")]); + let descargado = slots(&[("app-foo", "b3:v1")], &[("wayland-protocol", "b3:stock")]); + match evaluar(&mio, &descargado) { + Veredicto::Incompatible { slot, requerido, presente } => { + assert_eq!(slot, "wayland-protocol"); + assert_eq!(requerido, "b3:stock"); + assert_eq!(presente, Some("b3:mio".to_string())); + } + otro => panic!("esperaba Incompatible, fue {otro:?}"), + } + // Con el wayland stock, la misma descarga es compatible. + let stock = estado(&[("wayland-protocol", "b3:stock")]); + assert!(evaluar(&stock, &descargado).es_compatible()); + } + + #[test] + fn requisito_ausente_incompatible() { + let vacio = estado(&[]); + match evaluar(&vacio, &slots(&[], &[("wayland-protocol", "b3:stock")])) { + Veredicto::Incompatible { presente, .. } => assert_eq!(presente, None), + otro => panic!("esperaba Incompatible/None, fue {otro:?}"), + } + } +} diff --git a/crates/hammer-core/src/installed.rs b/crates/hammer-core/src/installed.rs index 1d8d240b..e98a4565 100644 --- a/crates/hammer-core/src/installed.rs +++ b/crates/hammer-core/src/installed.rs @@ -32,6 +32,11 @@ pub struct InstalledPackage { pub hash: Option, /// Rutas ABSOLUTAS que este paquete creó (orden de instalación). Lo que `uninstall` borra. pub files: Vec, + /// Superficies del sistema que este paquete **reclama** → hash del contenido (H4b, SDD 15 §H4). + /// Vacío por defecto (compatibilidad hacia atrás: una DB previa a H4b no lo tiene). Con esto, + /// `system_state` deriva el `slot → hash` instalado que el gate de compatibilidad consulta. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub claims: std::collections::BTreeMap, } impl InstalledDb { @@ -78,6 +83,21 @@ impl InstalledDb { Some(self.packages.remove(i)) } + /// El **estado del sistema** como `slot → hash`: la unión de los `claims` de todos los + /// paquetes instalados (H4b, SDD 15 §H4). Es el `Estado` contra el que el gate de + /// compatibilidad evalúa una config entrante. Un slot reclamado por dos paquetes no debería + /// pasar (el gate lo impide al instalar); si aún así ocurre, gana el orden de la lista (ya + /// ordenada por nombre) — determinista. + pub fn system_state(&self) -> std::collections::BTreeMap { + let mut estado = std::collections::BTreeMap::new(); + for p in &self.packages { + for (slot, hash) in &p.claims { + estado.entry(slot.clone()).or_insert_with(|| hash.clone()); + } + } + estado + } + /// ¿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 { @@ -97,9 +117,26 @@ mod tests { version: "1".into(), hash: None, files: files.iter().map(|s| s.to_string()).collect(), + claims: Default::default(), } } + #[test] + fn system_state_une_los_claims() { + let mut db = InstalledDb::default(); + db.record(InstalledPackage { + name: "tema-oscuro".into(), + version: "1".into(), + hash: None, + files: vec![], + claims: [("logo".to_string(), "b3:rojo".to_string())] + .into_iter() + .collect(), + }); + let estado = db.system_state(); + assert_eq!(estado.get("logo"), Some(&"b3:rojo".to_string())); + } + #[test] fn load_missing_is_empty() { let d = tempfile::tempdir().unwrap(); diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs index ae166c0c..03e35922 100644 --- a/crates/hammer-core/src/lib.rs +++ b/crates/hammer-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod apply; pub mod caps; +pub mod compat; pub mod hash; pub mod installed; pub mod proto; @@ -19,9 +20,10 @@ pub mod swm; pub use caps::{AgentCapsConfig, CapRule}; pub use hash::ArtifactHash; pub use installed::{InstalledDb, InstalledPackage}; +pub use compat::Veredicto as CompatVeredicto; pub use recipe::{ CheckOutcome, Compiler, Deps, Evidence, EvidenceCheck, EvidenceKind, LinkMode, Phases, Recipe, - Source, SourceKind, + Slots, Source, SourceKind, }; pub use repo::{PackageEntry, RepoIndex}; pub use sign::{KeyPair, SigStatus, TrustStore}; diff --git a/crates/hammer-core/src/recipe.rs b/crates/hammer-core/src/recipe.rs index df969568..0df369d1 100644 --- a/crates/hammer-core/src/recipe.rs +++ b/crates/hammer-core/src/recipe.rs @@ -19,6 +19,13 @@ pub struct Recipe { /// cambiar evidencia NO mueve el `artifact_hash` (el baseline de reproducibilidad no se toca). #[serde(default, skip_serializing_if = "Evidence::is_empty")] pub evidence: Evidence, + /// **Superficies del sistema** que este paquete reclama y requiere (H4b — configs + /// compatibles, SDD 15 §H4). Igual que `evidence`: **NO entra en `hash_inputs`** — declara + /// TOPOLOGÍA (qué toca / de qué depende), no identidad del artefacto; agregar o cambiar el + /// bloque no mueve el `artifact_hash`. Lo consume el gate de compatibilidad de `install` + /// (`crate::compat`) contra el estado instalado, no el build. + #[serde(default, skip_serializing_if = "Slots::is_empty")] + pub slots: Slots, /// Directorio base contra el que se resuelven rutas relativas de la receta /// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío. #[serde(skip, default)] @@ -163,6 +170,51 @@ impl Deps { } } +/// Las **superficies del sistema** de una config/paquete (H4b — SDD 15 §H4). Una config del +/// usuario = un conjunto de modificaciones direccionadas por contenido: cada modificación +/// **reclama** una superficie (la escribe) y/o **requiere** otra (depende de ella a una versión +/// exacta). El valor de cada slot es un hash `b3:…` del contenido — misma disciplina de "todo por +/// hash" que el resto del stack. +/// +/// El gate de compatibilidad (`crate::compat`) usa estos dos mapas contra el estado instalado: +/// - un `requires` que no resuelve contra el estado ⇒ **incompatible** (el caso "modifiqué el +/// protocolo de Wayland y bajo algo que depende del Wayland stock"); es el análogo, a nivel de +/// sistema, de una referencia colgante. +/// - un `claims` que pisa una superficie ya ocupada por otro contenido ⇒ **colisión = elección** +/// (el caso "ya cambié el logo y bajo otra config que también lo cambia"), no un error. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Slots { + /// Superficies que este paquete **escribe** → hash del contenido nuevo (`slot → b3:…`). + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub claims: std::collections::BTreeMap, + /// Superficies de las que **depende** → versión exacta requerida (`slot → b3:…`). + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub requires: std::collections::BTreeMap, +} + +impl Slots { + /// `true` si no declara ninguna superficie (ni claims ni requires). Para serde: una receta + /// sin bloque `slots` no lo emite. + pub fn is_empty(&self) -> bool { + self.claims.is_empty() && self.requires.is_empty() + } + + /// Valida la forma (schema, no compatibilidad): cada valor de slot debe ser un hash `b3:…`. + /// El gate de `install` corre esto antes de evaluar. + pub fn validate(&self) -> crate::Result<()> { + for (mapa, etiqueta) in [(&self.claims, "claims"), (&self.requires, "requires")] { + for (slot, hash) in mapa { + if !hash.starts_with("b3:") { + return Err(crate::Error::Recipe(format!( + "slots.{etiqueta}[{slot:?}]: el valor debe ser un hash `b3:…`, es {hash:?}" + ))); + } + } + } + Ok(()) + } +} + /// Evidencia de comportamiento de una receta (H1 — proof-carrying recipes, SDD 15 §H1). La IA /// entrega, junto al build, un conjunto de `checks` que el verificador (`hammer swm-verify /// --evidence`) corre DENTRO del sandbox reproducible; la mutación sólo se propone si todos pasan. @@ -674,6 +726,40 @@ expected_output = "b3:deadbeef" ); } + #[test] + fn slots_round_trip_y_no_afectan_el_hash() { + // H4b: el bloque `slots` declara TOPOLOGÍA (qué toca / de qué depende), no identidad ⇒ + // como la evidencia, NO entra en hash_inputs. Y round-trippea por TOML. + let con_toml = format!( + "{SAMPLE}\n[slots.claims]\nlogo = \"b3:rojo\"\n[slots.requires]\n\"wayland-protocol\" = \"b3:stock\"\n" + ); + let con = Recipe::from_toml(&con_toml).unwrap(); + assert_eq!(con.slots.claims.get("logo").unwrap(), "b3:rojo"); + assert_eq!(con.slots.requires.get("wayland-protocol").unwrap(), "b3:stock"); + con.slots.validate().unwrap(); + + // Round-trip: serializa y vuelve a parsear ⇒ mismos slots. + let re = Recipe::from_toml(&con.to_toml().unwrap()).unwrap(); + assert_eq!(re.slots, con.slots); + + // Mismo hash con y sin slots. + let mut sin = con.clone(); + sin.slots = Slots::default(); + assert!(!con.slots.is_empty() && sin.slots.is_empty()); + assert_eq!( + sin.hash_inputs(&[]).unwrap(), + con.hash_inputs(&[]).unwrap(), + "los slots NO deben entrar en hash_inputs" + ); + } + + #[test] + fn slots_validate_rechaza_hash_sin_prefijo() { + let mut s = Slots::default(); + s.claims.insert("logo".into(), "rojo".into()); // falta `b3:` + assert!(s.validate().is_err()); + } + #[test] fn evidence_validate_rejects_empty_cmd_and_bad_hash() { let bad_cmd = r#" diff --git a/crates/hammer-core/src/swm.rs b/crates/hammer-core/src/swm.rs index e298abf1..c9e01d58 100644 --- a/crates/hammer-core/src/swm.rs +++ b/crates/hammer-core/src/swm.rs @@ -106,6 +106,12 @@ pub enum Mutation { /// sandbox antes de aceptar la mutación. Vacío ⇒ paquete sin evidencia (sólo reproducibilidad). #[serde(default, skip_serializing_if = "crate::recipe::Evidence::is_empty")] evidence: crate::recipe::Evidence, + /// Superficies del sistema que el paquete reclama/requiere (H4b — configs compatibles, SDD + /// 15 §H4). El receptor las evalúa contra su estado instalado (`crate::compat`) antes de + /// hidratar: un requisito que no resuelve aborta, un reclamo que pisa otra config pide + /// elección. Vacío ⇒ paquete que no declara topología (no participa del gate). + #[serde(default, skip_serializing_if = "crate::recipe::Slots::is_empty")] + slots: crate::recipe::Slots, }, ConfigEdit { file: String, @@ -296,6 +302,7 @@ impl Swm { expected_hash, deps: recipe.deps.clone(), evidence: recipe.evidence.clone(), + slots: recipe.slots.clone(), }], signature: None, }; @@ -588,6 +595,7 @@ mutations: expected_hash: None, deps: Default::default(), evidence: Default::default(), + slots: Default::default(), }], signature: None, }; @@ -619,6 +627,7 @@ mutations: expected_hash: None, deps: Default::default(), evidence: Default::default(), + slots: Default::default(), }], signature: None, }; @@ -647,6 +656,7 @@ mutations: expected_hash: None, deps: Default::default(), evidence: Default::default(), + slots: Default::default(), }; let err = m.verify_schema().unwrap_err(); assert!(err.contains("no ambos"), "mensaje inesperado: {err}"); @@ -748,6 +758,7 @@ zig_version = "0.13.0" expected_hash: None, deps: Default::default(), evidence: Default::default(), + slots: Default::default(), }; let err = m.verify_schema().unwrap_err(); assert!(err.contains("faltan campos"), "mensaje inesperado: {err}"); diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs index 8a3dde75..d22b605a 100644 --- a/crates/hammerd/src/bus.rs +++ b/crates/hammerd/src/bus.rs @@ -385,6 +385,9 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender) { // [H1c] La evidencia declarada por el cliente viaja a la mutación para ejecutarse tras // sellar el artefacto (ver `evidence_verdict`). evidence: recipe.evidence, + // [H4b] El camino del daemon (agente propone → construye) no declara superficies: el gate + // de compatibilidad vive en `install` (CLI) contra el estado instalado. Vacío aquí. + slots: Default::default(), }; let store = match hammer_core::Store::open(&store_root) { Ok(s) => s, diff --git a/docs/15-frontier-ai-native.md b/docs/15-frontier-ai-native.md index e2113526..2ffb4044 100644 --- a/docs/15-frontier-ai-native.md +++ b/docs/15-frontier-ai-native.md @@ -303,14 +303,25 @@ La definición dura de compatibilidad de una config `C` contra mi estado `E`: chequeo de slots y ser inservible por **incompleta** (paquete manipulado ⇒ `RefColgante`) o **insegura** (resultado afirmado falso ⇒ recomputar lo rechaza) — las tres propiedades, un solo hash. -- **H4b** — (siguiente) subir el modelo de slots de `wawa-memo` (host/std) a la **receta** - real: que un `.swm`/receta declare `reclama`/`requiere` (fuera de `hash_inputs`, misma - disciplina que la evidencia de H1 y la procedencia de H3a). El `Estado` instalado es la - `InstalledDb` de Etapa F vista como `slot → Id`. Entonces `hammer install ` - corre `filtrar` contra el estado local antes de tocar nada. Frontera: definir el - **espacio de slots** del sistema (qué es una "superficie": un fichero, un módulo wasm de - wawa, un componente) — ahí está el trabajo de diseño real, no en el álgebra de - compatibilidad (que ya está). +- **H4b** ✅ — El modelo de slots **subido a la receta real de hammer** (no ya un prototipo + host). Cambios: + - `Recipe` (y el `source_patch` del `.swm`) llevan un bloque **`slots`** con `claims`/ + `requires` (`slot → b3:…`), **fuera de `hash_inputs`** — misma disciplina que la evidencia + de H1: declara topología, no identidad ⇒ no mueve el `artifact_hash` (test que lo ancla). + Viaja intacto por los dos sentidos del puente (`Recipe → .swm → Recipe`, test de round-trip). + - `InstalledDb` (Etapa F) registra los `claims` por paquete y expone `system_state() → + slot → hash`: el `Estado` que el gate consulta. + - `hammer-core::compat::evaluar(estado, slots) → Veredicto` (el álgebra probada en `wawa-memo`, + ahora sobre tipos de hammer). + - **El gate en `hammer install`**: antes de reconstruir o hidratar nada, evalúa el paquete + entrante contra el estado instalado. **Incompatible** (requisito sin resolver, caso wayland) + ⇒ aborta con un mensaje claro; **Colisión** (caso logo) ⇒ aborta pidiendo elección, salvo + `--force-slots`; **Compatible** ⇒ procede y registra los `claims` (para que la PRÓXIMA + 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. **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 @@ -370,7 +381,7 @@ H3a (design-doc) ──► registrar la visión, barato └► H3b ✅ (experimento wasm-por-función) [sobre H2 puro: núcleo Unison verde] └► H3c ✅ (linker de contenido: imports por hash, Merkle-DAG intra-función) └► H4a ✅ (config = conjunto de slots por hash: compatible/completa/segura) - └► H4b (subir el modelo de slots a la receta/.swm real) + └► H4b ✅ (slots en la receta/.swm real + gate en `hammer install`) └► [proceso] replay del MonotonicLog ──► plan OS-CRDT (otro agente) ```