Skip to main content

offline_protocol/protocol/
storage.rs

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