Skip to main content

offline_protocol/protocol/
storage.rs

1//! Storage persistence methods for protocol state.
2
3use super::state_crypto::{StateRecordCipher, SEALED_RECORD_OVERHEAD, STATE_RECORD_KEY_BYTES};
4use super::{
5    lifetime_expired, storage_keys, MediaTransferDescriptor, OfflineProtocol, OutboxEntry,
6    PeerCapabilities, PendingMessage, PendingMessageRecord, ReceivedKeyPackage, SessionState,
7    WelcomeDeliveryState, WelcomeLifecycleRecord, DATA_GROUP_V1, DATA_MEDIA_V1, DATA_SYNC_V1,
8    MAX_BLOCKED_USERS, MAX_KEY_PACKAGE_SENT_TO, MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH,
9    MAX_PENDING_KEY_PACKAGES, MAX_PENDING_MESSAGES_GLOBAL, MAX_PENDING_MESSAGES_PER_PEER,
10    MAX_PENDING_MESSAGE_BYTES_GLOBAL, MAX_PENDING_MESSAGE_BYTES_PER_PEER,
11    MAX_PERSISTED_CAPABILITY_VERSIONS, MAX_PROTOCOL_STATE_RECORD_BYTES, MLS_ENVELOPE_COMPACT_V1,
12    RICH_PAYLOAD_V1, WELCOME_LIFECYCLE_TTL_SECS,
13};
14use crate::constants::{MAX_MEDIA_DESCRIPTORS, MAX_OUTBOX_ENTRIES};
15use crate::{Error, Event, ProtocolStateError, ProtocolStateResult, ProtocolStateStorage, Result};
16use chrono::{Duration as ChronoDuration, Utc};
17use offline_protocol_core::{LamportClock, MessageId};
18use offline_protocol_mls::{MlsManager, MlsStorage};
19use offline_protocol_transport::{NostrKeypair, NostrTransport, TransportType};
20use serde::de::DeserializeOwned;
21use std::collections::{HashMap, HashSet};
22use std::sync::{Arc, RwLock};
23use tracing::{debug, info, warn};
24use zeroize::Zeroizing;
25
26/// Every protocol-state category the SDK writes through
27/// [`OfflineProtocol::write_state_record`].
28///
29/// The enum exists for [`Self::requires_sealing`], which is an exhaustive
30/// `match`: adding a variant is a compile error until someone decides whether
31/// that category's values are sensitive. The previous spelling was a `matches!`
32/// over `&str`, which silently answers "not sensitive" for a category nobody
33/// remembered to list — and that answer means message plaintext or cloud-media
34/// key material written to the app container in the clear, which is the one
35/// thing this module exists to prevent.
36///
37/// [`Self::from_key_type`] still has to cope with an unrecognised string,
38/// because the storage API is keyed by `&str`. It answers `None`, and
39/// `write_state_record` refuses to write that: a category the sealing decision
40/// does not cover fails closed and loudly rather than open and quietly.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub(crate) enum StateCategory {
43    /// The legacy per-recipient pending layout. Read-only now — restore
44    /// migrates it into [`Self::PendingMessageEntries`] — but still sealed,
45    /// because reading it means decrypting it.
46    PendingMessages,
47    PendingMessageEntries,
48    Outbox,
49    MediaDescriptors,
50    PeerKeyPackages,
51    PeerCapabilities,
52    SessionStates,
53    WelcomeLifecycles,
54    BlockedUsers,
55    BothCreateAwaitingDecrypt,
56    LamportClock,
57    /// The Nostr receive watermark. Post-split only, like [`Self::StateAdoption`]
58    /// — deliberately absent from [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`],
59    /// which has no pre-split data to inherit for it.
60    NostrWatermark,
61    /// The Nostr key-package publication slot map. Post-split only, like
62    /// [`Self::NostrWatermark`], and absent from
63    /// [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`] for the same reason.
64    NostrKeyPackageSlots,
65    /// This install's published username discovery claim. Post-split only, like
66    /// [`Self::NostrKeyPackageSlots`], and absent from
67    /// [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`] for the same reason.
68    NostrDiscoveryClaim,
69    /// The value-less marker recording that the pre-split adoption sweep
70    /// completed. Post-split only, so it is deliberately absent from
71    /// [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`] — but it *is* written to
72    /// protocol-state storage, so it belongs to the sealing decision like
73    /// every other category.
74    StateAdoption,
75    /// Compacted replicated documents. Post-split only, like
76    /// [`Self::StateAdoption`] — deliberately absent from
77    /// [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`], which has no pre-split
78    /// data to inherit for it.
79    DataDocs,
80    /// The uncompacted commit log of a replicated document. Post-split only,
81    /// like [`Self::DataDocs`], and absent from
82    /// [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`] for the same reason.
83    DataDeltaLog,
84    /// Per-space document indexes and compaction bookkeeping. Post-split
85    /// only, like [`Self::DataDocs`], and absent from
86    /// [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`] for the same reason.
87    DataSpaces,
88    /// Per-space replication bookkeeping. Post-split only, like
89    /// [`Self::DataDocs`], and absent from
90    /// [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`] for the same reason.
91    DataSync,
92}
93
94impl StateCategory {
95    /// The category a storage key type names, or `None` for a key type the
96    /// sealing decision below does not cover.
97    pub(crate) fn from_key_type(key_type: &str) -> Option<Self> {
98        Some(match key_type {
99            storage_keys::PENDING_MESSAGES => Self::PendingMessages,
100            storage_keys::PENDING_MESSAGE_ENTRIES => Self::PendingMessageEntries,
101            storage_keys::OUTBOX => Self::Outbox,
102            storage_keys::MEDIA_DESCRIPTORS => Self::MediaDescriptors,
103            storage_keys::PEER_KEY_PACKAGES => Self::PeerKeyPackages,
104            storage_keys::PEER_CAPABILITIES => Self::PeerCapabilities,
105            storage_keys::SESSION_STATES => Self::SessionStates,
106            storage_keys::WELCOME_LIFECYCLES => Self::WelcomeLifecycles,
107            storage_keys::BLOCKED_USERS => Self::BlockedUsers,
108            storage_keys::BOTH_CREATE_AWAITING_DECRYPT => Self::BothCreateAwaitingDecrypt,
109            storage_keys::LAMPORT_CLOCK => Self::LamportClock,
110            storage_keys::NOSTR_WATERMARK => Self::NostrWatermark,
111            storage_keys::NOSTR_KEY_PACKAGE_SLOTS => Self::NostrKeyPackageSlots,
112            storage_keys::NOSTR_DISCOVERY_CLAIM => Self::NostrDiscoveryClaim,
113            storage_keys::STATE_ADOPTION => Self::StateAdoption,
114            storage_keys::DATA_DOCS => Self::DataDocs,
115            storage_keys::DATA_DELTA_LOG => Self::DataDeltaLog,
116            storage_keys::DATA_SPACES => Self::DataSpaces,
117            storage_keys::DATA_SYNC => Self::DataSync,
118            _ => return None,
119        })
120    }
121
122    /// Every category, for tests that must cover the whole set.
123    ///
124    /// [`Self::requires_sealing`] is an exhaustive `match`, so a new variant
125    /// cannot be added without deciding its sensitivity. Nothing forces the
126    /// [`Self::from_key_type`] arm the same way: a variant with a
127    /// `storage_keys` constant but no arm compiles, and every write to it
128    /// then fails at runtime with "unknown protocol-state category". This
129    /// list plus `every_category_maps_back_from_its_key_type` closes that
130    /// gap, so the omission is a failing test rather than a production
131    /// write path that never worked.
132    #[cfg(test)]
133    pub(crate) const ALL: &'static [Self] = &[
134        Self::PendingMessages,
135        Self::PendingMessageEntries,
136        Self::Outbox,
137        Self::MediaDescriptors,
138        Self::PeerKeyPackages,
139        Self::PeerCapabilities,
140        Self::SessionStates,
141        Self::WelcomeLifecycles,
142        Self::BlockedUsers,
143        Self::BothCreateAwaitingDecrypt,
144        Self::LamportClock,
145        Self::NostrWatermark,
146        Self::NostrKeyPackageSlots,
147        Self::NostrDiscoveryClaim,
148        Self::StateAdoption,
149        Self::DataDocs,
150        Self::DataDeltaLog,
151        Self::DataSpaces,
152        Self::DataSync,
153    ];
154
155    /// The storage key type this category is written under.
156    ///
157    /// The inverse of [`Self::from_key_type`], and an exhaustive `match`, so
158    /// a new variant has to name its key type here before it compiles.
159    #[cfg(test)]
160    pub(crate) fn key_type(self) -> &'static str {
161        match self {
162            Self::PendingMessages => storage_keys::PENDING_MESSAGES,
163            Self::PendingMessageEntries => storage_keys::PENDING_MESSAGE_ENTRIES,
164            Self::Outbox => storage_keys::OUTBOX,
165            Self::MediaDescriptors => storage_keys::MEDIA_DESCRIPTORS,
166            Self::PeerKeyPackages => storage_keys::PEER_KEY_PACKAGES,
167            Self::PeerCapabilities => storage_keys::PEER_CAPABILITIES,
168            Self::SessionStates => storage_keys::SESSION_STATES,
169            Self::WelcomeLifecycles => storage_keys::WELCOME_LIFECYCLES,
170            Self::BlockedUsers => storage_keys::BLOCKED_USERS,
171            Self::BothCreateAwaitingDecrypt => storage_keys::BOTH_CREATE_AWAITING_DECRYPT,
172            Self::LamportClock => storage_keys::LAMPORT_CLOCK,
173            Self::NostrWatermark => storage_keys::NOSTR_WATERMARK,
174            Self::NostrKeyPackageSlots => storage_keys::NOSTR_KEY_PACKAGE_SLOTS,
175            Self::NostrDiscoveryClaim => storage_keys::NOSTR_DISCOVERY_CLAIM,
176            Self::StateAdoption => storage_keys::STATE_ADOPTION,
177            Self::DataDocs => storage_keys::DATA_DOCS,
178            Self::DataDeltaLog => storage_keys::DATA_DELTA_LOG,
179            Self::DataSpaces => storage_keys::DATA_SPACES,
180            Self::DataSync => storage_keys::DATA_SYNC,
181        }
182    }
183
184    /// Whether this category's values must be sealed before they reach the
185    /// install-scoped store.
186    ///
187    /// The single place record sensitivity is decided — adding a category means
188    /// deciding here, not at each call site. Sealed categories are the ones
189    /// whose values can carry message plaintext, user content, or media key
190    /// material:
191    ///
192    /// - [`storage_keys::PENDING_MESSAGE_ENTRIES`] and its legacy
193    ///   per-recipient predecessor [`storage_keys::PENDING_MESSAGES`]: original
194    ///   plaintext, plus rich extras that can include
195    ///   `MediaMetadata::encryption_key`/`iv`.
196    /// - [`storage_keys::OUTBOX`]: the outgoing `Message` — ciphertext for
197    ///   encrypted sends, but plaintext when the app opted out of encryption,
198    ///   and its outer `media_metadata` carries the cloud-media secrets on the
199    ///   forward path.
200    /// - [`storage_keys::MEDIA_DESCRIPTORS`]: file names and recipients of
201    ///   in-flight transfers.
202    /// - [`storage_keys::PEER_KEY_PACKAGES`]: the odd one out, sealed for
203    ///   **integrity** rather than confidentiality. The bytes are public wire
204    ///   material and hiding them buys nothing; what matters is that a seal is
205    ///   an AEAD, so a record that has been edited or moved between peers no
206    ///   longer opens. Substituting a cached key package would otherwise be
207    ///   enough to make this node build its session around the attacker's leaf.
208    ///   `MlsManager::verify_address_binding` is the real fix and covers the
209    ///   paths storage cannot see — it re-derives the address from the key
210    ///   package's own signature key and refuses any package whose credential
211    ///   does not match the peer id it arrived under, so a substituted package
212    ///   fails wherever it is read from. This shortens the reach of a container
213    ///   write as well. Failing closed is cheap here: an unsealable key package
214    ///   costs one re-exchange.
215    /// - [`storage_keys::NOSTR_KEY_PACKAGE_SLOTS`]: the second integrity case,
216    ///   for the same reason and not for confidentiality — slot ids and package
217    ///   ids are already public in the published record itself. What sealing
218    ///   buys is that the *silent* failure becomes unreachable. Deleting this
219    ///   map self-heals (the next tick mints fresh slots), and pointing it at a
220    ///   package id that does not exist self-heals too (a missing package reads
221    ///   as consumed and is refilled). The one damaging edit is repointing a
222    ///   slot at some *other* live package, which leaves a stale record standing
223    ///   on the relays while this node believes the slot is healthy — and every
224    ///   stranger who fetches it builds a Welcome that can never be processed.
225    ///   An AEAD makes that edit unopenable, and unopenable lands on the
226    ///   self-healing path.
227    /// - [`storage_keys::NOSTR_DISCOVERY_CLAIM`]: the third integrity case, and
228    ///   again not for confidentiality — the value is a username this install
229    ///   already published in a public directory. What sealing buys is that the
230    ///   damaging edit is unreachable. This record is the *only* thing that
231    ///   knows which name to retract, so an attacker who rewrites it to a name
232    ///   this install never claimed makes the real claim unretractable: the
233    ///   retraction is published at the wrong tag, and the live one stands
234    ///   pointing at this address forever. Deleting the record instead is the
235    ///   benign direction and is not prevented (nothing sealing does can),
236    ///   which is why the claim is also republished on every launch.
237    ///
238    /// Everything else is advertised capability versions, a small state enum, a
239    /// logical clock, a coarse wall-clock mark, or a value-less marker whose
240    /// only information is the key id — which sealing cannot hide anyway,
241    /// because the store addresses records by it.
242    ///
243    /// [`storage_keys::NOSTR_WATERMARK`] is in that second group: it is one
244    /// unix-seconds timestamp saying roughly when this install last received a
245    /// relay event. That is strictly less than the key id already tells a
246    /// reader with container access, and the record's *integrity* buys nothing
247    /// either — the value is clamped to "not in the future" on the way back in,
248    /// and moving it backwards only widens a replay window that
249    /// `NOSTR_INITIAL_QUERY_LIMIT` already bounds.
250    ///
251    /// Note what sealing does **not** provide, for any category: it authenticates
252    /// bytes that are present, so it cannot detect a record that was deleted or
253    /// rolled back to an earlier version. Controls that must survive a deletion
254    /// cannot be built on it — see `encryption_capable_peers`.
255    fn requires_sealing(self) -> bool {
256        match self {
257            Self::PendingMessages
258            | Self::PendingMessageEntries
259            | Self::Outbox
260            | Self::MediaDescriptors
261            | Self::PeerKeyPackages
262            | Self::NostrKeyPackageSlots
263            | Self::NostrDiscoveryClaim
264            | Self::DataDocs
265            | Self::DataDeltaLog
266            | Self::DataSpaces
267            | Self::DataSync => true,
268            Self::PeerCapabilities
269            | Self::SessionStates
270            | Self::WelcomeLifecycles
271            | Self::BlockedUsers
272            | Self::BothCreateAwaitingDecrypt
273            | Self::LamportClock
274            | Self::NostrWatermark
275            | Self::StateAdoption => false,
276        }
277    }
278}
279
280/// Whether a key type's values must be sealed. Unknown key types answer `false`
281/// only for *reads*, where nothing this SDK wrote can be waiting — the write
282/// side refuses them outright.
283fn record_requires_sealing(key_type: &str) -> bool {
284    StateCategory::from_key_type(key_type).is_some_and(StateCategory::requires_sealing)
285}
286
287/// Outcome of reading one protocol-state record.
288///
289/// Three states, not two, because "no bytes" has three very different causes
290/// and only one of them is a loss:
291///
292/// - [`Self::Missing`] — nothing was ever here.
293/// - [`Self::Unreadable`] — a record *was* here, was examined, and is gone for
294///   good (oversized, tampered, or sealed under a key this install no longer
295///   has). It has been deleted.
296/// - [`Self::Unavailable`] — a record is still here and may be perfectly good,
297///   but cannot be read *this session* (the record key is not loaded, or the
298///   backend refused the read). It is left on disk and a later launch may
299///   recover it.
300///
301/// Restore paths that own app-visible message state settle only
302/// [`Self::Unreadable`]. Settling [`Self::Unavailable`] would be wrong twice
303/// over: the record survives, so the next launch restores it and re-drives
304/// delivery — the app would have been told the message failed terminally and
305/// then have it delivered anyway, with a hand re-send (a *new* id, so dedup
306/// cannot collapse it) landing as a second copy.
307pub(crate) enum StateRecord {
308    /// No record under this key.
309    Missing,
310    /// A record existed, could not be returned, and has been deleted — or,
311    /// when the reader was given a [`PruneBudget`] that has run out, left for a
312    /// later launch to delete. Nothing is ever recovered from it either way, so
313    /// callers treat the two identically; the budget only defers the unlink.
314    Unreadable,
315    /// A record exists but could not be read this session; it is still on disk.
316    Unavailable,
317    /// The record's plaintext value.
318    Present(Vec<u8>),
319}
320
321impl StateRecord {
322    /// The record's bytes, treating an unrecoverable record like an absent one.
323    /// For callers whose category carries no app-visible commitment.
324    fn into_bytes(self) -> Option<Vec<u8>> {
325        match self {
326            Self::Present(data) => Some(data),
327            Self::Missing | Self::Unreadable | Self::Unavailable => None,
328        }
329    }
330}
331
332/// Outcome of restoring one recipient's pending queue. Mirrors
333/// [`StateRecord`]'s three-way split for the same reason.
334enum PendingRestore {
335    /// Nothing was queued for this recipient.
336    Absent,
337    /// A queue existed but could not be recovered. Its message ids are inside
338    /// the record that would not open, so they cannot be settled individually.
339    Lost,
340    /// A queue exists but could not be read this session and is still on disk.
341    /// Reporting it as lost would be a lie a later launch contradicts.
342    Unavailable,
343    /// The recovered queue (possibly empty).
344    Restored(Vec<PendingMessage>),
345}
346
347/// Outcome of a restore-path read of one JSON record
348/// ([`OfflineProtocol::load_restorable_state_record`]). Mirrors
349/// [`StateRecord`]'s split for the same reason, collapsing only the two states
350/// a restore treats identically.
351///
352/// [`Self::Absent`] and [`Self::Unavailable`] must stay apart even though both
353/// yield no value. A caller that re-bootstraps on absence — which is exactly
354/// what `restore_session_states_from_manager` does — would otherwise persist a
355/// fresh `Pending` over a record that is still on disk and may say `Confirmed`.
356pub(crate) enum RestorableRecord<T> {
357    /// The decoded value.
358    Present(T),
359    /// Nothing was ever here, or a record was examined and dropped. Either way
360    /// the category holds no value for this key and re-bootstrapping one is
361    /// safe.
362    Absent,
363    /// A record may well be here and could not be read *this session*. It is
364    /// left on disk, so the caller must neither treat it as absent nor write
365    /// over it; a later launch can still recover it.
366    Unavailable,
367}
368
369/// Largest number of keys any single pass will process for a category with no
370/// live insert-time cap of its own.
371///
372/// The pending queue is the widest such category, at
373/// [`MAX_PENDING_MESSAGES_GLOBAL`] recipients, so a store listing more than a
374/// generous multiple of that has been tampered with or was written by a build
375/// with wildly different bounds. That threat matters more here than it did
376/// before the storage split, not less: this state used to live in the
377/// credential store and now lives in the app container, where write access is
378/// easier to come by. Sealing already keeps a forged record from ever
379/// deserializing; this bounds the *work* as well as the result.
380///
381/// Every walk stops here; what happens to the tail depends on the category:
382///
383/// - Most **restores** simply *ignore* it — they do not delete it. A later
384///   launch still reaches it, but *not* because the listing order varies: all
385///   three built-in providers return their keys sorted, so the walked prefix is
386///   deterministic. It drains because the prefix is **consumed** — restored
387///   entries are flushed, expired, or evicted for capacity, and their records
388///   deleted — so each launch lists fewer keys and the walk reaches further.
389///   The categories restore never consumes ([`storage_keys::BLOCKED_USERS`],
390///   [`storage_keys::BOTH_CREATE_AWAITING_DECRYPT`],
391///   [`storage_keys::WELCOME_LIFECYCLES`]) therefore keep the *same* tail on
392///   every launch, indefinitely. That is tolerable only because this bound sits
393///   far above anything this SDK can legitimately write, so reaching it already
394///   means the store was tampered with — do not carry the "a later launch gets
395///   the rest" argument over to a tighter bound.
396/// - The two **cache** categories (`restore_peer_key_packages`,
397///   `restore_peer_capabilities`) prune the overflow *inside* the prefix down
398///   to their own much smaller live caps. Losing a cached key package or
399///   capability record only costs a recoverable re-exchange, which is what
400///   makes deleting the overflow the right policy there. How much they prune
401///   per launch is bounded separately by [`MAX_RESTORE_PRUNE_DELETES`],
402///   because this bound limits *reads* and a prune is a synchronous provider
403///   delete — so the store shrinks by that budget per launch and the tail
404///   drains over successive launches.
405/// - **Adoption** treats hitting this bound as an incomplete pass instead: it
406///   deletes each record it moves, so withholding its completion marker drains
407///   the remainder over successive launches rather than abandoning it in the
408///   credential store (see `adopt_legacy_protocol_state`).
409///
410/// Categories whose live insert path is capped get a tighter bound derived from
411/// that cap instead (see [`OUTBOX_RESTORE_KEY_CAP`]).
412///
413/// `restore_encryption_capable_peers` uses this bound too, even though those
414/// records never leave the credential store: the argument for bounding *work* on
415/// the boot path does not depend on which store is easier to write to. It is the
416/// one category that bounds its walk without ever pruning the tail — dropping a
417/// record there re-opens the plaintext gate for that peer, so stranding the
418/// overflow is the safer failure. See the rationale there.
419pub(super) const MAX_RESTORE_KEYS_PER_CATEGORY: usize = 4 * MAX_PENDING_MESSAGES_GLOBAL;
420
421/// Restore-walk bound for the outbox.
422///
423/// The live insert path hard-caps the outbox at [`MAX_OUTBOX_ENTRIES`]
424/// (`ensure_outbox_entry` evicts before admitting), so a durable store can
425/// never legitimately hold more than that plus what a merge restored. Four
426/// times the cap is ample headroom and avoids walking — and transiently
427/// allocating up to [`MAX_PROTOCOL_STATE_RECORD_BYTES`] for — thirty times more
428/// entries than could ever be kept.
429///
430/// Media transfer descriptors deliberately keep the wider bound: unlike the
431/// outbox, `persist_media_descriptor` has no insert-time cap
432/// ([`MAX_MEDIA_DESCRIPTORS`] is applied only on restore), so a long-running
433/// session can legitimately leave more on disk than the restore cap. Walking
434/// only a tight prefix would keep the wrong ones and strand the rest forever.
435const OUTBOX_RESTORE_KEY_CAP: usize = 4 * MAX_OUTBOX_ENTRIES;
436
437/// Restore-walk bound for the pending queue, counted in *entries* rather than
438/// records.
439///
440/// [`MAX_RESTORE_KEYS_PER_CATEGORY`] bounds how many pending-queue *records*
441/// one pass opens, and each record holds a whole recipient's queue — up to
442/// [`MAX_PENDING_MESSAGES_PER_PEER`] entries after the trim. Bounding only the
443/// record count therefore admits `16384 × 64` entries, and holding the global
444/// caps across them is not free: `evict_oldest_pending_message` scans the whole
445/// in-memory queue to find the oldest entry, so every entry past
446/// [`MAX_PENDING_MESSAGES_GLOBAL`] costs an O(`MAX_PENDING_MESSAGES_GLOBAL`)
447/// pass. That is roughly four billion comparisons on the *synchronous* boot
448/// path, before counting one provider load per record.
449///
450/// This is the same lesson the built-in providers' `list_keys` bound learned:
451/// count the work, not the results. And it is reachable without tampering — the
452/// pre-split build had no pending-queue caps at all, so an upgraded install can
453/// legitimately hold far more than the caps now admit, and the adoption sweep
454/// moves all of it into the store this walk reads.
455///
456/// The tail is *ignored*, never pruned, exactly like the record-count tail
457/// above it: those ids live inside records nothing has opened, so deleting them
458/// would be an unsettleable loss. Sized at the same generous multiple of the
459/// live cap the rest of this module uses, so no store this SDK wrote is ever
460/// truncated.
461///
462/// The tail needs no freeze, and once did. Under the per-recipient layout a
463/// record held a whole queue, so an ordinary enqueue for an unwalked recipient
464/// wrote the in-memory view straight over it and the bounds' promise to leave
465/// the tail "for a later launch" had to be enforced for the whole session.
466/// Pending records are now keyed per message id, so a later write only ever
467/// touches a different key — the same reason the outbox and media-descriptor
468/// tails never needed one.
469///
470/// Both passes of the walk share this bound, and only the legacy one can still
471/// open many entries per record; for per-message records it is simply the record
472/// count.
473pub(super) const MAX_PENDING_RESTORE_ENTRIES: usize = 4 * MAX_PENDING_MESSAGES_GLOBAL;
474
475/// Durable deletes one *pool* may fund across a launch.
476///
477/// [`MAX_RESTORE_KEYS_PER_CATEGORY`] bounds how many records a walk *reads*.
478/// It does not bound how many it *deletes*, and those are not the same cost: a
479/// delete is a synchronous provider round trip that flushes the containing
480/// directory on all three built-in providers — `F_FULLFSYNC` on iOS, which is a
481/// full device barrier rather than a hint. Pruning every over-cap record in one
482/// pass therefore costs, in the worst case, tens of thousands of device
483/// barriers on the *synchronous* `initialize_mls` path: not a slow launch, a
484/// launch the platform watchdog kills.
485///
486/// So the walk bound and the prune bound are separate numbers, because they
487/// bound separate resources. The same lesson as the providers' `list_keys`
488/// bound and [`MAX_PENDING_RESTORE_ENTRIES`], applied to the write side.
489///
490/// Safe to stop early precisely because pruning is *idempotent and resumable*:
491/// an over-cap record left on disk is walked again next launch and pruned then,
492/// exactly as `adopt_legacy_protocol_state` drains its own truncated pass.
493///
494/// # The bound is on the launch, so the allowance is a pool
495///
496/// The hazard above is a property of the whole `initialize_mls` call, not of
497/// any one walk in it. An earlier spelling gave *each* walk a private
498/// allowance of this size, so six walks cost six times the bound while every
499/// one of them truthfully reported staying inside it — the same failure this
500/// constant exists to prevent, one level up. Walks therefore draw from a
501/// [`PruneAllowance`] pool, and the launch ceiling is the sum of the pools
502/// rather than the sum of the walks.
503///
504/// # Two ways to spend it, because two kinds of walk owe the application
505/// different things
506///
507/// A **cache or advisory** walk ([`PruneAllowance::refusing`]) may simply
508/// refuse a delete once its share is gone: dropping a session-state record, a
509/// cached key package, a capability record, a Welcome lifecycle, or a media
510/// descriptor costs a recoverable re-exchange or a re-bootstrap, so the record
511/// is left on disk and nothing is owed to anyone. All [`ADVISORY_PRUNE_WALKS`]
512/// of them therefore share **one** pool, created by `initialize_mls` and
513/// threaded through them, so their deletes add up against a single allowance.
514///
515/// Sharing a pool is not the same as sharing it *fairly*, and the difference
516/// matters because the walks draw in a fixed order. Each therefore leaves the
517/// walks after it a [`MIN_ADVISORY_PRUNE_DELETES`] floor rather than being free
518/// to empty the pool — without which the first walk alone could starve the rest
519/// on every launch. Being *starved* is not the same as being *deferred*: a
520/// deferred record is re-walked next launch, while a walk that never draws
521/// again defers nothing, and these prunes are the only thing that ever deletes
522/// those records. Within that floor the allowance stays elastic: a walk may
523/// take everything except what it owes the ones still to come, so a launch with
524/// clean early categories still lets a later one draw deeply.
525///
526/// A **settlement-paired** walk ([`PruneAllowance::counting`]) cannot refuse.
527/// Refusing an individual delete there would either settle an id whose record a
528/// later launch restores and re-drives — the exact contradiction
529/// [`StateRecord`] exists to prevent — or drop an entry from memory while
530/// leaving the app holding an id nothing resolves. So it *counts* what it
531/// spends, never refuses a delete mid-record, and stops at the next **record
532/// boundary**, where stopping is safe because the untouched remainder is
533/// exactly what a later launch reads.
534/// Both [`OfflineProtocol::restore_pending_messages`] and
535/// [`OfflineProtocol::restore_outbox`] spend it that way, and neither needs to
536/// freeze the tail it stops at: both categories are keyed per message id, so a
537/// later write only ever touches a different key. The pending walk did need a
538/// freeze while its records were keyed per recipient and held a whole queue —
539/// see [`MAX_PENDING_RESTORE_ENTRIES`].
540///
541/// The two settlement-paired walks get a pool **each**, rather than sharing the
542/// advisory one. Starving an advisory walk defers a cache eviction; starving
543/// the outbox walk defers *delivery* of every message the app is still holding
544/// a live id for, and starving the pending walk defers every diagnostic it
545/// owes. Neither may be held hostage to a key-package flood.
546///
547/// # The derived launch ceiling
548///
549/// Three pools, so `3 × MAX_RESTORE_PRUNE_DELETES` is the whole launch's
550/// allowance. All three are constructed side by side by `initialize_mls` — see
551/// [`PruneAllowance::pool`] for why none of them may be allocated inside the
552/// walk that spends it — and
553/// `test_one_launch_cannot_exceed_the_derived_restore_delete_ceiling` pins the
554/// total against a provider that counts deletes, which is the invariant a
555/// per-walk regression breaks and a per-pool test does not see.
556///
557/// The ceiling covers every durable delete on the path, not only the ones a
558/// walk issues while it reads. [`OfflineProtocol::restore_outbox`]'s two
559/// post-walk prunes — the absolute-lifetime drop and the capacity drain — act
560/// on entries the walk already *admitted*, so a store whose records all open
561/// cleanly reaches them with the pool untouched and its working set bounded
562/// only by [`OUTBOX_RESTORE_KEY_CAP`]. Left ungated they cost up to
563/// `OUTBOX_RESTORE_KEY_CAP - MAX_OUTBOX_ENTRIES` deletes in one launch — the
564/// ordinary over-capacity case rather than the tampered one, and the figure
565/// that walk's original budget exemption was argued from. Both are
566/// settlement-paired, so neither may refuse an individual delete; both stop
567/// *between* entries instead, and an entry the pool cannot fund is dropped from
568/// memory and left on disk **unsettled**, so a later launch owns both halves.
569/// `test_outbox_capacity_prune_stays_inside_the_launch_budget` and
570/// `test_outbox_absolute_expiry_prune_stays_inside_the_launch_budget` pin them.
571///
572/// Two caveats remain, because neither can be budgeted away.
573///
574/// A settlement-paired walk stops *between* records, so the record it is
575/// already inside may push a little past its pool.
576///
577/// And the ceiling is over the restore walks, not over every durable delete
578/// `initialize_mls` can cause. `adopt_legacy_protocol_state` runs before them
579/// and deletes from the *secure* store as it moves each record across, bounded
580/// by its own truncated-and-resumable pass rather than by this constant. It is
581/// a one-time upgrade sweep on a different provider, so it is deliberately
582/// outside the pools — but a reader deriving "the most barriers one launch can
583/// issue" should count it separately rather than reading `3 ×` as the total.
584///
585/// # This bounds deletes, and deletes are not the only durable cost
586///
587/// A *write* flushes the record **and** its directory, so it is strictly more
588/// expensive than a delete. Two write paths on the restore walk spend outside
589/// this budget on purpose:
590///
591/// - `restore_pending_messages` writes each legacy entry that survives
592///   admission under its own id before dropping the per-recipient record it
593///   came out of (`persist_migrated_pending_queues`). Those
594///   writes have their own per-launch cap,
595///   [`MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH`].
596/// - `restore_outbox` re-persists refreshed and orphaned entries. Each is
597///   paired with a settlement that cannot be separated from it, and the volume
598///   is bounded transitively by [`OUTBOX_RESTORE_KEY_CAP`].
599///
600/// Tightening the write volume means tightening those, not budgeting here.
601pub(super) const MAX_RESTORE_PRUNE_DELETES: usize = 512;
602
603/// Everything one pending-queue restore walk accumulates across recipients.
604///
605/// The running totals are maintained rather than recomputed: the global caps
606/// would otherwise re-walk the whole in-memory queue once per eviction, and the
607/// walk can admit up to [`MAX_PENDING_RESTORE_ENTRIES`] entries.
608///
609/// They start at zero rather than from the in-memory queue because that queue is
610/// necessarily empty at this point: `queue_message_for_session_establishment` is
611/// gated on `should_auto_encrypt()`, which requires an MLS manager, and
612/// `initialize_mls` returns early once one is published — so nothing can have
613/// been queued before the restore that publishes it.
614#[derive(Default)]
615struct RestoredPendingAdmission {
616    /// Entries admitted into `pending_encrypted_messages` so far.
617    global_count: usize,
618    /// Their total serialized footprint.
619    global_bytes: usize,
620    /// Ids dropped by a count or byte cap, to settle as failed and delete.
621    capacity_evicted: Vec<MessageId>,
622}
623
624/// What the two pending-restore passes accumulate before the caps are applied.
625///
626/// The passes read two different layouts and both feed one grouped view, so
627/// this exists to keep them from having to know about each other beyond the
628/// `seen` set.
629#[derive(Default)]
630struct PendingRestoreWalk {
631    /// Recovered entries, grouped by recipient but **not yet ordered** — see
632    /// [`sort_pending_queue`], which the caller applies before the trims.
633    grouped: HashMap<String, Vec<PendingMessage>>,
634    /// Ids the per-message pass has already accounted for — recovered, or
635    /// examined and settled as destroyed.
636    ///
637    /// The migration pass skips these. Recovered ones because a crash between
638    /// its writes and its delete leaves a queue present in both layouts and the
639    /// migrated copy is the one to keep; settled ones because re-filing an id
640    /// the app was just told had failed would deliver a message after its own
641    /// terminal event.
642    seen: HashSet<String>,
643    /// Ids of per-message records that were examined and destroyed. Settleable
644    /// individually, because the key is the id — *unless* a surviving legacy
645    /// record also holds the id; see `seen_in_legacy`.
646    lost_ids: Vec<String>,
647    /// Ids the migration pass skipped as `seen`, mapped to the legacy recipient
648    /// whose record holds them.
649    ///
650    /// A `lost_ids` settlement is only honest while nothing can re-file the id.
651    /// An id also inside a legacy record whose delete then fails *can* be
652    /// re-filed — the next launch recovers and sends it — so its settlement is
653    /// withheld and the next launch owns both halves.
654    seen_in_legacy: HashMap<String, String>,
655    /// Ids recovered from a legacy queue, mapped to the record they came out
656    /// of, and which therefore have no record of their own yet.
657    ///
658    /// Persisted after admission rather than during the walk, so the entries the
659    /// caps drop are never written at all — see [`OfflineProtocol::restore_pending_messages`].
660    ///
661    /// The recipient rides along because a settlement for one of these ids is
662    /// only honest once that legacy record is *gone*: while it is still on disk
663    /// it can re-file the entry on a later launch, and a terminal event the next
664    /// launch overturns is worse than no event at all.
665    migrated: HashMap<String, String>,
666    /// Legacy records whose entries have been recovered, to drop once those
667    /// entries are durable under their own ids.
668    migrated_recipients: Vec<String>,
669    /// Recipients whose whole legacy queue died inside a record that would not
670    /// open. Only reportable per peer — every id was inside it.
671    lost_recipients: Vec<String>,
672    /// Ids queued for a recipient that is no longer a valid user id.
673    unaddressable: Vec<MessageId>,
674    /// Entries opened across both passes — what [`MAX_PENDING_RESTORE_ENTRIES`]
675    /// actually bounds.
676    examined_entries: usize,
677    entry_bound_reached: bool,
678    prune_bound_reached: bool,
679    write_bound_reached: bool,
680}
681
682/// Puts a recovered queue back into canonical order: oldest first, ties broken
683/// by message id.
684///
685/// The same comparator `evict_oldest_pending_message` uses, so "index 0 is the
686/// oldest" holds for a restored queue exactly as it does for a live one — which
687/// every oldest-first trim on the restore path depends on.
688///
689/// Necessary because records are keyed per message and come back in whatever
690/// order the store enumerates them, which carries no ordering information at
691/// all. The per-recipient layout got this for free from the array it stored, and
692/// paid for it by rewriting that array on every enqueue.
693fn sort_pending_queue(messages: &mut [PendingMessage]) {
694    messages.sort_by(|left, right| {
695        left.queued_at
696            .cmp(&right.queued_at)
697            .then_with(|| left.message_id.as_str().cmp(&right.message_id.as_str()))
698    });
699}
700
701/// Advisory restore walks that draw on the shared pool, in the order
702/// `initialize_mls` runs them.
703///
704/// Keep this in step with that call site. It is the divisor behind
705/// [`MIN_ADVISORY_PRUNE_DELETES`], so adding a walk without bumping it means
706/// the last walk in the sequence has no reservation left and can be starved —
707/// the exact failure the floor exists to prevent.
708/// `test_a_flooded_advisory_category_cannot_starve_the_walks_after_it` fails if
709/// the two disagree.
710pub(super) const ADVISORY_PRUNE_WALKS: usize = 5;
711
712/// Deletes each advisory walk is guaranteed, whatever the walks before it did.
713///
714/// The shared pool alone bounds the launch but says nothing about *who* gets to
715/// spend it, and the walks draw in a fixed order. Without a floor the first one
716/// can empty the pool on its own: `restore_peer_key_packages` refuses only once
717/// the pool is gone, so a key-package store over its cap by more than
718/// [`MAX_RESTORE_PRUNE_DELETES`] — which the flood-eviction exemption makes an
719/// ordinary state, not a tampered one — leaves the four walks after it with
720/// nothing, on every launch, for as long as it stays over cap. Their prunes are
721/// the only thing that ever deletes those records, so "deferred to a later
722/// launch" would have quietly meant "never".
723///
724/// Reserving is elastic rather than a fixed split: each walk may spend
725/// everything in the pool *except* the floor owed to the walks still to come,
726/// so a launch where the early categories are clean still lets a later one draw
727/// deeply.
728///
729/// The number is deliberately well under an even share of
730/// [`MAX_RESTORE_PRUNE_DELETES`]. An even split would be the wrong trade: the
731/// category that floods is the one that needs to converge fastest, while the
732/// categories it would starve hold small counts (crash-orphaned media
733/// descriptors, records that will not decode, session states the bootstrap
734/// write repairs anyway). So the floor is set at what makes a starved walk
735/// converge in a bounded number of launches rather than at what makes the split
736/// fair, leaving the rest of the pool elastic for whichever category is
737/// actually large. With five walks that reserves 320 of 512 and leaves the
738/// first flooded walk 320 rather than 102.
739pub(super) const MIN_ADVISORY_PRUNE_DELETES: usize = 64;
740
741const _: () = assert!(
742    ADVISORY_PRUNE_WALKS * MIN_ADVISORY_PRUNE_DELETES <= MAX_RESTORE_PRUNE_DELETES,
743    "the advisory floors must fit inside one pool, or the first walk's ceiling \
744     underflows to zero and it can never prune at all"
745);
746
747/// A pool of durable restore-path deletes, drawn on by one or more walks.
748///
749/// The unit the [`MAX_RESTORE_PRUNE_DELETES`] bound is actually about. A pool
750/// outlives the walk that spends from it, which is the whole point: the five
751/// advisory walks share one pool for the length of an `initialize_mls`, so
752/// their deletes add up against a single allowance instead of each getting a
753/// private one.
754pub(crate) struct PruneAllowance {
755    remaining: usize,
756    /// Advisory walks that have not yet drawn from this pool.
757    ///
758    /// Decremented by each [`Self::refusing`] draw, and what that draw reserves
759    /// for is the walks *after* it — see [`MIN_ADVISORY_PRUNE_DELETES`]. Kept
760    /// on the pool rather than passed in per call so no walk has to know its
761    /// own position in the sequence.
762    advisory_walks_left: usize,
763}
764
765impl PruneAllowance {
766    /// One pool of [`MAX_RESTORE_PRUNE_DELETES`] deletes.
767    ///
768    /// The **only** constructor, and deliberately so. A separate one for the
769    /// settlement-paired walks let those walks allocate a pool *inside the
770    /// callee* — which is the same shape this type exists to fix, one level
771    /// down: a walk handing itself a private allowance, where a second call in
772    /// the same launch silently doubles the ceiling with nothing to show for
773    /// it. The two constructors had identical bodies, which was the tell.
774    ///
775    /// So every pool is constructed by the caller that owns the launch —
776    /// `initialize_mls` builds all three side by side — and threaded in. The
777    /// launch ceiling reads off that one call site, and
778    /// `test_one_launch_cannot_exceed_the_derived_restore_delete_ceiling` pins
779    /// it.
780    pub(crate) fn pool() -> Self {
781        Self {
782            remaining: MAX_RESTORE_PRUNE_DELETES,
783            advisory_walks_left: ADVISORY_PRUNE_WALKS,
784        }
785    }
786
787    /// A budget for a walk whose records are caches or advisory signals, which
788    /// may refuse a delete outright and leave the record for a later launch.
789    ///
790    /// Capped so the walks after this one keep their
791    /// [`MIN_ADVISORY_PRUNE_DELETES`] floor. The two settlement-paired pools
792    /// never call this, so their `advisory_walks_left` is simply never spent.
793    pub(super) fn refusing(&mut self) -> PruneBudget<'_> {
794        let later = self.advisory_walks_left.saturating_sub(1);
795        self.advisory_walks_left = later;
796        let reserved = later.saturating_mul(MIN_ADVISORY_PRUNE_DELETES);
797        let ceiling = self.remaining.saturating_sub(reserved);
798        PruneBudget::new(&mut self.remaining, ceiling, true)
799    }
800
801    /// A budget for a settlement-paired walk, which counts every delete but
802    /// never refuses one.
803    ///
804    /// Refusing mid-record would break the pairing between a delete and the
805    /// terminal event that names what it destroyed. The caller instead polls
806    /// [`PruneBudget::is_spent`] between records and stops there. Reserves
807    /// nothing: each of these owns its pool outright.
808    fn counting(&mut self) -> PruneBudget<'_> {
809        let ceiling = self.remaining;
810        PruneBudget::new(&mut self.remaining, ceiling, false)
811    }
812}
813
814/// One walk's view of a [`PruneAllowance`], and whether it ran out.
815///
816/// Deliberately has no `Default` and no free constructor: whether a walk may
817/// *refuse* a delete or only *count* it is the whole safety question, so it has
818/// to be answered at the point the budget is drawn from a pool rather than
819/// inherited from whichever answer happened to be the zero value.
820pub(super) struct PruneBudget<'a> {
821    /// Deletes the pool can still fund. Shared with every other walk drawing
822    /// on the same [`PruneAllowance`].
823    remaining: &'a mut usize,
824    /// The most *this* walk may take out of the pool, leaving the rest for the
825    /// walks after it — see [`MIN_ADVISORY_PRUNE_DELETES`].
826    ceiling: usize,
827    /// Taken from the pool so far, which is what the ceiling bounds.
828    drawn: usize,
829    /// What *this* walk spent, for its own log line. Distinct from the pool, so
830    /// one walk's report never includes another's deletes, and distinct from
831    /// [`Self::drawn`] because a counting walk keeps deleting past its ceiling.
832    pub(super) spent: usize,
833    pub(super) exhausted: bool,
834    /// Whether [`Self::claim`] may turn a delete down once this walk's share is
835    /// gone. False for settlement-paired walks — see
836    /// [`MAX_RESTORE_PRUNE_DELETES`].
837    refusable: bool,
838}
839
840impl<'a> PruneBudget<'a> {
841    fn new(remaining: &'a mut usize, ceiling: usize, refusable: bool) -> Self {
842        Self {
843            remaining,
844            ceiling,
845            drawn: 0,
846            spent: 0,
847            exhausted: false,
848            refusable,
849        }
850    }
851
852    /// Claims one delete. Refuses — and records that this walk's share is gone
853    /// — only for a refusing budget; a counting one always allows the delete
854    /// and just charges for it.
855    fn claim(&mut self) -> bool {
856        if self.is_spent() {
857            self.exhausted = true;
858            if self.refusable {
859                return false;
860            }
861        } else {
862            *self.remaining -= 1;
863            self.drawn = self.drawn.saturating_add(1);
864        }
865        self.spent = self.spent.saturating_add(1);
866        true
867    }
868
869    /// Whether this walk's share is used up — either the pool is empty or the
870    /// walk has drawn everything it may without eating a later walk's floor. A
871    /// counting caller checks this at each record boundary to decide whether to
872    /// walk any further.
873    fn is_spent(&self) -> bool {
874        *self.remaining == 0 || self.drawn >= self.ceiling
875    }
876}
877
878/// Claims one delete against an optional budget, allowing it unconditionally
879/// when the caller supplied none.
880///
881/// The `None` case is for the runtime readers, which are not on a restore walk
882/// and issue at most one delete per call. See [`MAX_RESTORE_PRUNE_DELETES`].
883fn claim_prune(budget: &mut Option<&mut PruneBudget<'_>>) -> bool {
884    match budget {
885        Some(budget) => budget.claim(),
886        None => true,
887    }
888}
889
890impl OfflineProtocol {
891    // ========================================================================
892    // PROTOCOL-STATE RECORD I/O (SEALING + SIZE POLICY CHOKEPOINT)
893    // ========================================================================
894
895    /// Writes one protocol-state record, sealing it first when its category
896    /// requires that, and refusing anything over
897    /// [`MAX_PROTOCOL_STATE_RECORD_BYTES`].
898    ///
899    /// Every value written to [`ProtocolStateStorage`] goes through here, so
900    /// confidentiality and size policy live in one place rather than being
901    /// re-decided per call site.
902    pub(crate) fn write_state_record(
903        &self,
904        storage: &dyn ProtocolStateStorage,
905        key_type: &str,
906        key_id: &str,
907        data: &[u8],
908    ) -> ProtocolStateResult<()> {
909        if data.len() > MAX_PROTOCOL_STATE_RECORD_BYTES {
910            return Err(ProtocolStateError::StoreFailed(format!(
911                "record is {} bytes, over the {} byte limit",
912                data.len(),
913                MAX_PROTOCOL_STATE_RECORD_BYTES
914            )));
915        }
916
917        // Fail closed on a category the sealing decision does not cover. The
918        // alternative is writing it in the clear because the default answer to
919        // "is this sensitive?" happened to be "no", which is exactly the
920        // silent-plaintext outcome `StateCategory` exists to make impossible.
921        let Some(category) = StateCategory::from_key_type(key_type) else {
922            return Err(ProtocolStateError::StoreFailed(format!(
923                "unknown protocol-state category '{}'; refusing to persist \
924                 a record whose sensitivity has not been decided",
925                key_type
926            )));
927        };
928
929        if !category.requires_sealing() {
930            return storage.store(key_type, key_id, data);
931        }
932
933        // Fail closed: without the per-install key this record would have to be
934        // written in the clear, and losing crash recovery for it is strictly
935        // less bad than losing at-rest confidentiality.
936        let Some(cipher) = &self.state_record_cipher else {
937            return Err(ProtocolStateError::StoreFailed(
938                "protocol state record key unavailable; refusing to persist in the clear"
939                    .to_string(),
940            ));
941        };
942        let Some(sealed) = cipher.seal(key_type, key_id, data) else {
943            return Err(ProtocolStateError::StoreFailed(
944                "failed to seal protocol state record".to_string(),
945            ));
946        };
947        storage.store(key_type, key_id, &sealed)
948    }
949
950    /// Reads one protocol-state record, opening it when its category is sealed,
951    /// and discarding the bytes for callers that do not need the
952    /// missing-versus-destroyed distinction.
953    pub(crate) fn read_state_record(
954        &self,
955        storage: &dyn ProtocolStateStorage,
956        key_type: &str,
957        key_id: &str,
958    ) -> ProtocolStateResult<Option<Vec<u8>>> {
959        Ok(self
960            .read_state_record_detailed(storage, key_type, key_id)?
961            .into_bytes())
962    }
963
964    /// Loads one protocol-state entry for a caller that only needs to know
965    /// whether usable bytes are there.
966    ///
967    /// Both [`ProtocolStateError::NotFound`] and
968    /// [`ProtocolStateError::Corrupted`] read as `None`. The trait documents
969    /// `NotFound` as the variant for implementations whose platform API cannot
970    /// express absence any other way, so it has to be *read* as absence here —
971    /// otherwise a provider that honors that contract turns every record it
972    /// holds into a spurious unrecoverable loss. `Corrupted` is a record that
973    /// exists and can never be decoded, which for a probe is the same answer:
974    /// there is nothing to inherit, and writing over it is the recovery.
975    ///
976    /// Callers that owe the app a settlement need the two kept apart and go
977    /// through [`Self::read_state_record_detailed`] instead.
978    fn load_state_bytes(
979        storage: &dyn ProtocolStateStorage,
980        key_type: &str,
981        key_id: &str,
982    ) -> ProtocolStateResult<Option<Vec<u8>>> {
983        match storage.load(key_type, key_id) {
984            Ok(data) => Ok(data),
985            Err(ProtocolStateError::NotFound(_) | ProtocolStateError::Corrupted(_)) => Ok(None),
986            Err(e) => Err(e),
987        }
988    }
989
990    /// Lists one protocol-state category, treating
991    /// [`ProtocolStateError::NotFound`] as an empty category.
992    ///
993    /// Same reasoning as [`Self::load_state_bytes`]: a backend that can only
994    /// spell "nothing is filed under this key type" as `NotFound` is reporting
995    /// emptiness, not failure. Every restore propagates a listing error, so
996    /// without this a provider honoring that contract fails restore and rolls
997    /// `initialize_mls` back over a store that is merely empty.
998    fn list_state_keys(
999        storage: &dyn ProtocolStateStorage,
1000        key_type: &str,
1001    ) -> ProtocolStateResult<Vec<String>> {
1002        match storage.list_keys(key_type) {
1003            Ok(keys) => Ok(keys),
1004            Err(ProtocolStateError::NotFound(_)) => Ok(Vec::new()),
1005            Err(e) => Err(e),
1006        }
1007    }
1008
1009    /// Reads one protocol-state record, distinguishing an absent record from
1010    /// one that was destroyed and from one that merely cannot be read now.
1011    ///
1012    /// Never yields raw bytes for a record that is oversized or will not open.
1013    /// Such a record is corrupt or tampered, so it is also deleted, which keeps
1014    /// a poison record from being re-examined on every boot — and reported as
1015    /// [`StateRecord::Unreadable`], because a caller holding an app-visible
1016    /// promise about that record (an outbox entry the app was told was queued)
1017    /// has to settle it rather than let it evaporate.
1018    ///
1019    /// A missing record *key* is deliberately not that: those records may be
1020    /// perfectly good, so they are left on disk and reported as
1021    /// [`StateRecord::Unavailable`]. Nothing is recovered from them this run
1022    /// either way, but a later launch can — which is exactly why they must not
1023    /// be settled as failures now.
1024    ///
1025    /// The same split applies to what the store itself reports.
1026    /// [`ProtocolStateError::Corrupted`] is documented as a record that exists
1027    /// and cannot be decoded, which is permanent by construction — so it is
1028    /// [`StateRecord::Unreadable`], not a read to retry. Every other error is a
1029    /// failure of *this* read and stays [`StateRecord::Unavailable`].
1030    pub(crate) fn read_state_record_detailed(
1031        &self,
1032        storage: &dyn ProtocolStateStorage,
1033        key_type: &str,
1034        key_id: &str,
1035    ) -> ProtocolStateResult<StateRecord> {
1036        self.read_state_record_detailed_budgeted(storage, key_type, key_id, None)
1037    }
1038
1039    /// [`Self::read_state_record_detailed`], charging its own drop-deletes to
1040    /// `budget` when one is supplied.
1041    ///
1042    /// This reader deletes what it cannot return, and on a restore walk that is
1043    /// a durable delete like any other — the same synchronous provider round
1044    /// trip, with the same directory flush, that
1045    /// [`MAX_RESTORE_PRUNE_DELETES`] exists to bound. A walk whose records are
1046    /// *all* unreadable (a regenerated record key makes every sealed record on
1047    /// the install unopenable) would otherwise issue one per record with
1048    /// nothing counting them.
1049    ///
1050    /// A [`PruneBudget::refusing`] budget may turn the unlink down and leave the
1051    /// record for a later launch; the record is reported
1052    /// [`StateRecord::Unreadable`] either way, since nothing is ever recovered
1053    /// from it. A [`PruneBudget::counting`] one always performs the unlink and
1054    /// merely charges for it, so a settlement-paired caller
1055    /// ([`Self::restore_pending_messages`]) keeps its delete and its terminal
1056    /// event together and stops at its next record boundary instead.
1057    ///
1058    /// `None` is for the runtime callers, which are not on a restore walk and
1059    /// issue at most one delete per call. **Every** restore walk passes a
1060    /// budget, and the two that once did not are why that is worth stating: a
1061    /// walk bound was argued to be ceiling enough for [`Self::restore_outbox`],
1062    /// and a bounded-by-the-peer-count argument for
1063    /// `restore_session_states_from_manager`. Neither was. See
1064    /// [`MAX_RESTORE_PRUNE_DELETES`].
1065    fn read_state_record_detailed_budgeted(
1066        &self,
1067        storage: &dyn ProtocolStateStorage,
1068        key_type: &str,
1069        key_id: &str,
1070        mut budget: Option<&mut PruneBudget<'_>>,
1071    ) -> ProtocolStateResult<StateRecord> {
1072        let data = match storage.load(key_type, key_id) {
1073            Ok(Some(data)) => data,
1074            Ok(None) => return Ok(StateRecord::Missing),
1075            // Absence, spelled as an error by a backend that cannot spell it
1076            // any other way.
1077            Err(ProtocolStateError::NotFound(_)) => return Ok(StateRecord::Missing),
1078            // A record was there and its store cannot decode it. Nothing will
1079            // ever decode it, so this is a loss to settle rather than a read to
1080            // retry — and it is deleted so it cannot be re-examined on every
1081            // boot. Providers that detect corruption themselves have usually
1082            // deleted it already, which makes the delete a no-op.
1083            Err(ProtocolStateError::Corrupted(detail)) => {
1084                warn!(
1085                    key_type = %key_type,
1086                    key_id = %key_id,
1087                    detail = %detail,
1088                    "Protocol state record reported corrupt by its store; dropping"
1089                );
1090                if claim_prune(&mut budget) {
1091                    let _ = storage.delete(key_type, key_id);
1092                }
1093                return Ok(StateRecord::Unreadable);
1094            }
1095            Err(e) => return Err(e),
1096        };
1097
1098        // Bound before deserialization: a corrupted or tampered record must not
1099        // become an unbounded allocation while parsing during startup. The
1100        // limit is on the *plaintext*, which is what the write side checks, so
1101        // a sealed record is allowed its envelope on top — otherwise a record
1102        // written at exactly the cap could never be read back.
1103        let sealed = record_requires_sealing(key_type);
1104        let limit = if sealed {
1105            MAX_PROTOCOL_STATE_RECORD_BYTES.saturating_add(SEALED_RECORD_OVERHEAD)
1106        } else {
1107            MAX_PROTOCOL_STATE_RECORD_BYTES
1108        };
1109        if data.len() > limit {
1110            warn!(
1111                key_type = %key_type,
1112                key_id = %key_id,
1113                len = data.len(),
1114                limit,
1115                "Dropping oversized protocol state record"
1116            );
1117            if claim_prune(&mut budget) {
1118                let _ = storage.delete(key_type, key_id);
1119            }
1120            return Ok(StateRecord::Unreadable);
1121        }
1122
1123        if !sealed {
1124            return Ok(StateRecord::Present(data));
1125        }
1126
1127        let Some(cipher) = &self.state_record_cipher else {
1128            // Left on disk on purpose: the record is very likely intact and the
1129            // key may be back next launch. Reporting `Unavailable` is what stops
1130            // restore from settling the whole outbox as failed and then
1131            // re-delivering it on the next boot.
1132            warn!(
1133                key_type = %key_type,
1134                key_id = %key_id,
1135                "Protocol state record key unavailable; skipping sealed record"
1136            );
1137            return Ok(StateRecord::Unavailable);
1138        };
1139
1140        match cipher.open(key_type, key_id, &data) {
1141            Some(plaintext) => Ok(StateRecord::Present(plaintext)),
1142            None => {
1143                warn!(
1144                    key_type = %key_type,
1145                    key_id = %key_id,
1146                    sealed = StateRecordCipher::looks_sealed(&data),
1147                    "Protocol state record failed to open; deleting"
1148                );
1149                if claim_prune(&mut budget) {
1150                    let _ = storage.delete(key_type, key_id);
1151                }
1152                Ok(StateRecord::Unreadable)
1153            }
1154        }
1155    }
1156
1157    // ========================================================================
1158    // PRE-SPLIT PROTOCOL STATE ADOPTION
1159    // ========================================================================
1160
1161    /// Adopts protocol state written before the storage split, moving it out of
1162    /// secure storage and into the install-scoped store.
1163    ///
1164    /// Splitting the domains renamed where this state lives. Without a sweep,
1165    /// the first launch after an upgrade would come up with an empty outbox, an
1166    /// empty pending queue, and — most sharply — an **empty block list**, since
1167    /// all of it was previously persisted through the `MlsStorage` handle. The
1168    /// records would also stay in the credential store forever with nothing
1169    /// ever reading or deleting them, which is the worst possible resting place
1170    /// for `pending_messages` (message plaintext) and `outbox` (cloud-media
1171    /// `encryption_key`/`iv`) — the very values the split exists to seal.
1172    ///
1173    /// This is a bulk move rather than the read-through
1174    /// [`crate::MlsStorage`] adoption used for MLS material, because
1175    /// [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`] is a *closed* set: the SDK
1176    /// declares every protocol-state category itself, where OpenMLS contributes
1177    /// arbitrary labels to the MLS keyspace. Enumeration therefore terminates,
1178    /// which read-through can never guarantee.
1179    ///
1180    /// **This sweep depends on that other adoption.** On a pre-upgrade install
1181    /// the records it is looking for sit in the *un-namespaced* credential
1182    /// store, and the handle it enumerates is the namespaced one — so the only
1183    /// reason `secure.list_keys` and `secure.load` reach them at all is that
1184    /// the built-in providers union in, and read through to, the legacy store
1185    /// (`MlsSecureStorage` / `SecureStorage`, see `LegacyStoreAdoption`). Two
1186    /// independently-designed mechanisms, one silently load-bearing for the
1187    /// other: a provider built without a namespace has no read-through, so this
1188    /// sweep finds nothing and the pre-split state stays where it is. Python
1189    /// warns about exactly that case at construction.
1190    ///
1191    /// Properties worth keeping if this is ever touched:
1192    ///
1193    /// - **Resumable.** Each record is deleted from secure storage only once it
1194    ///   is durably written to protocol-state storage, so a crash mid-sweep
1195    ///   leaves the remainder to be re-adopted next launch.
1196    /// - **Non-destructive.** A key already present in protocol-state storage
1197    ///   wins and its legacy twin is left alone. Post-split state is always
1198    ///   authoritative, and — since one backend may legitimately serve both
1199    ///   handles in tests — a blind copy-then-delete could otherwise delete a
1200    ///   record through the same store it just read it from.
1201    /// - **All-or-nothing marker.** The marker is written only when the sweep
1202    ///   completed without a storage error, so a transiently unavailable
1203    ///   credential store means "try again next launch", not "give up forever".
1204    /// - **Sealing applies.** Records are written through
1205    ///   [`Self::write_state_record`], so categories that require sealing are
1206    ///   sealed on the way in and the legacy plaintext is then deleted.
1207    /// - **Nothing app-visible vanishes quietly.** A legacy record too large to
1208    ///   have any reachable destination is settled before it is deleted, the
1209    ///   same way [`Self::restore_outbox`] settles one it cannot recover. This
1210    ///   is not hypothetical: the pre-split build had neither a content cap nor
1211    ///   a per-peer byte budget, only 64 entries per peer, so the installs this
1212    ///   branch's budgets exist for are exactly the ones whose legacy records
1213    ///   can exceed [`MAX_PROTOCOL_STATE_RECORD_BYTES`].
1214    pub(crate) fn adopt_legacy_protocol_state(&mut self) {
1215        let (Some(secure), Some(state)) = (
1216            self.secure_storage.clone(),
1217            self.protocol_state_storage.clone(),
1218        ) else {
1219            return;
1220        };
1221
1222        match Self::load_state_bytes(
1223            state.as_ref(),
1224            storage_keys::STATE_ADOPTION,
1225            storage_keys::STATE_ADOPTION_ID,
1226        ) {
1227            Ok(Some(_)) => return,
1228            Ok(None) => {}
1229            Err(e) => {
1230                // Unknown whether the sweep already ran. Skipping is the safe
1231                // side: adoption is non-destructive, so re-running it later
1232                // costs nothing, while running it against a store we cannot
1233                // read is unnecessary work on every boot.
1234                warn!(error = %e, "Could not read the protocol-state adoption marker; skipping adoption");
1235                return;
1236            }
1237        }
1238
1239        let mut adopted = 0usize;
1240        let mut failed = false;
1241        // Settlements for records the sweep had to destroy. Collected rather
1242        // than emitted inline because the per-record helper only holds `&self`,
1243        // and because they belong on the same deferred path every other restore
1244        // settlement uses.
1245        let mut unadoptable: Vec<Event> = Vec::new();
1246        for key_type in storage_keys::ADOPTABLE_STATE_KEY_TYPES {
1247            let key_ids = match secure.list_keys(key_type) {
1248                Ok(ids) => ids,
1249                Err(e) => {
1250                    warn!(key_type = %key_type, error = %e, "Failed to list legacy protocol state");
1251                    failed = true;
1252                    continue;
1253                }
1254            };
1255
1256            if key_ids.len() > MAX_RESTORE_KEYS_PER_CATEGORY {
1257                // Never reachable from state this SDK wrote, so bound the pass
1258                // — but a truncated pass is not a completed one. Withholding
1259                // the marker is what makes the tail drain over successive
1260                // launches (each one adopts and deletes a prefix) instead of
1261                // being abandoned in the credential store forever, which for
1262                // `pending_messages` and `outbox` means message plaintext and
1263                // cloud-media key material parked in the one place this sweep
1264                // exists to clear.
1265                warn!(
1266                    key_type = %key_type,
1267                    listed = key_ids.len(),
1268                    cap = MAX_RESTORE_KEYS_PER_CATEGORY,
1269                    "Legacy protocol state listed more entries than any legitimate run can produce; adopting a prefix and retrying the rest next launch"
1270                );
1271                failed = true;
1272            }
1273            for key_id in key_ids.into_iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
1274                match self.adopt_one_legacy_record(
1275                    secure.as_ref(),
1276                    state.as_ref(),
1277                    key_type,
1278                    &key_id,
1279                    &mut unadoptable,
1280                ) {
1281                    Ok(true) => adopted += 1,
1282                    Ok(false) => {}
1283                    Err(()) => failed = true,
1284                }
1285            }
1286        }
1287
1288        // Settle before the early return below: these records are already gone,
1289        // so a partial sweep must not also withhold the only signal the app will
1290        // ever get about them.
1291        for event in unadoptable {
1292            self.settle_restored_message_failure(event);
1293        }
1294
1295        if failed {
1296            warn!(
1297                adopted,
1298                "Pre-split protocol state only partially adopted; will retry on the next launch"
1299            );
1300            return;
1301        }
1302
1303        if let Err(e) = self.write_state_record(
1304            state.as_ref(),
1305            storage_keys::STATE_ADOPTION,
1306            storage_keys::STATE_ADOPTION_ID,
1307            &[],
1308        ) {
1309            // Without the marker the sweep simply runs again next launch. It is
1310            // idempotent, so that costs one wasted enumeration, not correctness.
1311            warn!(error = %e, "Failed to record the protocol-state adoption marker");
1312        }
1313        if adopted > 0 {
1314            info!(
1315                count = adopted,
1316                "Adopted pre-split protocol state from secure storage"
1317            );
1318        }
1319    }
1320
1321    /// Moves one legacy record. `Ok(true)` when it was adopted, `Ok(false)`
1322    /// when there was nothing to do, `Err(())` when a storage failure means the
1323    /// sweep must be retried later. Settlements for records this had to destroy
1324    /// are pushed onto `unadoptable` for the caller to emit.
1325    fn adopt_one_legacy_record(
1326        &self,
1327        secure: &dyn MlsStorage,
1328        state: &dyn ProtocolStateStorage,
1329        key_type: &str,
1330        key_id: &str,
1331        unadoptable: &mut Vec<Event>,
1332    ) -> std::result::Result<bool, ()> {
1333        // Post-split state wins: never overwrite a record this build wrote.
1334        //
1335        // The probe gets the same three-way treatment as every other read, for
1336        // the same reason. A destination the store itself destroyed is not
1337        // absent — the app holds ids for it — so the sweep proceeds but owes a
1338        // settlement, which the legacy record only discharges by replacing it.
1339        // A destination that is merely unreadable *this session* is left alone
1340        // entirely: adopting over it would overwrite a record a later launch
1341        // can still read.
1342        let destination_destroyed = match self.read_state_record_detailed(state, key_type, key_id) {
1343            Ok(StateRecord::Present(_)) => return Ok(false),
1344            Ok(StateRecord::Missing) => false,
1345            Ok(StateRecord::Unreadable) => true,
1346            Ok(StateRecord::Unavailable) => {
1347                warn!(
1348                    key_type = %key_type,
1349                    "Protocol state record unreadable this session; deferring adoption of its legacy twin"
1350                );
1351                return Err(());
1352            }
1353            Err(e) => {
1354                warn!(key_type = %key_type, error = %e, "Failed to probe protocol state during adoption");
1355                return Err(());
1356            }
1357        };
1358
1359        let data = match secure.load(key_type, key_id) {
1360            Ok(Some(data)) => data,
1361            // A key that listed but no longer loads was deleted underneath us.
1362            Ok(None) => {
1363                // Nothing left to inherit *and* the destination was destroyed
1364                // by the probe, so this is the last moment anything can name
1365                // what the app is still holding.
1366                if destination_destroyed {
1367                    unadoptable.extend(Self::unadoptable_record_settlement(key_type, key_id));
1368                }
1369                return Ok(false);
1370            }
1371            Err(e) => {
1372                warn!(key_type = %key_type, error = %e, "Failed to read legacy protocol state");
1373                return Err(());
1374            }
1375        };
1376
1377        if data.len() > MAX_PROTOCOL_STATE_RECORD_BYTES {
1378            // Over the record cap, so it could never be written or restored.
1379            // Delete it rather than retrying the sweep forever over a record
1380            // that has no reachable destination — but settle first: the app is
1381            // holding ids from before the upgrade, and this is the last moment
1382            // anything can name them.
1383            warn!(
1384                key_type = %key_type,
1385                len = data.len(),
1386                "Dropping oversized legacy protocol state record"
1387            );
1388            unadoptable.extend(Self::unadoptable_record_settlement(key_type, key_id));
1389            let _ = secure.delete(key_type, key_id);
1390            return Ok(false);
1391        }
1392
1393        if let Err(e) = self.write_state_record(state, key_type, key_id, &data) {
1394            warn!(key_type = %key_type, error = %e, "Failed to adopt legacy protocol state record");
1395            return Err(());
1396        }
1397
1398        // Only now is the legacy copy redundant. Ordering matters: a delete
1399        // before the write would lose the record on a crash in between.
1400        if let Err(e) = secure.delete(key_type, key_id) {
1401            // The record is safely adopted; the leftover is cosmetic and the
1402            // next sweep's "already present" check will skip it. Do not fail
1403            // the sweep over it.
1404            debug!(key_type = %key_type, error = %e, "Failed to delete adopted legacy protocol state record");
1405        }
1406        Ok(true)
1407    }
1408
1409    /// The settlement owed for a legacy record that had to be destroyed rather
1410    /// than adopted, or `None` for a category the app holds no promise about.
1411    ///
1412    /// Mirrors what [`Self::restore_outbox`] and [`Self::restore_pending_messages`]
1413    /// emit for the same loss after the split: an outbox record is nameable
1414    /// without opening it, because its record key *is* the message id, while a
1415    /// pending queue's ids are inside the record — so the recipient is the most
1416    /// that can be reported.
1417    ///
1418    /// An outbox key that does not parse as a [`MessageId`] falls back to the
1419    /// same diagnostic the pending queue uses rather than to silence. It should
1420    /// not exist — this SDK has only ever keyed the outbox by message id — but
1421    /// "should not exist" is exactly the class of record this whole path is for,
1422    /// and the sweep deletes it either way. A settlement nobody can act on still
1423    /// beats a destruction nobody is told about, which is the invariant the rest
1424    /// of this module spends its time enforcing.
1425    fn unadoptable_record_settlement(key_type: &str, key_id: &str) -> Option<Event> {
1426        match key_type {
1427            storage_keys::OUTBOX => Some(Self::unrecoverable_message_settlement(
1428                key_id,
1429                "Outbox entry from a previous version was too large to migrate",
1430                0,
1431            )),
1432            storage_keys::PENDING_MESSAGES => Some(Event::convergence_diag(
1433                "pending_state_lost".to_string(),
1434                key_id.to_string(),
1435                "Messages queued before the storage split exceeded the protocol-state record \
1436                 limit and could not be migrated"
1437                    .to_string(),
1438            )),
1439            _ => None,
1440        }
1441    }
1442
1443    /// The settlement owed for a message-keyed record that has been destroyed
1444    /// and cannot be recovered — an outbox entry or a pending-queue entry.
1445    ///
1446    /// The record key *is* the message id, so the loss is nameable without
1447    /// opening the record — but only if the key parses. A key that does not is
1448    /// reported as a `pending_state_lost` diagnostic carrying the raw key,
1449    /// rather than as silence. It should not exist (this SDK keys both
1450    /// categories by message id), but "should not exist" is exactly the class of
1451    /// record every caller of this is handling, and the record is deleted either
1452    /// way. A settlement nobody can act on still beats a destruction nobody is
1453    /// told about.
1454    fn unrecoverable_message_settlement(key_id: &str, reason: &str, attempts: u32) -> Event {
1455        MessageId::from_str(key_id).map_or_else(
1456            |_| {
1457                Event::convergence_diag(
1458                    "pending_state_lost".to_string(),
1459                    key_id.to_string(),
1460                    format!(
1461                        "{reason}, and its message id could not be recovered from the record key"
1462                    ),
1463                )
1464            },
1465            |message_id| Event::message_failed(message_id, reason.to_string(), attempts),
1466        )
1467    }
1468
1469    // ========================================================================
1470    // PROTOCOL-STATE RECORD KEY
1471    // ========================================================================
1472
1473    /// Loads (or, on first run, generates and persists) the per-install key that
1474    /// seals sensitive protocol-state records.
1475    ///
1476    /// The key lives in *secure* storage while the records it protects live in
1477    /// the install-scoped container: uninstalling the app drops the container,
1478    /// and a container lifted without the credential store yields only
1479    /// ciphertext.
1480    ///
1481    /// Unlike the scrub and Nostr secrets, this one does **not** degrade to a
1482    /// session-local value when it cannot be persisted. A key that does not
1483    /// survive the process would seal records nothing could ever open, so a
1484    /// persist failure leaves the cipher uninstalled and sensitive categories
1485    /// simply are not persisted this session (they stay in memory and are
1486    /// re-driven from there, exactly as when no storage is configured at all).
1487    ///
1488    /// Unlike the other secret-restore paths this one has no "already loaded"
1489    /// short circuit, so the installed cipher is always the one belonging to
1490    /// the secure storage currently attached. Reusing a cipher across a storage
1491    /// swap would seal records under a key the next launch cannot find, which
1492    /// reads as silent loss of every sealed record.
1493    ///
1494    /// That invariant is enforced, not just documented: any previously
1495    /// installed cipher is dropped *before* the new store is read, so every
1496    /// path out of here either installs the key belonging to the currently
1497    /// attached store or leaves none at all. A failed load must not fall back
1498    /// to the key of a store we are no longer using — that cipher would open
1499    /// nothing here (records written under the new store's key are deleted as
1500    /// unauthentic on read) and would seal records the next launch cannot
1501    /// find a key for.
1502    ///
1503    /// # A present-but-wrong-length key is the destructive case
1504    ///
1505    /// The two failure branches below are deliberately asymmetric, and it is
1506    /// worth being explicit about why, because the asymmetry looks like an
1507    /// oversight and is not.
1508    ///
1509    /// A *failed load* leaves the cipher uninstalled. Sealed records then read
1510    /// as [`StateRecord::Unavailable`]: they stay on disk, nothing is settled,
1511    /// and a launch that can read the key recovers them. That is the recoverable
1512    /// case, and it is treated as one.
1513    ///
1514    /// A blob of the *wrong length* is not the key and never will be — no
1515    /// process can recover the original from it — so everything sealed under
1516    /// that key is already unrecoverable by the time this function runs.
1517    /// Regenerating is therefore not what destroys those records; it is what
1518    /// lets the install seal again. The visible consequence is still large: the
1519    /// records fail to open on the next read, are reported
1520    /// [`StateRecord::Unreadable`], and restore settles the whole outbox and
1521    /// pending queue as terminal `message_failed`. That is the honest answer —
1522    /// the alternative, refusing to regenerate, would preserve ciphertext
1523    /// nobody can ever open while permanently disabling persistence for every
1524    /// sensitive category.
1525    ///
1526    /// What it must not do is look like routine key generation in the log, so
1527    /// the warning names the consequence rather than the symptom.
1528    pub(crate) fn restore_or_init_state_record_key(&mut self) {
1529        self.state_record_cipher = None;
1530
1531        let Some(storage) = &self.secure_storage else {
1532            return;
1533        };
1534
1535        let key: Zeroizing<[u8; STATE_RECORD_KEY_BYTES]> = match storage.load(
1536            storage_keys::STATE_RECORD_KEY,
1537            storage_keys::STATE_RECORD_KEY_ID,
1538        ) {
1539            Ok(Some(bytes)) if bytes.len() == STATE_RECORD_KEY_BYTES => {
1540                let bytes = Zeroizing::new(bytes);
1541                let mut key = Zeroizing::new([0u8; STATE_RECORD_KEY_BYTES]);
1542                key.copy_from_slice(&bytes);
1543                debug!("Restored protocol state record key from secure storage");
1544                key
1545            }
1546            Ok(other) => {
1547                // A wrong-length blob is a corrupt write, not a usable key, and
1548                // nothing can recover the original from it — so whatever it
1549                // sealed is already lost before this runs. Regenerating is what
1550                // lets the install seal again; see the note on this function
1551                // for why refusing to is worse.
1552                if let Some(bytes) = &other {
1553                    warn!(
1554                        len = bytes.len(),
1555                        expected = STATE_RECORD_KEY_BYTES,
1556                        "Protocol state record key is not a key and cannot be recovered; \
1557                         regenerating. Every record sealed under the old key is \
1558                         unrecoverable and will be settled as failed on restore — this is \
1559                         not routine key generation"
1560                    );
1561                }
1562                let fresh = StateRecordCipher::generate_key();
1563                if let Err(e) = storage.store(
1564                    storage_keys::STATE_RECORD_KEY,
1565                    storage_keys::STATE_RECORD_KEY_ID,
1566                    &*fresh,
1567                ) {
1568                    warn!(
1569                        error = %e,
1570                        "Failed to persist protocol state record key; \
1571                         sensitive protocol state will not be persisted this session"
1572                    );
1573                    return;
1574                }
1575                info!("Generated and persisted per-install protocol state record key");
1576                fresh
1577            }
1578            Err(e) => {
1579                warn!(
1580                    error = %e,
1581                    "Failed to load protocol state record key; \
1582                     sensitive protocol state will not be persisted this session"
1583                );
1584                return;
1585            }
1586        };
1587
1588        self.state_record_cipher = Some(StateRecordCipher::new(&key));
1589    }
1590
1591    // ========================================================================
1592    // PENDING MESSAGES PERSISTENCE
1593    // ========================================================================
1594
1595    /// Persists one queued message, keyed by its own id, returning whether the
1596    /// record is durably on disk.
1597    ///
1598    /// Best-effort on the send path, like [`Self::persist_outbox_entry`]: it
1599    /// cannot propagate storage errors, so a failed write is logged and the
1600    /// return ignored. The message still lives in the in-memory queue and still
1601    /// flushes when the session establishes; it just will not survive a
1602    /// restart. The migration path must honour the return instead — its next
1603    /// step is deleting the legacy record these entries came out of, and doing
1604    /// that on top of a failed write would destroy the only surviving copy (see
1605    /// [`Self::persist_migrated_pending_queues`]).
1606    ///
1607    /// Re-persisting an id already on disk is an ordinary overwrite of its own
1608    /// record, which is what makes the flush-time re-queue path idempotent.
1609    pub(crate) fn persist_pending_message(
1610        &self,
1611        recipient: &str,
1612        message: &PendingMessage,
1613    ) -> bool {
1614        let Some(storage) = &self.protocol_state_storage else {
1615            return false;
1616        };
1617        let record = PendingMessageRecord {
1618            recipient: recipient.to_string(),
1619            message: message.clone(),
1620        };
1621        match serde_json::to_vec(&record) {
1622            Ok(data) => {
1623                if let Err(e) = self.write_state_record(
1624                    storage.as_ref(),
1625                    storage_keys::PENDING_MESSAGE_ENTRIES,
1626                    &record.message.message_id.as_str(),
1627                    &data,
1628                ) {
1629                    warn!(
1630                        recipient = %recipient,
1631                        message_id = %record.message.message_id,
1632                        error = %e,
1633                        "Failed to persist pending message"
1634                    );
1635                    return false;
1636                }
1637                true
1638            }
1639            Err(e) => {
1640                warn!(
1641                    recipient = %recipient,
1642                    message_id = %record.message.message_id,
1643                    error = %e,
1644                    "Failed to serialize pending message"
1645                );
1646                false
1647            }
1648        }
1649    }
1650
1651    /// Removes one persisted pending message.
1652    ///
1653    /// Best-effort and logged rather than swallowed, for the reason the old
1654    /// whole-queue clear was: a delete that silently failed leaves a record the
1655    /// next launch restores and re-flushes. The message carries its original id
1656    /// so receivers dedup it, but the sender re-emits `MessageSent` for traffic
1657    /// it already delivered, and nothing would say why.
1658    pub(crate) fn delete_pending_message_from_storage(&self, message_id: &MessageId) {
1659        let Some(storage) = &self.protocol_state_storage else {
1660            return;
1661        };
1662        if let Err(e) = storage.delete(storage_keys::PENDING_MESSAGE_ENTRIES, &message_id.as_str())
1663        {
1664            warn!(
1665                message_id = %message_id,
1666                error = %e,
1667                "Failed to clear persisted pending message"
1668            );
1669        }
1670    }
1671
1672    /// Removes the persisted copies of a batch of queued messages.
1673    pub(crate) fn delete_pending_messages_from_storage<'a>(
1674        &self,
1675        message_ids: impl IntoIterator<Item = &'a MessageId>,
1676    ) {
1677        for message_id in message_ids {
1678            self.delete_pending_message_from_storage(message_id);
1679        }
1680    }
1681
1682    /// Reassembles one recipient's persisted queue from the per-message
1683    /// records, in canonical queue order.
1684    ///
1685    /// Test-only, and deliberately the expensive spelling — it lists the whole
1686    /// category and filters. Production restore reads every record once and
1687    /// groups them in a single pass ([`Self::restore_pending_messages`]);
1688    /// nothing on the runtime path ever needs one peer's queue *from storage*,
1689    /// because the in-memory map is the source of truth.
1690    #[cfg(test)]
1691    pub(crate) fn load_pending_messages_from_storage(
1692        &self,
1693        recipient: &str,
1694    ) -> Option<Vec<PendingMessage>> {
1695        let storage = self.protocol_state_storage.as_ref()?;
1696        let keys =
1697            Self::list_state_keys(storage.as_ref(), storage_keys::PENDING_MESSAGE_ENTRIES).ok()?;
1698        let mut messages: Vec<PendingMessage> = keys
1699            .iter()
1700            .filter_map(|key| {
1701                let data = self
1702                    .read_state_record_detailed(
1703                        storage.as_ref(),
1704                        storage_keys::PENDING_MESSAGE_ENTRIES,
1705                        key,
1706                    )
1707                    .ok()?
1708                    .into_bytes()?;
1709                let record = serde_json::from_slice::<PendingMessageRecord>(&data).ok()?;
1710                (record.recipient == recipient).then_some(record.message)
1711            })
1712            .collect();
1713        if messages.is_empty() {
1714            return None;
1715        }
1716        sort_pending_queue(&mut messages);
1717        for message in &mut messages {
1718            message.measure();
1719        }
1720        Some(messages)
1721    }
1722
1723    /// Loads a recipient's queue from the **legacy** per-recipient record,
1724    /// distinguishing "nothing queued" from "a queue existed and its contents
1725    /// are gone" from "a queue exists and cannot be read this session".
1726    ///
1727    /// Entries come back *unmeasured*: the derived `serialized_bytes` is
1728    /// deliberately not persisted, and measuring re-serializes every entry, so
1729    /// the caller measures only what it decides to keep rather than paying for
1730    /// entries the per-peer count trim is about to drop. Anything that enters
1731    /// `pending_encrypted_messages` must be measured first or it reads as free
1732    /// against the queue byte budgets.
1733    ///
1734    /// `budget` counts the durable deletes this read causes — the reader's own
1735    /// drop of a record that will not open, and the drop below of one whose
1736    /// bytes will not parse. It must be a [`PruneBudget::counting`] budget, so
1737    /// neither delete is ever refused: both are paired with the
1738    /// `pending_state_lost` the caller emits, and settling a record still
1739    /// sitting on disk is a claim the next launch contradicts. The caller stops
1740    /// walking once the budget reads spent instead.
1741    fn load_legacy_pending_queue(
1742        &self,
1743        recipient: &str,
1744        mut budget: Option<&mut PruneBudget<'_>>,
1745    ) -> PendingRestore {
1746        let Some(storage) = self.protocol_state_storage.as_ref() else {
1747            return PendingRestore::Absent;
1748        };
1749        let record = self.read_state_record_detailed_budgeted(
1750            storage.as_ref(),
1751            storage_keys::PENDING_MESSAGES,
1752            recipient,
1753            budget.as_deref_mut(),
1754        );
1755        let data = match record {
1756            Ok(StateRecord::Present(data)) => data,
1757            Ok(StateRecord::Missing) => return PendingRestore::Absent,
1758            // Examined and destroyed: the queue that existed is gone for good.
1759            Ok(StateRecord::Unreadable) => return PendingRestore::Lost,
1760            // Still on disk, just not readable now. Reporting it as lost would
1761            // be contradicted by the next launch restoring it.
1762            Ok(StateRecord::Unavailable) | Err(_) => return PendingRestore::Unavailable,
1763        };
1764        let Ok(messages) = serde_json::from_slice::<Vec<PendingMessage>>(&data) else {
1765            // Parsed-as-garbage is the same loss as failed-to-open. Drop the
1766            // record so it is not re-parsed on every boot.
1767            warn!(recipient = %recipient, "Dropping corrupted pending message record");
1768            // Charged, and the answer deliberately not honoured: this delete is
1769            // paired with the caller's `pending_state_lost`, so a budget that
1770            // refused it would leave a settled record on disk. A `counting`
1771            // budget never refuses; ignoring the answer is what keeps a
1772            // mistakenly-`refusing` one failing in the safe direction.
1773            claim_prune(&mut budget);
1774            let _ = storage.delete(storage_keys::PENDING_MESSAGES, recipient);
1775            return PendingRestore::Lost;
1776        };
1777        PendingRestore::Restored(messages)
1778    }
1779
1780    /// Drops a legacy per-recipient record once its entries have been migrated
1781    /// into the per-message layout, or once they have been settled.
1782    ///
1783    /// Only ever called with a record this session has already *read*, which is
1784    /// what makes it safe without the freeze the old whole-queue clear needed:
1785    /// nothing outside the migration walk touches this category any more, so a
1786    /// record no walk reached is simply read by a later launch. (The one other
1787    /// writer, `adopt_legacy_protocol_state`, moves pre-split records *into*
1788    /// this category — but it runs earlier in the same launch, before this
1789    /// walk, so it is not an exception to the conclusion.)
1790    ///
1791    /// Returns whether the record is gone. Nothing else ever writes this
1792    /// category during or after the walk, so a delete that failed is permanent
1793    /// for the session — the caller has to undo the migration rather than log
1794    /// and move on, or the surviving record re-files its whole queue on a later
1795    /// launch. See [`Self::forget_migrated_entries`].
1796    fn delete_legacy_pending_queue(&self, recipient: &str) -> bool {
1797        if let Some(storage) = &self.protocol_state_storage {
1798            if let Err(e) = storage.delete(storage_keys::PENDING_MESSAGES, recipient) {
1799                warn!(
1800                    recipient = %recipient,
1801                    error = %e,
1802                    "Failed to clear migrated legacy pending queue"
1803                );
1804                return false;
1805            }
1806        }
1807        true
1808    }
1809
1810    /// Admits one recipient's recovered queue, holding it to the same caps the
1811    /// live admission path enforces.
1812    ///
1813    /// Split out of [`Self::restore_pending_messages`] because it is the one
1814    /// part of that walk with its own invariants rather than its own control
1815    /// flow: a record written by an older build — or by a build with no caps at
1816    /// all, which is every pre-split build — can hold entries the current
1817    /// boundary would reject, so restore has to re-apply four bounds in a fixed
1818    /// order.
1819    ///
1820    /// The order is load-bearing:
1821    ///
1822    /// 1. **Count trim first**, so the measure below is not paid for entries
1823    ///    that are about to be dropped anyway.
1824    /// 2. **Measure the survivors.** `serialized_bytes` is derived and not
1825    ///    persisted, and everything after this point — both byte budgets and the
1826    ///    map insert — reads an unmeasured entry as free.
1827    /// 3. **Per-peer byte trim**, oldest-first like every other eviction here.
1828    /// 4. **Global count and byte trim**, which can evict from a recipient
1829    ///    admitted on an earlier iteration, hence the shared accumulator.
1830    ///
1831    /// Every entry this drops goes into `capacity_evicted`, and the caller both
1832    /// settles those ids and deletes their records. Under the per-recipient
1833    /// layout the trim also had to *rewrite* the survivors' record, so it
1834    /// tracked which recipients had changed; now a dropped entry is just its own
1835    /// record, and the survivors' records are already correct.
1836    fn admit_restored_pending_queue(
1837        &mut self,
1838        recipient: &str,
1839        mut messages: Vec<PendingMessage>,
1840        admission: &mut RestoredPendingAdmission,
1841    ) {
1842        let overflow = messages.len().saturating_sub(MAX_PENDING_MESSAGES_PER_PEER);
1843        if overflow > 0 {
1844            admission
1845                .capacity_evicted
1846                .extend(messages.drain(..overflow).map(|message| message.message_id));
1847        }
1848        for message in &mut messages {
1849            message.measure();
1850        }
1851
1852        let mut peer_bytes: usize = messages
1853            .iter()
1854            .map(|message| message.serialized_bytes)
1855            .sum();
1856        let mut dropped_for_bytes = 0usize;
1857        while peer_bytes > MAX_PENDING_MESSAGE_BYTES_PER_PEER && !messages.is_empty() {
1858            let message = messages.remove(0);
1859            peer_bytes = peer_bytes.saturating_sub(message.serialized_bytes);
1860            admission.capacity_evicted.push(message.message_id);
1861            dropped_for_bytes += 1;
1862        }
1863        if dropped_for_bytes > 0 {
1864            warn!(
1865                recipient = %recipient,
1866                dropped = dropped_for_bytes,
1867                limit = MAX_PENDING_MESSAGE_BYTES_PER_PEER,
1868                "Restored pending queue exceeded the per-peer byte budget"
1869            );
1870        }
1871
1872        if !messages.is_empty() {
1873            info!(recipient = %recipient, count = messages.len(), "Restored pending messages from storage");
1874            admission.global_count += messages.len();
1875            admission.global_bytes += peer_bytes;
1876            self.pending_encrypted_messages
1877                .insert(recipient.to_string(), messages);
1878        }
1879
1880        while admission.global_count > MAX_PENDING_MESSAGES_GLOBAL
1881            || admission.global_bytes > MAX_PENDING_MESSAGE_BYTES_GLOBAL
1882        {
1883            let Some((_, message)) = self.evict_oldest_pending_message() else {
1884                break;
1885            };
1886            admission.global_count = admission.global_count.saturating_sub(1);
1887            admission.global_bytes = admission
1888                .global_bytes
1889                .saturating_sub(message.serialized_bytes);
1890            admission.capacity_evicted.push(message.message_id);
1891        }
1892    }
1893
1894    /// Restores all pending messages from storage on startup.
1895    ///
1896    /// Two passes over two layouts:
1897    ///
1898    /// 1. **Per-message records** ([`storage_keys::PENDING_MESSAGE_ENTRIES`]),
1899    ///    which is what this SDK writes. Each is one queued message keyed by its
1900    ///    own id, so a record that will not open still *names* the message it
1901    ///    destroyed and is settled with a `message_failed` for exactly that id —
1902    ///    the reason the layout exists.
1903    /// 2. **Legacy per-recipient records** ([`storage_keys::PENDING_MESSAGES`]),
1904    ///    migrated forward: read the queue, and once the caps have been applied
1905    ///    write each *surviving* entry as its own record and drop the legacy one
1906    ///    ([`Self::persist_migrated_pending_queues`]). A loss there is still only
1907    ///    reportable per peer, because every id was inside the record that would
1908    ///    not open.
1909    ///
1910    /// The migration writes before it deletes, so a crash in between leaves the
1911    /// entries in *both* layouts rather than neither. Pass 1 runs first and
1912    /// claims those ids — recovered or settled — so pass 2 skips them and the
1913    /// next launch converges.
1914    ///
1915    /// Neither pass needs the freeze the per-recipient walk did. Nothing outside
1916    /// this walk writes or deletes the legacy category any more, and a
1917    /// per-message record is only ever written or deleted under its own id — so
1918    /// an unwalked record is simply read by a later launch, which is the same
1919    /// argument [`Self::restore_outbox`] makes for the outbox.
1920    ///
1921    /// `allowance` is this walk's own pool, built by the launch — see
1922    /// [`PruneAllowance::pool`] and [`MAX_RESTORE_PRUNE_DELETES`].
1923    pub(crate) fn restore_pending_messages(
1924        &mut self,
1925        allowance: &mut PruneAllowance,
1926    ) -> Result<()> {
1927        let Some(storage) = self.protocol_state_storage.clone() else {
1928            return Ok(());
1929        };
1930
1931        // Counting rather than refusing: every delete either pass issues is
1932        // paired with a settlement, and a settlement for a record still on disk
1933        // is a claim the next launch contradicts. So the walk spends its
1934        // allowance, then stops at the next *record boundary* — the one place
1935        // stopping is safe — and leaves the rest for a later launch.
1936        //
1937        // Its own pool rather than the launch-wide advisory one: being starved
1938        // here means every diagnostic this walk owes is deferred, which must not
1939        // be a consequence of a key-package flood in an unrelated category. The
1940        // pool is passed in rather than built here — see `PruneAllowance::pool`.
1941        let mut budget = allowance.counting();
1942        let mut walk = PendingRestoreWalk::default();
1943
1944        self.restore_pending_message_entries(storage.as_ref(), &mut budget, &mut walk)?;
1945        self.migrate_legacy_pending_queues(storage.as_ref(), &mut budget, &mut walk)?;
1946
1947        // Records come back in whatever order the store enumerates them, which
1948        // carries no ordering information at all, so the canonical order has to
1949        // be re-imposed before the oldest-first trims below mean anything.
1950        // Recipients are visited in a stable order for the same reason: the
1951        // global caps evict across peers, so a map's iteration order would make
1952        // *which* entries survive a coin flip.
1953        let mut admission = RestoredPendingAdmission::default();
1954        let mut grouped: Vec<(String, Vec<PendingMessage>)> =
1955            std::mem::take(&mut walk.grouped).into_iter().collect();
1956        grouped.sort_by(|left, right| left.0.cmp(&right.0));
1957        for (recipient, mut messages) in grouped {
1958            sort_pending_queue(&mut messages);
1959            self.admit_restored_pending_queue(&recipient, messages, &mut admission);
1960        }
1961
1962        let RestoredPendingAdmission {
1963            capacity_evicted, ..
1964        } = admission;
1965
1966        // The migration finishes *before* anything it produced is settled. A
1967        // legacy record still on disk can re-file every id it holds, so a
1968        // settlement for one of its entries would be a terminal answer a later
1969        // launch overturns — the recipients it could not finish come back here
1970        // as `abandoned`, with their entries already taken back out of memory.
1971        let abandoned = self.persist_migrated_pending_queues(&mut budget, &mut walk);
1972
1973        // Every id the caps dropped had its own record, so the record goes with
1974        // the settlement rather than the survivors being rewritten around it —
1975        // unless it came out of a legacy queue, which has no per-message record
1976        // yet because the persist above writes only what survived.
1977        //
1978        // Budgeted like the outbox's capacity drain, and skipped between entries
1979        // for the same reason: an entry the pool cannot fund is already out of
1980        // memory and is left on disk **unsettled**, so a later launch restores
1981        // it, re-caps it, and owns both halves then.
1982        let mut capacity_settlements = Vec::new();
1983        for message_id in capacity_evicted {
1984            match walk.migrated.get(&message_id.as_str()) {
1985                // Recovered from a legacy record that is still on disk. That
1986                // record will re-file this entry next launch, which is where
1987                // both halves belong.
1988                Some(recipient) if abandoned.contains(recipient) => continue,
1989                // Recovered from a legacy record that is gone, and never written
1990                // under its own id — so there is no record to delete.
1991                Some(_) => {}
1992                // Its own record, which goes with the settlement.
1993                None => {
1994                    if budget.is_spent() {
1995                        walk.prune_bound_reached = true;
1996                        continue;
1997                    }
1998                    budget.claim();
1999                    self.delete_pending_message_from_storage(&message_id);
2000                }
2001            }
2002            capacity_settlements.push(Event::message_failed(
2003                message_id,
2004                "Pending session queue capacity exceeded".to_string(),
2005                0,
2006            ));
2007        }
2008        self.settle_restored_message_failures(capacity_settlements);
2009
2010        if walk.entry_bound_reached {
2011            warn!(
2012                examined = walk.examined_entries,
2013                cap = MAX_PENDING_RESTORE_ENTRIES,
2014                "Pending message store held more queued entries than one restore may walk; \
2015                 the remainder is left on disk for a later launch"
2016            );
2017        }
2018        if walk.prune_bound_reached {
2019            warn!(
2020                deleted = budget.spent,
2021                budget = MAX_RESTORE_PRUNE_DELETES,
2022                "Pending message restore spent its per-launch delete budget; the remainder \
2023                 is left on disk for a later launch"
2024            );
2025        }
2026        if walk.write_bound_reached {
2027            warn!(
2028                cap = MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH,
2029                "Legacy pending migration reached its per-launch write budget; the remaining \
2030                 queues are left on disk for a later launch"
2031            );
2032        }
2033
2034        self.settle_restored_message_failures(walk.unaddressable.into_iter().map(|message_id| {
2035            Event::message_failed(
2036                message_id,
2037                "Recipient is not a valid user ID; queued message cannot be delivered".to_string(),
2038                0,
2039            )
2040        }));
2041        // Withheld, not emitted, when a surviving legacy record also holds the
2042        // id: that record re-files it next launch, so a terminal answer now
2043        // would be overturned — the same rule the `capacity_evicted` loop above
2044        // applies through `abandoned`.
2045        self.settle_restored_message_failures(
2046            walk.lost_ids
2047                .iter()
2048                .filter(|key| {
2049                    !walk
2050                        .seen_in_legacy
2051                        .get(*key)
2052                        .is_some_and(|recipient| abandoned.contains(recipient))
2053                })
2054                .map(|key| {
2055                    Self::unrecoverable_message_settlement(
2056                        key,
2057                        "Queued message awaiting session establishment could not be recovered \
2058                         from protocol-state storage",
2059                        0,
2060                    )
2061                }),
2062        );
2063        for recipient in &walk.lost_recipients {
2064            warn!(recipient = %recipient, "Persisted legacy pending queue was unreadable and has been dropped");
2065        }
2066        self.settle_restored_message_failures(walk.lost_recipients.into_iter().map(|recipient| {
2067            Event::convergence_diag(
2068                "pending_state_lost".to_string(),
2069                recipient,
2070                "Queued messages awaiting session establishment could not be recovered \
2071                 from protocol-state storage and have been dropped"
2072                    .to_string(),
2073            )
2074        }));
2075
2076        self.recompute_next_pending_message_expiry();
2077        self.cleanup_expired_pending_messages();
2078        Ok(())
2079    }
2080
2081    /// Gives every migrated entry that survived admission a record of its own,
2082    /// then drops the legacy record those entries came out of — one recipient
2083    /// at a time, and all or nothing.
2084    ///
2085    /// Returns the recipients whose legacy record is **still on disk**. Their
2086    /// entries have been taken back out of memory, so the queue lives in exactly
2087    /// one place — the legacy record — for a later launch to migrate again.
2088    ///
2089    /// # Why it has to be all or nothing
2090    ///
2091    /// Nothing outside this walk writes or deletes the legacy category — the
2092    /// adoption sweep does write it, but strictly earlier in the same launch
2093    /// (see [`Self::delete_legacy_pending_queue`]) — so a
2094    /// legacy record left behind is left behind for good. The per-message pass
2095    /// claiming its ids only converges while those per-message records exist,
2096    /// and they do not survive the flush this launch is about to perform:
2097    /// dispatch deletes each one. A later launch would then find the legacy
2098    /// record with nothing claiming it and re-file — and re-send — a queue that
2099    /// was already delivered. So a recipient whose delete does not land — or
2100    /// whose *writes* do not all land, since the legacy record must then keep
2101    /// holding the entries the writes lost — is not half-migrated; it is not
2102    /// migrated.
2103    ///
2104    /// Undoing is cheap because the funding checks come *before* the writes: a
2105    /// recipient this launch cannot pay for has nothing on disk to take back.
2106    ///
2107    /// # Why it runs after the caps
2108    ///
2109    /// A pre-cap legacy install can hold far more than the current bounds admit,
2110    /// and writing each entry out only to delete it again moments later is the
2111    /// one shape that turns an ordinary upgrade into thousands of device
2112    /// barriers on the boot path. Writing only the survivors, and only
2113    /// [`MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH`] of them, bounds the burst.
2114    fn persist_migrated_pending_queues(
2115        &mut self,
2116        budget: &mut PruneBudget<'_>,
2117        walk: &mut PendingRestoreWalk,
2118    ) -> HashSet<String> {
2119        let mut abandoned = HashSet::new();
2120        let recipients = std::mem::take(&mut walk.migrated_recipients);
2121        let mut written = 0usize;
2122        for recipient in recipients {
2123            // Both bounds are checked before a single byte is written, which is
2124            // what makes abandoning free: there is nothing on disk to undo.
2125            // A delete is owed for every recipient that gets this far, so the
2126            // pool has to be able to fund one.
2127            if budget.is_spent() {
2128                walk.prune_bound_reached = true;
2129                self.forget_migrated_entries(&recipient, &walk.migrated, false);
2130                abandoned.insert(recipient);
2131                continue;
2132            }
2133            let survivors = self
2134                .pending_encrypted_messages
2135                .get(&recipient)
2136                .map_or(0, |messages| {
2137                    messages
2138                        .iter()
2139                        .filter(|message| walk.migrated.contains_key(&message.message_id.as_str()))
2140                        .count()
2141                });
2142            if written.saturating_add(survivors) > MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH {
2143                walk.write_bound_reached = true;
2144                self.forget_migrated_entries(&recipient, &walk.migrated, false);
2145                abandoned.insert(recipient);
2146                continue;
2147            }
2148
2149            // The writes are fallible too, not just the delete. Deleting the
2150            // legacy record on top of a failed write (a full disk fails the
2151            // `store` and lets the `delete` through) would destroy the only
2152            // durable copy of every entry the write did not land — so a
2153            // recipient with any failed write is abandoned exactly like one
2154            // whose delete failed, and the writes that did land are taken
2155            // back.
2156            let mut wrote_all = true;
2157            if let Some(messages) = self.pending_encrypted_messages.get(&recipient) {
2158                for message in messages {
2159                    if walk.migrated.contains_key(&message.message_id.as_str()) {
2160                        wrote_all &= self.persist_pending_message(&recipient, message);
2161                    }
2162                }
2163            }
2164            if !wrote_all {
2165                self.forget_migrated_entries(&recipient, &walk.migrated, true);
2166                abandoned.insert(recipient);
2167                continue;
2168            }
2169            written = written.saturating_add(survivors);
2170
2171            budget.claim();
2172            if !self.delete_legacy_pending_queue(&recipient) {
2173                self.forget_migrated_entries(&recipient, &walk.migrated, true);
2174                abandoned.insert(recipient);
2175            }
2176        }
2177        abandoned
2178    }
2179
2180    /// Takes one recipient's migrated entries back out of memory, and out of
2181    /// storage when they had already been written.
2182    ///
2183    /// The legacy record still holds this whole queue. Entries left in memory
2184    /// would flush, and dispatch would delete their per-message records — so
2185    /// nothing would be left to claim the ids that surviving record re-files on
2186    /// a later launch. Keeping the queue in exactly one place is the whole
2187    /// invariant; see [`Self::persist_migrated_pending_queues`].
2188    ///
2189    /// Entries the *per-message* pass restored for the same recipient stay: they
2190    /// have their own records and are not the legacy record's to re-file.
2191    fn forget_migrated_entries(
2192        &mut self,
2193        recipient: &str,
2194        migrated: &HashMap<String, String>,
2195        persisted: bool,
2196    ) {
2197        let mut removed = Vec::new();
2198        if let Some(messages) = self.pending_encrypted_messages.get_mut(recipient) {
2199            messages.retain(|message| {
2200                if migrated.contains_key(&message.message_id.as_str()) {
2201                    removed.push(message.message_id.clone());
2202                    false
2203                } else {
2204                    true
2205                }
2206            });
2207        }
2208        if self
2209            .pending_encrypted_messages
2210            .get(recipient)
2211            .is_some_and(Vec::is_empty)
2212        {
2213            self.pending_encrypted_messages.remove(recipient);
2214        }
2215        if persisted {
2216            self.delete_pending_messages_from_storage(removed.iter());
2217        }
2218        // Nothing taken back means nothing was recovered from this record this
2219        // launch — a previous launch's interrupted migration already claimed
2220        // every id in it, and only the leftover delete failed (already logged).
2221        // Warning "could not be migrated" there would report a success as an
2222        // error.
2223        if !removed.is_empty() {
2224            warn!(
2225                recipient = %recipient,
2226                count = removed.len(),
2227                "Legacy pending queue could not be migrated this launch; leaving it on disk for a later one"
2228            );
2229        }
2230    }
2231
2232    /// Reads the per-message pending records, grouping them by recipient.
2233    ///
2234    /// Shaped like [`Self::restore_outbox`], because it is now the same problem:
2235    /// one record per id, each loss individually settleable, no freeze.
2236    fn restore_pending_message_entries(
2237        &mut self,
2238        storage: &dyn ProtocolStateStorage,
2239        budget: &mut PruneBudget<'_>,
2240        walk: &mut PendingRestoreWalk,
2241    ) -> Result<()> {
2242        let keys = Self::list_state_keys(storage, storage_keys::PENDING_MESSAGE_ENTRIES)
2243            .map_err(|e| Error::Other(format!("Failed to list pending messages: {}", e)))?;
2244        let listed = keys.len();
2245
2246        for key in keys.into_iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
2247            if walk.examined_entries >= MAX_PENDING_RESTORE_ENTRIES {
2248                walk.entry_bound_reached = true;
2249                break;
2250            }
2251            if budget.is_spent() {
2252                walk.prune_bound_reached = true;
2253                break;
2254            }
2255            let data = match self.read_state_record_detailed_budgeted(
2256                storage,
2257                storage_keys::PENDING_MESSAGE_ENTRIES,
2258                &key,
2259                Some(budget),
2260            ) {
2261                Ok(StateRecord::Present(data)) => data,
2262                Ok(StateRecord::Missing) => continue,
2263                // Examined and destroyed. The key *is* the id the application is
2264                // holding, so unlike the legacy layout this is settleable on its
2265                // own without having opened the record.
2266                Ok(StateRecord::Unreadable) => {
2267                    warn!(message_id = %key, "Dropping unreadable pending message");
2268                    walk.seen.insert(key.clone());
2269                    walk.lost_ids.push(key);
2270                    continue;
2271                }
2272                // Still on disk and probably intact — the record key is not
2273                // loaded, or the backend refused this read. Settling it now would
2274                // be a terminal answer the next launch overturns by restoring the
2275                // entry and flushing it.
2276                Ok(StateRecord::Unavailable) | Err(_) => {
2277                    warn!(
2278                        message_id = %key,
2279                        "Pending message could not be read this session; leaving it in place"
2280                    );
2281                    continue;
2282                }
2283            };
2284            walk.examined_entries = walk.examined_entries.saturating_add(1);
2285
2286            let record = match serde_json::from_slice::<PendingMessageRecord>(&data) {
2287                Ok(record) => record,
2288                Err(e) => {
2289                    warn!(message_id = %key, error = %e, "Dropping corrupted pending message");
2290                    // Charged, never refused: the settlement below is already
2291                    // owed, so the record has to go with it.
2292                    budget.claim();
2293                    let _ = storage.delete(storage_keys::PENDING_MESSAGE_ENTRIES, &key);
2294                    walk.seen.insert(key.clone());
2295                    walk.lost_ids.push(key);
2296                    continue;
2297                }
2298            };
2299
2300            // A record whose id disagrees with its key is tampered or corrupt,
2301            // and is worse than merely unparseable: it is addressed under one id
2302            // and settles under another, so nothing on the runtime path could
2303            // ever delete it. Treat it as the loss it is.
2304            if record.message.message_id.as_str() != key {
2305                warn!(
2306                    message_id = %key,
2307                    record_id = %record.message.message_id,
2308                    "Dropping pending message whose record does not name its own key"
2309                );
2310                budget.claim();
2311                let _ = storage.delete(storage_keys::PENDING_MESSAGE_ENTRIES, &key);
2312                walk.seen.insert(key.clone());
2313                walk.lost_ids.push(key);
2314                continue;
2315            }
2316
2317            if Self::validate_outbound_recipient(&record.recipient).is_err() {
2318                // Nothing can ever be sent to this recipient again, so the record
2319                // goes — but the app still holds the id from `send_message*` and
2320                // it has to be settled.
2321                warn!(
2322                    message_id = %key,
2323                    "Dropping persisted pending message for an invalid recipient"
2324                );
2325                budget.claim();
2326                let _ = storage.delete(storage_keys::PENDING_MESSAGE_ENTRIES, &key);
2327                walk.seen.insert(key);
2328                walk.unaddressable.push(record.message.message_id);
2329                continue;
2330            }
2331
2332            walk.seen.insert(key);
2333            walk.grouped
2334                .entry(record.recipient)
2335                .or_default()
2336                .push(record.message);
2337        }
2338
2339        if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
2340            warn!(
2341                listed,
2342                cap = MAX_RESTORE_KEYS_PER_CATEGORY,
2343                "Pending message store listed more records than any legitimate run can \
2344                 produce; the tail is left on disk for a later launch"
2345            );
2346        }
2347        Ok(())
2348    }
2349
2350    /// Recovers legacy per-recipient queues so the caps can be applied to them.
2351    ///
2352    /// Recovery only — [`Self::persist_migrated_pending_queues`] does the writing
2353    /// and the legacy delete, after admission, so entries the caps drop are never
2354    /// written at all.
2355    ///
2356    /// Shares the walk's entry bound and delete budget with the per-message
2357    /// pass, which runs first — so a store holding
2358    /// [`MAX_PENDING_RESTORE_ENTRIES`] or more per-message entries starves this
2359    /// pass for the whole launch. Nothing is lost: this category is read-only
2360    /// and never grows, and the per-message population drains over successive
2361    /// launches (flushes, expiries, capacity evictions), so the migration
2362    /// eventually gets its turn.
2363    ///
2364    /// One-shot upgrade scaffolding, like `adopt_legacy_protocol_state`: once a
2365    /// launch has walked every legacy record the listing comes back empty and
2366    /// this costs one `list_keys`. Retire it on the same trigger as that sweep —
2367    /// when the oldest supported upgrade path starts at or after the release
2368    /// that introduced the per-message layout, not merely one release later.
2369    fn migrate_legacy_pending_queues(
2370        &mut self,
2371        storage: &dyn ProtocolStateStorage,
2372        budget: &mut PruneBudget<'_>,
2373        walk: &mut PendingRestoreWalk,
2374    ) -> Result<()> {
2375        let recipients = Self::list_state_keys(storage, storage_keys::PENDING_MESSAGES)
2376            .map_err(|e| Error::Other(format!("Failed to list legacy pending messages: {}", e)))?;
2377        if recipients.is_empty() {
2378            return Ok(());
2379        }
2380        let listed = recipients.len();
2381
2382        for recipient in recipients.into_iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
2383            if walk.examined_entries >= MAX_PENDING_RESTORE_ENTRIES {
2384                walk.entry_bound_reached = true;
2385                break;
2386            }
2387            if budget.is_spent() {
2388                walk.prune_bound_reached = true;
2389                break;
2390            }
2391            if Self::validate_outbound_recipient(&recipient).is_err() {
2392                // The queue is unaddressable — nothing can ever be sent to this
2393                // recipient again — so it is dropped, but the app still holds
2394                // ids from `send_message*` that must be settled. The record has
2395                // to be read *before* deleting it to recover them, and the read
2396                // gets the same three-way treatment as every other restore.
2397                match self.load_legacy_pending_queue(&recipient, Some(budget)) {
2398                    PendingRestore::Restored(messages) => {
2399                        walk.examined_entries =
2400                            walk.examined_entries.saturating_add(messages.len());
2401                        walk.unaddressable
2402                            .extend(messages.into_iter().map(|m| m.message_id));
2403                    }
2404                    // Listed but no longer there. Nothing was destroyed, so
2405                    // nothing is owed — and the delete below would charge the
2406                    // budget, log a destruction, and make a provider round trip
2407                    // for a record that is already gone.
2408                    PendingRestore::Absent => continue,
2409                    // Already examined and destroyed by the read; the ids went
2410                    // with it, so report the loss per recipient rather than
2411                    // letting an unaddressable queue vanish more quietly than an
2412                    // addressable one would.
2413                    PendingRestore::Lost => {
2414                        walk.lost_recipients.push(recipient);
2415                        continue;
2416                    }
2417                    // Intact on disk, just unreadable this session. Deleting it
2418                    // now would destroy ids nothing can name; a later launch
2419                    // reads the record and settles them properly.
2420                    PendingRestore::Unavailable => {
2421                        warn!(
2422                            recipient = %recipient,
2423                            "Legacy pending queue for an invalid recipient could not be read this session; leaving it in place"
2424                        );
2425                        continue;
2426                    }
2427                }
2428                warn!(recipient = %recipient, "Dropping legacy pending queue for an invalid recipient");
2429                // Charged, never refused: the settlement this delete pairs with
2430                // was already recorded above, so the record has to go with it.
2431                // The return is deliberately not honoured, unlike at the
2432                // migration call sites: this queue is unaddressable, so a
2433                // surviving record cannot re-*send* anything — the next launch
2434                // merely reads it and settles the same ids again, and a
2435                // duplicate terminal event is the acceptable end of that.
2436                budget.claim();
2437                let _ = self.delete_legacy_pending_queue(&recipient);
2438                continue;
2439            }
2440
2441            let messages = match self.load_legacy_pending_queue(&recipient, Some(budget)) {
2442                PendingRestore::Restored(messages) => messages,
2443                PendingRestore::Absent => continue,
2444                // A queue existed and its contents are gone. The ids are inside
2445                // the record we could not open, so they cannot be settled
2446                // individually — surface the loss per recipient instead of
2447                // letting it read as "there was nothing queued". This is the
2448                // reporting the per-message layout exists to improve on, and it
2449                // cannot be improved retroactively.
2450                PendingRestore::Lost => {
2451                    walk.lost_recipients.push(recipient);
2452                    continue;
2453                }
2454                // Not readable this session, but still on disk. Say nothing: a
2455                // later launch is expected to read it, and a loss diagnostic now
2456                // would be a claim the next launch contradicts.
2457                PendingRestore::Unavailable => {
2458                    warn!(
2459                        recipient = %recipient,
2460                        "Legacy pending queue could not be read this session; leaving it in place"
2461                    );
2462                    continue;
2463                }
2464            };
2465            walk.examined_entries = walk.examined_entries.saturating_add(messages.len());
2466
2467            // Recovered, not yet written. The caller persists what survives the
2468            // caps and then drops this record, so a queue too big for the
2469            // current bounds is never written out only to be deleted again.
2470            let mut migrated = 0usize;
2471            for message in messages {
2472                let message_id = message.message_id.as_str();
2473                if walk.seen.contains(&message_id) {
2474                    // Already accounted for by the pass before this one — either
2475                    // a previous launch migrated this queue and died before
2476                    // dropping it, or the per-message record was examined and
2477                    // settled. Re-filing a settled id would put a message on the
2478                    // wire after the app was told it failed. Remember where the
2479                    // legacy copy lives: if this record's delete fails, a
2480                    // settlement for the id has to be withheld, because the
2481                    // surviving record re-files it next launch.
2482                    walk.seen_in_legacy.insert(message_id, recipient.clone());
2483                    continue;
2484                }
2485                walk.seen.insert(message_id.clone());
2486                walk.migrated.insert(message_id, recipient.clone());
2487                walk.grouped
2488                    .entry(recipient.clone())
2489                    .or_default()
2490                    .push(message);
2491                migrated = migrated.saturating_add(1);
2492            }
2493            info!(
2494                recipient = %recipient,
2495                migrated,
2496                "Recovered legacy pending queue for migration"
2497            );
2498            walk.migrated_recipients.push(recipient);
2499        }
2500
2501        if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
2502            warn!(
2503                listed,
2504                cap = MAX_RESTORE_KEYS_PER_CATEGORY,
2505                "Legacy pending message store listed more recipients than any legitimate run \
2506                 can produce; the tail is left on disk for a later launch"
2507            );
2508        }
2509        Ok(())
2510    }
2511
2512    // ========================================================================
2513    // PEER KEY PACKAGES PERSISTENCE
2514    // ========================================================================
2515
2516    /// Persists a received key package for a peer so it survives restart.
2517    pub(crate) fn persist_peer_key_package(&self, peer_id: &str, pkg: &ReceivedKeyPackage) {
2518        let Some(storage) = &self.protocol_state_storage else {
2519            return;
2520        };
2521        match serde_json::to_vec(pkg) {
2522            Ok(data) => {
2523                if let Err(e) = self.write_state_record(
2524                    storage.as_ref(),
2525                    storage_keys::PEER_KEY_PACKAGES,
2526                    peer_id,
2527                    &data,
2528                ) {
2529                    warn!(peer_id = %peer_id, error = %e, "Failed to persist peer key package");
2530                }
2531            }
2532            Err(e) => {
2533                warn!(peer_id = %peer_id, error = %e, "Failed to serialize peer key package");
2534            }
2535        }
2536    }
2537
2538    /// Loads a persisted key package for a peer (if present and not expired).
2539    ///
2540    /// Unbudgeted, for the runtime callers: one expired record dropped while
2541    /// resolving a single peer is one provider round trip, not a storm.
2542    pub(crate) fn load_peer_key_package_from_storage(
2543        &self,
2544        peer_id: &str,
2545    ) -> Option<ReceivedKeyPackage> {
2546        self.load_peer_key_package_bounded(peer_id, None)
2547    }
2548
2549    /// Loads a persisted key package, charging the drop of an expired record to
2550    /// `budget` when one is supplied.
2551    ///
2552    /// Restore supplies one. The expiry drop is a durable delete like any other
2553    /// on that path — a synchronous provider round trip that flushes the
2554    /// containing directory on all three built-in stores — and it is the
2555    /// *common* one there: an over-cap key-package store is over-cap because it
2556    /// is old, and [`MAX_KEY_PACKAGE_LIFETIME_MS`] is 30 days, so most of what
2557    /// a restore walk finds in such a store has expired. Left unbudgeted it
2558    /// also bypassed the over-cap prune entirely, because an expired record
2559    /// never enters `pending_key_packages` and so never makes the cap bind.
2560    ///
2561    /// A record the budget spares is still not returned — it has expired either
2562    /// way — it is simply left on disk for a later launch to drop, which is the
2563    /// same idempotent-and-resumable property the rest of the prune relies on.
2564    fn load_peer_key_package_bounded(
2565        &self,
2566        peer_id: &str,
2567        mut budget: Option<&mut PruneBudget<'_>>,
2568    ) -> Option<ReceivedKeyPackage> {
2569        let storage = self.protocol_state_storage.as_ref()?;
2570        // The reader deletes what it cannot return, so it gets the budget too —
2571        // that delete is as durable as the expiry one below and was the last
2572        // one on this walk that nothing counted.
2573        let data = self
2574            .read_state_record_detailed_budgeted(
2575                storage.as_ref(),
2576                storage_keys::PEER_KEY_PACKAGES,
2577                peer_id,
2578                budget.as_deref_mut(),
2579            )
2580            .ok()?
2581            .into_bytes()?;
2582        let pkg: ReceivedKeyPackage = serde_json::from_slice(&data).ok()?;
2583        let now_ms = Utc::now().timestamp_millis() as u64;
2584        if now_ms >= pkg.local_expires_at_ms {
2585            if claim_prune(&mut budget) {
2586                let _ = storage.delete(storage_keys::PEER_KEY_PACKAGES, peer_id);
2587            }
2588            return None;
2589        }
2590        Some(pkg)
2591    }
2592
2593    /// Removes persisted key package for a peer (e.g. after session created).
2594    pub(crate) fn delete_peer_key_package_from_storage(&self, peer_id: &str) {
2595        if let Some(storage) = &self.protocol_state_storage {
2596            let _ = storage.delete(storage_keys::PEER_KEY_PACKAGES, peer_id);
2597        }
2598    }
2599
2600    /// Loads key package from storage into memory if not already present. Returns true if we now have one in memory.
2601    pub(crate) fn try_load_key_package_from_storage_into_memory(&mut self, peer_id: &str) -> bool {
2602        if self.pending_key_packages.contains_key(peer_id) {
2603            return true;
2604        }
2605        if let Some(pkg) = self.load_peer_key_package_from_storage(peer_id) {
2606            self.pending_key_packages.insert(peer_id.to_string(), pkg);
2607            return true;
2608        }
2609        false
2610    }
2611
2612    /// Restores peer key packages from storage for peers that have no MLS session.
2613    pub(crate) fn restore_peer_key_packages(
2614        &mut self,
2615        mls: &Arc<RwLock<MlsManager>>,
2616        allowance: &mut PruneAllowance,
2617    ) -> Result<()> {
2618        let Some(storage) = &self.protocol_state_storage else {
2619            return Ok(());
2620        };
2621
2622        let peer_ids = Self::list_state_keys(storage.as_ref(), storage_keys::PEER_KEY_PACKAGES)
2623            .map_err(|e| Error::Other(format!("Failed to list peer key packages: {}", e)))?;
2624        let listed = peer_ids.len();
2625
2626        let sessions = {
2627            let manager = mls
2628                .read()
2629                .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2630            manager.list_sessions().map_err(Error::Mls)?
2631        };
2632        let session_set: std::collections::HashSet<_> = sessions.into_iter().collect();
2633
2634        let mut budget = allowance.refusing();
2635        let mut over_cap_pruned = 0usize;
2636        // Bounded like every other category walk, and the prune inside it is
2637        // bounded again by `MAX_RESTORE_PRUNE_DELETES` — the walk bounds reads,
2638        // the budget bounds deletes, and a delete is the far more expensive of
2639        // the two (a synchronous provider round trip that flushes a directory
2640        // on all three built-in stores). Both tails drain over successive
2641        // launches rather than stranding.
2642        //
2643        // *Both* kinds of delete this walk issues share that budget: the
2644        // over-cap prune below and the expiry drop inside
2645        // `load_peer_key_package_bounded`. They are not alternatives — an
2646        // expired record never enters the map, so it never makes the cap bind,
2647        // which means an over-cap store full of expired records would otherwise
2648        // route every one of its deletes past the budget.
2649        for peer_id in peer_ids.into_iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
2650            if session_set.contains(&peer_id) {
2651                continue;
2652            }
2653            // Bound restore to the same cap as the live insert path so a
2654            // pre-existing over-cap durable store (e.g. a flood that landed
2655            // before the cap existed) cannot re-inflate memory on boot. Rather
2656            // than leaving the overflow to linger on disk forever — where it
2657            // would re-inflate memory on a future boot and waste durable
2658            // storage — prune it so the store shrinks toward the cap. Dropping
2659            // a cached package only costs a recoverable re-exchange, exactly
2660            // like the live eviction path. Overflow is deleted without loading
2661            // it, so peak memory stays cap-bounded.
2662            if self.pending_key_packages.len() >= MAX_PENDING_KEY_PACKAGES {
2663                if !budget.claim() {
2664                    // The map only grows and the cap only binds harder, so
2665                    // every remaining key would take this same branch. Nothing
2666                    // is left to restore and nothing more may be deleted.
2667                    break;
2668                }
2669                self.delete_peer_key_package_from_storage(&peer_id);
2670                over_cap_pruned += 1;
2671                continue;
2672            }
2673            if let Some(pkg) = self.load_peer_key_package_bounded(&peer_id, Some(&mut budget)) {
2674                info!(peer_id = %peer_id, "Restored peer key package from storage");
2675                self.pending_key_packages.insert(peer_id, pkg);
2676            }
2677        }
2678        if over_cap_pruned > 0 {
2679            warn!(
2680                cap = MAX_PENDING_KEY_PACKAGES,
2681                pruned = over_cap_pruned,
2682                "Peer key package store exceeded the cap on restore; pruned overflow from durable storage"
2683            );
2684        }
2685        // Routine, unlike the over-cap prune above: cached packages expire and
2686        // nothing else collects them, so this is the ordinary way the store
2687        // shrinks. Reported at debug so a normal launch stays quiet. Folded in
2688        // with the reader's own drops, which are neither routine nor a flood —
2689        // separating a third counter would say more about the log line than
2690        // about the store.
2691        let dropped = budget.spent.saturating_sub(over_cap_pruned);
2692        if dropped > 0 {
2693            debug!(
2694                pruned = dropped,
2695                "Dropped expired or unreadable peer key packages from durable storage on restore"
2696            );
2697        }
2698        if budget.exhausted {
2699            warn!(
2700                deleted = budget.spent,
2701                budget = MAX_RESTORE_PRUNE_DELETES,
2702                "Peer key package prune hit its share of the launch delete budget; the rest is left on disk for a later launch"
2703            );
2704        }
2705        if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
2706            warn!(
2707                listed,
2708                cap = MAX_RESTORE_KEYS_PER_CATEGORY,
2709                "Peer key package store listed more peers than any legitimate run can produce; deferring the tail to a later launch"
2710            );
2711        }
2712
2713        Ok(())
2714    }
2715
2716    // ========================================================================
2717    // PEER CAPABILITIES PERSISTENCE
2718    // ========================================================================
2719
2720    /// Persists the capability versions a peer advertised in its key package
2721    /// so they survive restarts (see [`PeerCapabilities`] for why this is a
2722    /// separate record from the key package itself). Best-effort like the
2723    /// other persist paths: a lost record only degrades output (JSON
2724    /// envelope, dropped rich extras) until the next live exchange.
2725    pub(crate) fn persist_peer_capabilities(&self, peer_id: &str, caps: &PeerCapabilities) {
2726        let Some(storage) = &self.protocol_state_storage else {
2727            return;
2728        };
2729        match serde_json::to_vec(caps) {
2730            Ok(data) => {
2731                if let Err(e) = self.write_state_record(
2732                    storage.as_ref(),
2733                    storage_keys::PEER_CAPABILITIES,
2734                    peer_id,
2735                    &data,
2736                ) {
2737                    warn!(peer_id = %peer_id, error = %e, "Failed to persist peer capabilities");
2738                }
2739            }
2740            Err(e) => {
2741                warn!(peer_id = %peer_id, error = %e, "Failed to serialize peer capabilities");
2742            }
2743        }
2744    }
2745
2746    /// Removes the persisted capability record for a peer (downgrade,
2747    /// eviction, or peer-state cleanup).
2748    pub(crate) fn delete_peer_capabilities_from_storage(&self, peer_id: &str) {
2749        if let Some(storage) = &self.protocol_state_storage {
2750            let _ = storage.delete(storage_keys::PEER_CAPABILITIES, peer_id);
2751        }
2752    }
2753
2754    /// Loads the persisted capability record for a peer.
2755    ///
2756    /// Three answers, not two, for the same reason [`StateRecord`] has three.
2757    /// `Ok(None)` is "there is nothing here to merge with"; `Err(())` is "a
2758    /// record may well be here and could not be read *this session*". The only
2759    /// caller merges into whatever this returns and then writes the result
2760    /// back, so collapsing the two would let one transient provider failure
2761    /// write an attested-only record over a peer's *directly advertised*
2762    /// capabilities — silently downgrading its MLS envelope and dropping its
2763    /// rich extras until the next live key-package exchange.
2764    ///
2765    /// A record that is present but will not deserialize is `Ok(None)`:
2766    /// nothing can be merged with bytes that do not parse, and the write that
2767    /// follows is the recovery. Same for one the store itself destroyed.
2768    fn load_peer_capabilities(
2769        &self,
2770        peer_id: &str,
2771    ) -> std::result::Result<Option<PeerCapabilities>, ()> {
2772        let Some(storage) = self.protocol_state_storage.as_ref() else {
2773            return Ok(None);
2774        };
2775        match self.read_state_record_detailed(
2776            storage.as_ref(),
2777            storage_keys::PEER_CAPABILITIES,
2778            peer_id,
2779        ) {
2780            Ok(StateRecord::Present(data)) => Ok(serde_json::from_slice(&data).ok()),
2781            // Never written, or examined and destroyed: nothing to preserve.
2782            Ok(StateRecord::Missing | StateRecord::Unreadable) => Ok(None),
2783            // Still on disk and probably intact — merging into a default and
2784            // persisting that would destroy it.
2785            Ok(StateRecord::Unavailable) | Err(_) => Err(()),
2786        }
2787    }
2788
2789    /// Reads the durable capability record for a peer, as an accessor over the
2790    /// module-private [`Self::load_peer_capabilities`].
2791    ///
2792    /// Collapses "never written" and "could not be read this session" into the
2793    /// same `None`, which the one production caller
2794    /// (`handle_key_package_message`, carrying a stored `nostr_pubkey` forward
2795    /// past an unsigned package) can afford: losing the value there degrades
2796    /// that peer to bootstrap-key sealing, which still delivers. A caller that
2797    /// needs to distinguish them — because writing a default would *destroy* a
2798    /// record that is merely unreadable — must use `load_peer_capabilities`
2799    /// directly and honour its `Err`, as the attested-capability merge does.
2800    pub(crate) fn load_peer_capabilities_record(&self, peer_id: &str) -> Option<PeerCapabilities> {
2801        self.load_peer_capabilities(peer_id).ok().flatten()
2802    }
2803
2804    /// The Nostr public key an outgoing key package would advertise.
2805    /// Test-only accessor for the value `send_key_package_to` embeds.
2806    #[cfg(test)]
2807    pub(crate) fn outgoing_key_package_nostr_pubkey_for_test(&self) -> Option<String> {
2808        self.transport_manager.nostr_public_key()
2809    }
2810
2811    /// Records an inviter-attested rich-payload capability for a peer we
2812    /// never directly exchanged key packages with (a group member added by
2813    /// someone else). In-memory recording mirrors the direct path in
2814    /// `handle_key_package_message`: gated by our own kill switch and
2815    /// bounded like `key_package_sent_to`. Persistence stores the raw
2816    /// attested versions merged into the peer's existing capability record
2817    /// (never clobbering directly-advertised fields — which is why a record
2818    /// that cannot be *read* this session skips the write rather than merging
2819    /// into a default), matching the "switches gate use, not knowledge" rule.
2820    /// A later direct key-package exchange overwrites the whole record and
2821    /// evicts the in-memory entry — direct knowledge is always authoritative.
2822    pub(crate) fn record_attested_rich(&mut self, peer_id: &str, versions: &[u8]) {
2823        if peer_id == self.local_id || !versions.contains(&RICH_PAYLOAD_V1) {
2824            return;
2825        }
2826        // Direct self-advertisement already covers this peer; an attested
2827        // duplicate would only go stale.
2828        if self.peer_rich_payload.contains(peer_id) {
2829            return;
2830        }
2831        if self.config.encryption.rich_payload_enabled {
2832            if !self.peer_rich_attested.contains(peer_id)
2833                && self.peer_rich_attested.len() >= MAX_KEY_PACKAGE_SENT_TO
2834            {
2835                self.peer_rich_attested.clear();
2836            }
2837            self.peer_rich_attested.insert(peer_id.to_string());
2838        }
2839        let Ok(existing) = self.load_peer_capabilities(peer_id) else {
2840            // The record could not be read this session, so a write now would
2841            // put an attested-only record over whatever is on disk — including
2842            // the versions this peer advertised for itself, which are the
2843            // authoritative ones. An attestation is advisory and the in-memory
2844            // set above already opens the group gate for this run; the next Add
2845            // commit re-attests. Skipping the write is strictly cheaper than
2846            // losing a peer's real capabilities to a transient read.
2847            warn!(
2848                peer_id = %peer_id,
2849                "Peer capability record unreadable this session; not persisting the attested capability"
2850            );
2851            return;
2852        };
2853        let mut caps = existing.unwrap_or_default();
2854        caps.attested_rich_versions = versions
2855            .iter()
2856            .copied()
2857            .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
2858            .collect();
2859        self.persist_peer_capabilities(peer_id, &caps);
2860    }
2861
2862    /// Records an inviter-attested *group replication* capability, the
2863    /// [`DATA_GROUP_V1`] sibling of [`Self::record_attested_rich`].
2864    ///
2865    /// Every rule there applies here for the same reasons: skip self, skip
2866    /// peers already known directly, gate the in-memory set on our own kill
2867    /// switch, bound it like `key_package_sent_to`, and never write an
2868    /// attested-only record over a capability record that could not be read
2869    /// this session.
2870    ///
2871    /// What differs is only what is at stake. Without this, a group
2872    /// replicates among whichever members happen to have exchanged key
2873    /// packages directly and quietly does not replicate with the rest —
2874    /// members of a group never exchange key packages with each other, so
2875    /// on a group nobody built out of existing 1:1 contacts that subset can
2876    /// easily be empty.
2877    ///
2878    /// [`DATA_GROUP_V1`]: crate::protocol::types::DATA_GROUP_V1
2879    #[cfg_attr(not(feature = "data"), allow(dead_code))]
2880    pub(crate) fn record_attested_data(&mut self, peer_id: &str, versions: &[u8]) {
2881        if peer_id == self.local_id || !versions.contains(&DATA_GROUP_V1) {
2882            return;
2883        }
2884        // Direct self-advertisement already covers this peer; an attested
2885        // duplicate would only go stale.
2886        if self.peer_data_group.contains(peer_id) {
2887            return;
2888        }
2889        if self.config.data.enabled {
2890            if !self.peer_data_group_attested.contains(peer_id)
2891                && self.peer_data_group_attested.len() >= MAX_KEY_PACKAGE_SENT_TO
2892            {
2893                self.peer_data_group_attested.clear();
2894            }
2895            self.peer_data_group_attested.insert(peer_id.to_string());
2896        }
2897        let Ok(existing) = self.load_peer_capabilities(peer_id) else {
2898            warn!(
2899                peer_id = %peer_id,
2900                "Peer capability record unreadable this session; not persisting the attested capability"
2901            );
2902            return;
2903        };
2904        let mut caps = existing.unwrap_or_default();
2905        caps.attested_data_versions = versions
2906            .iter()
2907            .copied()
2908            .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
2909            .collect();
2910        self.persist_peer_capabilities(peer_id, &caps);
2911    }
2912
2913    /// Repopulates the in-memory capability sets (`peer_compact_envelope`,
2914    /// `peer_rich_payload`) from the durable per-peer records, so a send
2915    /// right after relaunch — before any live key-package exchange — keeps
2916    /// sealing rich extras and emitting the compact envelope.
2917    ///
2918    /// Config gating happens here, not at persist time: a record is only
2919    /// applied to a set whose kill switch is on, but a record gated off is
2920    /// left in storage (the switch may come back on next run). Records are
2921    /// bounded to `MAX_KEY_PACKAGE_SENT_TO` — the same cap the live insert
2922    /// path enforces on the sets — and overflow is pruned from durable
2923    /// storage like `restore_peer_key_packages` does, so a pre-existing
2924    /// over-cap store shrinks to the cap in a single boot. Session peers are
2925    /// admitted first: they are exactly the records this feature exists for
2926    /// (their key-package cache entry is gone), and `list_keys` order is
2927    /// backend-defined, so an unprioritized over-cap prune — reachable only
2928    /// after a forged-sender flood — could evict them while keeping forged
2929    /// leftovers. Best-effort: failures degrade output, never blocking
2930    /// restore.
2931    pub(crate) fn restore_peer_capabilities(
2932        &mut self,
2933        mls: &Arc<RwLock<MlsManager>>,
2934        allowance: &mut PruneAllowance,
2935    ) {
2936        let Some(storage) = self.protocol_state_storage.clone() else {
2937            return;
2938        };
2939        let peer_ids =
2940            match Self::list_state_keys(storage.as_ref(), storage_keys::PEER_CAPABILITIES) {
2941                Ok(ids) => ids,
2942                Err(e) => {
2943                    warn!(error = %e, "Failed to list peer capabilities, skipping restore");
2944                    return;
2945                }
2946            };
2947
2948        // Best-effort session lookup: if it fails, restore proceeds in
2949        // backend order rather than not at all.
2950        let session_set: std::collections::HashSet<String> = mls
2951            .read()
2952            .ok()
2953            .and_then(|manager| manager.list_sessions().ok())
2954            .map(|sessions| sessions.into_iter().collect())
2955            .unwrap_or_default();
2956        let listed = peer_ids.len();
2957        let (mut peer_ids, non_session_ids): (Vec<_>, Vec<_>) = peer_ids
2958            .into_iter()
2959            .partition(|peer_id| session_set.contains(peer_id));
2960        peer_ids.extend(non_session_ids);
2961
2962        let mut kept = 0usize;
2963        let mut over_cap_pruned = 0usize;
2964        let mut budget = allowance.refusing();
2965        // Bounded like `restore_peer_key_packages`, for the same reason: the
2966        // prune below is a provider delete per over-cap entry, and those are
2967        // budgeted separately from the read walk. The take comes *after* the
2968        // session-peer partition, so the records this restore exists for are
2969        // always inside the prefix however the backend ordered its listing.
2970        //
2971        // *Every* kind of delete this walk causes shares that budget, including
2972        // the one it does not issue itself: a record the store reports corrupt,
2973        // or one over the record cap, is dropped inside the reader. Handing the
2974        // budget to the reader is the only way to count those — the same
2975        // correction `restore_media_descriptors` needed, for the same reason.
2976        for peer_id in peer_ids.into_iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
2977            if kept >= MAX_KEY_PACKAGE_SENT_TO {
2978                if !budget.claim() {
2979                    // `kept` only grows, so every remaining key would take this
2980                    // same branch: nothing left to restore, nothing more that
2981                    // may be deleted.
2982                    break;
2983                }
2984                self.delete_peer_capabilities_from_storage(&peer_id);
2985                over_cap_pruned += 1;
2986                continue;
2987            }
2988            let Ok(StateRecord::Present(data)) = self.read_state_record_detailed_budgeted(
2989                storage.as_ref(),
2990                storage_keys::PEER_CAPABILITIES,
2991                &peer_id,
2992                Some(&mut budget),
2993            ) else {
2994                continue;
2995            };
2996            let Ok(caps) = serde_json::from_slice::<PeerCapabilities>(&data) else {
2997                // Corrupt record: drop it rather than re-parsing it forever —
2998                // unless the delete budget is gone, in which case a later
2999                // launch drops it instead. Re-parsing one record is cheap; the
3000                // device barrier a delete costs is not.
3001                warn!(peer_id = %peer_id, "Corrupt peer capability record, deleting");
3002                if budget.claim() {
3003                    self.delete_peer_capabilities_from_storage(&peer_id);
3004                }
3005                continue;
3006            };
3007            if !caps.is_any() {
3008                // Empty records are deleted at persist time; clean up any
3009                // that predate that rule.
3010                if budget.claim() {
3011                    self.delete_peer_capabilities_from_storage(&peer_id);
3012                }
3013                continue;
3014            }
3015            if self.config.encryption.compact_envelope_enabled
3016                && caps.env_versions.contains(&MLS_ENVELOPE_COMPACT_V1)
3017            {
3018                self.peer_compact_envelope.insert(peer_id.clone());
3019            }
3020            if self.config.encryption.rich_payload_enabled
3021                && caps.rich_versions.contains(&RICH_PAYLOAD_V1)
3022            {
3023                self.peer_rich_payload.insert(peer_id.clone());
3024            }
3025            // Not gated on a config flag, unlike its neighbours. There is no
3026            // kill switch for *which payload we sign*: signing the older one
3027            // at a peer that has proved it signs the newer is exactly the
3028            // downgrade the ratchet refuses, so this node would be talking
3029            // itself out of a session. `control_freshness_enforced` gates
3030            // whether frames are refused, not what we produce.
3031            if caps
3032                .ctrl_versions
3033                .contains(&offline_protocol_sealed::CTRL_SIGN_V2)
3034            {
3035                self.peer_ctrl_freshness.insert(peer_id.clone());
3036            }
3037            if self.config.encryption.rich_payload_enabled
3038                && caps.attested_rich_versions.contains(&RICH_PAYLOAD_V1)
3039            {
3040                self.peer_rich_attested.insert(peer_id.clone());
3041            }
3042            // Skipping this arm is the failure mode this whole record exists
3043            // to prevent, in its quietest form: replication would stop at
3044            // every restart, both sides would keep accepting edits, and the
3045            // documents would diverge with nothing anywhere reporting a
3046            // problem.
3047            if self.config.data.enabled && caps.data_versions.contains(&DATA_SYNC_V1) {
3048                self.peer_data_sync.insert(peer_id.clone());
3049            }
3050            // The group half of the same failure, and a quieter one: a group
3051            // space that stops replicating after a restart looks to every
3052            // member like a group where nobody happens to be editing.
3053            if self.config.data.enabled && caps.data_versions.contains(&DATA_GROUP_V1) {
3054                self.peer_data_group.insert(peer_id.clone());
3055            }
3056            if self.config.data.enabled && caps.attested_data_versions.contains(&DATA_GROUP_V1) {
3057                self.peer_data_group_attested.insert(peer_id.clone());
3058            }
3059            // And the media half. Skipping it costs less than the two above
3060            // (a fetch that finds nobody is reported, where a stalled
3061            // replication is silent), but it costs it on every restart, and
3062            // the record is already here.
3063            if self.config.data.enabled && caps.data_versions.contains(&DATA_MEDIA_V1) {
3064                self.peer_data_media.insert(peer_id.clone());
3065            }
3066            // Not gated on the sealing kill switch: this is a destination
3067            // address, and the transport decides whether to seal at all. A
3068            // restore that skipped it would leave the peer addressed by the
3069            // publicly computable bootstrap key until the next live key-package
3070            // exchange — a silent privacy regression across every restart,
3071            // which is the exact failure this whole record exists to prevent.
3072            if let Some(pubkey) = caps.nostr_pubkey.as_deref() {
3073                self.transport_manager
3074                    .mark_peer_nostr_pubkey(&peer_id, Some(pubkey));
3075            }
3076            kept += 1;
3077        }
3078        if kept > 0 {
3079            info!(
3080                count = kept,
3081                "Restored peer capability records from storage"
3082            );
3083        }
3084        if over_cap_pruned > 0 {
3085            warn!(
3086                cap = MAX_KEY_PACKAGE_SENT_TO,
3087                pruned = over_cap_pruned,
3088                "Peer capability store exceeded the cap on restore; pruned overflow from durable storage"
3089            );
3090        }
3091        // Reported apart from the over-cap prune, like `restore_peer_key_packages`
3092        // separates its expiry drops: a corrupt, empty, or unreadable record is
3093        // not a store that outgrew its cap, and attributing it to one sends
3094        // whoever reads this log looking for a flood that never happened.
3095        let unreadable_pruned = budget.spent.saturating_sub(over_cap_pruned);
3096        if unreadable_pruned > 0 {
3097            debug!(
3098                pruned = unreadable_pruned,
3099                "Dropped unreadable peer capability records from durable storage on restore"
3100            );
3101        }
3102        if budget.exhausted {
3103            warn!(
3104                deleted = budget.spent,
3105                budget = MAX_RESTORE_PRUNE_DELETES,
3106                "Peer capability prune hit its share of the launch delete budget; the rest is left on disk for a later launch"
3107            );
3108        }
3109        if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
3110            warn!(
3111                listed,
3112                cap = MAX_RESTORE_KEYS_PER_CATEGORY,
3113                "Peer capability store listed more peers than any legitimate run can produce; deferring the tail to a later launch"
3114            );
3115        }
3116    }
3117
3118    // ========================================================================
3119    // RESTORE-PATH RECORD DECODING
3120    // ========================================================================
3121
3122    /// Reads and decodes one JSON protocol-state record *for a restore walk*,
3123    /// dropping a record whose bytes are not this category's type rather than
3124    /// failing the walk.
3125    ///
3126    /// A record that parses as garbage is the same permanent loss as one that
3127    /// fails to open, and every other restore on this path already treats it
3128    /// that way ([`Self::restore_outbox`], [`Self::restore_pending_messages`],
3129    /// [`Self::restore_peer_capabilities`]). Session states and Welcome
3130    /// lifecycles were the two that did not: their loaders map a serde failure
3131    /// to an error, and their restores propagate it, so one unparseable record
3132    /// failed `initialize_mls` outright — and, because nothing deleted it,
3133    /// failed it again on every launch after that. With `require_encryption`
3134    /// on by default that install can no longer send anything, and there is no
3135    /// in-app recovery. Both categories are unsealed, so they carry no
3136    /// integrity protection at all, and both now live in the app container
3137    /// rather than the credential store — the same threat model every restore
3138    /// walk here is bounded against.
3139    ///
3140    /// Deliberately *not* folded into the loaders themselves. Those are also
3141    /// read at runtime, where `is_session_confirmed` propagates the error on
3142    /// purpose so a send fails closed instead of silently reading a Confirmed
3143    /// session as Pending. Restore is the only caller that must survive the
3144    /// record; the runtime one must not.
3145    ///
3146    /// A **storage failure no longer fails the walk either**, and that is the
3147    /// other half of the same lesson. Fixing only the serde case left the
3148    /// identical outcome reachable through the store: `list_keys` succeeds,
3149    /// one record's read returns `LoadFailed`, and `initialize_mls` rolls back
3150    /// — every launch, for as long as that one file stays unreadable, on an
3151    /// install that can then never send. Every other category on this path
3152    /// already treats a per-record read failure as recoverable and continues
3153    /// ([`Self::restore_outbox`], [`Self::load_legacy_pending_queue`],
3154    /// [`Self::restore_media_descriptors`], [`Self::load_peer_capabilities`]).
3155    /// These two were the outliers, and moving both categories out of the
3156    /// credential store and into the app container — where `ENOSPC`, `EIO`, and
3157    /// protection-class failures are ordinary — is what made it matter.
3158    ///
3159    /// So this reader is infallible and answers three ways.
3160    /// [`RestorableRecord::Unavailable`] is the load-bearing one: the caller
3161    /// must not read it as absence, because for session states "absent" means
3162    /// *re-bootstrap and persist `Pending`*, which would write over a record
3163    /// that may say `Confirmed`. A *listing* failure still propagates, from the
3164    /// restores themselves — it is indistinguishable from an empty category and
3165    /// has no per-record fallback.
3166    ///
3167    /// `budget` bounds the durable deletes this causes — the drop below, and the
3168    /// reader's own drop of a record that will not open. Both categories reached
3169    /// through here are unsealed and advisory-to-restore, so a refusing budget
3170    /// is right: a record the budget spares is simply re-walked and dropped on a
3171    /// later launch, exactly like the cache prunes. Callers whose walk is
3172    /// bounded by something other than the store
3173    /// (`restore_session_states_from_manager` iterates the MLS session list)
3174    /// pass `None`.
3175    fn load_restorable_state_record<T: DeserializeOwned>(
3176        &self,
3177        key_type: &str,
3178        key_id: &str,
3179        mut budget: Option<&mut PruneBudget<'_>>,
3180    ) -> RestorableRecord<T> {
3181        let Some(storage) = &self.protocol_state_storage else {
3182            return RestorableRecord::Absent;
3183        };
3184
3185        let data = match self.read_state_record_detailed_budgeted(
3186            storage.as_ref(),
3187            key_type,
3188            key_id,
3189            budget.as_deref_mut(),
3190        ) {
3191            Ok(StateRecord::Present(data)) => data,
3192            // Never written, or examined and destroyed: either way there is
3193            // nothing here to recover and the caller may re-bootstrap.
3194            Ok(StateRecord::Missing | StateRecord::Unreadable) => return RestorableRecord::Absent,
3195            // Still on disk and quite possibly intact.
3196            Ok(StateRecord::Unavailable) => return RestorableRecord::Unavailable,
3197            // `debug`, not `warn`: a store failing systemically fails *every*
3198            // read, and this reader runs once per record on a walk bounded at
3199            // `MAX_RESTORE_KEYS_PER_CATEGORY`. A warn here is up to that many
3200            // lines on the synchronous boot path, in exactly the degraded state
3201            // this three-way answer exists to survive. Each caller emits the
3202            // operator-facing signal itself and aggregates it:
3203            // `restore_welcome_lifecycles` counts them into one warn, and
3204            // `restore_session_states_from_manager` walks the far smaller MLS
3205            // session list and can afford one per peer.
3206            Err(e) => {
3207                debug!(
3208                    key_type = %key_type,
3209                    key_id = %key_id,
3210                    error = %e,
3211                    "Protocol state record could not be read this session; leaving it in place"
3212                );
3213                return RestorableRecord::Unavailable;
3214            }
3215        };
3216
3217        match serde_json::from_slice::<T>(&data) {
3218            Ok(value) => RestorableRecord::Present(value),
3219            Err(e) => {
3220                warn!(
3221                    key_type = %key_type,
3222                    key_id = %key_id,
3223                    error = %e,
3224                    "Dropping a protocol-state record whose bytes are not the type \
3225                     its category holds"
3226                );
3227                if claim_prune(&mut budget) {
3228                    let _ = storage.delete(key_type, key_id);
3229                }
3230                RestorableRecord::Absent
3231            }
3232        }
3233    }
3234
3235    /// Restore-path read of a peer's session state. See
3236    /// [`Self::load_restorable_state_record`] for why this is separate from
3237    /// [`Self::load_session_state_entry`].
3238    ///
3239    /// Budgeted, though its caller walks the *MLS session list* rather than a
3240    /// protocol-state category. The walk bound was once argued to be enough on
3241    /// the grounds that the volume is bounded by sessions this install actually
3242    /// has — but the MLS session list carries no restore cap of its own, so
3243    /// "bounded" there means "bounded by the peer count", and a store that
3244    /// reports every session-state record corrupt (or holds bytes that will not
3245    /// decode) turns that into one device barrier per peer on the boot path
3246    /// with nothing counting them. That is the same argument-from-the-wrong-
3247    /// number that exempted `restore_outbox`.
3248    ///
3249    /// A [`PruneAllowance::refusing`] budget is right here, and refusing costs
3250    /// less than anywhere else on the path: a spared record still reads
3251    /// [`StateRecord::Unreadable`] → [`RestorableRecord::Absent`], so the
3252    /// caller re-bootstraps and persists a fresh `Pending` **over** it. The
3253    /// record is repaired by that write whether or not the delete was funded —
3254    /// which makes refusing strictly cheaper than claiming, since it saves a
3255    /// barrier rather than deferring one.
3256    pub(super) fn load_session_state_for_restore(
3257        &self,
3258        peer_id: &str,
3259        budget: Option<&mut PruneBudget<'_>>,
3260    ) -> RestorableRecord<SessionState> {
3261        self.load_restorable_state_record(storage_keys::SESSION_STATES, peer_id, budget)
3262    }
3263
3264    /// Reads a peer's Welcome lifecycle. See
3265    /// [`Self::load_restorable_state_record`].
3266    ///
3267    /// Unlike session states, this category has no strict twin: restore is its
3268    /// only reader (`welcome_lifecycles` is an in-memory map from then on), so
3269    /// there is no send-path decision that has to fail closed on a record that
3270    /// will not decode. It *is* walked by container-listed key, though, so its
3271    /// deletes are budgeted like every other such walk.
3272    fn load_welcome_lifecycle_for_restore(
3273        &self,
3274        peer_id: &str,
3275        budget: Option<&mut PruneBudget<'_>>,
3276    ) -> RestorableRecord<WelcomeLifecycleRecord> {
3277        self.load_restorable_state_record(storage_keys::WELCOME_LIFECYCLES, peer_id, budget)
3278    }
3279
3280    // ========================================================================
3281    // SESSION STATE PERSISTENCE
3282    // ========================================================================
3283
3284    /// Loads a persisted session state entry (if present).
3285    ///
3286    /// A record that will not deserialize is an error here, not an absent
3287    /// record: `is_session_confirmed` reads this on the send path and must
3288    /// fail closed rather than treat a Confirmed session as Pending. The
3289    /// restore walk, which must not be blocked by one poison record, goes
3290    /// through [`Self::load_session_state_for_restore`] instead.
3291    pub(crate) fn load_session_state_entry(&self, peer_id: &str) -> Result<Option<SessionState>> {
3292        let Some(storage) = &self.protocol_state_storage else {
3293            return Ok(None);
3294        };
3295
3296        let Some(data) = self
3297            .read_state_record(storage.as_ref(), storage_keys::SESSION_STATES, peer_id)
3298            .map_err(|e| {
3299                Error::Other(format!(
3300                    "Failed to load session state for {}: {}",
3301                    peer_id, e
3302                ))
3303            })?
3304        else {
3305            return Ok(None);
3306        };
3307
3308        let state = serde_json::from_slice::<SessionState>(&data).map_err(|e| {
3309            Error::Other(format!(
3310                "Failed to deserialize session state for {}: {}",
3311                peer_id, e
3312            ))
3313        })?;
3314
3315        Ok(Some(state))
3316    }
3317
3318    /// Persists session state for a single peer key.
3319    pub(crate) fn persist_session_state(
3320        &self,
3321        peer_id: &str,
3322        new_state: SessionState,
3323        source_event: &str,
3324    ) -> Result<()> {
3325        let Some(storage) = &self.protocol_state_storage else {
3326            return Err(Error::MlsNotInitialized);
3327        };
3328
3329        let encoded = serde_json::to_vec(&new_state).map_err(|e| {
3330            Error::Serialization(format!("Failed to serialize session state: {}", e))
3331        })?;
3332        self.write_state_record(
3333            storage.as_ref(),
3334            storage_keys::SESSION_STATES,
3335            peer_id,
3336            &encoded,
3337        )
3338        .map_err(|e| {
3339            Error::Other(format!(
3340                "Failed to persist session state for {}: {}",
3341                peer_id, e
3342            ))
3343        })?;
3344
3345        if matches!(new_state, SessionState::Confirmed) {
3346            info!(
3347                event = "confirmation_persisted",
3348                session_or_group_id = %peer_id,
3349                previous_state = "Pending",
3350                new_state = "Confirmed",
3351                source_event = %source_event,
3352                "confirmation_persisted"
3353            );
3354        }
3355
3356        Ok(())
3357    }
3358
3359    pub(crate) fn clear_session_state_entry(&self, peer_id: &str) -> Result<()> {
3360        let Some(storage) = &self.protocol_state_storage else {
3361            return Ok(());
3362        };
3363        storage
3364            .delete(storage_keys::SESSION_STATES, peer_id)
3365            .map_err(|e| {
3366                Error::Other(format!(
3367                    "Failed to clear session state for {}: {}",
3368                    peer_id, e
3369                ))
3370            })
3371    }
3372
3373    // ========================================================================
3374    // WELCOME LIFECYCLE PERSISTENCE
3375    // ========================================================================
3376
3377    pub(crate) fn persist_welcome_lifecycle_entry(
3378        &self,
3379        record: &WelcomeLifecycleRecord,
3380    ) -> Result<()> {
3381        let Some(storage) = &self.protocol_state_storage else {
3382            return Err(Error::MlsNotInitialized);
3383        };
3384
3385        let encoded = serde_json::to_vec(record).map_err(|e| {
3386            Error::Serialization(format!("Failed to serialize welcome lifecycle: {}", e))
3387        })?;
3388        self.write_state_record(
3389            storage.as_ref(),
3390            storage_keys::WELCOME_LIFECYCLES,
3391            &record.peer_id,
3392            &encoded,
3393        )
3394        .map_err(|e| {
3395            Error::Other(format!(
3396                "Failed to persist welcome lifecycle for {}: {}",
3397                record.peer_id, e
3398            ))
3399        })
3400    }
3401
3402    pub(crate) fn clear_welcome_lifecycle_entry(&self, peer_id: &str) -> Result<()> {
3403        let Some(storage) = &self.protocol_state_storage else {
3404            return Ok(());
3405        };
3406        storage
3407            .delete(storage_keys::WELCOME_LIFECYCLES, peer_id)
3408            .map_err(|e| {
3409                Error::Other(format!(
3410                    "Failed to clear welcome lifecycle for {}: {}",
3411                    peer_id, e
3412                ))
3413            })
3414    }
3415
3416    /// Brings one restored Welcome lifecycle back to a state the retry ladder
3417    /// can act on, and reports whether anything changed.
3418    ///
3419    /// Split out so the walk can persist the result **once**, non-fatally. The
3420    /// repairs used to persist inline with `?`, which made a transient
3421    /// `StoreFailed` on any one of them fail `initialize_mls` — a persistence
3422    /// failure blocking restore, where every sibling path in this module logs
3423    /// and carries on. The in-memory map is what drives retries; a repair that
3424    /// could not be written is simply re-derived from the same record next
3425    /// launch.
3426    fn repair_restored_welcome_lifecycle(
3427        peer_id: &str,
3428        record: &mut WelcomeLifecycleRecord,
3429    ) -> bool {
3430        let mut repaired = false;
3431        if matches!(
3432            record.state,
3433            WelcomeDeliveryState::Created | WelcomeDeliveryState::SendAttempted
3434        ) {
3435            record.state = WelcomeDeliveryState::Failed;
3436            record.next_retry_at = Some(Utc::now());
3437            repaired = true;
3438            warn!(
3439                event = "welcome_lifecycle_repaired",
3440                session_or_group_id = %peer_id,
3441                repair_action = "in_flight_to_failed_retry_now",
3442                state = record.state.as_str(),
3443                attempt = record.attempt,
3444                "welcome_lifecycle_repaired"
3445            );
3446        }
3447        if matches!(record.state, WelcomeDeliveryState::Failed) && record.next_retry_at.is_none() {
3448            if matches!(
3449                record.last_reason_code,
3450                Some(crate::events::WelcomeReasonCode::RetryExhausted)
3451            ) {
3452                // Only genuine retry exhaustion (a present carrier that kept
3453                // failing) is terminal here. A stale TTL alone must NOT expire a
3454                // no-carrier Welcome on restart — the TTL clock is
3455                // carrier-relative and is refreshed below.
3456                record.state = WelcomeDeliveryState::Expired;
3457                warn!(
3458                    event = "welcome_lifecycle_repaired",
3459                    session_or_group_id = %peer_id,
3460                    repair_action = "failed_no_retry_to_expired",
3461                    state = record.state.as_str(),
3462                    attempt = record.attempt,
3463                    "welcome_lifecycle_repaired"
3464                );
3465            } else {
3466                // Recover from partial-crash write where Failed was persisted
3467                // without a retry schedule.
3468                record.next_retry_at = Some(Utc::now());
3469                warn!(
3470                    event = "welcome_lifecycle_repaired",
3471                    session_or_group_id = %peer_id,
3472                    repair_action = "failed_no_retry_to_failed_retry_now",
3473                    state = record.state.as_str(),
3474                    attempt = record.attempt,
3475                    "welcome_lifecycle_repaired"
3476                );
3477            }
3478            repaired = true;
3479        }
3480        // The TTL clock is carrier-relative: a Welcome must not be restored
3481        // already-expired after an offline period. Restart the window for any
3482        // non-terminal lifecycle whose TTL has lapsed so it gets a fresh chance
3483        // once a carrier (or the peer) reappears.
3484        if matches!(record.state, WelcomeDeliveryState::Failed) && record.expires_at <= Utc::now() {
3485            record.expires_at = Utc::now() + ChronoDuration::seconds(WELCOME_LIFECYCLE_TTL_SECS);
3486            repaired = true;
3487            warn!(
3488                event = "welcome_lifecycle_repaired",
3489                session_or_group_id = %peer_id,
3490                repair_action = "ttl_refreshed_carrier_relative",
3491                state = record.state.as_str(),
3492                attempt = record.attempt,
3493                "welcome_lifecycle_repaired"
3494            );
3495        }
3496        if matches!(
3497            record.state,
3498            WelcomeDeliveryState::Sent | WelcomeDeliveryState::Expired
3499        ) && record.next_retry_at.is_some()
3500        {
3501            record.next_retry_at = None;
3502            repaired = true;
3503            warn!(
3504                event = "welcome_lifecycle_repaired",
3505                session_or_group_id = %peer_id,
3506                repair_action = "terminal_clear_retry_schedule",
3507                state = record.state.as_str(),
3508                attempt = record.attempt,
3509                "welcome_lifecycle_repaired"
3510            );
3511        }
3512        repaired
3513    }
3514
3515    pub(crate) fn restore_welcome_lifecycles(
3516        &mut self,
3517        allowance: &mut PruneAllowance,
3518    ) -> Result<()> {
3519        self.welcome_lifecycles.clear();
3520        let Some(storage) = &self.protocol_state_storage else {
3521            return Ok(());
3522        };
3523
3524        let peers = Self::list_state_keys(storage.as_ref(), storage_keys::WELCOME_LIFECYCLES)
3525            .map_err(|e| Error::Other(format!("Failed to list welcome lifecycles: {}", e)))?;
3526        let listed = peers.len();
3527
3528        // Bounded like every other container-listed walk: a record that will not
3529        // decode is dropped, and that drop is a provider delete with a directory
3530        // flush behind it. The tail waits for a later launch, which is safe for
3531        // the same reason it is safe for the cache prunes — dropping a Welcome
3532        // lifecycle is recoverable, and re-reading one costs a parse. Draws on
3533        // the launch-wide advisory pool, so this walk and the three cache walks
3534        // add up against one allowance.
3535        let mut budget = allowance.refusing();
3536        let mut unavailable = 0usize;
3537        for peer_id in peers.into_iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
3538            let mut record =
3539                match self.load_welcome_lifecycle_for_restore(&peer_id, Some(&mut budget)) {
3540                    RestorableRecord::Present(record) => record,
3541                    RestorableRecord::Absent => continue,
3542                    // Left on disk for a later launch. Skipping one peer costs a
3543                    // Welcome retry this session; failing the whole restore —
3544                    // which propagating here used to do — costs the install
3545                    // every send it would make, on every launch, for as long as
3546                    // that one record stays unreadable.
3547                    RestorableRecord::Unavailable => {
3548                        unavailable += 1;
3549                        continue;
3550                    }
3551                };
3552            if Self::repair_restored_welcome_lifecycle(&peer_id, &mut record) {
3553                if let Err(e) = self.persist_welcome_lifecycle_entry(&record) {
3554                    warn!(
3555                        session_or_group_id = %peer_id,
3556                        error = %e,
3557                        "Failed to persist a repaired Welcome lifecycle; the repair holds in \
3558                         memory and is re-derived on the next launch"
3559                    );
3560                }
3561            }
3562            self.welcome_lifecycles.insert(peer_id.clone(), record);
3563            info!(
3564                event = "welcome_lifecycle_restored",
3565                session_or_group_id = %peer_id,
3566                "welcome_lifecycle_restored"
3567            );
3568        }
3569
3570        if unavailable > 0 {
3571            warn!(
3572                unavailable,
3573                "Welcome lifecycle records could not be read this session; left on disk for a \
3574                 later launch"
3575            );
3576        }
3577        if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
3578            warn!(
3579                listed,
3580                cap = MAX_RESTORE_KEYS_PER_CATEGORY,
3581                "Welcome lifecycle store listed more peers than any legitimate run can produce; ignoring the tail"
3582            );
3583        }
3584        if budget.exhausted {
3585            warn!(
3586                deleted = budget.spent,
3587                budget = MAX_RESTORE_PRUNE_DELETES,
3588                "Welcome lifecycle restore hit its share of the launch delete budget; the rest is left on disk for a later launch"
3589            );
3590        }
3591
3592        Ok(())
3593    }
3594
3595    // ========================================================================
3596    // OUTBOX PERSISTENCE
3597    // ========================================================================
3598
3599    /// Persists a single outbox entry to storage, keyed by message id.
3600    ///
3601    /// Best-effort and infallible: the send path cannot propagate storage
3602    /// errors, so a failed write is logged and swallowed (the message still
3603    /// lives in the in-memory outbox and will retry; it just won't survive a
3604    /// restart). No-ops when persistence is not configured or when the entry
3605    /// belongs to the media outbox — file transfers are not persisted and
3606    /// resurrected chunks could never complete, so we never write them.
3607    pub(crate) fn persist_outbox_entry(&self, entry: &OutboxEntry) {
3608        let Some(storage) = &self.protocol_state_storage else {
3609            return;
3610        };
3611        if Self::is_media_outbox_message(&entry.message) {
3612            return;
3613        }
3614        match serde_json::to_vec(entry) {
3615            Ok(data) => {
3616                if let Err(e) = self.write_state_record(
3617                    storage.as_ref(),
3618                    storage_keys::OUTBOX,
3619                    &entry.message.id.as_str(),
3620                    &data,
3621                ) {
3622                    warn!(message_id = %entry.message.id, error = %e, "Failed to persist outbox entry");
3623                }
3624            }
3625            Err(e) => {
3626                warn!(message_id = %entry.message.id, error = %e, "Failed to serialize outbox entry");
3627            }
3628        }
3629    }
3630
3631    /// Removes a persisted outbox entry from storage. Best-effort: a media
3632    /// message id is never persisted, so deleting it is a harmless no-op.
3633    pub(crate) fn clear_outbox_entry_from_storage(&self, message_id: &MessageId) {
3634        let Some(storage) = &self.protocol_state_storage else {
3635            return;
3636        };
3637        if let Err(e) = storage.delete(storage_keys::OUTBOX, &message_id.as_str()) {
3638            warn!(message_id = %message_id, error = %e, "Failed to clear persisted outbox entry");
3639        }
3640    }
3641
3642    /// Restores the store-and-forward outbox from storage on startup.
3643    ///
3644    /// Merges persisted entries into `self.outbox` — it is *not* cleared first.
3645    /// An entry queued before persistence was enabled lives only in memory and,
3646    /// if it succeeded at the transport but is awaiting an ACK, is not in the
3647    /// retry queue either; clearing would strand it with no recovery path (the
3648    /// ACK-timeout path drops a message that is missing from the outbox). Where
3649    /// an id exists in both, storage is authoritative and overwrites.
3650    ///
3651    /// The retry queue and ACK manager start empty, so every restored entry
3652    /// lands in the "stranded" state that [`Self::flush_outbox_all`] already
3653    /// recovers — a flush on `start()` re-drives delivery.
3654    ///
3655    /// Recovery rules, mirroring the other restore paths:
3656    /// - corrupted entries are dropped from storage and skipped;
3657    /// - any stray media entry is dropped (they must never be resurrected);
3658    /// - the total is pruned to `MAX_OUTBOX_ENTRIES`, keeping the newest by
3659    ///   `last_sent_at`;
3660    /// - an entry whose total age (from `first_sent_at`) exceeds
3661    ///   [`OUTBOX_ABSOLUTE_LIFETIME_FACTOR`] × the outbox lifetime is dropped
3662    ///   terminally with a `message_failed`, so repeated restarts can't
3663    ///   re-grant a fresh window forever;
3664    /// - otherwise the TTL clock is carrier-relative: an entry whose
3665    ///   `last_sent_at` has already lapsed the outbox lifetime is refreshed
3666    ///   rather than restored already-expired, so it gets a fresh delivery
3667    ///   window once a carrier reappears (mirrors the Welcome lifecycle
3668    ///   repair). This runs *after* the prune, so a refreshed (now-stamped)
3669    ///   clock can't sort as the newest and crowd genuinely-fresh entries out
3670    ///   of the kept set;
3671    /// - pre-existing in-memory entries not yet in storage are persisted, so
3672    ///   memory and storage are consistent once restore returns.
3673    ///
3674    /// Budgeted by [`MAX_RESTORE_PRUNE_DELETES`] the way
3675    /// [`Self::restore_pending_messages`] is, and for the same reason: every
3676    /// delete here is paired with a terminal `message_failed`, so an individual
3677    /// one can never be *refused* — skipping the delete while settling would
3678    /// settle an id whose record the next launch restores and re-drives, and
3679    /// skipping both would drop the entry from memory while leaving the
3680    /// application holding an id nothing ever resolves. What the pairing does
3681    /// not forbid is **stopping between records**, which is what this walk does
3682    /// — and what its two post-walk prunes do between *entries*.
3683    ///
3684    /// [`OUTBOX_RESTORE_KEY_CAP`] alone was once argued to be enough here, on
3685    /// the grounds that it caps the walk near 1.5k deletes. Two things were
3686    /// wrong with that. The number came from the capacity prune, not the walk:
3687    /// the outbox is a *sealed* category, so the wrong-length record-key branch
3688    /// of [`Self::restore_or_init_state_record_key`] makes every entry on the
3689    /// install unopenable at once and each one takes the reader's drop — the
3690    /// full walk bound, in device barriers, on the synchronous boot path. And
3691    /// the number it *did* describe was never bounded either: the capacity
3692    /// drain and the absolute-lifetime drop run after the walk, on entries it
3693    /// admitted, so a store whose records all open cleanly reaches them with
3694    /// the pool untouched. All three now draw on the same pool and stop at an
3695    /// entry boundary; see [`MAX_RESTORE_PRUNE_DELETES`].
3696    ///
3697    /// It draws on its **own** pool rather than the launch-wide advisory one:
3698    /// an outbox record left unwalked is one the application holds a *live* id
3699    /// for, so deferring it defers delivery, and that must not be a consequence
3700    /// of an unrelated key-package flood spending the shared allowance first.
3701    /// No freeze is needed for the tail — outbox records are keyed per message
3702    /// id, so a later write only ever touches a different key.
3703    ///
3704    /// `allowance` is this walk's own pool, built by the launch — see
3705    /// [`PruneAllowance::pool`].
3706    pub(crate) fn restore_outbox(&mut self, allowance: &mut PruneAllowance) -> Result<()> {
3707        // Cloned rather than borrowed: the loop below both reads through this
3708        // handle and calls `&mut self` settlement paths, so holding a borrow of
3709        // `self` across it is what forced the awkward per-iteration re-fetch
3710        // this replaced.
3711        let Some(storage) = self.protocol_state_storage.clone() else {
3712            return Ok(());
3713        };
3714
3715        let message_ids = Self::list_state_keys(storage.as_ref(), storage_keys::OUTBOX)
3716            .map_err(|e| Error::Other(format!("Failed to list outbox entries: {}", e)))?;
3717
3718        let lifetime_ms = self.config.reliability.retry.outbox_max_lifetime_ms;
3719        let listed = message_ids.len();
3720
3721        let mut restored: Vec<OutboxEntry> = Vec::new();
3722        // Record keys the app was told were queued but that cannot be
3723        // recovered. Unlike the pending queue, an outbox record is keyed *by*
3724        // its message id, so each loss is individually settleable without
3725        // opening it — kept as the raw key so one that does not parse as a
3726        // `MessageId` still gets a diagnostic instead of silence.
3727        let mut unrecoverable: Vec<String> = Vec::new();
3728        // Settlement-paired like the pending walk, and budgeted the same way:
3729        // counting, never refusing, stopping at a record boundary. Its own pool
3730        // rather than the advisory one, because starving this walk defers
3731        // *delivery* of every message the app still holds a live id for.
3732        //
3733        // The walk bound alone is not the ceiling it was argued to be. The
3734        // outbox is a sealed category, so a regenerated record key makes every
3735        // entry on the install fail to open at once and each one takes the
3736        // reader's drop below — `OUTBOX_RESTORE_KEY_CAP` device barriers, on the
3737        // synchronous boot path, with nothing counting them. That is verbatim
3738        // the case that put a budget on `restore_pending_messages`.
3739        //
3740        // No freeze is needed for the tail this stops at, unlike the pending
3741        // walk: outbox records are keyed per message id, so every later write
3742        // touches a different key and an unwalked record is simply restored and
3743        // re-driven on the next launch.
3744        //
3745        // The pool is passed in rather than built here — see
3746        // `PruneAllowance::pool`.
3747        let mut budget = allowance.counting();
3748        let mut prune_bound_reached = false;
3749        for message_id in message_ids.into_iter().take(OUTBOX_RESTORE_KEY_CAP) {
3750            if budget.is_spent() {
3751                prune_bound_reached = true;
3752                break;
3753            }
3754            let data = match self.read_state_record_detailed_budgeted(
3755                storage.as_ref(),
3756                storage_keys::OUTBOX,
3757                &message_id,
3758                Some(&mut budget),
3759            ) {
3760                Ok(StateRecord::Present(data)) => data,
3761                Ok(StateRecord::Missing) => continue,
3762                Ok(StateRecord::Unreadable) => {
3763                    warn!(message_id = %message_id, "Dropping unreadable outbox entry");
3764                    unrecoverable.push(message_id);
3765                    continue;
3766                }
3767                // Still on disk and probably intact — the record key is not
3768                // loaded, or the backend refused this read. Settling it as
3769                // failed would be a terminal answer the next launch overturns
3770                // by restoring the entry and re-driving delivery, so the app
3771                // would see `message_failed` and then a delivery (plus a second
3772                // copy if the user re-sent by hand, which mints a new id that
3773                // dedup cannot collapse). Leave it be.
3774                Ok(StateRecord::Unavailable) | Err(_) => {
3775                    warn!(
3776                        message_id = %message_id,
3777                        "Outbox entry could not be read this session; leaving it in place"
3778                    );
3779                    continue;
3780                }
3781            };
3782
3783            let entry = match serde_json::from_slice::<OutboxEntry>(&data) {
3784                Ok(entry) => entry,
3785                Err(e) => {
3786                    warn!(message_id = %message_id, error = %e, "Dropping corrupted outbox entry");
3787                    // Charged, never refused: the settlement below is already
3788                    // owed, so the record has to go with it.
3789                    budget.claim();
3790                    self.delete_outbox_key(&message_id);
3791                    unrecoverable.push(message_id);
3792                    continue;
3793                }
3794            };
3795
3796            // A media entry should never have been persisted; drop any that
3797            // slipped in (e.g. from an older build) so it can't be resurrected.
3798            if Self::is_media_outbox_message(&entry.message) {
3799                warn!(message_id = %message_id, "Dropping persisted media outbox entry");
3800                budget.claim();
3801                self.delete_outbox_key(&message_id);
3802                continue;
3803            }
3804
3805            restored.push(entry);
3806        }
3807
3808        // Drop entries past the absolute lifetime cap before anything else:
3809        // the carrier-relative refresh below would otherwise re-grant them a
3810        // fresh window on every restart, indefinitely. This is a terminal
3811        // drop — emit `message_failed` like the in-process expiry does.
3812        let absolute_lifetime_ms =
3813            lifetime_ms.saturating_mul(crate::constants::OUTBOX_ABSOLUTE_LIFETIME_FACTOR as u64);
3814        let now = Utc::now();
3815        let mut absolutely_expired: Vec<OutboxEntry> = Vec::new();
3816        restored.retain(|entry| {
3817            if lifetime_expired(now, entry.last_sent_at, lifetime_ms)
3818                && lifetime_expired(now, entry.first_sent_at, absolute_lifetime_ms)
3819            {
3820                absolutely_expired.push(entry.clone());
3821                return false;
3822            }
3823            true
3824        });
3825        // Counted and never refused *mid-entry* — this delete is inseparable
3826        // from the `message_failed` that pairs with it — but stopped *between*
3827        // entries, exactly as the walk stops between records.
3828        //
3829        // The gate is load-bearing rather than tidy. This loop and the capacity
3830        // drain below act on entries the walk *admitted*, so a store whose
3831        // records all open cleanly reaches them with the pool untouched and
3832        // `restored` bounded only by `OUTBOX_RESTORE_KEY_CAP`. Ungated, that is
3833        // up to that many device barriers in a single launch — a device offline
3834        // past the absolute lifetime with a full outbox, which is the *ordinary*
3835        // over-capacity case rather than the tampered one.
3836        //
3837        // An entry the pool cannot fund is dropped from memory and left on disk
3838        // **unsettled**. That is what makes stopping safe: nothing has told the
3839        // application anything about it, so a later launch restores it, re-ages
3840        // it, and owns both halves of the settlement then — the same deferral
3841        // the walk's own early break relies on.
3842        let mut expiry_settlements: Vec<Event> = Vec::new();
3843        for entry in &absolutely_expired {
3844            if budget.is_spent() {
3845                prune_bound_reached = true;
3846                break;
3847            }
3848            budget.claim();
3849            self.delete_outbox_key(&entry.message.id.as_str());
3850            info!(
3851                event = "outbox_entry_dropped",
3852                message_id = %entry.message.id,
3853                repair_action = "absolute_lifetime_exceeded",
3854                "outbox_entry_dropped"
3855            );
3856            expiry_settlements.push(Event::message_failed(
3857                entry.message.id.clone(),
3858                "Outbox lifetime exceeded".to_string(),
3859                entry.attempt_count,
3860            ));
3861        }
3862        // Batched: this is one of the two loops `settle_restored_message_failures`
3863        // exists for. A store past its cap drives it into the hundreds, and the
3864        // per-event form takes the shared-state lock once each.
3865        self.settle_restored_message_failures(expiry_settlements);
3866
3867        // Prune to capacity BEFORE refreshing TTLs, keeping the newest by
3868        // last_sent_at. Delete the pruned overflow from storage so it can't
3869        // linger and be re-restored. Ordering matters: refreshing a lapsed
3870        // clock stamps it with `now`, which would otherwise sort it as the
3871        // newest and crowd genuinely-fresh entries out of the kept set.
3872        if restored.len() > MAX_OUTBOX_ENTRIES {
3873            restored.sort_by_key(|e| std::cmp::Reverse(e.last_sent_at));
3874            // `drain` removes its whole range when the iterator is dropped, so
3875            // breaking early still takes every over-cap entry out of memory.
3876            // That is what keeps the cap absolute: deferring a *delete* must
3877            // never defer the cap itself, or an over-cap store would re-inflate
3878            // memory on boot. The entries the pool could not fund are left on
3879            // disk unsettled and re-capped by a later launch, exactly like the
3880            // tail the walk never reached.
3881            //
3882            // Unbudgeted, this drain alone issues
3883            // `OUTBOX_RESTORE_KEY_CAP - MAX_OUTBOX_ENTRIES` deletes in one
3884            // launch — the very figure this walk's old budget exemption was
3885            // argued from. Counted, never refused mid-entry, stopped between
3886            // entries: see the absolute-expiry drop above.
3887            let mut capacity_settlements: Vec<Event> = Vec::new();
3888            for entry in restored.drain(MAX_OUTBOX_ENTRIES..) {
3889                if budget.is_spent() {
3890                    prune_bound_reached = true;
3891                    break;
3892                }
3893                budget.claim();
3894                self.delete_outbox_key(&entry.message.id.as_str());
3895                // Terminal, like the pending queue's capacity eviction: the app
3896                // holds this id and nothing will ever resolve it otherwise.
3897                capacity_settlements.push(Event::message_failed(
3898                    entry.message.id.clone(),
3899                    "Outbox capacity exceeded".to_string(),
3900                    entry.attempt_count,
3901                ));
3902            }
3903            // Batched for the same reason as the expiry settlements above.
3904            self.settle_restored_message_failures(capacity_settlements);
3905        }
3906
3907        self.settle_restored_message_failures(unrecoverable.iter().map(|key| {
3908            Self::unrecoverable_message_settlement(
3909                key,
3910                "Outbox entry could not be recovered from protocol-state storage",
3911                0,
3912            )
3913        }));
3914        if listed > OUTBOX_RESTORE_KEY_CAP {
3915            warn!(
3916                listed,
3917                cap = OUTBOX_RESTORE_KEY_CAP,
3918                "Outbox store listed more entries than any legitimate run can produce; ignoring the tail"
3919            );
3920        }
3921        // Covers all three places this walk can stop: the read loop, the
3922        // absolute-expiry drop, and the capacity drain. `budget.exhausted` is
3923        // unreachable while each of those checks `is_spent()` before claiming —
3924        // it is ORed in so a future ungated claim still surfaces rather than
3925        // spending the pool in silence.
3926        if prune_bound_reached || budget.exhausted {
3927            warn!(
3928                deleted = budget.spent,
3929                budget = MAX_RESTORE_PRUNE_DELETES,
3930                "Outbox restore spent its delete budget; the remaining entries are left on disk \
3931                 and restored on a later launch"
3932            );
3933        }
3934
3935        // Carrier-relative TTL: refresh any entry already past the outbox
3936        // lifetime so it survives the first cleanup tick and gets a fresh chance
3937        // once a carrier appears. Collect the refreshed clones so the repair can
3938        // be re-persisted below, once the mutable borrow of `restored` is gone.
3939        let mut refreshed: Vec<OutboxEntry> = Vec::new();
3940        for entry in &mut restored {
3941            if lifetime_expired(now, entry.last_sent_at, lifetime_ms) {
3942                entry.last_sent_at = now;
3943                refreshed.push(entry.clone());
3944                info!(
3945                    event = "outbox_entry_restored",
3946                    message_id = %entry.message.id,
3947                    repair_action = "ttl_refreshed_carrier_relative",
3948                    "outbox_entry_restored"
3949                );
3950            }
3951        }
3952
3953        // Pre-existing in-memory entries not backed by storage (queued before
3954        // persistence was enabled) must be persisted too, so they survive the
3955        // next restart and memory/storage stay consistent after restore.
3956        let restored_ids: std::collections::HashSet<String> =
3957            restored.iter().map(|e| e.message.id.as_str()).collect();
3958        let orphans: Vec<OutboxEntry> = self
3959            .outbox
3960            .values()
3961            .filter(|e| !restored_ids.contains(&e.message.id.as_str()))
3962            .cloned()
3963            .collect();
3964
3965        let count = restored.len();
3966        for entry in restored {
3967            self.outbox.insert(entry.message.id.clone(), entry);
3968        }
3969        for entry in &refreshed {
3970            self.persist_outbox_entry(entry);
3971        }
3972        for entry in &orphans {
3973            self.persist_outbox_entry(entry);
3974        }
3975        if count > 0 {
3976            info!(count = count, "Restored outbox entries from storage");
3977        }
3978
3979        Ok(())
3980    }
3981
3982    /// Deletes an outbox key from storage without the media/no-storage guards
3983    /// of [`Self::clear_outbox_entry_from_storage`] — used inside
3984    /// [`Self::restore_outbox`], which already holds a storage handle and
3985    /// operates on raw persisted keys.
3986    fn delete_outbox_key(&self, message_id: &str) {
3987        if let Some(storage) = &self.protocol_state_storage {
3988            if let Err(e) = storage.delete(storage_keys::OUTBOX, message_id) {
3989                warn!(message_id = %message_id, error = %e, "Failed to delete outbox key");
3990            }
3991        }
3992    }
3993
3994    // ========================================================================
3995    // MEDIA TRANSFER DESCRIPTOR PERSISTENCE
3996    // ========================================================================
3997
3998    /// Persists a media transfer descriptor (never chunk bytes) to storage.
3999    ///
4000    /// Best-effort and infallible, like [`Self::persist_outbox_entry`]: a
4001    /// failed write only costs the crash-recovery signal, not the transfer.
4002    pub(crate) fn persist_media_descriptor(&self, descriptor: &MediaTransferDescriptor) {
4003        let Some(storage) = &self.protocol_state_storage else {
4004            return;
4005        };
4006        match serde_json::to_vec(descriptor) {
4007            Ok(data) => {
4008                if let Err(e) = self.write_state_record(
4009                    storage.as_ref(),
4010                    storage_keys::MEDIA_DESCRIPTORS,
4011                    &descriptor.file_id,
4012                    &data,
4013                ) {
4014                    warn!(file_id = %descriptor.file_id, error = %e, "Failed to persist media descriptor");
4015                }
4016            }
4017            Err(e) => {
4018                warn!(file_id = %descriptor.file_id, error = %e, "Failed to serialize media descriptor");
4019            }
4020        }
4021    }
4022
4023    /// Removes a persisted media transfer descriptor (and any restored copy).
4024    ///
4025    /// Called wherever the in-memory transfer is removed — completion, abort,
4026    /// stale sweep — and when a restored descriptor is consumed by a resend,
4027    /// so a descriptor only ever survives into a restore when the process
4028    /// died mid-transfer.
4029    pub(crate) fn remove_media_descriptor(&mut self, file_id: &str) {
4030        self.restored_media_descriptors.remove(file_id);
4031        let Some(storage) = &self.protocol_state_storage else {
4032            return;
4033        };
4034        if let Err(e) = storage.delete(storage_keys::MEDIA_DESCRIPTORS, file_id) {
4035            warn!(file_id = %file_id, error = %e, "Failed to clear persisted media descriptor");
4036        }
4037    }
4038
4039    /// Restores persisted media transfer descriptors on startup.
4040    ///
4041    /// Recovery rules mirror [`Self::restore_outbox`]:
4042    /// - a record that will not open, or that the store itself calls corrupt,
4043    ///   is dropped and skipped;
4044    /// - so is one whose bytes are not a descriptor;
4045    /// - entries older than the outbox lifetime are dropped — the app has
4046    ///   long settled that message's fate, a resend signal would be noise;
4047    /// - the total is pruned to `MAX_MEDIA_DESCRIPTORS`, keeping the newest
4048    ///   by `queued_at` (overflow deleted from storage).
4049    ///
4050    /// **All four** of those deletes share one [`MAX_RESTORE_PRUNE_DELETES`]
4051    /// budget, including the first — which this walk does not issue itself, so
4052    /// it is the one that went uncounted: it happens inside
4053    /// [`Self::read_state_record_detailed`], which is why the budget is handed
4054    /// to the reader rather than only claimed around the explicit calls below.
4055    /// Descriptors are a sealed category, so a regenerated record key makes
4056    /// *every* record on the install take that path at once.
4057    ///
4058    /// A descriptor is advisory, so a record the budget spared is simply
4059    /// re-walked and dropped on a later launch.
4060    ///
4061    /// The survivors are parked in `restored_media_descriptors`; `start()`
4062    /// emits one `MediaResendRequired` each once the event pipeline is live,
4063    /// leaving entries parked until a same-`file_id` resend consumes them or
4064    /// the restore TTL prunes them.
4065    pub(crate) fn restore_media_descriptors(
4066        &mut self,
4067        allowance: &mut PruneAllowance,
4068    ) -> Result<()> {
4069        // Cloned rather than borrowed, like `restore_outbox`: the walk below
4070        // reads through this handle while holding a mutable borrow of the
4071        // prune budget and calling `&self` delete helpers.
4072        let Some(storage) = self.protocol_state_storage.clone() else {
4073            return Ok(());
4074        };
4075
4076        let file_ids = Self::list_state_keys(storage.as_ref(), storage_keys::MEDIA_DESCRIPTORS)
4077            .map_err(|e| Error::Other(format!("Failed to list media descriptors: {}", e)))?;
4078
4079        let lifetime_ms = self.config.reliability.retry.outbox_max_lifetime_ms;
4080        let now = Utc::now();
4081
4082        let mut restored: Vec<MediaTransferDescriptor> = Vec::new();
4083        // Every delete this walk causes — unreadable, unparseable, expired,
4084        // over-cap — is budgeted together: a descriptor is purely advisory (the
4085        // app re-initiates the transfer), so a record left on disk one launch
4086        // longer costs nothing, while the device barrier each delete carries is
4087        // the most expensive thing this walk can do. Skipped records are simply
4088        // re-walked next launch and dropped then. "Causes" rather than
4089        // "issues": the first of the four happens inside the reader, which is
4090        // why the budget is handed to it below. Draws on the launch-wide
4091        // advisory pool it shares with the two cache walks and the Welcome
4092        // lifecycle walk.
4093        let mut budget = allowance.refusing();
4094        // Bounded, but deliberately by the wide ceiling rather than a multiple
4095        // of `MAX_MEDIA_DESCRIPTORS`: that cap is applied here, on restore, and
4096        // nowhere on the insert path, so a long session can legitimately leave
4097        // more descriptors on disk than the cap. A tight prefix would keep the
4098        // wrong ones (the survivors are chosen by `queued_at` among what was
4099        // walked) and strand the rest forever, since nothing else deletes them.
4100        // A descriptor loss is advisory anyway — the app re-initiates the
4101        // transfer — so there is nothing to settle here; only the walk itself
4102        // needs a ceiling.
4103        for file_id in file_ids.into_iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
4104            // The reader takes the budget because it is the reader that deletes
4105            // a record it cannot return — absent, unreadable, and
4106            // unreadable-this-session are all "nothing to park" here, so this
4107            // walk only has to distinguish them from a record it can decode.
4108            let data = match self.read_state_record_detailed_budgeted(
4109                storage.as_ref(),
4110                storage_keys::MEDIA_DESCRIPTORS,
4111                &file_id,
4112                Some(&mut budget),
4113            ) {
4114                Ok(StateRecord::Present(data)) => data,
4115                Ok(StateRecord::Missing | StateRecord::Unreadable | StateRecord::Unavailable)
4116                | Err(_) => continue,
4117            };
4118
4119            let descriptor = match serde_json::from_slice::<MediaTransferDescriptor>(&data) {
4120                Ok(descriptor) => descriptor,
4121                Err(e) => {
4122                    warn!(file_id = %file_id, error = %e, "Dropping corrupted media descriptor");
4123                    if budget.claim() {
4124                        self.delete_media_descriptor_key(&file_id);
4125                    }
4126                    continue;
4127                }
4128            };
4129
4130            if lifetime_expired(now, descriptor.queued_at, lifetime_ms) {
4131                debug!(file_id = %file_id, "Dropping expired media descriptor");
4132                if budget.claim() {
4133                    self.delete_media_descriptor_key(&file_id);
4134                }
4135                continue;
4136            }
4137
4138            restored.push(descriptor);
4139        }
4140
4141        if restored.len() > MAX_MEDIA_DESCRIPTORS {
4142            restored.sort_by_key(|d| std::cmp::Reverse(d.queued_at));
4143            for descriptor in restored.drain(MAX_MEDIA_DESCRIPTORS..) {
4144                // Dropped from the parked set either way: the cap is what this
4145                // build will announce, and a record the budget spared is
4146                // re-walked (and re-capped) on the next launch.
4147                if budget.claim() {
4148                    self.delete_media_descriptor_key(&descriptor.file_id);
4149                }
4150            }
4151        }
4152
4153        if budget.exhausted {
4154            warn!(
4155                deleted = budget.spent,
4156                budget = MAX_RESTORE_PRUNE_DELETES,
4157                "Media descriptor prune hit its share of the launch delete budget; the rest is left on disk for a later launch"
4158            );
4159        }
4160
4161        let count = restored.len();
4162        for descriptor in restored {
4163            self.restored_media_descriptors
4164                .insert(descriptor.file_id.clone(), descriptor);
4165        }
4166        if count > 0 {
4167            info!(
4168                count = count,
4169                "Restored media transfer descriptors from storage"
4170            );
4171        }
4172
4173        Ok(())
4174    }
4175
4176    /// Deletes a media descriptor key from storage (restore-internal, mirrors
4177    /// [`Self::delete_outbox_key`]).
4178    fn delete_media_descriptor_key(&self, file_id: &str) {
4179        if let Some(storage) = &self.protocol_state_storage {
4180            if let Err(e) = storage.delete(storage_keys::MEDIA_DESCRIPTORS, file_id) {
4181                warn!(file_id = %file_id, error = %e, "Failed to delete media descriptor key");
4182            }
4183        }
4184    }
4185
4186    // ========================================================================
4187    // BLOCKED USERS PERSISTENCE
4188    // ========================================================================
4189
4190    /// Persists a blocked user entry to storage.
4191    pub(crate) fn persist_blocked_user(&self, user_id: &str) {
4192        let Some(storage) = &self.protocol_state_storage else {
4193            return;
4194        };
4195        if let Err(e) =
4196            self.write_state_record(storage.as_ref(), storage_keys::BLOCKED_USERS, user_id, &[])
4197        {
4198            warn!(user_id = %user_id, error = %e, "Failed to persist blocked user");
4199        }
4200    }
4201
4202    /// Deletes a blocked user entry from storage.
4203    pub(crate) fn delete_blocked_user(&self, user_id: &str) {
4204        let Some(storage) = &self.protocol_state_storage else {
4205            return;
4206        };
4207        if let Err(e) = storage.delete(storage_keys::BLOCKED_USERS, user_id) {
4208            warn!(user_id = %user_id, error = %e, "Failed to delete blocked user from storage");
4209        }
4210    }
4211
4212    /// Restores blocked users from persistent storage.
4213    ///
4214    /// Skips entries with invalid user IDs (best-effort restore).
4215    ///
4216    /// A *listing* failure is not best-effort: it is indistinguishable from an
4217    /// empty store, so swallowing it would come up with an empty block list and
4218    /// tell no one — every peer the user blocked silently unblocked, from a
4219    /// transient error. That is the same outcome this branch's release notes
4220    /// call out as the reason a downgrade is not a rollback. Propagating rolls
4221    /// `initialize_mls` back instead, so the app finds out rather than running
4222    /// unprotected. Blocking is a safety control; it fails closed.
4223    pub(crate) fn restore_blocked_users(&mut self) -> Result<()> {
4224        let Some(storage) = &self.protocol_state_storage else {
4225            return Ok(());
4226        };
4227        let user_ids = Self::list_state_keys(storage.as_ref(), storage_keys::BLOCKED_USERS)
4228            .map_err(|e| Error::Other(format!("Failed to list blocked users: {}", e)))?;
4229        let listed = user_ids.len();
4230        // Two bounds, because they do two different jobs and neither implies
4231        // the other. This one bounds *work*: how many keys a single pass may
4232        // look at, like every other category walk. The `MAX_BLOCKED_USERS`
4233        // break below bounds the *set*. Only the walk bound covers the entries
4234        // that never reach the set at all — an invalid user id or a planted
4235        // self-block `continue`s without counting toward the live cap, so a
4236        // container filled with unparseable keys would otherwise cost one
4237        // iteration and one `warn!` line each, unbounded, on every launch.
4238        for user_id in user_ids.iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
4239            if offline_protocol_core::UserId::new(user_id).is_err() {
4240                warn!(user_id = %user_id, "Skipping blocked user entry with invalid user ID");
4241                continue;
4242            }
4243            // The live path refuses to block your own id; restore has to refuse
4244            // it too, or a planted record blocks the local user — which
4245            // `block_user` would never have produced.
4246            if user_id.as_str() == self.local_id {
4247                warn!("Skipping blocked user entry naming the local user");
4248                continue;
4249            }
4250            // Bounded by the *live* cap, not the restore-walk cap.
4251            //
4252            // These are two different numbers for two different jobs, and using
4253            // the walk bound here let them disagree by 6384 entries.
4254            // `MAX_RESTORE_KEYS_PER_CATEGORY` bounds work — how many keys one
4255            // pass may look at. `MAX_BLOCKED_USERS` bounds the block list
4256            // itself, and `block_user` enforces it on every insert. Restoring
4257            // past it left the set in a state the live path cannot reach, where
4258            // every subsequent `block_user` fails with "limit reached" until the
4259            // user manually unblocks. Since this category is never consumed by
4260            // restore, planted records persist across every launch — so an
4261            // attacker who can write the container could permanently disable
4262            // blocking by adding markers, without unblocking anyone.
4263            //
4264            // Stopping at the cap keeps the blocks already restored, which is
4265            // the fail-closed direction: an over-full store still blocks the
4266            // first MAX_BLOCKED_USERS peers it names.
4267            if self.blocked_users.len() >= MAX_BLOCKED_USERS {
4268                warn!(
4269                    listed,
4270                    cap = MAX_BLOCKED_USERS,
4271                    "Blocked-user store holds more entries than the live cap admits; \
4272                     restoring the first {} and ignoring the rest",
4273                    MAX_BLOCKED_USERS
4274                );
4275                break;
4276            }
4277            self.blocked_users.insert(user_id.clone());
4278        }
4279        if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
4280            warn!(
4281                listed,
4282                cap = MAX_RESTORE_KEYS_PER_CATEGORY,
4283                "Blocked-user store listed more entries than any legitimate run can produce; ignoring the tail"
4284            );
4285        }
4286        if !self.blocked_users.is_empty() {
4287            info!(
4288                count = self.blocked_users.len(),
4289                "Restored blocked users from storage"
4290            );
4291        }
4292        Ok(())
4293    }
4294
4295    // ========================================================================
4296    // BOTH-CREATE OWNER GATE PERSISTENCE
4297    // ========================================================================
4298
4299    /// Persists a both-create owner-gate entry (value-less; the key is the peer).
4300    pub(crate) fn persist_both_create_awaiting_decrypt(&self, peer_id: &str) {
4301        let Some(storage) = &self.protocol_state_storage else {
4302            return;
4303        };
4304        if let Err(e) = self.write_state_record(
4305            storage.as_ref(),
4306            storage_keys::BOTH_CREATE_AWAITING_DECRYPT,
4307            peer_id,
4308            &[],
4309        ) {
4310            warn!(peer_id = %peer_id, error = %e, "Failed to persist both-create owner gate");
4311        }
4312    }
4313
4314    /// Deletes a both-create owner-gate entry once the peer has converged.
4315    pub(crate) fn delete_both_create_awaiting_decrypt(&self, peer_id: &str) {
4316        let Some(storage) = &self.protocol_state_storage else {
4317            return;
4318        };
4319        if let Err(e) = storage.delete(storage_keys::BOTH_CREATE_AWAITING_DECRYPT, peer_id) {
4320            warn!(peer_id = %peer_id, error = %e, "Failed to delete both-create owner gate");
4321        }
4322    }
4323
4324    /// Restores the both-create owner gate from storage on startup, so an owner
4325    /// that restarted mid-convergence keeps requiring a group-aware decrypt
4326    /// before confirming a still-pending peer. Stale entries for already-confirmed
4327    /// peers are harmless (confirmation short-circuits) and are cleared on the
4328    /// next confirm.
4329    pub(crate) fn restore_both_create_awaiting_decrypt(&mut self) {
4330        let Some(storage) = &self.protocol_state_storage else {
4331            return;
4332        };
4333        let peer_ids = match Self::list_state_keys(
4334            storage.as_ref(),
4335            storage_keys::BOTH_CREATE_AWAITING_DECRYPT,
4336        ) {
4337            Ok(keys) => keys,
4338            Err(e) => {
4339                warn!(error = %e, "Failed to list both-create owner gate from storage");
4340                return;
4341            }
4342        };
4343        let listed = peer_ids.len();
4344        for peer_id in peer_ids.iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
4345            self.both_create_awaiting_decrypt.insert(peer_id.clone());
4346        }
4347        if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
4348            warn!(
4349                listed,
4350                cap = MAX_RESTORE_KEYS_PER_CATEGORY,
4351                "Both-create owner gate listed more peers than any legitimate run can produce; ignoring the tail"
4352            );
4353        }
4354        if !self.both_create_awaiting_decrypt.is_empty() {
4355            info!(
4356                count = self.both_create_awaiting_decrypt.len(),
4357                "Restored both-create owner gate from storage"
4358            );
4359        }
4360    }
4361
4362    // ========================================================================
4363    // TELEMETRY SCRUB-SECRET PERSISTENCE
4364    // ========================================================================
4365
4366    /// Loads (or, on first run, generates and persists) the per-install
4367    /// telemetry scrub secret, then installs it as the fallback secret used
4368    /// for opaque-identifier hashing when no explicit `scrub_secret` is set on
4369    /// the installed `TelemetryConfig`.
4370    ///
4371    /// This makes opaque identifiers stable across process restarts so backend
4372    /// telemetry can count distinct devices: the same device hashes to the same
4373    /// opaque id every session. Until storage is available (or if storage is
4374    /// never provided), the SDK keeps using the random per-instance fallback
4375    /// generated at construction — so this is purely an upgrade over the
4376    /// random fallback, never a regression.
4377    ///
4378    /// Secret precedence is unchanged: an explicit
4379    /// [`crate::telemetry::TelemetryConfig::with_scrub_secret`] still wins over
4380    /// this persistent fallback (see [`crate::telemetry::Scrubber::from_config`]).
4381    ///
4382    /// Idempotent across repeated initialization attempts via
4383    /// `telemetry_secret_persisted`. All
4384    /// storage failures degrade gracefully to the in-memory random fallback —
4385    /// telemetry pseudonymization must never block protocol initialization.
4386    pub(crate) fn restore_or_init_scrub_secret(&mut self) {
4387        if self.telemetry_secret_persisted {
4388            return;
4389        }
4390        let Some(storage) = &self.secure_storage else {
4391            return;
4392        };
4393
4394        let secret: [u8; 16] = match storage
4395            .load(storage_keys::SCRUB_SECRET, storage_keys::SCRUB_SECRET_ID)
4396        {
4397            Ok(Some(bytes)) if bytes.len() == 16 => {
4398                let mut secret = [0u8; 16];
4399                secret.copy_from_slice(&bytes);
4400                debug!("Restored persistent telemetry scrub secret from storage");
4401                secret
4402            }
4403            Ok(other) => {
4404                // Absent, or present but corrupt/wrong-length: generate a fresh
4405                // secret and persist it. A wrong-length blob is overwritten so a
4406                // single corrupt write does not pin every future session to the
4407                // random fallback.
4408                if other.is_some() {
4409                    warn!("Persisted scrub secret had unexpected length; regenerating");
4410                }
4411                let fresh = *uuid::Uuid::new_v4().as_bytes();
4412                if let Err(e) = storage.store(
4413                    storage_keys::SCRUB_SECRET,
4414                    storage_keys::SCRUB_SECRET_ID,
4415                    &fresh,
4416                ) {
4417                    // Keep the in-memory secret for this session; next launch
4418                    // will retry persistence. Opaque ids stay stable within
4419                    // this process but may differ next session — strictly no
4420                    // worse than the legacy random fallback.
4421                    warn!(error = %e, "Failed to persist telemetry scrub secret; using session-local secret");
4422                    return self.adopt_fallback_secret(fresh);
4423                }
4424                info!("Generated and persisted per-install telemetry scrub secret");
4425                fresh
4426            }
4427            Err(e) => {
4428                warn!(error = %e, "Failed to load telemetry scrub secret; keeping random fallback");
4429                return;
4430            }
4431        };
4432
4433        self.telemetry_secret_persisted = true;
4434        self.adopt_fallback_secret(secret);
4435    }
4436
4437    // ========================================================================
4438    // NOSTR SIGNING-SECRET PERSISTENCE
4439    // ========================================================================
4440
4441    /// Loads (or, on first run, generates and persists) the per-install Nostr
4442    /// signing secret and installs the derived signing key into the Nostr
4443    /// transport, replacing the ephemeral key it was constructed with.
4444    ///
4445    /// This gives the install a stable Nostr identity (event signatures,
4446    /// relay-visible pubkey) across restarts. The signing key is intentionally
4447    /// not derivable from any public identifier (SEC-M4); message addressing
4448    /// is unaffected because it uses the separate routing tag, which is
4449    /// derived from this device's address.
4450    ///
4451    /// Registering the Nostr transport *after* this has run leaves it on its
4452    /// ephemeral key — see [`Self::restore_nostr_transport_state`], which is
4453    /// how the bindings layer (whose transport cannot exist until the address
4454    /// does) gets a second pass.
4455    ///
4456    /// Idempotent across repeated initialization attempts via
4457    /// `nostr_secret_persisted`. All
4458    /// failures degrade gracefully to the construction-time ephemeral key,
4459    /// which is equally unforgeable but rotates per process — transport
4460    /// keying must never block protocol initialization. A secret that was
4461    /// installed but could not be persisted is kept in
4462    /// `nostr_unpersisted_secret` so a later attempt retries persisting the
4463    /// same identity instead of rotating it.
4464    pub(crate) fn restore_or_init_nostr_signing_secret(&mut self) {
4465        if self.nostr_secret_persisted {
4466            return;
4467        }
4468        let Some(storage) = self.secure_storage.clone() else {
4469            return;
4470        };
4471        let Some(nostr_arc) = self.transport_manager.get_transport(TransportType::Nostr) else {
4472            // Nostr transport not registered — nothing to key.
4473            return;
4474        };
4475
4476        let (secret, persisted): (Zeroizing<[u8; 32]>, bool) = match storage.load(
4477            storage_keys::NOSTR_SIGNING_SECRET,
4478            storage_keys::NOSTR_SIGNING_SECRET_ID,
4479        ) {
4480            Ok(Some(bytes)) if bytes.len() == 32 => {
4481                let bytes = Zeroizing::new(bytes);
4482                let mut secret = Zeroizing::new([0u8; 32]);
4483                secret.copy_from_slice(&bytes);
4484                // A stored secret supersedes anything a previous attempt
4485                // failed to persist.
4486                self.nostr_unpersisted_secret = None;
4487                debug!("Restored persistent Nostr signing secret from storage");
4488                (secret, true)
4489            }
4490            Ok(other) => {
4491                // Absent, or present but corrupt/wrong-length: persist a
4492                // fresh secret (a wrong-length blob is overwritten so a
4493                // single corrupt write does not pin every future session to
4494                // the ephemeral key). Prefer a secret a previous attempt
4495                // installed but failed to persist, so the retry keeps the
4496                // identity already in use instead of rotating it again.
4497                if other.is_some() {
4498                    warn!("Persisted Nostr signing secret had unexpected length; regenerating");
4499                }
4500                let fresh = match self.nostr_unpersisted_secret.take() {
4501                    Some(unpersisted) => unpersisted,
4502                    None => match NostrKeypair::generate_install_secret() {
4503                        Ok(fresh) => fresh,
4504                        Err(e) => {
4505                            warn!(error = %e, "Failed to generate Nostr signing secret; keeping ephemeral key");
4506                            return;
4507                        }
4508                    },
4509                };
4510                match storage.store(
4511                    storage_keys::NOSTR_SIGNING_SECRET,
4512                    storage_keys::NOSTR_SIGNING_SECRET_ID,
4513                    &*fresh,
4514                ) {
4515                    Ok(()) => {
4516                        info!("Generated and persisted per-install Nostr signing secret");
4517                        (fresh, true)
4518                    }
4519                    Err(e) => {
4520                        // Install the unpersisted secret anyway: the identity
4521                        // is stable for this session and the next entry path
4522                        // or launch retries persistence — strictly no worse
4523                        // than the ephemeral key.
4524                        warn!(error = %e, "Failed to persist Nostr signing secret; Nostr identity is session-local");
4525                        (fresh, false)
4526                    }
4527                }
4528            }
4529            Err(e) => {
4530                warn!(error = %e, "Failed to load Nostr signing secret; keeping ephemeral key");
4531                return;
4532            }
4533        };
4534
4535        let installed = match nostr_arc.as_any().downcast_ref::<NostrTransport>() {
4536            Some(nostr) => match nostr.install_signing_secret(&*secret) {
4537                Ok(()) => true,
4538                Err(e) => {
4539                    warn!(error = %e, "Failed to install Nostr signing key; keeping ephemeral key");
4540                    false
4541                }
4542            },
4543            None => {
4544                warn!("Transport registered as Nostr is not a NostrTransport; cannot install signing key");
4545                false
4546            }
4547        };
4548
4549        if !persisted {
4550            // Keep the secret so the next entry path retries persisting this
4551            // same identity rather than generating a new one.
4552            self.nostr_unpersisted_secret = Some(secret);
4553        }
4554        self.nostr_secret_persisted = installed && persisted;
4555    }
4556
4557    // ========================================================================
4558    // NOSTR RECEIVE-WATERMARK PERSISTENCE
4559    // ========================================================================
4560
4561    /// Runs `f` against the registered Nostr transport, or returns `None` if
4562    /// Nostr is not enabled for this instance.
4563    fn with_nostr_transport<R>(&self, f: impl FnOnce(&NostrTransport) -> R) -> Option<R> {
4564        let arc = self.transport_manager.get_transport(TransportType::Nostr)?;
4565        let nostr = arc.as_any().downcast_ref::<NostrTransport>()?;
4566        Some(f(nostr))
4567    }
4568
4569    /// Records that a Nostr relay event dated `created_at_secs` (unix seconds)
4570    /// was received and decoded, advancing the receive watermark and persisting
4571    /// it if it has moved far enough.
4572    ///
4573    /// Call this only for frames that deserialized into a `Message` — see
4574    /// [`NostrTransport::advance_receive_watermark`] for why an undecodable
4575    /// frame must not move the mark.
4576    pub fn note_nostr_event_received(&mut self, created_at_secs: i64) {
4577        let advanced = self
4578            .with_nostr_transport(|nostr| nostr.advance_receive_watermark(created_at_secs))
4579            .unwrap_or(false);
4580        if advanced {
4581            self.persist_nostr_watermark();
4582        }
4583    }
4584
4585    /// Debounced Nostr receive-watermark persistence. Writes only once the
4586    /// live mark has advanced past the last persisted one by
4587    /// [`NOSTR_WATERMARK_PERSIST_INTERVAL_SECS`](super::NOSTR_WATERMARK_PERSIST_INTERVAL_SECS),
4588    /// so a busy relay connection does not cost one storage write per inbound
4589    /// event.
4590    pub(crate) fn persist_nostr_watermark(&mut self) {
4591        let Some(current) = self.with_nostr_transport(NostrTransport::receive_watermark_secs)
4592        else {
4593            return;
4594        };
4595        let Some(current) = current else {
4596            return;
4597        };
4598        let due = match self.last_persisted_nostr_watermark {
4599            Some(last) => {
4600                current.saturating_sub(last) >= super::NOSTR_WATERMARK_PERSIST_INTERVAL_SECS
4601            }
4602            // Nothing written yet this session: get a mark on disk promptly, so
4603            // a first launch that receives one message and is killed does not
4604            // start the next launch from a full backfill window.
4605            None => true,
4606        };
4607        if due {
4608            self.write_nostr_watermark_to_storage(current);
4609        }
4610    }
4611
4612    /// Forces the Nostr receive watermark to storage regardless of debounce
4613    /// state. Called on shutdown so the un-flushed tail is not lost.
4614    pub(crate) fn flush_nostr_watermark(&mut self) {
4615        let Some(Some(current)) = self.with_nostr_transport(NostrTransport::receive_watermark_secs)
4616        else {
4617            return;
4618        };
4619        if self.last_persisted_nostr_watermark != Some(current) {
4620            self.write_nostr_watermark_to_storage(current);
4621        }
4622    }
4623
4624    fn write_nostr_watermark_to_storage(&mut self, value: i64) {
4625        let Some(storage) = self.protocol_state_storage.clone() else {
4626            return;
4627        };
4628        let bytes = value.to_le_bytes();
4629        if let Err(e) = self.write_state_record(
4630            storage.as_ref(),
4631            storage_keys::NOSTR_WATERMARK,
4632            storage_keys::NOSTR_WATERMARK_ID,
4633            &bytes,
4634        ) {
4635            warn!(error = %e, "Failed to persist Nostr receive watermark");
4636            return;
4637        }
4638        self.last_persisted_nostr_watermark = Some(value);
4639    }
4640
4641    /// Restores the Nostr receive watermark from storage into the transport.
4642    ///
4643    /// Installs through the transport's own `advance_receive_watermark`, so the
4644    /// restore inherits both of its rules: it can only move the mark *forward*
4645    /// (an instance that already received an event this session keeps the newer
4646    /// value), and a record dated in the future — written by an older build
4647    /// without that check, or edited in the container — is ignored rather than
4648    /// allowed to stall every subsequent subscription.
4649    ///
4650    /// A missing record is the normal first-run case and is not an error: the
4651    /// transport falls back to its bounded first-run backfill window.
4652    pub(crate) fn restore_nostr_watermark(&mut self) {
4653        let Some(storage) = self.protocol_state_storage.clone() else {
4654            return;
4655        };
4656        if self
4657            .transport_manager
4658            .get_transport(TransportType::Nostr)
4659            .is_none()
4660        {
4661            // Nostr not enabled — nothing to install into.
4662            return;
4663        }
4664
4665        // A *missing* record is the ordinary first run; a read *error* is a
4666        // storage problem, and collapsing the two would hide it behind the
4667        // most common path in the function.
4668        let data = match self.read_state_record(
4669            storage.as_ref(),
4670            storage_keys::NOSTR_WATERMARK,
4671            storage_keys::NOSTR_WATERMARK_ID,
4672        ) {
4673            Ok(Some(data)) => data,
4674            Ok(None) => return,
4675            Err(e) => {
4676                warn!(
4677                    error = %e,
4678                    "Failed to read the Nostr receive watermark; \
4679                     this launch falls back to the first-run backfill window"
4680                );
4681                return;
4682            }
4683        };
4684
4685        let Ok(bytes) = <[u8; 8]>::try_from(data.as_slice()) else {
4686            warn!(
4687                len = data.len(),
4688                "Corrupted Nostr receive watermark in storage (expected 8 bytes), starting fresh"
4689            );
4690            return;
4691        };
4692        let restored = i64::from_le_bytes(bytes);
4693        self.with_nostr_transport(|nostr| nostr.advance_receive_watermark(restored));
4694        let live = self
4695            .with_nostr_transport(NostrTransport::receive_watermark_secs)
4696            .flatten();
4697
4698        // The debounce baseline is "what is on disk", but only when disk holds
4699        // a value we would ever have written. A record at or below the live
4700        // mark qualifies — whether it was just installed, or superseded by a
4701        // newer in-memory one. A record *above* the live mark is one the
4702        // transport refused as future-dated: adopting it as the baseline would
4703        // make every later `current - baseline` negative, so nothing would be
4704        // due and the bad record would survive until the next flush. Treat that
4705        // as no baseline, so the next accepted event overwrites it promptly.
4706        // One predicate, used for both the debounce baseline and the log, so
4707        // the two can never disagree about whether the record was adopted.
4708        let adopted = matches!(live, Some(live) if live >= restored);
4709        self.last_persisted_nostr_watermark = adopted.then_some(restored);
4710
4711        if adopted {
4712            debug!(
4713                watermark = restored,
4714                live, "Restored Nostr receive watermark from storage"
4715            );
4716        } else {
4717            // Say which outcome this is. A refused record installs nothing, so
4718            // logging "restored" here would mislead exactly when someone is
4719            // debugging the failure this path exists to contain — a
4720            // subscription stalled by a future-dated mark.
4721            warn!(
4722                watermark = restored,
4723                live,
4724                "Refused a future-dated Nostr receive watermark from storage; \
4725                 keeping the live mark and rewriting on the next accepted event"
4726            );
4727        }
4728    }
4729
4730    /// Installs the persisted Nostr state into a transport that was registered
4731    /// *after* [`Self::initialize_mls`] ran.
4732    ///
4733    /// Both restores below are no-ops when no Nostr transport is registered,
4734    /// which is exactly the state `initialize_mls` runs them in for the FFI:
4735    /// the Nostr transport is derived from this device's address, so the
4736    /// bindings layer cannot construct it until `initialize_mls` has produced
4737    /// one, and it installs the transport immediately afterwards. Without this
4738    /// second pass that transport keeps the ephemeral signing key it was
4739    /// constructed with — rotating this install's Nostr identity on every
4740    /// launch, so peers who sealed to the pubkey we advertised last time get
4741    /// frames we can no longer open — and starts with no receive watermark, so
4742    /// every cold start replays the full first-run backfill window.
4743    ///
4744    /// Safe to call whether or not the first pass found a transport: the
4745    /// signing-secret restore is guarded by `nostr_secret_persisted`, which a
4746    /// transport-less pass leaves unset, and the watermark only ever moves
4747    /// forward.
4748    pub fn restore_nostr_transport_state(&mut self) {
4749        self.restore_or_init_nostr_signing_secret();
4750        self.restore_nostr_watermark();
4751    }
4752
4753    /// Installs `secret` as the telemetry fallback secret and rebuilds the
4754    /// pre-install scrubber so the legacy MLS observability path also hashes
4755    /// with the stable secret. Does not touch an already-installed
4756    /// [`crate::telemetry::TelemetryContext`] (rebuilding a live context would
4757    /// rotate opaque ids mid-run); apps that need stable ids should provide
4758    /// storage before installing a telemetry sink.
4759    fn adopt_fallback_secret(&mut self, secret: [u8; 16]) {
4760        self.telemetry_fallback_secret = secret;
4761        self.telemetry_scrubber = crate::telemetry::Scrubber::from_config(
4762            &crate::telemetry::TelemetryConfig::default(),
4763            secret,
4764        );
4765    }
4766
4767    /// Returns a stable, opaque per-install telemetry identifier, or `None`
4768    /// until the persistent scrub secret is available.
4769    ///
4770    /// The id is `SHA-256(secret || domain)` truncated to a 32-character hex
4771    /// string, where `secret` is the per-install scrub secret managed by
4772    /// `Self::restore_or_init_scrub_secret`. The secret cannot be recovered
4773    /// from the id, and the fixed domain string keeps the id un-correlatable
4774    /// with opaque identifiers the scrubber produces for telemetry records:
4775    /// the domain contains `:`, which id validation
4776    /// (`offline_protocol_core::types::validate_id_chars`) rejects in every
4777    /// `UserId`/`AppId`, so no validated identifier reaching the scrubber can
4778    /// ever equal the domain and collide with the install id.
4779    ///
4780    /// Returns `None` while the SDK is still on the random per-instance
4781    /// fallback secret — i.e. before secure storage is provided via
4782    /// [`super::OfflineProtocol::initialize_mls`], or when persistence failed
4783    /// this session. In that state the id would not be stable across launches,
4784    /// so none is exposed.
4785    ///
4786    /// Deliberately derived from the persistent fallback secret, not from an
4787    /// installed [`crate::telemetry::TelemetryConfig::with_scrub_secret`]
4788    /// override: the install id must not rotate when a sink is (re)installed,
4789    /// and must not be computable from an app-chosen secret.
4790    ///
4791    /// The domain string is part of the public contract: changing it would
4792    /// silently rotate every device's install id. Frozen — do not edit.
4793    pub fn telemetry_install_id(&self) -> Option<String> {
4794        const TELEMETRY_INSTALL_ID_DOMAIN: &str = "telemetry:install-id";
4795        self.telemetry_secret_persisted.then(|| {
4796            crate::telemetry::scrubber::opaque_id(
4797                TELEMETRY_INSTALL_ID_DOMAIN,
4798                &self.telemetry_fallback_secret,
4799            )
4800        })
4801    }
4802
4803    // ========================================================================
4804    // LAMPORT CLOCK PERSISTENCE
4805    // ========================================================================
4806
4807    /// Debounced Lamport clock persistence. Only writes to storage when the
4808    /// in-memory value has advanced past `last_persisted_lamport` by at least
4809    /// `LAMPORT_PERSIST_INTERVAL` ticks. This avoids a Keychain/Keystore
4810    /// write on every sent and received message.
4811    pub(crate) fn persist_lamport_clock(&mut self) {
4812        let current = self.lamport_clock.value();
4813        if current.wrapping_sub(self.last_persisted_lamport) < super::LAMPORT_PERSIST_INTERVAL {
4814            return;
4815        }
4816        self.write_lamport_clock_to_storage(current);
4817    }
4818
4819    /// Forces the Lamport clock to storage regardless of debounce state.
4820    /// Called on shutdown to avoid losing any un-flushed ticks.
4821    pub(crate) fn flush_lamport_clock(&mut self) {
4822        self.write_lamport_clock_to_storage(self.lamport_clock.value());
4823    }
4824
4825    fn write_lamport_clock_to_storage(&mut self, value: u64) {
4826        let Some(storage) = self.protocol_state_storage.clone() else {
4827            return;
4828        };
4829        let bytes = value.to_le_bytes();
4830        if let Err(e) = self.write_state_record(
4831            storage.as_ref(),
4832            storage_keys::LAMPORT_CLOCK,
4833            storage_keys::LAMPORT_CLOCK_ID,
4834            &bytes,
4835        ) {
4836            warn!(error = %e, "Failed to persist Lamport clock");
4837            return;
4838        }
4839        self.last_persisted_lamport = value;
4840    }
4841
4842    /// Restores the Lamport clock from storage.
4843    ///
4844    /// Uses `max(current, restored)` so the clock never goes backward even
4845    /// if the in-memory value has advanced before storage was attached.
4846    pub(crate) fn restore_lamport_clock(&mut self) {
4847        let Some(storage) = self.protocol_state_storage.clone() else {
4848            return;
4849        };
4850        if let Ok(Some(data)) = self.read_state_record(
4851            storage.as_ref(),
4852            storage_keys::LAMPORT_CLOCK,
4853            storage_keys::LAMPORT_CLOCK_ID,
4854        ) {
4855            if data.len() == 8 {
4856                let restored = u64::from_le_bytes(data.try_into().expect("verified length is 8"));
4857                let restored_clock = LamportClock::from_value(restored);
4858                if restored_clock > self.lamport_clock {
4859                    self.lamport_clock = restored_clock;
4860                }
4861                self.last_persisted_lamport = self.lamport_clock.value();
4862                debug!(clock = %self.lamport_clock, "Restored Lamport clock from storage");
4863            } else {
4864                warn!(
4865                    len = data.len(),
4866                    "Corrupted Lamport clock in storage (expected 8 bytes), starting fresh"
4867                );
4868            }
4869        }
4870    }
4871}
4872
4873#[cfg(test)]
4874mod category_registration_tests {
4875    use super::*;
4876
4877    /// Every category must round-trip through its key type.
4878    ///
4879    /// The gap this closes: `requires_sealing` is exhaustive, so a new
4880    /// variant cannot skip the sealing decision, but `from_key_type` matches
4881    /// on strings and happily compiles with an arm missing. A category in
4882    /// that state fails only at runtime, on the first write, with "unknown
4883    /// protocol-state category" — and only in whatever test happens to
4884    /// exercise that particular write path. Here it fails in `cargo test`.
4885    #[test]
4886    fn every_category_maps_back_from_its_key_type() {
4887        for category in StateCategory::ALL {
4888            let key_type = category.key_type();
4889            assert_eq!(
4890                StateCategory::from_key_type(key_type),
4891                Some(*category),
4892                "{category:?} has no from_key_type arm for {key_type:?}"
4893            );
4894        }
4895    }
4896
4897    /// Key types are the on-disk names of directories in every shipped
4898    /// provider, so a duplicate would silently merge two categories.
4899    #[test]
4900    fn key_types_are_unique_across_categories() {
4901        let mut seen = std::collections::HashSet::new();
4902        for category in StateCategory::ALL {
4903            assert!(
4904                seen.insert(category.key_type()),
4905                "{:?} reuses key type {:?}",
4906                category,
4907                category.key_type()
4908            );
4909        }
4910    }
4911
4912    /// Documents carry user content, so all three data categories are sealed.
4913    /// Stated as a test because the failure it prevents (plaintext documents
4914    /// at rest) is silent: nothing observable changes when sealing is lost.
4915    #[test]
4916    fn data_categories_are_sealed_at_rest() {
4917        for category in [
4918            StateCategory::DataDocs,
4919            StateCategory::DataDeltaLog,
4920            StateCategory::DataSpaces,
4921        ] {
4922            assert!(
4923                category.requires_sealing(),
4924                "{category:?} must be sealed: it carries document content"
4925            );
4926        }
4927    }
4928
4929    /// The data categories are post-split: there is no pre-split data for
4930    /// them to inherit, and listing them would only cost a pointless
4931    /// enumeration of the credential store on every launch.
4932    #[test]
4933    fn data_categories_are_not_adoptable() {
4934        for key_type in [
4935            storage_keys::DATA_DOCS,
4936            storage_keys::DATA_DELTA_LOG,
4937            storage_keys::DATA_SPACES,
4938            storage_keys::DATA_SYNC,
4939        ] {
4940            assert!(
4941                !storage_keys::ADOPTABLE_STATE_KEY_TYPES.contains(&key_type),
4942                "{key_type} must not be adoptable"
4943            );
4944        }
4945    }
4946}