Skip to main content

didbot_policy_records/
merge.rs

1//! Assembling every source into one [`didbot_policy::PolicyTree`].
2//!
3//! # No precedence engine, and none is needed
4//!
5//! `plan/policy.md`: "All three deny; none can permit what another denies,
6//! so their order is a matter of where a policy is written and not of what
7//! it can do." [`build`] registers builtin, then startup, then
8//! operator-sourced policies, in that fixed order -- and each source's own
9//! records ordered by id within it -- but nothing about the
10//! *verdict* a write receives depends on that order, only on which reason
11//! [`didbot_policy::combine`] reports when more than one policy denies the
12//! same write. Deny-only monotonicity is what makes this true: adding a
13//! policy from any source can only hold a verdict steady or make it more
14//! severe, never less, so there is no shadowing between sources and nothing
15//! for a precedence engine to arbitrate. The fixed order here exists only
16//! so a report is reproducible, not because it changes what is enforced.
17
18use std::collections::HashMap;
19
20use didbot_policy::{PolicyId, PolicyTree, Revision};
21
22use crate::builtin::BuiltinPolicy;
23use crate::compile::{
24    compile_policies, compiled_successfully, register, resolve_predicate, CompileReport, Language,
25    PolicyWarning, ScopedDenial,
26};
27use crate::last_good::LastGoodPolicies;
28use crate::record::ParsedPolicy;
29
30/// One id whose current revision would not compile, still enforced at the
31/// last revision that did -- `plan/policy.md`'s "was working, new revision
32/// will not compile" state, told apart from a first-publish typo (which
33/// stays a plain [`ScopedDenial`], with nothing to fall back to) precisely
34/// by this type existing for it. See [`crate::last_good`]'s doc comment.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct LastGoodFallback {
37    /// The record's id.
38    pub id: String,
39    /// The revision still being enforced. See
40    /// [`ParsedPolicy::revision`](crate::record::ParsedPolicy::revision) for
41    /// who chooses the number.
42    pub enforced_revision: Revision,
43    /// The revision that arrived and failed to compile. See
44    /// [`ParsedPolicy::revision`](crate::record::ParsedPolicy::revision).
45    pub failed_revision: Revision,
46    /// Why the failed revision could not be compiled.
47    pub reason: String,
48}
49
50/// Everything worth telling an operator about a tree build: which
51/// operator-or-startup-sourced records could not be compiled (scoped-deny,
52/// per `crate::compile`), which compiled with a warning, which fell back to
53/// a last-good revision, and which records were not policies at all,
54/// reported by whichever source loaded them.
55#[derive(Debug, Clone, Default)]
56pub struct LoadReport {
57    /// Records whose predicate this build could not compile, with no
58    /// last-good revision to fall back to -- still registered, scoped, via
59    /// [`didbot_policy::Predicate::Unusable`]. See [`crate::compile`].
60    pub scoped_denials: Vec<ScopedDenial>,
61    /// Records that compiled, but with something the operator should see.
62    /// Never blocks the policy -- see
63    /// [`didbot_policy::CompileOutcome::CompiledWithWarnings`].
64    pub warnings: Vec<PolicyWarning>,
65    /// Operator-sourced records whose current revision would not compile,
66    /// still enforced at their last good revision. See [`LastGoodFallback`].
67    pub last_good_fallbacks: Vec<LastGoodFallback>,
68    /// Records that were not policies at all -- malformed `applicability`
69    /// or another structural problem. Already source-labeled by whoever
70    /// called this.
71    pub malformed: Vec<String>,
72    /// The tree position every policy was registered at, by its id: a
73    /// built-in under its [`BuiltinPolicy::name`], a startup or operator
74    /// record under the id it was compiled as -- see
75    /// [`crate::compile::CompileReport::registered`].
76    pub registered: HashMap<String, PolicyId>,
77}
78
79/// Build the full [`PolicyTree`] from every source.
80///
81/// `builtins` is source 1 ([`crate::builtin::builtin_policies`]). `startup`
82/// is source 2's already-parsed records
83/// supplied at startup, compiled with no last-good-revision fallback: it is
84/// immutable for the run, so there is never a second revision to have fallen
85/// back from. `operator` is source 3's current view -- a snapshot taken once
86/// at the start of this call --
87/// compiled *with* last-good-revision fallback via `last_good`, which must
88/// be the same [`LastGoodPolicies`] passed on every call across this
89/// deployment's lifetime, or there is nothing for a bad revision to fall
90/// back to. `languages` are every [`Language`] this build carries, used to
91/// compile `startup` and `operator` predicates (`builtins` need none: they
92/// carry already-constructed evaluators).
93pub fn build(
94    builtins: Vec<BuiltinPolicy>,
95    startup: &HashMap<String, ParsedPolicy>,
96    operator: &HashMap<String, ParsedPolicy>,
97    languages: &[&dyn Language],
98    last_good: &LastGoodPolicies,
99) -> (PolicyTree, LoadReport) {
100    let mut builder = PolicyTree::builder();
101    let mut report = LoadReport::default();
102
103    // Source 1, compiled with the same call the other two immutable
104    // source uses: a built-in is the record an operator would write, so
105    // there is nothing here that reads it differently.
106    let builtins: HashMap<String, ParsedPolicy> = builtins
107        .into_iter()
108        .map(|builtin| (builtin.name, builtin.policy))
109        .collect();
110    let builtin_report = compile_policies(by_id(&builtins), languages, &mut builder);
111    report.scoped_denials.extend(builtin_report.denials);
112    report.warnings.extend(builtin_report.warnings);
113    report.registered.extend(builtin_report.registered);
114
115    let startup_report = compile_policies(by_id(startup), languages, &mut builder);
116    report.scoped_denials.extend(startup_report.denials);
117    report.warnings.extend(startup_report.warnings);
118    report.registered.extend(startup_report.registered);
119
120    for (id, policy) in by_id(operator) {
121        let mut attempt = CompileReport::default();
122        let predicate = resolve_predicate(id, policy, languages, &mut builder, &mut attempt);
123
124        if compiled_successfully(&attempt, id) {
125            // This id's current revision compiled: it is the new last-good,
126            // and it is what gets registered.
127            report.warnings.extend(attempt.warnings);
128            last_good.record_success(id, policy);
129            let registered = register(&mut builder, policy, predicate);
130            report.registered.insert(id.to_string(), registered);
131            continue;
132        }
133
134        // This id's current revision did not compile. Fall back to the
135        // last revision that did, if there is one -- recompiled fresh here
136        // (cheap, and `Evaluator::compile` is required to be idempotent),
137        // never the artifact this attempt half-built via
138        // `register_evaluator` above, which is simply never referenced by
139        // any registered policy and costs nothing beyond an unused `Vec`
140        // slot. Never re-record `last_good` for the fallback itself: it is
141        // already this id's stored entry, not a new observation.
142        match last_good.get(id) {
143            Some((good_revision, good_policy)) => {
144                let mut fallback_report = CompileReport::default();
145                let fallback_predicate = resolve_predicate(
146                    id,
147                    &good_policy,
148                    languages,
149                    &mut builder,
150                    &mut fallback_report,
151                );
152                let registered = register(&mut builder, &good_policy, fallback_predicate);
153                report.registered.insert(id.to_string(), registered);
154                report.last_good_fallbacks.push(LastGoodFallback {
155                    id: id.to_string(),
156                    enforced_revision: good_revision,
157                    failed_revision: policy.revision,
158                    reason: attempt
159                        .denials
160                        .iter()
161                        .find(|denial| denial.id == id)
162                        .map(|denial| denial.reason.clone())
163                        .unwrap_or_default(),
164                });
165            }
166            None => {
167                // Never compiled. Nothing to fall back to -- loud, per
168                // `plan/policy.md`: register the failed predicate as-is
169                // (Predicate::Unusable, scoped to its own applicability)
170                // and let it stand as an ordinary scoped denial.
171                report.scoped_denials.extend(attempt.denials);
172                let registered = register(&mut builder, policy, predicate);
173                report.registered.insert(id.to_string(), registered);
174            }
175        }
176    }
177    last_good.prune(operator);
178
179    (builder.build(), report)
180}
181
182/// One source's records in a fixed order: by id, ascending.
183///
184/// `plan/policy.md` puts the order of the list the tree produces in this
185/// server's hands precisely so "the reason reported for a denial is
186/// stable." A source is stored as a `HashMap` keyed by at-uri (or by a
187/// startup-file position), and iterating one is seeded randomly per map --
188/// so handing `compile_policies` a raw `HashMap::iter` would assign a
189/// different `PolicyId` to each record on every build, and two identical
190/// tree builds would name different policies as the reason for the same
191/// refusal. Nothing about the *verdict* changes either way; the stability
192/// of what an operator is told does.
193fn by_id(records: &std::collections::HashMap<String, ParsedPolicy>) -> Vec<(&str, &ParsedPolicy)> {
194    let mut ordered: Vec<(&str, &ParsedPolicy)> = records
195        .iter()
196        .map(|(id, policy)| (id.as_str(), policy))
197        .collect();
198    ordered.sort_unstable_by_key(|(id, _)| *id);
199    ordered
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use didbot_policy::{
206        AccountKind, ActionSet, Applicability, DeploymentAttributes, Diff, EvalError, Evaluator,
207        KindSet, Lifecycle, Observed, Outcome, Scope, Staleness, Subject, SubjectAttributes,
208        SubjectKind, Universal, WriteAction,
209    };
210    use std::collections::HashMap;
211    use std::sync::{Arc, Mutex};
212
213    struct RejectAll;
214    impl Evaluator for RejectAll {
215        fn compile(&self, _predicate: &serde_json::Value) -> didbot_policy::CompileOutcome {
216            didbot_policy::CompileOutcome::Compiled(didbot_policy::CompiledId(0))
217        }
218        fn evaluate(
219            &self,
220            _subject: &Subject<'_>,
221            _compiled: didbot_policy::CompiledId,
222        ) -> Result<Outcome, EvalError> {
223            Ok(Outcome::Reject {
224                reason: "builtin denial".into(),
225            })
226        }
227        fn observe(&self, _event: &Observed<'_>) {}
228        fn lifecycle(&self, _event: &Lifecycle<'_>) {}
229        fn staleness(&self) -> Staleness {
230            Staleness::Linearized
231        }
232    }
233
234    fn dummy_subject() -> Subject<'static> {
235        Subject::Write {
236            account: "did:plc:agent",
237            attributes: SubjectAttributes {
238                kind: SubjectKind::Known(AccountKind::Agent),
239                handle: None,
240                depth: None,
241            },
242            universal: Universal {
243                deployment: DeploymentAttributes::default(),
244                now: time::macros::datetime!(2026 - 01 - 01 0:00 UTC),
245            },
246            client_id: None,
247            collection: "app.bsky.feed.post",
248            action: WriteAction::Create,
249            diff: Diff::default(),
250            before: None,
251            after: None,
252            lookups: &[],
253        }
254    }
255
256    /// A build with only a builtin denial still denies, with nothing
257    /// supplied at startup and no operator view loaded -- the sources are
258    /// genuinely independent inputs to the same tree.
259    #[test]
260    fn a_builtin_alone_still_denies() {
261        struct RejectAllLanguage;
262        impl Language for RejectAllLanguage {
263            fn name(&self) -> &str {
264                "reject-all"
265            }
266            fn new_evaluator(&self) -> Arc<dyn Evaluator> {
267                Arc::new(RejectAll)
268            }
269        }
270        let applicability = Applicability::Write {
271            kinds: KindSet::ALL,
272            collections: Scope::Any,
273            actions: ActionSet::ALL,
274            path_prefixes: Scope::Any,
275        };
276        let builtins = vec![BuiltinPolicy {
277            name: "builtin/reject-all/0/record.write".to_owned(),
278            policy: ParsedPolicy {
279                observes: applicability.clone(),
280                applicability,
281                staleness: Staleness::Linearized,
282                language: "reject-all".to_owned(),
283                predicate: serde_json::json!({}),
284                revision: Revision(0),
285                lookups: Vec::new(),
286            },
287        }];
288        let (tree, report) = build(
289            builtins,
290            &HashMap::new(),
291            &HashMap::new(),
292            &[&RejectAllLanguage],
293            &LastGoodPolicies::new(),
294        );
295        assert!(report.scoped_denials.is_empty());
296        assert!(matches!(
297            didbot_policy::evaluate(&dummy_subject(), &tree),
298            didbot_policy::Verdict::Reject { .. }
299        ));
300    }
301
302    /// `plan/policy.md`: "the order is part of this server's version of
303    /// the tree, so that the reason reported for a denial is stable."
304    /// Building the same set of records twice must therefore report the
305    /// same reason and name the same policy -- which iterating a
306    /// `HashMap` does not give, since each map carries its own random
307    /// seed.
308    #[test]
309    fn the_reason_reported_is_stable_across_two_builds_of_the_same_records() {
310        fn records(n: usize) -> HashMap<String, ParsedPolicy> {
311            (0..n)
312                .map(|i| {
313                    let policy = ParsedPolicy {
314                        applicability: Applicability::Write {
315                            kinds: KindSet::ALL,
316                            collections: Scope::Any,
317                            actions: ActionSet::ALL,
318                            path_prefixes: Scope::Any,
319                        },
320                        observes: Applicability::Write {
321                            kinds: KindSet::ALL,
322                            collections: Scope::Any,
323                            actions: ActionSet::ALL,
324                            path_prefixes: Scope::Any,
325                        },
326                        staleness: Staleness::Linearized,
327                        language: "regex".to_string(),
328                        predicate: serde_json::json!({}),
329                        revision: didbot_policy::Revision(1),
330                        lookups: Vec::new(),
331                    };
332                    (format!("at://op/bot.did.policy/{i:02}"), policy)
333                })
334                .collect()
335        }
336
337        // Every record here compiles to `Predicate::Unusable` (no
338        // languages registered), so all of them deny and the reported
339        // reason names whichever one the tree put first.
340        let first = build(
341            Vec::new(),
342            &HashMap::new(),
343            &records(16),
344            &[],
345            &LastGoodPolicies::new(),
346        );
347        let second = build(
348            Vec::new(),
349            &HashMap::new(),
350            &records(16),
351            &[],
352            &LastGoodPolicies::new(),
353        );
354
355        let reason_of = |tree: &didbot_policy::PolicyTree| match didbot_policy::evaluate(
356            &dummy_subject(),
357            tree,
358        ) {
359            didbot_policy::Verdict::Reject { reason, policy } => (reason, policy),
360            other => panic!("expected a refusal, got {other:?}"),
361        };
362        assert_eq!(reason_of(&first.0), reason_of(&second.0));
363
364        // And the report an operator reads is in the same order too.
365        let ids = |report: &LoadReport| -> Vec<String> {
366            report.scoped_denials.iter().map(|d| d.id.clone()).collect()
367        };
368        assert_eq!(ids(&first.1), ids(&second.1));
369    }
370
371    #[test]
372    fn an_empty_build_allows_everything() {
373        let (tree, report) = build(
374            Vec::new(),
375            &HashMap::new(),
376            &HashMap::new(),
377            &[],
378            &LastGoodPolicies::new(),
379        );
380        assert!(report.scoped_denials.is_empty());
381        assert_eq!(
382            didbot_policy::evaluate(&dummy_subject(), &tree),
383            didbot_policy::Verdict::Allow
384        );
385    }
386
387    /// A predicate `{"valid": bool, "reason": "..."}`: compiles (denying
388    /// with `reason`) when `valid` is `true`, rejects otherwise -- just
389    /// enough to drive last-good-revision tests without needing
390    /// `didbot-policy-regex`.
391    struct SwitchableEvaluator {
392        reason: Mutex<Option<String>>,
393    }
394    impl SwitchableEvaluator {
395        fn new() -> Self {
396            Self {
397                reason: Mutex::new(None),
398            }
399        }
400    }
401    impl Evaluator for SwitchableEvaluator {
402        fn compile(&self, predicate: &serde_json::Value) -> didbot_policy::CompileOutcome {
403            let valid = predicate
404                .get("valid")
405                .and_then(|v| v.as_bool())
406                .unwrap_or(false);
407            let reason = predicate
408                .get("reason")
409                .and_then(|v| v.as_str())
410                .unwrap_or("denied")
411                .to_string();
412            if valid {
413                *self.reason.lock().unwrap() = Some(reason);
414                didbot_policy::CompileOutcome::Compiled(didbot_policy::CompiledId(0))
415            } else {
416                didbot_policy::CompileOutcome::Rejected(reason)
417            }
418        }
419        fn evaluate(
420            &self,
421            _subject: &Subject<'_>,
422            _compiled: didbot_policy::CompiledId,
423        ) -> Result<Outcome, EvalError> {
424            let reason = self.reason.lock().unwrap().clone().unwrap_or_default();
425            Ok(Outcome::Reject { reason })
426        }
427        fn observe(&self, _event: &Observed<'_>) {}
428        fn lifecycle(&self, _event: &Lifecycle<'_>) {}
429        fn staleness(&self) -> Staleness {
430            Staleness::Linearized
431        }
432    }
433    struct SwitchableLanguage;
434    impl Language for SwitchableLanguage {
435        fn name(&self) -> &str {
436            "switchable"
437        }
438        fn new_evaluator(&self) -> Arc<dyn Evaluator> {
439            Arc::new(SwitchableEvaluator::new())
440        }
441    }
442
443    fn switchable_policy(valid: bool, reason: &str, revision: u64) -> ParsedPolicy {
444        ParsedPolicy {
445            applicability: Applicability::Write {
446                kinds: KindSet::ALL,
447                collections: Scope::Any,
448                actions: ActionSet::ALL,
449                path_prefixes: Scope::Any,
450            },
451            observes: Applicability::Write {
452                kinds: KindSet::ALL,
453                collections: Scope::Any,
454                actions: ActionSet::ALL,
455                path_prefixes: Scope::Any,
456            },
457            staleness: Staleness::Linearized,
458            language: "switchable".to_string(),
459            predicate: serde_json::json!({
460                "valid": valid,
461                "reason": reason,
462            }),
463            revision: didbot_policy::Revision(revision),
464            lookups: Vec::new(),
465        }
466    }
467
468    /// The core of `plan/policy.md`'s last-good-revision behaviour: a
469    /// policy that compiled at revision 1 and fails to compile at revision
470    /// 2 keeps enforcing revision 1, and the failure is reported as a
471    /// fallback rather than a plain scoped denial.
472    #[test]
473    fn a_bad_revision_falls_back_to_the_last_good_one() {
474        let last_good = LastGoodPolicies::new();
475        let languages: Vec<&dyn Language> = vec![&SwitchableLanguage];
476
477        let mut operator = HashMap::new();
478        operator.insert(
479            "at://operator/bot.did.policy/1".to_string(),
480            switchable_policy(true, "revision one enforced", 1),
481        );
482        let (tree, report) = build(
483            Vec::new(),
484            &HashMap::new(),
485            &operator,
486            &languages,
487            &last_good,
488        );
489        assert!(report.scoped_denials.is_empty());
490        assert!(report.last_good_fallbacks.is_empty());
491        assert!(matches!(
492            didbot_policy::evaluate(&dummy_subject(), &tree),
493            didbot_policy::Verdict::Reject { ref reason, .. } if reason == "revision one enforced"
494        ));
495
496        // Revision 2 arrives, broken.
497        operator.insert(
498            "at://operator/bot.did.policy/1".to_string(),
499            switchable_policy(false, "revision two is broken", 2),
500        );
501        let (tree, report) = build(
502            Vec::new(),
503            &HashMap::new(),
504            &operator,
505            &languages,
506            &last_good,
507        );
508        assert!(
509            report.scoped_denials.is_empty(),
510            "a bad revision with a last-good fallback is not a plain scoped denial"
511        );
512        assert_eq!(report.last_good_fallbacks.len(), 1);
513        assert_eq!(
514            report.last_good_fallbacks[0].enforced_revision,
515            didbot_policy::Revision(1)
516        );
517        assert_eq!(
518            report.last_good_fallbacks[0].failed_revision,
519            didbot_policy::Revision(2)
520        );
521        // Revision 1's own predicate is still what is enforced -- the edit
522        // visibly did not land, rather than the write being denied by
523        // revision 2's (broken) applicability or reason.
524        assert!(matches!(
525            didbot_policy::evaluate(&dummy_subject(), &tree),
526            didbot_policy::Verdict::Reject { ref reason, .. } if reason == "revision one enforced"
527        ));
528    }
529
530    /// A policy that has *never* compiled has nothing to fall back to: it
531    /// is an ordinary, loud [`ScopedDenial`], not a
532    /// [`LastGoodFallback`] -- the two states an operator must be able to
533    /// tell apart.
534    #[test]
535    fn a_policy_that_never_compiled_is_a_plain_scoped_denial() {
536        let last_good = LastGoodPolicies::new();
537        let languages: Vec<&dyn Language> = vec![&SwitchableLanguage];
538
539        let mut operator = HashMap::new();
540        operator.insert(
541            "at://operator/bot.did.policy/1".to_string(),
542            switchable_policy(false, "never worked", 1),
543        );
544        let (tree, report) = build(
545            Vec::new(),
546            &HashMap::new(),
547            &operator,
548            &languages,
549            &last_good,
550        );
551        assert_eq!(report.scoped_denials.len(), 1);
552        assert!(report.last_good_fallbacks.is_empty());
553        // Still fail-closed within its own applicability -- a broken
554        // never-compiled policy is loud to the operator, but the write
555        // path itself is unaffected by which of the two report shapes
556        // produced the denial.
557        assert!(matches!(
558            didbot_policy::evaluate(&dummy_subject(), &tree),
559            didbot_policy::Verdict::Reject { .. }
560        ));
561    }
562
563    /// A policy that compiles again after a bad revision replaces the
564    /// last-good entry with the new success -- fixing the typo clears the
565    /// fallback, it does not become permanent.
566    #[test]
567    fn a_later_good_revision_replaces_the_fallback() {
568        let last_good = LastGoodPolicies::new();
569        let languages: Vec<&dyn Language> = vec![&SwitchableLanguage];
570        let id = "at://operator/bot.did.policy/1".to_string();
571
572        let mut operator = HashMap::new();
573        operator.insert(id.clone(), switchable_policy(true, "v1", 1));
574        build(
575            Vec::new(),
576            &HashMap::new(),
577            &operator,
578            &languages,
579            &last_good,
580        );
581
582        operator.insert(id.clone(), switchable_policy(false, "v2 broken", 2));
583        build(
584            Vec::new(),
585            &HashMap::new(),
586            &operator,
587            &languages,
588            &last_good,
589        );
590
591        operator.insert(id.clone(), switchable_policy(true, "v3", 3));
592        let (tree, report) = build(
593            Vec::new(),
594            &HashMap::new(),
595            &operator,
596            &languages,
597            &last_good,
598        );
599        assert!(report.last_good_fallbacks.is_empty());
600        assert!(matches!(
601            didbot_policy::evaluate(&dummy_subject(), &tree),
602            didbot_policy::Verdict::Reject { ref reason, .. } if reason == "v3"
603        ));
604    }
605
606    /// Last-good-revision must not become a way to resurrect a policy the
607    /// operator deleted. A source's view drops a record absent
608    /// from a successful listing, so a deleted id simply stops appearing in
609    /// `operator` -- but an at-uri is the operator's to reuse, and a *new*
610    /// record published at a deleted one's rkey whose first revision does
611    /// not compile would otherwise find the deleted policy's entry still
612    /// sitting in `LastGoodPolicies` and enforce it. `build`'s prune is
613    /// what closes that: a first publish that fails to compile is the loud,
614    /// never-compiled state, whatever used to live at that id.
615    #[test]
616    fn a_deleted_id_leaves_nothing_for_a_later_record_to_fall_back_onto() {
617        let last_good = LastGoodPolicies::new();
618        let languages: Vec<&dyn Language> = vec![&SwitchableLanguage];
619        let id = "at://operator/bot.did.policy/reused-rkey".to_string();
620
621        // A policy that works, so it becomes this id's last-good.
622        let mut operator = HashMap::new();
623        operator.insert(id.clone(), switchable_policy(true, "the deleted policy", 1));
624        let (tree, _) = build(
625            Vec::new(),
626            &HashMap::new(),
627            &operator,
628            &languages,
629            &last_good,
630        );
631        assert!(matches!(
632            didbot_policy::evaluate(&dummy_subject(), &tree),
633            didbot_policy::Verdict::Reject { ref reason, .. } if reason == "the deleted policy"
634        ));
635
636        // The operator deletes it: the view no longer names the id at all.
637        operator.remove(&id);
638        let (tree, report) = build(
639            Vec::new(),
640            &HashMap::new(),
641            &operator,
642            &languages,
643            &last_good,
644        );
645        assert!(report.last_good_fallbacks.is_empty());
646        assert_eq!(
647            didbot_policy::evaluate(&dummy_subject(), &tree),
648            didbot_policy::Verdict::Allow,
649            "a deleted policy must not still be enforced"
650        );
651
652        // A new, unrelated record is published at the same at-uri, and its
653        // very first revision does not compile.
654        operator.insert(id.clone(), switchable_policy(false, "a fresh typo", 1));
655        let (tree, report) = build(
656            Vec::new(),
657            &HashMap::new(),
658            &operator,
659            &languages,
660            &last_good,
661        );
662        assert!(
663            report.last_good_fallbacks.is_empty(),
664            "the deleted policy is not a revision this record can fall back to"
665        );
666        assert_eq!(report.scoped_denials.len(), 1);
667        match didbot_policy::evaluate(&dummy_subject(), &tree) {
668            didbot_policy::Verdict::Reject { reason, .. } => assert_ne!(
669                reason, "the deleted policy",
670                "a deleted policy's predicate was resurrected at a reused at-uri"
671            ),
672            other => panic!("a never-compiled predicate must still deny, got {other:?}"),
673        }
674    }
675}