batpak 0.9.0

Event sourcing with causal graphs and caller-defined gates. Sync API, no async runtime.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
//! Per-scope symmetric key material for opt-in payload encryption (crypto-shred).
//!
//! This module is a *mechanism*, not a policy. A [`KeyStore`] holds one
//! 256-bit symmetric key per [`KeyScope`]; encrypting a payload under its
//! scope's key and later [`destroy`](KeyStore::destroy)-ing that key renders
//! the ciphertext permanently unrecoverable (crypto-shred). batpak only ever
//! observes "the key for scope X was created / used / destroyed" — never any
//! meaning attached to a scope. The scope granularity ([`KeyScopeGranularity`])
//! is a purely structural choice about which events share a key.
//!
//! The AEAD is XChaCha20-Poly1305 (a pure-Rust construction with a 192-bit
//! nonce and 128-bit authentication tag). Key and nonce bytes are drawn from
//! the OS CSPRNG; no non-cryptographic PRNG is ever used for key material.
//!
//! The keyset is durably persisted with cold-start rehydration (see the
//! `persist` child module) and wired into the append/read payload paths: writes
//! seal under the scope key (`write/writer/encrypt.rs`), reads decrypt.
//!
//! # Durability ordering (the fence)
//!
//! `persist` gives the keyset a [`KeyStore::flush`]. The append path flushes a
//! freshly-minted key DURABLY **before** the data it encrypts is acknowledged
//! durable. If that order were inverted, a crash landing between "append is
//! durable" and "key is durable" would leave a durable ciphertext whose key
//! never reached disk — permanently unrecoverable *live* data (a spontaneous,
//! unintended crypto-shred). The fence enforces flush-before-frame, so no crash
//! window can order data-durable ahead of key-durable under any sync mode.

use crate::coordinate::Coordinate;
use crate::event::EventKind;
use crate::id::{EntityIdType, EventId};
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{XChaCha20Poly1305, XNonce};
use std::collections::btree_map::{BTreeMap, Entry};
use std::fmt;
use zeroize::Zeroizing;

/// Byte length of a symmetric payload key (256-bit).
const KEY_LEN: usize = 32;
/// Byte length of an XChaCha20-Poly1305 nonce (192-bit).
const NONCE_LEN: usize = 24;

/// How coarsely payload keys are partitioned — i.e. which events share a key,
/// and therefore what a single [`destroy`](KeyStore::destroy) shreds.
///
/// Each variant is a neutral structural choice; batpak attaches no meaning to
/// the resulting partitions.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum KeyScopeGranularity {
    /// One key per entity: destroying it shreds every payload written for that
    /// entity, across all kinds. The default granularity.
    #[default]
    PerEntity,
    /// One key per event-kind category (the high 4 bits of an [`EventKind`]):
    /// destroying it shreds every payload whose kind falls in that category.
    PerCategory,
    /// One key per full event kind (category plus type id): destroying it
    /// shreds every payload of exactly that kind.
    PerTypeId,
    /// One key per individual event: destroying it shreds exactly that event's
    /// payload and nothing else — the finest granularity.
    PerEvent,
}

/// The opaque identity a payload key is filed under.
///
/// A `KeyScope` is derived deterministically and canonically from a
/// [`KeyScopeGranularity`] plus an event's coordinate, kind, and id via
/// [`scope_for`]. Its internal byte representation is private; callers treat it
/// only as an opaque, comparable, orderable handle.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct KeyScope(Box<[u8]>);

impl fmt::Debug for KeyScope {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("KeyScope(0x")?;
        for byte in self.0.iter() {
            write!(f, "{byte:02x}")?;
        }
        f.write_str(")")
    }
}

// Stable scope-derivation discriminants: the first byte of every [`KeyScope`],
// so two granularities never collide and the wire byte never silently tracks a
// source-order change. Shared by [`scope_for`] (the write/read seams) and
// [`KeyScopeGranularity::resolve_shred_scope`] (the erasure selector) so the
// two can never drift out of byte-agreement.
const SCOPE_DISC_PER_ENTITY: u8 = 0x01;
const SCOPE_DISC_PER_CATEGORY: u8 = 0x02;
const SCOPE_DISC_PER_TYPE_ID: u8 = 0x03;
const SCOPE_DISC_PER_EVENT: u8 = 0x04;

fn scope_per_entity(entity: &str) -> KeyScope {
    let mut bytes = Vec::with_capacity(1 + entity.len());
    bytes.push(SCOPE_DISC_PER_ENTITY);
    bytes.extend_from_slice(entity.as_bytes());
    KeyScope(bytes.into_boxed_slice())
}

