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 Sutichaykuna

H1a (proof-carrying recipes): bloque `evidence` en receta y .swm

Frontera AI-nativa SDD 15 §H1. Agrega `Evidence { checks: Vec<EvidenceCheck> }` a Recipe
(TOML) y a Mutation::SourcePatch (.swm YAML): cada check es {kind, cmd, expected_exit,
expected_output?} con kind ∈ {cmd-exit, proptest, contract, kani} (estratos de confianza
crecientes, EvidenceKind: Ord). DECISIÓN CLAVE: la evidencia NO entra en hash_inputs —
certifica comportamiento, no identidad ⇒ no mueve el artifact_hash (baseline de
reproducibilidad intacto). Round-trip completo: from_recipe (forward) + swm_bridge
synthesize_recipe (reverse) + camino de pack (cli). verify_schema valida forma (cmd no
vacío, expected_output con prefijo b3:). El checker que EJECUTA la evidencia es H1b; el
cableado al Orchestrator VERIFY es H1c (marcado con evidence: _). Tests: recipe + swm,
incl. que la evidencia no cambia el hash. Sin warnings clippy nuevos.

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

3116ccfc93dcf98986fb9a16ba252961e3692ae9 · ruwaq sergio · (3 killa ñawpaq) · tayta 49106ed4c · kay commitpi sach'ata qhaway

Semántico sayk'uy · 33

crates/hammer-agent/src/orchestrator.rs

  • ukhunpi t'ikrakurqan fn run pub

crates/hammer-build/src/swm_bridge.rs

crates/hammer-cli/src/main.rs

crates/hammer-core/src/recipe.rs

crates/hammer-core/src/swm.rs

crates/hammerd/src/bus.rs

7 qillqakuna · +315 −5

