1use serde::{Deserialize, Serialize};
14
15use crate::precedence::{
16 OverrideRecord, PolicyVerdict, ResolvedDecision, VerdictKind, PRECEDENCE_LABEL,
17};
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct DecisionTrace {
22 pub matched: Vec<PolicyVerdict>,
26
27 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub winning_kind: Option<VerdictKind>,
32
33 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub winning_source: Option<String>,
37
38 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub overrides: Vec<OverrideRecord>,
42
43 pub precedence: String,
46}
47
48impl DecisionTrace {
49 pub fn from_resolution(matched: Vec<PolicyVerdict>, resolved: &ResolvedDecision) -> Self {
52 let (winning_kind, winning_source) = match resolved.winning.as_ref() {
53 Some(v) => (Some(v.kind), Some(v.source.clone())),
54 None => (None, None),
55 };
56 Self {
57 matched,
58 winning_kind,
59 winning_source,
60 overrides: resolved.overridden.clone(),
61 precedence: PRECEDENCE_LABEL.to_string(),
62 }
63 }
64
65 pub fn had_conflicts(&self) -> bool {
69 !self.overrides.is_empty()
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use crate::precedence::{resolve_conflicts, PolicyVerdict, VerdictKind};
77
78 fn v(kind: VerdictKind, src: &str) -> PolicyVerdict {
79 PolicyVerdict::new(kind, src, format!("{src} matched"))
80 }
81
82 #[test]
83 fn trace_records_winner_source_and_overrides_in_submission_order() {
84 let matched = vec![
85 v(VerdictKind::Allow, "allowlist:allowed"),
86 v(VerdictKind::Deny, "risk_tier:critical"),
87 v(VerdictKind::RequiresApproval, "allowlist:approval"),
88 ];
89 let resolved = resolve_conflicts(matched.clone());
90 let trace = DecisionTrace::from_resolution(matched, &resolved);
91
92 assert_eq!(trace.winning_kind, Some(VerdictKind::Deny));
93 assert_eq!(trace.winning_source.as_deref(), Some("risk_tier:critical"));
94 assert_eq!(trace.overrides.len(), 2);
95 assert_eq!(trace.overrides[0].verdict.kind, VerdictKind::Allow);
97 assert_eq!(
98 trace.overrides[1].verdict.kind,
99 VerdictKind::RequiresApproval
100 );
101 assert!(trace.had_conflicts());
102 assert_eq!(trace.precedence, PRECEDENCE_LABEL);
103 }
104
105 #[test]
106 fn trace_with_single_match_reports_no_conflicts() {
107 let matched = vec![v(VerdictKind::Allow, "allowlist:allowed")];
108 let resolved = resolve_conflicts(matched.clone());
109 let trace = DecisionTrace::from_resolution(matched, &resolved);
110
111 assert_eq!(trace.winning_kind, Some(VerdictKind::Allow));
112 assert!(trace.overrides.is_empty());
113 assert!(!trace.had_conflicts());
114 }
115
116 #[test]
117 fn trace_with_no_matches_records_default_deny_signal() {
118 let matched: Vec<PolicyVerdict> = vec![];
119 let resolved = resolve_conflicts(matched.clone());
120 let trace = DecisionTrace::from_resolution(matched, &resolved);
121
122 assert!(trace.winning_kind.is_none());
123 assert!(trace.winning_source.is_none());
124 assert!(trace.overrides.is_empty());
125 assert!(!trace.had_conflicts());
126 }
127
128 #[test]
129 fn trace_serializes_with_only_set_fields() {
130 let trace = DecisionTrace::from_resolution(vec![], &resolve_conflicts(vec![]));
133 let json = serde_json::to_string(&trace).unwrap();
134 assert!(json.contains("\"matched\""));
135 assert!(json.contains("\"precedence\""));
136 assert!(!json.contains("\"winning_kind\""));
137 assert!(!json.contains("\"winning_source\""));
138 assert!(!json.contains("\"overrides\""));
139 }
140}