fn scope_per_category(category: u8) -> KeyScope {
    KeyScope(vec![SCOPE_DISC_PER_CATEGORY, category].into_boxed_slice())
}

fn scope_per_type_id(kind_raw: u16) -> KeyScope {
    let mut bytes = Vec::with_capacity(3);
    bytes.push(SCOPE_DISC_PER_TYPE_ID);
    bytes.extend_from_slice(&kind_raw.to_be_bytes());
    KeyScope(bytes.into_boxed_slice())
}

fn scope_per_event(event_id: u128) -> KeyScope {
    let mut bytes = Vec::with_capacity(17);
    bytes.push(SCOPE_DISC_PER_EVENT);
    bytes.extend_from_slice(&event_id.to_be_bytes());
    KeyScope(bytes.into_boxed_slice())
}

/// Derive the [`KeyScope`] an event's payload key is filed under.
///
/// Deterministic and canonical: the same inputs always yield byte-identical
/// scopes, and two granularities never collide (each derivation is prefixed
/// with a distinct discriminant). Only the field relevant to the chosen
/// granularity contributes to the identity.
#[must_use]
pub fn scope_for(
    granularity: KeyScopeGranularity,
    coordinate: &Coordinate,
    event_kind: EventKind,
    event_id: EventId,
) -> KeyScope {
    match granularity {
        KeyScopeGranularity::PerEntity => scope_per_entity(coordinate.entity()),
        KeyScopeGranularity::PerCategory => scope_per_category(event_kind.category()),
        KeyScopeGranularity::PerTypeId => scope_per_type_id(event_kind.as_raw_u16()),
        KeyScopeGranularity::PerEvent => scope_per_event(event_id.as_u128()),
    }
}

/// The selector that names WHICH scope's key an erasure destroys, matched to a
/// store's configured [`KeyScopeGranularity`].
///
/// Crypto-shred is per-SCOPE-KEY, and the scope partition depends on the
/// configured granularity, so the ergonomic selector differs per granularity: an
/// entity coordinate addresses a `PerEntity` scope, an [`EventKind`] addresses a
/// `PerCategory`/`PerTypeId` scope, and an [`EventId`] addresses a `PerEvent`
/// scope. A selector that cannot address the configured granularity is a typed
/// mismatch (it never silently reinterprets one granularity's selector as
/// another's) — see `KeyScopeGranularity::resolve_shred_scope`.
#[derive(Clone, Copy, Debug)]
pub enum ShredScope<'a> {
    /// Erase the `PerEntity` scope keyed by a coordinate's entity id.
    Entity(&'a Coordinate),
    /// Erase the `PerCategory` (category nibble) or `PerTypeId` (full kind)
    /// scope keyed by an event kind.
    Kind(EventKind),
    /// Erase the `PerEvent` scope keyed by a single event id.
    Event(EventId),
}

impl ShredScope<'_> {
    /// A stable, non-secret label for the selector variant, used only to render
    /// the typed [`crate::store::StoreError::ShredSelectorMismatch`]. Never
    /// carries key material.
    pub(crate) fn label(&self) -> &'static str {
        match self {
            ShredScope::Entity(_) => "Entity",
            ShredScope::Kind(_) => "Kind",
            ShredScope::Event(_) => "Event",
        }
    }
}

impl KeyScopeGranularity {
    /// Resolve a [`ShredScope`] selector into the [`KeyScope`] whose key an
    /// erasure would destroy — but ONLY when the selector addresses THIS
    /// granularity. A selector that cannot address this granularity returns
    /// `None` (the caller raises a typed mismatch error), so an entity selector
    /// can never be silently reinterpreted as a per-event scope, or vice versa.
    ///
    /// Reuses the same scope builders as [`scope_for`], so the resolved erasure
    /// scope is byte-identical to the scope a matching append sealed its payload
    /// under — the key the erasure removes is exactly the key those payloads
    /// were encrypted with.
    pub(crate) fn resolve_shred_scope(self, selector: &ShredScope<'_>) -> Option<KeyScope> {
        match (self, selector) {
            (KeyScopeGranularity::PerEntity, ShredScope::Entity(coordinate)) => {
                Some(scope_per_entity(coordinate.entity()))
            }
            (KeyScopeGranularity::PerCategory, ShredScope::Kind(kind)) => {
                Some(scope_per_category(kind.category()))
            }
            (KeyScopeGranularity::PerTypeId, ShredScope::Kind(kind)) => {
                Some(scope_per_type_id(kind.as_raw_u16()))
            }
            (KeyScopeGranularity::PerEvent, ShredScope::Event(event_id)) => {
                Some(scope_per_event(event_id.as_u128()))
            }
            _ => None,
        }
    }
}