diff --git a/crates/hammer-agent/src/orchestrator.rs b/crates/hammer-agent/src/orchestrator.rs
index 128c7ea2..a86d5621 100644
--- a/crates/hammer-agent/src/orchestrator.rs
+++ b/crates/hammer-agent/src/orchestrator.rs
@@ -170,6 +170,7 @@ impl<T: IntentTranslator> Orchestrator<T> {
                     target_bin,
                     expected_hash,
                     deps: _,
+                    evidence: _, // H1c cableará la ejecución de la evidencia en el paso VERIFY
                 } => {
                     let CompileMode::ViaBus { store, timeout, .. } = &self.compile else {
                         skipped_source_patches += 1;
diff --git a/crates/hammer-build/src/swm_bridge.rs b/crates/hammer-build/src/swm_bridge.rs
index dc77ce38..7477659c 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<Recipe> {
-    let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, deps) =
+    let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, deps, evidence) =
         match mutation {
             Mutation::SourcePatch {
                 repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
-                target_bin, deps, ..
+                target_bin, deps, evidence, ..
             } => (
                 repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
-                target_bin, deps,
+                target_bin, deps, evidence,
             ),
             _ => {
                 return Err(hammer_core::Error::Recipe(
@@ -133,6 +133,7 @@ pub fn recipe_from_source_patch(
         build_cfg,
         *strip_components,
         deps,
+        evidence,
         patches,
         catalog_dir,
     )
@@ -156,6 +157,7 @@ fn synthesize_recipe(
     build_cfg: &SwmBuild,
     strip_components: Option<usize>,
     deps: &hammer_core::Deps,
+    evidence: &hammer_core::Evidence,
     patches: Vec<String>,
     base_dir: &Path,
 ) -> hammer_core::Result<Recipe> {
@@ -213,6 +215,9 @@ flags    = []
     // necesitan, pero la resolución de build-deps SÍ. `install` puebla el catálogo con los
     // `{dep}.toml` antes de construir; sin deps, el dir simplemente no se consulta.
     recipe.deps = deps.clone();
+    // 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();
     recipe.base_dir = base_dir.to_path_buf();
     Ok(recipe)
 }
@@ -272,6 +277,7 @@ mod tests {
             &fake_swm_build(),
             None,
             &hammer_core::Deps::default(),
+            &hammer_core::Evidence::default(),
             vec![],
             d.path(),
         )
@@ -300,6 +306,7 @@ mod tests {
             &fake_swm_build(),
             None,
             &hammer_core::Deps::default(),
+            &hammer_core::Evidence::default(),
             vec![],
             d.path(),
         )
@@ -334,6 +341,7 @@ mod tests {
             target_bin: "/usr/bin/jq".into(),
             expected_hash: None,
             deps: Default::default(),
+            evidence: Default::default(),
         };
         let recipe = recipe_from_source_patch(&m, None, &catalog).unwrap();
         assert_eq!(recipe.source.patches.len(), 1);
@@ -388,6 +396,7 @@ mod tests {
             target_bin: "/bin/x".into(),
             expected_hash: None,
             deps: Default::default(),
+            evidence: 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 4d8af8e9..635c04d8 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -2350,6 +2350,7 @@ fn build_export_mutations(
             target_bin,
             expected_hash: Some(art_hash),
             deps: recipe.deps.clone(),
+            evidence: recipe.evidence.clone(),
         });
         stats.source_patches += 1;
     }
diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs
index 2d250192..6ae4c989 100644
--- a/crates/hammer-core/src/lib.rs
+++ b/crates/hammer-core/src/lib.rs
@@ -19,7 +19,10 @@ pub mod swm;
 pub use caps::{AgentCapsConfig, CapRule};
 pub use hash::ArtifactHash;
 pub use installed::{InstalledDb, InstalledPackage};
-pub use recipe::{Compiler, Deps, LinkMode, Phases, Recipe, Source, SourceKind};
+pub use recipe::{
+    Compiler, Deps, Evidence, EvidenceCheck, EvidenceKind, LinkMode, Phases, Recipe, Source,
+    SourceKind,
+};
 pub use repo::{PackageEntry, RepoIndex};
 pub use sign::{KeyPair, SigStatus, TrustStore};
 pub use store::Store;
diff --git a/crates/hammer-core/src/recipe.rs b/crates/hammer-core/src/recipe.rs
index 75ee9bea..3e9a9615 100644
--- a/crates/hammer-core/src/recipe.rs
+++ b/crates/hammer-core/src/recipe.rs
@@ -14,6 +14,11 @@ pub struct Recipe {
     pub build: Build,
     #[serde(default)]
     pub deps: Deps,
+    /// Evidencia de COMPORTAMIENTO que acompaña la receta (H1 — proof-carrying recipes, SDD 15).
+    /// Opcional. **No entra en `hash_inputs`**: certifica comportamiento, no identidad ⇒ agregar o
+    /// 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,
     /// 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)]
@@ -158,6 +163,100 @@ impl Deps {
     }
 }
 
+/// 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.
+/// La confianza vive en el checker (pequeño, auditable), no en el generador: el sistema mejora solo
+/// sin poder corromperse solo. Deliberadamente FUERA de `Recipe::hash_inputs` (comportamiento ≠
+/// identidad). Frontera honesta: garantiza que *lo declarado pasa*, no que *lo declarado basta*.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Evidence {
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub checks: Vec<EvidenceCheck>,
+}
+
+impl Evidence {
+    /// `true` si no hay ningún check. Sirve a serde (`skip_serializing_if`) para no emitir el
+    /// bloque `evidence` vacío en una receta/`.swm`.
+    pub fn is_empty(&self) -> bool {
+        self.checks.is_empty()
+    }
+
+    /// Valida la forma del bloque (schema, no ejecución): cada `cmd` no vacío y cada
+    /// `expected_output`, si está, con prefijo `b3:`. El checker de H1b corre esto antes de ejecutar.
+    pub fn validate(&self) -> crate::Result<()> {
+        for (i, c) in self.checks.iter().enumerate() {
+            if c.cmd.trim().is_empty() {
+                return Err(crate::Error::Recipe(format!(
+                    "evidence.checks[{i}] ({}): `cmd` vacío",
+                    c.kind.as_str()
+                )));
+            }
+            if let Some(h) = &c.expected_output {
+                if !h.starts_with("b3:") {
+                    return Err(crate::Error::Recipe(format!(
+                        "evidence.checks[{i}] ({}): `expected_output` debe ser un hash `b3:…`, es {h:?}",
+                        c.kind.as_str()
+                    )));
+                }
+            }
+        }
+        Ok(())
+    }
+}
+
+/// Un ítem de evidencia: un comando + el resultado exigido. `kind` estratifica la CONFIANZA
+/// (`cmd-exit` < `proptest` < `contract` < `kani`), que se reporta tal cual — H1 no finge que todo
+/// es prueba formal.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct EvidenceCheck {
+    pub kind: EvidenceKind,
+    /// Comando shell a correr en el sandbox reproducible tras el build (misma vía que las fases).
+    pub cmd: String,
+    /// Exit code exigido. Default 0.
+    #[serde(default)]
+    pub expected_exit: i32,
+    /// Hash BLAKE3 (`b3:…`) opcional del stdout, para checks deterministas cuyo OUTPUT también se
+    /// ancla (no sólo el exit). `None` ⇒ sólo se exige el exit code.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub expected_output: Option<String>,
+}
+
+/// Estrato de confianza de un `EvidenceCheck`, en orden creciente (`Ord` = comparación de estratos).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum EvidenceKind {
+    /// El comando sale con `expected_exit`. La garantía más débil (un smoke test).
+    CmdExit,
+    /// Property tests (proptest/quickcheck): pasan sobre entradas generadas.
+    Proptest,
+    /// Contratos / aserciones de pre y post-condición.
+    Contract,
+    /// Prueba formal acotada (Kani/Creusot). La garantía más fuerte.
+    Kani,
+}
+
+impl EvidenceKind {
+    /// Nivel numérico de confianza (0 = más débil), para reportar/ordenar.
+    pub fn level(&self) -> u8 {
+        match self {
+            EvidenceKind::CmdExit => 0,
+            EvidenceKind::Proptest => 1,
+            EvidenceKind::Contract => 2,
+            EvidenceKind::Kani => 3,
+        }
+    }
+
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            EvidenceKind::CmdExit => "cmd-exit",
+            EvidenceKind::Proptest => "proptest",
+            EvidenceKind::Contract => "contract",
+            EvidenceKind::Kani => "kani",
+        }
+    }
+}
+
 /// Compilador del lab, POR RECETA (no global). `zig-cc` por defecto; escotilla a clang/gcc
 /// para paquetes con gcc-ismos. Ver ADR 0003.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
