Skip to main content

fd_policy/
precedence.rs

1//! Explicit policy-conflict resolution.
2//!
3//! When multiple policies match the same action (e.g. one matches by glob
4//! allowlist, another by risk-tier denylist, a third demands approval), a
5//! single winning decision must be picked deterministically — never by
6//! "whichever rule was evaluated first."
7//!
8//! This module encodes the project's canonical precedence:
9//!
10//! ```text
11//! Deny  >  RequiresApproval  >  BudgetCap  >  Allow
12//! ```
13//!
14//! …as the pure, named, testable [`precedence_rank`] function plus
15//! [`resolve_conflicts`], which folds a bag of [`PolicyVerdict`]s into a
16//! single [`ResolvedDecision`] and an audit-grade list of overrides.
17//!
18//! Deny-by-default behaviour is preserved at the caller: if zero verdicts
19//! are produced, the caller must still treat the action as denied. This
20//! module never invents an `Allow` out of thin air.
21//!
22//! See `docs/runbooks/policy-conflict-resolution.md` for operator-facing
23//! guidance.
24
25use serde::{Deserialize, Serialize};
26
27/// Kind of verdict a single matching policy can produce.
28///
29/// Variants are ordered by precedence — `Deny` is the strongest, `Allow` the
30/// weakest. The numeric ranks are returned by [`precedence_rank`] (lower
31/// number wins). `BudgetCap` is the dedicated tier for budget-driven
32/// rejections so operators can distinguish "denied because the allowlist
33/// said no" from "denied because the run is over budget".
34#[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/// Precedence rank for a verdict kind. Lower = stronger.
55///
56/// This is the *only* place in the codebase that names the precedence
57/// order. All callers MUST consult it instead of inlining `match`
58/// arms — that's the invariant being protected.
59#[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/// A single matched policy's verdict on an action.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct PolicyVerdict {
72    /// The verdict's effective kind.
73    pub kind: VerdictKind,
74    /// Short stable identifier of where the verdict came from — e.g.
75    /// `"allowlist:denied"`, `"allowlist:approval"`, `"budget:max_cost_cents"`.
76    /// Used by the dashboard to label rows in the trace UI.
77    pub source: String,
78    /// Operator-readable explanation. Surfaced in audit and SSE.
79    pub reason: String,
80    /// Optional reference to the originating rule (`pol_*` ID, allowlist
81    /// pattern, budget axis). Free-form so non-Postgres rule sources can
82    /// also fit.
83    #[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/// A record of a verdict that lost the precedence comparison.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct OverrideRecord {
106    /// The verdict that was overridden.
107    pub verdict: PolicyVerdict,
108    /// The kind that overrode it (= the winning verdict's kind).
109    pub overridden_by: VerdictKind,
110    /// Why — references the canonical precedence ordering.
111    pub reason: String,
112}
113
114/// Resolution of a conflicting set of verdicts.
115///
116/// `winning` is the verdict that survives precedence. `overridden` lists
117/// the others in the order they were submitted to [`resolve_conflicts`],
118/// each annotated with the precedence reason. `None` for `winning` means
119/// the caller passed in zero verdicts — the policy plane saw nothing
120/// match, and the deny-by-default fallback at the caller applies.
121#[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
138/// Human-readable summary of the precedence ordering. Surfaced in the
139/// trace so dashboards and audit log readers don't need to consult code.
140pub const PRECEDENCE_LABEL: &str = "deny > requires_approval > budget_cap > allow";
141
142/// Resolve a bag of matching verdicts into a single decision plus an
143/// override list. Pure — same inputs always produce the same outputs.
144///
145/// Tie-breaking within a precedence tier: the *first* verdict submitted
146/// in that tier wins. This makes evaluation order matter only inside a
147/// tier (e.g. between two `Deny` rules), not across tiers. Verdicts that
148/// lose the tier tie-breaker are still recorded as overridden so the
149/// trace is complete.
150pub fn resolve_conflicts(verdicts: Vec<PolicyVerdict>) -> ResolvedDecision {
151    if verdicts.is_empty() {
152        return ResolvedDecision::empty();
153    }
154
155    // Find the winner by index so duplicates / equal verdicts don't confuse
156    // the override list. Stable: scan once, keep the strongest rank; on a
157    // tie, the earlier submission stays.
158    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            // winner hasn't been popped yet (loser appears before winner in
178            // submission order) — derive from the index we recorded above.
179            None => {
180                // SAFETY: winner_idx was clamped to a valid index above and
181                // ranks line up with kinds 1:1.
182                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    // -------------------------------------------------------------------------
228    // precedence_rank
229    // -------------------------------------------------------------------------
230
231    #[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        // Cheap regression guard so doc and code don't drift apart.
246        assert_eq!(
247            PRECEDENCE_LABEL,
248            "deny > requires_approval > budget_cap > allow"
249        );
250    }
251
252    // -------------------------------------------------------------------------
253    // resolve_conflicts — empty / single verdict
254    // -------------------------------------------------------------------------
255
256    #[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    // -------------------------------------------------------------------------
271    // resolve_conflicts — cross-tier
272    // -------------------------------------------------------------------------
273
274    #[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        // Overrides retain submission order.
316        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    // -------------------------------------------------------------------------
348    // resolve_conflicts — within-tier tie-breaking
349    // -------------------------------------------------------------------------
350
351    #[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    // -------------------------------------------------------------------------
365    // Determinism
366    // -------------------------------------------------------------------------
367
368    #[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}