#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateDecision {
Allow,
Escalate,
Block,
}
impl GateDecision {
pub fn as_str(self) -> &'static str {
match self {
GateDecision::Allow => "allow",
GateDecision::Escalate => "escalate",
GateDecision::Block => "block",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GateThreshold {
pub max_severity: Option<warble::Severity>,
pub max_downstream: Option<usize>,
pub protected: Vec<String>,
}
pub fn severity_str(severity: warble::Severity) -> &'static str {
match severity {
warble::Severity::None => "none",
warble::Severity::Compatibility => "compatibility",
warble::Severity::Structural => "structural",
warble::Severity::Semantic => "semantic",
}
}
pub fn parse_severity(s: &str) -> Option<warble::Severity> {
match s.to_ascii_lowercase().as_str() {
"none" => Some(warble::Severity::None),
"compatibility" => Some(warble::Severity::Compatibility),
"structural" => Some(warble::Severity::Structural),
"semantic" => Some(warble::Severity::Semantic),
_ => None,
}
}
pub fn decide(radius: &warble::BlastRadius, t: &GateThreshold) -> (GateDecision, String) {
if radius.downstream.is_empty() {
return (
GateDecision::Allow,
"empty blast radius — no downstream impact".to_string(),
);
}
if let Some(hit) = std::iter::once(&radius.seed)
.chain(radius.downstream.iter())
.find(|id| t.protected.contains(id))
{
return (
GateDecision::Block,
format!("touches protected asset '{hit}'"),
);
}
if let Some(max) = t.max_severity {
if radius.severity > max {
return (
GateDecision::Escalate,
format!(
"radius severity '{}' exceeds max '{}'",
severity_str(radius.severity),
severity_str(max)
),
);
}
}
if let Some(max) = t.max_downstream {
if radius.downstream.len() > max {
return (
GateDecision::Escalate,
format!(
"blast radius of {} nodes exceeds max {max}",
radius.downstream.len()
),
);
}
}
(
GateDecision::Allow,
"within blast-radius limits".to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use warble::Severity;
fn radius(seed: &str, downstream: &[&str], severity: Severity) -> warble::BlastRadius {
warble::BlastRadius {
seed: seed.to_string(),
downstream: downstream.iter().map(|s| s.to_string()).collect(),
severity,
}
}
#[test]
fn empty_radius_allows() {
let r = radius("model:x", &[], Severity::None);
let (decision, reason) = decide(&r, &GateThreshold::default());
assert_eq!(decision, GateDecision::Allow);
assert!(reason.contains("no downstream impact"));
}
#[test]
fn protected_seed_blocks() {
let r = radius("model:orders", &["cube:revenue"], Severity::Compatibility);
let t = GateThreshold {
protected: vec!["model:orders".to_string()],
..Default::default()
};
let (decision, reason) = decide(&r, &t);
assert_eq!(decision, GateDecision::Block);
assert!(reason.contains("model:orders"), "reason was: {reason}");
}
#[test]
fn protected_downstream_blocks_and_names_first_sorted_hit() {
let r = radius(
"model:orders",
&["cube:revenue", "metric:revenue.total_revenue"],
Severity::Semantic,
);
let t = GateThreshold {
protected: vec!["metric:revenue.total_revenue".to_string()],
..Default::default()
};
let (decision, reason) = decide(&r, &t);
assert_eq!(decision, GateDecision::Block);
assert!(
reason.contains("metric:revenue.total_revenue"),
"reason was: {reason}"
);
}
#[test]
fn severity_above_max_escalates() {
let r = radius(
"model:orders",
&["metric:revenue.total"],
Severity::Semantic,
);
let t = GateThreshold {
max_severity: Some(Severity::Structural),
..Default::default()
};
let (decision, reason) = decide(&r, &t);
assert_eq!(decision, GateDecision::Escalate);
assert!(reason.contains("semantic"), "reason was: {reason}");
assert!(reason.contains("structural"), "reason was: {reason}");
}
#[test]
fn severity_at_or_below_max_does_not_escalate() {
let r = radius(
"model:orders",
&["dim:revenue.status"],
Severity::Compatibility,
);
let t = GateThreshold {
max_severity: Some(Severity::Compatibility),
..Default::default()
};
let (decision, _) = decide(&r, &t);
assert_eq!(decision, GateDecision::Allow);
}
#[test]
fn downstream_count_above_max_escalates() {
let r = radius("model:orders", &["a", "b", "c"], Severity::Compatibility);
let t = GateThreshold {
max_downstream: Some(2),
..Default::default()
};
let (decision, reason) = decide(&r, &t);
assert_eq!(decision, GateDecision::Escalate);
assert!(
reason.contains('3') && reason.contains('2'),
"reason was: {reason}"
);
}
#[test]
fn downstream_count_at_or_below_max_allows() {
let r = radius("model:orders", &["a", "b"], Severity::Compatibility);
let t = GateThreshold {
max_downstream: Some(2),
..Default::default()
};
let (decision, reason) = decide(&r, &t);
assert_eq!(decision, GateDecision::Allow);
assert!(reason.contains("within blast-radius limits"));
}
#[test]
fn no_thresholds_set_always_allows_when_not_protected_and_nonempty() {
let r = radius("model:orders", &["a", "b", "c", "d"], Severity::Semantic);
let (decision, _) = decide(&r, &GateThreshold::default());
assert_eq!(decision, GateDecision::Allow);
}
#[test]
fn severity_str_and_parse_severity_round_trip() {
for s in [
Severity::None,
Severity::Compatibility,
Severity::Structural,
Severity::Semantic,
] {
let name = severity_str(s);
assert_eq!(
parse_severity(name),
Some(s),
"round-trip failed for {name}"
);
}
assert_eq!(parse_severity("SEMANTIC"), Some(Severity::Semantic));
assert_eq!(parse_severity("nonsense"), None);
}
#[test]
fn decision_as_str() {
assert_eq!(GateDecision::Allow.as_str(), "allow");
assert_eq!(GateDecision::Escalate.as_str(), "escalate");
assert_eq!(GateDecision::Block.as_str(), "block");
}
}