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 · 2026-07-02T16:41:23Z (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
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,