Skip to main content

acdp_client/
revocation_cache.rs

1//! Issue #257: a cache for RFC-ACDP-0014 §7/§8 revocation discovery.
2//!
3//! This is deliberately **two objects sharing one lock**, not one:
4//!
5//! - **Facts** — verified [`KeyRevocation`]s. RFC-ACDP-0014 §7:114 licenses
6//!   caching these *indefinitely* ("the statement is permanent"). They are
7//!   **always unioned** into classification by
8//!   [`crate::verified::VerifiedContext`]'s internal `verify_retrieved` —
9//!   never used to replace or subset a discovery result. A revocation is
10//!   monotone (more revocations ⇒ an earlier effective boundary ⇒ strictly
11//!   more fail-closed verdicts), so seeding from the fact store can only
12//!   *tighten* a verdict, never loosen one. That makes the fact store an
13//!   **anti-rollback security control**: without it, a registry that serves
14//!   a revocation on one call and hides it on the next causes the client to
15//!   forget it ever saw it. See `RevocationCache::facts_for` (read) and
16//!   `RevocationCache::record_success` (write) — both crate-private.
17//!
18//!   **Every stored fact carries the vantage that minted it** (its
19//!   `origin`, set from `record_success`'s `authority` parameter — see
20//!   `StoredFact`). RFC-ACDP-0014 §6 draws a real distinction here: a
21//!   *producer-signed* fact is self-contained and "verifies identically
22//!   wherever it came from" (§8), so it is never filtered by origin — this
23//!   is what makes it correct for it to cross vantages (see
24//!   `crate::verified`'s anti-rollback tests). A *registry-attested* fact is
25//!   one specific registry's claim, and §6 licenses applying it only "for
26//!   contexts served by or receipted by that same registry" — so a read
27//!   filters registry-attested facts to `origin == current vantage`.
28//!   Storing only `trust_class` (as an earlier revision of this cache did)
29//!   answers a different question than "was this claim made by the
30//!   registry now serving this context" — collapsing the two let a fact
31//!   minted at a hostile or deceived registry A fail-close a producer's
32//!   contexts at every OTHER authority, for the cache's lifetime: a
33//!   targeted cross-registry DoS exactly bounded by §6's scoping default.
34//! - **Freshness markers** — "vantage V was asked about producer/controller
35//!   P for trust class C, and a full, untruncated discovery completed at
36//!   time T." This is a cached *absence*, which §7:114 does **not** license
37//!   and which §8 explicitly warns about: "a malicious registry can hide a
38//!   revocation … absence of search results is not evidence of absence." So
39//!   a marker is TTL-bounded ([`crate::verified::RevocationDiscovery::freshness`],
40//!   default [`std::time::Duration::ZERO`] — i.e. off), per-vantage, and
41//!   read via `RevocationCache::marker_fresh` (crate-private) — the only
42//!   method in this module that can cause a lookup to be skipped rather
43//!   than merely supplemented.
44//!
45//! Both objects live in the same per-producer `CacheEntry` (crate-private)
46//! so eviction is whole-entry: facts and markers for one producer always leave together,
47//! which makes "a fresh marker over an evicted (or fact-capped) set" —
48//! stale-absence-over-nothing — structurally unrepresentable rather than
49//! merely guarded against.
50//!
51//! **Never call anything here holding the lock across an `.await`** — every
52//! method in this module is synchronous and returns before its caller does
53//! any network I/O.
54
55use std::collections::HashMap;
56use std::sync::{Arc, Mutex, MutexGuard};
57use std::time::{Duration, Instant};
58
59use acdp_types::revocation::{KeyRevocation, RevocationTrustClass};
60
61/// Capacity-triggered bound on the number of distinct producer/controller
62/// entries this cache holds. Deliberately **not** an LRU (no recency
63/// tracking) — per the wave plan (issue #257), eviction here can only ever
64/// lose caching, never safety (a missing entry just means discovery runs
65/// again), so plain capacity-triggered eviction of an arbitrary entry is
66/// sufficient.
67const MAX_CACHE_ENTRIES: usize = 1000;
68
69/// Per-entry cap on stored facts. Reached only by a producer with a
70/// genuinely large revocation history, or (defensively) a registry padding
71/// results — either way, once hit this entry stops accepting new facts and
72/// drops its markers, degrading to plain pass-through (full discovery,
73/// every call) rather than to false completeness. `KeyRevocation` is not
74/// `Hash` (`acdp_types::revocation`), so dedup on insert is a linear `Eq`
75/// scan — trivial at this bound.
76const MAX_FACTS_PER_ENTRY: usize = 256;
77
78/// A cached, verified [`KeyRevocation`] plus the vantage that minted it.
79///
80/// BLOCKER-1 (fresh-Opus review of the #257/#258/#260 wave): the origin is
81/// consulted only for [`RevocationTrustClass::RegistryAttested`] facts —
82/// `facts_for` filters those to `origin == current vantage`, per
83/// RFC-ACDP-0014 §6 ("apply it ... for contexts served by or receipted by
84/// that same registry"). A [`RevocationTrustClass::ProducerSigned`] fact is
85/// self-contained (§8) and is never filtered by origin regardless of what
86/// this field holds.
87struct StoredFact {
88    rev: KeyRevocation,
89    /// [`crate::RegistryClient::authority`] at the moment [`RevocationCache::record_success`]
90    /// stored this fact. Both call sites (`crate::revocation::find_revocations`,
91    /// `crate::revocation::find_registry_attested_revocations`) gate the
92    /// call on a `Some(vantage)`, so this is always the vantage that
93    /// genuinely served/attested the fact, never a placeholder.
94    origin: String,
95}
96
97/// One producer/controller's cached state: verified facts (both trust
98/// classes, filtered by class AND, for registry-attested facts, by origin
99/// on read) plus per-vantage freshness markers.
100#[derive(Default)]
101struct CacheEntry {
102    facts: Vec<StoredFact>,
103    /// Keyed by `(authority, is_registry_attested)` — never by trust class
104    /// alone (a producer-signed and a registry-attested marker for the same
105    /// authority are independent) and never by the search identity used
106    /// internally by the registry-attested lookup (that would let one
107    /// marker suppress discovery for every producer at that registry — see
108    /// `crate::revocation::find_registry_attested_revocations`, which keys
109    /// its marker by the `controller` parameter, not by
110    /// `capabilities.registry_did`). Plain `bool` rather than
111    /// `RevocationTrustClass` because that type is not `Hash`.
112    markers: HashMap<(String, bool), Instant>,
113    /// Set once `facts.len()` has reached [`MAX_FACTS_PER_ENTRY`]; once
114    /// true, new facts are dropped and no further marker is ever minted for
115    /// this entry, so it degrades to pass-through rather than silently
116    /// claiming completeness it can no longer track.
117    at_cap: bool,
118}
119
120/// N5: an exhaustive `match`, not `matches!`, so a future third
121/// `RevocationTrustClass` variant fails to compile here instead of
122/// silently aliasing onto the `ProducerSigned` marker bucket.
123fn class_key(class: RevocationTrustClass) -> bool {
124    match class {
125        RevocationTrustClass::ProducerSigned => false,
126        RevocationTrustClass::RegistryAttested => true,
127    }
128}
129
130/// A cache of RFC-ACDP-0014 revocation facts and discovery-freshness
131/// markers, injectable into a [`crate::RegistryClient`] via
132/// [`crate::RegistryClient::with_revocation_cache`].
133///
134/// `Clone`, cheap (an `Arc` handle around a `Mutex<HashMap<..>>>`, following
135/// the same hand-rolled-bound pattern `CrossRegistryResolver` already uses
136/// for `client_cache`/`caps_cache` — see `crate::cross_registry`). Sharing
137/// one clone across multiple `RegistryClient`s (or multiple producers'
138/// verify calls) is the point: it is how a caller amortizes discovery
139/// across many `verify_retrieved` calls against the same producer, and how
140/// issue #260's cross-registry walk will later share one cache across the
141/// per-authority clients it builds.
142///
143/// Attaching a cache changes nothing observable by itself: with the default
144/// `freshness: Duration::ZERO` (both `RevocationDiscovery` named
145/// constructors), markers never suppress a lookup — see
146/// `marker_fresh`'s fast path (crate-private). The only behavioral change from
147/// attaching a cache at the default is anti-rollback (facts persisting
148/// across an induced discovery failure); saving requests is a *further*,
149/// opt-in step that requires setting `freshness` above zero.
150#[derive(Clone)]
151pub struct RevocationCache {
152    inner: Arc<Mutex<HashMap<String, CacheEntry>>>,
153}
154
155impl RevocationCache {
156    /// A new, empty cache.
157    #[must_use]
158    pub fn new() -> Self {
159        Self {
160            inner: Arc::new(Mutex::new(HashMap::new())),
161        }
162    }
163
164    /// Recover from a poisoned lock rather than propagate the panic. A
165    /// poisoned cache must degrade to *running discovery* (the safe
166    /// direction), never to silently skipping it — recovering the guard
167    /// and continuing achieves exactly that: the stale/possibly-torn data
168    /// underneath can, at worst, cause an unnecessary re-discovery (a
169    /// missed marker) or an unnecessary fact write, never a false skip of
170    /// a lookup that was never actually run.
171    fn lock(&self) -> MutexGuard<'_, HashMap<String, CacheEntry>> {
172        self.inner
173            .lock()
174            .unwrap_or_else(|poisoned| poisoned.into_inner())
175    }
176
177    /// Read cached facts for `agent_id`, filtered to the trust classes the
178    /// CURRENT discovery configuration opted into — never all classes ever
179    /// cached. A `producer_signed_only()` caller must not have a
180    /// registry-attested fact (cached from some earlier `all_trust_classes()`
181    /// run against the same producer) silently applied to it; that would
182    /// apply a trust class this call explicitly declined, per RFC-ACDP-0014
183    /// §6.
184    ///
185    /// BLOCKER-1: a registry-attested fact is ADDITIONALLY filtered to
186    /// `origin == current_vantage` — a registry-attested claim is one
187    /// specific registry's claim (§6), so a fact minted while talking to
188    /// authority A must not apply while talking to a different authority
189    /// B, even under `all_trust_classes()`. `current_vantage: None` (no
190    /// resolvable authority on this call's client) excludes every
191    /// registry-attested fact, fail-closed toward "don't apply," never
192    /// toward "apply everywhere." A producer-signed fact is never filtered
193    /// by origin — it is self-contained (§8) and correct to cross vantages.
194    ///
195    /// Returns an empty `Vec` when no entry exists yet — a cache miss is
196    /// indistinguishable from "no facts found," which is correct: either
197    /// way discovery proceeds unseeded.
198    pub(crate) fn facts_for(
199        &self,
200        agent_id: &str,
201        include_registry_attested: bool,
202        current_vantage: Option<&str>,
203    ) -> Vec<KeyRevocation> {
204        let map = self.lock();
205        match map.get(agent_id) {
206            Some(entry) => entry
207                .facts
208                .iter()
209                .filter(|f| match f.rev.trust_class {
210                    RevocationTrustClass::ProducerSigned => true,
211                    RevocationTrustClass::RegistryAttested => {
212                        include_registry_attested && current_vantage.is_some_and(|v| v == f.origin)
213                    }
214                })
215                .map(|f| f.rev.clone())
216                .collect(),
217            None => Vec::new(),
218        }
219    }
220
221    /// Check whether a marker minted for `(authority, agent_id, class)` is
222    /// still within `freshness` — the only method that can cause a lookup
223    /// to be skipped. `freshness == Duration::ZERO` (the default) always
224    /// returns `false` without even touching the lock: a zero TTL can
225    /// never be "still fresh," and this fast path is what keeps the
226    /// default behavior request-for-request identical to no cache at all
227    /// (issue #257 AC3).
228    ///
229    /// N4: matches the raw `lock()` result directly rather than recovering
230    /// via `poisoned.into_inner()` — a marker present at poison time must
231    /// not suppress discovery. A poisoned lock (`Err(_)`) returns `false`
232    /// unconditionally, degrading to "run discovery" (the safe direction),
233    /// never to a false skip. Recovering via `into_inner` is reserved for
234    /// `facts_for`/`record_success` below, where recovering can only ever
235    /// tighten a verdict or cost an extra future write, never cause a
236    /// false skip.
237    pub(crate) fn marker_fresh(
238        &self,
239        authority: &str,
240        agent_id: &str,
241        class: RevocationTrustClass,
242        freshness: Duration,
243    ) -> bool {
244        if freshness.is_zero() {
245            return false;
246        }
247        let map = match self.inner.lock() {
248            Ok(guard) => guard,
249            Err(_poisoned) => return false,
250        };
251        match map
252            .get(agent_id)
253            .and_then(|e| e.markers.get(&(authority.to_string(), class_key(class))))
254        {
255            Some(minted_at) => minted_at.elapsed() < freshness,
256            None => false,
257        }
258    }
259
260    /// Record a fully successful, untruncated discovery lookup: dedup-merge
261    /// `facts` into the entry (via `KeyRevocation`'s derived `Eq` — it is
262    /// not `Hash`) and, unless the per-entry fact cap has just been
263    /// reached, mint a fresh marker for `(authority, agent_id, class)`.
264    ///
265    /// Called ONLY from the tail of `find_revocations` /
266    /// `find_registry_attested_revocations`, immediately before each
267    /// returns `Ok` — i.e. only on a call that neither errored nor hit
268    /// `AcdpError::SearchTruncated`, both of which return early via `?`
269    /// before ever reaching here. This is what makes "marker minted only on
270    /// full, untruncated success" structural rather than a discipline: a
271    /// truncated or failed call simply never calls this method, so it can
272    /// neither corrupt the fact set (facts are additive-only regardless)
273    /// nor mint a marker that would silently suppress the next attempt.
274    ///
275    /// At the per-entry fact cap, new facts are dropped (not merely this
276    /// call's — the entry is permanently past caching new information) and
277    /// existing markers for the entry are cleared, so it degrades to
278    /// pass-through: every future call re-discovers from scratch for this
279    /// producer, rather than risk a marker outliving a set that can no
280    /// longer track completeness.
281    pub(crate) fn record_success(
282        &self,
283        authority: &str,
284        agent_id: &str,
285        class: RevocationTrustClass,
286        facts: &[KeyRevocation],
287    ) {
288        let mut map = self.lock();
289        if !map.contains_key(agent_id) && map.len() >= MAX_CACHE_ENTRIES {
290            // Capacity-triggered eviction of an arbitrary existing entry —
291            // no LRU recency tracking (see MAX_CACHE_ENTRIES doc). Losing
292            // an entry can only cost a future re-discovery, never safety.
293            if let Some(victim) = map.keys().next().cloned() {
294                map.remove(&victim);
295            }
296        }
297        let entry = map.entry(agent_id.to_string()).or_default();
298        if !entry.at_cap {
299            for f in facts {
300                // N6: check for a duplicate BEFORE consulting the cap. A
301                // re-discovery of facts already known must never itself
302                // trip `at_cap` (and clear this entry's markers) merely
303                // because the entry happens to sit exactly at the limit —
304                // only a genuinely NEW fact that would overflow the cap
305                // does that.
306                if entry.facts.iter().any(|existing| existing.rev == *f) {
307                    continue;
308                }
309                if entry.facts.len() >= MAX_FACTS_PER_ENTRY {
310                    entry.at_cap = true;
311                    break;
312                }
313                entry.facts.push(StoredFact {
314                    rev: f.clone(),
315                    origin: authority.to_string(),
316                });
317            }
318        }
319        if entry.at_cap {
320            entry.markers.clear();
321        } else {
322            entry
323                .markers
324                .insert((authority.to_string(), class_key(class)), Instant::now());
325        }
326    }
327
328    /// Test-only introspection (MATERIAL-2, fresh-Opus review of Phase 2):
329    /// the number of facts currently stored for `agent_id`, unfiltered by
330    /// trust class or origin. Exists so an integration test can prove
331    /// dedup holds across independently-run, real discoveries — not just
332    /// across clones of one in-memory value, which is all the unit test
333    /// `record_success_dedups_identical_facts` below can show.
334    ///
335    /// `#[doc(hidden)]` and gated behind `test-transport`, the same
336    /// feature that already gates `RegistryClient::with_test_endpoint` for
337    /// exactly this reason: a tiny, harmless, read-only accessor that must
338    /// never be mistaken for part of the real public surface.
339    #[doc(hidden)]
340    #[cfg(feature = "test-transport")]
341    #[must_use]
342    pub fn fact_count(&self, agent_id: &str) -> usize {
343        self.lock().get(agent_id).map_or(0, |e| e.facts.len())
344    }
345}
346
347impl Default for RevocationCache {
348    fn default() -> Self {
349        Self::new()
350    }
351}
352
353impl std::fmt::Debug for RevocationCache {
354    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355        f.debug_struct("RevocationCache").finish_non_exhaustive()
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use chrono::{DateTime, Utc};
363
364    fn rev(fp: &str, class: RevocationTrustClass) -> KeyRevocation {
365        KeyRevocation {
366            revoked_key_fingerprint: fp.into(),
367            compromised_since: "2026-05-01T00:00:00.000Z".parse::<DateTime<Utc>>().unwrap(),
368            reason: None,
369            revoked_key_id: None,
370            revoked_key_controller: acdp_types::primitives::AgentDid::new(
371                "did:web:agents.example.com:p",
372            ),
373            publisher: acdp_types::primitives::AgentDid::new("did:web:agents.example.com:p"),
374            trust_class: class,
375        }
376    }
377
378    /// AC4 (dedup): recording the same fact repeatedly does not grow the
379    /// entry.
380    #[test]
381    fn record_success_dedups_identical_facts() {
382        let cache = RevocationCache::new();
383        let r = rev("sha256:aaaa", RevocationTrustClass::ProducerSigned);
384        for _ in 0..5 {
385            cache.record_success(
386                "reg.example",
387                "did:web:p",
388                RevocationTrustClass::ProducerSigned,
389                std::slice::from_ref(&r),
390            );
391        }
392        assert_eq!(
393            cache
394                .facts_for("did:web:p", true, Some("reg.example"))
395                .len(),
396            1
397        );
398    }
399
400    /// Per-entry fact cap: once reached, new distinct facts are dropped and
401    /// markers are cleared (never a fresh marker over a set that stopped
402    /// growing).
403    #[test]
404    fn record_success_caps_facts_and_drops_markers_at_cap() {
405        let cache = RevocationCache::new();
406        for i in 0..(MAX_FACTS_PER_ENTRY + 5) {
407            let fp = format!("sha256:{i:064x}");
408            let r = rev(&fp, RevocationTrustClass::ProducerSigned);
409            cache.record_success(
410                "reg.example",
411                "did:web:p",
412                RevocationTrustClass::ProducerSigned,
413                &[r],
414            );
415        }
416        assert_eq!(
417            cache
418                .facts_for("did:web:p", true, Some("reg.example"))
419                .len(),
420            MAX_FACTS_PER_ENTRY
421        );
422        assert!(!cache.marker_fresh(
423            "reg.example",
424            "did:web:p",
425            RevocationTrustClass::ProducerSigned,
426            Duration::from_secs(3600)
427        ));
428    }
429
430    /// `facts_for` filters registry-attested facts out for a
431    /// producer-signed-only caller, and in for an all-trust-classes one
432    /// reading from the SAME vantage that minted the fact.
433    #[test]
434    fn facts_for_filters_by_trust_class() {
435        let cache = RevocationCache::new();
436        let attested = rev("sha256:bbbb", RevocationTrustClass::RegistryAttested);
437        cache.record_success(
438            "reg.example",
439            "did:web:p",
440            RevocationTrustClass::RegistryAttested,
441            &[attested],
442        );
443        assert!(cache
444            .facts_for("did:web:p", false, Some("reg.example"))
445            .is_empty());
446        assert_eq!(
447            cache
448                .facts_for("did:web:p", true, Some("reg.example"))
449                .len(),
450            1
451        );
452    }
453
454    /// BLOCKER-1: a registry-attested fact minted at vantage A must NOT be
455    /// returned when reading at a DIFFERENT vantage B, even under
456    /// `include_registry_attested: true` — RFC-ACDP-0014 §6 scopes a
457    /// registry-attested claim to the registry that made it. `None` (no
458    /// resolvable vantage on the reading call) must behave the same as a
459    /// mismatched vantage: exclude the fact, never include it.
460    #[test]
461    fn facts_for_filters_registry_attested_facts_by_origin() {
462        let cache = RevocationCache::new();
463        let attested = rev("sha256:cccc", RevocationTrustClass::RegistryAttested);
464        cache.record_success(
465            "a.example",
466            "did:web:p",
467            RevocationTrustClass::RegistryAttested,
468            &[attested],
469        );
470        assert_eq!(
471            cache.facts_for("did:web:p", true, Some("a.example")).len(),
472            1,
473            "reading from the SAME vantage that minted the fact must include it"
474        );
475        assert!(
476            cache
477                .facts_for("did:web:p", true, Some("b.example"))
478                .is_empty(),
479            "reading from a DIFFERENT vantage must exclude a registry-attested fact"
480        );
481        assert!(
482            cache.facts_for("did:web:p", true, None).is_empty(),
483            "reading with no resolvable current vantage must exclude a registry-attested \
484             fact, not include it by default"
485        );
486    }
487
488    /// BLOCKER-1's other direction: a producer-signed fact is
489    /// self-contained (RFC-ACDP-0014 §8) and is NEVER filtered by origin —
490    /// it must be returned when read from a vantage other than the one
491    /// that minted it.
492    #[test]
493    fn facts_for_never_filters_producer_signed_facts_by_origin() {
494        let cache = RevocationCache::new();
495        let signed = rev("sha256:dddd", RevocationTrustClass::ProducerSigned);
496        cache.record_success(
497            "a.example",
498            "did:web:p",
499            RevocationTrustClass::ProducerSigned,
500            &[signed],
501        );
502        assert_eq!(
503            cache.facts_for("did:web:p", false, Some("b.example")).len(),
504            1
505        );
506        assert_eq!(cache.facts_for("did:web:p", false, None).len(), 1);
507    }
508
509    /// Markers are per-vantage: minting one for authority A does not make
510    /// `marker_fresh` true for authority B.
511    #[test]
512    fn markers_are_per_vantage() {
513        let cache = RevocationCache::new();
514        cache.record_success(
515            "a.example",
516            "did:web:p",
517            RevocationTrustClass::ProducerSigned,
518            &[],
519        );
520        assert!(cache.marker_fresh(
521            "a.example",
522            "did:web:p",
523            RevocationTrustClass::ProducerSigned,
524            Duration::from_secs(60)
525        ));
526        assert!(!cache.marker_fresh(
527            "b.example",
528            "did:web:p",
529            RevocationTrustClass::ProducerSigned,
530            Duration::from_secs(60)
531        ));
532    }
533
534    /// Markers are per trust class too.
535    #[test]
536    fn markers_are_per_trust_class() {
537        let cache = RevocationCache::new();
538        cache.record_success(
539            "a.example",
540            "did:web:p",
541            RevocationTrustClass::ProducerSigned,
542            &[],
543        );
544        assert!(!cache.marker_fresh(
545            "a.example",
546            "did:web:p",
547            RevocationTrustClass::RegistryAttested,
548            Duration::from_secs(60)
549        ));
550    }
551
552    /// `freshness: ZERO` never reports a marker as fresh, regardless of how
553    /// recently it was minted.
554    #[test]
555    fn zero_freshness_never_suppresses() {
556        let cache = RevocationCache::new();
557        cache.record_success(
558            "a.example",
559            "did:web:p",
560            RevocationTrustClass::ProducerSigned,
561            &[],
562        );
563        assert!(!cache.marker_fresh(
564            "a.example",
565            "did:web:p",
566            RevocationTrustClass::ProducerSigned,
567            Duration::ZERO
568        ));
569    }
570
571    /// AC10: eviction cannot desync — an evicted entry loses its fact and
572    /// its marker TOGETHER, never one without the other. Whole-entry
573    /// eviction (`record_success`'s capacity check keys on the outer
574    /// `agent_id` map, never on facts/markers separately) makes this
575    /// unrepresentable rather than merely guarded: insert one more
576    /// distinct producer than `MAX_CACHE_ENTRIES` allows and confirm
577    /// fact-presence and marker-presence agree for every surviving and
578    /// evicted entry alike.
579    #[test]
580    fn eviction_at_capacity_removes_facts_and_markers_together() {
581        let cache = RevocationCache::new();
582        for i in 0..MAX_CACHE_ENTRIES {
583            let agent = format!("did:web:p{i}");
584            let fp = format!("sha256:{i:064x}");
585            cache.record_success(
586                "reg.example",
587                &agent,
588                RevocationTrustClass::ProducerSigned,
589                &[rev(&fp, RevocationTrustClass::ProducerSigned)],
590            );
591        }
592        for i in 0..MAX_CACHE_ENTRIES {
593            let agent = format!("did:web:p{i}");
594            assert_eq!(
595                cache.facts_for(&agent, true, Some("reg.example")).len(),
596                1,
597                "entry {i} must have its fact before eviction pressure"
598            );
599        }
600
601        // One more distinct entry forces capacity-triggered eviction of
602        // some existing entry.
603        let overflow_agent = "did:web:overflow";
604        cache.record_success(
605            "reg.example",
606            overflow_agent,
607            RevocationTrustClass::ProducerSigned,
608            &[rev(
609                "sha256:overff0000000000000000000000000000000000000000000000000000000",
610                RevocationTrustClass::ProducerSigned,
611            )],
612        );
613
614        let mut evicted = 0;
615        for i in 0..MAX_CACHE_ENTRIES {
616            let agent = format!("did:web:p{i}");
617            let has_fact = !cache
618                .facts_for(&agent, true, Some("reg.example"))
619                .is_empty();
620            let has_marker = cache.marker_fresh(
621                "reg.example",
622                &agent,
623                RevocationTrustClass::ProducerSigned,
624                Duration::from_secs(3600),
625            );
626            assert_eq!(
627                has_fact, has_marker,
628                "entry {i}: fact presence ({has_fact}) and marker presence \
629                 ({has_marker}) must never disagree — eviction must remove both \
630                 together, never a fresh marker over an emptied fact set"
631            );
632            if !has_fact {
633                evicted += 1;
634            }
635        }
636        assert_eq!(
637            evicted, 1,
638            "capacity-triggered eviction must remove exactly one existing entry \
639             to make room for the new one"
640        );
641        assert!(
642            !cache
643                .facts_for(overflow_agent, true, Some("reg.example"))
644                .is_empty(),
645            "the newly-inserted entry must itself survive"
646        );
647    }
648}