Skip to main content

rch_common/
proof_policy.rs

1//! Proof-mode fail-closed execution policy
2//! (bd-session-history-remediation-ocv9i.5.1).
3//!
4//! Proof mode exists to *prove* a command ran on a remote worker. Its cardinal
5//! rule, learned from session history where a local fallback was silently
6//! stamped "success": **proof mode must never run locally and must never report
7//! a local fallback as success.** It either proceeds with genuine remote
8//! execution or refuses *before* anything runs, with a durable, machine-readable
9//! reason.
10//!
11//! [`ProofVerdict`] encodes that guarantee in the type system — it has only
12//! `ProceedRemote` and `Refuse`; there is deliberately no `Local` variant, so no
13//! code path can accidentally satisfy proof mode locally. [`evaluate_proof_mode`]
14//! is the pure decision over observable [`ProofInputs`]; [`ProofRefusal`] is the
15//! durable JSON envelope that records the refusal and marks it an *admission /
16//! policy denial*, distinct from a product compile/test failure (which is a
17//! separate, post-execution outcome — see
18//! [`crate::disk_pressure_report::classify_exec_failure`]).
19
20use serde::{Deserialize, Serialize};
21
22use crate::admit_preflight::AdmitRecommendation;
23use crate::incident::IncidentReasonCode;
24
25/// Why proof mode refused to proceed. Each is a refusal *before* execution.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ProofRefusalReason {
29    /// No worker was admissible for the command.
30    NoAdmissibleWorker,
31    /// Classifier/preflight state would run the command locally — proof mode
32    /// cannot be satisfied locally.
33    ClassifierChoseLocal,
34    /// The command shape is unsafe to prove remotely (e.g. not a clean,
35    /// reproducible compilation).
36    UnsafeCommandShape,
37    /// The requested placement profile (specific worker/profile) cannot be
38    /// honored.
39    PlacementProfileUnhonored,
40}
41
42impl ProofRefusalReason {
43    /// Stable lowercase token.
44    #[must_use]
45    pub fn as_str(self) -> &'static str {
46        match self {
47            ProofRefusalReason::NoAdmissibleWorker => "no_admissible_worker",
48            ProofRefusalReason::ClassifierChoseLocal => "classifier_chose_local",
49            ProofRefusalReason::UnsafeCommandShape => "unsafe_command_shape",
50            ProofRefusalReason::PlacementProfileUnhonored => "placement_profile_unhonored",
51        }
52    }
53
54    /// All refusal reasons, declaration order (for coverage tests).
55    pub const ALL: &'static [ProofRefusalReason] = &[
56        Self::NoAdmissibleWorker,
57        Self::ClassifierChoseLocal,
58        Self::UnsafeCommandShape,
59        Self::PlacementProfileUnhonored,
60    ];
61
62    /// Operator-facing explanation.
63    #[must_use]
64    pub fn detail(self) -> &'static str {
65        match self {
66            ProofRefusalReason::NoAdmissibleWorker => {
67                "proof mode requires remote execution but no worker is admissible"
68            }
69            ProofRefusalReason::ClassifierChoseLocal => {
70                "classifier/preflight would run this locally; proof mode cannot be satisfied locally"
71            }
72            ProofRefusalReason::UnsafeCommandShape => {
73                "command shape is unsafe to prove remotely (not a clean reproducible compilation)"
74            }
75            ProofRefusalReason::PlacementProfileUnhonored => {
76                "requested placement profile cannot be honored"
77            }
78        }
79    }
80}
81
82/// The proof-mode decision. Note the absence of any `Local` variant: proof mode
83/// is fail-closed by construction.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum ProofVerdict {
86    /// Proceed with genuine remote execution.
87    ProceedRemote,
88    /// Refuse before running anything.
89    Refuse(ProofRefusalReason),
90}
91
92impl ProofVerdict {
93    /// Whether proof mode will proceed (to remote execution).
94    #[must_use]
95    pub fn proceeds(&self) -> bool {
96        matches!(self, ProofVerdict::ProceedRemote)
97    }
98
99    /// The refusal reason, if refused.
100    #[must_use]
101    pub fn refusal(&self) -> Option<ProofRefusalReason> {
102        match self {
103            ProofVerdict::ProceedRemote => None,
104            ProofVerdict::Refuse(reason) => Some(*reason),
105        }
106    }
107}
108
109/// Observable inputs for the proof-mode decision. Plain data so the policy is
110/// pure and unit-testable.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct ProofInputs {
113    /// The preflight recommendation for the command.
114    pub recommendation: AdmitRecommendation,
115    /// At least one worker was admissible for the command.
116    pub any_admissible: bool,
117    /// The command shape is safe to prove remotely.
118    pub command_safe_for_proof: bool,
119    /// The requested placement profile can be honored.
120    pub placement_honored: bool,
121}
122
123impl ProofInputs {
124    /// Inputs for a clean, offload-eligible, admissible, honorable command.
125    #[must_use]
126    pub fn ready() -> Self {
127        Self {
128            recommendation: AdmitRecommendation::Offload,
129            any_admissible: true,
130            command_safe_for_proof: true,
131            placement_honored: true,
132        }
133    }
134}
135
136/// Decide proof mode. Pure and total — and, by the [`ProofVerdict`] type, can
137/// only ever proceed-remote or refuse, never run locally.
138///
139/// Refusal precedence (most fundamental first): a `Local` recommendation means
140/// the classifier would never even attempt remote, so that is reported first;
141/// then an unsafe command shape; then no admissible worker; then an unhonored
142/// placement profile. A `Defer`/`Queue` recommendation with no admissible
143/// worker also refuses (proof mode does not wait or queue — it must run now).
144#[must_use]
145pub fn evaluate_proof_mode(inputs: &ProofInputs) -> ProofVerdict {
146    if inputs.recommendation == AdmitRecommendation::Local {
147        return ProofVerdict::Refuse(ProofRefusalReason::ClassifierChoseLocal);
148    }
149    if !inputs.command_safe_for_proof {
150        return ProofVerdict::Refuse(ProofRefusalReason::UnsafeCommandShape);
151    }
152    if !inputs.any_admissible {
153        return ProofVerdict::Refuse(ProofRefusalReason::NoAdmissibleWorker);
154    }
155    if !inputs.placement_honored {
156        return ProofVerdict::Refuse(ProofRefusalReason::PlacementProfileUnhonored);
157    }
158    ProofVerdict::ProceedRemote
159}
160
161/// The durable JSON envelope for a proof-mode refusal. `category` is fixed to
162/// `admission_denial` so a consumer never confuses a proof *refusal* (nothing
163/// ran) with a product compile/test *failure* (something ran and failed).
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct ProofRefusal {
166    /// Always true — this envelope only exists for refusals.
167    pub refused: bool,
168    /// Fixed discriminator distinguishing this from a product failure.
169    pub category: String,
170    /// The refusal reason token.
171    pub reason: ProofRefusalReason,
172    /// The stable incident reason code (`RCH-I012` proof refusal).
173    pub incident_reason: IncidentReasonCode,
174    /// Operator-facing detail.
175    pub detail: String,
176}
177
178impl ProofRefusal {
179    /// The fixed category discriminator.
180    pub const CATEGORY: &'static str = "admission_denial";
181
182    /// Build a refusal envelope from a reason.
183    #[must_use]
184    pub fn new(reason: ProofRefusalReason) -> Self {
185        Self {
186            refused: true,
187            category: Self::CATEGORY.to_string(),
188            reason,
189            incident_reason: IncidentReasonCode::ProofRefusal,
190            detail: reason.detail().to_string(),
191        }
192    }
193
194    /// Build a refusal envelope from a verdict, if it refused.
195    #[must_use]
196    pub fn from_verdict(verdict: &ProofVerdict) -> Option<Self> {
197        verdict.refusal().map(Self::new)
198    }
199
200    /// Whether this denial is an admission/policy denial (always true) — i.e.
201    /// NOT a product compile/test failure.
202    #[must_use]
203    pub fn is_admission_denial(&self) -> bool {
204        self.category == Self::CATEGORY
205    }
206
207    /// Human-readable line.
208    #[must_use]
209    pub fn render(&self) -> String {
210        format!(
211            "proof refused ({}, {}): {}",
212            self.reason.as_str(),
213            self.incident_reason.code(),
214            self.detail
215        )
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn proceeds_only_when_remote_admissible_safe_and_honored() {
225        assert_eq!(
226            evaluate_proof_mode(&ProofInputs::ready()),
227            ProofVerdict::ProceedRemote
228        );
229    }
230
231    #[test]
232    fn refuses_when_classifier_chose_local() {
233        // The headline guarantee: proof mode never runs locally.
234        let inputs = ProofInputs {
235            recommendation: AdmitRecommendation::Local,
236            ..ProofInputs::ready()
237        };
238        let v = evaluate_proof_mode(&inputs);
239        assert_eq!(
240            v,
241            ProofVerdict::Refuse(ProofRefusalReason::ClassifierChoseLocal)
242        );
243        assert!(!v.proceeds());
244    }
245
246    #[test]
247    fn refuses_when_no_admissible_worker() {
248        let inputs = ProofInputs {
249            any_admissible: false,
250            ..ProofInputs::ready()
251        };
252        assert_eq!(
253            evaluate_proof_mode(&inputs),
254            ProofVerdict::Refuse(ProofRefusalReason::NoAdmissibleWorker)
255        );
256    }
257
258    #[test]
259    fn refuses_when_command_shape_unsafe() {
260        let inputs = ProofInputs {
261            command_safe_for_proof: false,
262            ..ProofInputs::ready()
263        };
264        assert_eq!(
265            evaluate_proof_mode(&inputs),
266            ProofVerdict::Refuse(ProofRefusalReason::UnsafeCommandShape)
267        );
268    }
269
270    #[test]
271    fn refuses_when_placement_profile_unhonored() {
272        let inputs = ProofInputs {
273            placement_honored: false,
274            ..ProofInputs::ready()
275        };
276        assert_eq!(
277            evaluate_proof_mode(&inputs),
278            ProofVerdict::Refuse(ProofRefusalReason::PlacementProfileUnhonored)
279        );
280    }
281
282    #[test]
283    fn defer_recommendation_without_admissible_still_refuses_never_waits() {
284        // Proof mode does not queue/defer; a Defer recommendation with no
285        // admissible worker refuses now rather than waiting.
286        let inputs = ProofInputs {
287            recommendation: AdmitRecommendation::Defer,
288            any_admissible: false,
289            ..ProofInputs::ready()
290        };
291        assert_eq!(
292            evaluate_proof_mode(&inputs),
293            ProofVerdict::Refuse(ProofRefusalReason::NoAdmissibleWorker)
294        );
295    }
296
297    #[test]
298    fn local_recommendation_outranks_other_refusals() {
299        // Even with everything else also wrong, a Local recommendation reports
300        // ClassifierChoseLocal (the most fundamental: it would never go remote).
301        let inputs = ProofInputs {
302            recommendation: AdmitRecommendation::Local,
303            any_admissible: false,
304            command_safe_for_proof: false,
305            placement_honored: false,
306        };
307        assert_eq!(
308            evaluate_proof_mode(&inputs).refusal(),
309            Some(ProofRefusalReason::ClassifierChoseLocal)
310        );
311    }
312
313    #[test]
314    fn verdict_type_has_no_local_variant() {
315        // Exhaustive match proves the type can only proceed-remote or refuse —
316        // there is no way to express "ran locally and proved".
317        for inputs in [
318            ProofInputs::ready(),
319            ProofInputs {
320                any_admissible: false,
321                ..ProofInputs::ready()
322            },
323        ] {
324            match evaluate_proof_mode(&inputs) {
325                ProofVerdict::ProceedRemote | ProofVerdict::Refuse(_) => {}
326            }
327        }
328    }
329
330    #[test]
331    fn refusal_envelope_is_durable_and_marks_admission_denial() {
332        let verdict = evaluate_proof_mode(&ProofInputs {
333            any_admissible: false,
334            ..ProofInputs::ready()
335        });
336        let refusal = ProofRefusal::from_verdict(&verdict).expect("refused");
337        assert!(refusal.refused);
338        assert!(refusal.is_admission_denial());
339        assert_eq!(refusal.incident_reason, IncidentReasonCode::ProofRefusal);
340        let value = serde_json::to_value(&refusal).unwrap();
341        assert_eq!(value["category"], "admission_denial");
342        assert_eq!(value["reason"], "no_admissible_worker");
343        assert_eq!(value["incident_reason"], "RCH-I012");
344        // Durable round-trip.
345        let back: ProofRefusal = serde_json::from_value(value).unwrap();
346        assert_eq!(back, refusal);
347    }
348
349    #[test]
350    fn proceed_verdict_has_no_refusal_envelope() {
351        let verdict = evaluate_proof_mode(&ProofInputs::ready());
352        assert!(ProofRefusal::from_verdict(&verdict).is_none());
353    }
354
355    #[test]
356    fn every_refusal_reason_has_token_and_detail() {
357        let mut tokens: Vec<&str> = ProofRefusalReason::ALL.iter().map(|r| r.as_str()).collect();
358        tokens.sort_unstable();
359        let before = tokens.len();
360        tokens.dedup();
361        assert_eq!(before, tokens.len(), "duplicate refusal tokens");
362        for r in ProofRefusalReason::ALL {
363            assert!(!r.detail().is_empty());
364        }
365    }
366}