1use serde::{Deserialize, Serialize};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum VerdictKind {
37 Deny,
38 RequiresApproval,
39 BudgetCap,
40 Allow,
41}
42
43impl VerdictKind {
44 pub fn as_str(self) -> &'static str {
45 match self {
46 VerdictKind::Deny => "deny",
47 VerdictKind::RequiresApproval => "requires_approval",
48 VerdictKind::BudgetCap => "budget_cap",
49 VerdictKind::Allow => "allow",
50 }
51 }
52}
53
54#[inline]
60pub const fn precedence_rank(kind: VerdictKind) -> u8 {
61 match kind {
62 VerdictKind::Deny => 0,
63 VerdictKind::RequiresApproval => 1,
64 VerdictKind::BudgetCap => 2,
65 VerdictKind::Allow => 3,
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct PolicyVerdict {
72 pub kind: VerdictKind,
74 pub source: String,
78 pub reason: String,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub rule_ref: Option<String>,
85}
86
87impl PolicyVerdict {
88 pub fn new(kind: VerdictKind, source: impl Into<String>, reason: impl Into<String>) -> Self {
89 Self {
90 kind,
91 source: source.into(),
92 reason: reason.into(),
93 rule_ref: None,
94 }
95 }
96
97 pub fn with_rule_ref(mut self, rule_ref: impl Into<String>) -> Self {
98 self.rule_ref = Some(rule_ref.into());
99 self
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct OverrideRecord {
106 pub verdict: PolicyVerdict,
108 pub overridden_by: VerdictKind,
110 pub reason: String,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct ResolvedDecision {
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub winning: Option<PolicyVerdict>,
125 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub overridden: Vec<OverrideRecord>,
127}
128
129impl ResolvedDecision {
130 pub fn empty() -> Self {
131 Self {
132 winning: None,
133 overridden: Vec::new(),
134 }
135 }
136}
137
138pub const PRECEDENCE_LABEL: &str = "deny > requires_approval > budget_cap > allow";
141
142pub fn resolve_conflicts(verdicts: Vec<PolicyVerdict>) -> ResolvedDecision {
151 if verdicts.is_empty() {
152 return ResolvedDecision::empty();
153 }
154
155 let mut winner_idx: usize = 0;
159 let mut winner_rank: u8 = precedence_rank(verdicts[0].kind);
160 for (idx, verdict) in verdicts.iter().enumerate().skip(1) {
161 let rank = precedence_rank(verdict.kind);
162 if rank < winner_rank {
163 winner_idx = idx;
164 winner_rank = rank;
165 }
166 }
167
168 let mut winning: Option<PolicyVerdict> = None;
169 let mut overridden: Vec<OverrideRecord> = Vec::with_capacity(verdicts.len().saturating_sub(1));
170 for (idx, verdict) in verdicts.into_iter().enumerate() {
171 if idx == winner_idx {
172 winning = Some(verdict);
173 continue;
174 }
175 let winner_kind = match winning.as_ref().map(|w| w.kind) {
176 Some(k) => k,
177 None => {
180 match winner_rank {
183 0 => VerdictKind::Deny,
184 1 => VerdictKind::RequiresApproval,
185 2 => VerdictKind::BudgetCap,
186 _ => VerdictKind::Allow,
187 }
188 }
189 };
190 overridden.push(OverrideRecord {
191 overridden_by: winner_kind,
192 reason: override_reason(verdict.kind, winner_kind),
193 verdict,
194 });
195 }
196
197 ResolvedDecision {
198 winning,
199 overridden,
200 }
201}
202
203fn override_reason(loser: VerdictKind, winner: VerdictKind) -> String {
204 if precedence_rank(loser) == precedence_rank(winner) {
205 format!(
206 "tied with the winning {} verdict; first-submitted wins ({})",
207 winner.as_str(),
208 PRECEDENCE_LABEL
209 )
210 } else {
211 format!(
212 "overridden by higher-precedence {} verdict ({})",
213 winner.as_str(),
214 PRECEDENCE_LABEL
215 )
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 fn v(kind: VerdictKind, src: &str) -> PolicyVerdict {
224 PolicyVerdict::new(kind, src, format!("{src} matched"))
225 }
226
227 #[test]
232 fn precedence_rank_orders_deny_highest_allow_lowest() {
233 assert!(
234 precedence_rank(VerdictKind::Deny) < precedence_rank(VerdictKind::RequiresApproval)
235 );
236 assert!(
237 precedence_rank(VerdictKind::RequiresApproval)
238 < precedence_rank(VerdictKind::BudgetCap)
239 );
240 assert!(precedence_rank(VerdictKind::BudgetCap) < precedence_rank(VerdictKind::Allow));
241 }
242
243 #[test]
244 fn precedence_label_lists_kinds_in_order() {
245 assert_eq!(
247 PRECEDENCE_LABEL,
248 "deny > requires_approval > budget_cap > allow"
249 );
250 }
251
252 #[test]
257 fn empty_verdict_set_returns_empty_resolution() {
258 let resolved = resolve_conflicts(vec![]);
259 assert!(resolved.winning.is_none());
260 assert!(resolved.overridden.is_empty());
261 }
262
263 #[test]
264 fn single_verdict_wins_with_no_overrides() {
265 let resolved = resolve_conflicts(vec![v(VerdictKind::Allow, "allowlist:allowed")]);
266 assert_eq!(resolved.winning.as_ref().unwrap().kind, VerdictKind::Allow);
267 assert!(resolved.overridden.is_empty());
268 }
269
270 #[test]
275 fn deny_beats_allow() {
276 let resolved = resolve_conflicts(vec![
277 v(VerdictKind::Allow, "allowlist:allowed"),
278 v(VerdictKind::Deny, "allowlist:denied"),
279 ]);
280 assert_eq!(resolved.winning.as_ref().unwrap().kind, VerdictKind::Deny);
281 assert_eq!(resolved.overridden.len(), 1);
282 assert_eq!(resolved.overridden[0].verdict.kind, VerdictKind::Allow);
283 assert_eq!(resolved.overridden[0].overridden_by, VerdictKind::Deny);
284 assert!(resolved.overridden[0]
285 .reason
286 .contains("higher-precedence deny"));
287 }
288
289 #[test]
290 fn deny_beats_requires_approval() {
291 let resolved = resolve_conflicts(vec![
292 v(VerdictKind::RequiresApproval, "allowlist:approval"),
293 v(VerdictKind::Deny, "allowlist:denied"),
294 ]);
295 assert_eq!(resolved.winning.as_ref().unwrap().kind, VerdictKind::Deny);
296 assert_eq!(resolved.overridden.len(), 1);
297 assert_eq!(
298 resolved.overridden[0].verdict.kind,
299 VerdictKind::RequiresApproval
300 );
301 }
302
303 #[test]
304 fn requires_approval_beats_budget_cap_and_allow() {
305 let resolved = resolve_conflicts(vec![
306 v(VerdictKind::Allow, "allowlist:allowed"),
307 v(VerdictKind::BudgetCap, "budget:max_cost_cents"),
308 v(VerdictKind::RequiresApproval, "allowlist:approval"),
309 ]);
310 assert_eq!(
311 resolved.winning.as_ref().unwrap().kind,
312 VerdictKind::RequiresApproval
313 );
314 assert_eq!(resolved.overridden.len(), 2);
315 assert_eq!(resolved.overridden[0].verdict.kind, VerdictKind::Allow);
317 assert_eq!(resolved.overridden[1].verdict.kind, VerdictKind::BudgetCap);
318 }
319
320 #[test]
321 fn budget_cap_beats_allow() {
322 let resolved = resolve_conflicts(vec![
323 v(VerdictKind::Allow, "allowlist:allowed"),
324 v(VerdictKind::BudgetCap, "budget:max_cost_cents"),
325 ]);
326 assert_eq!(
327 resolved.winning.as_ref().unwrap().kind,
328 VerdictKind::BudgetCap
329 );
330 assert_eq!(resolved.overridden.len(), 1);
331 assert_eq!(resolved.overridden[0].verdict.kind, VerdictKind::Allow);
332 }
333
334 #[test]
335 fn deny_wins_even_when_three_kinds_collide() {
336 let resolved = resolve_conflicts(vec![
337 v(VerdictKind::Allow, "allowlist:allowed"),
338 v(VerdictKind::RequiresApproval, "allowlist:approval"),
339 v(VerdictKind::Deny, "allowlist:denied"),
340 ]);
341 let winning = resolved.winning.unwrap();
342 assert_eq!(winning.kind, VerdictKind::Deny);
343 assert_eq!(winning.source, "allowlist:denied");
344 assert_eq!(resolved.overridden.len(), 2);
345 }
346
347 #[test]
352 fn within_tier_first_submitted_wins_others_recorded() {
353 let resolved = resolve_conflicts(vec![
354 v(VerdictKind::Deny, "allowlist:denied"),
355 v(VerdictKind::Deny, "risk_tier:critical"),
356 ]);
357 let winning = resolved.winning.unwrap();
358 assert_eq!(winning.source, "allowlist:denied");
359 assert_eq!(resolved.overridden.len(), 1);
360 assert_eq!(resolved.overridden[0].verdict.source, "risk_tier:critical");
361 assert!(resolved.overridden[0].reason.contains("tied"));
362 }
363
364 #[test]
369 fn resolution_is_deterministic_for_same_input() {
370 let input = vec![
371 v(VerdictKind::Allow, "allowlist:allowed"),
372 v(VerdictKind::BudgetCap, "budget:max_cost_cents"),
373 v(VerdictKind::Deny, "allowlist:denied"),
374 v(VerdictKind::RequiresApproval, "allowlist:approval"),
375 ];
376 let first = resolve_conflicts(input.clone());
377 let second = resolve_conflicts(input);
378 assert_eq!(first, second);
379 }
380}