use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::driver::correlation::{ScopeCheck, SessionCorrelation};
const DESTRUCTIVE_KEYWORDS: &[&str] = &[
"delete",
"drop table",
"drop database",
"push --force",
"force-push",
"force push",
"decommission",
"rm -rf",
"truncate",
"revoke",
"rotate secret",
"rotate key",
"wipe",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewVerdict {
Approve,
Reject,
Unavailable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CiStatus {
Green,
Red,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuardrailSignals {
pub review: ReviewVerdict,
pub ci: CiStatus,
pub search_consistent: bool,
pub memory_consistent: bool,
pub scope: ScopeCheck,
}
impl GuardrailSignals {
pub fn all_clear(&self) -> bool {
self.review == ReviewVerdict::Approve
&& self.ci == CiStatus::Green
&& self.search_consistent
&& self.memory_consistent
&& self.scope.is_in_scope()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeClass {
StyleOnly,
Standard,
Architectural,
Destructive,
}
#[derive(Debug, Clone)]
pub struct ActionContext {
pub pending_decision: String,
pub change_class: ChangeClass,
pub correlation: SessionCorrelation,
pub prior_rejections: u32,
}
impl ActionContext {
pub fn mentions_destructive_op(&self) -> bool {
let lowered = self.pending_decision.to_lowercase();
DESTRUCTIVE_KEYWORDS.iter().any(|kw| lowered.contains(kw))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum AutonomyTier {
T1,
T2,
T3,
T4,
}
impl AutonomyTier {
pub fn label(&self) -> &'static str {
match self {
Self::T1 => "T1",
Self::T2 => "T2",
Self::T3 => "T3",
Self::T4 => "T4",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Disposition {
AutoAccept {
reason: String,
},
Escalate {
reason: String,
},
}
impl Disposition {
pub fn is_auto_accept(&self) -> bool {
matches!(self, Disposition::AutoAccept { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutonomyDecision {
pub tier: AutonomyTier,
pub disposition: Disposition,
}
impl AutonomyDecision {
pub fn is_auto_accept(&self) -> bool {
self.disposition.is_auto_accept()
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum PolicyError {
#[error("pending decision text is empty; cannot evaluate autonomy policy")]
EmptyDecision,
}
fn classify_tier(ctx: &ActionContext) -> AutonomyTier {
let base = match ctx.change_class {
ChangeClass::StyleOnly => AutonomyTier::T1,
ChangeClass::Standard => AutonomyTier::T2,
ChangeClass::Architectural => AutonomyTier::T3,
ChangeClass::Destructive => AutonomyTier::T4,
};
if ctx.mentions_destructive_op() {
base.max(AutonomyTier::T4)
} else {
base
}
}
pub fn evaluate_autonomy_tier(
ctx: &ActionContext,
signals: &GuardrailSignals,
) -> Result<AutonomyDecision, PolicyError> {
if ctx.pending_decision.trim().is_empty() {
return Err(PolicyError::EmptyDecision);
}
let tier = classify_tier(ctx);
if ctx.prior_rejections > 0 {
return Ok(AutonomyDecision {
tier,
disposition: Disposition::Escalate {
reason: format!(
"decision previously rejected {} time(s); re-escalating to human",
ctx.prior_rejections
),
},
});
}
let disposition = match tier {
AutonomyTier::T1 => gate_t1(signals),
AutonomyTier::T2 => gate_t2(signals),
AutonomyTier::T3 => gate_t3(signals),
AutonomyTier::T4 => Disposition::Escalate {
reason: "T4: irreversible or security-sensitive operation; human confirmation required"
.to_string(),
},
};
Ok(AutonomyDecision { tier, disposition })
}
fn gate_t1(signals: &GuardrailSignals) -> Disposition {
if signals.review == ReviewVerdict::Reject {
Disposition::Escalate {
reason: "T1: trusty-review returned REJECT on a style-only change".to_string(),
}
} else if signals.ci == CiStatus::Red {
Disposition::Escalate {
reason: "T1: CI is red on a style-only change".to_string(),
}
} else {
Disposition::AutoAccept {
reason: "T1: style-only change with no objecting guardrail".to_string(),
}
}
}
fn gate_t2(signals: &GuardrailSignals) -> Disposition {
if signals.all_clear() {
Disposition::AutoAccept {
reason: "T2: all structured guardrails green (review APPROVE, CI green, search+memory consistent, in-scope)".to_string(),
}
} else {
Disposition::Escalate {
reason: format!(
"T2: guardrail not satisfied: {}",
first_failing_signal(signals)
),
}
}
}
fn gate_t3(signals: &GuardrailSignals) -> Disposition {
let approved = signals.review == ReviewVerdict::Approve;
let in_scope = signals.scope.is_in_scope();
let ci_ok = signals.ci != CiStatus::Red;
if approved && in_scope && ci_ok {
Disposition::AutoAccept {
reason: "T3: architecture-touching change with explicit trusty-review APPROVE and in-scope validation".to_string(),
}
} else {
Disposition::Escalate {
reason: format!(
"T3: requires explicit APPROVE + in-scope + non-red CI; got {}",
first_failing_signal(signals)
),
}
}
}
fn first_failing_signal(signals: &GuardrailSignals) -> &'static str {
if signals.review != ReviewVerdict::Approve {
"trusty-review did not APPROVE"
} else if signals.ci != CiStatus::Green {
"CI not green"
} else if !signals.search_consistent {
"trusty-search found a conflicting implementation"
} else if !signals.memory_consistent {
"trusty-memory surfaced a blocking prior decision"
} else if !signals.scope.is_in_scope() {
"change is out-of-scope or session is uncorrelated"
} else {
"none"
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn correlated() -> SessionCorrelation {
SessionCorrelation::new()
.with_worktree("/repo/wt")
.with_issue_id(1204)
}
fn ctx(decision: &str, class: ChangeClass) -> ActionContext {
ActionContext {
pending_decision: decision.to_string(),
change_class: class,
correlation: correlated(),
prior_rejections: 0,
}
}
fn all_clear_signals() -> GuardrailSignals {
GuardrailSignals {
review: ReviewVerdict::Approve,
ci: CiStatus::Green,
search_consistent: true,
memory_consistent: true,
scope: ScopeCheck::InScope,
}
}
#[test]
fn tier_ordering() {
assert!(AutonomyTier::T1 < AutonomyTier::T2);
assert!(AutonomyTier::T2 < AutonomyTier::T3);
assert!(AutonomyTier::T3 < AutonomyTier::T4);
assert_eq!(AutonomyTier::T2.max(AutonomyTier::T4), AutonomyTier::T4);
}
#[test]
fn tier_labels() {
assert_eq!(AutonomyTier::T1.label(), "T1");
assert_eq!(AutonomyTier::T4.label(), "T4");
}
#[test]
fn destructive_keyword_detection() {
let c = ctx(
"Proceed to delete the staging table?",
ChangeClass::Standard,
);
assert!(c.mentions_destructive_op());
let c2 = ctx("Apply the formatting change?", ChangeClass::StyleOnly);
assert!(!c2.mentions_destructive_op());
let c3 = ctx(
"Should I push --force to the branch?",
ChangeClass::Standard,
);
assert!(c3.mentions_destructive_op());
}
#[test]
fn base_tier_for_class() {
assert_eq!(
classify_tier(&ctx("ok", ChangeClass::StyleOnly)),
AutonomyTier::T1
);
assert_eq!(
classify_tier(&ctx("ok", ChangeClass::Standard)),
AutonomyTier::T2
);
assert_eq!(
classify_tier(&ctx("ok", ChangeClass::Architectural)),
AutonomyTier::T3
);
assert_eq!(
classify_tier(&ctx("ok", ChangeClass::Destructive)),
AutonomyTier::T4
);
}
#[test]
fn destructive_text_forces_t4() {
let c = ctx("delete the old index", ChangeClass::Standard);
assert_eq!(classify_tier(&c), AutonomyTier::T4);
}
#[test]
fn empty_decision_is_error() {
let c = ctx(" ", ChangeClass::Standard);
let res = evaluate_autonomy_tier(&c, &all_clear_signals());
assert_eq!(res, Err(PolicyError::EmptyDecision));
}
#[test]
fn evaluate_t1_auto_accepts() {
let c = ctx("apply rustfmt", ChangeClass::StyleOnly);
let d = evaluate_autonomy_tier(&c, &all_clear_signals()).expect("ok");
assert_eq!(d.tier, AutonomyTier::T1);
assert!(d.is_auto_accept());
}
#[test]
fn evaluate_t1_escalates_on_red() {
let c = ctx("apply rustfmt", ChangeClass::StyleOnly);
let mut s = all_clear_signals();
s.ci = CiStatus::Red;
let d = evaluate_autonomy_tier(&c, &s).expect("ok");
assert_eq!(d.tier, AutonomyTier::T1);
assert!(!d.is_auto_accept());
}
#[test]
fn evaluate_t1_escalates_on_reject() {
let c = ctx("apply rustfmt", ChangeClass::StyleOnly);
let mut s = all_clear_signals();
s.review = ReviewVerdict::Reject;
let d = evaluate_autonomy_tier(&c, &s).expect("ok");
assert!(!d.is_auto_accept());
}
#[test]
fn evaluate_t2_auto_accepts() {
let c = ctx("implement the parser fix", ChangeClass::Standard);
let d = evaluate_autonomy_tier(&c, &all_clear_signals()).expect("ok");
assert_eq!(d.tier, AutonomyTier::T2);
assert!(d.is_auto_accept());
}
#[test]
fn evaluate_t2_escalates_on_review_unavailable() {
let c = ctx("implement the parser fix", ChangeClass::Standard);
let mut s = all_clear_signals();
s.review = ReviewVerdict::Unavailable;
let d = evaluate_autonomy_tier(&c, &s).expect("ok");
assert_eq!(d.tier, AutonomyTier::T2);
assert!(!d.is_auto_accept());
}
#[test]
fn evaluate_t2_escalates_on_inconsistency() {
let c = ctx("implement the parser fix", ChangeClass::Standard);
let mut s = all_clear_signals();
s.memory_consistent = false;
let d = evaluate_autonomy_tier(&c, &s).expect("ok");
assert!(!d.is_auto_accept());
}
#[test]
fn evaluate_t2_escalates_out_of_scope() {
let c = ctx("implement the parser fix", ChangeClass::Standard);
let mut s = all_clear_signals();
s.scope = ScopeCheck::OutOfScope {
stray_paths: vec![PathBuf::from("/etc/passwd")],
foreign_issue_ids: vec![],
};
let d = evaluate_autonomy_tier(&c, &s).expect("ok");
assert!(!d.is_auto_accept());
}
#[test]
fn evaluate_t3_auto_accepts() {
let c = ctx("refactor the cross-crate trait", ChangeClass::Architectural);
let d = evaluate_autonomy_tier(&c, &all_clear_signals()).expect("ok");
assert_eq!(d.tier, AutonomyTier::T3);
assert!(d.is_auto_accept());
}
#[test]
fn evaluate_t3_escalates_without_approve() {
let c = ctx("refactor the cross-crate trait", ChangeClass::Architectural);
let mut s = all_clear_signals();
s.review = ReviewVerdict::Unavailable; let d = evaluate_autonomy_tier(&c, &s).expect("ok");
assert_eq!(d.tier, AutonomyTier::T3);
assert!(!d.is_auto_accept());
}
#[test]
fn evaluate_t3_tolerates_unknown_ci_but_not_red() {
let c = ctx("refactor the cross-crate trait", ChangeClass::Architectural);
let mut s = all_clear_signals();
s.ci = CiStatus::Unknown;
let d = evaluate_autonomy_tier(&c, &s).expect("ok");
assert!(
d.is_auto_accept(),
"T3 tolerates Unknown CI when APPROVE+scope"
);
s.ci = CiStatus::Red;
let d2 = evaluate_autonomy_tier(&c, &s).expect("ok");
assert!(!d2.is_auto_accept(), "T3 must escalate on red CI");
}
#[test]
fn evaluate_t4_always_escalates() {
let c = ctx(
"decommission the production index",
ChangeClass::Destructive,
);
let d = evaluate_autonomy_tier(&c, &all_clear_signals()).expect("ok");
assert_eq!(d.tier, AutonomyTier::T4);
assert!(!d.is_auto_accept());
}
#[test]
fn evaluate_destructive_text_escalates_even_when_classified_standard() {
let c = ctx("drop table sessions to reset", ChangeClass::Standard);
let d = evaluate_autonomy_tier(&c, &all_clear_signals()).expect("ok");
assert_eq!(d.tier, AutonomyTier::T4);
assert!(!d.is_auto_accept());
}
#[test]
fn prior_rejection_always_escalates() {
let mut c = ctx("implement the parser fix", ChangeClass::Standard);
c.prior_rejections = 1;
let d = evaluate_autonomy_tier(&c, &all_clear_signals()).expect("ok");
assert_eq!(d.tier, AutonomyTier::T2);
assert!(!d.is_auto_accept());
}
#[test]
fn all_clear_requires_every_signal() {
assert!(all_clear_signals().all_clear());
let mut s = all_clear_signals();
s.search_consistent = false;
assert!(!s.all_clear());
}
#[test]
fn review_verdict_gates() {
let c = ctx("standard fix", ChangeClass::Standard);
let mut s = all_clear_signals();
s.review = ReviewVerdict::Reject;
assert!(!evaluate_autonomy_tier(&c, &s).unwrap().is_auto_accept());
}
#[test]
fn ci_status_gates() {
let c = ctx("standard fix", ChangeClass::Standard);
let mut s = all_clear_signals();
s.ci = CiStatus::Unknown;
assert!(!evaluate_autonomy_tier(&c, &s).unwrap().is_auto_accept());
}
#[test]
fn change_class_orders_tiers() {
assert!(
classify_tier(&ctx("a", ChangeClass::StyleOnly))
< classify_tier(&ctx("b", ChangeClass::Standard))
);
assert!(
classify_tier(&ctx("c", ChangeClass::Architectural))
< classify_tier(&ctx("d", ChangeClass::Destructive))
);
}
#[test]
fn first_failing_signal_priority() {
let mut s = all_clear_signals();
assert_eq!(first_failing_signal(&s), "none");
s.memory_consistent = false;
assert_eq!(
first_failing_signal(&s),
"trusty-memory surfaced a blocking prior decision"
);
s.review = ReviewVerdict::Reject; assert_eq!(first_failing_signal(&s), "trusty-review did not APPROVE");
}
#[test]
fn decision_serde_round_trip_for_tier() {
let t = AutonomyTier::T3;
let json = serde_json::to_string(&t).expect("ser");
assert_eq!(json, "\"T3\"");
let back: AutonomyTier = serde_json::from_str(&json).expect("de");
assert_eq!(back, t);
}
}