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>
This commit is contained in:
2026-07-02 12:41:23 -04:00
co-authored by Claude Opus 4.8
parent 49106ed4ca
commit 3116ccfc93
7 changed files with 315 additions and 5 deletions
+1
View File
@@ -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;
+12 -3
View File
@@ -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());
+1
View File
@@ -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;
}
+4 -1
View File
@@ -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;
+202
View File
@@ -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);
}
}
+94 -1
View File
@@ -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}");
}
}
+1
View File
@@ -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,