1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum GateDecision {
15 Allow,
17 Escalate,
19 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
36pub struct GateThreshold {
37 pub max_severity_rank: Option<u32>,
42 pub max_downstream: Option<usize>,
44 pub protected: Vec<String>,
46}
47
48pub 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 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 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 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}