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