Skip to main content

offline_protocol/protocol/
storage.rs

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