1use serde::{Deserialize, Serialize};
21
22use crate::admit_preflight::AdmitRecommendation;
23use crate::incident::IncidentReasonCode;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ProofRefusalReason {
29 NoAdmissibleWorker,
31 ClassifierChoseLocal,
34 UnsafeCommandShape,
37 PlacementProfileUnhonored,
40}
41
42impl ProofRefusalReason {
43 #[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 pub const ALL: &'static [ProofRefusalReason] = &[
56 Self::NoAdmissibleWorker,
57 Self::ClassifierChoseLocal,
58 Self::UnsafeCommandShape,
59 Self::PlacementProfileUnhonored,
60 ];
61
62 #[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#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum ProofVerdict {
86 ProceedRemote,
88 Refuse(ProofRefusalReason),
90}
91
92impl ProofVerdict {
93 #[must_use]
95 pub fn proceeds(&self) -> bool {
96 matches!(self, ProofVerdict::ProceedRemote)
97 }
98
99 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct ProofInputs {
113 pub recommendation: AdmitRecommendation,
115 pub any_admissible: bool,
117 pub command_safe_for_proof: bool,
119 pub placement_honored: bool,
121}
122
123impl ProofInputs {
124 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct ProofRefusal {
166 pub refused: bool,
168 pub category: String,
170 pub reason: ProofRefusalReason,
172 pub incident_reason: IncidentReasonCode,
174 pub detail: String,
176}
177
178impl ProofRefusal {
179 pub const CATEGORY: &'static str = "admission_denial";
181
182 #[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 #[must_use]
196 pub fn from_verdict(verdict: &ProofVerdict) -> Option<Self> {
197 verdict.refusal().map(Self::new)
198 }
199
200 #[must_use]
203 pub fn is_admission_denial(&self) -> bool {
204 self.category == Self::CATEGORY
205 }
206
207 #[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 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 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 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 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 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}