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