H1c (proof-carrying recipes): cablea la evidencia al Orchestrator VERIFY
La evidencia corre del lado del BUILD (hammer-agent no depende de hammer-build:
separación PROPONE/CONSTRUYE). Piezas:
- proto: RecipeInline lleva `evidence`; Event::BuildReady lleva
`verdict: Option<EvidenceVerdict>` (+ EvidenceCheckVerdict). Ambos con
skip_serializing_if ⇒ wire compatible con clientes pre-H1c.
- hammerd/bus: run_compile ejecuta la evidencia declarada tras sellar el
artefacto (run_evidence vía swm_bridge::recipe_from_source_patch) y adjunta
el veredicto en BuildReady. Si ni se pudo ejecutar ⇒ veredicto fallido
sintético (no pasa en silencio). El lab reporta; el gate vive en el agente.
- client: compile() devuelve CompileOutcome { artifact, verdict }.
- orchestrator: VERIFY lee el veredicto; si all_passed=false empuja
VerifyCheck::fail y ABORTA antes de hidratar (artefacto sellado, no toca el
sistema). Nuevo constructor VerifyCheck::fail.
Tests: roundtrip del veredicto en proto; stub-bus refleja evidencia→verdict;
nueva prueba de integración orchestrator_evidence_gate (veredicto fallido ⇒
run() aborta con "no se propone" y no hidrata). Suite completa verde (33 suites).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6ac6438c00ef554cc399483408f6331bf09b1091 · by sergio · 2026-07-02T17:33:25Z (3 months ago) · parent 497d0d00e · browse the tree at this commit
Semantic delta · 22
crates/hammer-agent/src/client.rs
crates/hammer-agent/src/orchestrator.rs
new fn fail pub body changed fn run pub
crates/hammer-agent/tests/client_against_stub_bus.rs
crates/hammer-agent/tests/orchestrator_evidence_gate.rs
crates/hammer-core/src/proto.rs
crates/hammerd/src/bus.rs
crates/hammerd/tests/bus_e2e.rs
diff --git a/crates/hammer-agent/src/client.rs b/crates/hammer-agent/src/client.rs
index 9ed1243a..b01145e7 100644
--- a/crates/hammer-agent/src/client.rs
+++ b/crates/hammer-agent/src/client.rs
@@ -44,6 +44,14 @@ pub struct Welcome {
pub peer: Peer,
}
+/// Resultado de `compile`: el hash del artefacto sellado más el veredicto de la evidencia (H1c).
+/// `verdict` es `None` cuando la receta no declaró evidencia.
+#[derive(Debug, Clone)]
+pub struct CompileOutcome {
+ pub artifact: String,
+ pub verdict: Option<hammer_core::proto::EvidenceVerdict>,
+}
+
/// Estado compartido entre el reader thread y el caller.
struct Inbox {
/// Eventos asíncronos (Modified/Crashed) en orden de llegada.
@@ -123,10 +131,10 @@ impl AgentClient {
/// Bloquea hasta `BuildReady` o `BuildFailed`. `timeout` es el wall-clock máximo —
/// el lab puede tardar varios minutos, así que el default debería ser generoso.
- pub fn compile(&mut self, recipe: RecipeInline, timeout: Duration) -> Result<String> {
+ pub fn compile(&mut self, recipe: RecipeInline, timeout: Duration) -> Result<CompileOutcome> {
self.send(&Command::Compile { recipe })?;
match self.recv_pending(timeout)? {
- Event::BuildReady { artifact, .. } => Ok(artifact),
+ Event::BuildReady { artifact, verdict, .. } => Ok(CompileOutcome { artifact, verdict }),
Event::BuildFailed { reason, .. } => Err(ClientError::Bus {
code: "build_failed".into(),
msg: reason,
diff --git a/crates/hammer-agent/src/lib.rs b/crates/hammer-agent/src/lib.rs
index 5d34ff9f..4f0355b3 100644
--- a/crates/hammer-agent/src/lib.rs
+++ b/crates/hammer-agent/src/lib.rs
@@ -22,7 +22,7 @@ pub mod orchestrator;
pub mod translator;
pub mod translator_claude;
-pub use client::{AgentClient, ClientError, Welcome};
+pub use client::{AgentClient, ClientError, CompileOutcome, Welcome};
pub use orchestrator::{Orchestrator, Proposal, VerifyCheck};
pub use translator::{IntentCatalog, IntentTranslator, MockTranslator, SystemContext, TranslateError};
pub use translator_claude::{ClaudeConfig, ClaudeTranslator, HttpClient};
diff --git a/crates/hammer-agent/src/orchestrator.rs b/crates/hammer-agent/src/orchestrator.rs
index b8e48c63..ef06647c 100644
--- a/crates/hammer-agent/src/orchestrator.rs
+++ b/crates/hammer-agent/src/orchestrator.rs
@@ -170,7 +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
+ evidence, // [H1c] viaja al lab; el veredicto vuelve en BuildReady
} => {
let CompileMode::ViaBus { store, timeout, .. } = &self.compile else {
skipped_source_patches += 1;
@@ -195,11 +195,13 @@ impl<T: IntentTranslator> Orchestrator<T> {
target: build.target.clone(),
link: build.link.clone(),
flags: build.flags.clone(),
+ evidence: evidence.clone(),
};
let client = maybe_client.as_mut().expect("client conectado arriba");
- let artifact = client
+ let outcome = client
.compile(recipe, *timeout)
.map_err(Error::Client)?;
+ let artifact = outcome.artifact;
if let Some(expected) = expected_hash {
let want = expected.strip_prefix("b3:").unwrap_or(expected);
let got = artifact.strip_prefix("b3:").unwrap_or(&artifact);
@@ -210,6 +212,37 @@ impl<T: IntentTranslator> Orchestrator<T> {
)));
}
}
+ // [H1c] VERIFY con evidencia: el lab ya ejecutó la evidencia declarada tras
+ // sellar el artefacto y nos devolvió el veredicto. Es el gate "no proponer":
+ // si algún check falló, NO hidratamos (el artefacto queda sellado en el store,
+ // pero no toca el sistema) y abortamos con el detalle.
+ if let Some(verdict) = &outcome.verdict {
+ for c in &verdict.checks {
+ if !c.passed {
+ checks.push(VerifyCheck::fail(format!(
+ "evidencia [{}] {}: {}",
+ c.kind.as_str(),
+ c.cmd,
+ c.detail
+ )));
+ }
+ }
+ if !verdict.all_passed {
+ let n_fail = verdict.checks.iter().filter(|c| !c.passed).count();
+ return Err(Error::Orchestrate(format!(
+ "source_patch {target_bin}: {n_fail}/{} check(s) de evidencia \
+ fallaron ⇒ no se propone (artefacto {artifact} sellado pero \
+ NO hidratado)",
+ verdict.checks.len()
+ )));
+ }
+ let lvl = verdict.max_level.map(|k| k.as_str()).unwrap_or("—");
+ checks.push(VerifyCheck::pass(format!(
+ "evidencia: {} check(s) pasan (verificado hasta el estrato '{lvl}')",
+ verdict.checks.len()
+ )));
+ }
+
// Hidratamos localmente desde el store (sin pasar por el bus, evita un
// segundo Inject que duplicaría trabajo).
let store = hammer_core::Store::open(store)?;
@@ -430,6 +463,10 @@ impl VerifyCheck {
pub fn warn(msg: impl Into<String>) -> Self {
Self { level: CheckLevel::Warn, msg: msg.into() }
}
+ /// [H1c] Un check que FALLÓ. Hoy lo produce el gate de evidencia: la mutación no se propone.
+ pub fn fail(msg: impl Into<String>) -> Self {
+ Self { level: CheckLevel::Fail, msg: msg.into() }
+ }
}
/// El resultado del bucle. El humano lo lee para decidir `hammer commit <overlay_id>` (si
diff --git a/crates/hammer-agent/tests/client_against_stub_bus.rs b/crates/hammer-agent/tests/client_against_stub_bus.rs
index 009530be..80f3881a 100644
--- a/crates/hammer-agent/tests/client_against_stub_bus.rs
+++ b/crates/hammer-agent/tests/client_against_stub_bus.rs
@@ -82,13 +82,35 @@ fn stub_serve(stream: UnixStream, rx_async: std::sync::mpsc::Receiver<Event>) {
Err(_) => continue,
};
let response = match cmd {
- Command::Compile { recipe } => Event::BuildReady {
- recipe: recipe.name.clone(),
- artifact: format!(
- "b3:stub-{}",
- recipe.commit.as_deref().or(recipe.sha256.as_deref()).unwrap_or("nosrc")
- ),
- },
+ Command::Compile { recipe } => {
+ // El stub refleja la evidencia declarada como un veredicto "todo pasa" para
+ // ejercitar el camino H1c del cliente (sin lab real que la ejecute).
+ let verdict = (!recipe.evidence.is_empty()).then(|| {
+ hammer_core::proto::EvidenceVerdict {
+ all_passed: true,
+ max_level: recipe.evidence.checks.iter().map(|c| c.kind).max(),
+ checks: recipe
+ .evidence
+ .checks
+ .iter()
+ .map(|c| hammer_core::proto::EvidenceCheckVerdict {
+ kind: c.kind,
+ cmd: c.cmd.clone(),
+ passed: true,
+ detail: "stub ok".into(),
+ })
+ .collect(),
+ }
+ });
+ Event::BuildReady {
+ recipe: recipe.name.clone(),
+ artifact: format!(
+ "b3:stub-{}",
+ recipe.commit.as_deref().or(recipe.sha256.as_deref()).unwrap_or("nosrc")
+ ),
+ verdict,
+ }
+ }
Command::Query { what, .. } => Event::QueryResult {
what,
value: serde_json::json!({"stub": true}),
@@ -145,11 +167,24 @@ fn client_handshake_compile_and_async_modified() {
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec![],
+ evidence: hammer_core::Evidence {
+ checks: vec![hammer_core::EvidenceCheck {
+ kind: hammer_core::EvidenceKind::CmdExit,
+ cmd: "grep --version".into(),
+ expected_exit: 0,
+ expected_output: None,
+ }],
+ },
};
- let artifact = client
+ let outcome = client
.compile(recipe, Duration::from_secs(2))
.expect("BuildReady");
- assert_eq!(artifact, "b3:stub-abc123");
+ assert_eq!(outcome.artifact, "b3:stub-abc123");
+ // [H1c] el veredicto de la evidencia llega por BuildReady y el cliente lo expone.
+ let verdict = outcome.verdict.expect("verdict presente (declaramos evidencia)");
+ assert!(verdict.all_passed);
+ assert_eq!(verdict.checks.len(), 1);
+ assert_eq!(verdict.max_level, Some(hammer_core::EvidenceKind::CmdExit));
// El stub también puede empujar eventos asíncronos. Inyectamos un Modified y vemos
// que el cliente lo entrega por drain_async()/next_async().
diff --git a/crates/hammer-agent/tests/orchestrator_evidence_gate.rs b/crates/hammer-agent/tests/orchestrator_evidence_gate.rs
new file mode 100644
index 00000000..f01251e6
--- /dev/null
+++ b/crates/hammer-agent/tests/orchestrator_evidence_gate.rs
@@ -0,0 +1,158 @@
+//! [H1c] Gate de evidencia en el orquestador contra un bus stub.
+//!
+//! El lab (aquí, un stub) ejecuta la evidencia tras sellar el artefacto y devuelve el veredicto
+//! en `BuildReady`. Si algún check falla, `Orchestrator::run` debe ABORTAR con "no se propone" y
+//! NO hidratar nada al `prefix` — el gate "falla ⇒ no se propone" del SDD 15 §H1.
+
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::PathBuf;
+use std::thread;
+use std::time::Duration;
+
+use hammer_agent::orchestrator::{ApplyTarget, CompileMode, Orchestrator};
+use hammer_agent::MockTranslator;
+use hammer_core::proto::{
+ Cap, Command, Event, EvidenceCheckVerdict, EvidenceVerdict, Peer, PROTOCOL_VERSION,
+};
+use hammer_core::swm::{Base, Mutation, Swm, SwmBuild};
+use hammer_core::{BaseRef, Evidence, EvidenceCheck, EvidenceKind};
+use std::collections::BTreeMap;
+
+/// Bus stub que responde a `Compile` con `BuildReady` cuyo veredicto tiene `all_passed=false`.
+fn start_failing_bus(sock: PathBuf) {
+ let _ = std::fs::remove_file(&sock);
+ let listener = UnixListener::bind(&sock).expect("bind stub");
+ thread::spawn(move || {
+ if let Some(Ok(stream)) = listener.incoming().next() {
+ thread::spawn(move || serve(stream));
+ }
+ });
+}
+
+fn serve(stream: UnixStream) {
+ let mut writer = stream.try_clone().unwrap();
+ let mut reader = BufReader::new(stream);
+ let send = |w: &mut UnixStream, ev: &Event| {
+ w.write_all(serde_json::to_string(ev).unwrap().as_bytes()).unwrap();
+ w.write_all(b"\n").unwrap();
+ w.flush().unwrap();
+ };
+
+ let mut first = String::new();
+ reader.read_line(&mut first).unwrap();
+ let Ok(Command::Hello { .. }) = serde_json::from_str::<Command>(first.trim()) else {
+ return;
+ };
+ send(
+ &mut writer,
+ &Event::Welcome {
+ ver: PROTOCOL_VERSION,
+ caps: vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init],
+ peer: Peer { uid: 1000, gid: 1000, pid: 1 },
+ },
+ );
+
+ loop {
+ let mut line = String::new();
+ match reader.read_line(&mut line) {
+ Ok(0) | Err(_) => break,
+ Ok(_) => {}
+ }
+ if line.trim().is_empty() {
+ continue;
+ }
+ let Ok(cmd) = serde_json::from_str::<Command>(line.trim()) else {
+ continue;
+ };
+ if let Command::Compile { recipe } = cmd {
+ send(
+ &mut writer,
+ &Event::BuildReady {
+ recipe: recipe.name.clone(),
+ artifact: "b3:stub-fail".into(),
+ verdict: Some(EvidenceVerdict {
+ all_passed: false,
+ max_level: None,
+ checks: vec![EvidenceCheckVerdict {
+ kind: EvidenceKind::CmdExit,
+ cmd: "grep --version".into(),
+ passed: false,
+ detail: "exit 7 ≠ esperado 0".into(),
+ }],
+ }),
+ },
+ );
+ }
+ }
+}
+
+fn swm_with_evidence() -> Swm {
+ Swm {
+ swm_version: 1,
+ base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
+ mutations: vec![Mutation::SourcePatch {
+ repo: Some("git://x/grep.git".into()),
+ commit: Some("abc".into()),
+ tarball: None,
+ sha256: None,
+ strip_components: None,
+ patch: None,
+ patch_url: None,
+ build: 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/grep".into(),
+ expected_hash: None,
+ deps: Default::default(),
+ evidence: Evidence {
+ checks: vec![EvidenceCheck {
+ kind: EvidenceKind::CmdExit,
+ cmd: "grep --version".into(),
+ expected_exit: 0,
+ expected_output: None,
+ }],
+ },
+ }],
+ signature: None,
+ }
+}
+
+#[test]
+fn failing_evidence_aborts_and_does_not_hydrate() {
+ let d = tempfile::tempdir().unwrap();
+ let sock = d.path().join("agent.sock");
+ start_failing_bus(sock.clone());
+ for _ in 0..50 {
+ if sock.exists() {
+ break;
+ }
+ thread::sleep(Duration::from_millis(10));
+ }
+
+ let prefix = d.path().join("prefix");
+ let store = d.path().join("store");
+ let base = BaseRef { distro_version: "2026-06-06".into(), pins: BTreeMap::new() };
+ let t = MockTranslator::empty().with_intent("compile grep", swm_with_evidence());
+ let o = Orchestrator::new(
+ t,
+ base,
+ ApplyTarget::Prefix { prefix: prefix.clone() },
+ CompileMode::ViaBus { sock, store, timeout: Duration::from_secs(5) },
+ );
+
+ let err = o.run("compile grep").unwrap_err().to_string();
+ assert!(err.contains("no se propone"), "mensaje inesperado: {err}");
+ assert!(err.contains("evidencia") || err.contains("check(s)"), "{err}");
+
+ // El gate corta ANTES de hidratar: el target_bin no debe existir bajo el prefix.
+ assert!(
+ !prefix.join("usr/bin/grep").exists(),
+ "el binario se hidrató pese al fallo de evidencia"
+ );
+}
diff --git a/crates/hammer-core/src/proto.rs b/crates/hammer-core/src/proto.rs
index d6733efd..7c574c7e 100644
--- a/crates/hammer-core/src/proto.rs
+++ b/crates/hammer-core/src/proto.rs
@@ -84,6 +84,11 @@ pub struct RecipeInline {
pub link: String,
#[serde(default)]
pub flags: Vec<String>,
+ /// [H1c] Bloque de evidencia de comportamiento (proof-carrying recipe). Viaja al lado del
+ /// build para que `hammerd` la EJECUTE tras sellar el artefacto y devuelva el veredicto en
+ /// `BuildReady`. Vacío ⇒ receta sin evidencia (compat con clientes previos a H1c).
+ #[serde(default, skip_serializing_if = "crate::recipe::Evidence::is_empty")]
+ pub evidence: crate::recipe::Evidence,
}
fn default_compiler() -> String {
@@ -108,7 +113,15 @@ pub enum Event {
peer: Peer,
},
/// Build terminó OK. `recipe` es el `name` original; `artifact` es el hash sellado.
- BuildReady { recipe: String, artifact: String },
+ /// `verdict` [H1c]: `None` si la receta no declaró evidencia; `Some` con el resultado de
+ /// ejecutarla en el sandbox reproducible. `all_passed=false` ⇒ el artefacto quedó sellado
+ /// pero una prueba declarada falló — el orquestador lo usa como gate "no proponer".
+ BuildReady {
+ recipe: String,
+ artifact: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ verdict: Option<EvidenceVerdict>,
+ },
/// Build falló. `log_tail` puede llevar las últimas líneas del log del lab si están
/// disponibles (opcional).
BuildFailed {
@@ -149,6 +162,29 @@ pub struct Peer {
pub pid: i32,
}
+/// [H1c] Forma serializable del `EvidenceReport` de `hammer_build` que viaja en `BuildReady`.
+/// El lado del build (hammerd) ejecuta la evidencia y la adjunta; el orquestador la lee sin
+/// depender de `hammer_build` (la separación agente-PROPONE / lab-CONSTRUYE se preserva).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct EvidenceVerdict {
+ /// Gate: `false` ⇒ al menos un check declarado falló ⇒ el orquestador no propone la mutación.
+ pub all_passed: bool,
+ /// Estrato de confianza más alto que pasó (para reportar "verificado hasta …"). No promete
+ /// que ese nivel *baste*, sólo que lo declarado a ese nivel pasó.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub max_level: Option<crate::recipe::EvidenceKind>,
+ pub checks: Vec<EvidenceCheckVerdict>,
+}
+
+/// [H1c] Veredicto de un check individual, ya evaluado (exit + hash del stdout) en el sandbox.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct EvidenceCheckVerdict {
+ pub kind: crate::recipe::EvidenceKind,
+ pub cmd: String,
+ pub passed: bool,
+ pub detail: String,
+}
+
/// Capacidades concedidas a una conexión. Por defecto `query`. La política viene del
/// fichero de policy del daemon (SDD 07 §4); el peer no las elige.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -226,18 +262,23 @@ mod tests {
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec!["--enable-foo".into()],
+ evidence: Default::default(),
},
};
let s = serde_json::to_string(&c).unwrap();
assert!(s.contains(r#""t":"compile""#));
assert!(s.contains(r#""name":"grep""#));
+ // Sin evidencia declarada, el bloque no se serializa (compat con clientes pre-H1c).
+ assert!(!s.contains("evidence"), "{s}");
}
#[test]
fn build_ready_event() {
+ // Sin veredicto, el JSON es idéntico al de antes de H1c (skip_serializing_if).
let e = Event::BuildReady {
recipe: "grep".into(),
artifact: "b3:deadbeef".into(),
+ verdict: None,
};
let s = serde_json::to_string(&e).unwrap();
assert_eq!(
@@ -246,6 +287,28 @@ mod tests {
);
}
+ #[test]
+ fn build_ready_with_verdict_roundtrips() {
+ let e = Event::BuildReady {
+ recipe: "tree".into(),
+ artifact: "b3:cafe".into(),
+ verdict: Some(EvidenceVerdict {
+ all_passed: false,
+ max_level: Some(crate::recipe::EvidenceKind::CmdExit),
+ checks: vec![EvidenceCheckVerdict {
+ kind: crate::recipe::EvidenceKind::CmdExit,
+ cmd: "tree --version".into(),
+ passed: false,
+ detail: "exit 7 ≠ esperado 0".into(),
+ }],
+ }),
+ };
+ let s = serde_json::to_string(&e).unwrap();
+ assert!(s.contains(r#""all_passed":false"#), "{s}");
+ let back: Event = serde_json::from_str(&s).unwrap();
+ assert_eq!(back, e);
+ }
+
#[test]
fn error_event_minimal() {
let e = Event::Error { code: "no_cap".into(), msg: "no tienes 'compile'".into() };
diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs
index 9b802971..8a3dde75 100644
--- a/crates/hammerd/src/bus.rs
+++ b/crates/hammerd/src/bus.rs
@@ -25,7 +25,10 @@ use std::thread;
use nix::sys::socket::{getsockopt, sockopt::PeerCredentials};
use crate::events::EventBus;
-use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION};
+use hammer_core::proto::{
+ Cap, Command, Event, EvidenceCheckVerdict, EvidenceVerdict, Peer, RecipeInline,
+ PROTOCOL_VERSION,
+};
/// Política de capacidades: dado el `Peer` (ya autenticado por SO_PEERCRED), devuelve qué
/// caps tiene esa conexión. Por defecto: misma UID que el daemon ⇒ todo menos `inject-real`;
@@ -379,7 +382,9 @@ 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(),
+ // [H1c] La evidencia declarada por el cliente viaja a la mutación para ejecutarse tras
+ // sellar el artefacto (ver `evidence_verdict`).
+ evidence: recipe.evidence,
};
let store = match hammer_core::Store::open(&store_root) {
Ok(s) => s,
@@ -395,9 +400,15 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
match hammer_build::build_source_patch(&mutation, &cfg, &store, None, None) {
Ok(h) => {
+ // [H1c] El artefacto está sellado; ahora EJECUTAMOS la evidencia declarada y
+ // adjuntamos el veredicto. El gate ("no proponer" si falla) vive en el orquestador
+ // —el lab sólo construye y reporta—; por eso enviamos `BuildReady` con veredicto
+ // aunque algún check falle.
+ let verdict = evidence_verdict(&mutation, &cfg, &store);
let _ = tx.send(Event::BuildReady {
recipe: name,
artifact: h.as_str().to_string(),
+ verdict,
});
}
Err(e) => {
@@ -416,6 +427,67 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
}
}
+/// [H1c] Ejecuta la evidencia declarada de una mutación `source_patch` y la reduce al veredicto
+/// serializable del bus. Devuelve:
+/// - `None` si la receta no declara evidencia (nada que correr).
+/// - `Some(all_passed=true/false, …)` con el resultado por check.
+/// - `Some(all_passed=false, …)` sintético si la evidencia ni siquiera pudo ejecutarse (falta una
+/// build-dep, error de sandbox…): eso también debe bloquear la propuesta, no pasar en silencio.
+fn evidence_verdict(
+ mutation: &hammer_core::swm::Mutation,
+ cfg: &hammer_build::BuildConfig,
+ store: &hammer_core::Store,
+) -> Option<EvidenceVerdict> {
+ let has_evidence = matches!(
+ mutation,
+ hammer_core::swm::Mutation::SourcePatch { evidence, .. } if !evidence.is_empty()
+ );
+ if !has_evidence {
+ return None;
+ }
+ match run_evidence_inner(mutation, cfg, store) {
+ Ok(v) => Some(v),
+ Err(e) => {
+ tracing::warn!(error = %e, "evidence: no se pudo ejecutar; veredicto = fallo");
+ Some(EvidenceVerdict {
+ all_passed: false,
+ max_level: None,
+ checks: vec![EvidenceCheckVerdict {
+ kind: hammer_core::EvidenceKind::CmdExit,
+ cmd: "<runner>".into(),
+ passed: false,
+ detail: format!("la evidencia no pudo ejecutarse: {e}"),
+ }],
+ })
+ }
+ }
+}
+
+fn run_evidence_inner(
+ mutation: &hammer_core::swm::Mutation,
+ cfg: &hammer_build::BuildConfig,
+ store: &hammer_core::Store,
+) -> hammer_core::Result<EvidenceVerdict> {
+ let catalog_dir = hammer_build::swm_bridge::catalog_dir_for(cfg, None);
+ std::fs::create_dir_all(&catalog_dir)?;
+ let recipe = hammer_build::swm_bridge::recipe_from_source_patch(mutation, None, &catalog_dir)?;
+ let report = hammer_build::run_evidence(&recipe, cfg, store)?;
+ Ok(EvidenceVerdict {
+ all_passed: report.all_passed(),
+ max_level: report.max_level_passed(),
+ checks: report
+ .results
+ .iter()
+ .map(|r| EvidenceCheckVerdict {
+ kind: r.kind,
+ cmd: r.cmd.clone(),
+ passed: r.outcome.passed,
+ detail: r.outcome.detail.clone(),
+ })
+ .collect(),
+ })
+}
+
fn query_file(path: &str) -> serde_json::Value {
let p = Path::new(path);
let meta = match std::fs::symlink_metadata(p) {
@@ -510,6 +582,7 @@ caps = ["query", "compile", "inject", "inject-real", "init"]
target: "t".into(),
link: "static".into(),
flags: vec![],
+ evidence: Default::default(),
},
}),
"compile"
diff --git a/crates/hammerd/tests/bus_e2e.rs b/crates/hammerd/tests/bus_e2e.rs
index 12799879..2209955a 100644
--- a/crates/hammerd/tests/bus_e2e.rs
+++ b/crates/hammerd/tests/bus_e2e.rs
@@ -147,6 +147,7 @@ fn missing_cap_returns_error_no_cap() {
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec![],
+ evidence: Default::default(),
},
},
);
diff --git a/docs/15-frontier-ai-native.md b/docs/15-frontier-ai-native.md
index 48108f01..26090726 100644
--- a/docs/15-frontier-ai-native.md
+++ b/docs/15-frontier-ai-native.md
@@ -49,17 +49,23 @@ del lado del init: **el permiso deriva de una propiedad verificada del artefacto
firmó.** H1 es esa idea, subida del boot al build.
**Tickets:**
-- **H1a** — Extender el `.swm` (SDD 06) con un bloque `evidence`: lista de `{kind, cmd, expected}`
+- **H1a** ✅ — Extender el `.swm` (SDD 06) con un bloque `evidence`: lista de `{kind, cmd, expected}`
donde `kind ∈ {proptest, contract, kani, cmd-exit}`. Serialización YAML estable + `verify_schema`.
-- **H1b** — El checker: `hammer swm-verify --evidence` corre cada ítem **dentro del sandbox
+- **H1b** ✅ — El checker: `hammer swm-verify --evidence` corre cada ítem **dentro del sandbox
reproducible** y exige `expected`. Pequeño y auditable a propósito (no un framework: un runner
de comandos con hash del output). Falla ⇒ no se propone. Reusa `Sandbox::run` + el `log_tail`
ya existente (Fase 5).
-- **H1c** — Cablear al `Orchestrator`: VERIFY consume `evidence`; el `Proposal` lleva el
+- **H1c** ✅ — Cablear al `Orchestrator`: VERIFY consume `evidence`; el `Proposal` lleva el
**veredicto de evidencia** además del diff. El humano ve "reproduce + estas N pruebas pasan",
no sólo "reproduce". **Frontera:** el checker confía en que las pruebas *cubren* lo que
importa — eso lo juzga el humano; H1 garantiza que *lo declarado pasa*, no que *lo declarado
basta*. Se dice así.
+ **Implementación:** la evidencia corre del lado del BUILD (no del agente — `hammer-agent` no
+ depende de `hammer-build`, separación PROPONE/CONSTRUYE). `proto::RecipeInline` lleva `evidence`
+ y `Event::BuildReady` lleva `verdict: Option<EvidenceVerdict>`; `hammerd::bus` la ejecuta tras
+ sellar el artefacto (`run_evidence` vía `swm_bridge::recipe_from_source_patch`) y adjunta el
+ veredicto; el `Orchestrator` lee el veredicto y, si `all_passed=false`, empuja `VerifyCheck::fail`
+ y **aborta antes de hidratar** (el artefacto queda sellado pero no toca el sistema).
**Frontera honesta.** Property tests no son prueba; Kani/Creusot sí pero sólo cubren crates que
se dejan. H1 **estratifica** la confianza (`cmd-exit` < `proptest` < `contract` < `kani`) y la