Skip to main content

didbot_policy/
lib.rs

1//! The policy machinery: what a policy declares, the tree that indexes it,
2//! and deny-only evaluation over the ordered list the tree produces.
3//!
4//! This is the core `plan/policy.md` describes — not a policy language, not
5//! an evaluator, not I/O. Two sibling crates use it: `didbot-policy-regex`
6//! is a deterministic in-process [`Evaluator`], and `didbot-pds`'s write
7//! path drives [`evaluate()`] per write, outside its store lock, and turns a
8//! [`Verdict::Freeze`] into the queue-draining behaviour `plan/policy.md`
9//! describes under "Outcomes" — this crate produces the verdict and stops
10//! there.
11//!
12//! # What is here, and why
13//!
14//! - [`Subject`], [`WriteAction`], [`Diff`], [`Change`],
15//!   [`SubjectAttributes`], [`AccountKind`], [`DeploymentAttributes`],
16//!   [`Universal`] and [`ClientId`] — what a policy is asked about. Three
17//!   subject shapes, one set of mechanics; a write's diff carries
18//!   before/after values, not just paths, so a rule reads a changed value
19//!   rather than only knowing that a path changed; and a subject is not
20//!   always an agent — see [`AccountKind`].
21//! - [`Applicability`], [`Scope`], [`ActionSet`], [`KindSet`],
22//!   [`Predicate`], [`Revision`] and [`PolicyDeclaration`] — what a policy
23//!   declares statically. This is what goes in the tree; a policy's actual
24//!   judgement stays behind the [`Evaluator`] trait, opaque to this crate.
25//!   `Predicate` is what lets a policy's applicability be known and
26//!   scoped even when its predicate is not — see its doc comment for why
27//!   that split matters once policies are independent records rather than
28//!   a set.
29//! - [`PolicyTree`] and [`PolicyTreeBuilder`] — the applicability index.
30//!   Turns a subject into an ordered candidate list in time bounded by the
31//!   subject's own diff, not by how many policies are registered.
32//! - [`Outcome`], [`Staleness`], [`Observed`], [`Lifecycle`], [`EvalError`]
33//!   and the [`Evaluator`] trait — the contract every evaluator implements.
34//! - [`Language`] and [`KnownLanguage`] — the open union of policy languages
35//!   this build can turn into a working [`Evaluator`]. A wrapper's loader
36//!   resolves [`Language::Unknown`] to [`Predicate::Unusable`] rather than
37//!   failing to parse the record; see [`Language`]'s doc comment.
38//! - [`Verdict`], [`combine`] and [`evaluate()`] — ordered evaluation with
39//!   deny-only semantics. [`combine`] is where monotonicity is structural
40//!   rather than conventional: see its doc comment.
41//!
42//! # What is deliberately not here
43//!
44//! No I/O, no async, anywhere in this crate. Evaluators do the work — some
45//! of them out of process, some of them with a model or a network
46//! resolution in the loop — and this crate stays the thing that is testable
47//! without a runtime and cheap to reason about under a deadline. See
48//! [`Evaluator`]'s doc comment for what that implies for anyone implementing
49//! one.
50//!
51//! No storage of policies, no polling, no wrapper metadata (source, format
52//! version, which PDS a record applies to). `plan/policy.md` calls that "a
53//! policy is not its wrapper" and leaves the wrapper's shape and the
54//! lexicon that carries it unsettled; this crate only consumes the
55//! [`PolicyDeclaration`] a wrapper resolves to, never the wrapper itself.
56//!
57//! No position on what a delete means. [`Diff`] makes exact every path
58//! whose value differs between before and after, absence included as a
59//! value, and [`WriteAction`] says which kind of write it was. A policy can
60//! declare it applies only to deletes; an evaluator decides what a delete
61//! means for its own rules.
62
63#![forbid(unsafe_code)]
64
65mod client_id;
66mod evaluate;
67mod index;
68mod json;
69mod language;
70mod outcome;
71mod policy;
72mod subject;
73
74pub use client_id::{is_local_origin, ClientId, ClientIdError};
75pub use evaluate::{combine, evaluate, worst, Verdict};
76pub use index::{PolicyTree, PolicyTreeBuilder};
77pub use json::sorted_json;
78pub use language::{KnownLanguage, Language};
79pub use outcome::{
80    CompileOutcome, EvalError, Evaluator, Lifecycle, Observed, Outcome, PolicyLifecycle, Staleness,
81};
82pub use policy::{
83    ActionSet, Applicability, CompiledId, EvaluatorId, KindSet, PolicyDeclaration, PolicyId,
84    Predicate, Revision, Scope,
85};
86pub use subject::{
87    AccountKind, Asked, Change, DeploymentAttributes, Diff, Found, Lookup, Subject,
88    SubjectAttributes, SubjectKind, Universal, WriteAction,
89};
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use std::sync::atomic::{AtomicUsize, Ordering};
95    use std::sync::Arc;
96
97    /// An evaluator that always returns the same outcome, and counts how
98    /// many times it was asked.
99    struct Fixed {
100        outcome: Outcome,
101        calls: AtomicUsize,
102    }
103
104    impl Fixed {
105        fn new(outcome: Outcome) -> Self {
106            Fixed {
107                outcome,
108                calls: AtomicUsize::new(0),
109            }
110        }
111    }
112
113    impl Evaluator for Fixed {
114        fn compile(&self, _predicate: &serde_json::Value) -> CompileOutcome {
115            CompileOutcome::Compiled(CompiledId(0))
116        }
117        fn evaluate(
118            &self,
119            _subject: &Subject<'_>,
120            _compiled: CompiledId,
121        ) -> Result<Outcome, EvalError> {
122            self.calls.fetch_add(1, Ordering::SeqCst);
123            Ok(self.outcome.clone())
124        }
125        fn observe(&self, _event: &Observed<'_>) {}
126        fn lifecycle(&self, _event: &Lifecycle<'_>) {}
127        fn staleness(&self) -> Staleness {
128            Staleness::Linearized
129        }
130    }
131
132    /// An evaluator that compiles cleanly and then fails at decision
133    /// time -- the live counterpart to [`Predicate::Unusable`], which is a
134    /// compile-time failure. Reachable in production wherever an evaluator
135    /// answers a subject shape its policy was routed for but cannot judge.
136    struct FailsAtDecisionTime;
137
138    impl Evaluator for FailsAtDecisionTime {
139        fn compile(&self, _predicate: &serde_json::Value) -> CompileOutcome {
140            CompileOutcome::Compiled(CompiledId(0))
141        }
142        fn evaluate(
143            &self,
144            _subject: &Subject<'_>,
145            _compiled: CompiledId,
146        ) -> Result<Outcome, EvalError> {
147            Err(EvalError::Failed("the evaluator is not answering".into()))
148        }
149        fn observe(&self, _event: &Observed<'_>) {}
150        fn lifecycle(&self, _event: &Lifecycle<'_>) {}
151        fn staleness(&self) -> Staleness {
152            Staleness::Linearized
153        }
154    }
155
156    /// A working evaluator that fails at decision time is fail-closed, and
157    /// scoped by the tree exactly as a `Predicate::Unusable` is:
158    /// `plan/policy.md`'s "fail closed per policy, not per deployment".
159    /// Distinct from the `Unusable` case above -- that one never compiled,
160    /// this one compiled and then stopped answering -- and the two reach
161    /// `Reject` down different arms of `PolicyTree::decide`.
162    #[test]
163    fn an_evaluator_that_fails_denies_only_within_its_own_applicability() {
164        let mut builder = PolicyTree::builder();
165        let evaluator_id = builder.register_evaluator(Arc::new(FailsAtDecisionTime));
166        builder.add_policy(PolicyDeclaration {
167            applicability: Applicability::Write {
168                kinds: KindSet::ALL,
169                collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
170                actions: ActionSet::ALL,
171                path_prefixes: Scope::Any,
172            },
173            observes: Applicability::Write {
174                kinds: KindSet::ALL,
175                collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
176                actions: ActionSet::ALL,
177                path_prefixes: Scope::Any,
178            },
179            staleness: Staleness::Linearized,
180            predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
181            revision: Revision(1),
182        });
183        let tree = builder.build();
184
185        let covered = write("app.bsky.feed.post", WriteAction::Create, &[]);
186        assert!(
187            matches!(
188                evaluate(&covered, &tree),
189                Verdict::Reject { ref reason, .. }
190                    if reason.contains("the evaluator is not answering")
191            ),
192            "a failing evaluator must deny the writes it was routed, naming why"
193        );
194
195        let uncovered = write("bot.did.record", WriteAction::Create, &[]);
196        assert_eq!(
197            evaluate(&uncovered, &tree),
198            Verdict::Allow,
199            "one evaluator's failure must not widen into a blanket denial"
200        );
201    }
202
203    /// `decide_detailed` tells a caller explaining a refusal to an operator
204    /// whether the evaluator itself decided it, or whether this tree
205    /// substituted a fail-closed refusal because the evaluator could not
206    /// answer -- the distinction `didbot-policy-check`'s simulator draws
207    /// between a policy that fired and a policy whose check could not
208    /// complete. `decide` itself does not care, and returns the same
209    /// `Outcome` either way.
210    #[test]
211    fn decide_detailed_tells_a_decision_apart_from_a_substituted_refusal() {
212        let mut builder = PolicyTree::builder();
213        let working = builder.register_evaluator(Arc::new(Fixed::new(Outcome::Reject {
214            reason: "no".into(),
215        })));
216        let failing = builder.register_evaluator(Arc::new(FailsAtDecisionTime));
217        let applicability = || Applicability::Write {
218            kinds: KindSet::ALL,
219            collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
220            actions: ActionSet::ALL,
221            path_prefixes: Scope::Any,
222        };
223        let decided = builder.add_policy(PolicyDeclaration {
224            applicability: applicability(),
225            observes: applicability(),
226            staleness: Staleness::Linearized,
227            predicate: Predicate::Evaluator(working, CompiledId(0)),
228            revision: Revision(1),
229        });
230        let substituted = builder.add_policy(PolicyDeclaration {
231            applicability: applicability(),
232            observes: applicability(),
233            staleness: Staleness::Linearized,
234            predicate: Predicate::Evaluator(failing, CompiledId(0)),
235            revision: Revision(1),
236        });
237        let tree = builder.build();
238        let subject = write("app.bsky.feed.post", WriteAction::Create, &[]);
239
240        let (outcome, errored) = tree.decide_detailed(&subject, decided);
241        assert!(matches!(outcome, Outcome::Reject { ref reason } if reason == "no"));
242        assert!(
243            !errored,
244            "the evaluator's own decision is not a substituted refusal"
245        );
246        assert_eq!(tree.decide(&subject, decided), outcome);
247
248        let (outcome, errored) = tree.decide_detailed(&subject, substituted);
249        assert!(matches!(outcome, Outcome::Reject { .. }));
250        assert!(errored, "a failed evaluate() call is a substituted refusal");
251        assert_eq!(tree.decide(&subject, substituted), outcome);
252    }
253
254    /// A placeholder JSON value, for tests that only care which paths a
255    /// diff touched, not what changed at them.
256    fn dummy_value() -> &'static serde_json::Value {
257        static VALUE: std::sync::OnceLock<serde_json::Value> = std::sync::OnceLock::new();
258        VALUE.get_or_init(|| serde_json::Value::Bool(true))
259    }
260
261    /// Deployment attributes and a fixed instant, for tests that do not
262    /// exercise a time- or deployment-shaped rule.
263    fn test_universal() -> Universal<'static> {
264        Universal {
265            deployment: DeploymentAttributes {
266                pds_did: "did:web:test.example",
267                pds_hostname: "test.example",
268                operator_did: None,
269            },
270            now: time::macros::datetime!(2026 - 01 - 01 0:00 UTC),
271        }
272    }
273
274    /// Build a write [`Subject`] that touched exactly `paths`, each
275    /// reported as newly present — enough for index-routing tests, which
276    /// never inspect `before`/`after`.
277    fn write<'a>(collection: &'a str, action: WriteAction, paths: &'a [&'a str]) -> Subject<'a> {
278        let changes: Vec<Change<'a>> = paths
279            .iter()
280            .map(|&path| Change {
281                path,
282                before: None,
283                after: Some(dummy_value()),
284            })
285            .collect();
286        let changes: &'a [Change<'a>] = Box::leak(changes.into_boxed_slice());
287        Subject::Write {
288            account: "did:plc:agent",
289            attributes: SubjectAttributes {
290                kind: SubjectKind::Known(AccountKind::Agent),
291                handle: None,
292                depth: None,
293            },
294            universal: test_universal(),
295            client_id: None,
296            collection,
297            action,
298            diff: Diff { changes },
299            before: None,
300            after: None,
301            lookups: &[],
302        }
303    }
304
305    /// A write to a collection nothing is registered for costs only the
306    /// tree walk: no evaluator is ever invoked, regardless of how many
307    /// policies exist for *other* collections.
308    #[test]
309    fn empty_match_never_calls_an_evaluator() {
310        let denier = Arc::new(Fixed::new(Outcome::Reject {
311            reason: "no".into(),
312        }));
313        let mut builder = PolicyTree::builder();
314        let evaluator_id = builder.register_evaluator(denier.clone());
315        builder.add_policy(PolicyDeclaration {
316            applicability: Applicability::Write {
317                kinds: KindSet::ALL,
318                collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
319                actions: ActionSet::ALL,
320                path_prefixes: Scope::Any,
321            },
322            observes: Applicability::Write {
323                kinds: KindSet::ALL,
324                collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
325                actions: ActionSet::ALL,
326                path_prefixes: Scope::Any,
327            },
328            staleness: Staleness::Linearized,
329            predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
330            revision: Revision(1),
331        });
332        let tree = builder.build();
333
334        let subject = write("bot.did.record", WriteAction::Create, &["track"]);
335        assert!(tree.decision_candidates(&subject).is_empty());
336
337        let verdict = evaluate(&subject, &tree);
338        assert_eq!(verdict, Verdict::Allow);
339        assert_eq!(denier.calls.load(Ordering::SeqCst), 0);
340    }
341
342    /// A denying policy registered with `Scope::Any` collections applies
343    /// everywhere, including a collection with no specific entry.
344    #[test]
345    fn a_wildcard_collection_policy_still_matches() {
346        let denier = Arc::new(Fixed::new(Outcome::Reject {
347            reason: "app denied".into(),
348        }));
349        let mut builder = PolicyTree::builder();
350        let evaluator_id = builder.register_evaluator(denier);
351        builder.add_policy(PolicyDeclaration {
352            applicability: Applicability::Write {
353                kinds: KindSet::ALL,
354                collections: Scope::Any,
355                actions: ActionSet::ALL,
356                path_prefixes: Scope::Any,
357            },
358            observes: Applicability::Write {
359                kinds: KindSet::ALL,
360                collections: Scope::Any,
361                actions: ActionSet::ALL,
362                path_prefixes: Scope::Any,
363            },
364            staleness: Staleness::Linearized,
365            predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
366            revision: Revision(1),
367        });
368        let tree = builder.build();
369
370        let subject = write("bot.did.record", WriteAction::Create, &[]);
371        let verdict = evaluate(&subject, &tree);
372        assert!(matches!(verdict, Verdict::Reject { .. }));
373    }
374
375    /// A policy declaring only `Delete` never fires on a `Create`, and vice
376    /// versa — the core routes on the explicit action, taking no position
377    /// on what a delete's diff contains.
378    #[test]
379    fn action_routing_is_exact() {
380        let delete_denier = Arc::new(Fixed::new(Outcome::Reject {
381            reason: "no deletes".into(),
382        }));
383        let mut builder = PolicyTree::builder();
384        let evaluator_id = builder.register_evaluator(delete_denier);
385        builder.add_policy(PolicyDeclaration {
386            applicability: Applicability::Write {
387                kinds: KindSet::ALL,
388                collections: Scope::Any,
389                actions: ActionSet::of(&[WriteAction::Delete]),
390                path_prefixes: Scope::Any,
391            },
392            observes: Applicability::Write {
393                kinds: KindSet::ALL,
394                collections: Scope::Any,
395                actions: ActionSet::of(&[WriteAction::Delete]),
396                path_prefixes: Scope::Any,
397            },
398            staleness: Staleness::Linearized,
399            predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
400            revision: Revision(1),
401        });
402        let tree = builder.build();
403
404        let create = write("bot.did.record", WriteAction::Create, &[]);
405        assert_eq!(evaluate(&create, &tree), Verdict::Allow);
406
407        let delete = write("bot.did.record", WriteAction::Delete, &[]);
408        assert!(matches!(evaluate(&delete, &tree), Verdict::Reject { .. }));
409    }
410
411    /// A policy declared for agents only routes past a host's write
412    /// entirely: the evaluator is never invoked, the same "no evaluator
413    /// call" property `empty_match_never_calls_an_evaluator` demonstrates
414    /// for an unregistered collection — kind is a filter the tree applies,
415    /// not one every evaluator has to re-check and return `Allow` from.
416    #[test]
417    fn kind_routing_never_calls_an_evaluator_for_an_uncovered_kind() {
418        let agent_only = Arc::new(Fixed::new(Outcome::Reject {
419            reason: "agents only".into(),
420        }));
421        let mut builder = PolicyTree::builder();
422        let evaluator_id = builder.register_evaluator(agent_only.clone());
423        builder.add_policy(PolicyDeclaration {
424            applicability: Applicability::Write {
425                kinds: KindSet::of(&[AccountKind::Agent]),
426                collections: Scope::Any,
427                actions: ActionSet::ALL,
428                path_prefixes: Scope::Any,
429            },
430            observes: Applicability::Write {
431                kinds: KindSet::of(&[AccountKind::Agent]),
432                collections: Scope::Any,
433                actions: ActionSet::ALL,
434                path_prefixes: Scope::Any,
435            },
436            staleness: Staleness::Linearized,
437            predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
438            revision: Revision(1),
439        });
440        let tree = builder.build();
441
442        let agent_write = write("bot.did.record", WriteAction::Create, &[]);
443        assert!(matches!(
444            evaluate(&agent_write, &tree),
445            Verdict::Reject { .. }
446        ));
447        assert_eq!(agent_only.calls.load(Ordering::SeqCst), 1);
448
449        let host_write = Subject::Write {
450            account: "did:plc:host",
451            attributes: SubjectAttributes {
452                kind: SubjectKind::Known(AccountKind::Host),
453                handle: None,
454                depth: None,
455            },
456            universal: test_universal(),
457            client_id: None,
458            collection: "bot.did.record",
459            action: WriteAction::Create,
460            diff: Diff { changes: &[] },
461            before: None,
462            after: None,
463            lookups: &[],
464        };
465        assert!(tree.decision_candidates(&host_write).is_empty());
466        assert_eq!(evaluate(&host_write, &tree), Verdict::Allow);
467        // Still just the one call from the agent write above: the host's
468        // write never reached this evaluator at all.
469        assert_eq!(agent_only.calls.load(Ordering::SeqCst), 1);
470    }
471
472    /// Build a write [`Subject`] whose account kind is unrecognized —
473    /// `bot.did.registration`'s `actor` union is open, so a subject can
474    /// carry a kind this build has never heard of.
475    fn write_with_unknown_kind<'a>(collection: &'a str, kind_name: &'a str) -> Subject<'a> {
476        Subject::Write {
477            account: "did:plc:from-the-future",
478            attributes: SubjectAttributes {
479                kind: SubjectKind::Unknown(kind_name),
480                handle: None,
481                depth: None,
482            },
483            universal: test_universal(),
484            client_id: None,
485            collection,
486            action: WriteAction::Create,
487            diff: Diff { changes: &[] },
488            before: None,
489            after: None,
490            lookups: &[],
491        }
492    }
493
494    /// A policy scoped to named kinds — even naming all four known ones —
495    /// never matches a subject whose kind this build does not recognize.
496    /// Naming `Agent` made a claim about agents; extending it to a kind
497    /// nobody had seen when the policy was written would be a premise the
498    /// author never established, so the tree routes an unknown kind past
499    /// it exactly as it would an uncovered known one.
500    #[test]
501    fn an_unknown_kind_routes_past_a_policy_scoped_to_known_kinds() {
502        let known_only = Arc::new(Fixed::new(Outcome::Reject {
503            reason: "known kinds only".into(),
504        }));
505        let mut builder = PolicyTree::builder();
506        let evaluator_id = builder.register_evaluator(known_only.clone());
507        builder.add_policy(PolicyDeclaration {
508            applicability: Applicability::Write {
509                // All four *known* kinds — and still, per KindSet's
510                // contract, not a kind this build has never seen.
511                kinds: KindSet::ALL,
512                collections: Scope::Any,
513                actions: ActionSet::ALL,
514                path_prefixes: Scope::Any,
515            },
516            observes: Applicability::Write {
517                kinds: KindSet::ALL,
518                collections: Scope::Any,
519                actions: ActionSet::ALL,
520                path_prefixes: Scope::Any,
521            },
522            staleness: Staleness::Linearized,
523            predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
524            revision: Revision(1),
525        });
526        let tree = builder.build();
527
528        let from_the_future = write_with_unknown_kind("bot.did.record", "oracle");
529        assert!(tree.decision_candidates(&from_the_future).is_empty());
530        assert_eq!(evaluate(&from_the_future, &tree), Verdict::Allow);
531        assert_eq!(known_only.calls.load(Ordering::SeqCst), 0);
532    }
533
534    /// `KindSet::ANY` is the explicit opt-in: a policy declared with it
535    /// reaches a subject of an unrecognized kind, because its author chose
536    /// to cover kinds invented after the policy was written.
537    #[test]
538    fn kind_set_any_reaches_an_unknown_kind() {
539        let any_kind = Arc::new(Fixed::new(Outcome::Reject {
540            reason: "no kind is exempt".into(),
541        }));
542        let mut builder = PolicyTree::builder();
543        let evaluator_id = builder.register_evaluator(any_kind.clone());
544        builder.add_policy(PolicyDeclaration {
545            applicability: Applicability::Write {
546                kinds: KindSet::ANY,
547                collections: Scope::Any,
548                actions: ActionSet::ALL,
549                path_prefixes: Scope::Any,
550            },
551            observes: Applicability::Write {
552                kinds: KindSet::ANY,
553                collections: Scope::Any,
554                actions: ActionSet::ALL,
555                path_prefixes: Scope::Any,
556            },
557            staleness: Staleness::Linearized,
558            predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
559            revision: Revision(1),
560        });
561        let tree = builder.build();
562
563        let from_the_future = write_with_unknown_kind("bot.did.record", "oracle");
564        assert_eq!(tree.decision_candidates(&from_the_future).len(), 1);
565        assert!(matches!(
566            evaluate(&from_the_future, &tree),
567            Verdict::Reject { .. }
568        ));
569        assert_eq!(any_kind.calls.load(Ordering::SeqCst), 1);
570    }
571
572    /// A path-scoped policy matches a write that touches its prefix and not
573    /// one that does not — "an agent may edit its bio but not its display
574    /// name" from `plan/policy.md`.
575    #[test]
576    fn path_prefix_scoping() {
577        let denier = Arc::new(Fixed::new(Outcome::Reject {
578            reason: "displayName is locked".into(),
579        }));
580        let mut builder = PolicyTree::builder();
581        let evaluator_id = builder.register_evaluator(denier);
582        builder.add_policy(PolicyDeclaration {
583            applicability: Applicability::Write {
584                kinds: KindSet::ALL,
585                collections: Scope::Only(vec!["app.bsky.actor.profile".into()]),
586                actions: ActionSet::ALL,
587                path_prefixes: Scope::Only(vec!["displayName".into()]),
588            },
589            observes: Applicability::Write {
590                kinds: KindSet::ALL,
591                collections: Scope::Any,
592                actions: ActionSet::ALL,
593                path_prefixes: Scope::Any,
594            },
595            staleness: Staleness::Linearized,
596            predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
597            revision: Revision(1),
598        });
599        let tree = builder.build();
600
601        let bio_edit = write("app.bsky.actor.profile", WriteAction::Update, &["bio"]);
602        assert_eq!(evaluate(&bio_edit, &tree), Verdict::Allow);
603
604        let name_edit = write(
605            "app.bsky.actor.profile",
606            WriteAction::Update,
607            &["displayName"],
608        );
609        assert!(matches!(
610            evaluate(&name_edit, &tree),
611            Verdict::Reject { .. }
612        ));
613    }
614
615    /// A path-scoped policy guarding a field guards that field's array
616    /// elements too. `didbot_pds::policy::diff` reports an array element
617    /// as `tags[0]`, so a prefix of `tags` that did not treat the
618    /// subscript as a segment boundary would route past it — the policy
619    /// would silently enforce nothing the moment the field it named held
620    /// an array, while the identical policy over a scalar field worked.
621    #[test]
622    fn a_path_prefix_reaches_its_field_s_array_elements() {
623        let denier = Arc::new(Fixed::new(Outcome::Reject {
624            reason: "tags are locked".into(),
625        }));
626        let mut builder = PolicyTree::builder();
627        let evaluator_id = builder.register_evaluator(denier.clone());
628        builder.add_policy(PolicyDeclaration {
629            applicability: Applicability::Write {
630                kinds: KindSet::ALL,
631                collections: Scope::Any,
632                actions: ActionSet::ALL,
633                path_prefixes: Scope::Only(vec!["tags".into()]),
634            },
635            observes: Applicability::Write {
636                kinds: KindSet::ALL,
637                collections: Scope::Any,
638                actions: ActionSet::ALL,
639                path_prefixes: Scope::Any,
640            },
641            staleness: Staleness::Linearized,
642            predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
643            revision: Revision(1),
644        });
645        let tree = builder.build();
646
647        // The scalar shape, which has always worked.
648        let scalar = write("app.bsky.actor.profile", WriteAction::Update, &["tags"]);
649        assert!(matches!(evaluate(&scalar, &tree), Verdict::Reject { .. }));
650
651        // The array shape the write path actually produces for the same
652        // field.
653        let element = write("app.bsky.actor.profile", WriteAction::Update, &["tags[0]"]);
654        assert!(
655            matches!(evaluate(&element, &tree), Verdict::Reject { .. }),
656            "a policy guarding `tags` must reach `tags[0]`"
657        );
658
659        // A nested one, likewise.
660        let nested = write(
661            "app.bsky.actor.profile",
662            WriteAction::Update,
663            &["tags[2].label"],
664        );
665        assert!(matches!(evaluate(&nested, &tree), Verdict::Reject { .. }));
666
667        // And a field whose name merely starts with the same letters is
668        // still not covered: this widens the prefix's reach into its own
669        // subscripts, not into its siblings.
670        let sibling = write(
671            "app.bsky.actor.profile",
672            WriteAction::Update,
673            &["tagsExtra"],
674        );
675        assert_eq!(evaluate(&sibling, &tree), Verdict::Allow);
676    }
677
678    /// A prefix may still name one specific element: the subscript
679    /// segments the same way on the declaring side as on the matching
680    /// one, so `tags[0]` covers `tags[0]` and not `tags[1]`.
681    #[test]
682    fn a_path_prefix_may_name_one_element() {
683        let denier = Arc::new(Fixed::new(Outcome::Reject {
684            reason: "the first tag is locked".into(),
685        }));
686        let mut builder = PolicyTree::builder();
687        let evaluator_id = builder.register_evaluator(denier);
688        builder.add_policy(PolicyDeclaration {
689            applicability: Applicability::Write {
690                kinds: KindSet::ALL,
691                collections: Scope::Any,
692                actions: ActionSet::ALL,
693                path_prefixes: Scope::Only(vec!["tags[0]".into()]),
694            },
695            observes: Applicability::Write {
696                kinds: KindSet::ALL,
697                collections: Scope::Any,
698                actions: ActionSet::ALL,
699                path_prefixes: Scope::Any,
700            },
701            staleness: Staleness::Linearized,
702            predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
703            revision: Revision(1),
704        });
705        let tree = builder.build();
706
707        let first = write("app.bsky.actor.profile", WriteAction::Update, &["tags[0]"]);
708        assert!(matches!(evaluate(&first, &tree), Verdict::Reject { .. }));
709        let second = write("app.bsky.actor.profile", WriteAction::Update, &["tags[1]"]);
710        assert_eq!(evaluate(&second, &tree), Verdict::Allow);
711    }
712
713    /// The observation surface is independent of the decision surface: a
714    /// policy can be told about an attempt it has no say in deciding.
715    #[test]
716    fn observation_surface_is_independent_of_decision_surface() {
717        let watcher = Arc::new(Fixed::new(Outcome::Allow));
718        let mut builder = PolicyTree::builder();
719        let evaluator_id = builder.register_evaluator(watcher);
720        builder.add_policy(PolicyDeclaration {
721            applicability: Applicability::Write {
722                kinds: KindSet::ALL,
723                collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
724                actions: ActionSet::ALL,
725                path_prefixes: Scope::Any,
726            },
727            observes: Applicability::Write {
728                kinds: KindSet::ALL,
729                collections: Scope::Any,
730                actions: ActionSet::ALL,
731                path_prefixes: Scope::Any,
732            },
733            staleness: Staleness::Linearized,
734            predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
735            revision: Revision(1),
736        });
737        let tree = builder.build();
738
739        let subject = write("bot.did.record", WriteAction::Create, &[]);
740        assert!(tree.decision_candidates(&subject).is_empty());
741        assert_eq!(tree.observation_candidates(&subject).len(), 1);
742    }
743
744    /// Registration order is presentational order: with two rejecting
745    /// policies over the same write, the reason reported is the
746    /// first-registered one — and building the tree with the declarations
747    /// in the opposite order changes only which reason is reported, never
748    /// that the write is denied.
749    #[test]
750    fn registration_order_only_changes_which_reason_is_reported() {
751        fn tree_with(first: &str, second: &str) -> PolicyTree {
752            let mut builder = PolicyTree::builder();
753            let e1 = builder.register_evaluator(Arc::new(Fixed::new(Outcome::Reject {
754                reason: first.into(),
755            })));
756            let e2 = builder.register_evaluator(Arc::new(Fixed::new(Outcome::Reject {
757                reason: second.into(),
758            })));
759            for evaluator in [e1, e2] {
760                builder.add_policy(PolicyDeclaration {
761                    applicability: Applicability::Write {
762                        kinds: KindSet::ALL,
763                        collections: Scope::Any,
764                        actions: ActionSet::ALL,
765                        path_prefixes: Scope::Any,
766                    },
767                    observes: Applicability::Write {
768                        kinds: KindSet::ALL,
769                        collections: Scope::Any,
770                        actions: ActionSet::ALL,
771                        path_prefixes: Scope::Any,
772                    },
773                    staleness: Staleness::Linearized,
774                    predicate: Predicate::Evaluator(evaluator, CompiledId(0)),
775                    revision: Revision(1),
776                });
777            }
778            builder.build()
779        }
780
781        let subject = write("bot.did.record", WriteAction::Create, &[]);
782
783        let a = tree_with("a fires first", "b fires first");
784        assert!(matches!(
785            evaluate(&subject, &a),
786            Verdict::Reject { ref reason, .. } if reason == "a fires first"
787        ));
788
789        // Same two policies, evaluators registered in the opposite order:
790        // still denied, only the reported reason changes.
791        let b = tree_with("b fires first", "a fires first");
792        assert!(matches!(
793            evaluate(&subject, &b),
794            Verdict::Reject { ref reason, .. } if reason == "b fires first"
795        ));
796    }
797
798    /// A policy whose applicability parses but whose predicate is
799    /// [`Predicate::Unusable`] denies every subject inside that
800    /// applicability — fail-closed, scoped exactly like an unavailable
801    /// evaluator — and leaves everything outside it untouched: a policy
802    /// scoped to one collection being broken must not deny writes to a
803    /// different collection nobody declared it covers.
804    #[test]
805    fn an_unusable_predicate_denies_only_within_its_own_applicability() {
806        let mut builder = PolicyTree::builder();
807        builder.add_policy(PolicyDeclaration {
808            applicability: Applicability::Write {
809                kinds: KindSet::ALL,
810                collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
811                actions: ActionSet::ALL,
812                path_prefixes: Scope::Any,
813            },
814            observes: Applicability::Write {
815                kinds: KindSet::ALL,
816                collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
817                actions: ActionSet::ALL,
818                path_prefixes: Scope::Any,
819            },
820            staleness: Staleness::Linearized,
821            predicate: Predicate::Unusable {
822                reason: "names an engine this build does not have".into(),
823            },
824            revision: Revision(1),
825        });
826        let tree = builder.build();
827
828        // Inside this policy's applicability: denied.
829        let covered = write("app.bsky.feed.post", WriteAction::Create, &[]);
830        assert!(matches!(
831            evaluate(&covered, &tree),
832            Verdict::Reject { ref reason, .. }
833                if reason.contains("names an engine this build does not have")
834        ));
835
836        // Outside it — a different collection entirely — untouched: the
837        // brokenness of one policy does not widen into a blanket denial.
838        let uncovered = write("bot.did.record", WriteAction::Create, &[]);
839        assert_eq!(evaluate(&uncovered, &tree), Verdict::Allow);
840    }
841
842    /// An `Unusable` predicate is a refusal, and a refusal must never
843    /// outrank — or be outranked into silence by — a sibling's freeze. A
844    /// freeze needs an operator and a rejection does not, so whichever
845    /// order the two are registered in, the verdict is the freeze.
846    #[test]
847    fn an_unusable_predicate_never_downgrades_a_siblings_freeze() {
848        fn tree_with_unusable_and_freeze(unusable_first: bool) -> PolicyTree {
849            let applicability = || Applicability::Write {
850                kinds: KindSet::ALL,
851                collections: Scope::Any,
852                actions: ActionSet::ALL,
853                path_prefixes: Scope::Any,
854            };
855            let mut builder = PolicyTree::builder();
856            let freezer = builder.register_evaluator(Arc::new(Fixed::new(Outcome::Freeze {
857                reason: "repeatedly tripped policy".into(),
858            })));
859            let mut add = |predicate: Predicate| {
860                builder.add_policy(PolicyDeclaration {
861                    applicability: applicability(),
862                    observes: applicability(),
863                    staleness: Staleness::Linearized,
864                    predicate,
865                    revision: Revision(1),
866                });
867            };
868            let unusable = Predicate::Unusable {
869                reason: "predicate did not compile".into(),
870            };
871            if unusable_first {
872                add(unusable);
873                add(Predicate::Evaluator(freezer, CompiledId(0)));
874            } else {
875                add(Predicate::Evaluator(freezer, CompiledId(0)));
876                add(unusable);
877            }
878            builder.build()
879        }
880
881        let subject = write("bot.did.record", WriteAction::Create, &[]);
882        for unusable_first in [true, false] {
883            let tree = tree_with_unusable_and_freeze(unusable_first);
884            assert!(
885                matches!(
886                    evaluate(&subject, &tree),
887                    Verdict::Freeze { ref reason, .. }
888                        if reason == "repeatedly tripped policy"
889                ),
890                "a freeze must outrank a broken sibling's refusal \
891                 (unusable_first = {unusable_first})"
892            );
893        }
894    }
895
896    /// [`PolicyTree::evaluator`] has nothing to return for a policy whose
897    /// predicate is [`Predicate::Unusable`] — there is no evaluator to call
898    /// `observe` or `lifecycle` on.
899    #[test]
900    fn unusable_predicate_has_no_evaluator() {
901        let mut builder = PolicyTree::builder();
902        let policy = builder.add_policy(PolicyDeclaration {
903            applicability: Applicability::Write {
904                kinds: KindSet::ALL,
905                collections: Scope::Any,
906                actions: ActionSet::ALL,
907                path_prefixes: Scope::Any,
908            },
909            observes: Applicability::Write {
910                kinds: KindSet::ALL,
911                collections: Scope::Any,
912                actions: ActionSet::ALL,
913                path_prefixes: Scope::Any,
914            },
915            staleness: Staleness::Linearized,
916            predicate: Predicate::Unusable {
917                reason: "bad syntax".into(),
918            },
919            revision: Revision(1),
920        });
921        let tree = builder.build();
922        assert!(tree.evaluator(policy).is_none());
923    }
924
925    /// A real value-reading evaluator: *"displayName may not change to a
926    /// different value once it has one"*. Written with no `WriteAction`
927    /// arm at all — before/after resolves create and delete on their own,
928    /// which is the whole point of carrying values in [`Diff`] instead of
929    /// bare paths.
930    struct NoRenaming;
931
932    impl Evaluator for NoRenaming {
933        fn compile(&self, _predicate: &serde_json::Value) -> CompileOutcome {
934            CompileOutcome::Compiled(CompiledId(0))
935        }
936        fn evaluate(
937            &self,
938            subject: &Subject<'_>,
939            _compiled: CompiledId,
940        ) -> Result<Outcome, EvalError> {
941            let Subject::Write { diff, .. } = subject else {
942                return Ok(Outcome::Allow);
943            };
944            for change in diff.changes {
945                if change.path == "displayName"
946                    && change.before.is_some()
947                    && change.after != change.before
948                {
949                    return Ok(Outcome::Reject {
950                        reason: "displayName may not change".into(),
951                    });
952                }
953            }
954            Ok(Outcome::Allow)
955        }
956        fn observe(&self, _event: &Observed<'_>) {}
957        fn lifecycle(&self, _event: &Lifecycle<'_>) {}
958        fn staleness(&self) -> Staleness {
959            Staleness::Linearized
960        }
961    }
962
963    fn tree_with_evaluator(evaluator: Arc<dyn Evaluator>) -> PolicyTree {
964        let mut builder = PolicyTree::builder();
965        let evaluator_id = builder.register_evaluator(evaluator);
966        builder.add_policy(PolicyDeclaration {
967            applicability: Applicability::Write {
968                kinds: KindSet::ALL,
969                collections: Scope::Any,
970                actions: ActionSet::ALL,
971                path_prefixes: Scope::Only(vec!["displayName".into()]),
972            },
973            observes: Applicability::Write {
974                kinds: KindSet::ALL,
975                collections: Scope::Any,
976                actions: ActionSet::ALL,
977                path_prefixes: Scope::Any,
978            },
979            staleness: Staleness::Linearized,
980            predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
981            revision: Revision(1),
982        });
983        builder.build()
984    }
985
986    #[test]
987    fn before_after_resolves_create_and_delete_without_an_action_arm() {
988        let tree = tree_with_evaluator(Arc::new(NoRenaming));
989        let old = serde_json::Value::String("alice".into());
990        let new = serde_json::Value::String("bob".into());
991
992        // Create: no prior value, so nothing to protect.
993        let created = Change {
994            path: "displayName",
995            before: None,
996            after: Some(&new),
997        };
998        let subject = Subject::Write {
999            account: "did:plc:agent",
1000            attributes: SubjectAttributes {
1001                kind: SubjectKind::Known(AccountKind::Agent),
1002                handle: None,
1003                depth: None,
1004            },
1005            universal: test_universal(),
1006            client_id: None,
1007            collection: "app.bsky.actor.profile",
1008            action: WriteAction::Create,
1009            diff: Diff {
1010                changes: &[created],
1011            },
1012            before: None,
1013            after: None,
1014            lookups: &[],
1015        };
1016        assert_eq!(evaluate(&subject, &tree), Verdict::Allow);
1017
1018        // Update to a different value: denied.
1019        let renamed = Change {
1020            path: "displayName",
1021            before: Some(&old),
1022            after: Some(&new),
1023        };
1024        let subject = Subject::Write {
1025            account: "did:plc:agent",
1026            attributes: SubjectAttributes {
1027                kind: SubjectKind::Known(AccountKind::Agent),
1028                handle: None,
1029                depth: None,
1030            },
1031            universal: test_universal(),
1032            client_id: None,
1033            collection: "app.bsky.actor.profile",
1034            action: WriteAction::Update,
1035            diff: Diff {
1036                changes: &[renamed],
1037            },
1038            before: None,
1039            after: None,
1040            lookups: &[],
1041        };
1042        assert!(matches!(evaluate(&subject, &tree), Verdict::Reject { .. }));
1043
1044        // Delete: the value existed and is now gone — also denied, with no
1045        // `WriteAction::Delete` special case anywhere in `NoRenaming`.
1046        let deleted = Change {
1047            path: "displayName",
1048            before: Some(&old),
1049            after: None,
1050        };
1051        let subject = Subject::Write {
1052            account: "did:plc:agent",
1053            attributes: SubjectAttributes {
1054                kind: SubjectKind::Known(AccountKind::Agent),
1055                handle: None,
1056                depth: None,
1057            },
1058            universal: test_universal(),
1059            client_id: None,
1060            collection: "app.bsky.actor.profile",
1061            action: WriteAction::Delete,
1062            diff: Diff {
1063                changes: &[deleted],
1064            },
1065            before: None,
1066            after: None,
1067            lookups: &[],
1068        };
1069        assert!(matches!(evaluate(&subject, &tree), Verdict::Reject { .. }));
1070    }
1071
1072    /// An "allowlist" policy — *"displayName may be set only to the
1073    /// account's own handle"* — is a deny rule like any other: it denies
1074    /// whenever the new value is not the handle. No allow variant is
1075    /// needed or used; this is [`SubjectAttributes`]'s worked example.
1076    struct HandleOnly;
1077
1078    impl Evaluator for HandleOnly {
1079        fn compile(&self, _predicate: &serde_json::Value) -> CompileOutcome {
1080            CompileOutcome::Compiled(CompiledId(0))
1081        }
1082        fn evaluate(
1083            &self,
1084            subject: &Subject<'_>,
1085            _compiled: CompiledId,
1086        ) -> Result<Outcome, EvalError> {
1087            let Subject::Write {
1088                diff, attributes, ..
1089            } = subject
1090            else {
1091                return Ok(Outcome::Allow);
1092            };
1093            for change in diff.changes {
1094                if change.path != "displayName" {
1095                    continue;
1096                }
1097                let Some(after) = change.after else {
1098                    continue;
1099                };
1100                let allowed = attributes
1101                    .handle
1102                    .is_some_and(|handle| after.as_str() == Some(handle));
1103                if !allowed {
1104                    return Ok(Outcome::Reject {
1105                        reason: "displayName must be the account's handle".into(),
1106                    });
1107                }
1108            }
1109            Ok(Outcome::Allow)
1110        }
1111        fn observe(&self, _event: &Observed<'_>) {}
1112        fn lifecycle(&self, _event: &Lifecycle<'_>) {}
1113        fn staleness(&self) -> Staleness {
1114            Staleness::Linearized
1115        }
1116    }
1117
1118    #[test]
1119    fn an_allowlist_is_expressed_as_a_deny_rule() {
1120        let tree = tree_with_evaluator(Arc::new(HandleOnly));
1121        let handle_value = serde_json::Value::String("alice.did.bot".into());
1122        let other_value = serde_json::Value::String("someone-else".into());
1123
1124        let matches_handle = Change {
1125            path: "displayName",
1126            before: None,
1127            after: Some(&handle_value),
1128        };
1129        let subject = Subject::Write {
1130            account: "did:plc:agent",
1131            attributes: SubjectAttributes {
1132                kind: SubjectKind::Known(AccountKind::Agent),
1133                handle: Some("alice.did.bot"),
1134                depth: None,
1135            },
1136            universal: test_universal(),
1137            client_id: None,
1138            collection: "app.bsky.actor.profile",
1139            action: WriteAction::Update,
1140            diff: Diff {
1141                changes: &[matches_handle],
1142            },
1143            before: None,
1144            after: None,
1145            lookups: &[],
1146        };
1147        assert_eq!(evaluate(&subject, &tree), Verdict::Allow);
1148
1149        let does_not_match = Change {
1150            path: "displayName",
1151            before: None,
1152            after: Some(&other_value),
1153        };
1154        let subject = Subject::Write {
1155            account: "did:plc:agent",
1156            attributes: SubjectAttributes {
1157                kind: SubjectKind::Known(AccountKind::Agent),
1158                handle: Some("alice.did.bot"),
1159                depth: None,
1160            },
1161            universal: test_universal(),
1162            client_id: None,
1163            collection: "app.bsky.actor.profile",
1164            action: WriteAction::Update,
1165            diff: Diff {
1166                changes: &[does_not_match],
1167            },
1168            before: None,
1169            after: None,
1170            lookups: &[],
1171        };
1172        assert!(matches!(evaluate(&subject, &tree), Verdict::Reject { .. }));
1173    }
1174
1175    /// A policy on token issue is not asked at sign-in, and one on sign-in
1176    /// is not asked at token issue. An operator who denies a client only
1177    /// `oauth.authorize` lets its existing logins keep their tokens.
1178    #[test]
1179    fn a_grant_policy_and_a_token_policy_are_asked_apart() {
1180        let client = ClientId::parse("https://client.example/metadata.json").unwrap();
1181        let mut builder = PolicyTree::builder();
1182        let deny = builder.register_evaluator(Arc::new(Fixed::new(Outcome::Reject {
1183            reason: "no".into(),
1184        })));
1185        let at_token = Applicability::Token {
1186            client_ids: Scope::Only(vec![client.as_str().to_owned()]),
1187        };
1188        builder.add_policy(PolicyDeclaration {
1189            applicability: at_token.clone(),
1190            observes: at_token,
1191            staleness: Staleness::Linearized,
1192            predicate: Predicate::Evaluator(deny, CompiledId(0)),
1193            revision: Revision(1),
1194        });
1195        let tree = builder.build();
1196
1197        let grant = Subject::Grant {
1198            universal: test_universal(),
1199            client_id: &client,
1200            requested_scopes: &["atproto"],
1201        };
1202        let token = Subject::Token {
1203            account: "did:plc:agent",
1204            attributes: SubjectAttributes {
1205                kind: SubjectKind::Known(AccountKind::Agent),
1206                handle: None,
1207                depth: None,
1208            },
1209            universal: test_universal(),
1210            client_id: &client,
1211            scopes: &["atproto"],
1212        };
1213        assert_eq!(evaluate(&grant, &tree), Verdict::Allow);
1214        assert!(matches!(evaluate(&token, &tree), Verdict::Reject { .. }));
1215    }
1216}