impl KeyScope {
    /// Borrow the opaque scope bytes.
    ///
    /// Stage C stamps these into the event header ([`keyscope_id`]) so the read
    /// path can rebuild the exact scope a ciphertext's key is filed under. The
    /// bytes are non-secret (derived from coordinates/kinds/ids).
    ///
    /// [`keyscope_id`]: crate::event::PayloadEncryption::keyscope_id
    #[must_use]
    pub(crate) fn as_bytes(&self) -> &[u8] {
        &self.0
    }

    /// Reconstruct a scope from its raw bytes (the read-path inverse of
    /// [`as_bytes`](Self::as_bytes)). The bytes are treated as opaque — no
    /// structural validation is performed, mirroring the fact that a scope is
    /// only ever compared for equality against the keyset's live entries.
    #[must_use]
    pub(crate) fn from_bytes(bytes: Vec<u8>) -> Self {
        KeyScope(bytes.into_boxed_slice())
    }
}

/// Canonical associated-data (AAD) binding a sealed payload to the stable
/// identity of the event it belongs to.
///
/// The AAD is authenticated (not encrypted) by the AEAD, so `open` only
/// succeeds when the ciphertext is presented under the SAME identity it was
/// sealed with. Binding `coordinate + kind + event_id` makes a ciphertext
/// non-relocatable: moving a `{nonce, ciphertext}` pair onto any other event
/// (different entity, scope, kind, or event id) changes the AAD and fails
/// authentication (tamper detected), so a ciphertext can never be replayed
/// against a different event.
///
/// The encoding is explicit and length-prefixed — NOT MessagePack — so the
/// write path (writer) and the read path (`read_api`) reconstruct byte-identical
/// AAD from fields that are all present in the frame header on read
/// (`event_id`, `event_kind`) plus the frame's entity/scope strings
/// (`coordinate`). `global_sequence` is deliberately NOT bound: it is assigned
/// by the writer and is absent from the frame header, so the read path could
/// not reconstruct it.
#[must_use]
pub(crate) fn payload_aad(
    coordinate: &Coordinate,
    event_kind: EventKind,
    event_id: EventId,
) -> Vec<u8> {
    // Version byte, then length-prefixed entity + scope, then kind, then id.
    let entity = coordinate.entity().as_bytes();
    let scope = coordinate.scope().as_bytes();
    let mut aad = Vec::with_capacity(1 + 4 + entity.len() + 4 + scope.len() + 2 + 16);
    aad.push(0x01);
    // Coordinate entity/scope are length-bounded well under u32::MAX at
    // construction, so the saturation never triggers; it keeps the length prefix
    // a fixed 4 bytes and identical on the write and read sides.
    let entity_len = u32::try_from(entity.len()).unwrap_or(u32::MAX);
    let scope_len = u32::try_from(scope.len()).unwrap_or(u32::MAX);
    aad.extend_from_slice(&entity_len.to_le_bytes());
    aad.extend_from_slice(entity);
    aad.extend_from_slice(&scope_len.to_le_bytes());
    aad.extend_from_slice(scope);
    aad.extend_from_slice(&event_kind.as_raw_u16().to_le_bytes());
    aad.extend_from_slice(&event_id.as_u128().to_be_bytes());
    aad
}

/// A failure from the key store or its AEAD primitives.
///
/// Deliberately opaque: an [`open`](PayloadKey::open) failure reveals only that
/// authentication failed, never why, so it cannot serve as a decryption oracle.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyStoreError {
    /// The OS CSPRNG failed to produce key material.
    Rng,
    /// AEAD cipher construction rejected the key length (defensive; a stored
    /// key is always exactly 256 bits).
    KeyInit,
    /// Authenticated encryption (sealing) failed.
    Seal,
    /// Authenticated decryption (opening) failed — wrong key, nonce, associated
    /// data, or a tampered ciphertext. No further detail is exposed.
    Open,
}

impl fmt::Display for KeyStoreError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let message = match self {
            Self::Rng => "CSPRNG failed to produce key material",
            Self::KeyInit => "AEAD key initialization rejected the key length",
            Self::Seal => "authenticated encryption failed",
            Self::Open => "authenticated decryption failed",
        };
        f.write_str(message)
    }
}

impl std::error::Error for KeyStoreError {}

/// A 256-bit symmetric payload key.
///
/// The raw bytes are held in a [`Zeroizing`] buffer, so they are wiped from
/// memory when the key is dropped, and they never appear in any `Debug` output.
/// The only way to use a key is through [`seal`](Self::seal) /
/// [`open`](Self::open); the bytes are never exposed.
pub struct PayloadKey(Zeroizing<[u8; KEY_LEN]>);

