use crate::proposals::{ProposalError, ProposalStore};
use crate::safety_gates::{
ApprovalRequirement, ChangeEntry, SafetyGatesTable, classify_proposal_changes,
};
#[derive(Debug, thiserror::Error)]
pub enum GatedProposalError {
#[error("Proposal error: {0}")]
Proposal(#[from] ProposalError),
#[error("Safety gate requires human approval (gate: {gate_id})")]
HumanGateRequired { gate_id: String },
#[error("Shadow evaluation required but not provided")]
ShadowEvalRequired,
#[error("Shadow evaluation below threshold: improvement {actual:.4} < required {required:.4}")]
BelowThreshold { actual: f64, required: f64 },
#[error("Proof gates failed (CQ, KBDD, provenance, or do-calculus)")]
ProofGatesFailed,
}
pub type Result<T> = std::result::Result<T, GatedProposalError>;
#[derive(Debug, Clone)]
pub struct ShadowEvalResult {
pub metric_improvement: f64,
pub proof_gates_passed: bool,
}
#[derive(Debug, Clone)]
pub struct ProposalSafetyMetadata {
pub requirement: ApprovalRequirement,
pub requires_human_approval: bool,
pub requires_shadow_eval: bool,
pub shadow_eval_passed: Option<bool>,
pub gate_ids: String,
}
pub fn classify_and_gate_proposal(
gates: &SafetyGatesTable,
_proposal_store: &ProposalStore,
_proposal_id: &str,
changes: &[ChangeEntry],
) -> ProposalSafetyMetadata {
let req = classify_proposal_changes(gates, changes);
ProposalSafetyMetadata {
requires_human_approval: req.requires_human,
requires_shadow_eval: req.requires_shadow,
shadow_eval_passed: None,
gate_ids: req.gate_id.clone(),
requirement: req,
}
}
pub fn classify_proposal(gates: &SafetyGatesTable, changes: &[ChangeEntry]) -> ApprovalRequirement {
classify_proposal_changes(gates, changes)
}
pub fn check_approval_gate(
requirement: &ApprovalRequirement,
reviewer_is_human: bool,
shadow_eval: Option<&ShadowEvalResult>,
) -> Result<()> {
if requirement.requires_human && !reviewer_is_human {
return Err(GatedProposalError::HumanGateRequired {
gate_id: requirement.gate_id.clone(),
});
}
if requirement.requires_shadow {
let eval = shadow_eval.ok_or(GatedProposalError::ShadowEvalRequired)?;
if eval.metric_improvement < requirement.auto_approve_threshold {
return Err(GatedProposalError::BelowThreshold {
actual: eval.metric_improvement,
required: requirement.auto_approve_threshold,
});
}
}
Ok(())
}
pub fn check_merge_gate(
requirement: &ApprovalRequirement,
shadow_eval: Option<&ShadowEvalResult>,
) -> Result<()> {
if requirement.requires_shadow {
let eval = shadow_eval.ok_or(GatedProposalError::ShadowEvalRequired)?;
if eval.metric_improvement < requirement.auto_approve_threshold {
return Err(GatedProposalError::BelowThreshold {
actual: eval.metric_improvement,
required: requirement.auto_approve_threshold,
});
}
if !eval.proof_gates_passed {
return Err(GatedProposalError::ProofGatesFailed);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::safety_gates::default_gates;
fn sample_y0_changes() -> Vec<ChangeEntry> {
vec![ChangeEntry {
y_layer: 0,
domain: "general".to_string(),
}]
}
fn sample_y6_changes() -> Vec<ChangeEntry> {
vec![ChangeEntry {
y_layer: 6,
domain: "general".to_string(),
}]
}
fn sample_y2_changes() -> Vec<ChangeEntry> {
vec![ChangeEntry {
y_layer: 2,
domain: "general".to_string(),
}]
}
fn sample_mixed_changes() -> Vec<ChangeEntry> {
vec![
ChangeEntry {
y_layer: 0,
domain: "general".to_string(),
},
ChangeEntry {
y_layer: 6,
domain: "general".to_string(),
},
]
}
fn passing_shadow() -> ShadowEvalResult {
ShadowEvalResult {
metric_improvement: 0.15,
proof_gates_passed: true,
}
}
fn failing_shadow() -> ShadowEvalResult {
ShadowEvalResult {
metric_improvement: 0.01,
proof_gates_passed: false,
}
}
#[test]
fn test_classify_y0_auto_approve() {
let gates = default_gates().unwrap();
let req = classify_proposal(&gates, &sample_y0_changes());
assert!(!req.requires_human);
assert!(!req.requires_shadow);
}
#[test]
fn test_classify_y6_requires_human() {
let gates = default_gates().unwrap();
let req = classify_proposal(&gates, &sample_y6_changes());
assert!(req.requires_human);
assert!(req.requires_shadow);
}
#[test]
fn test_classify_mixed_strictest_wins() {
let gates = default_gates().unwrap();
let req = classify_proposal(&gates, &sample_mixed_changes());
assert!(req.requires_human); }
#[test]
fn test_y0_auto_approves_without_shadow() {
let gates = default_gates().unwrap();
let req = classify_proposal(&gates, &sample_y0_changes());
assert!(check_approval_gate(&req, false, None).is_ok());
}
#[test]
fn test_y6_blocks_non_human_reviewer() {
let gates = default_gates().unwrap();
let req = classify_proposal(&gates, &sample_y6_changes());
let result = check_approval_gate(&req, false, Some(&passing_shadow()));
assert!(result.is_err());
match result.unwrap_err() {
GatedProposalError::HumanGateRequired { .. } => {}
e => panic!("Expected HumanGateRequired, got: {e}"),
}
}
#[test]
fn test_y6_allows_human_reviewer_with_shadow() {
let gates = default_gates().unwrap();
let req = classify_proposal(&gates, &sample_y6_changes());
assert!(check_approval_gate(&req, true, Some(&passing_shadow())).is_ok());
}
#[test]
fn test_y2_requires_shadow() {
let gates = default_gates().unwrap();
let req = classify_proposal(&gates, &sample_y2_changes());
let result = check_approval_gate(&req, false, None);
assert!(result.is_err());
match result.unwrap_err() {
GatedProposalError::ShadowEvalRequired => {}
e => panic!("Expected ShadowEvalRequired, got: {e}"),
}
}
#[test]
fn test_y2_with_passing_shadow_approves() {
let gates = default_gates().unwrap();
let req = classify_proposal(&gates, &sample_y2_changes());
assert!(check_approval_gate(&req, false, Some(&passing_shadow())).is_ok());
}
#[test]
fn test_below_threshold_rejects() {
let gates = default_gates().unwrap();
let req = classify_proposal(&gates, &sample_y2_changes());
let result = check_approval_gate(&req, false, Some(&failing_shadow()));
assert!(result.is_err());
match result.unwrap_err() {
GatedProposalError::BelowThreshold { actual, required } => {
assert!(actual < required);
}
e => panic!("Expected BelowThreshold, got: {e}"),
}
}
#[test]
fn test_merge_gate_passes_with_shadow() {
let gates = default_gates().unwrap();
let req = classify_proposal(&gates, &sample_y2_changes());
assert!(check_merge_gate(&req, Some(&passing_shadow())).is_ok());
}
#[test]
fn test_merge_gate_blocks_without_shadow() {
let gates = default_gates().unwrap();
let req = classify_proposal(&gates, &sample_y2_changes());
assert!(check_merge_gate(&req, None).is_err());
}
#[test]
fn test_merge_gate_blocks_below_threshold() {
let gates = default_gates().unwrap();
let req = classify_proposal(&gates, &sample_y2_changes());
assert!(check_merge_gate(&req, Some(&failing_shadow())).is_err());
}
#[test]
fn test_merge_gate_blocks_when_proof_gates_failed() {
let gates = default_gates().unwrap();
let req = classify_proposal(&gates, &sample_y2_changes());
let eval = ShadowEvalResult {
metric_improvement: 0.15,
proof_gates_passed: false,
};
let result = check_merge_gate(&req, Some(&eval));
assert!(result.is_err());
match result.unwrap_err() {
GatedProposalError::ProofGatesFailed => {}
e => panic!("Expected ProofGatesFailed, got: {e}"),
}
}
#[test]
fn test_y0_merge_passes_without_shadow() {
let gates = default_gates().unwrap();
let req = classify_proposal(&gates, &sample_y0_changes());
assert!(check_merge_gate(&req, None).is_ok());
}
}