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>
This commit is contained in:
2026-07-02 13:33:25 -04:00
co-authored by Claude Opus 4.8
parent 497d0d00e7
commit 6ac6438c00
9 changed files with 401 additions and 20 deletions
+10 -2
View File
@@ -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,
+1 -1
View File
@@ -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};
+39 -2
View File
@@ -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
@@ -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().
@@ -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"
);
}
+64 -1
View File
@@ -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() };
+75 -2
View File
@@ -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"
+1
View File
@@ -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(),
},
},
);