@@ -472,4 +571,107 @@ commit = "deadbeef"
         let r = Recipe::load_from_path(&recipe_path).unwrap();
         assert_eq!(r.base_dir, dir.path());
     }
+
+    const WITH_EVIDENCE: &str = r#"
+name    = "grep"
+version = "3.11"
+[source]
+repo   = "git://git.savannah.gnu.org/grep.git"
+commit = "a1b2c3d4e5f6"
+[build]
+[deps]
+build = ["pcre2"]
+
+[[evidence.checks]]
+kind = "cmd-exit"
+cmd  = "grep --version"
+
+[[evidence.checks]]
+kind = "proptest"
+cmd  = "cd /src && cargo test --release proptest_"
+expected_exit = 0
+
+[[evidence.checks]]
+kind = "cmd-exit"
+cmd  = "printf hola | grep -q hola"
+expected_output = "b3:deadbeef"
+"#;
+
+    #[test]
+    fn parse_evidence_block() {
+        let r = Recipe::from_toml(WITH_EVIDENCE).expect("parse");
+        assert_eq!(r.evidence.checks.len(), 3);
+        assert_eq!(r.evidence.checks[0].kind, EvidenceKind::CmdExit);
+        assert_eq!(r.evidence.checks[0].cmd, "grep --version");
+        assert_eq!(r.evidence.checks[0].expected_exit, 0); // default
+        assert_eq!(r.evidence.checks[1].kind, EvidenceKind::Proptest);
+        assert_eq!(r.evidence.checks[2].expected_output.as_deref(), Some("b3:deadbeef"));
+        r.evidence.validate().expect("schema válido");
+    }
+
+    #[test]
+    fn evidence_absent_is_empty_and_omitted() {
+        let r = Recipe::from_toml(SAMPLE).unwrap();
+        assert!(r.evidence.is_empty());
+        // `skip_serializing_if` ⇒ una receta sin evidencia no emite el bloque.
+        let toml = r.to_toml().unwrap();
+        assert!(!toml.contains("evidence"), "toml no debería traer `evidence`: {toml}");
+    }
+
+    #[test]
+    fn evidence_does_not_affect_artifact_hash() {
+        // La clave de H1a: la evidencia certifica COMPORTAMIENTO, no IDENTIDAD ⇒ el hash del
+        // artefacto es idéntico con y sin bloque evidence (el baseline de reproducibilidad no se mueve).
+        // Comparamos LA MISMA receta con y sin evidencia (clon + limpieza), no dos recetas distintas.
+        let con = Recipe::from_toml(WITH_EVIDENCE).unwrap();
+        let mut sin = con.clone();
+        sin.evidence = Evidence::default();
+        assert!(con.evidence.checks.len() == 3 && sin.evidence.is_empty());
+        assert_eq!(
+            sin.hash_inputs(&[]).unwrap(),
+            con.hash_inputs(&[]).unwrap(),
+            "la evidencia NO debe entrar en hash_inputs"
+        );
+    }
+
+    #[test]
+    fn evidence_validate_rejects_empty_cmd_and_bad_hash() {
+        let bad_cmd = r#"
+name = "x"
+version = "0"
+[source]
+repo = "git://x"
+commit = "deadbeef"
+[build]
+[[evidence.checks]]
+kind = "cmd-exit"
+cmd  = "   "
+"#;
+        let r = Recipe::from_toml(bad_cmd).unwrap();
+        assert!(r.evidence.validate().unwrap_err().to_string().contains("vacío"));
+
+        let bad_hash = r#"
+name = "x"
+version = "0"
+[source]
+repo = "git://x"
+commit = "deadbeef"
+[build]
+[[evidence.checks]]
+kind = "cmd-exit"
+cmd  = "true"
+expected_output = "sha256:zzz"
+"#;
+        let r = Recipe::from_toml(bad_hash).unwrap();
+        assert!(r.evidence.validate().unwrap_err().to_string().contains("b3:"));
+    }
+
+    #[test]
+    fn evidence_kind_levels_are_stratified() {
+        assert!(EvidenceKind::CmdExit < EvidenceKind::Proptest);
+        assert!(EvidenceKind::Proptest < EvidenceKind::Contract);
+        assert!(EvidenceKind::Contract < EvidenceKind::Kani);
+        assert_eq!(EvidenceKind::CmdExit.level(), 0);
+        assert_eq!(EvidenceKind::Kani.level(), 3);
+    }
 }
