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