impl fmt::Debug for PayloadKey {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // Never render key bytes — only an opaque marker.
        f.debug_struct("PayloadKey").finish_non_exhaustive()
    }
}

impl PayloadKey {
    /// Mint a fresh key from the OS CSPRNG.
    fn generate() -> Result<Self, KeyStoreError> {
        // Fill the secret in place inside the zeroizing buffer so no plaintext
        // key copy is ever left on the stack.
        let mut key: Zeroizing<[u8; KEY_LEN]> = Zeroizing::new([0u8; KEY_LEN]);
        getrandom::fill(key.as_mut_slice()).map_err(|_| KeyStoreError::Rng)?;
        Ok(Self(key))
    }

    fn cipher(&self) -> Result<XChaCha20Poly1305, KeyStoreError> {
        XChaCha20Poly1305::new_from_slice(self.0.as_slice()).map_err(|_| KeyStoreError::KeyInit)
    }

    /// Seal `plaintext` under this key with a 24-byte `nonce`, binding `aad`
    /// (associated data authenticated but not encrypted). Returns the
    /// ciphertext with its appended authentication tag.
    ///
    /// The caller owns nonce uniqueness: a nonce must never repeat under the
    /// same key. XChaCha20-Poly1305's 192-bit nonce makes random nonces safe.
    ///
    /// # Errors
    /// Returns [`KeyStoreError::Seal`] if the AEAD encryption fails, or
    /// [`KeyStoreError::KeyInit`] if cipher construction rejects the key.
    pub fn seal(
        &self,
        nonce: &[u8; NONCE_LEN],
        aad: &[u8],
        plaintext: &[u8],
    ) -> Result<Vec<u8>, KeyStoreError> {
        let cipher = self.cipher()?;
        let nonce = XNonce::from_slice(nonce);
        cipher
            .encrypt(
                nonce,
                Payload {
                    msg: plaintext,
                    aad,
                },
            )
            .map_err(|_| KeyStoreError::Seal)
    }

    /// Open `ciphertext` sealed under this key with the same `nonce` and `aad`.
    /// Returns the recovered plaintext.
    ///
    /// # Errors
    /// Returns [`KeyStoreError::Open`] if authentication fails (wrong key,
    /// nonce, associated data, or tampered ciphertext), or
    /// [`KeyStoreError::KeyInit`] if cipher construction rejects the key.
    pub fn open(
        &self,
        nonce: &[u8; NONCE_LEN],
        aad: &[u8],
        ciphertext: &[u8],
    ) -> Result<Vec<u8>, KeyStoreError> {
        let cipher = self.cipher()?;
        let nonce = XNonce::from_slice(nonce);
        cipher
            .decrypt(
                nonce,
                Payload {
                    msg: ciphertext,
                    aad,
                },
            )
            .map_err(|_| KeyStoreError::Open)
    }
}

/// An in-memory store of per-scope payload keys.
///
/// Keys are minted lazily on first use and destroyed on demand. Destroying a
/// scope's key is the crypto-shred primitive: it zeroizes and removes the key,
/// after which any payload sealed under that scope can never be opened again.
pub struct KeyStore {
    keys: BTreeMap<KeyScope, PayloadKey>,
    granularity: KeyScopeGranularity,
    /// `true` when the in-memory keyset has diverged from the last durable flush
    /// — set via [`mark_dirty`](Self::mark_dirty) (the writer calls it whenever an
    /// append mints a fresh scope key) or by [`destroy`](Self::destroy), and
    /// cleared ONLY by a successful [`flush`](Self::flush). The append durability
    /// fence flushes whenever this is set, so a mint whose fence-flush FAILED (the
    /// key is resident in memory but never reached disk) forces the NEXT ciphertext
    /// write to re-flush before it can ack, instead of trusting the resident key
    /// and skipping the fence — which would otherwise leave a durable ciphertext
    /// whose key is on disk nowhere (a silent, unintended crypto-shred of live
    /// data).
    dirty: bool,
    /// `true` when this keyset was rehydrated from an ABSENT keyset file — the
    /// file did not exist at open, so no keys were ever loaded. Distinguishes a
    /// lost/withheld keyset (a keys-excluded snapshot opened without its
    /// out-of-band keyset) from a deliberate per-scope crypto-shred: with this set, a
    /// missing scope key on read is reported as [`StoreError::KeysetMissing`]
    /// rather than a Shredded lookalike (D24). Cleared on the first key mint
    /// (`get_or_create`): once this store holds a key of its own it is not a
    /// lost-keyset store, so a later missing scope key reads as a deliberate
    /// shred — matching a keyset rehydrated from a present file, and stable across
    /// restarts. Accepted edge: a restored keys-excluded copy appended to BEFORE
    /// any read reports its pre-existing keyless events as `Shredded` rather than
    /// `KeysetMissing` (an odd operator sequence, consistent across restarts).
    absent_on_load: bool,
}

