Skip to main content

cachekit/
client.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use bytes::Bytes;
5
6use serde::{de::DeserializeOwned, Serialize};
7
8use crate::backend::Backend;
9#[cfg(feature = "l1")]
10use crate::backend::{ttl_wire_secs, Freshness};
11use crate::error::CachekitError;
12use crate::metrics::{CacheCounters, L1Stats, ReadOutcome};
13use crate::serializer;
14
15// ── SharedBackend type alias ──────────────────────────────────────────────────
16
17/// Reference-counted pointer to a heap-allocated backend.
18///
19/// On native targets (without `unsync`) we require `Send + Sync` via `Arc`.
20/// On `wasm32` or with the `unsync` feature, `Rc` is used instead — the runtime
21/// is single-threaded so `Send` bounds are unnecessary.
22#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
23pub type SharedBackend = std::sync::Arc<dyn Backend>;
24
25/// Reference-counted pointer to a heap-allocated backend (`?Send` variant).
26#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
27pub type SharedBackend = std::rc::Rc<dyn Backend>;
28
29// ── SharedFlight type alias ──────────────────────────────────────────────────
30
31/// Reference-counted pointer to the single-flight map, so client clones share
32/// fill-dedup state (two clones racing a cold miss must collapse to one fill).
33#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
34pub(crate) type SharedFlight = std::sync::Arc<crate::flight::FlightMap>;
35
36#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
37pub(crate) type SharedFlight = std::rc::Rc<crate::flight::FlightMap>;
38
39/// Separate same-key ordering from single-flight: a refresh holds the flight
40/// lock while computing, then takes this lock only for its version-checked
41/// commit. Direct reads/writes/deletes take the same mutation lock.
42#[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
43type SharedMutations = std::sync::Arc<crate::flight::MutationMap>;
44
45// ── SharedEncryption type alias ──────────────────────────────────────────────
46
47/// Reference-counted pointer to the encryption layer.
48///
49/// On native targets (without `unsync`) `Arc` is used (requires `Sync`).
50/// On `wasm32` or with `unsync`, `Rc` is used — avoids the `!Sync` problem
51/// caused by `Cell<u64>` inside cachekit-core's nonce counter.
52#[cfg(all(
53    feature = "encryption",
54    not(any(target_arch = "wasm32", feature = "unsync"))
55))]
56type SharedEncryption = std::sync::Arc<crate::encryption::EncryptionLayer>;
57
58#[cfg(all(
59    feature = "encryption",
60    any(target_arch = "wasm32", feature = "unsync")
61))]
62type SharedEncryption = std::rc::Rc<crate::encryption::EncryptionLayer>;
63
64// ── Key validation ────────────────────────────────────────────────────────────
65
66const MAX_KEY_BYTES: usize = 1024;
67
68/// Maximum TTL for L1 entries populated from L2 cache hits.
69/// Uses a short ceiling to limit staleness when the original TTL is unknown.
70/// A server `X-CacheKit-Fresh-For` bounds it further, and a stale read or a
71/// zero bound is not backfilled at all — see [`l1_backfill_ttl`].
72///
73/// Reconciliation with stale-while-revalidate: a backfilled entry's SWR
74/// freshness window derives from this capped TTL (window = ratio × entry
75/// TTL), **not** from the write-path TTL — the cap is the staleness bound
76/// for L2-derived data, deliberately kept. SWR removes the cap's expiry
77/// cliff instead: past ~ratio × 30 s the entry is served stale while one
78/// background refresh re-executes the origin. If no newer mutation replaced
79/// the entry, that refresh writes both layers and renews L1 hard expiry with
80/// the caller's full TTL. Without SWR the entry simply hard-expires at the cap
81/// and the next read blocks on L2, as before.
82#[cfg(feature = "l1")]
83const L1_BACKFILL_TTL_SECS: u64 = 30;
84
85/// L1 lifetime for a backfill from an L2 hit, or `None` when the read must not
86/// be backfilled (`spec/saas-api.md` § Remaining Freshness). A stale label or a
87/// zero `Fresh-For` forbids it; a present `Fresh-For` is a hard bound under the
88/// 30 s cap, which SWR cannot outlive because moka drops the entry at its TTL.
89/// With no header the cap applies unchanged.
90#[cfg(feature = "l1")]
91fn l1_backfill_ttl(default_ttl: Duration, freshness: Freshness) -> Option<Duration> {
92    let cap = std::cmp::min(default_ttl, Duration::from_secs(L1_BACKFILL_TTL_SECS));
93    let ttl = freshness
94        .fresh_for
95        .map_or(cap, |fresh_for| cap.min(fresh_for));
96    (!freshness.is_stale && !ttl.is_zero()).then_some(ttl)
97}
98
99fn validate_key(key: &str) -> Result<(), CachekitError> {
100    if key.is_empty() {
101        return Err(CachekitError::InvalidKey(
102            "key must not be empty".to_owned(),
103        ));
104    }
105    if key.len() > MAX_KEY_BYTES {
106        return Err(CachekitError::InvalidKey(format!(
107            "key is {} bytes (limit: {MAX_KEY_BYTES})",
108            key.len()
109        )));
110    }
111    for b in key.bytes() {
112        if b < 0x20 || b == 0x7F {
113            return Err(CachekitError::InvalidKey(format!(
114                "key contains illegal control character 0x{b:02X}"
115            )));
116        }
117    }
118    Ok(())
119}
120
121// ── Stale-while-revalidate ───────────────────────────────────────────────────
122//
123// SWR needs an L1 to age entries in and a spawnable (`Send`) runtime for the
124// background refresh — native, non-`unsync` targets with the `l1` feature.
125// Everywhere else the SWR read path degrades to the plain read path and
126// `SwrRead::Stale` is never produced.
127
128/// Outcome of an SWR-aware typed read — see [`CacheKit::interop_get_swr`].
129#[derive(Debug, Clone, PartialEq)]
130pub enum SwrRead<T> {
131    /// Cache hit within the freshness window (or an L2 hit): use directly.
132    Fresh(T),
133    /// L1 hit past the freshness threshold but before hard expiry: use the
134    /// value now, and schedule a background refresh (the `#[cachekit]` macro
135    /// re-executes the function, deduplicated on the [`CacheKit::single_flight`]
136    /// locks without waiting on them). The token
137    /// makes refresh completion conditional: a newer set or delete wins.
138    Stale(T, SwrToken),
139    /// No usable entry: fall through to a normal blocking miss + fill.
140    Miss,
141}
142
143/// Mutation version captured by an SWR stale read.
144///
145/// Pass this back only through the `#[cachekit]`-generated refresh path. It
146/// prevents an older background computation from overwriting a newer write
147/// or resurrecting a deleted entry on this client or any of its clones.
148#[derive(Clone)]
149pub struct SwrToken {
150    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
151    state: std::sync::Arc<crate::flight::MutationState>,
152    version: u64,
153}
154
155impl std::fmt::Debug for SwrToken {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        f.debug_struct("SwrToken")
158            .field("version", &self.version)
159            .finish_non_exhaustive()
160    }
161}
162
163impl PartialEq for SwrToken {
164    fn eq(&self, other: &Self) -> bool {
165        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
166        {
167            self.version == other.version && std::sync::Arc::ptr_eq(&self.state, &other.state)
168        }
169        #[cfg(not(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32"))))]
170        {
171            self.version == other.version
172        }
173    }
174}
175
176impl Eq for SwrToken {}
177
178// ── CacheKit ─────────────────────────────────────────────────────────────────
179
180/// Cache client with optional L1 in-process cache layer.
181///
182/// `Clone` is cheap and shares everything: backend, L1 cache, single-flight
183/// state, and encryption layer. Clones exist so `'static` background work
184/// (e.g. the SWR refresh spawned by `#[cachekit]`) can hold the client
185/// without borrowing it.
186///
187/// # Encryption
188///
189/// A client built with encryption configured (by any of the builder's
190/// encryption methods, the `secure` / `secure_from_env` preset, or
191/// [`CacheKit::from_env`] with `CACHEKIT_MASTER_KEY` set) encrypts every
192/// value it writes and decrypts every value it reads — [`Self::get`],
193/// [`Self::interop_get`], [`Self::interop_get_swr`], [`Self::set`] and
194/// [`Self::set_with_ttl`] — and L1 holds ciphertext. Such a client has no
195/// plaintext value path, and the `SecureCache` handle from `secure_cache()`
196/// reads and writes the same entries. [`Self::delete`] and [`Self::exists`]
197/// carry no value and are the same either way.
198#[derive(Clone)]
199pub struct CacheKit {
200    backend: SharedBackend,
201    default_ttl: Duration,
202    namespace: Option<String>,
203    max_payload_bytes: usize,
204    flight: SharedFlight,
205    /// Read counters behind [`Self::stats`]; the backend holds a
206    /// `MetricsProvider` over the same allocation.
207    counters: Arc<CacheCounters>,
208
209    /// The reliability stack's breaker, when configured — kept so
210    /// [`Self::circuit_state`] can read it through the type-erased backend.
211    #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
212    breaker: Option<Arc<crate::reliability::CircuitBreaker>>,
213
214    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
215    mutations: SharedMutations,
216
217    #[cfg(feature = "l1")]
218    pub(crate) l1: Option<crate::l1::L1Cache>,
219
220    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
221    pub(crate) swr_enabled: bool,
222
223    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
224    swr_threshold_ratio: f64,
225
226    #[cfg(feature = "encryption")]
227    encryption: Option<SharedEncryption>,
228}
229
230impl CacheKit {
231    /// Create a new builder.
232    pub fn builder() -> CacheKitBuilder {
233        CacheKitBuilder::default()
234    }
235
236    /// Build from environment variables via [`crate::config::CachekitConfig::from_env`].
237    ///
238    /// Creates a [`crate::backend::cachekitio::CachekitIO`] backend from the
239    /// config. Requires the `cachekitio` feature. With the `encryption`
240    /// feature on and `CACHEKIT_MASTER_KEY` set, the client encrypts every
241    /// value read and write (see [Encryption](CacheKit#encryption)); without
242    /// the feature, a set `CACHEKIT_MASTER_KEY` is a [`CachekitError::Config`].
243    #[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))]
244    pub fn from_env() -> Result<CacheKitBuilder, CachekitError> {
245        use crate::backend::cachekitio::CachekitIO;
246        use crate::config::CachekitConfig;
247
248        let config = CachekitConfig::from_env()?;
249
250        // The same environment encrypts on a build with the feature, so
251        // ignoring the key here would be a silent plaintext downgrade.
252        #[cfg(not(feature = "encryption"))]
253        if config.master_key.is_some() {
254            return Err(encryption_feature_missing(
255                "CacheKit::from_env() with CACHEKIT_MASTER_KEY set",
256            ));
257        }
258
259        let api_key_z = config
260            .api_key
261            .ok_or_else(|| CachekitError::Config("CACHEKIT_API_KEY is required".to_owned()))?;
262
263        let backend = CachekitIO::builder()
264            .api_key(api_key_z.as_str())
265            .api_url(config.api_url)
266            .build()
267            .map_err(|e| CachekitError::Config(e.to_string()))?;
268
269        #[cfg(not(feature = "unsync"))]
270        let shared: SharedBackend = std::sync::Arc::new(backend);
271        #[cfg(feature = "unsync")]
272        let shared: SharedBackend = std::rc::Rc::new(backend);
273
274        let mut builder = CacheKitBuilder::default()
275            .backend(shared)
276            .default_ttl(config.default_ttl)
277            .max_payload_bytes(config.max_payload_bytes)
278            .l1_capacity(config.l1_capacity);
279
280        if let Some(ns) = config.namespace.clone() {
281            builder = builder.namespace(ns);
282        }
283
284        // Wire up encryption if master key is configured
285        #[cfg(feature = "encryption")]
286        if let Some(ref master_key) = config.master_key {
287            let namespace = config.namespace.as_deref().unwrap_or("default");
288            let previous: Vec<&[u8]> = config
289                .previous_master_keys
290                .iter()
291                .map(|key| key.as_slice())
292                .collect();
293            builder = builder.encryption_from_hex_decoded(master_key, &previous, namespace)?;
294        }
295
296        Ok(builder)
297    }
298
299    // ── Namespacing ───────────────────────────────────────────────────────────
300
301    fn namespaced_key(&self, key: &str) -> String {
302        match &self.namespace {
303            Some(ns) => format!("{ns}:{key}"),
304            None => key.to_owned(),
305        }
306    }
307
308    /// Validate key and return the namespaced version.
309    fn resolve_key(&self, key: &str) -> Result<String, CachekitError> {
310        validate_key(key)?;
311        Ok(self.namespaced_key(key))
312    }
313
314    // ── L1 helpers ───────────────────────────────────────────────────────────
315
316    /// Try L1 cache first. Returns Some(bytes) on hit.
317    #[cfg(feature = "l1")]
318    fn l1_get(&self, full_key: &str) -> Option<Bytes> {
319        self.l1.as_ref().and_then(|l1| l1.get_shared(full_key))
320    }
321
322    /// Populate L1 from an L2 hit, for no longer than [`l1_backfill_ttl`] allows.
323    #[cfg(feature = "l1")]
324    fn l1_backfill(&self, full_key: &str, bytes: Bytes, freshness: Freshness) {
325        if let (Some(l1), Some(ttl)) = (&self.l1, l1_backfill_ttl(self.default_ttl, freshness)) {
326            l1.set_shared(full_key, bytes, ttl);
327        }
328    }
329
330    /// Write-through to L1 after a write the backend accepted. The copy lives
331    /// no longer than the TTL the write sent on the wire ([`ttl_wire_secs`]):
332    /// `spec/saas-api.md` forbids serving it past `min(local_ttl, effective_ttl)`,
333    /// and the wire truncates a fractional TTL from 1 s up (1.5 s goes as 1).
334    #[cfg(feature = "l1")]
335    fn l1_set(&self, full_key: &str, bytes: Bytes, ttl: Duration) {
336        if let Some(ref l1) = self.l1 {
337            let wire = Duration::from_secs(ttl_wire_secs(ttl));
338            l1.set_shared(full_key, bytes, ttl.min(wire));
339        }
340    }
341
342    /// Invalidate L1 entry.
343    #[cfg(feature = "l1")]
344    fn l1_delete(&self, full_key: &str) {
345        if let Some(ref l1) = self.l1 {
346            l1.delete(full_key);
347        }
348    }
349
350    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
351    async fn lock_l1_mutation(&self, full_key: &str) -> Option<crate::flight::MutationGuard> {
352        if self.l1.is_some() {
353            Some(self.mutations.lock(full_key).await)
354        } else {
355            None
356        }
357    }
358
359    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
360    async fn complete_swr_bytes(
361        &self,
362        key: &str,
363        bytes: Vec<u8>,
364        ttl: Duration,
365        token: SwrToken,
366    ) -> Result<bool, CachekitError> {
367        Self::validate_ttl(ttl)?;
368        self.check_payload_size(bytes.len())?;
369        let full_key = self.resolve_key(key)?;
370        let Some(mutation) = self.lock_l1_mutation(&full_key).await else {
371            return Ok(false);
372        };
373
374        // Check before touching L2. The mutation state lives independently of
375        // the moka entry, so hard expiry or capacity eviction does not look
376        // like an explicit set/delete and waste a valid origin result.
377        if !mutation.is_current(&token.state, token.version) {
378            return Ok(false);
379        }
380        let l1_bytes = Bytes::copy_from_slice(&bytes);
381        self.backend.set(&full_key, bytes, Some(ttl)).await?;
382        self.l1_set(&full_key, l1_bytes, ttl);
383        mutation.advance();
384        crate::metrics::trace_write(&full_key, ttl);
385        Ok(true)
386    }
387
388    #[cfg(not(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32"))))]
389    async fn complete_swr_bytes(
390        &self,
391        _key: &str,
392        _bytes: Vec<u8>,
393        _ttl: Duration,
394        _token: SwrToken,
395    ) -> Result<bool, CachekitError> {
396        // `SwrRead::Stale` is unreachable on this build, so there is no
397        // versioned entry a caller could legitimately complete.
398        Ok(false)
399    }
400
401    /// Rejects a zero TTL.
402    fn validate_ttl(ttl: Duration) -> Result<(), CachekitError> {
403        if ttl.is_zero() {
404            return Err(CachekitError::Config(
405                "TTL must be greater than zero".to_owned(),
406            ));
407        }
408        Ok(())
409    }
410
411    // ── Public operations ─────────────────────────────────────────────────────
412
413    /// Retrieve and deserialize a value stored under `key`.
414    ///
415    /// Returns `None` if the key does not exist.
416    /// Checks L1 cache before hitting the backend.
417    ///
418    /// # Errors
419    ///
420    /// On a client with encryption configured, returns
421    /// [`CachekitError::Encryption`] — not a miss — if the stored entry fails
422    /// decryption. The key's L1 copy is dropped first and the backend entry
423    /// is left in place, so the next read reaches the backend: a hit once the
424    /// entry is replaced with ciphertext this client can decrypt, a miss once
425    /// it expires or is deleted. A decrypted entry that does not decode as `T`
426    /// is [`CachekitError::Serialization`] naming only `T`, never quoting the
427    /// value.
428    pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, CachekitError> {
429        #[cfg(feature = "encryption")]
430        if let Some(secure) = self.encrypting() {
431            return secure.get(key).await;
432        }
433        match self.get_bytes(key).await? {
434            Some(bytes) => Ok(Some(serializer::deserialize(&bytes)?)),
435            None => Ok(None),
436        }
437    }
438
439    /// Retrieve and deserialize an interop-mode value stored under `key`.
440    ///
441    /// Identical to [`Self::get`] except the payload is decoded with
442    /// [`crate::interop::deserialize`]. Both consume exactly one MessagePack
443    /// document and reject trailing bytes (interop/v1 spec MUST); this one
444    /// also names a Python-SDK-internal CK frame with a specific diagnostic
445    /// instead of a generic trailing-bytes error.
446    ///
447    /// Use with keys from [`crate::interop::interop_key`] on a client
448    /// **without** a namespace prefix. There is no interop-specific write
449    /// method: [`Self::set`] already writes plain MessagePack (no ByteStorage
450    /// envelope), which is the interop value format. On a client with
451    /// encryption configured the stored bytes are AES-GCM ciphertext of that
452    /// MessagePack, decrypted here before decoding: in interop mode the
453    /// AES-GCM plaintext is the plain MessagePack value bytes, so the AAD
454    /// (v0x03, `format="msgpack"`, `compressed="False"`) verifies cross-SDK
455    /// unchanged.
456    ///
457    /// Unlike this read, [`Self::set`] / [`Self::set_with_ttl`] do not check
458    /// the client namespace: on a client with [`CacheKitBuilder::namespace`]
459    /// set they return `Ok` and store the entry under
460    /// `{namespace}:{interop_key}`, which no interop reader addresses. Interop
461    /// writes need a client without a namespace.
462    ///
463    /// # Errors
464    ///
465    /// Returns [`CachekitError::Config`] if the client was built with
466    /// [`CacheKitBuilder::namespace`]: the prefix
467    /// would rewrite the storage key to `{prefix}:{interop_key}`, which no
468    /// other SDK computes — every cross-SDK entry would silently miss. Interop
469    /// keys carry their own namespace segment; failing loudly here beats a
470    /// 100% miss rate that looks like a cold cache. With encryption
471    /// configured, an entry that fails decryption is
472    /// [`CachekitError::Encryption`], with the key's L1 copy dropped as for
473    /// [`Self::get`].
474    pub async fn interop_get<T: DeserializeOwned>(
475        &self,
476        key: &str,
477    ) -> Result<Option<T>, CachekitError> {
478        #[cfg(feature = "encryption")]
479        if let Some(secure) = self.encrypting() {
480            return secure.interop_get(key).await;
481        }
482        self.reject_namespaced_interop()?;
483        match self.get_bytes(key).await? {
484            Some(bytes) => Ok(Some(crate::interop::deserialize(&bytes)?)),
485            None => Ok(None),
486        }
487    }
488
489    /// Interop keys must reach the backend verbatim; a client namespace prefix
490    /// would silently produce storage keys no other SDK computes.
491    fn reject_namespaced_interop(&self) -> Result<(), CachekitError> {
492        match self.namespace {
493            None => Ok(()),
494            Some(_) => Err(CachekitError::Config(
495                "interop reads require a client without a namespace prefix: .namespace() \
496                 would store interop entries under {prefix}:{interop_key}, \
497                 which other SDKs never compute (interop keys already carry a namespace \
498                 segment) — use a dedicated non-namespaced client for interop entries"
499                    .to_owned(),
500            )),
501        }
502    }
503
504    /// Retrieve and deserialize an interop-mode value with SWR classification.
505    ///
506    /// Identical to [`Self::interop_get`] except an L1 hit is classified
507    /// against the client's stale-while-revalidate freshness window:
508    ///
509    /// - [`SwrRead::Fresh`] — L1 hit within `swr_threshold_ratio` of the
510    ///   entry's TTL (±10% jitter), or any L2 hit. Use directly.
511    /// - [`SwrRead::Stale`] — L1 hit past the threshold but **before hard
512    ///   expiry**: the value is returned without touching the backend or
513    ///   origin, and the caller should schedule exactly one background
514    ///   refresh. The `#[cachekit]` macro dedups it on the cold-miss
515    ///   [`Self::single_flight`] locks without waiting for another worker's
516    ///   fill: a refresh that finds either lock held, or whose lock call
517    ///   fails, stands down and the stale value keeps being served. The
518    ///   accompanying [`SwrToken`] makes completion conditional,
519    ///   so a newer set/delete always wins.
520    /// - [`SwrRead::Miss`] — nothing usable anywhere: normal blocking miss.
521    ///
522    /// A hard-expired L1 entry is a [`SwrRead::Miss`], never `Stale` — moka
523    /// drops entries at their TTL, so SWR cannot serve past hard expiry.
524    ///
525    /// With SWR disabled ([`CacheKitBuilder::swr_enabled`]`(false)`), without
526    /// the `l1` feature, on wasm32, or under `unsync`, this behaves exactly
527    /// like [`Self::interop_get`]: hits are `Fresh`, `Stale` is never
528    /// produced.
529    ///
530    /// On a client with encryption configured, staleness is judged on the L1
531    /// **ciphertext** entry (freshness metadata never exposes plaintext),
532    /// then the value is decrypted as for [`Self::interop_get`].
533    ///
534    /// # Errors
535    ///
536    /// Same as [`Self::interop_get`] (including the namespaced-client
537    /// rejection).
538    pub async fn interop_get_swr<T: DeserializeOwned>(
539        &self,
540        key: &str,
541    ) -> Result<SwrRead<T>, CachekitError> {
542        #[cfg(feature = "encryption")]
543        if let Some(secure) = self.encrypting() {
544            return secure.interop_get_swr(key).await;
545        }
546        self.reject_namespaced_interop()?;
547        match self.get_bytes_swr(key).await? {
548            SwrRead::Fresh(b) => Ok(SwrRead::Fresh(crate::interop::deserialize(&b)?)),
549            SwrRead::Stale(b, token) => Ok(SwrRead::Stale(crate::interop::deserialize(&b)?, token)),
550            SwrRead::Miss => Ok(SwrRead::Miss),
551        }
552    }
553
554    /// Fetch raw payload bytes with SWR classification: an L1 hit is split
555    /// into fresh vs stale against the configured freshness window; on L1
556    /// miss this defers to [`Self::get_bytes`] (L2 + backfill), whose hit is
557    /// always fresh.
558    async fn get_bytes_swr(&self, key: &str) -> Result<SwrRead<Bytes>, CachekitError> {
559        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
560        if self.swr_enabled {
561            if let Some(ref l1) = self.l1 {
562                let full_key = self.resolve_key(key)?;
563                match l1.get_with_swr_shared(&full_key, self.swr_threshold_ratio) {
564                    crate::l1::SharedSwrRead::Fresh(bytes) => {
565                        self.counters.record(ReadOutcome::L1Hit, &full_key);
566                        self.check_payload_size(bytes.len())?;
567                        return Ok(SwrRead::Fresh(bytes));
568                    }
569                    crate::l1::SharedSwrRead::Stale(_) => {
570                        // Token capture and the stale snapshot must be atomic
571                        // relative to explicit mutations. Re-check after
572                        // taking the per-key guard; a write may have landed
573                        // between the optimistic classification and here.
574                        let mutation = self.mutations.lock(&full_key).await;
575                        match l1.get_with_swr_shared(&full_key, self.swr_threshold_ratio) {
576                            crate::l1::SharedSwrRead::Fresh(bytes) => {
577                                self.counters.record(ReadOutcome::L1Hit, &full_key);
578                                self.check_payload_size(bytes.len())?;
579                                return Ok(SwrRead::Fresh(bytes));
580                            }
581                            crate::l1::SharedSwrRead::Stale(bytes) => {
582                                self.counters.record(ReadOutcome::L1Stale, &full_key);
583                                self.check_payload_size(bytes.len())?;
584                                let (state, version) = mutation.snapshot();
585                                return Ok(SwrRead::Stale(bytes, SwrToken { state, version }));
586                            }
587                            crate::l1::SharedSwrRead::Miss => {}
588                        }
589                    }
590                    // Absent or hard-expired: fall through to the normal
591                    // read path (the redundant L1 re-check there is a cheap
592                    // in-process miss).
593                    crate::l1::SharedSwrRead::Miss => {}
594                }
595            }
596        }
597
598        Ok(match self.get_bytes(key).await? {
599            Some(bytes) => SwrRead::Fresh(bytes),
600            None => SwrRead::Miss,
601        })
602    }
603
604    /// Fetch raw payload bytes for `key` (L1, then L2 with L1 backfill).
605    async fn get_bytes(&self, key: &str) -> Result<Option<Bytes>, CachekitError> {
606        let full_key = self.resolve_key(key)?;
607
608        // L1 hit
609        #[cfg(feature = "l1")]
610        if let Some(bytes) = self.l1_get(&full_key) {
611            self.counters.record(ReadOutcome::L1Hit, &full_key);
612            self.check_payload_size(bytes.len())?;
613            return Ok(Some(bytes));
614        }
615
616        // Serialize an L2 read/backfill with same-key writes. Re-check L1
617        // after taking the lock because another operation may have filled it
618        // between the optimistic read above and lock acquisition.
619        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
620        let _mutation = self.lock_l1_mutation(&full_key).await;
621
622        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
623        if let Some(bytes) = self.l1_get(&full_key) {
624            self.counters.record(ReadOutcome::L1Hit, &full_key);
625            self.check_payload_size(bytes.len())?;
626            return Ok(Some(bytes));
627        }
628
629        // L2 backend
630        let (bytes, freshness) = match self.backend.get_with_freshness(&full_key).await? {
631            Some(hit) => {
632                self.counters.record(ReadOutcome::L2Hit, &full_key);
633                hit
634            }
635            None => {
636                self.counters.record(ReadOutcome::Miss, &full_key);
637                return Ok(None);
638            }
639        };
640
641        self.check_payload_size(bytes.len())?;
642        // Takes over the Vec's allocation: no copy, and L1 shares it below.
643        let bytes = Bytes::from(bytes);
644
645        // Populate L1 on L2 hit, bounded by the cap and the server's freshness
646        #[cfg(feature = "l1")]
647        self.l1_backfill(&full_key, bytes.clone(), freshness);
648        #[cfg(not(feature = "l1"))]
649        let _ = freshness;
650
651        Ok(Some(bytes))
652    }
653
654    /// Serialize and store `value` under `key` using the client's default TTL.
655    ///
656    /// Encrypts first on a client with encryption configured — see
657    /// [`Self::set_with_ttl`].
658    pub async fn set<T: Serialize>(&self, key: &str, value: &T) -> Result<(), CachekitError> {
659        self.set_with_ttl(key, value, self.default_ttl).await
660    }
661
662    /// Serialize and store `value` under `key` with an explicit `ttl`.
663    ///
664    /// On a client with encryption configured the value is encrypted first:
665    /// the backend and L1 receive only the AES-256-GCM ciphertext, and the
666    /// payload limit applies to the ciphertext.
667    ///
668    /// A positive sub-second `ttl` is sent to the backend as 1 second.
669    ///
670    /// Returns [`CachekitError::Config`] if `ttl` is zero.
671    ///
672    /// The client namespace is not checked here: an interop key written on a
673    /// namespaced client lands under `{namespace}:{interop_key}`, which no
674    /// interop reader addresses — see [`Self::interop_get`].
675    pub async fn set_with_ttl<T: Serialize>(
676        &self,
677        key: &str,
678        value: &T,
679        ttl: Duration,
680    ) -> Result<(), CachekitError> {
681        #[cfg(feature = "encryption")]
682        if let Some(secure) = self.encrypting() {
683            return secure.set_with_ttl(key, value, ttl).await;
684        }
685        Self::validate_ttl(ttl)?;
686
687        let bytes = serializer::serialize(value)?;
688        self.check_payload_size(bytes.len())?;
689
690        let full_key = self.resolve_key(key)?;
691
692        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
693        let mutation = self.lock_l1_mutation(&full_key).await;
694
695        // Invalidate older refresh tokens before the first backend await, so
696        // cancellation cannot leave an applied/attempted write vulnerable to
697        // a stale background result.
698        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
699        if let Some(ref mutation) = mutation {
700            mutation.advance();
701        }
702
703        // L1 needs its own copy: the backend consumes the Vec.
704        #[cfg(feature = "l1")]
705        {
706            let l1_bytes = Bytes::copy_from_slice(&bytes);
707            self.backend.set(&full_key, bytes, Some(ttl)).await?;
708            self.l1_set(&full_key, l1_bytes, ttl);
709        }
710        #[cfg(not(feature = "l1"))]
711        {
712            self.backend.set(&full_key, bytes, Some(ttl)).await?;
713        }
714
715        crate::metrics::trace_write(&full_key, ttl);
716        Ok(())
717    }
718
719    /// Commit an SWR refresh only if its stale-read token is still current,
720    /// encrypted when the client has encryption configured. Macro plumbing;
721    /// ordinary writes use [`Self::set_with_ttl`].
722    #[doc(hidden)]
723    pub async fn __complete_swr_refresh<T: Serialize>(
724        &self,
725        key: &str,
726        value: &T,
727        ttl: Duration,
728        token: SwrToken,
729    ) -> Result<bool, CachekitError> {
730        #[cfg(feature = "encryption")]
731        if let Some(secure) = self.encrypting() {
732            return secure.__complete_swr_refresh(key, value, ttl, token).await;
733        }
734        let bytes = serializer::serialize(value)?;
735        self.complete_swr_bytes(key, bytes, ttl, token).await
736    }
737
738    /// Delete `key` and return `true` if it existed.
739    ///
740    /// `CachekitIO` and `WorkersCachekitIO` return `true` on every successful
741    /// delete, whether or not the key existed: the server does not report
742    /// existence on `DELETE`.
743    ///
744    /// Invalidates the L1 entry regardless of the backend result.
745    pub async fn delete(&self, key: &str) -> Result<bool, CachekitError> {
746        let full_key = self.resolve_key(key)?;
747
748        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
749        let mutation = self.lock_l1_mutation(&full_key).await;
750
751        // Invalidate before L1 changes or backend I/O so task cancellation
752        // cannot let an older refresh resurrect this key.
753        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
754        if let Some(ref mutation) = mutation {
755            mutation.advance();
756        }
757
758        // Invalidate L1 first so callers never read a stale value even if the
759        // backend delete fails partway through.
760        #[cfg(feature = "l1")]
761        self.l1_delete(&full_key);
762
763        let existed = self.backend.delete(&full_key).await?;
764        crate::metrics::trace_delete(&full_key, existed);
765        Ok(existed)
766    }
767
768    /// Return `true` if `key` exists without fetching the value.
769    ///
770    /// A live L1 entry answers without a backend call. That check does not
771    /// count as a use of the entry for L1 eviction; only reads do.
772    pub async fn exists(&self, key: &str) -> Result<bool, CachekitError> {
773        let full_key = self.resolve_key(key)?;
774
775        // Check L1 first — avoids a network round-trip for warm entries.
776        #[cfg(feature = "l1")]
777        if self.l1.as_ref().is_some_and(|l1| l1.contains(&full_key)) {
778            return Ok(true);
779        }
780
781        Ok(self.backend.exists(&full_key).await?)
782    }
783
784    // ── Observability ─────────────────────────────────────────────────────────
785
786    /// Live hit/miss counters for this client and all of its clones.
787    ///
788    /// Every value read — `get`, `interop_get`, their SWR variants, and the
789    /// [`SecureCache`] equivalents — is counted by where it was served from.
790    /// `exists` and reads that fail with a backend error are not counted. The
791    /// same counters feed the cachekit.io backends' `X-CacheKit-*` telemetry
792    /// headers, so for a backend used by this client alone, what you see here
793    /// is what the SaaS sees (a backend shared by several clients reports the
794    /// first client built over it — see [`Backend::attach_metrics`]).
795    ///
796    /// ```no_run
797    /// # fn example(cache: &cachekit::CacheKit) {
798    /// let stats = cache.stats();
799    /// let reads = stats.l1_hits + stats.l2_hits + stats.misses;
800    /// eprintln!("L1 hit rate {:.1}% over {reads} reads", stats.l1_hit_rate() * 100.0);
801    /// # }
802    /// ```
803    pub fn stats(&self) -> L1Stats {
804        self.counters.snapshot()
805    }
806
807    /// Number of entries currently held in L1, or `None` when L1 is disabled
808    /// (or the `l1` feature is off).
809    ///
810    /// Exact at the time of the call: moka's pending expirations and
811    /// evictions are applied first, which makes this a housekeeping call, not
812    /// a free counter read — poll it, don't put it on a hot path.
813    pub fn l1_entry_count(&self) -> Option<u64> {
814        #[cfg(feature = "l1")]
815        let count = self.l1.as_ref().map(crate::l1::L1Cache::entry_count);
816        #[cfg(not(feature = "l1"))]
817        let count = None;
818        count
819    }
820
821    /// Current circuit-breaker state, or `None` when this client has no
822    /// breaker (reliability disabled, or configured with
823    /// `circuit_breaker: None`).
824    ///
825    /// Reads the live state: an open circuit whose `open_timeout` has elapsed
826    /// reports [`CircuitState::HalfOpen`](crate::CircuitState::HalfOpen). With
827    /// the `tracing` feature every transition is also emitted on the
828    /// `cachekit::reliability` target — `warn` when the circuit opens, `info`
829    /// for half-open and closed.
830    #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
831    pub fn circuit_state(&self) -> Option<crate::reliability::CircuitState> {
832        self.breaker.as_ref().map(|breaker| breaker.state())
833    }
834
835    // ── Single-flight ─────────────────────────────────────────────────────────
836
837    /// Begin a cold-miss single-flight for `key` (see [`crate::flight`]).
838    ///
839    /// Call after a cache miss, before computing the value. Concurrent
840    /// in-process fills of the same key are collapsed to one; with the
841    /// `reliability` feature and a lock-capable backend (CachekitIO, Redis),
842    /// fills are also suppressed across processes via a distributed fill
843    /// lock. The `#[cachekit]` macro does this automatically.
844    ///
845    /// The key is namespaced like every cache operation but not validated —
846    /// this call is infallible; an invalid key simply fails later at the
847    /// actual cache operation.
848    pub async fn single_flight(&self, key: &str) -> crate::flight::SingleFlight {
849        let full_key = self.namespaced_key(key);
850        crate::flight::SingleFlight::acquire(&self.flight, &self.backend, &full_key).await
851    }
852
853    /// Begin the single-flight for an SWR background refresh, or `None` when
854    /// another worker already holds this key's in-process flight or
855    /// distributed fill lock, or the lock call fails. Unlike
856    /// [`Self::single_flight`] it never waits for another worker's fill.
857    /// Macro plumbing for `#[cachekit]` — not public API.
858    #[doc(hidden)]
859    pub async fn __refresh_flight(&self, key: &str) -> Option<crate::flight::SingleFlight> {
860        let full_key = self.namespaced_key(key);
861        crate::flight::SingleFlight::try_lead(&self.flight, &self.backend, &full_key).await
862    }
863
864    // ── Secure cache ─────────────────────────────────────────────────────────
865
866    /// Return a [`SecureCache`] handle that encrypts all values before storage.
867    ///
868    /// L1 stores **ciphertext** (not plaintext) to preserve the zero-knowledge
869    /// property across all cache layers.
870    ///
871    /// The handle encrypts exactly what this client's own value methods
872    /// already encrypt (see [Encryption](CacheKit#encryption)), and the two
873    /// read each other's entries. What it adds is the guarantee: code that
874    /// must never run unencrypted asks for the handle, which fails on a
875    /// client without encryption, and it carries the
876    /// [`previous_key_hits`](SecureCache::previous_key_hits) rotation signal.
877    ///
878    /// # Errors
879    /// Returns `CachekitError::Config` if no encryption layer is configured.
880    /// Configure encryption via [`CacheKitBuilder::encryption`] /
881    /// [`CacheKitBuilder::encryption_from_bytes`], or start from the
882    /// `CacheKit::secure(url, master_key_hex)` /
883    /// `CacheKit::secure_from_env(url)` preset (`redis` feature).
884    #[cfg(feature = "encryption")]
885    pub fn secure_cache(&self) -> Result<SecureCache<'_>, CachekitError> {
886        self.encrypting().ok_or_else(|| {
887            CachekitError::Config(
888                "encryption not configured: use the CacheKit::secure(url, master_key_hex) \
889                 preset, CacheKit::secure_from_env(url) to read the hex key from \
890                 CACHEKIT_MASTER_KEY, or call .encryption() on the builder"
891                    .to_owned(),
892            )
893        })
894    }
895
896    /// The encrypting handle when encryption is configured. Every value
897    /// method routes through it first, so a configured key leaves no
898    /// plaintext path (protocol `spec/intent-presets.md` § Encryption
899    /// Activation, rule 1).
900    #[cfg(feature = "encryption")]
901    fn encrypting(&self) -> Option<SecureCache<'_>> {
902        self.encryption.as_deref().map(|encryption| SecureCache {
903            client: self,
904            encryption,
905        })
906    }
907
908    // ── Private helpers ───────────────────────────────────────────────────────
909
910    fn check_payload_size(&self, size: usize) -> Result<(), CachekitError> {
911        if size > self.max_payload_bytes {
912            return Err(CachekitError::PayloadTooLarge {
913                size,
914                limit: self.max_payload_bytes,
915            });
916        }
917        Ok(())
918    }
919}
920
921// ── SecureCache ──────────────────────────────────────────────────────────────
922
923/// Encrypted cache handle returned by [`CacheKit::secure_cache()`].
924///
925/// All values are serialized, then encrypted with AES-256-GCM before storage.
926/// L1 stores ciphertext to maintain zero-knowledge guarantees. The client's
927/// own value methods route through these same methods once encryption is
928/// configured, so the handle and the client read each other's entries.
929#[cfg(feature = "encryption")]
930pub struct SecureCache<'a> {
931    client: &'a CacheKit,
932    encryption: &'a crate::encryption::EncryptionLayer,
933}
934
935#[cfg(feature = "encryption")]
936impl std::fmt::Debug for SecureCache<'_> {
937    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
938        f.debug_struct("SecureCache")
939            .field("tenant_id", &self.encryption.tenant_id())
940            .finish()
941    }
942}
943
944#[cfg(feature = "encryption")]
945impl SecureCache<'_> {
946    /// Rotation drain signal; see
947    /// [`EncryptionLayer::previous_key_hits`](crate::EncryptionLayer::previous_key_hits).
948    pub fn previous_key_hits(&self) -> Vec<u64> {
949        self.encryption.previous_key_hits()
950    }
951
952    /// Whether AES is hardware-accelerated on this host (informational); see
953    /// [`EncryptionLayer::hardware_acceleration_enabled`](crate::EncryptionLayer::hardware_acceleration_enabled).
954    pub fn hardware_acceleration_enabled(&self) -> bool {
955        self.encryption.hardware_acceleration_enabled()
956    }
957
958    /// Encrypt and store `value` under `key` using the client's default TTL.
959    pub async fn set<T: Serialize>(&self, key: &str, value: &T) -> Result<(), CachekitError> {
960        self.set_with_ttl(key, value, self.client.default_ttl).await
961    }
962
963    /// Encrypt and store `value` under `key` with an explicit `ttl`.
964    ///
965    /// The client namespace is not checked here: an interop key written on a
966    /// namespaced client lands under `{namespace}:{interop_key}`, which no
967    /// interop reader addresses — see [`CacheKit::interop_get`].
968    pub async fn set_with_ttl<T: Serialize>(
969        &self,
970        key: &str,
971        value: &T,
972        ttl: Duration,
973    ) -> Result<(), CachekitError> {
974        CacheKit::validate_ttl(ttl)?;
975        let (full_key, ciphertext) = self.seal(key, value)?;
976        // Size-check what is actually persisted (nonce + ciphertext + tag).
977        // The get paths check the stored ciphertext length, so checking the
978        // plaintext here would let a value within 28 bytes of the limit write
979        // successfully and then fail EVERY subsequent read with PayloadTooLarge.
980        self.client.check_payload_size(ciphertext.len())?;
981
982        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
983        let mutation = self.client.lock_l1_mutation(&full_key).await;
984
985        // Match the plain write path: invalidate older refresh tokens before
986        // the first backend await, including on cancellation or failure.
987        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
988        if let Some(ref mutation) = mutation {
989            mutation.advance();
990        }
991
992        // L1 needs its own copy: the backend consumes the Vec.
993        #[cfg(feature = "l1")]
994        {
995            let l1_bytes = Bytes::copy_from_slice(&ciphertext);
996            self.client
997                .backend
998                .set(&full_key, ciphertext, Some(ttl))
999                .await?;
1000            self.client.l1_set(&full_key, l1_bytes, ttl);
1001        }
1002        #[cfg(not(feature = "l1"))]
1003        {
1004            self.client
1005                .backend
1006                .set(&full_key, ciphertext, Some(ttl))
1007                .await?;
1008        }
1009
1010        crate::metrics::trace_write(&full_key, ttl);
1011        Ok(())
1012    }
1013
1014    /// Commit an encrypted SWR refresh only if its stale-read token is still
1015    /// current. Macro plumbing; ordinary writes use [`Self::set_with_ttl`].
1016    #[doc(hidden)]
1017    pub async fn __complete_swr_refresh<T: Serialize>(
1018        &self,
1019        key: &str,
1020        value: &T,
1021        ttl: Duration,
1022        token: SwrToken,
1023    ) -> Result<bool, CachekitError> {
1024        let (_, ciphertext) = self.seal(key, value)?;
1025        self.client
1026            .complete_swr_bytes(key, ciphertext, ttl, token)
1027            .await
1028    }
1029
1030    /// Serialize and encrypt `value` for `key`, returning the key this client
1031    /// passes to its backend (`.namespace()` prefix included) with the
1032    /// ciphertext. Every write encrypts here, so the AAD binds that key and an
1033    /// entry copied to another namespace fails to decrypt.
1034    fn seal<T: Serialize>(&self, key: &str, value: &T) -> Result<(String, Vec<u8>), CachekitError> {
1035        let full_key = self.client.resolve_key(key)?;
1036        let plaintext = serializer::serialize(value)?;
1037        let ciphertext = self.encryption.encrypt(&plaintext, &full_key)?;
1038        Ok((full_key, ciphertext))
1039    }
1040
1041    /// Retrieve, decrypt, and deserialize a value stored under `key`.
1042    ///
1043    /// Checks L1 (which holds ciphertext) before the backend.
1044    ///
1045    /// # Errors
1046    ///
1047    /// Returns [`CachekitError::Encryption`] if the stored entry fails
1048    /// decryption. The key's L1 copy is dropped first and the backend entry
1049    /// is left in place, so the next read reaches the backend: a hit once the
1050    /// entry is replaced with ciphertext this client can decrypt, a miss once
1051    /// it expires or is deleted. A decrypted entry that does not decode as `T`
1052    /// is [`CachekitError::Serialization`] naming only `T`: the decoder's own
1053    /// message would quote the plaintext.
1054    pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, CachekitError> {
1055        match self.get_plaintext(key).await? {
1056            Some(plaintext) => Ok(Some(redact_decode(serializer::deserialize(&plaintext))?)),
1057            None => Ok(None),
1058        }
1059    }
1060
1061    /// Retrieve, decrypt, and deserialize an interop-mode value stored under `key`.
1062    ///
1063    /// Identical to [`Self::get`] except the decrypted plaintext is decoded
1064    /// with [`crate::interop::deserialize`] — exactly one MessagePack document,
1065    /// trailing bytes rejected (interop/v1 spec MUST), as in [`Self::get`]. In interop mode the
1066    /// AES-GCM plaintext is the plain MessagePack value bytes, so the AAD
1067    /// (v0x03, `format="msgpack"`, `compressed="False"`) verifies cross-SDK
1068    /// unchanged.
1069    ///
1070    /// # Errors
1071    ///
1072    /// Returns [`CachekitError::Config`] on a namespace-prefixed client — see
1073    /// [`CacheKit::interop_get`] — and [`CachekitError::Encryption`] if the
1074    /// stored entry fails decryption, with the key's L1 copy dropped as for
1075    /// [`Self::get`].
1076    pub async fn interop_get<T: DeserializeOwned>(
1077        &self,
1078        key: &str,
1079    ) -> Result<Option<T>, CachekitError> {
1080        self.client.reject_namespaced_interop()?;
1081        match self.get_plaintext(key).await? {
1082            Some(plaintext) => Ok(Some(redact_decode(crate::interop::deserialize(
1083                &plaintext,
1084            ))?)),
1085            None => Ok(None),
1086        }
1087    }
1088
1089    /// Retrieve, decrypt, and deserialize an interop-mode value with SWR
1090    /// classification. The secure twin of [`CacheKit::interop_get_swr`]:
1091    /// staleness is judged on the L1 **ciphertext** entry (zero-knowledge is
1092    /// preserved — freshness metadata never exposes plaintext), then the
1093    /// value is decrypted and decoded per [`Self::interop_get`].
1094    ///
1095    /// # Errors
1096    ///
1097    /// Same as [`Self::interop_get`] — the secure path fails closed on every
1098    /// backend and decryption error.
1099    pub async fn interop_get_swr<T: DeserializeOwned>(
1100        &self,
1101        key: &str,
1102    ) -> Result<SwrRead<T>, CachekitError> {
1103        self.client.reject_namespaced_interop()?;
1104        match self.client.get_bytes_swr(key).await? {
1105            SwrRead::Fresh(ct) => Ok(SwrRead::Fresh(redact_decode(crate::interop::deserialize(
1106                &self.decrypt_or_evict(&ct, key)?,
1107            ))?)),
1108            SwrRead::Stale(ct, token) => Ok(SwrRead::Stale(
1109                redact_decode(crate::interop::deserialize(
1110                    &self.decrypt_or_evict(&ct, key)?,
1111                ))?,
1112                token,
1113            )),
1114            SwrRead::Miss => Ok(SwrRead::Miss),
1115        }
1116    }
1117
1118    /// Fetch ciphertext (L1, then L2 with L1 backfill) and decrypt it.
1119    ///
1120    /// Ciphertext retrieval delegates to [`CacheKit::get_bytes`], which returns
1121    /// the stored bytes untransformed — for a secure cache exactly the AES-GCM
1122    /// ciphertext, so decrypt receives the same bytes the backend holds.
1123    async fn get_plaintext(&self, key: &str) -> Result<Option<Vec<u8>>, CachekitError> {
1124        match self.client.get_bytes(key).await? {
1125            Some(ciphertext) => Ok(Some(self.decrypt_or_evict(&ciphertext, key)?)),
1126            None => Ok(None),
1127        }
1128    }
1129
1130    /// Decrypt ciphertext read for `key` against the key this client passes to
1131    /// its backend (`.namespace()` prefix included); on failure, drop the key's L1 copy before propagating the
1132    /// error.
1133    ///
1134    /// An L2 hit is backfilled into L1 before it is decrypted, so an entry
1135    /// that fails authentication would otherwise keep failing from L1 after
1136    /// the backend entry is fixed or deleted. The backend entry itself is
1137    /// left untouched as evidence.
1138    fn decrypt_or_evict(&self, ciphertext: &[u8], key: &str) -> Result<Vec<u8>, CachekitError> {
1139        let full_key = self.client.resolve_key(key)?;
1140        self.encryption
1141            .decrypt(ciphertext, &full_key)
1142            .inspect_err(|_| {
1143                #[cfg(feature = "l1")]
1144                self.client.l1_delete(&full_key);
1145            })
1146    }
1147
1148    /// Delete an encrypted key. Behaves identically to [`CacheKit::delete`].
1149    pub async fn delete(&self, key: &str) -> Result<bool, CachekitError> {
1150        self.client.delete(key).await
1151    }
1152
1153    /// Check if an encrypted key exists. Behaves identically to [`CacheKit::exists`].
1154    pub async fn exists(&self, key: &str) -> Result<bool, CachekitError> {
1155        self.client.exists(key).await
1156    }
1157}
1158
1159/// Decoding decrypted plaintext keeps the `Serialization` class, which
1160/// `#[cachekit]` reads as a miss, but replaces the decoder's message: serde
1161/// quotes the offending value (`invalid type: string "…", expected struct
1162/// …`), and that value is the plaintext encryption exists to protect
1163/// (CWE-532).
1164#[cfg(feature = "encryption")]
1165fn redact_decode<T>(decoded: Result<T, CachekitError>) -> Result<T, CachekitError> {
1166    decoded.map_err(|e| match e {
1167        CachekitError::Serialization(_) => CachekitError::Serialization(format!(
1168            "decrypted entry does not decode as {}",
1169            std::any::type_name::<T>()
1170        )),
1171        other => other,
1172    })
1173}
1174
1175// ── CacheKitBuilder ───────────────────────────────────────────────────────────
1176
1177/// Fluent builder for [`CacheKit`].
1178#[derive(Default)]
1179#[must_use]
1180pub struct CacheKitBuilder {
1181    backend: Option<SharedBackend>,
1182    default_ttl: Option<Duration>,
1183    namespace: Option<String>,
1184    max_payload_bytes: Option<usize>,
1185
1186    #[cfg(feature = "l1")]
1187    l1_capacity: Option<usize>,
1188
1189    #[cfg(feature = "l1")]
1190    no_l1: bool,
1191
1192    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1193    swr_enabled: Option<bool>,
1194
1195    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1196    swr_threshold_ratio: Option<f64>,
1197
1198    // pub(crate): the `secure` preset's key-path tests read the layer.
1199    #[cfg(feature = "encryption")]
1200    pub(crate) encryption: Option<SharedEncryption>,
1201
1202    #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1203    reliability: Option<crate::reliability::ReliabilityConfig>,
1204
1205    /// Retry deadlines for the backend `CacheKit::io` built; cleared by
1206    /// [`Self::backend`] so a replaced backend never inherits them.
1207    #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1208    pub(crate) retry_deadlines: Option<crate::reliability::RetryDeadlines>,
1209}
1210
1211#[cfg(not(feature = "encryption"))]
1212fn encryption_feature_missing(what: &str) -> CachekitError {
1213    CachekitError::Config(format!(
1214        "{what} needs the `encryption` cargo feature, which this build of \
1215         cachekit-rs was compiled without; enable it to encrypt"
1216    ))
1217}
1218
1219impl CacheKitBuilder {
1220    /// Set the storage backend.
1221    pub fn backend(mut self, backend: SharedBackend) -> Self {
1222        self.backend = Some(backend);
1223        #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1224        {
1225            self.retry_deadlines = None;
1226        }
1227        self
1228    }
1229
1230    /// Override the default TTL (used when no per-call TTL is specified).
1231    pub fn default_ttl(mut self, ttl: Duration) -> Self {
1232        self.default_ttl = Some(ttl);
1233        self
1234    }
1235
1236    /// Set a namespace prefix. All keys will be stored as `{namespace}:{key}`.
1237    pub fn namespace(mut self, ns: impl Into<String>) -> Self {
1238        self.namespace = Some(ns.into());
1239        self
1240    }
1241
1242    /// Set the maximum accepted payload size in bytes.
1243    pub fn max_payload_bytes(mut self, limit: usize) -> Self {
1244        self.max_payload_bytes = Some(limit);
1245        self
1246    }
1247
1248    /// Set the L1 cache capacity (max entries).
1249    #[cfg(feature = "l1")]
1250    pub fn l1_capacity(mut self, capacity: usize) -> Self {
1251        self.l1_capacity = Some(capacity);
1252        self
1253    }
1254
1255    /// Disable the L1 cache entirely.
1256    #[cfg(feature = "l1")]
1257    pub fn no_l1(mut self) -> Self {
1258        self.no_l1 = true;
1259        self
1260    }
1261
1262    /// Enable or disable L1 stale-while-revalidate (default: **enabled**,
1263    /// matching the Python and TypeScript SDKs).
1264    ///
1265    /// With SWR on, an L1 hit older than `swr_threshold_ratio` of its TTL is
1266    /// still served immediately, and the `#[cachekit]` macro schedules at
1267    /// most one background refresh (deduplicated on the
1268    /// [`CacheKit::single_flight`] locks, in-process and — on lock-capable
1269    /// backends — across processes). A refresh that finds either lock held,
1270    /// or whose lock call fails, stands down instead of waiting, and the
1271    /// stale value keeps being served. A hard-expired entry is never served:
1272    /// it falls through to a normal blocking miss.
1273    ///
1274    /// Native targets only: this knob does not exist on wasm32, under the
1275    /// `unsync` feature, or without `l1` — calling it there is a compile
1276    /// error rather than a silent no-op.
1277    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1278    pub fn swr_enabled(mut self, enabled: bool) -> Self {
1279        self.swr_enabled = Some(enabled);
1280        self
1281    }
1282
1283    /// Set the SWR freshness threshold as a fraction of each L1 entry's TTL
1284    /// (default: **0.5**, matching the Python and TypeScript SDKs).
1285    ///
1286    /// An entry is *fresh* until it has lived `ratio × TTL` (±10% jitter,
1287    /// drawn once when the entry is inserted, to de-synchronise refreshes
1288    /// across processes), then *stale* — served immediately with a background
1289    /// refresh — until hard expiry. Mirrors cachekit-py's
1290    /// `swr_threshold_ratio` semantics (elapsed-lifetime fraction). Must be in
1291    /// `(0.0, 1.0]`; validated at [`Self::build`].
1292    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1293    pub fn swr_threshold_ratio(mut self, ratio: f64) -> Self {
1294        self.swr_threshold_ratio = Some(ratio);
1295        self
1296    }
1297
1298    // Stubs for when the l1 feature is disabled — still compile cleanly.
1299    /// No-op: the `l1` feature is disabled, so there is no L1 to size.
1300    #[cfg(not(feature = "l1"))]
1301    pub fn l1_capacity(self, _capacity: usize) -> Self {
1302        self
1303    }
1304
1305    /// No-op: the `l1` feature is disabled, so L1 is already off.
1306    #[cfg(not(feature = "l1"))]
1307    pub fn no_l1(self) -> Self {
1308        self
1309    }
1310
1311    /// Wrap the backend in the reliability stack (retry with exponential
1312    /// backoff + jitter, circuit breaker, backpressure) — see
1313    /// [`crate::reliability`].
1314    ///
1315    /// Enabled by default with production settings by the `production`,
1316    /// `secure`, and `io` intent presets; off for `minimal` and for
1317    /// manually-built clients. To opt a preset out, pass
1318    /// [`ReliabilityConfig::disabled()`](crate::reliability::ReliabilityConfig::disabled)
1319    /// — a disabled config applies no wrapping at all.
1320    #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1321    pub fn reliability(mut self, config: crate::reliability::ReliabilityConfig) -> Self {
1322        self.reliability = Some(config);
1323        self
1324    }
1325
1326    /// Configure encryption from raw master key bytes and tenant ID.
1327    ///
1328    /// The master key must be exactly 32 raw bytes; anything else is a
1329    /// [`CachekitError::Config`]. Hex-encoded keys go to [`Self::encryption`]
1330    /// (or [`Self::encryption_with_previous`] to rotate) — their ASCII bytes
1331    /// are 64 bytes long and rejected here.
1332    /// Keys are derived per-tenant via HKDF-SHA256.
1333    ///
1334    /// The built client encrypts every value read and write, not only those
1335    /// through the [`secure_cache()`](CacheKit::secure_cache) handle; see
1336    /// [Encryption](CacheKit#encryption). Calling any encryption method
1337    /// again replaces the earlier configuration.
1338    ///
1339    /// Without the `encryption` cargo feature this method, and every other
1340    /// builder encryption method, returns [`CachekitError::Config`]: a build
1341    /// that cannot encrypt never accepts a key.
1342    #[cfg(feature = "encryption")]
1343    pub fn encryption_from_bytes(
1344        mut self,
1345        master_key: &[u8],
1346        tenant_id: &str,
1347    ) -> Result<Self, CachekitError> {
1348        let layer = crate::encryption::EncryptionLayer::new(master_key, tenant_id)?;
1349        self.encryption = Some(SharedEncryption::new(layer));
1350        Ok(self)
1351    }
1352
1353    /// Configure encryption with decrypt-only previous master keys for
1354    /// key rotation.
1355    ///
1356    /// Writes encrypt under `master_key`; reads attempt it first, then each
1357    /// key in `previous_keys` sequentially (attempt order = slice order).
1358    /// At most 3 previous keys; supplying more is a config error, never
1359    /// truncated. See [`crate::encryption::EncryptionLayer::with_previous_keys`].
1360    ///
1361    /// Every key, current and previous, must be exactly 32 raw bytes. For
1362    /// hex keys, use the hex counterpart, [`Self::encryption_with_previous`].
1363    ///
1364    /// Encrypts every value read and write, as for
1365    /// [`Self::encryption_from_bytes`].
1366    #[cfg(feature = "encryption")]
1367    pub fn encryption_from_bytes_with_previous(
1368        mut self,
1369        master_key: &[u8],
1370        previous_keys: &[&[u8]],
1371        tenant_id: &str,
1372    ) -> Result<Self, CachekitError> {
1373        let layer = crate::encryption::EncryptionLayer::with_previous_keys(
1374            master_key,
1375            previous_keys,
1376            tenant_id,
1377        )?;
1378        self.encryption = Some(SharedEncryption::new(layer));
1379        Ok(self)
1380    }
1381
1382    /// Configure encryption from a hex-encoded master key string.
1383    ///
1384    /// Hex-decodes with the same decoder as [`CacheKit::from_env`] and the
1385    /// `secure` preset. The key must decode to at least 32 bytes; use exactly
1386    /// 32 (64 hex chars), the only length every SDK accepts.
1387    ///
1388    /// Encrypts every value read and write, as for
1389    /// [`Self::encryption_from_bytes`].
1390    #[cfg(feature = "encryption")]
1391    pub fn encryption(self, hex_key: &str, tenant_id: &str) -> Result<Self, CachekitError> {
1392        let bytes = crate::config::decode_master_key_hex(hex_key, "master key")?;
1393        self.encryption_from_hex_decoded(&bytes, &[], tenant_id)
1394    }
1395
1396    /// Configure encryption from hex-encoded keys, with decrypt-only previous
1397    /// master keys for key rotation.
1398    ///
1399    /// The hex counterpart of [`Self::encryption_from_bytes_with_previous`].
1400    /// Every key, current and previous, is decoded as for [`Self::encryption`]
1401    /// and must decode to at least 32 bytes, so a key longer than 32 bytes
1402    /// that [`Self::encryption`] accepted can be listed as a previous key
1403    /// under the tenant it was written for (the env variables always derive
1404    /// for tenant `"default"`). Use exactly 32 bytes (64 hex chars) for new
1405    /// keys, the only length every SDK accepts.
1406    ///
1407    /// Writes encrypt under `hex_key`; reads attempt it first, then each key
1408    /// in `previous_hex_keys` sequentially (attempt order = slice order).
1409    /// At most 3 previous keys, and the current key must not be among them;
1410    /// either violation is a [`CachekitError::Config`], never truncated.
1411    ///
1412    /// ```no_run
1413    /// # fn demo(backend: cachekit::client::SharedBackend, k2_hex: &str, k1_hex: &str)
1414    /// #     -> Result<(), cachekit::CachekitError> {
1415    /// let cache = cachekit::CacheKit::builder()
1416    ///     .backend(backend)
1417    ///     .encryption_with_previous(k2_hex, &[k1_hex], "tenant-a")?
1418    ///     .build()?;
1419    /// # Ok(()) }
1420    /// ```
1421    ///
1422    /// Encrypts every value read and write, as for
1423    /// [`Self::encryption_from_bytes`].
1424    #[cfg(feature = "encryption")]
1425    pub fn encryption_with_previous(
1426        self,
1427        hex_key: &str,
1428        previous_hex_keys: &[&str],
1429        tenant_id: &str,
1430    ) -> Result<Self, CachekitError> {
1431        let current = crate::config::decode_master_key_hex(hex_key, "master key")?;
1432        let previous = previous_hex_keys
1433            .iter()
1434            .enumerate()
1435            .map(|(i, key)| {
1436                crate::config::decode_master_key_hex(key, &format!("previous master key {i}"))
1437            })
1438            .collect::<Result<Vec<_>, _>>()?;
1439        let previous: Vec<&[u8]> = previous.iter().map(|key| key.as_slice()).collect();
1440        self.encryption_from_hex_decoded(&current, &previous, tenant_id)
1441    }
1442
1443    /// Shared tail of the hex paths: keys already validated by
1444    /// `decode_master_key_hex`, so the `>= 32` floor applies, not the
1445    /// raw-bytes exactly-32 rule.
1446    #[cfg(feature = "encryption")]
1447    pub(crate) fn encryption_from_hex_decoded(
1448        mut self,
1449        master_key: &[u8],
1450        previous_keys: &[&[u8]],
1451        tenant_id: &str,
1452    ) -> Result<Self, CachekitError> {
1453        let layer = crate::encryption::EncryptionLayer::from_hex_decoded_keys(
1454            master_key,
1455            previous_keys,
1456            tenant_id,
1457        )?;
1458        self.encryption = Some(SharedEncryption::new(layer));
1459        Ok(self)
1460    }
1461
1462    // Without the `encryption` feature the builder methods still exist, so
1463    // feature-agnostic code compiles, but each one is an error: accepting a
1464    // key and building a plaintext client is the silent downgrade protocol
1465    // `spec/intent-presets.md` § Encryption Activation rule 1 forbids.
1466
1467    /// Always [`CachekitError::Config`]: this build has no `encryption`
1468    /// feature.
1469    #[cfg(not(feature = "encryption"))]
1470    pub fn encryption_from_bytes(
1471        self,
1472        _master_key: &[u8],
1473        _tenant_id: &str,
1474    ) -> Result<Self, CachekitError> {
1475        Err(encryption_feature_missing(".encryption_from_bytes()"))
1476    }
1477
1478    /// Always [`CachekitError::Config`]: this build has no `encryption`
1479    /// feature.
1480    #[cfg(not(feature = "encryption"))]
1481    pub fn encryption_from_bytes_with_previous(
1482        self,
1483        _master_key: &[u8],
1484        _previous_keys: &[&[u8]],
1485        _tenant_id: &str,
1486    ) -> Result<Self, CachekitError> {
1487        Err(encryption_feature_missing(
1488            ".encryption_from_bytes_with_previous()",
1489        ))
1490    }
1491
1492    /// Always [`CachekitError::Config`]: this build has no `encryption`
1493    /// feature.
1494    #[cfg(not(feature = "encryption"))]
1495    pub fn encryption(self, _hex_key: &str, _tenant_id: &str) -> Result<Self, CachekitError> {
1496        Err(encryption_feature_missing(".encryption()"))
1497    }
1498
1499    /// Always [`CachekitError::Config`]: this build has no `encryption`
1500    /// feature.
1501    #[cfg(not(feature = "encryption"))]
1502    pub fn encryption_with_previous(
1503        self,
1504        _hex_key: &str,
1505        _previous_hex_keys: &[&str],
1506        _tenant_id: &str,
1507    ) -> Result<Self, CachekitError> {
1508        Err(encryption_feature_missing(".encryption_with_previous()"))
1509    }
1510
1511    /// Finalise and build the [`CacheKit`] client.
1512    ///
1513    /// Returns an error if no backend was provided.
1514    pub fn build(self) -> Result<CacheKit, CachekitError> {
1515        let backend = self.backend.ok_or_else(|| {
1516            CachekitError::Config("a backend must be provided via .backend()".to_owned())
1517        })?;
1518
1519        // Validate namespace if provided
1520        if let Some(ref ns) = self.namespace {
1521            if ns.is_empty() {
1522                return Err(CachekitError::Config("namespace cannot be empty".into()));
1523            }
1524            if ns.len() > 255 {
1525                return Err(CachekitError::Config("namespace exceeds 255 bytes".into()));
1526            }
1527            if !ns.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
1528                return Err(CachekitError::Config(
1529                    "namespace must be ASCII printable".into(),
1530                ));
1531            }
1532        }
1533
1534        #[cfg(feature = "l1")]
1535        let l1 = if self.no_l1 {
1536            None
1537        } else {
1538            let capacity = self.l1_capacity.unwrap_or(1000);
1539            Some(crate::l1::L1Cache::new(capacity))
1540        };
1541
1542        // SWR defaults mirror the sibling SDKs: enabled, threshold ratio 0.5,
1543        // ratio validated in (0.0, 1.0] exactly like py's L1CacheConfig.
1544        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1545        let swr_threshold_ratio = {
1546            let ratio = self.swr_threshold_ratio.unwrap_or(0.5);
1547            if !(ratio > 0.0 && ratio <= 1.0) {
1548                return Err(CachekitError::Config(format!(
1549                    "swr_threshold_ratio must be in (0.0, 1.0]; got {ratio}"
1550                )));
1551            }
1552            ratio
1553        };
1554
1555        // Hand the backend a live view of this client's read counters so the
1556        // SaaS telemetry headers report real numbers with no user plumbing.
1557        // Attached to the raw backend, before the reliability decorator, so
1558        // no forwarding is needed; a provider set on the backend's own
1559        // builder wins (see `Backend::attach_metrics`).
1560        #[cfg(feature = "l1")]
1561        let l1_enabled = l1.is_some();
1562        #[cfg(not(feature = "l1"))]
1563        let l1_enabled = false;
1564        let counters = Arc::new(CacheCounters::new(l1_enabled));
1565        backend.attach_metrics(counters.provider());
1566
1567        // Apply the reliability stack last so it decorates the final backend.
1568        // A disabled config is the documented opt-out: skip the (no-op)
1569        // decorator entirely. The layer check lives on ReliabilityConfig
1570        // itself so a future layer can't be missed here (this gate
1571        // shipped that exact bug once already).
1572        #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1573        let (backend, breaker) = match self.reliability {
1574            Some(config) if !config.is_disabled() => {
1575                crate::reliability::wrap_reliable(backend, config, self.retry_deadlines)
1576            }
1577            _ => (backend, None),
1578        };
1579
1580        Ok(CacheKit {
1581            backend,
1582            default_ttl: self.default_ttl.unwrap_or(Duration::from_secs(300)),
1583            namespace: self.namespace,
1584            max_payload_bytes: self.max_payload_bytes.unwrap_or(5 * 1024 * 1024),
1585            flight: SharedFlight::default(),
1586            counters,
1587
1588            #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1589            breaker,
1590
1591            #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1592            mutations: SharedMutations::default(),
1593
1594            #[cfg(feature = "l1")]
1595            l1,
1596
1597            #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1598            swr_enabled: self.swr_enabled.unwrap_or(true),
1599
1600            #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1601            swr_threshold_ratio,
1602
1603            #[cfg(feature = "encryption")]
1604            encryption: self.encryption,
1605        })
1606    }
1607}
1608
1609#[cfg(all(test, feature = "l1"))]
1610mod backfill_ttl_tests {
1611    use std::time::Duration;
1612
1613    use super::l1_backfill_ttl;
1614    use crate::backend::Freshness;
1615
1616    const DEFAULT_TTL: Duration = Duration::from_secs(300);
1617
1618    fn fresh_for(secs: u64) -> Freshness {
1619        Freshness {
1620            is_stale: false,
1621            fresh_for: Some(Duration::from_secs(secs)),
1622        }
1623    }
1624
1625    /// (e) No header: today's `min(default_ttl, 30 s)`, unchanged.
1626    #[test]
1627    fn absent_header_keeps_the_30s_cap() {
1628        let absent = Freshness::default();
1629        assert_eq!(
1630            l1_backfill_ttl(DEFAULT_TTL, absent),
1631            Some(Duration::from_secs(30))
1632        );
1633        assert_eq!(
1634            l1_backfill_ttl(Duration::from_secs(10), absent),
1635            Some(Duration::from_secs(10))
1636        );
1637    }
1638
1639    #[test]
1640    fn fresh_for_bounds_but_never_lifts_the_cap() {
1641        assert_eq!(
1642            l1_backfill_ttl(DEFAULT_TTL, fresh_for(5)),
1643            Some(Duration::from_secs(5))
1644        );
1645        assert_eq!(
1646            l1_backfill_ttl(DEFAULT_TTL, fresh_for(3600)),
1647            Some(Duration::from_secs(30))
1648        );
1649    }
1650
1651    #[test]
1652    fn stale_or_zero_forbids_the_backfill() {
1653        assert_eq!(l1_backfill_ttl(DEFAULT_TTL, fresh_for(0)), None);
1654        let stale = |fresh_for| Freshness {
1655            is_stale: true,
1656            fresh_for,
1657        };
1658        assert_eq!(l1_backfill_ttl(DEFAULT_TTL, stale(None)), None);
1659        assert_eq!(
1660            l1_backfill_ttl(DEFAULT_TTL, stale(Some(Duration::from_secs(60)))),
1661            None
1662        );
1663    }
1664}
1665
1666// SWR staleness exists only on native builds with L1 (see tests/swr_tests.rs).
1667#[cfg(all(
1668    test,
1669    feature = "encryption",
1670    feature = "l1",
1671    not(feature = "unsync"),
1672    not(target_arch = "wasm32")
1673))]
1674#[allow(clippy::unwrap_used)] // test-only: a failed setup step should panic loudly
1675mod secure_l1_tests {
1676    use std::time::Duration;
1677
1678    use super::{CacheKit, SharedBackend, SwrRead};
1679    use crate::backend::MemoryBackend;
1680    use crate::error::CachekitError;
1681
1682    /// The Stale arm reads straight from L1, and every public write on an
1683    /// encrypted client stores decryptable ciphertext there, so the
1684    /// undecryptable bytes are planted in L1 directly.
1685    #[tokio::test]
1686    async fn secure_interop_get_swr_evicts_stale_l1_after_decrypt_failure() {
1687        let backend = MemoryBackend::default();
1688        let shared: SharedBackend = std::sync::Arc::new(backend.clone());
1689        let client = CacheKit::builder()
1690            .backend(shared)
1691            .l1_capacity(100)
1692            .swr_threshold_ratio(0.001)
1693            .encryption_from_bytes(&[7u8; 32], "test-tenant")
1694            .unwrap()
1695            .build()
1696            .unwrap();
1697        client.l1_set(
1698            "poisoned-stale",
1699            bytes::Bytes::from_static(b"not ciphertext"),
1700            Duration::from_secs(60),
1701        );
1702        // Real time, not tokio's paused clock: L1 ages entries by std
1703        // Instant. threshold = 0.001 × 60 s = 60 ms (±10%); hard expiry 60 s.
1704        tokio::time::sleep(Duration::from_millis(200)).await;
1705
1706        let secure = client.secure_cache().unwrap();
1707        let err = secure
1708            .interop_get_swr::<String>("poisoned-stale")
1709            .await
1710            .unwrap_err();
1711        assert!(matches!(err, CachekitError::Encryption(_)), "got: {err:?}");
1712        // Served from L1, so the entry had not hard-expired into the L2 path.
1713        let stats = client.stats();
1714        assert_eq!((stats.l1_hits, stats.l2_hits), (1, 0), "{stats:?}");
1715
1716        // Nothing in the backend: a surviving L1 copy would keep failing; an
1717        // evicted one yields a miss.
1718        assert!(backend.store.lock().await.is_empty());
1719        assert!(matches!(
1720            secure.interop_get_swr::<String>("poisoned-stale").await,
1721            Ok(SwrRead::Miss)
1722        ));
1723    }
1724}