Skip to main content

fd_policy/
trace.rs

1//! Explanation traces for policy decisions.
2//!
3//! Every [`PolicyDecision`](crate::decision::PolicyDecision) returned by the
4//! engine carries an optional [`DecisionTrace`] so audit consumers and the
5//! dashboard can answer "why was this denied / approved" without re-running
6//! the engine.
7//!
8//! The trace is **additive metadata** — existing API contracts continue to
9//! work unchanged. Consumers that don't care about explanations can ignore
10//! the field; consumers that do care receive the full conflict-resolution
11//! record produced by [`crate::precedence::resolve_conflicts`].
12
13use serde::{Deserialize, Serialize};
14
15use crate::precedence::{
16    OverrideRecord, PolicyVerdict, ResolvedDecision, VerdictKind, PRECEDENCE_LABEL,
17};
18
19/// Audit-grade explanation of how a policy decision was reached.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct DecisionTrace {
22    /// Every verdict that matched, in the order they were submitted to the
23    /// conflict resolver. This preserves what the policy plane *saw*; the
24    /// `winning_*` fields say which one survived precedence.
25    pub matched: Vec<PolicyVerdict>,
26
27    /// Kind of the winning verdict. `None` only when the policy plane saw
28    /// zero matches — i.e. the deny-by-default fallback at the caller is
29    /// what's actually denying the action.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub winning_kind: Option<VerdictKind>,
32
33    /// Stable source string of the winning verdict. See
34    /// [`PolicyVerdict::source`] for the conventions.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub winning_source: Option<String>,
37
38    /// Each verdict that lost the precedence comparison, with the reason it
39    /// was overridden.
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub overrides: Vec<OverrideRecord>,
42
43    /// Human-readable precedence ordering. Frozen at decision time so a
44    /// later code change can't make an old audit record lie.
45    pub precedence: String,
46}
47
48impl DecisionTrace {
49    /// Build a trace from the set of matched verdicts and the resolved
50    /// decision they produced.
51    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    /// True iff the trace records at least one override — i.e. there was an
66    /// actual conflict between matching policies. Useful for dashboards
67    /// that want to badge "this decision had conflicts resolved".
68    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        // Overrides retain submission order (Allow first, then Approval).
96        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        // No-matches case should serialize as a small object — no `null`s
131        // for the optional fields.
132        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}