1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum GateDecision {
12 Allow,
14 Escalate,
16 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
33pub struct GateThreshold {
34 pub max_severity: Option<warble::Severity>,
36 pub max_downstream: Option<usize>,
38 pub protected: Vec<String>,
40}
41
42pub 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
52pub 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
65pub 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}