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}