Skip to main content

warble_cli/
gate.rs

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