diff --git a/crates/hammer-core/src/swm.rs b/crates/hammer-core/src/swm.rs
index 6e2690fb..e298abf1 100644
--- a/crates/hammer-core/src/swm.rs
+++ b/crates/hammer-core/src/swm.rs
@@ -101,6 +101,11 @@ pub enum Mutation {
         /// Vacío ⇒ paquete autocontenido (el caso común, binarios estáticos sin deps).
         #[serde(default, skip_serializing_if = "crate::recipe::Deps::is_empty")]
         deps: crate::recipe::Deps,
+        /// Evidencia de comportamiento que acompaña al paquete (H1 — proof-carrying recipes). El
+        /// receptor puede correr `hammer swm-verify --evidence` para reproducir cada check en el
+        /// 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,
     },
     ConfigEdit {
         file: String,
@@ -290,6 +295,7 @@ impl Swm {
                 target_bin: target_bin.into(),
                 expected_hash,
                 deps: recipe.deps.clone(),
+                evidence: recipe.evidence.clone(),
             }],
             signature: None,
         };
@@ -326,7 +332,7 @@ impl Mutation {
         use crate::recipe::SourceKind;
         match self {
             Mutation::SourcePatch {
-                repo, commit, tarball, sha256, patch, patch_url, target_bin, ..
+                repo, commit, tarball, sha256, patch, patch_url, target_bin, evidence, ..
             } => {
                 match swm_source_kind(
                     repo.as_deref(),
@@ -361,6 +367,9 @@ impl Mutation {
                         "source_patch: target_bin debe ser ruta absoluta, no '{target_bin}'"
                     ));
                 }
+                // La evidencia (H1) viaja con el paquete: validamos su FORMA acá (cmd no vacío,
+                // hash bien formado). La EJECUCIÓN la hace `hammer swm-verify --evidence` (H1b).
+                evidence.validate().map_err(|e| e.to_string())?;
                 Ok(())
             }
             Mutation::ConfigEdit { file, inline_diff } => {
@@ -578,6 +587,7 @@ mutations:
                 target_bin: "/bin/x".into(),
                 expected_hash: None,
                 deps: Default::default(),
+                evidence: Default::default(),
             }],
             signature: None,
         };
