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 Definitions

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 · (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

9 files · +401 −20

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