Skip to main content

warble_cli/
gate.rs

1//! Gate-decision policy over an already-computed [`warble::BlastRadius`] (Phase 4a — the mutating
2//! guardrail described in [`blast-radius.md`][spec-blast] §6). Pure, no I/O: the host computes the
3//! radius (via [`crate::blast_radius_for_project`]), then this module turns it plus a threshold
4//! into a decision the host can act on (allow the apply, escalate to human approval, or hard-block
5//! it).
6//!
7//! [spec-blast]: https://github.com/Canner/Warble/blob/main/docs/spec/blast-radius.md
8
9/// The outcome of a gate decision — what the host should do with the pending mutating apply.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum GateDecision {
12    /// No downstream impact worth gating on — proceed.
13    Allow,
14    /// Impact exceeds a soft threshold — route to human approval rather than auto-apply.
15    Escalate,
16    /// The change touches a protected asset — refuse outright, no escalation path.
17    Block,
18}
19
20impl GateDecision {
21    pub fn as_str(self) -> &'static str {
22        match self {
23            GateDecision::Allow => "allow",
24            GateDecision::Escalate => "escalate",
25            GateDecision::Block => "block",
26        }
27    }
28}
29
30/// The thresholds a gate decision is evaluated against. All fields are optional: an absent
31/// `max_severity`/`max_downstream` simply never triggers that branch of the policy.
32#[derive(Debug, Clone, Default, PartialEq, Eq)]
33pub struct GateThreshold {
34    /// Escalate when the radius's severity is strictly above this ceiling.
35    pub max_severity: Option<warble::Severity>,
36    /// Escalate when the radius's downstream count is strictly above this ceiling.
37    pub max_downstream: Option<usize>,
38    /// Node ids that force a hard block if they are the seed or anywhere in the downstream set.
39    pub protected: Vec<String>,
40}
41
42/// Human-readable name for a [`warble::Severity`] (the inverse of [`parse_severity`]).
43pub fn severity_str(severity: warble::Severity) -> &'static str {
44    match severity {
45        warble::Severity::None => "none",
46        warble::Severity::Compatibility => "compatibility",
47        warble::Severity::Structural => "structural",
48        warble::Severity::Semantic => "semantic",
49    }
50}
51
52/// Parse a [`warble::Severity`] from its human-readable name (the inverse of [`severity_str`]).
53/// Case-insensitive; returns `None` for anything else (the caller should reject an unknown flag
54/// value rather than silently defaulting).
55pub fn parse_severity(s: &str) -> Option<warble::Severity> {
56    match s.to_ascii_lowercase().as_str() {
57        "none" => Some(warble::Severity::None),
58        "compatibility" => Some(warble::Severity::Compatibility),
59        "structural" => Some(warble::Severity::Structural),
60        "semantic" => Some(warble::Severity::Semantic),
61        _ => None,
62    }
63}
64
65/// Pure policy over an already-computed radius. Returns the decision plus a human-readable reason
66/// (surfaced to the operator / logged, not machine-parsed).
67///
68/// Policy, evaluated in order (first match wins):
69/// 1. Empty blast radius → `Allow`.
70/// 2. The seed or any downstream node is in `t.protected` → `Block` (names the first hit: seed
71///    checked before downstream, downstream in its existing sorted order).
72/// 3. `t.max_severity` is set and the radius severity exceeds it → `Escalate`.
73/// 4. `t.max_downstream` is set and the downstream count exceeds it → `Escalate`.
74/// 5. Otherwise → `Allow`.
75pub fn decide(radius: &warble::BlastRadius, t: &GateThreshold) -> (GateDecision, String) {
76    if radius.downstream.is_empty() {
77        return (
78            GateDecision::Allow,
79            "empty blast radius — no downstream impact".to_string(),
80        );
81    }
82
83    if let Some(hit) = std::iter::once(&radius.seed)
84        .chain(radius.downstream.iter())
85        .find(|id| t.protected.contains(id))
86    {
87        return (
88            GateDecision::Block,
89            format!("touches protected asset '{hit}'"),
90        );
91    }
92
93    if let Some(max) = t.max_severity {
94        if radius.severity > max {
95            return (
96                GateDecision::Escalate,
97                format!(
98                    "radius severity '{}' exceeds max '{}'",
99                    severity_str(radius.severity),
100                    severity_str(max)
101                ),
102            );
103        }
104    }
105
106    if let Some(max) = t.max_downstream {
107        if radius.downstream.len() > max {
108            return (
109                GateDecision::Escalate,
110                format!(
111                    "blast radius of {} nodes exceeds max {max}",
112                    radius.downstream.len()
113                ),
114            );
115        }
116    }
117
118    (
119        GateDecision::Allow,
120        "within blast-radius limits".to_string(),
121    )
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use warble::Severity;
128
129    fn radius(seed: &str, downstream: &[&str], severity: Severity) -> warble::BlastRadius {
130        warble::BlastRadius {
131            seed: seed.to_string(),
132            downstream: downstream.iter().map(|s| s.to_string()).collect(),
133            severity,
134        }
135    }
136
137    #[test]
138    fn empty_radius_allows() {
139        let r = radius("model:x", &[], Severity::None);
140        let (decision, reason) = decide(&r, &GateThreshold::default());
141        assert_eq!(decision, GateDecision::Allow);
142        assert!(reason.contains("no downstream impact"));
143    }
144
145    #[test]
146    fn protected_seed_blocks() {
147        let r = radius("model:orders", &["cube:revenue"], Severity::Compatibility);
148        let t = GateThreshold {
149            protected: vec!["model:orders".to_string()],
150            ..Default::default()
151        };
152        let (decision, reason) = decide(&r, &t);
153        assert_eq!(decision, GateDecision::Block);
154        assert!(reason.contains("model:orders"), "reason was: {reason}");
155    }
156
157    #[test]
158    fn protected_downstream_blocks_and_names_first_sorted_hit() {
159        let r = radius(
160            "model:orders",
161            &["cube:revenue", "metric:revenue.total_revenue"],
162            Severity::Semantic,
163        );
164        let t = GateThreshold {
165            protected: vec!["metric:revenue.total_revenue".to_string()],
166            ..Default::default()
167        };
168        let (decision, reason) = decide(&r, &t);
169        assert_eq!(decision, GateDecision::Block);
170        assert!(
171            reason.contains("metric:revenue.total_revenue"),
172            "reason was: {reason}"
173        );
174    }
175
176    #[test]
177    fn severity_above_max_escalates() {
178        let r = radius(
179            "model:orders",
180            &["metric:revenue.total"],
181            Severity::Semantic,
182        );
183        let t = GateThreshold {
184            max_severity: Some(Severity::Structural),
185            ..Default::default()
186        };
187        let (decision, reason) = decide(&r, &t);
188        assert_eq!(decision, GateDecision::Escalate);
189        assert!(reason.contains("semantic"), "reason was: {reason}");
190        assert!(reason.contains("structural"), "reason was: {reason}");
191    }
192
193    #[test]
194    fn severity_at_or_below_max_does_not_escalate() {
195        let r = radius(
196            "model:orders",
197            &["dim:revenue.status"],
198            Severity::Compatibility,
199        );
200        let t = GateThreshold {
201            max_severity: Some(Severity::Compatibility),
202            ..Default::default()
203        };
204        let (decision, _) = decide(&r, &t);
205        assert_eq!(decision, GateDecision::Allow);
206    }
207
208    #[test]
209    fn downstream_count_above_max_escalates() {
210        let r = radius("model:orders", &["a", "b", "c"], Severity::Compatibility);
211        let t = GateThreshold {
212            max_downstream: Some(2),
213            ..Default::default()
214        };
215        let (decision, reason) = decide(&r, &t);
216        assert_eq!(decision, GateDecision::Escalate);
217        assert!(
218            reason.contains('3') && reason.contains('2'),
219            "reason was: {reason}"
220        );
221    }
222
223    #[test]
224    fn downstream_count_at_or_below_max_allows() {
225        let r = radius("model:orders", &["a", "b"], Severity::Compatibility);
226        let t = GateThreshold {
227            max_downstream: Some(2),
228            ..Default::default()
229        };
230        let (decision, reason) = decide(&r, &t);
231        assert_eq!(decision, GateDecision::Allow);
232        assert!(reason.contains("within blast-radius limits"));
233    }
234
235    #[test]
236    fn no_thresholds_set_always_allows_when_not_protected_and_nonempty() {
237        let r = radius("model:orders", &["a", "b", "c", "d"], Severity::Semantic);
238        let (decision, _) = decide(&r, &GateThreshold::default());
239        assert_eq!(decision, GateDecision::Allow);
240    }
241
242    #[test]
243    fn severity_str_and_parse_severity_round_trip() {
244        for s in [
245            Severity::None,
246            Severity::Compatibility,
247            Severity::Structural,
248            Severity::Semantic,
249        ] {
250            let name = severity_str(s);
251            assert_eq!(
252                parse_severity(name),
253                Some(s),
254                "round-trip failed for {name}"
255            );
256        }
257        assert_eq!(parse_severity("SEMANTIC"), Some(Severity::Semantic));
258        assert_eq!(parse_severity("nonsense"), None);
259    }
260
261    #[test]
262    fn decision_as_str() {
263        assert_eq!(GateDecision::Allow.as_str(), "allow");
264        assert_eq!(GateDecision::Escalate.as_str(), "escalate");
265        assert_eq!(GateDecision::Block.as_str(), "block");
266    }
267}