@@ -608,6 +618,7 @@ mutations:
                 target_bin: "/bin/grep".into(),
                 expected_hash: None,
                 deps: Default::default(),
+                evidence: Default::default(),
             }],
             signature: None,
         };
@@ -635,6 +646,7 @@ mutations:
             target_bin: "/bin/x".into(),
             expected_hash: None,
             deps: Default::default(),
+            evidence: Default::default(),
         };
         let err = m.verify_schema().unwrap_err();
         assert!(err.contains("no ambos"), "mensaje inesperado: {err}");
@@ -735,8 +747,89 @@ zig_version = "0.13.0"
             target_bin: "/bin/x".into(),
             expected_hash: None,
             deps: Default::default(),
+            evidence: Default::default(),
         };
         let err = m.verify_schema().unwrap_err();
         assert!(err.contains("faltan campos"), "mensaje inesperado: {err}");
     }
+
+    #[test]
+    fn from_recipe_carries_evidence() {
+        // H1a: la evidencia de la receta viaja al .swm (source_patch), roundtripea por YAML,
+        // y verify_schema la valida.
+        let toml = r#"
+name = "grep"
+version = "3.11"
+[source]
+tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
+sha256  = "deadbeef"
+[build]
+[[evidence.checks]]
+kind = "cmd-exit"
+cmd  = "grep --version"
+[[evidence.checks]]
+kind = "proptest"
+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();
+        // La evidencia llegó a la mutación.
+        match &swm.mutations[0] {
+            Mutation::SourcePatch { evidence, .. } => {
+                assert_eq!(evidence.checks.len(), 2);
+                assert_eq!(evidence.checks[0].kind, crate::recipe::EvidenceKind::CmdExit);
+                assert_eq!(evidence.checks[1].kind, crate::recipe::EvidenceKind::Proptest);
+            }
+            other => panic!("esperaba SourcePatch, obtuve {other:?}"),
+        }
+        // Roundtrip YAML: el bloque evidence sobrevive.
+        let yaml = swm.to_yaml().unwrap();
+        assert!(yaml.contains("evidence"), "el YAML debe traer evidence: {yaml}");
+        let back = Swm::from_yaml(&yaml).unwrap();
+        match &back.mutations[0] {
+            Mutation::SourcePatch { evidence, .. } => assert_eq!(evidence.checks.len(), 2),
+            other => panic!("esperaba SourcePatch, obtuve {other:?}"),
+        }
+        back.verify_schema().unwrap();
+    }
+
+    #[test]
+    fn verify_schema_rejects_evidence_with_empty_cmd() {
+        let yaml = r#"
+swm_version: 1
+base:
+  distro_version: "dev"
+mutations:
+  - type: source_patch
+    tarball: "https://x/a.tar.gz"
+    sha256: "deadbeef"
+    build: {}
+    target_bin: "/usr/bin/x"
+    evidence:
+      checks:
+        - kind: cmd-exit
+          cmd: "   "
+"#;
+        let swm = Swm::from_yaml(yaml).unwrap();
+        let err = swm.verify_schema().unwrap_err().to_string();
+        assert!(err.contains("vacío"), "mensaje inesperado: {err}");
+    }
+
+    #[test]
+    fn source_patch_without_evidence_omits_block() {
+        // skip_serializing_if ⇒ un paquete sin evidencia no emite el bloque (compat hacia atrás).
+        let toml = r#"
+name = "x"
+version = "0"
+[source]
+repo = "git://x"
+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 yaml = swm.to_yaml().unwrap();
+        assert!(!yaml.contains("evidence"), "no debería emitir evidence: {yaml}");
+    }
 }
diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs
index 84a28116..9b802971 100644
--- a/crates/hammerd/src/bus.rs
+++ b/crates/hammerd/src/bus.rs
@@ -379,6 +379,7 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
         target_bin: format!("/usr/bin/{name}"),
         expected_hash: None,
         deps: Default::default(),
+        evidence: Default::default(),
     };
     let store = match hammer_core::Store::open(&store_root) {
         Ok(s) => s,