Skip to main content

didbot_policy/
evaluate.rs

1//! Ordered evaluation with deny-only semantics, made structural rather than
2//! conventional.
3//!
4//! `plan/policy.md`: "Adding a policy can never widen what is permitted [...]
5//! Order is presentational, not semantic." [`combine`] is the one place that
6//! property lives: it folds outcomes by severity alone, so no matter what
7//! order they arrive in, the *verdict* — allow, reject, or freeze — can only
8//! stay the same or get more severe as more denying outcomes are folded in.
9//! Only which reason is reported, when several outcomes tie for the worst
10//! severity, depends on order.
11
12use crate::index::PolicyTree;
13use crate::outcome::Outcome;
14use crate::policy::PolicyId;
15use crate::subject::Subject;
16
17/// The result of evaluating a subject against every candidate policy.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum Verdict {
20    /// No candidate policy denied the subject.
21    Allow,
22    /// At least one policy denied the subject, and no policy froze it.
23    Reject {
24        /// The reason of the first-encountered reject-or-worse outcome
25        /// among those tied for most severe. Presentational: which policy
26        /// is named here can depend on evaluation order; that a denial
27        /// happened at all cannot.
28        reason: String,
29        /// Which policy authored `reason`.
30        policy: PolicyId,
31    },
32    /// At least one policy froze the subject. Freezing is strictly more
33    /// severe than rejecting — a frozen account needs an operator, a rejected
34    /// write does not — so a freeze outranks any reject also present.
35    Freeze {
36        /// The reason of the first-encountered freeze outcome.
37        reason: String,
38        /// Which policy authored `reason`.
39        policy: PolicyId,
40    },
41}
42
43/// A total order on severity, `Allow < Reject < Freeze`, kept as a plain
44/// function rather than a derived `Ord` on [`Outcome`] because `Outcome`'s
45/// `reason` fields carry no ordering of their own.
46fn severity(outcome: &Outcome) -> u8 {
47    match outcome {
48        Outcome::Allow => 0,
49        Outcome::Reject { .. } => 1,
50        Outcome::Freeze { .. } => 2,
51    }
52}
53
54/// Fold a set of `(policy, outcome)` pairs into one [`Verdict`], taking the
55/// most severe outcome regardless of the order the pairs arrive in — the
56/// structural half of "order is presentational, not semantic".
57///
58/// Ties (more than one outcome at the worst severity) are broken by
59/// input order: the first one seen is reported. That is the *only* thing
60/// the order of `outcomes` can change.
61pub fn combine(outcomes: impl IntoIterator<Item = (PolicyId, Outcome)>) -> Verdict {
62    let mut best: Option<(u8, PolicyId, String)> = None;
63    for (policy, outcome) in outcomes {
64        let sev = severity(&outcome);
65        if sev == 0 {
66            continue;
67        }
68        let is_new_best = match &best {
69            None => true,
70            Some((best_sev, ..)) => sev > *best_sev,
71        };
72        if is_new_best {
73            let reason = match outcome {
74                Outcome::Reject { reason } | Outcome::Freeze { reason } => reason,
75                Outcome::Allow => unreachable!("sev == 0 already continued"),
76            };
77            best = Some((sev, policy, reason));
78        }
79    }
80    match best {
81        None => Verdict::Allow,
82        Some((2, policy, reason)) => Verdict::Freeze { reason, policy },
83        Some((_, policy, reason)) => Verdict::Reject { reason, policy },
84    }
85}
86
87/// Fold outcomes into the most severe one, taking `Allow < Reject < Freeze`
88/// — the same total order [`combine`] folds by, exposed for callers that
89/// hold outcomes without a [`PolicyId`] to attribute each one to.
90///
91/// The motivating caller is a batch of writes judged as a unit
92/// (`com.atproto.repo.applyWrites`): each operation is judged on its own
93/// subject and the batch's answer is the most severe of theirs, so a freeze
94/// tripped by any one operation outranks a rejection tripped by another and
95/// reaches the caller as a freeze. Ties are broken by input order, exactly as
96/// in [`combine`]: the first outcome at the worst severity is the one whose
97/// reason is reported.
98///
99/// Kept beside [`combine`] rather than reimplemented at each call site so
100/// that severity precedence lives in exactly one place — the private
101/// `severity` order both folds read — and a change to it cannot leave two
102/// folds disagreeing.
103pub fn worst(outcomes: impl IntoIterator<Item = Outcome>) -> Outcome {
104    let mut best: Option<Outcome> = None;
105    for outcome in outcomes {
106        let sev = severity(&outcome);
107        if sev == 0 {
108            continue;
109        }
110        let is_new_best = match &best {
111            None => true,
112            Some(held) => sev > severity(held),
113        };
114        if is_new_best {
115            best = Some(outcome);
116        }
117    }
118    best.unwrap_or(Outcome::Allow)
119}
120
121/// Evaluate a subject against a [`PolicyTree`], in the tree's presentational
122/// order.
123///
124/// Any [`EvalError`](crate::outcome::EvalError) from a candidate's evaluator,
125/// and any candidate whose predicate is
126/// [`Predicate::Unusable`](crate::policy::Predicate::Unusable) — see
127/// [`PolicyTree::decide`] — is treated as a refusal: fail-closed, and
128/// scoped to exactly the subject that predicate was asked about, never
129/// escalated to [`Outcome::Freeze`] on its own. This is deny-only
130/// monotonicity's third consequence from `plan/policy.md`: a predicate
131/// that cannot answer can only make the verdict *more* restrictive, never
132/// less.
133///
134/// The decision channel short-circuits: once a [`Outcome::Freeze`] is seen,
135/// no severity can outrank it, so remaining candidates are not asked to
136/// decide. (They may still be reached by
137/// [`PolicyTree::observation_candidates`] — a separate walk this function
138/// does not perform; see that method's doc comment for why observation is
139/// independent of decision.)
140pub fn evaluate(subject: &Subject<'_>, tree: &PolicyTree) -> Verdict {
141    let mut outcomes = Vec::new();
142    for policy in tree.decision_candidates(subject) {
143        let outcome = tree.decide(subject, policy);
144        let is_freeze = matches!(outcome, Outcome::Freeze { .. });
145        outcomes.push((policy, outcome));
146        if is_freeze {
147            break;
148        }
149    }
150    combine(outcomes)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn reject(text: &str) -> Outcome {
158        Outcome::Reject {
159            reason: text.to_string(),
160        }
161    }
162
163    fn freeze(text: &str) -> Outcome {
164        Outcome::Freeze {
165            reason: text.to_string(),
166        }
167    }
168
169    fn pid(i: u32) -> PolicyId {
170        PolicyId(i)
171    }
172
173    #[test]
174    fn worst_of_nothing_allows() {
175        assert_eq!(worst(Vec::new()), Outcome::Allow);
176        assert_eq!(worst(vec![Outcome::Allow, Outcome::Allow]), Outcome::Allow);
177    }
178
179    /// A freeze anywhere in the set outranks any rejection also present, and
180    /// which position it occupies cannot change that — the property a batch
181    /// of writes depends on to surface a freeze rather than a rejection.
182    #[test]
183    fn worst_takes_a_freeze_over_a_reject_in_any_order() {
184        let base = [
185            Outcome::Allow,
186            reject("a"),
187            freeze("b"),
188            reject("c"),
189            Outcome::Allow,
190        ];
191        for start in 0..base.len() {
192            let mut rotated = base[start..].to_vec();
193            rotated.extend_from_slice(&base[..start]);
194            assert!(
195                matches!(worst(rotated), Outcome::Freeze { .. }),
196                "a Freeze anywhere in the set must win regardless of order"
197            );
198        }
199    }
200
201    /// Ties at the worst severity report the first one seen, which is what
202    /// makes the reason a caller receives stable rather than arbitrary.
203    #[test]
204    fn worst_breaks_ties_toward_the_first_outcome_seen() {
205        assert_eq!(
206            worst(vec![Outcome::Allow, reject("first"), reject("second")]),
207            reject("first")
208        );
209    }
210
211    #[test]
212    fn empty_outcomes_allow() {
213        assert_eq!(combine(Vec::new()), Verdict::Allow);
214    }
215
216    #[test]
217    fn a_single_reject_denies() {
218        let v = combine(vec![(pid(0), reject("no"))]);
219        assert_eq!(
220            v,
221            Verdict::Reject {
222                reason: "no".into(),
223                policy: pid(0)
224            }
225        );
226    }
227
228    /// Deny-only, made structural: no permutation of a fixed outcome set can
229    /// change the verdict's *severity* — only which reason ties are broken
230    /// toward. This is the property `plan/policy.md` calls "order is
231    /// presentational, not semantic."
232    #[test]
233    fn shuffling_outcomes_cannot_change_the_verdict_severity() {
234        use Outcome::*;
235        let base = [
236            (pid(0), Allow),
237            (pid(1), reject("a")),
238            (pid(2), Allow),
239            (pid(3), freeze("b")),
240            (pid(4), reject("c")),
241        ];
242
243        // Every rotation of the same multiset of outcomes.
244        for start in 0..base.len() {
245            let mut rotated = base[start..].to_vec();
246            rotated.extend_from_slice(&base[..start]);
247            let verdict = combine(rotated);
248            assert!(
249                matches!(verdict, Verdict::Freeze { .. }),
250                "a Freeze anywhere in the set must win regardless of order"
251            );
252        }
253    }
254
255    /// Adding a policy — i.e. folding in one more outcome — can only hold
256    /// the verdict's severity steady or increase it, never decrease it.
257    /// That is monotonicity, made structural: nothing in `combine` can turn
258    /// a Reject back into an Allow.
259    #[test]
260    fn adding_an_outcome_never_reduces_severity() {
261        let mut outcomes = vec![(pid(0), Outcome::Allow)];
262        assert_eq!(combine(outcomes.clone()), Verdict::Allow);
263
264        outcomes.push((pid(1), reject("no")));
265        assert!(matches!(combine(outcomes.clone()), Verdict::Reject { .. }));
266
267        outcomes.push((pid(2), Outcome::Allow));
268        assert!(
269            matches!(combine(outcomes.clone()), Verdict::Reject { .. }),
270            "an added Allow cannot undo an earlier Reject"
271        );
272
273        outcomes.push((pid(3), freeze("worse")));
274        assert!(matches!(combine(outcomes), Verdict::Freeze { .. }));
275    }
276
277    /// Two rejects tied for worst severity: which reason is reported can
278    /// depend on order (presentational), but both orders still deny
279    /// (semantic content is order-independent).
280    #[test]
281    fn tied_severity_reports_the_first_seen_reason() {
282        let first = combine(vec![(pid(0), reject("a")), (pid(1), reject("b"))]);
283        let second = combine(vec![(pid(1), reject("b")), (pid(0), reject("a"))]);
284        assert!(matches!(first, Verdict::Reject { ref reason, .. } if reason == "a"));
285        assert!(matches!(second, Verdict::Reject { ref reason, .. } if reason == "b"));
286    }
287}