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}