Skip to main content

rch_common/
proof_handoff.rs

1//! Agent-facing proof handoff output
2//! (bd-session-history-remediation-ocv9i.5.4).
3//!
4//! When proof is denied or queued, a Beads / Agent-Mail handoff must not rely on
5//! prose like "RCH had no admissible workers". This module emits a concise,
6//! machine-readable [`ProofHandoff`]: the proof-intent id, a decisive incident
7//! reason code, the next action to take, a replay command, and — critically — a
8//! [`ProofFailureClass`] discriminator that tells a downstream agent whether the
9//! failure was the product's, the infrastructure's, an admission denial, or a
10//! refused local fallback.
11//!
12//! The handoff is a plain serializable value with **no Beads dependency** — a
13//! handoff consumer embeds its JSON; proof storage never depends on Beads. It
14//! reuses the proof-mode policy ([`crate::proof_policy`]), the next-action
15//! vocabulary ([`crate::admission_recommendation`]), the incident registry, and
16//! the exec failure classifier ([`crate::disk_pressure_report`]).
17
18use serde::{Deserialize, Serialize};
19
20use crate::admission_recommendation::NextAction;
21use crate::disk_pressure_report::ExecFailureClass;
22use crate::incident::IncidentReasonCode;
23use crate::proof_policy::{ProofRefusal, ProofRefusalReason};
24
25/// The top-level class of a proof failure — the discriminator the bead requires.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ProofFailureClass {
29    /// The command ran remotely and the product failed (compile/test error).
30    ProductFailure,
31    /// A worker/transport/daemon/environment problem (not the product).
32    InfrastructureFailure,
33    /// Refused before execution (no admissible worker, policy, capability).
34    AdmissionDenial,
35    /// Proof mode refused to accept a local fallback.
36    LocalFallbackRefused,
37}
38
39impl ProofFailureClass {
40    /// Stable lowercase token.
41    #[must_use]
42    pub fn as_str(self) -> &'static str {
43        match self {
44            ProofFailureClass::ProductFailure => "product_failure",
45            ProofFailureClass::InfrastructureFailure => "infrastructure_failure",
46            ProofFailureClass::AdmissionDenial => "admission_denial",
47            ProofFailureClass::LocalFallbackRefused => "local_fallback_refused",
48        }
49    }
50
51    /// All four classes (for coverage tests).
52    pub const ALL: &'static [ProofFailureClass] = &[
53        Self::ProductFailure,
54        Self::InfrastructureFailure,
55        Self::AdmissionDenial,
56        Self::LocalFallbackRefused,
57    ];
58
59    /// Class of an exec failure (a command that ran on a worker).
60    #[must_use]
61    pub fn from_exec_failure(class: ExecFailureClass) -> Self {
62        match class {
63            ExecFailureClass::ProductCompile => Self::ProductFailure,
64            ExecFailureClass::WorkerEnvironment => Self::InfrastructureFailure,
65            ExecFailureClass::Indeterminate => Self::InfrastructureFailure,
66        }
67    }
68
69    /// Class implied by an incident reason code (for pre-execution incidents).
70    #[must_use]
71    pub fn from_incident_reason(code: IncidentReasonCode) -> Self {
72        match code {
73            // Worker/transport/environment infrastructure.
74            IncidentReasonCode::DaemonSocketRefused
75            | IncidentReasonCode::RsyncVanishedFile
76            | IncidentReasonCode::DiskFull
77            | IncidentReasonCode::CriticalPressure
78            | IncidentReasonCode::WrongUserPathWorkerBinary
79            | IncidentReasonCode::ArtifactMiss => Self::InfrastructureFailure,
80            // Proof refused to accept a local fallback.
81            IncidentReasonCode::LocalFallback => Self::LocalFallbackRefused,
82            // Everything else is an admission/policy denial.
83            _ => Self::AdmissionDenial,
84        }
85    }
86}
87
88/// Map a proof refusal reason to its failure class and next action.
89fn refusal_class(reason: ProofRefusalReason) -> ProofFailureClass {
90    match reason {
91        // The classifier would run local; proof refused that fallback.
92        ProofRefusalReason::ClassifierChoseLocal => ProofFailureClass::LocalFallbackRefused,
93        // No admissible worker / unsafe shape / unhonored placement: admission denial.
94        ProofRefusalReason::NoAdmissibleWorker
95        | ProofRefusalReason::UnsafeCommandShape
96        | ProofRefusalReason::PlacementProfileUnhonored => ProofFailureClass::AdmissionDenial,
97    }
98}
99
100/// The next action an agent should take for a refusal reason.
101#[must_use]
102pub fn next_action_for_refusal(reason: ProofRefusalReason) -> NextAction {
103    match reason {
104        ProofRefusalReason::NoAdmissibleWorker => NextAction::QueueProof,
105        ProofRefusalReason::ClassifierChoseLocal => NextAction::RefreshCapabilities,
106        ProofRefusalReason::UnsafeCommandShape => NextAction::ShrinkScope,
107        ProofRefusalReason::PlacementProfileUnhonored => NextAction::ContactOperator,
108    }
109}
110
111/// The concise, machine-readable proof handoff for Beads / Agent-Mail.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct ProofHandoff {
114    /// Durable proof-intent id (see [`crate::proof_intent`]).
115    pub proof_intent_id: String,
116    /// The top-level failure discriminator.
117    pub failure_class: ProofFailureClass,
118    /// Decisive incident reason code (`RCH-Innn`).
119    pub reason_code: IncidentReasonCode,
120    /// The next action an agent should take.
121    pub next_action: NextAction,
122    /// A command the agent can run to retry/replay.
123    pub replay_command: String,
124    /// Concise human/agent-facing detail.
125    pub detail: String,
126}
127
128impl ProofHandoff {
129    /// Build a handoff for a proof refusal.
130    #[must_use]
131    pub fn for_refusal(
132        intent_id: impl Into<String>,
133        command: &str,
134        refusal: &ProofRefusal,
135    ) -> Self {
136        let next_action = next_action_for_refusal(refusal.reason);
137        Self {
138            proof_intent_id: intent_id.into(),
139            failure_class: refusal_class(refusal.reason),
140            reason_code: refusal.incident_reason,
141            next_action,
142            replay_command: format!("rch exec --proof -- {command}"),
143            detail: refusal.detail.clone(),
144        }
145    }
146
147    /// Build a handoff for a proof that was queued (deferred). The replay
148    /// command reattaches by intent id.
149    #[must_use]
150    pub fn for_queued(intent_id: impl Into<String>, command: &str) -> Self {
151        let intent_id = intent_id.into();
152        Self {
153            replay_command: format!("rch exec --proof -- {command}"),
154            detail: format!("proof queued; reattach with intent {intent_id}"),
155            proof_intent_id: intent_id,
156            failure_class: ProofFailureClass::AdmissionDenial,
157            reason_code: IncidentReasonCode::InsufficientSlots,
158            next_action: NextAction::QueueProof,
159        }
160    }
161
162    /// Whether this handoff blames the product (vs infra/admission/fallback).
163    #[must_use]
164    pub fn is_product_failure(&self) -> bool {
165        self.failure_class == ProofFailureClass::ProductFailure
166    }
167
168    /// One-line summary for logs.
169    #[must_use]
170    pub fn render(&self) -> String {
171        format!(
172            "proof handoff [{}] {} ({}) -> {} | replay: {}",
173            self.proof_intent_id,
174            self.failure_class.as_str(),
175            self.reason_code.code(),
176            self.next_action.as_str(),
177            self.replay_command,
178        )
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn refusal_for_no_admissible_worker_is_admission_denial_queue() {
188        let refusal = ProofRefusal::new(ProofRefusalReason::NoAdmissibleWorker);
189        let handoff = ProofHandoff::for_refusal("intent-1", "cargo build", &refusal);
190        assert_eq!(handoff.failure_class, ProofFailureClass::AdmissionDenial);
191        assert_eq!(handoff.reason_code, IncidentReasonCode::ProofRefusal);
192        assert_eq!(handoff.next_action, NextAction::QueueProof);
193        assert_eq!(handoff.replay_command, "rch exec --proof -- cargo build");
194        assert_eq!(handoff.proof_intent_id, "intent-1");
195    }
196
197    #[test]
198    fn classifier_chose_local_is_local_fallback_refused() {
199        let refusal = ProofRefusal::new(ProofRefusalReason::ClassifierChoseLocal);
200        let handoff = ProofHandoff::for_refusal("i2", "cargo fmt", &refusal);
201        assert_eq!(
202            handoff.failure_class,
203            ProofFailureClass::LocalFallbackRefused
204        );
205        assert_eq!(handoff.next_action, NextAction::RefreshCapabilities);
206    }
207
208    #[test]
209    fn unsafe_shape_and_placement_reasons_map_through() {
210        let unsafe_h = ProofHandoff::for_refusal(
211            "i",
212            "x",
213            &ProofRefusal::new(ProofRefusalReason::UnsafeCommandShape),
214        );
215        assert_eq!(unsafe_h.next_action, NextAction::ShrinkScope);
216        assert_eq!(unsafe_h.failure_class, ProofFailureClass::AdmissionDenial);
217        let place_h = ProofHandoff::for_refusal(
218            "i",
219            "x",
220            &ProofRefusal::new(ProofRefusalReason::PlacementProfileUnhonored),
221        );
222        assert_eq!(place_h.next_action, NextAction::ContactOperator);
223    }
224
225    #[test]
226    fn queued_handoff_reattaches_by_intent() {
227        let handoff = ProofHandoff::for_queued("intent-q", "cargo test");
228        assert_eq!(handoff.next_action, NextAction::QueueProof);
229        assert!(handoff.detail.contains("intent-q"));
230        assert!(handoff.replay_command.contains("cargo test"));
231    }
232
233    #[test]
234    fn failure_class_from_exec_failure() {
235        assert_eq!(
236            ProofFailureClass::from_exec_failure(ExecFailureClass::ProductCompile),
237            ProofFailureClass::ProductFailure
238        );
239        assert_eq!(
240            ProofFailureClass::from_exec_failure(ExecFailureClass::WorkerEnvironment),
241            ProofFailureClass::InfrastructureFailure
242        );
243    }
244
245    #[test]
246    fn failure_class_from_incident_reason() {
247        assert_eq!(
248            ProofFailureClass::from_incident_reason(IncidentReasonCode::DaemonSocketRefused),
249            ProofFailureClass::InfrastructureFailure
250        );
251        assert_eq!(
252            ProofFailureClass::from_incident_reason(IncidentReasonCode::DiskFull),
253            ProofFailureClass::InfrastructureFailure
254        );
255        assert_eq!(
256            ProofFailureClass::from_incident_reason(IncidentReasonCode::NoAdmissibleWorkers),
257            ProofFailureClass::AdmissionDenial
258        );
259        assert_eq!(
260            ProofFailureClass::from_incident_reason(IncidentReasonCode::LocalFallback),
261            ProofFailureClass::LocalFallbackRefused
262        );
263    }
264
265    #[test]
266    fn json_carries_all_four_discriminators() {
267        // Every required discriminator is representable and stable.
268        let tokens: Vec<&str> = ProofFailureClass::ALL.iter().map(|c| c.as_str()).collect();
269        assert!(tokens.contains(&"product_failure"));
270        assert!(tokens.contains(&"infrastructure_failure"));
271        assert!(tokens.contains(&"admission_denial"));
272        assert!(tokens.contains(&"local_fallback_refused"));
273    }
274
275    #[test]
276    fn handoff_serializes_standalone_without_beads() {
277        // The handoff is a plain value — it serializes with no Beads coupling.
278        let refusal = ProofRefusal::new(ProofRefusalReason::NoAdmissibleWorker);
279        let handoff = ProofHandoff::for_refusal("intent-1", "cargo build", &refusal);
280        let value = serde_json::to_value(&handoff).unwrap();
281        assert_eq!(value["failure_class"], "admission_denial");
282        assert_eq!(value["reason_code"], "RCH-I012");
283        assert_eq!(value["next_action"], "queue_proof");
284        assert_eq!(value["proof_intent_id"], "intent-1");
285        let back: ProofHandoff = serde_json::from_value(value).unwrap();
286        assert_eq!(back, handoff);
287    }
288
289    #[test]
290    fn render_is_concise_and_machine_greppable() {
291        let refusal = ProofRefusal::new(ProofRefusalReason::NoAdmissibleWorker);
292        let line = ProofHandoff::for_refusal("intent-1", "cargo build", &refusal).render();
293        assert!(line.contains("intent-1"));
294        assert!(line.contains("admission_denial"));
295        assert!(line.contains("queue_proof"));
296        assert!(line.contains("replay:"));
297    }
298}