impl KeyStore {
    /// Create an empty key store with the given scope granularity.
    #[must_use]
    pub fn new(granularity: KeyScopeGranularity) -> Self {
        Self {
            keys: BTreeMap::new(),
            granularity,
            dirty: false,
            absent_on_load: false,
        }
    }

    /// Create an empty key store rehydrated from an ABSENT keyset file (the file
    /// did not exist at open). Reads of a pre-existing encrypted event then report
    /// [`StoreError::KeysetMissing`](crate::store::StoreError::KeysetMissing)
    /// instead of a Shredded lookalike — see the
    /// [`absent_on_load`](Self#structfield.absent_on_load) field (D24).
    #[must_use]
    pub(crate) fn new_absent(granularity: KeyScopeGranularity) -> Self {
        Self {
            keys: BTreeMap::new(),
            granularity,
            dirty: false,
            absent_on_load: true,
        }
    }

    /// Whether this keyset was rehydrated from an absent file (no keys ever
    /// loaded). See [`absent_on_load`](Self#structfield.absent_on_load).
    #[must_use]
    pub(crate) fn was_absent_on_load(&self) -> bool {
        self.absent_on_load
    }

    /// Whether the in-memory keyset is ahead of the last durable flush (see the
    /// [`dirty`](Self#structfield.dirty) field). The durability fence flushes
    /// whenever this holds. Internal durability mechanism, not public surface.
    #[must_use]
    pub(crate) fn is_dirty(&self) -> bool {
        self.dirty
    }

    /// Flag the keyset dirty — the in-memory keys are ahead of the last durable
    /// flush. Idempotent; cleared only by a successful flush.
    pub(crate) fn mark_dirty(&mut self) {
        self.dirty = true;
    }

    /// The scope granularity this store partitions keys by.
    #[must_use]
    pub fn granularity(&self) -> KeyScopeGranularity {
        self.granularity
    }

    /// Number of live payload keys currently held (observability; never exposes
    /// key material). A destroyed scope no longer counts.
    #[must_use]
    pub fn key_count(&self) -> usize {
        self.keys.len()
    }

    /// Return the key for `scope`, minting a fresh random key on first use.
    ///
    /// A second call for the same scope returns the same key until it is
    /// [`destroy`](Self::destroy)-ed.
    ///
    /// # Errors
    /// Returns [`KeyStoreError::Rng`] if the CSPRNG fails while minting a new key.
    pub fn get_or_create(&mut self, scope: &KeyScope) -> Result<&PayloadKey, KeyStoreError> {
        // Minting/using the keyset to seal a payload means this store holds keys
        // of its own — it is no longer an absent-on-load keyset. A later missing
        // scope key is then a deliberate crypto-shred (`Shredded`), not a lost keyset
        // (`KeysetMissing`). Only a store that loaded an absent keyset file AND
        // never minted a key (a restored keys-excluded copy) stays absent (D24).
        self.absent_on_load = false;
        match self.keys.entry(scope.clone()) {
            Entry::Occupied(entry) => Ok(entry.into_mut()),
            Entry::Vacant(entry) => {
                let key = PayloadKey::generate()?;
                Ok(entry.insert(key))
            }
        }
    }

    /// Return the key for `scope` if one currently exists, without minting.
    #[must_use]
    pub fn get(&self, scope: &KeyScope) -> Option<&PayloadKey> {
        self.keys.get(scope)
    }

    /// Destroy the key for `scope` (the crypto-shred primitive).
    ///
    /// Returns `true` if a key existed and was removed. The removed
    /// [`PayloadKey`] is dropped here, zeroizing its bytes; a subsequent
    /// [`get`](Self::get) returns `None`, and any ciphertext sealed under the
    /// old key is permanently unrecoverable.
    pub fn destroy(&mut self, scope: &KeyScope) -> bool {
        let removed = self.keys.remove(scope).is_some();
        if removed {
            // The in-memory keyset now differs from the last durable flush; the
            // erasure is not durable until the next successful flush persists it.
            self.dirty = true;
        }
        removed
    }
}

/// Durable keyset persistence + cold-start rehydration (Stage B).
pub mod persist;

#[cfg(test)]
mod tests;