use crate::scope::RuntimeScope;
use car_ir::ActionProposal;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
pub struct GateContext<'a> {
pub session_id: Option<&'a str>,
pub scope: Option<&'a RuntimeScope>,
pub state: &'a HashMap<String, Value>,
pub versions: &'a HashMap<String, u64>,
}
#[derive(Debug, Clone)]
pub enum GateOutcome {
Allow,
Reject {
blocked: HashSet<String>,
reason: String,
},
NeedsApproval {
actions: HashSet<String>,
fingerprint: String,
reason: String,
},
}
impl GateOutcome {
pub fn reject_all(reason: impl Into<String>) -> Self {
GateOutcome::Reject {
blocked: HashSet::new(),
reason: reason.into(),
}
}
pub fn reject_actions<I, S>(blocked: I, reason: impl Into<String>) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
GateOutcome::Reject {
blocked: blocked.into_iter().map(Into::into).collect(),
reason: reason.into(),
}
}
pub fn is_allow(&self) -> bool {
matches!(self, GateOutcome::Allow)
}
pub fn label(&self) -> &'static str {
match self {
GateOutcome::Allow => "allow",
GateOutcome::Reject { .. } => "reject",
GateOutcome::NeedsApproval { .. } => "needs_approval",
}
}
}
#[async_trait::async_trait]
pub trait AdmissionGate: Send + Sync {
fn name(&self) -> &str;
async fn check(&self, proposal: &ActionProposal, ctx: &GateContext<'_>) -> GateOutcome;
}
#[derive(Debug, Clone)]
pub struct AdmissionEscalation {
pub gate: String,
pub fingerprint: String,
pub reason: String,
pub actions: HashSet<String>,
}
#[derive(Debug, Clone, Default)]
pub struct AdmissionDecision {
pub admitted: bool,
pub blocked: HashSet<String>,
pub deciding_gate: Option<String>,
pub reason: Option<String>,
pub hard_rejected: bool,
pub escalations: Vec<AdmissionEscalation>,
}
impl AdmissionDecision {
pub fn admit() -> Self {
AdmissionDecision {
admitted: true,
..Default::default()
}
}
pub fn needs_approval(&self) -> bool {
!self.hard_rejected && !self.escalations.is_empty()
}
pub fn absorb(&mut self, gate_name: &str, outcome: GateOutcome) {
match outcome {
GateOutcome::Allow => {}
GateOutcome::Reject { blocked, reason } => {
self.blocked.extend(blocked);
self.hard_rejected = true;
if self.admitted {
self.admitted = false;
self.deciding_gate = Some(gate_name.to_string());
self.reason = Some(reason);
} else if self.deciding_gate.is_none() {
self.deciding_gate = Some(gate_name.to_string());
self.reason = Some(reason);
}
}
GateOutcome::NeedsApproval {
actions,
fingerprint,
reason,
} => {
self.blocked.extend(actions.clone());
self.escalations.push(AdmissionEscalation {
gate: gate_name.to_string(),
fingerprint,
reason: reason.clone(),
actions,
});
if self.admitted {
self.admitted = false;
self.deciding_gate = Some(gate_name.to_string());
self.reason = Some(reason);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_decision_admits() {
let d = AdmissionDecision::admit();
assert!(d.admitted);
assert!(d.blocked.is_empty());
}
#[test]
fn reject_marks_first_gate_and_unions_actions() {
let mut d = AdmissionDecision::admit();
d.absorb("flow", GateOutcome::reject_actions(["a1"], "exfil"));
d.absorb("concurrency", GateOutcome::reject_actions(["a2"], "stale"));
assert!(!d.admitted);
assert_eq!(d.deciding_gate.as_deref(), Some("flow"));
assert_eq!(d.reason.as_deref(), Some("exfil"));
assert!(d.blocked.contains("a1"));
assert!(d.blocked.contains("a2"));
assert!(!d.needs_approval());
assert!(d.hard_rejected);
}
#[test]
fn allow_after_reject_stays_rejected() {
let mut d = AdmissionDecision::admit();
d.absorb("flow", GateOutcome::reject_all("nope"));
d.absorb("other", GateOutcome::Allow);
assert!(!d.admitted);
assert_eq!(d.deciding_gate.as_deref(), Some("flow"));
}
#[test]
fn needs_approval_is_tracked() {
let mut d = AdmissionDecision::admit();
d.absorb(
"permission",
GateOutcome::NeedsApproval {
actions: ["a1".to_string()].into_iter().collect(),
fingerprint: "fp123".to_string(),
reason: "tier escalation".to_string(),
},
);
assert!(!d.admitted);
assert!(d.needs_approval());
assert_eq!(d.escalations.len(), 1);
assert_eq!(d.escalations[0].fingerprint, "fp123");
assert_eq!(d.escalations[0].gate, "permission");
}
#[test]
fn every_escalation_keeps_its_own_fingerprint() {
let mut d = AdmissionDecision::admit();
d.absorb(
"flow",
GateOutcome::NeedsApproval {
actions: ["a1".to_string()].into_iter().collect(),
fingerprint: "fp-flow".to_string(),
reason: "flow hazard".to_string(),
},
);
d.absorb(
"skill_ceiling",
GateOutcome::NeedsApproval {
actions: ["a2".to_string()].into_iter().collect(),
fingerprint: "fp-ceiling".to_string(),
reason: "over ceiling".to_string(),
},
);
assert!(d.needs_approval());
let fps: Vec<&str> = d.escalations.iter().map(|e| e.fingerprint.as_str()).collect();
assert_eq!(fps, vec!["fp-flow", "fp-ceiling"]);
}
#[test]
fn hard_reject_dominates_escalations() {
let mut d = AdmissionDecision::admit();
d.absorb(
"flow",
GateOutcome::NeedsApproval {
actions: ["a1".to_string()].into_iter().collect(),
fingerprint: "fp-flow".to_string(),
reason: "flow hazard".to_string(),
},
);
d.absorb("rules", GateOutcome::reject_actions(["a2"], "deny rule"));
assert!(d.hard_rejected);
assert!(!d.needs_approval(), "a hard reject is never approval-resolvable");
}
#[test]
fn outcome_labels_are_stable() {
assert_eq!(GateOutcome::Allow.label(), "allow");
assert_eq!(GateOutcome::reject_all("x").label(), "reject");
}
}