Skip to main content

offline_protocol/protocol/
mod.rs

1//! Main protocol engine.
2
3mod blocking;
4mod config_accessors;
5#[cfg(feature = "data")]
6pub(crate) mod data;
7#[cfg(feature = "data")]
8mod data_sync;
9mod decryption_queue;
10pub(crate) mod mesh_relay;
11mod message_dispatch;
12mod nostr_discovery;
13mod nostr_publication;
14mod observability;
15mod pending_queue;
16mod prefixes;
17pub(crate) mod reachability;
18mod receive;
19mod security;
20mod send;
21mod session;
22pub(crate) mod state_crypto;
23mod storage;
24mod types;
25
26pub(crate) use decryption_queue::PendingDecryptionQueue;
27pub use decryption_queue::PendingQueueMetrics;
28pub(crate) use prefixes::*;
29pub(crate) use storage::{PruneAllowance, RestorableRecord};
30pub(crate) use types::*;
31pub use types::{MediaSendOptions, ProtocolState, SendMessageOptions};
32
33use crate::file_transfer::{FileTransferManager, OutboundTransferState};
34use crate::mls_observability::{MlsEventEmitter, MlsEventRateLimiter, NoopMlsEventEmitter};
35use crate::telemetry::aggregator::{
36    build_metrics_frame, device_battery_from_available, diff_device_capability,
37    diff_transport_state, DeviceSnap,
38};
39use crate::telemetry::{
40    dispatch_record, Scrubber, TelemetryConfig, TelemetryContext, TelemetryRecord, TelemetrySink,
41};
42use crate::{
43    Error, EstablishmentState, Event, ProtocolConfig, ProtocolStateStorage, Result,
44    TransportManager,
45};
46use chrono::{DateTime, Utc};
47use mesh_relay::{MeshRelayGovernor, RelayActivity};
48use offline_protocol_core::{LamportClock, Message, MessageId, MutexExt, Username};
49use offline_protocol_mls::{EncryptedMessage, MlsManager, MlsStorage, WelcomeMessage};
50use offline_protocol_reliability::{AckManager, Deduplicator, RetryQueue};
51use offline_protocol_router::{RelayPriority, RelayRole, TransportSelector};
52use offline_protocol_services::MeshServices;
53use offline_protocol_transport::{BleTransport, TransportStatus, TransportType};
54use std::collections::HashMap;
55use std::collections::HashSet;
56use std::sync::{Arc, Mutex, RwLock};
57use std::time::Instant;
58use tracing::{debug, error, info, warn};
59use zeroize::Zeroizing;
60
61/// Returns whether `timestamp` is at least `lifetime_ms` old without relying
62/// on Chrono's panicking `DateTime - Duration` operator.
63///
64/// A lifetime too large to represent, or whose cutoff predates Chrono's
65/// calendar range, cannot expire a representable timestamp.
66fn lifetime_expired(now: DateTime<Utc>, timestamp: DateTime<Utc>, lifetime_ms: u64) -> bool {
67    let Ok(lifetime_ms) = i64::try_from(lifetime_ms) else {
68        return false;
69    };
70    now.checked_sub_signed(chrono::Duration::milliseconds(lifetime_ms))
71        .is_some_and(|cutoff| timestamp <= cutoff)
72}
73
74/// Main entry point for the Offline Protocol SDK.
75///
76/// This struct combines all protocol components and provides a unified API
77/// for sending/receiving messages with automatic transport selection and
78/// reliable delivery.
79pub struct OfflineProtocol {
80    /// Configuration.
81    pub(crate) config: ProtocolConfig,
82
83    /// This device's identity on the wire: the address derived from the
84    /// identity key in this profile's storage.
85    ///
86    /// Every outbound frame is stamped with it, every self-filter compares
87    /// against it, and it is one half of every 1:1 session slot. It is set
88    /// once, by `initialize_mls`, from the key that storage holds — the
89    /// protocol cannot know it before then, because storage is what carries
90    /// the key and storage does not arrive until that call.
91    ///
92    /// Until then it holds [`ProtocolConfig::profile`], which is *not* an
93    /// address and is never a peer's real identity. That fallback keeps the
94    /// pre-MLS surface (transport wiring, local bookkeeping) working on an
95    /// instance that has not been initialized.
96    ///
97    /// One configuration can put the fallback on the wire: the explicit
98    /// `require_encryption = false` opt-out lets a send go out in plaintext
99    /// with MLS never initialized, and such a frame is stamped with the
100    /// profile. It is the same fall-through that already emits
101    /// [`crate::events::SecurityWarningCode::PlaintextSend`], so the condition
102    /// is reported rather than silent. The default config forbids it —
103    /// `require_encryption` is `true`, which fails the send closed.
104    pub(crate) local_id: String,
105
106    /// Whether [`Self::local_id`] is the derived address rather than the
107    /// profile fallback.
108    pub(crate) identity_established: bool,
109
110    /// Transport manager (manages all transports with DORS).
111    pub(crate) transport_manager: TransportManager,
112
113    /// ACK manager for tracking acknowledgments.
114    ack_manager: AckManager,
115
116    /// Retry queue for failed messages.
117    retry_queue: RetryQueue,
118
119    /// Deduplicator for preventing duplicates.
120    pub(crate) deduplicator: Deduplicator,
121
122    /// Decides which frames this device carries for other people, when they
123    /// go out, and how fast. Kept separate from [`Self::deduplicator`], whose
124    /// entries track messages addressed to us and are removed again when
125    /// delivery has to be retried.
126    pub(crate) mesh_relay: MeshRelayGovernor,
127
128    /// Shared mutable state.
129    shared_state: Arc<Mutex<SharedState>>,
130
131    /// Messages awaiting delivery/acknowledgment (store-and-forward outbox).
132    outbox: HashMap<MessageId, OutboxEntry>,
133
134    /// Transient staging for outbox re-seal provenance, keyed by message id.
135    /// Populated at send time (where the plaintext is in scope) and consumed
136    /// when the outbox entry is first created (which happens deep in the send/
137    /// retry machinery, from the already-sealed `Message`). Once consumed it
138    /// lives on the `OutboxEntry` itself. See [`OutboxReseal`].
139    pending_reseal: HashMap<MessageId, OutboxReseal>,
140
141    /// Dedicated outbox for file chunk messages, separate from the main outbox
142    /// to prevent large file transfers from evicting regular messages.
143    media_outbox: HashMap<MessageId, OutboxEntry>,
144
145    /// Consecutive unreachable-park counter per recipient for plain DMs,
146    /// driving the escalating reachability-probe interval on every carrier
147    /// (see `handle_recipient_unreachable_for_message` —
148    /// mirrors `WelcomeLifecycleRecord::unreachable_parks`). Reset on every
149    /// reachability edge (`flush_outbox_for_peer_via` / `flush_outbox_all`) and
150    /// on delivery; pruned alongside `cleanup_outbox` so only peers that
151    /// still hold outbox entries can retain a counter. While live, ACK
152    /// exhaustion for the recipient's plain DMs re-parks instead of
153    /// settling terminally (`try_repark_exhausted_dm`). In-memory only: a
154    /// restart re-drives the outbox anyway, which is itself a fresh probe.
155    dm_unreachable_parks: HashMap<String, u32>,
156
157    /// Per-recipient reachability claims, keyed by recipient and carrier.
158    ///
159    /// The generalisation of the counter above: that one records *how often*
160    /// a recipient has been parked, this one records *what a carrier said*
161    /// about reaching them, so a decision can be made before the send rather
162    /// than only after it failed. Written by the same producers (a gateway
163    /// verdict, a gateway presence answer), read at the send and carrying
164    /// seams. In-memory only, and absent facts mean today's behaviour.
165    pub(crate) reachability: reachability::ReachabilityFacts,
166
167    /// MLS manager for end-to-end encryption.
168    mls_manager: Option<Arc<RwLock<MlsManager>>>,
169
170    /// Pending messages waiting for session establishment (recipient -> messages).
171    pending_encrypted_messages: HashMap<String, Vec<PendingMessage>>,
172
173    /// Terminal message settlements produced while restoring, held until the
174    /// event pipeline is live.
175    ///
176    /// Restore runs inside `initialize_mls`, which apps routinely call before
177    /// installing an event callback — so a `message_failed` emitted there would
178    /// be dropped, and the app would keep an id that never resolves. `start()`
179    /// drains this, mirroring how restored media descriptors already wait to be
180    /// announced.
181    ///
182    /// Explicitly capped at [`MAX_DEFERRED_RESTORE_SETTLEMENTS`] rather than
183    /// left to the restore caps that feed it. Those caps do bound it, but they
184    /// bound it as a *sum* across every category, and nothing drains this until
185    /// `start()` — which an app that only ever calls `initialize_mls`, or that
186    /// retries it against a failing store, may never reach.
187    deferred_restore_settlements: Vec<Event>,
188
189    /// Number of settlements suppressed by the cap above, so the count is
190    /// reported even though the individual events are not.
191    suppressed_restore_settlements: usize,
192
193    /// Earliest wall-clock expiry in `pending_encrypted_messages`.
194    ///
195    /// `process()` consults this before scanning the bounded queue, avoiding an
196    /// O(N) walk on every 100 ms tick while preserving exact configured expiry.
197    /// A stale early deadline is harmless: it causes one extra scan, which then
198    /// recomputes the real minimum.
199    next_pending_message_expiry: Option<DateTime<Utc>>,
200
201    /// Key packages received but not yet used (sender_id -> package).
202    pub(crate) pending_key_packages: HashMap<String, ReceivedKeyPackage>,
203
204    /// Set of peers we've already sent our key package to.
205    pub(crate) key_package_sent_to: std::collections::HashSet<String>,
206
207    /// All discovered/connected peers, tracked independently of encryption.
208    /// Used by service discovery to know who to broadcast queries to.
209    /// Values are last-seen instants: refreshed on every discovery signal,
210    /// swept by [`Self::prune_stale_known_peers`] after `KNOWN_PEER_TTL_SECS`,
211    /// and used to pick the least-recently-seen victim when an insert hits
212    /// `MAX_KNOWN_PEERS`.
213    known_peers: HashMap<String, Instant>,
214
215    /// Sessions confirmed established (received Welcome or successful decrypt).
216    /// Only encrypt messages when the session is confirmed to avoid race conditions.
217    confirmed_sessions: std::collections::HashSet<String>,
218
219    /// Peers that have demonstrated they run MLS, used by
220    /// [`Self::accept_plaintext_content`] to reject cleartext claiming to come
221    /// from an encryption-capable peer.
222    ///
223    /// # Why this is not `confirmed_sessions`, and not a storage read
224    ///
225    /// The gate used to ask `is_session_confirmed`, which reads the
226    /// `session_states` protocol-state record. That record lives in the
227    /// install-scoped app container, where an attacker with write access can
228    /// simply **delete** it: restore then treats absence as `Pending` (it must
229    /// — see `bootstrap_missing_session_state`), the peer drops out of
230    /// `confirmed_sessions`, and the gate opens for unauthenticated cleartext
231    /// carrying an attacker-chosen `sender`. Sealing the record does not help;
232    /// a seal authenticates bytes that are present, and this attack removes
233    /// them. So the signal has to come from somewhere the app container cannot
234    /// reach.
235    ///
236    /// Both sources here are credential-store-resident: the MLS session list
237    /// and the durable capability records
238    /// ([`storage_keys::ENCRYPTION_CAPABLE_PEERS`]). Neither is sufficient
239    /// alone. A session can exist for a peer that has never signed a control
240    /// frame we verified (it is established from key packages, and the Welcome
241    /// path marks capability without a signature), and a record can outlive the
242    /// session it was learned alongside. The union is what actually tracks
243    /// "this peer speaks MLS".
244    ///
245    /// # Monotone within a run — deliberately, and this is load-bearing
246    ///
247    /// Entries are added and **never removed on session teardown**. That looks
248    /// like a leak and is in fact the whole security property. Teardown is
249    /// reachable from an *unauthenticated* frame: an injected `__MLS_ENC__`
250    /// classifies as `SessionDesync`, `schedule_session_rekey` tears down our
251    /// session and advertises a `session_reset` key package, and the peer's
252    /// `handle_key_package_message` then deletes its own. A set that shrank on
253    /// teardown would therefore let one injected frame re-open the very gate
254    /// this field exists to hold shut — moving the attack from "needs
255    /// app-container write access" to "needs one forged packet", which is
256    /// strictly worse than the bug it fixes.
257    ///
258    /// Keeping a peer after teardown costs nothing real: a peer that spoke MLS
259    /// a moment ago is still encryption-capable, so its cleartext is still a
260    /// downgrade. The invariant to preserve is one-directional — this set may
261    /// **over**-approximate (which fails closed) but must never
262    /// **under**-approximate (which fails open). Anything that populates it may
263    /// be lossy; nothing may prune it.
264    ///
265    /// There is no longer any way out at all, and that is now *correct* rather
266    /// than merely safe. The old escape hatch (`reset_tofu_for_peer`) meant
267    /// "this identity has re-keyed, treat it as new" — a thing that could
268    /// happen when an identity was a name. Under self-certifying addressing an
269    /// address is the hash of its key, so a peer that re-keys becomes a
270    /// *different address*: nothing about the old one ever stops being true,
271    /// and forgetting it could only ever be wrong.
272    ///
273    /// Bounded by [`MAX_ENCRYPTION_CAPABLE_PEERS`], enforced as a refusal rather
274    /// than an eviction so the monotonicity above survives the cap — see
275    /// [`Self::mark_encryption_capable`].
276    encryption_capable_peers: std::collections::HashSet<String>,
277
278    /// Peers whose key package advertised the compact MLS envelope
279    /// ([`MLS_ENVELOPE_COMPACT_V1`] in `env_versions`), so
280    /// `seal_encrypted_content` may emit it instead of the legacy JSON form.
281    /// Learned from key-package exchange, persisted per peer as
282    /// [`PeerCapabilities`], and repopulated by `restore_peer_capabilities`
283    /// on `initialize_mls` — unlike the transport manager's binary-wire
284    /// registry, which stays in-memory because direct connections re-exchange
285    /// key packages anyway. Bounded like `key_package_sent_to` (it is keyed
286    /// by the wire-claimed sender).
287    peer_compact_envelope: std::collections::HashSet<String>,
288
289    /// Peers whose key package advertised the sealed rich payload
290    /// ([`RICH_PAYLOAD_V1`] in `rich_versions`), so the send path may seal
291    /// rich extras (reply context, rich media metadata, forward attribution)
292    /// inside the `__RICH_V1__` body. Same lifecycle as
293    /// `peer_compact_envelope` above: learned from key-package exchange,
294    /// persisted, restored on `initialize_mls`, bounded like
295    /// `key_package_sent_to`. Forgetting a peer only costs silently dropped
296    /// rich extras — never a cleartext fallback.
297    peer_rich_payload: std::collections::HashSet<String>,
298
299    /// Peers whose key package advertised replicated-document sync
300    /// ([`DATA_SYNC_V1`] in `data_versions`), so the send path may push
301    /// `__DATA_V1__` frames to them. Same lifecycle as the two capability
302    /// sets above: learned from key-package exchange, persisted, restored on
303    /// `initialize_mls`, bounded like `key_package_sent_to`.
304    ///
305    /// Forgetting a peer costs replication with them until the next live
306    /// exchange, which is worse than it sounds: documents stay editable on
307    /// both sides and diverge quietly, so the symptom is not an error but
308    /// two people looking at different text. It is why this set is restored
309    /// rather than relearned.
310    ///
311    /// [`DATA_SYNC_V1`]: crate::protocol::types::DATA_SYNC_V1
312    peer_data_sync: std::collections::HashSet<String>,
313
314    /// Peers whose sealed-rich-payload support we learned *indirectly*: a
315    /// group inviter attested it on the Add commit (to existing members) or
316    /// the Welcome (to the joining member), because the members of a group
317    /// never directly exchange key packages with everyone else. Kept
318    /// separate from `peer_rich_payload` (direct self-advertisement) so
319    /// direct knowledge can stay authoritative: any directly received key
320    /// package from a peer evicts its attested entry. Consulted only by
321    /// `group_rich_seal_active` — never DM sealing (which always has direct
322    /// knowledge: session establishment exchanges key packages) and never
323    /// envelope selection (a stale attestation there could corrupt
324    /// decoding; here the worst case is one member rendering a literal
325    /// `__RICH_V1__` body). Persisted inside `PeerCapabilities`, restored
326    /// on `initialize_mls`, bounded like `key_package_sent_to`.
327    peer_rich_attested: std::collections::HashSet<String>,
328
329    /// Peers whose key package advertised *group* document replication
330    /// ([`DATA_GROUP_V1`] in `data_versions`), so a group they share with
331    /// this device may carry `__DATA_V1__` frames.
332    ///
333    /// Separate from `peer_data_sync` because the two entries mean
334    /// different things and a peer can advertise the first without the
335    /// second: an install predating group spaces replicates 1:1 perfectly
336    /// well and would render a group sync frame as chat text. Same
337    /// lifecycle as every capability set: learned from key-package
338    /// exchange, persisted, restored on `initialize_mls`, bounded like
339    /// `key_package_sent_to`.
340    ///
341    /// [`DATA_GROUP_V1`]: crate::protocol::types::DATA_GROUP_V1
342    peer_data_group: std::collections::HashSet<String>,
343
344    /// Peers whose *group* replication support we learned indirectly, from
345    /// an inviter's attestation on the Add commit or Welcome.
346    ///
347    /// The group analogue of `peer_rich_attested`, and load-bearing for the
348    /// same structural reason: members of a group never exchange key
349    /// packages with each other, so without attestation a group would
350    /// replicate only among whichever members happened to have met
351    /// directly. Direct knowledge stays authoritative — a key package from
352    /// the peer evicts its attested entry. Consulted only by
353    /// `group_data_sync_active`, never by 1:1 sync.
354    peer_data_group_attested: std::collections::HashSet<String>,
355
356    /// Peers whose key package advertised attachment and snapshot carriage
357    /// over the media path ([`DATA_MEDIA_V1`] in `data_versions`), so this
358    /// device may ask them for a blob and may hand them a document too large
359    /// to travel inside a sync frame.
360    ///
361    /// A third set rather than a flag on the first, for the same reason
362    /// `peer_data_group` is separate: a peer can advertise 1:1 replication
363    /// without this, and toward such a peer a data-purposed media transfer
364    /// arrives as an ordinary received file. Same lifecycle as every
365    /// capability set: learned from key-package exchange, persisted inside
366    /// `PeerCapabilities`, restored on `initialize_mls`, bounded like
367    /// `key_package_sent_to`.
368    ///
369    /// [`DATA_MEDIA_V1`]: crate::protocol::types::DATA_MEDIA_V1
370    peer_data_media: std::collections::HashSet<String>,
371
372    /// Peers already flagged with a `PlaintextSend` security warning, so the
373    /// explicit-opt-out plaintext path warns once per peer instead of once
374    /// per message.
375    pub(crate) plaintext_send_warned: std::collections::HashSet<String>,
376
377    /// Peers already flagged with a `PlaintextReceiveRejected` security
378    /// warning, so a chatty legacy or malicious peer warns once per peer
379    /// instead of once per rejected message. Keys are wire-claimed
380    /// (attacker-controllable) sender ids, so the set is bounded: it resets
381    /// at `MAX_PLAINTEXT_RECEIVE_WARNED_PEERS` instead of growing without
382    /// limit.
383    pub(crate) plaintext_receive_warned: std::collections::HashSet<String>,
384
385    /// Peers whose durable `encryption_capable_peers` record is known to be on
386    /// disk — either restored from it at boot or written successfully this run.
387    ///
388    /// Purely a write-elision cache for
389    /// [`OfflineProtocol::record_encryption_capable`]: a verified control frame
390    /// arrives on chatty prefixes (`__TYPING__`, `__PRESENCE__`), and the record
391    /// it would rewrite carries no field any decision reads. Tracking *landed
392    /// writes* rather than set membership is what keeps a best-effort storage
393    /// failure retryable on the peer's next frame instead of stranded for the
394    /// process.
395    ///
396    /// A subset of [`Self::encryption_capable_peers`] by construction, so
397    /// `MAX_ENCRYPTION_CAPABLE_PEERS` bounds it too.
398    pub(crate) encryption_capable_persisted: HashSet<String>,
399
400    /// Which control-gate rejection codes have already been reported per peer,
401    /// as a bitmask (see `OfflineProtocol::control_gate_warning_bit`), so a
402    /// peer whose control traffic keeps being refused warns once per code
403    /// instead of once per frame.
404    ///
405    /// Keys are wire-claimed (attacker-controllable) sender ids, so the map is
406    /// bounded: it resets at `MAX_CONTROL_GATE_WARNED_PEERS` instead of growing
407    /// without limit.
408    pub(crate) control_gate_warned: HashMap<String, u8>,
409
410    /// Bounded pending decryption queue for encrypted messages received before
411    /// the MLS session is ready.
412    pub(crate) pending_queue: PendingDecryptionQueue,
413
414    /// Secure storage for MLS/key material and SDK-owned secrets.
415    secure_storage: Option<Arc<dyn MlsStorage>>,
416
417    /// Install-scoped storage for restartable message-plane and protocol state.
418    protocol_state_storage: Option<Arc<dyn ProtocolStateStorage>>,
419
420    /// Seals sensitive protocol-state records before they reach the
421    /// install-scoped store, with a per-install key held in `secure_storage`.
422    ///
423    /// `None` until that key is available (and if it never becomes available,
424    /// for the whole session). Sealed categories then fail *closed*: they are
425    /// not persisted at all rather than written in the clear, since losing
426    /// crash recovery is recoverable and losing at-rest confidentiality is not.
427    /// See `state_crypto` and `restore_or_init_state_record_key`.
428    ///
429    /// Always belongs to the currently attached `secure_storage` — it is
430    /// re-derived, never carried across a storage swap.
431    state_record_cipher: Option<state_crypto::StateRecordCipher>,
432
433    /// Open replicated documents and their per-space indexes.
434    #[cfg(feature = "data")]
435    pub(crate) data: data::DataLayer,
436
437    /// When each peer was last offered our document versions.
438    ///
439    /// Neighbour rediscovery is not a rare event: a peer walking in and out
440    /// of Bluetooth range fires it repeatedly, and an offer per discovery
441    /// would put a frame on the mesh every time. The offer is not urgent
442    /// (anything committed locally is pushed as it happens) and it is not
443    /// load-bearing (the next trigger repeats it), so it is exactly the kind
444    /// of traffic that should be rate limited rather than merely small.
445    #[cfg(feature = "data")]
446    pub(crate) last_data_sync_offer: HashMap<String, Instant>,
447
448    /// Attachment fetches asked for and not yet answered, keyed by space and
449    /// blob hash, valued by the last sign that an answer is coming: the
450    /// moment the question went out, or the arrival of a chunk of its
451    /// answer.
452    ///
453    /// The refresh is what keeps this record's two jobs consistent. It bounds
454    /// how long a question is remembered, and it is also what admits the
455    /// bytes when they finally land, so a record that expired while its own
456    /// answer was arriving would discard a blob that fully crossed.
457    ///
458    /// Load-bearing for more than bookkeeping: arriving blob bytes are
459    /// admitted only against an entry here, checked when the transfer's
460    /// chunk 0 identifies it and again before the bytes reach the
461    /// application. Without it a peer could push blobs this device never
462    /// asked for and the application would be handed files nobody wanted.
463    ///
464    /// The bound it gives is on delivery and on the bulk of the buffering,
465    /// not on every byte: chunks need not arrive in order, so any that land
466    /// before chunk 0 are buffered before there is anything to judge, and
467    /// are released when it arrives. What remains is bounded by the media
468    /// layer's own resource caps, the same ones every unsolicited file
469    /// transfer has always been subject to.
470    pub(crate) pending_attachment_fetches: HashMap<String, Instant>,
471
472    /// Blob requests recently acted on, keyed by asking peer and blob hash.
473    ///
474    /// Its own map rather than a corner of `last_data_sync_offer`, because
475    /// this is the only rate-limit key a peer chooses freely: every other one
476    /// is a peer or a peer-and-group, bounded by a roster. Bounded here by
477    /// [`MAX_BLOB_REQUESTS_PER_WINDOW`] per peer and
478    /// [`MAX_BLOB_REQUEST_WINDOWS`] overall, and swept on every read.
479    ///
480    /// [`MAX_BLOB_REQUESTS_PER_WINDOW`]: crate::protocol::data_sync::MAX_BLOB_REQUESTS_PER_WINDOW
481    /// [`MAX_BLOB_REQUEST_WINDOWS`]: crate::protocol::data_sync::MAX_BLOB_REQUEST_WINDOWS
482    pub(crate) blob_request_windows: HashMap<String, Instant>,
483
484    /// Whether the MLS group list has been read into `group_mesh.members`
485    /// once this session, so the shared-group rediscovery sweep can see the
486    /// groups this device is in.
487    ///
488    /// That cache is filled on demand by group traffic and is empty after a
489    /// restart, so a sweep that only read it would find nothing on exactly
490    /// the launch where two members have drifted apart and neither is
491    /// editing. Enumerating is one key listing plus, per group not already
492    /// cached, the group-state read the next group send would have paid
493    /// anyway.
494    ///
495    /// One shot rather than per sweep because the cache stays complete once
496    /// filled: creating a group inserts, a Welcome inserts, and leaving
497    /// removes. Deliberately not a cold-start sweep, which was rejected for
498    /// putting an offer per member per group on the mesh at every launch;
499    /// this is per peer, only when that peer is actually in front of us, and
500    /// still behind the offer window.
501    #[cfg(feature = "data")]
502    pub(crate) group_spaces_enumerated: bool,
503
504    /// Lamport logical clock for causal message ordering.
505    /// Ticked on send, merged on receive.
506    lamport_clock: LamportClock,
507
508    /// Retry schedule for peers whose confirmation persistence failed.
509    confirmation_retry_due_at: HashMap<String, DateTime<Utc>>,
510
511    /// Probe schedule for pending sessions to guarantee post-restart convergence.
512    confirmation_probe_due_at: HashMap<String, DateTime<Utc>>,
513
514    /// Rate-limit schedule for 1:1 session re-keys triggered by an epoch-desync
515    /// decrypt failure. Bounds the re-key to at most one per peer per
516    /// `REKEY_INTERVAL_SECS` so a peer replaying stale-epoch ciphertext (or an
517    /// injected wrong-epoch frame) cannot drive a re-key storm. The floor is
518    /// **never reset early** — not even by a successful decrypt on the healed
519    /// session — because a genuine re-fork and a replay are indistinguishable
520    /// here; it lapses only by the interval elapsing (see
521    /// `schedule_session_rekey`). In-memory only: a re-key is a live-connectivity
522    /// action, and a fresh desync after restart simply re-arms it.
523    rekey_due_at: HashMap<String, DateTime<Utc>>,
524
525    /// Outbound welcome lifecycle records keyed by peer id.
526    welcome_lifecycles: HashMap<String, WelcomeLifecycleRecord>,
527
528    /// Outbound connection requests still awaiting a transport outcome,
529    /// keyed by message id → recipient. Lets `on_transport_send_failed`
530    /// turn the relay's authoritative "recipient offline" DeliveryError
531    /// into a typed `ConnectionRequestUndeliverable` event instead of
532    /// silently discarding it (the welcome-only path never matches a
533    /// connection request's message id). Entries are dropped on emission
534    /// and on proof of delivery (the request's delivery ack, or an inbound
535    /// accept/reject from the recipient), and TTL-pruned on insert and at
536    /// read; bounded, so an app spamming requests cannot grow it
537    /// unboundedly.
538    pending_connection_requests: HashMap<String, PendingConnectionRequest>,
539
540    /// Backoff state for presence-driven welcome rescue, keyed by peer id.
541    /// Bounds how often an online-but-never-confirming peer triggers a
542    /// welcome re-arm/re-send (see `on_peer_presence`). Entries are dropped
543    /// lazily once the peer has no unconfirmed welcome left.
544    welcome_presence_rescue: HashMap<String, PresenceRescueThrottle>,
545
546    /// Peers for which we are the both-create "owner" (kept our own session
547    /// group on the lexicographic tiebreak) and are still awaiting a
548    /// *group-aware* proof that the peer adopted our group. While a peer is in
549    /// this set, only `decrypt_success` may confirm the session — a plaintext
550    /// confirmation probe/ack is NOT group-aware (it only proves the peer holds
551    /// *some* session, possibly its own pre-adoption group) and must not be
552    /// allowed to stop our Welcome retransmission, or the peer could be left
553    /// stranded on a divergent group. Persisted (see
554    /// `restore_both_create_awaiting_decrypt`) so an owner restart mid-convergence
555    /// keeps the gate, rather than letting a stale plaintext probe/ack confirm
556    /// prematurely. Mutate only via `mark_both_create_awaiting_decrypt` /
557    /// `clear_both_create_awaiting_decrypt` to keep memory and storage in sync.
558    both_create_awaiting_decrypt: std::collections::HashSet<String>,
559
560    /// Sink for MLS lifecycle telemetry.
561    mls_event_emitter: Arc<dyn MlsEventEmitter>,
562
563    /// Rate limiting policy for MLS failure event floods.
564    mls_event_rate_limiter: MlsEventRateLimiter,
565
566    /// Pre-install scrubber used by MLS emit sites before
567    /// `install_telemetry_sink` is called. Once a sink is installed, emit
568    /// sites read `self.telemetry.scrubber` instead via `current_scrubber()`.
569    /// Both scrubbers share `telemetry_fallback_secret` so opaque identifiers
570    /// observed by the legacy `MlsEventEmitter` stay consistent across the
571    /// install boundary.
572    telemetry_scrubber: Scrubber,
573
574    /// Per-instance fallback secret for identifier scrubbing. Random at
575    /// construction. Reused by both the pre-install `telemetry_scrubber`
576    /// and by any scrubber built inside `install_telemetry_sink` (unless the
577    /// installed `TelemetryConfig` carries its own `scrub_secret`). Keeping
578    /// the fallback stable across installs means the legacy
579    /// `MlsEventEmitter` path observes consistent opaque IDs before and
580    /// after a sink is installed on the same protocol instance.
581    telemetry_fallback_secret: [u8; 16],
582
583    /// Whether the persistent per-install scrub secret has been loaded from
584    /// (or initialized into) secure storage yet. Starts `false`; set `true`
585    /// the first time `restore_or_init_scrub_secret` succeeds so the load is
586    /// idempotent across the two storage-entry paths
587    /// (`initialize_mls` also enables persistence, which would otherwise
588    /// re-load and rebuild the scrubber a second time).
589    telemetry_secret_persisted: bool,
590
591    /// Whether the persistent per-install Nostr signing secret has been
592    /// loaded from (or initialized into) secure storage and installed into
593    /// the Nostr transport yet. Same idempotency role across the two
594    /// storage-entry paths as `telemetry_secret_persisted`; see
595    /// `restore_or_init_nostr_signing_secret`.
596    nostr_secret_persisted: bool,
597
598    /// A Nostr signing secret that was installed into the transport but
599    /// could not be persisted (storage `store` failed). Kept so the next
600    /// `restore_or_init_nostr_signing_secret` attempt retries persisting
601    /// this same secret instead of rotating the install's relay-visible
602    /// identity again mid-session.
603    nostr_unpersisted_secret: Option<Zeroizing<[u8; 32]>>,
604
605    /// Installed telemetry context (sink + config + scrubber). `None` until
606    /// `install_telemetry_sink` is called; thereafter shared with
607    /// `SharedState` via `Arc` clone so both emit paths dispatch through the
608    /// same configuration.
609    ///
610    /// The duplication with [`SharedState::telemetry`] is deliberate: MLS
611    /// lifecycle emission is driven from `&self` on `OfflineProtocol` and
612    /// does not hold the shared-state lock, while protocol-event emission
613    /// happens inside `SharedState::emit_event` under the lock. Each path
614    /// reads the context from whichever side it already has in hand, and
615    /// `install_telemetry_sink` is the single writer that keeps both copies
616    /// in sync (guaranteed atomic from the caller's perspective because
617    /// `&mut self` excludes concurrent calls).
618    pub(crate) telemetry: Option<Arc<TelemetryContext>>,
619
620    /// File transfer manager for chunking outbound and reassembling inbound media.
621    file_transfer_manager: FileTransferManager,
622
623    /// Stashed (content_type, media_metadata) from chunk-0 of incoming file transfers,
624    /// used to populate the FileReceived event once all chunks arrive.
625    pending_media_metadata: HashMap<String, PendingMediaMetadataEntry>,
626    /// Tracks outbound media transfer delivery progress keyed by file_id.
627    outbound_media_transfers: HashMap<String, OutboundMediaTransfer>,
628    /// Maps each outbound media chunk message ID to (file_id, chunk_index).
629    outbound_media_chunks: HashMap<MessageId, (String, u32)>,
630    /// Sliding-window state for outbound file transfers, keyed by file_id.
631    /// Chunks are only sent when the window has capacity (previous chunks ACKed).
632    outbound_media_windows: HashMap<String, OutboundTransferState>,
633    /// Descriptors of transfers that were in flight when the previous process
634    /// died, restored from storage. `start()` emits one `MediaResendRequired`
635    /// event each but deliberately does NOT drain them: entries stay parked
636    /// (and persisted) until a same-`file_id` resend consumes them — after
637    /// `send_media_with` checksum-validates the re-supplied bytes against
638    /// the descriptor — or the restore TTL prunes them, so an app that
639    /// misses the signal gets it again next restart.
640    restored_media_descriptors: HashMap<String, MediaTransferDescriptor>,
641
642    /// Mesh service registry and handler (extracted crate).
643    mesh_services: MeshServices,
644
645    /// Bundled state for mesh group messaging (member cache, dedup, pending commits).
646    pub(crate) group_mesh: crate::group_mesh::GroupMeshState,
647
648    /// Set of blocked user IDs. Messages from blocked users are silently
649    /// dropped (no ACK, no event). Persisted via `ProtocolStateStorage`.
650    blocked_users: HashSet<String>,
651
652    /// Timestamp of the last `kick_pending_session_reconciliation` execution.
653    /// Used to throttle reconciliation to avoid expensive storage I/O
654    /// (list_sessions → Keychain/Keystore) on every process tick / receive poll.
655    last_reconciliation_at: Option<Instant>,
656
657    /// Instant of the last periodic `MetricsFrame` emission, used to rate
658    /// the telemetry aggregator to `TelemetryConfig::metrics_cadence`.
659    last_metrics_emit_at: Option<Instant>,
660
661    /// Last observed per-transport status map, used by the telemetry
662    /// aggregator to diff against the current map and emit one
663    /// `TransportStateEvent` per transition.
664    transport_status_snapshot: HashMap<TransportType, TransportStatus>,
665
666    /// Last observed (battery, charging, relay_role) snapshot, used by the
667    /// telemetry aggregator to fire `DeviceCapabilitySnapshot` on change.
668    device_capability_snapshot: Option<DeviceSnap>,
669
670    /// The Lamport clock value at the time of the last storage write.
671    /// Used to debounce `persist_lamport_clock()` — only write when the
672    /// in-memory value has advanced past this by `LAMPORT_PERSIST_INTERVAL`
673    /// ticks. On crash, at most `LAMPORT_PERSIST_INTERVAL` ticks are lost,
674    /// which is harmless for a logical clock (the gap is absorbed by the
675    /// next merge with any peer).
676    last_persisted_lamport: u64,
677
678    /// The Nostr receive watermark last written to storage, or `None` if this
679    /// session has neither written nor restored one. Debounces
680    /// `persist_nostr_watermark()` the same way `last_persisted_lamport`
681    /// debounces the clock.
682    ///
683    /// Deliberately *not* captured for `initialize_mls` rollback, matching
684    /// where the restore itself runs: the mark lives in the transport, which
685    /// the rollback cannot reach, and re-installing a durable value that only
686    /// ever moves forward is not something a failed MLS restore needs undone.
687    last_persisted_nostr_watermark: Option<i64>,
688
689    /// Which MLS key package stands in each published Nostr slot.
690    ///
691    /// Persisted, because the alternative to remembering it is republishing
692    /// under fresh slot ids on every launch — leaving the old records standing
693    /// with packages we would no longer track for consumption.
694    nostr_publication_slots: Vec<nostr_publication::NostrPublicationSlot>,
695
696    /// Slot ids published during *this* process.
697    ///
698    /// Deliberately not persisted: an addressable record lives on the relays,
699    /// so a launch cannot know which of them still hold it. Publishing each
700    /// slot once per process is the cheap way to be sure — it replaces rather
701    /// than accumulates, so the cost of being wrong is one redundant event.
702    nostr_published_slots: HashSet<String>,
703
704    /// When the publication slots were last re-scanned, throttling the MLS
705    /// storage reads that scan costs. See `nostr_slot_refresh_due`.
706    last_nostr_slot_refresh: Option<Instant>,
707
708    /// Per-slot republish backoff after publications that never reached a
709    /// relay. Memory-only, and pruned to the live slot ids each refresh.
710    nostr_publication_backoff: HashMap<String, nostr_publication::PublicationBackoff>,
711
712    /// When the slot-exhaustion security warning was last emitted, suppressing
713    /// repeats for a cause that by nature persists.
714    last_nostr_slot_warning: Option<Instant>,
715
716    /// The username this install has published a discovery claim for, as last
717    /// persisted. `None` means no claim stands.
718    ///
719    /// Persisted, because it is the only thing that knows *which* name to
720    /// retract when the profile changes or the feature is turned off. See
721    /// `storage_keys::NOSTR_DISCOVERY_CLAIM`.
722    nostr_discovery_claim: Option<Username>,
723
724    /// Names whose tombstone has not yet reached a relay, oldest first.
725    ///
726    /// Persisted beside the claim, and for a stronger reason than the claim
727    /// is: the claim can be rebuilt from `config.profile`, while a retracted
728    /// name has no other record anywhere once the profile has moved on.
729    /// Dropping it before the tombstone lands strands a human-readable name in
730    /// a public directory pointing at an address whose key packages are still
731    /// being published, so nothing downstream expires it.
732    nostr_discovery_retracting: Vec<Username>,
733
734    /// Whether the standing claim has been published during *this* process.
735    ///
736    /// Not persisted, for the same reason `nostr_published_slots` is not: an
737    /// addressable record lives on the relays and a launch cannot know which
738    /// of them still hold it. Republishing once per process replaces rather
739    /// than accumulates.
740    nostr_discovery_published: bool,
741
742    /// Republish backoff for the discovery claim, keyed by its tag.
743    ///
744    /// Memory-only and structurally identical to
745    /// `nostr_publication_backoff`: a relay that rejects kind 30777 outright
746    /// would otherwise be retried once per refresh forever.
747    nostr_discovery_backoff: HashMap<String, nostr_publication::PublicationBackoff>,
748
749    /// When the discovery claim was last re-examined, throttling the tick.
750    last_nostr_discovery_refresh: Option<Instant>,
751
752    /// In-flight username resolutions, keyed by query id.
753    ///
754    /// A resolution accumulates every verified claim for its username and
755    /// emits **one** event carrying the whole set. See
756    /// `nostr_discovery::PendingResolution` for why the set is the only thing
757    /// that is ever emitted.
758    nostr_resolutions: HashMap<String, nostr_discovery::PendingResolution>,
759
760    /// Username lookups requested but not yet minted into a relay query, with
761    /// the time each was asked for.
762    ///
763    /// The transport holds the queue; this holds the *deadline*. They are
764    /// separate because minting is what may never happen: the platform pumps
765    /// queries only while the relay socket is up, so a lookup requested offline
766    /// would otherwise have no clock running behind it and no event to arrive.
767    /// Bounded by the transport's own resolve-queue ceiling, since an entry is
768    /// recorded only when the transport accepts the lookup.
769    nostr_resolution_requests: HashMap<Username, Instant>,
770
771    /// When the push key-package pool-exhaustion warning was last emitted.
772    ///
773    /// Suppressed for the same reason the slot warning is: the condition is a
774    /// pool at its ceiling, which persists until packages are consumed or
775    /// expire, and every push while it holds would otherwise emit.
776    last_push_key_package_warning: Option<Instant>,
777}
778
779impl Drop for OfflineProtocol {
780    fn drop(&mut self) {
781        // Flush debounced Lamport clock so no ticks are lost when the
782        // protocol is dropped without an explicit stop() call.
783        self.flush_lamport_clock();
784        self.flush_nostr_watermark();
785        // Same reason: edits batch before they reach a record, so without a
786        // flush here the debounce window between an edit and its delta is a
787        // window in which work is lost.
788        #[cfg(feature = "data")]
789        let _ = self.data_flush_all();
790    }
791}
792
793#[cfg(test)]
794struct TestProtocolStateStorage {
795    storage: Arc<dyn MlsStorage>,
796}
797
798/// Maps the fixture's MLS-storage failures onto the protocol-state contract,
799/// mirroring what the UniFFI adapter does for real providers.
800#[cfg(test)]
801pub(crate) fn map_test_storage_error(
802    error: offline_protocol_mls::StorageError,
803) -> crate::ProtocolStateError {
804    use crate::ProtocolStateError as P;
805    use offline_protocol_mls::StorageError as S;
806    match error {
807        S::KeyNotFound(detail) => P::NotFound(detail),
808        S::CorruptedData(detail) => P::Corrupted(detail),
809        S::StoreFailed(detail) => P::StoreFailed(detail),
810        S::LoadFailed(detail) => P::LoadFailed(detail),
811        S::DeleteFailed(detail) => P::DeleteFailed(detail),
812        S::Unavailable(detail) => P::LoadFailed(detail),
813        // Required by `#[non_exhaustive]` on StorageError. This fixture mirrors
814        // the UniFFI adapter, whose own fallback also degrades to `LoadFailed`.
815        ref other => P::LoadFailed(other.to_string()),
816    }
817}
818
819#[cfg(test)]
820impl ProtocolStateStorage for TestProtocolStateStorage {
821    fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> crate::ProtocolStateResult<()> {
822        self.storage
823            .store(key_type, key_id, data)
824            .map_err(map_test_storage_error)
825    }
826
827    fn load(&self, key_type: &str, key_id: &str) -> crate::ProtocolStateResult<Option<Vec<u8>>> {
828        self.storage
829            .load(key_type, key_id)
830            .map_err(map_test_storage_error)
831    }
832
833    fn delete(&self, key_type: &str, key_id: &str) -> crate::ProtocolStateResult<()> {
834        self.storage
835            .delete(key_type, key_id)
836            .map_err(map_test_storage_error)
837    }
838
839    fn list_keys(&self, key_type: &str) -> crate::ProtocolStateResult<Vec<String>> {
840        self.storage
841            .list_keys(key_type)
842            .map_err(map_test_storage_error)
843    }
844}
845
846impl OfflineProtocol {
847    /// Creates a new protocol instance.
848    ///
849    /// # Arguments
850    ///
851    /// * `config` - Protocol configuration
852    ///
853    /// # Returns
854    ///
855    /// Returns `Ok(OfflineProtocol)` if successful, `Err` if configuration is invalid.
856    pub fn new(config: ProtocolConfig) -> Result<Self> {
857        // Validate configuration
858        config.validate()?;
859
860        // Create transport selector for DORS
861        let transport_selector = TransportSelector::with_config(config.dors.clone());
862
863        // Create transport manager
864        let transport_manager = TransportManager::new(transport_selector);
865
866        // Per-instance fallback secret for identifier scrubbing. Stable for
867        // the life of this protocol instance so opaque IDs stay consistent
868        // across any later `install_telemetry_sink` call that does not
869        // supply its own `scrub_secret`.
870        let telemetry_fallback_secret = *uuid::Uuid::new_v4().as_bytes();
871
872        Ok(Self {
873            transport_manager,
874            ack_manager: AckManager::with_config(config.reliability.ack.clone()),
875            retry_queue: RetryQueue::with_config(config.reliability.retry.clone()),
876            deduplicator: Deduplicator::with_config(config.reliability.dedup.clone()),
877            mesh_relay: MeshRelayGovernor::with_config(
878                config.profile.clone(),
879                config.mesh_relay.clone(),
880            ),
881            local_id: config.profile.clone(),
882            identity_established: false,
883            shared_state: Arc::new(Mutex::new(SharedState::new())),
884            outbox: HashMap::new(),
885            pending_reseal: HashMap::new(),
886            media_outbox: HashMap::new(),
887            dm_unreachable_parks: HashMap::new(),
888            reachability: reachability::ReachabilityFacts::default(),
889            mls_manager: None,
890            pending_encrypted_messages: HashMap::new(),
891            deferred_restore_settlements: Vec::new(),
892            suppressed_restore_settlements: 0,
893            next_pending_message_expiry: None,
894            pending_key_packages: HashMap::new(),
895            key_package_sent_to: std::collections::HashSet::new(),
896            known_peers: HashMap::new(),
897            confirmed_sessions: std::collections::HashSet::new(),
898            encryption_capable_peers: std::collections::HashSet::new(),
899            peer_compact_envelope: std::collections::HashSet::new(),
900            peer_rich_payload: std::collections::HashSet::new(),
901            peer_data_sync: std::collections::HashSet::new(),
902            peer_data_group: std::collections::HashSet::new(),
903            peer_data_group_attested: std::collections::HashSet::new(),
904            peer_data_media: std::collections::HashSet::new(),
905            peer_rich_attested: std::collections::HashSet::new(),
906            plaintext_send_warned: std::collections::HashSet::new(),
907            plaintext_receive_warned: std::collections::HashSet::new(),
908            encryption_capable_persisted: HashSet::new(),
909            control_gate_warned: HashMap::new(),
910            pending_queue: PendingDecryptionQueue::default(),
911            secure_storage: None,
912            protocol_state_storage: None,
913            state_record_cipher: None,
914            #[cfg(feature = "data")]
915            data: data::DataLayer::default(),
916            #[cfg(feature = "data")]
917            last_data_sync_offer: HashMap::new(),
918            pending_attachment_fetches: HashMap::new(),
919            blob_request_windows: HashMap::new(),
920            #[cfg(feature = "data")]
921            group_spaces_enumerated: false,
922            lamport_clock: LamportClock::new(),
923            confirmation_retry_due_at: HashMap::new(),
924            confirmation_probe_due_at: HashMap::new(),
925            rekey_due_at: HashMap::new(),
926            welcome_lifecycles: HashMap::new(),
927            pending_connection_requests: HashMap::new(),
928            welcome_presence_rescue: HashMap::new(),
929            both_create_awaiting_decrypt: std::collections::HashSet::new(),
930            mls_event_emitter: Arc::new(NoopMlsEventEmitter),
931            mls_event_rate_limiter: MlsEventRateLimiter::default(),
932            // The pre-install scrubber uses `TelemetryConfig::default()` —
933            // `scrub_ids=true`, no explicit `scrub_secret`. Identifier hashing
934            // is therefore on from the moment the protocol is constructed,
935            // matching today's always-scrub MLS observability semantics. When
936            // `install_telemetry_sink` later supplies a config with
937            // `scrub_ids(false)`, a new scrubber is derived from that config
938            // but reuses `telemetry_fallback_secret` so opaque IDs remain
939            // stable across the install boundary.
940            telemetry_scrubber: Scrubber::from_config(
941                &TelemetryConfig::default(),
942                telemetry_fallback_secret,
943            ),
944            telemetry_fallback_secret,
945            telemetry_secret_persisted: false,
946            nostr_secret_persisted: false,
947            nostr_unpersisted_secret: None,
948            telemetry: None,
949            file_transfer_manager: FileTransferManager::new(),
950            pending_media_metadata: HashMap::new(),
951            outbound_media_transfers: HashMap::new(),
952            outbound_media_chunks: HashMap::new(),
953            outbound_media_windows: HashMap::new(),
954            restored_media_descriptors: HashMap::new(),
955            mesh_services: MeshServices::new(),
956            group_mesh: crate::group_mesh::GroupMeshState::default(),
957            blocked_users: HashSet::new(),
958            last_reconciliation_at: None,
959            last_metrics_emit_at: None,
960            transport_status_snapshot: HashMap::new(),
961            device_capability_snapshot: None,
962            last_persisted_lamport: 0,
963            last_persisted_nostr_watermark: None,
964            nostr_publication_slots: Vec::new(),
965            nostr_published_slots: HashSet::new(),
966            last_nostr_slot_refresh: None,
967            nostr_publication_backoff: HashMap::new(),
968            last_nostr_slot_warning: None,
969            nostr_discovery_claim: None,
970            nostr_discovery_retracting: Vec::new(),
971            nostr_discovery_published: false,
972            nostr_discovery_backoff: HashMap::new(),
973            last_nostr_discovery_refresh: None,
974            nostr_resolutions: HashMap::new(),
975            nostr_resolution_requests: HashMap::new(),
976            last_push_key_package_warning: None,
977            config,
978        })
979    }
980
981    /// Initializes MLS encryption and protocol persistence.
982    ///
983    /// This must be called before encryption can be used. The storage
984    /// `secure_storage` must be a platform-native credential store.
985    /// `protocol_state_storage` must be scoped to the app container and is
986    /// removed when that container is deleted.
987    ///
988    /// Ownership model:
989    /// - `OfflineProtocol` is the single authoritative owner of `MlsManager`
990    /// - initialization is idempotent per protocol instance
991    /// - subsequent calls return without replacing the existing manager
992    /// - manager publication is transactional: restore must succeed before
993    ///   `mls_manager` becomes visible to callers
994    ///
995    /// The two storage domains are intentionally different trait objects. This
996    /// prevents message-plane state from inheriting the lifecycle of Keychain
997    /// or another credential store.
998    pub fn initialize_mls(
999        &mut self,
1000        secure_storage: Arc<dyn MlsStorage>,
1001        protocol_state_storage: Arc<dyn ProtocolStateStorage>,
1002    ) -> Result<()> {
1003        self.initialize_mls_inner(secure_storage, protocol_state_storage, true)
1004    }
1005
1006    /// Returns this device's address, once it has one.
1007    ///
1008    /// `None` before `initialize_mls`: the address is the hash of an identity
1009    /// key that lives in this profile's storage, and storage does not arrive
1010    /// until then. It never changes afterwards, and it is stable across
1011    /// restarts of the same profile because the key is.
1012    pub fn local_address(&self) -> Option<&str> {
1013        self.identity_established.then_some(self.local_id.as_str())
1014    }
1015
1016    /// `adopt_legacy_state` exists only so test fixtures can point both handles
1017    /// at one backend. Adoption moves records *between* the two stores, which
1018    /// on a shared backend would mean deleting a record through the same store
1019    /// it was just read from.
1020    ///
1021    /// There is one identity path and every caller takes it — a fixture that
1022    /// wants a particular identity seeds the key into `secure_storage` first
1023    /// (see [`crate::test_identity`]) rather than naming an id here, because an
1024    /// id nothing holds a key for is exactly what `MlsManager::new` refuses.
1025    fn initialize_mls_inner(
1026        &mut self,
1027        secure_storage: Arc<dyn MlsStorage>,
1028        protocol_state_storage: Arc<dyn ProtocolStateStorage>,
1029        adopt_legacy_state: bool,
1030    ) -> Result<()> {
1031        if self.mls_manager.is_some() {
1032            return Ok(());
1033        }
1034
1035        // The bootstrap inversion: load (or mint) the identity key first, and
1036        // take this device's wire identity from it. Before this point the
1037        // protocol has no address — `local_id` is still the profile — which is
1038        // exactly why the key has to be resolved here, ahead of the manager
1039        // that embeds the id in its credential and in every session slot.
1040        let (_, address) = MlsManager::load_or_create_identity(&secure_storage)?;
1041        let local_id = address.to_string();
1042
1043        let mut manager = MlsManager::new(&local_id, secure_storage.clone())?;
1044        // Installed before the manager becomes shared: the enforcement flag is
1045        // read on every group decrypt, and this is the one point where the
1046        // manager is still uniquely owned.
1047        manager.set_enforce_admin_commits(self.config.group.enforce_admin_commits);
1048        let manager = Arc::new(RwLock::new(manager));
1049
1050        // Keep initialization transactional so a restore failure cannot leave
1051        // partially-initialized MLS state visible and then permanently block retries.
1052        let previous_secure_storage = self.secure_storage.clone();
1053        let previous_protocol_state_storage = self.protocol_state_storage.clone();
1054        // The record cipher belongs to the secure store it was loaded from, so
1055        // it is part of the same transaction as the storage handles: taken here
1056        // so a failed init cannot leave the new store's key installed next to
1057        // the rolled-back handles, and restored below alongside them.
1058        let previous_state_record_cipher = self.state_record_cipher.take();
1059        let previous_pending_messages = self.pending_encrypted_messages.clone();
1060        let previous_pending_message_expiry = self.next_pending_message_expiry;
1061        // Populated by restore steps that run before other fallible ones, so
1062        // they belong in the transaction like everything else: a failed init
1063        // must not leave a key-package cache, parked media descriptors, or an
1064        // owner gate sourced from a store the rollback has just detached.
1065        let previous_pending_key_packages = self.pending_key_packages.clone();
1066        let previous_restored_media_descriptors = self.restored_media_descriptors.clone();
1067        let previous_both_create_awaiting_decrypt = self.both_create_awaiting_decrypt.clone();
1068        let previous_confirmed_sessions = self.confirmed_sessions.clone();
1069        let previous_encryption_capable_peers = self.encryption_capable_peers.clone();
1070        let previous_welcome_lifecycles = self.welcome_lifecycles.clone();
1071        let previous_lamport_clock = self.lamport_clock.value();
1072        // The debounce watermark travels with the clock: `restore_lamport_clock`
1073        // sets both, so rolling back only the clock leaves a watermark ahead of
1074        // it and `persist_lamport_clock`'s `wrapping_sub` reads that as an
1075        // enormous delta — writing on every tick instead of every interval.
1076        let previous_last_persisted_lamport = self.last_persisted_lamport;
1077        let previous_blocked_users = self.blocked_users.clone();
1078        let previous_outbox = self.outbox.clone();
1079        let previous_peer_compact_envelope = self.peer_compact_envelope.clone();
1080        let previous_peer_rich_payload = self.peer_rich_payload.clone();
1081        let previous_peer_data_sync = self.peer_data_sync.clone();
1082        let previous_peer_rich_attested = self.peer_rich_attested.clone();
1083        let previous_peer_data_group = self.peer_data_group.clone();
1084        let previous_peer_data_group_attested = self.peer_data_group_attested.clone();
1085        let previous_peer_data_media = self.peer_data_media.clone();
1086
1087        let previous_local_id = std::mem::replace(&mut self.local_id, local_id.clone());
1088        let previous_identity_established = self.identity_established;
1089        self.identity_established = true;
1090        // The governor took the profile at construction because no address
1091        // existed yet; it keys relay fan-out and jitter off the local id, so it
1092        // has to move to the real one here.
1093        self.mesh_relay.set_local_id(local_id);
1094
1095        self.secure_storage = Some(secure_storage);
1096        self.protocol_state_storage = Some(protocol_state_storage);
1097
1098        // Must precede every restore below: sealed protocol-state records
1099        // (pending messages, outbox, media descriptors) are unreadable without
1100        // this key, so restoring first would silently start from empty and then
1101        // overwrite durable state with that empty view.
1102        self.restore_or_init_state_record_key();
1103
1104        // Then adopt anything the pre-split build left in secure storage, so
1105        // the restores below see one complete view. Must follow the record key
1106        // (adoption seals what it moves) and precede every restore (a restore
1107        // that ran first would find nothing and then overwrite the legacy state
1108        // with that empty view). Best-effort and resumable: a failure here
1109        // leaves the legacy records in place for the next launch.
1110        if adopt_legacy_state {
1111            self.adopt_legacy_protocol_state();
1112        }
1113
1114        // The settlement queue is deliberately NOT captured for rollback, and
1115        // that is the whole point rather than an omission.
1116        //
1117        // The rollback below restores *in-memory* state. It restores no storage
1118        // state, because restore has none to give back: by the time a later
1119        // step fails, an earlier one has already deleted an unaddressable
1120        // pending queue, dropped a record its store reported corrupt, and
1121        // rewritten a peer's snapshot without the entries it evicted for
1122        // capacity. Those records are gone. So the invariant adoption is
1123        // documented with — nothing can re-derive a settlement for a record
1124        // that no longer exists — is not special to adoption; it holds for
1125        // every settlement produced under this transaction, and rolling any of
1126        // them back means the application keeps an id that resolves to nothing,
1127        // on this launch and on every launch after it.
1128        //
1129        // The cost of not rolling back is that a retry which re-examines a
1130        // still-present record can settle the same id twice. `message_failed`
1131        // is terminal and idempotent for any reasonable consumer, and a
1132        // duplicate terminal event is a far smaller lie than silence.
1133        //
1134        // Load the persistent scrub secret outside the transactional restore
1135        // below: it is independent of MLS state, and a later MLS-restore
1136        // rollback must not undo it (the secret is idempotent and reused on
1137        // the next attempt).
1138        self.restore_or_init_scrub_secret();
1139
1140        // Same reasoning for the Nostr signing secret: independent of MLS
1141        // state, idempotent, and must survive an MLS-restore rollback.
1142        self.restore_or_init_nostr_signing_secret();
1143
1144        // And for the Nostr receive watermark, which sits outside the
1145        // transaction below for a third reason on top of those two: it does
1146        // not live in `self` at all. The mark is transport state, so the
1147        // rollback — which restores in-memory fields of `OfflineProtocol` —
1148        // could not put it back even if it wanted to. Installing it under a
1149        // later failure is harmless: it only ever moves forward, and the worst
1150        // a stale-but-installed mark does is narrow one replay window.
1151        self.restore_nostr_watermark();
1152        self.restore_nostr_publication_slots();
1153        self.restore_nostr_discovery_claim();
1154
1155        // The launch's whole durable-delete allowance, in one place because the
1156        // bound is on the launch. A device-barrier storm kills *this call*, not
1157        // any single walk in it, so no walk may hand itself a pool — see
1158        // `PruneAllowance::pool`. Three of them, and the ceiling is their sum:
1159        //
1160        // - `advisory_prunes` is shared by the `ADVISORY_PRUNE_WALKS` walks
1161        //   whose prunes are caches or advisory signals. They draw in the order
1162        //   written below, and that order is part of the contract: each leaves
1163        //   the ones after it a `MIN_ADVISORY_PRUNE_DELETES` floor and may take
1164        //   the rest, so an earlier walk gets first call on whatever the later
1165        //   ones do not reserve. Reordering them, or adding a sixth without
1166        //   bumping `ADVISORY_PRUNE_WALKS`, changes who gets pruned under load.
1167        // - `pending_prunes` and `outbox_prunes` are private to the two
1168        //   settlement-paired walks, because being starved there defers a
1169        //   diagnostic or a *delivery* rather than a cache eviction, and
1170        //   neither may be held hostage to a key-package flood in a category it
1171        //   has nothing to do with.
1172        //
1173        // See `storage::MAX_RESTORE_PRUNE_DELETES`.
1174        let mut advisory_prunes = PruneAllowance::pool();
1175        let mut pending_prunes = PruneAllowance::pool();
1176        let mut outbox_prunes = PruneAllowance::pool();
1177
1178        // Restore state from previous session
1179        let restore_result = (|| {
1180            self.restore_pending_messages(&mut pending_prunes)?;
1181            self.restore_lamport_clock();
1182            self.restore_encryption_capable_peers();
1183            self.restore_blocked_users()?;
1184            self.restore_session_states_from_manager(manager.clone(), &mut advisory_prunes)?;
1185            self.restore_peer_key_packages(&manager, &mut advisory_prunes)?;
1186            // Must precede start(): flush_restored_confirmed_pending_messages
1187            // re-makes the rich seal decision against these sets, and an
1188            // empty set there silently drops queued rich extras.
1189            self.restore_peer_capabilities(&manager, &mut advisory_prunes);
1190            self.restore_welcome_lifecycles(&mut advisory_prunes)?;
1191            self.restore_outbox(&mut outbox_prunes)?;
1192            self.restore_media_descriptors(&mut advisory_prunes)?;
1193            self.restore_both_create_awaiting_decrypt();
1194            Ok(())
1195        })();
1196
1197        if let Err(err) = restore_result {
1198            self.mesh_relay.set_local_id(previous_local_id.clone());
1199            self.local_id = previous_local_id;
1200            self.identity_established = previous_identity_established;
1201            self.secure_storage = previous_secure_storage;
1202            self.protocol_state_storage = previous_protocol_state_storage;
1203            self.state_record_cipher = previous_state_record_cipher;
1204            self.pending_encrypted_messages = previous_pending_messages;
1205            self.next_pending_message_expiry = previous_pending_message_expiry;
1206            // `deferred_restore_settlements` is deliberately left alone — see
1207            // the comment where the other baselines are captured.
1208            self.pending_key_packages = previous_pending_key_packages;
1209            self.restored_media_descriptors = previous_restored_media_descriptors;
1210            self.both_create_awaiting_decrypt = previous_both_create_awaiting_decrypt;
1211            self.confirmed_sessions = previous_confirmed_sessions;
1212            // Restored for consistency with the rest of the transaction, not
1213            // for safety: this set only ever grows, so keeping the wider view
1214            // would also have been sound. Rolling it back keeps "a failed
1215            // initialize_mls leaves no trace" true without exception.
1216            self.encryption_capable_peers = previous_encryption_capable_peers;
1217            self.welcome_lifecycles = previous_welcome_lifecycles;
1218            self.lamport_clock = LamportClock::from_value(previous_lamport_clock);
1219            self.last_persisted_lamport = previous_last_persisted_lamport;
1220            self.blocked_users = previous_blocked_users;
1221            self.outbox = previous_outbox;
1222            self.peer_compact_envelope = previous_peer_compact_envelope;
1223            self.peer_rich_payload = previous_peer_rich_payload;
1224            self.peer_data_sync = previous_peer_data_sync;
1225            self.peer_rich_attested = previous_peer_rich_attested;
1226            self.peer_data_group = previous_peer_data_group;
1227            self.peer_data_group_attested = previous_peer_data_group_attested;
1228            self.peer_data_media = previous_peer_data_media;
1229            return Err(err);
1230        }
1231
1232        self.mls_manager = Some(manager);
1233        // A re-init can swap the identity, and with it which groups exist, so
1234        // the one-shot group enumeration has to run again. Past the last
1235        // `return Err` above, so a failed init leaves the flag alone.
1236        #[cfg(feature = "data")]
1237        {
1238            self.group_spaces_enumerated = false;
1239        }
1240        self.emit_mls_initialized();
1241
1242        // Announced only here, past the last `return Err` — a restore failure
1243        // rolls the address back, and announcing an identity the instance then
1244        // abandoned would leave the app addressing a device that is not this
1245        // one.
1246        self.emit_event(Event::identity_ready(self.local_id.clone()));
1247
1248        info!(profile = %self.config.profile, "MLS encryption initialized with split secure and protocol-state storage");
1249        Ok(())
1250    }
1251
1252    /// Test-only adapter for fixtures that intentionally use one
1253    /// in-memory/fault-injection backend to observe both storage domains.
1254    #[cfg(test)]
1255    pub(crate) fn initialize_mls_for_test(&mut self, storage: Arc<dyn MlsStorage>) -> Result<()> {
1256        let protocol_state_storage = Arc::new(TestProtocolStateStorage {
1257            storage: storage.clone(),
1258        });
1259        // Give the storage the identity this fixture's profile label stands
1260        // for, then take the production path over it. The instance comes up as
1261        // a genuine derived address, and it is the *same* address every time a
1262        // fixture says `"alice"` — which is what lets tests keep readable
1263        // labels while still exercising derived identity.
1264        crate::test_identity::seed_identity(&storage, &self.config.profile);
1265        self.initialize_mls_inner(storage, protocol_state_storage, false)
1266    }
1267
1268    /// Test-only init that takes the **production** identity path: the address
1269    /// is derived from the identity key in `storage` rather than forced.
1270    #[cfg(test)]
1271    pub(crate) fn initialize_mls_for_test_derived(
1272        &mut self,
1273        storage: Arc<dyn MlsStorage>,
1274    ) -> Result<()> {
1275        let protocol_state_storage = Arc::new(TestProtocolStateStorage {
1276            storage: storage.clone(),
1277        });
1278        self.initialize_mls_inner(storage, protocol_state_storage, false)
1279    }
1280
1281    /// Test-only storage initialization for persistence-focused fixtures that
1282    /// do not need an MLS manager.
1283    #[cfg(test)]
1284    pub(crate) fn enable_message_persistence_for_test(
1285        &mut self,
1286        storage: Arc<dyn MlsStorage>,
1287    ) -> Result<()> {
1288        self.secure_storage = Some(storage.clone());
1289        self.protocol_state_storage = Some(Arc::new(TestProtocolStateStorage { storage }));
1290        self.restore_or_init_state_record_key();
1291        self.restore_or_init_scrub_secret();
1292        self.restore_or_init_nostr_signing_secret();
1293        self.restore_nostr_watermark();
1294        self.restore_nostr_publication_slots();
1295        self.restore_nostr_discovery_claim();
1296        // Same three pools as `initialize_mls_inner`, for the same reason.
1297        let mut advisory_prunes = PruneAllowance::pool();
1298        let mut pending_prunes = PruneAllowance::pool();
1299        let mut outbox_prunes = PruneAllowance::pool();
1300        self.restore_pending_messages(&mut pending_prunes)?;
1301        self.restore_lamport_clock();
1302        self.restore_encryption_capable_peers();
1303        self.restore_blocked_users()?;
1304        self.restore_outbox(&mut outbox_prunes)?;
1305        self.restore_media_descriptors(&mut advisory_prunes)?;
1306        Ok(())
1307    }
1308
1309    /// Checks if MLS encryption is initialized.
1310    pub fn is_mls_initialized(&self) -> bool {
1311        self.mls_manager.is_some()
1312    }
1313
1314    /// Records that `peer_id` has demonstrated it runs MLS.
1315    ///
1316    /// The only way into [`Self::encryption_capable_peers`]. Call it from
1317    /// anywhere a peer proves encryption capability — creating or joining a 1:1
1318    /// session with them, or pinning their signing key. Calling it redundantly
1319    /// is free and calling it too often is safe; the failure that matters is
1320    /// *not* calling it, which leaves the plaintext gate open for that peer.
1321    ///
1322    /// # Bounded by refusal, never by eviction
1323    ///
1324    /// The keys are wire-claimed ids and need their own cap, like every other
1325    /// map keyed that way — but not for the reason this comment used to give.
1326    /// It cited an unsigned `__MLS_WELCOME__` reaching the marking path; the
1327    /// control gate now rejects unsigned control frames before dispatch, and
1328    /// `__MLS_WELCOME__` is in neither exemption list, so `handle_welcome_message`
1329    /// only ever runs on a verified frame.
1330    ///
1331    /// The cap survives that because a signature was never the bound. A
1332    /// signature proves the signer holds the key its address derives from; it
1333    /// does not prove the address belongs to anyone real, and minting a fresh
1334    /// one costs a keygen. So an attacker willing to spend keygens can still
1335    /// present an unbounded supply of distinct, perfectly well-signed peers —
1336    /// which is precisely what a cap on a wire-keyed map is for.
1337    ///
1338    /// It has to be a refusal. Evicting would un-mark a peer that really is
1339    /// encryption-capable — the fail-open direction this field exists to
1340    /// prevent — and would hand an attacker a way to unprotect a *chosen*
1341    /// victim by flooding. Refusing only declines to add knowledge: that peer
1342    /// falls through to the `is_session_confirmed` check in
1343    /// [`Self::accept_plaintext_content`], which is exactly what shipped before
1344    /// this set existed. So a flood costs later peers the improvement and never
1345    /// costs anyone the protection they already had.
1346    ///
1347    /// Legitimate peers are also first in line by construction: restore seeds
1348    /// the set from the session list and the durable capability records during
1349    /// `initialize_mls`, before `start()` admits any traffic.
1350    ///
1351    /// # Return value
1352    ///
1353    /// `true` when `peer_id` is in the set on return — whether this call added
1354    /// it or a previous one did. This is what bounds the *durable* record:
1355    /// [`Self::record_encryption_capable`](crate::OfflineProtocol) persists only
1356    /// when this returns `true`, so the storage category can never hold more
1357    /// peers than the cap below. Without that coupling a Sybil flood — cheap,
1358    /// since each identity is one Ed25519 keygen that signs honestly as itself
1359    /// — would write unbounded records, and the restore walk (bounded by
1360    /// `MAX_RESTORE_KEYS_PER_CATEGORY`) could then miss a legitimate peer's
1361    /// record and re-open the plaintext gate for them on the next launch.
1362    pub(crate) fn mark_encryption_capable(&mut self, peer_id: &str) -> bool {
1363        if peer_id.is_empty() || peer_id == self.local_id {
1364            return false;
1365        }
1366        if self.encryption_capable_peers.contains(peer_id) {
1367            return true;
1368        }
1369        if self.encryption_capable_peers.len() >= MAX_ENCRYPTION_CAPABLE_PEERS {
1370            debug!(
1371                peer_id = %peer_id,
1372                cap = MAX_ENCRYPTION_CAPABLE_PEERS,
1373                "Encryption-capability set at capacity; peer falls back to the session-state check"
1374            );
1375            return false;
1376        }
1377        self.encryption_capable_peers.insert(peer_id.to_string());
1378        debug!(peer_id = %peer_id, "Peer marked encryption-capable");
1379        if self.encryption_capable_peers.len() == MAX_ENCRYPTION_CAPABLE_PEERS {
1380            // Emitted on the transition into full, so it cannot become a
1381            // per-frame flood: the set only grows and has no removal path, so
1382            // this line is reachable exactly once per process.
1383            warn!(
1384                cap = MAX_ENCRYPTION_CAPABLE_PEERS,
1385                "Encryption-capability set reached capacity; further peers fall back to the \
1386                 session-state check. Expected only under a forged-sender flood."
1387            );
1388        }
1389        true
1390    }
1391
1392    /// Whether `peer_id` is known to run MLS. See
1393    /// [`Self::encryption_capable_peers`] for why this is not a storage read.
1394    pub(crate) fn is_encryption_capable(&self, peer_id: &str) -> bool {
1395        self.encryption_capable_peers.contains(peer_id)
1396    }
1397
1398    /// Drops `peer_id` from the in-memory capability set **only**, leaving its
1399    /// durable record and write-elision cache entry in place.
1400    ///
1401    /// Test-only, and deliberately not a general "forget" — the set has no
1402    /// removal path by design (see [`Self::encryption_capable_peers`]). It
1403    /// reproduces one specific state that production does reach: the rollback in
1404    /// `initialize_mls` restores the pre-restore snapshot of the set while
1405    /// [`Self::encryption_capable_persisted`] keeps what the restore walk read,
1406    /// so the two disagree until the peer's next verified frame re-marks them.
1407    #[cfg(test)]
1408    pub(crate) fn forget_encryption_capable_for_test(&mut self, peer_id: &str) {
1409        self.encryption_capable_peers.remove(peer_id);
1410    }
1411
1412    /// Returns whether auto-encryption should be applied.
1413    fn should_auto_encrypt(&self) -> bool {
1414        self.config.encryption.enabled && self.mls_manager.is_some()
1415    }
1416
1417    /// Configures the MLS lifecycle event emitter.
1418    pub fn set_mls_event_emitter(&mut self, emitter: Arc<dyn MlsEventEmitter>) {
1419        self.mls_event_emitter = emitter;
1420    }
1421
1422    /// Installs the unified telemetry sink for this protocol instance.
1423    ///
1424    /// Every subsequent protocol [`Event`] and MLS lifecycle event is
1425    /// additionally forwarded to `sink` as a
1426    /// [`crate::telemetry::TelemetryRecord`], gated by the verbosity tier
1427    /// and identifier-scrubbing preferences in `config`. Protocol events
1428    /// flow through as [`TelemetryRecord::Protocol`], MLS lifecycle events
1429    /// as [`TelemetryRecord::Mls`].
1430    ///
1431    /// This does not replace the legacy [`crate::EventCallback`] and
1432    /// [`MlsEventEmitter`] paths — they continue to fire independently for
1433    /// backward compatibility.
1434    ///
1435    /// Events emitted before this call (for example
1436    /// [`crate::mls_observability::MlsLifecycleEvent::Initialized`] fired
1437    /// during `initialize_mls`) are not replayed to a sink installed later.
1438    ///
1439    /// # Re-install semantics
1440    ///
1441    /// Calling this method a second time *replaces* the previously installed
1442    /// sink and config — the old sink stops receiving records as soon as
1443    /// this call returns. The per-instance fallback secret is preserved so
1444    /// opaque identifiers stay stable across the swap when the new config
1445    /// does not carry its own `scrub_secret`.
1446    ///
1447    /// The per-tick diff snapshots (transport status, device capability,
1448    /// metrics cadence) are **rearmed** on every install: the next
1449    /// `process()` tick observes the current state as its baseline and emits
1450    /// no synthetic transitions for transports that were already available
1451    /// at install time. Apps that need the current transport statuses at
1452    /// install time should pull them explicitly via
1453    /// [`TransportManager::get_all_transport_statuses`].
1454    ///
1455    /// The escalation-trigger dedupe window (`ESCALATION_TRIGGER_DEDUPE_SECS`
1456    /// inside [`TransportManager`]) is **not** rearmed. A sink installed
1457    /// within that window after an escalation event fired to a previous
1458    /// sink may miss the next occurrence of the same reason until the
1459    /// window elapses. This is intentional: the dedupe is a property of
1460    /// the routing engine, not of the observer.
1461    ///
1462    /// # Lifecycle
1463    ///
1464    /// Installing a sink wires the structured routing-decision callback
1465    /// on the underlying [`TransportManager`]. The callback lives for the
1466    /// life of the protocol instance or until another
1467    /// `install_telemetry_sink` call replaces it — `start()` and `stop()`
1468    /// do not toggle it. This means apps can install a sink before
1469    /// `start()` and see routing records from the very first `send()`,
1470    /// and a `stop() → start()` cycle preserves the wiring so no
1471    /// re-install is needed. Conversely, apps that never install a sink
1472    /// pay no per-`send()` routing-emission overhead.
1473    ///
1474    /// # Errors
1475    ///
1476    /// Returns `Err` if the shared-state mutex is poisoned (indicating an
1477    /// earlier panic in another thread while holding the lock). On error no
1478    /// sink is installed, and the legacy emission paths are unaffected.
1479    ///
1480    /// [`TelemetryRecord::Protocol`]: crate::telemetry::TelemetryRecord::Protocol
1481    /// [`TelemetryRecord::Mls`]: crate::telemetry::TelemetryRecord::Mls
1482    pub fn install_telemetry_sink(
1483        &mut self,
1484        sink: Arc<dyn TelemetrySink>,
1485        config: TelemetryConfig,
1486    ) -> Result<()> {
1487        // Forward the routing-diagnostic preference to the TransportManager
1488        // before wiring any callbacks so the very first routing decision
1489        // post-install already reflects the requested tier.
1490        self.transport_manager
1491            .set_routing_diagnostic(config.routing_diagnostic());
1492
1493        let ctx = TelemetryContext::new(sink, config, self.telemetry_fallback_secret);
1494        let mut state = lock_shared_state(&self.shared_state).map_err(|err| {
1495            error!(
1496                error = %err,
1497                "install_telemetry_sink: shared-state mutex poisoned; sink NOT installed",
1498            );
1499            err
1500        })?;
1501        state.telemetry = Some(ctx.clone());
1502        drop(state);
1503        self.telemetry = Some(ctx);
1504
1505        // Wire the structured routing-decision callback here so the
1506        // `TransportManager` pays the per-`send()` emission cost only when
1507        // a sink is actually installed, and so the wiring persists across
1508        // `stop() → start()` cycles (the callback reads `s.telemetry` on
1509        // every invocation and that field stays set). A subsequent
1510        // `install_telemetry_sink` replaces the closure with one capturing
1511        // the fresh `shared_routing` clone; both closures read the same
1512        // `SharedState::telemetry` slot, so even a concurrent in-flight
1513        // emission from the pre-replace closure would still resolve to
1514        // the currently-installed sink.
1515        let shared_routing = self.shared_state.clone();
1516        self.transport_manager
1517            .set_routing_decision_callback(Some(Arc::new(move |decision| {
1518                let s = shared_routing.lock_or_recover();
1519                if let Some(ctx) = &s.telemetry {
1520                    let record = TelemetryRecord::Routing(Box::new(decision));
1521                    // Dispatch is panic-isolated so a sink that panics
1522                    // here cannot unwind through the live `MutexGuard`
1523                    // and poison `SharedState`.
1524                    dispatch_record(&ctx.sink, &record);
1525                }
1526            })));
1527
1528        // Rearm the per-tick diff snapshots so the first tick after install
1529        // reports honest transitions only. Without this, a sink installed
1530        // after start() would observe a synthetic `Unavailable → Available`
1531        // transition for every already-running transport on the next tick.
1532        let (statuses, available) = self.transport_manager.snapshot_status_and_available();
1533        self.transport_status_snapshot = statuses;
1534        let (battery_level, is_charging) =
1535            device_battery_from_available(self.transport_manager.current_transport(), &available);
1536        let relay_role = self.relay_role();
1537        self.device_capability_snapshot = Some(DeviceSnap::from_parts(
1538            battery_level,
1539            is_charging,
1540            relay_role,
1541        ));
1542        // Leave `last_metrics_emit_at` at None so the first tick after
1543        // install fires a fresh metrics snapshot — that is the bootstrap
1544        // payload a new sink actually wants (full counter state in one
1545        // record).
1546        self.last_metrics_emit_at = None;
1547
1548        Ok(())
1549    }
1550
1551    /// Starts the protocol.
1552    ///
1553    /// # Returns
1554    ///
1555    /// Returns `Ok(())` if started successfully, `Err` if already started.
1556    pub fn start(&mut self) -> Result<()> {
1557        let state = lock_shared_state(&self.shared_state)?;
1558
1559        if state.state != ProtocolState::Stopped {
1560            return Err(Error::AlreadyStarted);
1561        }
1562
1563        // Start all transports
1564        drop(state);
1565        self.transport_manager.start()?;
1566
1567        // Apply the Nostr sealing kill switch here rather than at transport
1568        // construction: the transport is built by the bindings layer, so this
1569        // is the one place every caller — FFI, RN, and a pure-Rust embedder
1570        // that installed its own `NostrTransport` — passes through with the
1571        // config in hand. A no-op when Nostr is not installed.
1572        self.transport_manager
1573            .set_nostr_sealing_enabled(self.config.transport.nostr_sealing_enabled);
1574        self.transport_manager
1575            .set_nostr_cold_contact_enabled(self.config.transport.nostr_cold_contact_enabled);
1576        self.transport_manager.set_nostr_username_discovery_enabled(
1577            self.config.transport.nostr_username_discovery_enabled,
1578        );
1579
1580        // Wire DORS event callback so app receives dors_score_updated, dors_transport_selected,
1581        let shared = self.shared_state.clone();
1582        self.transport_manager
1583            .set_dors_event_callback(Some(Arc::new(move |event| {
1584                shared.lock_or_recover().emit_event(event);
1585            })));
1586
1587        // NOTE: the structured routing-decision callback is wired by
1588        // `install_telemetry_sink`, not here. Its lifetime is tied to sink
1589        // presence rather than protocol start/stop — see the docstring on
1590        // `install_telemetry_sink` for the rationale.
1591
1592        // Wire BLE fragment eviction callback so app receives FragmentAssemblyEvicted.
1593        if let Some(ble_arc) = self.transport_manager.get_transport(TransportType::BLE) {
1594            let shared = self.shared_state.clone();
1595            if let Some(ble) = ble_arc.as_any().downcast_ref::<BleTransport>() {
1596                ble.set_fragment_eviction_callback(Some(Arc::new(move |info| {
1597                    shared
1598                        .lock_or_recover()
1599                        .emit_event(Event::fragment_assembly_evicted(
1600                            info.message_id,
1601                            info.completion_percent,
1602                            "capacity".to_string(),
1603                        ));
1604                })));
1605            }
1606        }
1607
1608        let mut state = lock_shared_state(&self.shared_state)?;
1609
1610        state.state = ProtocolState::Running;
1611        drop(state);
1612
1613        // Settle what restore could not recover, now that the event pipeline is
1614        // live. These ids were handed to the app by `send_message*` before the
1615        // process died; without this they would never resolve to anything.
1616        // Drained before the flush below so an app sees the failures first and
1617        // cannot mistake a restored send for the settlement of a lost one.
1618        self.drain_deferred_restore_settlements();
1619
1620        self.flush_restored_confirmed_pending_messages();
1621
1622        // Cold start: sessions restored from storage are already confirmed,
1623        // so no confirmation transition will fire for them and nothing else
1624        // would offer versions until the peer happened to be rediscovered.
1625        // Frames toward an unreachable peer queue in the outbox, which is the
1626        // ladder doing its job rather than a reason not to try.
1627        #[cfg(feature = "data")]
1628        {
1629            // The space list is derived from the records themselves rather
1630            // than from the space index, because the index is a cache: a
1631            // document written without ever being explicitly created leaves
1632            // records but no index entry, and those are precisely the
1633            // documents no peer has seen yet.
1634            // Group spaces appear in this list and are deliberately not
1635            // swept: `kick_data_sync` answers only for a space named after
1636            // a peer, so a group space falls through it. A cold-start sweep
1637            // for groups would mean an offer per member per group at every
1638            // launch, and what it would recover is narrow — a local commit
1639            // is already pushed to the roster when it happens, and the
1640            // per-member outbox carries it across a restart. Rediscovery
1641            // and joins reconcile the rest.
1642            for space in self.data_list_spaces().unwrap_or_default() {
1643                self.kick_data_sync(&space, "start");
1644            }
1645        }
1646
1647        self.kick_pending_session_reconciliation("start");
1648        self.process_welcome_retry_queue()?;
1649
1650        // Re-drive delivery of any outbox entries restored from a previous
1651        // session. They land here in the "stranded" state (not in the retry
1652        // queue, not awaiting an ACK), which flush_outbox_all already recovers:
1653        // it sends immediately where a transport is available and re-enqueues
1654        // the rest with backoff. Runs at start() rather than at restore time
1655        // because transports aren't up yet during initialize_mls.
1656        self.flush_outbox_all();
1657
1658        // Media transfers that died with the previous process cannot be
1659        // re-driven — only their descriptors survive (never chunk bytes).
1660        // Tell the app which transfers to re-initiate, now that the event
1661        // pipeline is live. The descriptors stay parked (and persisted)
1662        // until the resend consumes them or the restore TTL prunes them,
1663        // so an app that misses this signal gets it again next restart.
1664        //
1665        // Except the layer's own. An application cannot answer one of those:
1666        // the only surface that takes a caller-supplied `file_id` is
1667        // `send_media_with`, which forces the purpose to `None`, so a
1668        // well-behaved app re-supplying the bytes would deliver a document
1669        // snapshot to the peer's user as a downloaded file. They are dropped
1670        // instead, and nothing is stranded: the requester's fetch expires and
1671        // can be asked again, and a snapshot is re-offered by the next
1672        // anti-entropy exchange.
1673        let (recoverable, internal): (Vec<_>, Vec<_>) = self
1674            .restored_media_descriptors
1675            .values()
1676            .cloned()
1677            .partition(|descriptor| descriptor.data_purpose.is_none());
1678        for descriptor in &internal {
1679            debug!(
1680                file_id = %descriptor.file_id,
1681                "Dropping an interrupted document-layer transfer; the asking side will retry"
1682            );
1683            self.restored_media_descriptors.remove(&descriptor.file_id);
1684            self.remove_media_descriptor(&descriptor.file_id);
1685        }
1686        for descriptor in recoverable {
1687            self.emit_event(Event::media_resend_required(
1688                descriptor.file_id.clone(),
1689                descriptor.recipient.clone(),
1690                descriptor.file_name.clone(),
1691                descriptor.file_size,
1692            ));
1693        }
1694
1695        Ok(())
1696    }
1697
1698    /// Stops the protocol gracefully.
1699    ///
1700    /// # Returns
1701    ///
1702    /// Returns `Ok(())` if stopped successfully, `Err` if not started.
1703    pub fn stop(&mut self) -> Result<()> {
1704        let state = lock_shared_state(&self.shared_state)?;
1705
1706        if state.state == ProtocolState::Stopped {
1707            return Ok(()); // Already stopped
1708        }
1709
1710        // Flush debounced Lamport clock before stopping so no ticks are lost.
1711        drop(state);
1712        self.flush_lamport_clock();
1713        // Same for the Nostr receive watermark: an un-flushed tail costs the
1714        // next launch a wider replay window.
1715        self.flush_nostr_watermark();
1716        // Answer any lookup still in flight while the transport is still up to
1717        // take the cancellations. Nothing pumps relays or sweeps deadlines once
1718        // stopped, so a resolution left here is an event that never comes.
1719        self.flush_username_resolutions_for_stop();
1720
1721        // Clear event callbacks to release shared_state references.
1722        // NOTE: the routing-decision callback is deliberately NOT cleared
1723        // here. Its lifetime is sink-scoped (installed by
1724        // `install_telemetry_sink`, replaced by a subsequent install),
1725        // not protocol-running-scoped — so a `stop() → start()` cycle
1726        // preserves the wiring without requiring the app to re-install.
1727        self.transport_manager.set_dors_event_callback(None);
1728        if let Some(ble_arc) = self.transport_manager.get_transport(TransportType::BLE) {
1729            if let Some(ble) = ble_arc.as_any().downcast_ref::<BleTransport>() {
1730                ble.set_fragment_eviction_callback(None);
1731            }
1732        }
1733
1734        self.transport_manager.stop()?;
1735        let mut state = lock_shared_state(&self.shared_state)?;
1736
1737        state.state = ProtocolState::Stopped;
1738
1739        Ok(())
1740    }
1741
1742    /// Pauses the protocol (for background mode).
1743    pub fn pause(&mut self) -> Result<()> {
1744        self.flush_lamport_clock();
1745        self.flush_nostr_watermark();
1746        let mut state = lock_shared_state(&self.shared_state)?;
1747
1748        if state.state != ProtocolState::Running {
1749            return Err(Error::NotStarted);
1750        }
1751
1752        state.state = ProtocolState::Paused;
1753        Ok(())
1754    }
1755
1756    /// Resumes the protocol from pause.
1757    pub fn resume(&mut self) -> Result<()> {
1758        {
1759            let mut state = lock_shared_state(&self.shared_state)?;
1760
1761            if state.state != ProtocolState::Paused {
1762                return Err(Error::InvalidConfiguration(
1763                    "Protocol is not paused".to_string(),
1764                ));
1765            }
1766
1767            state.state = ProtocolState::Running;
1768        }
1769
1770        // A pause is the other edge back into a live event pipeline, so it owes
1771        // the same drain `start()` does. `settle_restored_message_failure` parks
1772        // anything it produces while the protocol is not `Running`, and
1773        // `update_retry_config` reaches it at runtime: shortening
1774        // `pending_message_max_lifetime_ms` in the background expires queued
1775        // messages and parks their terminal `message_failed`. Without this the
1776        // app would hold those ids until a `start()` that may never come again.
1777        self.drain_deferred_restore_settlements();
1778        Ok(())
1779    }
1780
1781    /// Emits every terminal settlement parked while the event pipeline was not
1782    /// live, and reports the count of any the cap dropped.
1783    ///
1784    /// Called from both edges into `Running` ([`Self::start`] and
1785    /// [`Self::resume`]) so a parked settlement has no state to be stranded in.
1786    /// Idempotent: draining an empty queue emits nothing.
1787    fn drain_deferred_restore_settlements(&mut self) {
1788        for event in std::mem::take(&mut self.deferred_restore_settlements) {
1789            self.emit_event(event);
1790        }
1791        let suppressed = std::mem::take(&mut self.suppressed_restore_settlements);
1792        if suppressed > 0 {
1793            warn!(
1794                suppressed,
1795                cap = MAX_DEFERRED_RESTORE_SETTLEMENTS,
1796                "Restore produced more terminal settlements than any legitimate run can produce; \
1797                 the ids past the cap were not reported individually"
1798            );
1799        }
1800    }
1801
1802    /// Forget that a peer replicates documents.
1803    ///
1804    /// The capability and the offer window are two halves of one fact, and
1805    /// they have to be dropped together: a window left behind outlives the
1806    /// capability and then suppresses the first offer to the same peer once
1807    /// it is relearned, which is a document that silently does not sync.
1808    pub(crate) fn forget_data_sync_peer(&mut self, peer: &str) {
1809        self.peer_data_sync.remove(peer);
1810        self.peer_data_group.remove(peer);
1811        self.peer_data_group_attested.remove(peer);
1812        self.peer_data_media.remove(peer);
1813        // A peer we have stopped replicating with cannot answer anything we
1814        // asked them for, so the questions go too. Left behind they would
1815        // hold slots against the fetch bound until they timed out.
1816        #[cfg(feature = "data")]
1817        self.forget_attachment_state(peer);
1818        #[cfg(feature = "data")]
1819        self.forget_data_sync_offer_windows(peer);
1820    }
1821
1822    /// [`Self::forget_data_sync_peer`] for every peer at once.
1823    pub(crate) fn forget_every_data_sync_peer(&mut self) {
1824        self.peer_data_sync.clear();
1825        self.peer_data_group.clear();
1826        self.peer_data_group_attested.clear();
1827        self.peer_data_media.clear();
1828        // Reported, not merely dropped, exactly as the single-peer road
1829        // reports. Nothing the application did reaches this one: the bound
1830        // on remembered peers is hit, a stranger's key package forgets every
1831        // capability at once, and a fetch outstanding at that moment ends
1832        // for a reason its asker has no way to see. Dropping the record
1833        // silently also forecloses the expiry that would have reported it
1834        // later, so the spinner this event exists to end never ends at all.
1835        #[cfg(feature = "data")]
1836        self.end_every_pending_fetch();
1837        #[cfg(not(feature = "data"))]
1838        self.pending_attachment_fetches.clear();
1839        self.blob_request_windows.clear();
1840        #[cfg(feature = "data")]
1841        self.last_data_sync_offer.clear();
1842    }
1843
1844    /// Called when a new neighbor is discovered.
1845    ///
1846    /// When auto key exchange is enabled, this method sends our key package
1847    /// to the newly discovered peer to enable encrypted communication.
1848    ///
1849    /// # Arguments
1850    ///
1851    /// * `peer_id` - The ID of the discovered peer
1852    pub fn on_neighbor_discovered(&mut self, peer_id: &str) {
1853        self.on_neighbor_discovered_via(peer_id, None);
1854    }
1855
1856    /// [`Self::on_neighbor_discovered`] with an optional transport override
1857    /// for the outbox flush (see [`Self::flush_outbox_for_peer_via`]).
1858    /// `unpark_via` is `Some(Internet)` when the relay presence-online edge
1859    /// re-enters discovery through its rescue branch: a message whose
1860    /// forced-internet re-drive just failed sits in the retry queue with no
1861    /// pending ACK, so this inner flush would pick it up again — and
1862    /// without the override DORS could route it into the mesh void with the
1863    /// park counter already cleared, past re-park's reach.
1864    fn on_neighbor_discovered_via(&mut self, peer_id: &str, unpark_via: Option<TransportType>) {
1865        // Don't track ourselves
1866        if peer_id == self.local_id {
1867            return;
1868        }
1869
1870        // Don't track or auto-exchange keys with blocked users
1871        if self.is_user_blocked(peer_id) {
1872            debug!(peer_id = %peer_id, "Ignoring neighbor discovery for blocked user");
1873            return;
1874        }
1875
1876        // Track discovered peers for service discovery and routing. Existing
1877        // peers get their last-seen refreshed; a new peer at capacity evicts
1878        // the least-recently-seen entry so a genuinely present neighbor is
1879        // never locked out by stale message-path senders (issue #140).
1880        if let Some(last_seen) = self.known_peers.get_mut(peer_id) {
1881            *last_seen = Instant::now();
1882        } else {
1883            if self.known_peers.len() >= MAX_KNOWN_PEERS {
1884                if let Some(victim) = self
1885                    .known_peers
1886                    .iter()
1887                    .min_by_key(|(_, seen)| **seen)
1888                    .map(|(id, _)| id.clone())
1889                {
1890                    debug!(peer_id = %peer_id, evicted = %victim, cap = MAX_KNOWN_PEERS, "Known peers at capacity, evicting least-recently-seen");
1891                    self.evict_known_peer(&victim);
1892                }
1893            }
1894            self.known_peers.insert(peer_id.to_string(), Instant::now());
1895        }
1896
1897        // Flush any pending outbox messages destined for this peer
1898        self.flush_outbox_for_peer_via(peer_id, unpark_via);
1899
1900        // A Welcome that stalled or expired while this peer was unreachable now
1901        // has a fresh delivery opportunity over the carrier that surfaced this
1902        // peer — re-arm it. No-op when there is no pending Welcome for the peer.
1903        self.rearm_welcome_for_peer(peer_id, "peer_rediscovered");
1904
1905        // Offer document versions while the peer is in front of us. This has
1906        // to sit above the key-package guard below, which returns early for
1907        // any peer we have already exchanged with — that is to say, for
1908        // exactly the established peers whose documents drifted apart during
1909        // the partition this discovery just ended.
1910        #[cfg(feature = "data")]
1911        self.kick_data_sync(peer_id, "peer_rediscovered");
1912
1913        // The same reconciliation for every group space shared with this
1914        // peer. A group space cannot ride the 1:1 trigger above: two
1915        // members of a group need not have a session with each other at
1916        // all, so for them this discovery is the only moment anything knows
1917        // they are both reachable.
1918        #[cfg(feature = "data")]
1919        self.kick_shared_group_data_sync(peer_id, "peer_rediscovered");
1920
1921        // Only send key package if encryption is enabled and auto key exchange is on
1922        if !self.config.encryption.enabled || !self.config.encryption.auto_key_exchange {
1923            return;
1924        }
1925
1926        // Only send once per peer
1927        if self.key_package_sent_to.contains(peer_id) {
1928            return;
1929        }
1930
1931        // Only if MLS is initialized
1932        if self.mls_manager.is_none() {
1933            return;
1934        }
1935
1936        if let Err(e) = self.send_key_package_to(peer_id, false) {
1937            warn!(error = %e, peer_id = %peer_id, "Failed to send key package on discovery");
1938        }
1939    }
1940
1941    /// Immediately attempts to send all pending outbox messages destined
1942    /// for a specific peer, bypassing backoff timers. Called on per-peer
1943    /// reachability edges (discovery, relay presence-online) to flush
1944    /// messages that were queued while the peer was unreachable.
1945    ///
1946    /// `unpark_via` is an optional transport override for the re-driven
1947    /// sends: `Some(Internet)` on the relay presence-online edge
1948    /// (`on_peer_presence`, including its rescue branch's re-entry through
1949    /// `on_neighbor_discovered_via`): the reachability
1950    /// proof is relay-scoped, so the re-drive must go out over the internet
1951    /// transport — DORS could otherwise route it back into the mesh, where
1952    /// the send locally succeeds against a peer that is not there,
1953    /// re-registering an unanswerable ACK that re-strands the message (and,
1954    /// with the platform bridge unwatching the peer on the online answer,
1955    /// no further presence edge would arrive to save it). Pinned media
1956    /// transports still win over the override.
1957    fn flush_outbox_for_peer_via(&mut self, peer_id: &str, unpark_via: Option<TransportType>) {
1958        // Probe ACKs first: with a live park counter, the peer's DMs may be
1959        // mid-probe — awaiting a probe ACK that may never arrive (never, over
1960        // a mesh carrier) — and the awaiting-ACK filter below would skip
1961        // exactly the messages this edge exists to re-drive.
1962        self.cancel_probe_state_for_parked_peer(peer_id);
1963        // A per-peer reachability edge: the escalating unreachable-probe
1964        // interval starts over (mirrors rearm_welcome_for_peer resetting
1965        // unreachable_parks). Snapshotted, not dropped: an edge whose
1966        // re-drives all fail didn't actually take, and restores it below.
1967        let prior_parks = self.dm_unreachable_parks.remove(peer_id);
1968
1969        // Collect matching messages from outbox + media_outbox
1970        let mut to_send: Vec<(Message, u32)> = Vec::new();
1971
1972        for entry in self.outbox.values() {
1973            if entry.message.recipient.as_str() == peer_id
1974                && !self.ack_manager.is_waiting_for_ack(&entry.message.id)
1975            {
1976                to_send.push((entry.message.clone(), entry.attempt_count));
1977            }
1978        }
1979        for entry in self.media_outbox.values() {
1980            if entry.message.recipient.as_str() == peer_id
1981                && !self.ack_manager.is_waiting_for_ack(&entry.message.id)
1982            {
1983                to_send.push((entry.message.clone(), entry.attempt_count));
1984            }
1985        }
1986
1987        if to_send.is_empty() {
1988            return;
1989        }
1990
1991        let count = to_send.len().min(crate::constants::FLUSH_BATCH_LIMIT);
1992        debug!(peer_id = %peer_id, count = count, "Flushing outbox for discovered peer");
1993
1994        let mut iter = to_send.into_iter();
1995        let mut parked_dm_seen = false;
1996        let mut parked_dm_redriven = false;
1997
1998        // Only process up to FLUSH_BATCH_LIMIT messages per edge.
1999        for (message, attempt_count) in iter.by_ref().take(crate::constants::FLUSH_BATCH_LIMIT) {
2000            let parkable = self.is_parkable_plain_dm(&message.id);
2001            // Remove from retry queue since we're sending immediately
2002            self.retry_queue.remove(&message.id.as_str());
2003            let sent = self.try_flush_send_via(message, attempt_count, unpark_via);
2004            if parkable {
2005                parked_dm_seen = true;
2006                parked_dm_redriven |= sent.is_some();
2007            }
2008        }
2009
2010        // Re-enqueue the overflow (mirrors flush_outbox_all): the probe
2011        // cancel above may have stripped a parked message's retry entry, so
2012        // "still holds its backoff timer" cannot be assumed past the batch
2013        // limit. Enqueue dedupes by id — entries still scheduled keep their
2014        // existing timer, canceled ones get a fresh backoff slot instead of
2015        // stranding edge-only with the park counter cleared.
2016        for (message, attempt_count) in iter {
2017            parked_dm_seen = parked_dm_seen || self.is_parkable_plain_dm(&message.id);
2018            let _ = self.retry_queue.enqueue(message, attempt_count);
2019        }
2020
2021        // An edge that re-drove no parkable DM at all didn't actually take:
2022        // the DMs now sit in the retry queue, whose later sends are
2023        // DORS-routed (no transport override there), and a send that succeeds
2024        // locally without reaching the peer would re-register an ACK nothing
2025        // will answer. Restore the counter so exhaustion stays within
2026        // `try_repark_exhausted_dm`'s reach instead of settling terminally
2027        // with the counter cleared.
2028        //
2029        // Restore granularity is per-peer, matching the counter: one
2030        // successful re-drive clears it even when a sibling's send failed on
2031        // the same edge, leaving that sibling on DORS-routed backoff exposed
2032        // to terminal exhaustion. Accepted gap — it requires one transport
2033        // to both succeed and fail within a single flush loop.
2034        if let Some(parks) = prior_parks {
2035            if parked_dm_seen && !parked_dm_redriven {
2036                self.dm_unreachable_parks.insert(peer_id.to_string(), parks);
2037            }
2038        }
2039    }
2040
2041    /// Immediately attempts to send all pending outbox messages across all peers.
2042    ///
2043    /// Called when a transport becomes available (e.g. internet reconnects) to
2044    /// flush all queued messages, bypassing backoff timers.
2045    pub fn flush_outbox_all(&mut self) {
2046        // Probe ACKs first (see `flush_outbox_for_peer_via`): mid-probe DMs
2047        // are awaiting unanswerable mesh ACKs, and the awaiting-ACK guard
2048        // below would strand them past this edge with their counters
2049        // cleared. Canceled entries surface through the stranded-outbox
2050        // collection.
2051        let parked_peers: Vec<String> = self.dm_unreachable_parks.keys().cloned().collect();
2052        for peer_id in parked_peers {
2053            self.cancel_probe_state_for_parked_peer(&peer_id);
2054        }
2055        // A carrier-level reachability edge (reconnect / start): every
2056        // escalating unreachable-probe interval starts over. Snapshotted,
2057        // not dropped: a peer whose re-drives all fail gets its counter
2058        // restored below (see `flush_outbox_for_peer_via`).
2059        let prior_parks = std::mem::take(&mut self.dm_unreachable_parks);
2060
2061        // Drain all entries from the retry queue (ignores timing)
2062        let retry_entries = self.retry_queue.drain_all();
2063
2064        // Also collect outbox entries NOT in the retry queue (stranded after
2065        // previous max_retries rejection)
2066        let mut all_messages: Vec<(Message, u32)> = retry_entries
2067            .into_iter()
2068            .map(|e| (e.message, e.retry_count))
2069            .collect();
2070
2071        // Add outbox entries that weren't in the retry queue (stranded) AND
2072        // aren't already waiting for an ACK (successfully sent, just awaiting
2073        // confirmation). Without the ACK check, a prior flush that succeeded
2074        // would cause these messages to be re-sent unnecessarily.
2075        let retry_ids: std::collections::HashSet<String> =
2076            all_messages.iter().map(|(m, _)| m.id.as_str()).collect();
2077
2078        for entry in self.outbox.values() {
2079            if !retry_ids.contains(&entry.message.id.as_str())
2080                && !self.ack_manager.is_waiting_for_ack(&entry.message.id)
2081            {
2082                all_messages.push((entry.message.clone(), entry.attempt_count));
2083            }
2084        }
2085        for entry in self.media_outbox.values() {
2086            if !retry_ids.contains(&entry.message.id.as_str())
2087                && !self.ack_manager.is_waiting_for_ack(&entry.message.id)
2088            {
2089                all_messages.push((entry.message.clone(), entry.attempt_count));
2090            }
2091        }
2092
2093        if all_messages.is_empty() {
2094            return;
2095        }
2096
2097        let count = all_messages.len().min(crate::constants::FLUSH_BATCH_LIMIT);
2098        debug!(
2099            count = count,
2100            total = all_messages.len(),
2101            "Flushing all outbox messages"
2102        );
2103
2104        let mut iter = all_messages.into_iter();
2105        let mut parked_dm_seen: std::collections::HashSet<String> =
2106            std::collections::HashSet::new();
2107        let mut parked_dm_redriven: std::collections::HashSet<String> =
2108            std::collections::HashSet::new();
2109
2110        for (message, attempt_count) in iter.by_ref().take(crate::constants::FLUSH_BATCH_LIMIT) {
2111            let recipient = message.recipient.as_str().to_string();
2112            let parkable =
2113                prior_parks.contains_key(&recipient) && self.is_parkable_plain_dm(&message.id);
2114            let sent_via = self.try_flush_send(message, attempt_count);
2115            if parkable {
2116                // Unlike the per-peer edges (discovery carries mesh-scoped
2117                // proof, presence-online forces the internet transport), this
2118                // carrier-level edge re-drives via DORS with no per-peer
2119                // reachability proof: a mesh-routed send "locally succeeds"
2120                // into the mesh whether or not the peer is there, so only an
2121                // internet-routed send — the one that can earn a relay
2122                // verdict — counts as re-driven for counter-clearing. A
2123                // mesh-routed success keeps the counter restore-eligible;
2124                // if its ACK genuinely arrives, delivery prunes the counter.
2125                if sent_via == Some(TransportType::Internet) {
2126                    parked_dm_redriven.insert(recipient);
2127                } else {
2128                    parked_dm_seen.insert(recipient);
2129                }
2130            }
2131        }
2132
2133        // Re-enqueue any messages beyond the batch limit so they aren't lost
2134        for (message, attempt_count) in iter {
2135            let recipient = message.recipient.as_str();
2136            if prior_parks.contains_key(recipient) && self.is_parkable_plain_dm(&message.id) {
2137                parked_dm_seen.insert(recipient.to_string());
2138            }
2139            let _ = self.retry_queue.enqueue(message, attempt_count);
2140        }
2141
2142        // Restore the counter of every parked peer none of whose DMs were
2143        // re-driven on this edge (see `flush_outbox_for_peer_via`): their
2144        // retry-queue sends are DORS-routed, and a send that succeeds locally
2145        // without reaching the peer must leave exhaustion re-parkable rather
2146        // than terminal.
2147        for (peer_id, parks) in prior_parks {
2148            if parked_dm_seen.contains(&peer_id) && !parked_dm_redriven.contains(&peer_id) {
2149                self.dm_unreachable_parks.insert(peer_id, parks);
2150            }
2151        }
2152    }
2153
2154    /// Peers the platform layer should watch via relay `CheckPresence`
2155    /// queries: every peer with an undelivered or session-unproven MLS
2156    /// welcome ([`Self::welcome_pending_peers`]) plus every recipient of an
2157    /// outbox message not currently awaiting an ACK — which includes DMs
2158    /// parked on a relay `recipient_unreachable` verdict. The relay's
2159    /// presence-online answer lands in [`Self::on_peer_presence`], whose
2160    /// `flush_outbox_for_peer_via` is what re-drives (un-parks) those messages,
2161    /// so the SDK — not the app — owns the "watch my DeliveryError
2162    /// recipients" duty. Bounded by the outbox cap (500 entries).
2163    pub fn presence_watch_peers(&self) -> Vec<String> {
2164        let mut peers = self.welcome_pending_peers();
2165        let mut seen: std::collections::HashSet<String> = peers.iter().cloned().collect();
2166        for entry in self.outbox.values() {
2167            if self.ack_manager.is_waiting_for_ack(&entry.message.id) {
2168                continue;
2169            }
2170            let recipient = entry.message.recipient.as_str();
2171            if seen.insert(recipient.to_string()) {
2172                peers.push(recipient.to_string());
2173            }
2174        }
2175        peers
2176    }
2177
2178    /// Attempts to send a single message as part of a flush operation.
2179    ///
2180    /// Ensures the outbox entry exists before sending (it may have been evicted
2181    /// by capacity limits while the message sat in the retry queue). On success,
2182    /// registers ACK tracking and updates the outbox entry. On failure,
2183    /// re-enqueues the message to the retry queue with its current attempt count
2184    /// so backoff resumes. Returns the transport the send went out over, or
2185    /// `None` when it failed (or, defensively, when the transport could not
2186    /// be attributed — the park restore logic treats both as not-redriven,
2187    /// the safe direction; in practice a successful send always has one).
2188    fn try_flush_send(&mut self, message: Message, attempt_count: u32) -> Option<TransportType> {
2189        self.try_flush_send_via(message, attempt_count, None)
2190    }
2191
2192    /// [`Self::try_flush_send`] with an optional transport override (see
2193    /// [`Self::flush_outbox_for_peer_via`]). A pinned media transport takes
2194    /// precedence over the override; without either, DORS picks.
2195    fn try_flush_send_via(
2196        &mut self,
2197        mut message: Message,
2198        attempt_count: u32,
2199        transport_override: Option<TransportType>,
2200    ) -> Option<TransportType> {
2201        self.ensure_outbox_entry(&message);
2202        // Tier 2: re-seal against the peer's current session before flushing, so
2203        // a post-re-key epoch change no longer leaves the bytes undecryptable.
2204        // No-op for media/plaintext/unconfirmed sessions.
2205        self.reseal_for_resend_in_place(&mut message);
2206        let forced_transport = self
2207            .pinned_media_transport_for_message(&message.id)
2208            .or(transport_override);
2209        let send_result = if let Some(transport) = forced_transport {
2210            self.transport_manager
2211                .send_via_transport(&message, transport)
2212        } else {
2213            self.transport_manager.send(&message)
2214        };
2215        let current_transport =
2216            forced_transport.or_else(|| self.transport_manager.current_transport());
2217
2218        match send_result {
2219            Ok(()) => {
2220                if let Err(e) = self.ensure_ack_registration(&message) {
2221                    warn!(message_id = %message.id, error = %e, "ACK registration failed during flush");
2222                }
2223                self.mark_message_sent(
2224                    &message,
2225                    current_transport,
2226                    Some(attempt_count.saturating_add(1)),
2227                );
2228                debug!(message_id = %message.id, "Flush send succeeded");
2229                current_transport
2230            }
2231            Err(e) => {
2232                let _ = self.retry_queue.enqueue(message.clone(), attempt_count);
2233                debug!(message_id = %message.id, error = %e, "Flush send failed, re-enqueued");
2234                None
2235            }
2236        }
2237    }
2238
2239    /// Called when a neighbor is lost.
2240    ///
2241    /// Cleans up tracking state for the lost peer.
2242    ///
2243    /// # Arguments
2244    ///
2245    /// * `peer_id` - The ID of the lost peer
2246    pub fn on_neighbor_lost(&mut self, peer_id: &str) {
2247        self.evict_known_peer(peer_id);
2248    }
2249
2250    /// Drops a peer from discovery tracking. Shared by explicit neighbor
2251    /// loss, the TTL sweep, and least-recently-seen eviction at capacity —
2252    /// all three mean "treat this peer as gone until re-seen", so all three
2253    /// also clear the key-package marker, letting a re-appearing peer
2254    /// receive a fresh key package exactly as on a BLE reconnect.
2255    fn evict_known_peer(&mut self, peer_id: &str) {
2256        self.key_package_sent_to.remove(peer_id);
2257        self.known_peers.remove(peer_id);
2258        // Release the forwarding allowance held for this link. A peer that
2259        // reconnects starts with a full allowance, which is correct: the limit
2260        // exists to pace a live link, not to punish one that came back.
2261        self.mesh_relay.forget_peer(peer_id);
2262    }
2263
2264    /// Establishes a secure MLS session with a peer.
2265    ///
2266    /// This high-level method handles the complete session establishment flow:
2267    /// 1. Checks if a session already exists (returns Ok(None) if so)
2268    /// 2. Checks for a pending key package from the peer
2269    /// 3. If found, imports the key package, creates the session, and sends the Welcome message
2270    /// 4. If no key package is available, returns an error
2271    ///
2272    /// This method is designed for use by application code that needs explicit control
2273    /// over session establishment, as opposed to the automatic encryption flow.
2274    ///
2275    /// # Arguments
2276    ///
2277    /// * `peer_id` - The ID of the peer to establish a session with
2278    ///
2279    /// # Returns
2280    ///
2281    /// * `Ok(Some(WelcomeMessage))` - Session created, Welcome message returned (and sent to peer)
2282    /// * `Ok(None)` - Session already exists
2283    /// * `Err(SessionNotReady(state))` - Establishment in progress; caller can retry or show "Establishing…"
2284    pub fn establish_secure_session(&mut self, peer_id: &str) -> Result<Option<WelcomeMessage>> {
2285        if self.is_user_blocked(peer_id) {
2286            return Err(Error::UserBlocked(peer_id.to_string()));
2287        }
2288
2289        let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
2290
2291        // Check if session already exists
2292        let has_session = {
2293            let manager = mls
2294                .read()
2295                .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2296            manager.has_session(peer_id)?
2297        };
2298
2299        if has_session {
2300            debug!(peer_id = %peer_id, "Session already exists");
2301            return Ok(None);
2302        }
2303
2304        // Try loading key package from storage (e.g. after restart) before giving up
2305        self.try_load_key_package_from_storage_into_memory(peer_id);
2306
2307        // Check for pending key package (memory, possibly just restored from storage)
2308        // Clone first, only remove after all operations succeed to avoid losing the key package on failure
2309        if let Some(received_pkg) = self.pending_key_packages.get(peer_id).cloned() {
2310            // Check if key package has expired (using local clock)
2311            let now_ms = Utc::now().timestamp_millis() as u64;
2312            if now_ms >= received_pkg.local_expires_at_ms {
2313                warn!(peer_id = %peer_id, "Received key package has expired, discarding");
2314                self.pending_key_packages.remove(peer_id);
2315                self.delete_peer_key_package_from_storage(peer_id);
2316            } else {
2317                {
2318                    let manager = mls
2319                        .read()
2320                        .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2321                    manager.import_key_package(peer_id, &received_pkg.key_package_data)?;
2322                }
2323
2324                // Create session and get welcome message
2325                let welcome = {
2326                    let manager = mls
2327                        .read()
2328                        .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2329                    manager.create_session(peer_id)?
2330                };
2331                // We only ever build a session from a key package the peer
2332                // published, so reaching here proves they run MLS.
2333                self.mark_encryption_capable(peer_id);
2334
2335                // Send welcome message to peer
2336                let welcome_sent = self.send_welcome_message(peer_id, &welcome)?;
2337
2338                // All operations succeeded, now safe to remove the key package
2339                self.pending_key_packages.remove(peer_id);
2340                self.delete_peer_key_package_from_storage(peer_id);
2341
2342                let group_id = welcome.group_id.as_str().to_string();
2343                let is_session = group_id.starts_with("session:");
2344                if let Err(err) = self.ensure_session_state_entry(peer_id, "session_created_local")
2345                {
2346                    warn!(
2347                        peer_id = %peer_id,
2348                        error = %err,
2349                        "Failed to persist pending session state"
2350                    );
2351                }
2352
2353                info!(peer_id = %peer_id, group_id = %group_id, "Established secure session");
2354
2355                // Send a fresh key package so the peer has one available for
2356                // group invites (the original was consumed to create this session).
2357                if self.config.encryption.enabled {
2358                    let _ = self.send_key_package_to(peer_id, false);
2359                }
2360
2361                if welcome_sent {
2362                    debug!(peer_id = %peer_id, group_id = %group_id, is_session, "Welcome synchronously sent");
2363                }
2364
2365                return Ok(Some(welcome));
2366            }
2367        }
2368
2369        // No key package available (memory nor storage) — return non-terminal state so caller can retry
2370        Err(Error::SessionNotReady(self.establishment_state(peer_id)?))
2371    }
2372
2373    /// Checks if a pending key package is available for a peer.
2374    ///
2375    /// This can be used to check if session establishment is possible
2376    /// before calling `establish_secure_session`.
2377    ///
2378    /// # Arguments
2379    ///
2380    /// * `peer_id` - The ID of the peer to check
2381    ///
2382    /// # Returns
2383    ///
2384    /// `true` if a key package is available, `false` otherwise
2385    pub fn has_pending_key_package(&self, peer_id: &str) -> bool {
2386        self.pending_key_packages.contains_key(peer_id)
2387    }
2388
2389    /// Returns the current establishment state for a peer.
2390    ///
2391    /// Use this to show "Establishing…" or drive retries without calling send/establish.
2392    pub fn get_establishment_state(&self, peer_id: &str) -> Result<EstablishmentState> {
2393        self.establishment_state(peer_id)
2394    }
2395
2396    /// Whether rich extras (reply context, rich media metadata, forward
2397    /// attribution) on a send toward `peer_id` would travel in the sealed
2398    /// rich body — i.e. the peer's last key package advertised the
2399    /// capability (surviving restarts) and our own `rich_payload_enabled`
2400    /// kill switch is on. When `false` the extras are silently dropped and
2401    /// the message degrades to plain text, so apps can use this to render
2402    /// honest degraded-reply UX instead of discovering the drop after the
2403    /// fact.
2404    pub fn peer_supports_rich_payload(&self, peer_id: &str) -> bool {
2405        self.rich_seal_active(peer_id)
2406    }
2407
2408    /// Creates a session using manually imported key material.
2409    ///
2410    /// This entrypoint is for bindings that expose low-level MLS APIs. It must
2411    /// still keep protocol-level session lifecycle state in sync with MLS state.
2412    pub fn manual_mls_create_session(&mut self, peer_id: &str) -> Result<WelcomeMessage> {
2413        let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
2414        let welcome = {
2415            let manager = mls
2416                .read()
2417                .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2418            manager.create_session(peer_id)?
2419        };
2420        self.mark_encryption_capable(peer_id);
2421        if let Err(err) = self.ensure_session_state_entry(peer_id, "manual_session_created") {
2422            warn!(
2423                peer_id = %peer_id,
2424                error = %err,
2425                "Failed to persist pending session state after manual session create"
2426            );
2427        }
2428        Ok(welcome)
2429    }
2430
2431    /// Imports a contact's key package and records them encryption-capable.
2432    ///
2433    /// The entry point for bindings that expose low-level MLS APIs. It exists
2434    /// because the FFI wrapper used to reach straight for the `MlsManager` and
2435    /// pass the since-deleted `KeyPackageTrust::FirstUse` — so an app could
2436    /// import a key package
2437    /// under any peer id with no correlation to that peer, bypassing the check
2438    /// `establish_secure_session` performs. The identity claim no longer needs
2439    /// the detour: `import_key_package` re-derives the address from the leaf
2440    /// signature key and refuses a mismatch on its own. The routing stays
2441    /// because the capability record lives here, not on the manager.
2442    pub fn manual_mls_import_key_package(
2443        &mut self,
2444        peer_id: &str,
2445        key_package_data: &[u8],
2446    ) -> Result<()> {
2447        let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
2448        {
2449            let manager = mls
2450                .read()
2451                .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2452            manager.import_key_package(peer_id, key_package_data)?;
2453        }
2454        // A key package we accepted for this peer is proof they run MLS.
2455        self.mark_encryption_capable(peer_id);
2456        Ok(())
2457    }
2458
2459    /// Deletes a 1:1 session and clears protocol-level lifecycle state.
2460    pub fn manual_mls_delete_session(&mut self, peer_id: &str) -> Result<()> {
2461        let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
2462        let manager = mls
2463            .read()
2464            .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2465        manager.delete_session(peer_id)?;
2466
2467        // Apply protocol-state cleanup only after MLS deletion succeeds.
2468        self.confirmed_sessions.remove(peer_id);
2469        self.clear_confirmation_recovery_tracking(peer_id);
2470        self.welcome_lifecycles.remove(peer_id);
2471        // Drop the both-create owner gate too (memory + storage). A leaked gate
2472        // entry would make `can_confirm_from_source` reject every non-decrypt
2473        // confirmation source — including `welcome_received` — on the NEXT
2474        // session with this peer, re-stranding it in Pending (and surviving
2475        // restart via `restore_both_create_awaiting_decrypt`).
2476        self.clear_both_create_awaiting_decrypt(peer_id);
2477        self.clear_session_state_entry(peer_id)?;
2478        self.clear_welcome_lifecycle_entry(peer_id)?;
2479        Ok(())
2480    }
2481
2482    /// Joins a session from a Welcome message and synchronizes confirmation state.
2483    pub fn manual_mls_join_session(
2484        &mut self,
2485        welcome: &WelcomeMessage,
2486    ) -> Result<offline_protocol_mls::GroupInfo> {
2487        let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
2488        let group_info = {
2489            let manager = mls
2490                .read()
2491                .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2492            manager.join_session(welcome)?
2493        };
2494        self.handle_manual_welcome_confirmation(&welcome.inviter_id);
2495        Ok(group_info)
2496    }
2497
2498    /// Processes an MLS Welcome message and synchronizes confirmation state.
2499    pub fn manual_mls_process_welcome(
2500        &mut self,
2501        welcome: &WelcomeMessage,
2502    ) -> Result<offline_protocol_mls::GroupInfo> {
2503        let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
2504        let group_info = {
2505            let manager = mls
2506                .read()
2507                .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2508            manager.process_welcome(welcome)?
2509        };
2510        self.handle_manual_welcome_confirmation(&welcome.inviter_id);
2511        Ok(group_info)
2512    }
2513
2514    /// Decrypts a user-scoped MLS message and synchronizes confirmation state.
2515    pub fn manual_mls_decrypt_from_user(
2516        &mut self,
2517        encrypted: &EncryptedMessage,
2518    ) -> Result<Option<Vec<u8>>> {
2519        let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
2520        let plaintext = {
2521            let manager = mls
2522                .read()
2523                .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2524            manager.decrypt_from_user(encrypted, &encrypted.sender_id)?
2525        };
2526        if plaintext.is_some() {
2527            self.handle_manual_decrypt_confirmation(&encrypted.sender_id);
2528        }
2529        Ok(plaintext)
2530    }
2531
2532    /// Decrypts any MLS message and synchronizes confirmation state for 1:1 flows.
2533    pub fn manual_mls_decrypt(&mut self, encrypted: &EncryptedMessage) -> Result<Option<Vec<u8>>> {
2534        let mls = self.mls_manager.clone().ok_or(Error::MlsNotInitialized)?;
2535        let plaintext = {
2536            let manager = mls
2537                .read()
2538                .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2539            manager.decrypt(encrypted, &encrypted.sender_id)?
2540        };
2541        if plaintext.is_some() && Self::is_session_group_id(encrypted.group_id.as_str()) {
2542            self.handle_manual_decrypt_confirmation(&encrypted.sender_id);
2543        }
2544        Ok(plaintext)
2545    }
2546
2547    pub(crate) fn is_session_group_id(group_id: &str) -> bool {
2548        group_id.starts_with("session:")
2549    }
2550
2551    fn handle_manual_welcome_confirmation(&mut self, peer_id: &str) {
2552        // Shared by `manual_mls_join_session` and `manual_mls_process_welcome`:
2553        // in both cases we just joined a session this peer built for us.
2554        self.mark_encryption_capable(peer_id);
2555        match self.confirm_session_state(peer_id, "welcome_received") {
2556            Ok(true) => {
2557                let _ = self.flush_pending_messages(peer_id);
2558                self.process_pending_decryption(peer_id);
2559            }
2560            Ok(false) => {}
2561            Err(err) => {
2562                warn!(
2563                    peer_id = %peer_id,
2564                    error = %err,
2565                    "Failed to persist session confirmation after manual welcome processing"
2566                );
2567            }
2568        }
2569    }
2570
2571    fn handle_manual_decrypt_confirmation(&mut self, peer_id: &str) {
2572        if !self.can_confirm_from_source(peer_id, "decrypt_success") {
2573            return;
2574        }
2575        match self.confirm_session_state(peer_id, "decrypt_success") {
2576            Ok(true) => {
2577                let _ = self.flush_pending_messages(peer_id);
2578            }
2579            Ok(false) => {}
2580            Err(err) => {
2581                warn!(
2582                    peer_id = %peer_id,
2583                    error = %err,
2584                    "Failed to persist session confirmation after manual decrypt"
2585                );
2586            }
2587        }
2588    }
2589
2590    /// Processes internal MLS protocol messages.
2591    ///
2592    /// Returns `Some(InternalMessageResult::Consumed)` if the message was an internal
2593    /// protocol message that should not be surfaced to the application.
2594    /// Returns `Some(InternalMessageResult::Decrypted(plaintext))` if the message was
2595    /// encrypted and successfully decrypted.
2596    /// Returns `None` if the message is not an internal message.
2597    pub(crate) fn process_internal_message(
2598        &mut self,
2599        message: &Message,
2600    ) -> Option<InternalMessageResult> {
2601        self.process_internal_message_via(message, None)
2602    }
2603
2604    /// [`Self::process_internal_message`] with the transport the frame
2605    /// arrived on, when the caller knows it. Handlers that trust relay-server
2606    /// answers (`__GROUP_CREATED__` setting `relay_synced`) require the
2607    /// Internet transport; with `None` they treat the frame as untrusted.
2608    pub(crate) fn process_internal_message_via(
2609        &mut self,
2610        message: &Message,
2611        arrival_transport: Option<TransportType>,
2612    ) -> Option<InternalMessageResult> {
2613        let content = &message.content;
2614
2615        // Run the security gate for control messages (transport identity +
2616        // signature verification). `Rejected` drops the message; `Proceed`
2617        // carries whether the frame was actually signed, for the one handler
2618        // that consumes a payload field as authenticated data.
2619        let signed = match self.security_gate_control_message(message, arrival_transport) {
2620            ControlGateOutcome::Rejected(result) => return Some(result),
2621            ControlGateOutcome::Proceed { signed } => signed,
2622        };
2623
2624        let sender = message.sender.as_str();
2625
2626        // Handle key package messages
2627        if let Some(data) = content.strip_prefix(internal_prefixes::KEY_PACKAGE) {
2628            self.handle_key_package_message(sender, data, signed);
2629            return Some(InternalMessageResult::Consumed);
2630        }
2631
2632        if content.starts_with(internal_prefixes::SESSION_CONFIRM_PROBE) {
2633            self.handle_session_confirm_probe(sender, content);
2634            return Some(InternalMessageResult::Consumed);
2635        }
2636
2637        if content.starts_with(internal_prefixes::SESSION_CONFIRM_ACK) {
2638            self.handle_session_confirm_ack(sender, content);
2639            return Some(InternalMessageResult::Consumed);
2640        }
2641
2642        // Handle welcome messages (session invitation)
2643        if let Some(data) = content.strip_prefix(internal_prefixes::WELCOME) {
2644            self.handle_welcome_message(sender, data);
2645            return Some(InternalMessageResult::Consumed);
2646        }
2647
2648        // Handle encrypted messages
2649        if let Some(data) = content.strip_prefix(internal_prefixes::ENCRYPTED) {
2650            if let Some(result) =
2651                self.handle_encrypted_message(sender, data, message, arrival_transport)
2652            {
2653                return Some(result);
2654            }
2655            return Some(InternalMessageResult::Consumed);
2656        }
2657
2658        // Handle connection request messages
2659        if let Some(data) = content.strip_prefix(internal_prefixes::CONN_REQUEST) {
2660            self.handle_connection_request(sender, data);
2661            return Some(InternalMessageResult::Consumed);
2662        }
2663
2664        // Handle connection accepted messages
2665        if let Some(data) = content.strip_prefix(internal_prefixes::CONN_ACCEPT) {
2666            self.handle_connection_accepted(sender, data);
2667            return Some(InternalMessageResult::Consumed);
2668        }
2669
2670        // Handle connection rejected messages
2671        if content
2672            .strip_prefix(internal_prefixes::CONN_REJECT)
2673            .is_some()
2674        {
2675            self.handle_connection_rejected(sender);
2676            return Some(InternalMessageResult::Consumed);
2677        }
2678
2679        // Handle connection cancelled messages
2680        if content
2681            .strip_prefix(internal_prefixes::CONN_CANCEL)
2682            .is_some()
2683        {
2684            self.handle_connection_cancelled(sender);
2685            return Some(InternalMessageResult::Consumed);
2686        }
2687
2688        // --- Presence, typing, and read receipt messages ---
2689
2690        if let Some(data) = content.strip_prefix(internal_prefixes::PRESENCE) {
2691            self.handle_presence_message(sender, data);
2692            return Some(InternalMessageResult::Consumed);
2693        }
2694
2695        if let Some(data) = content.strip_prefix(internal_prefixes::TYPING_INDICATOR) {
2696            self.handle_typing_indicator(sender, data);
2697            return Some(InternalMessageResult::Consumed);
2698        }
2699
2700        if let Some(data) = content.strip_prefix(internal_prefixes::READ_RECEIPT) {
2701            self.handle_read_receipt(sender, data);
2702            return Some(InternalMessageResult::Consumed);
2703        }
2704
2705        // --- Group (mesh/MLS) messages ---
2706
2707        if let Some(data) = content.strip_prefix(internal_prefixes::GROUP_MLS_MSG) {
2708            return Some(self.handle_group_mls_msg_via(message, sender, data, arrival_transport));
2709        }
2710
2711        if let Some(data) = content.strip_prefix(internal_prefixes::GROUP_MLS_WELCOME) {
2712            let mid = message.id.as_str();
2713            self.handle_group_mls_welcome(&mid, sender, data);
2714            return Some(InternalMessageResult::Consumed);
2715        }
2716
2717        if let Some(data) = content.strip_prefix(internal_prefixes::GROUP_MLS_COMMIT) {
2718            let mid = message.id.as_str();
2719            self.handle_group_mls_commit(&mid, sender, data);
2720            return Some(InternalMessageResult::Consumed);
2721        }
2722
2723        if let Some(data) = content.strip_prefix(internal_prefixes::GROUP_MLS_LEAVE) {
2724            let mid = message.id.as_str();
2725            self.handle_group_mls_leave(&mid, sender, data);
2726            return Some(InternalMessageResult::Consumed);
2727        }
2728
2729        if let Some(data) = content.strip_prefix(internal_prefixes::GROUP_ROLE_CHANGE) {
2730            let mid = message.id.as_str();
2731            self.handle_group_role_change(&mid, sender, data);
2732            return Some(InternalMessageResult::Consumed);
2733        }
2734
2735        if let Some(data) = content.strip_prefix(internal_prefixes::GROUP_RENAME) {
2736            let mid = message.id.as_str();
2737            self.handle_group_rename(&mid, sender, data);
2738            return Some(InternalMessageResult::Consumed);
2739        }
2740
2741        // --- Group (relay) and service messages ---
2742
2743        if content.starts_with(internal_prefixes::GROUP_CREATED)
2744            || content.starts_with(internal_prefixes::GROUP_MSG)
2745            || content.starts_with(internal_prefixes::GROUP_MEMBER_ADDED)
2746            || content.starts_with(internal_prefixes::GROUP_MEMBER_REMOVED)
2747            || content.starts_with(internal_prefixes::GROUP_INFO)
2748            || content.starts_with(internal_prefixes::USER_GROUPS)
2749            || content.starts_with(internal_prefixes::GROUP_ERROR)
2750        {
2751            self.handle_group_relay_message(sender, content, arrival_transport);
2752            return Some(InternalMessageResult::Consumed);
2753        }
2754
2755        // --- Service discovery & request/response ---
2756        if content.starts_with(offline_protocol_services::SVC_MESSAGE_PREFIX) {
2757            self.handle_service_message(sender, content, message);
2758            return Some(InternalMessageResult::Consumed);
2759        }
2760
2761        None // Not an internal message
2762    }
2763
2764    /// Registers an event handler.
2765    ///
2766    /// # Arguments
2767    ///
2768    /// * `handler` - Callback function that will be called for each event
2769    pub fn on_event<F>(&mut self, handler: F)
2770    where
2771        F: Fn(Event) + Send + Sync + 'static,
2772    {
2773        let Ok(mut state) = lock_shared_state(&self.shared_state) else {
2774            error!("Failed to lock shared state in on_event");
2775            return;
2776        };
2777        state.event_handlers.push(Arc::new(handler));
2778    }
2779
2780    /// Processes pending operations (retries, timeouts, etc.).
2781    ///
2782    /// This should be called periodically to handle background tasks.
2783    pub fn process(&mut self) -> Result<()> {
2784        {
2785            let state = lock_shared_state(&self.shared_state)?;
2786            if state.state != ProtocolState::Running {
2787                return Ok(()); // Don't process if not running
2788            }
2789        }
2790
2791        // Transmit forwards whose hold has elapsed. Runs early and before the
2792        // retry ladders: other people's traffic is time-sensitive in a way our
2793        // own retries are not — a frame held too long is dropped rather than
2794        // sent late.
2795        self.flush_mesh_relays();
2796
2797        self.process_retry_queue()?;
2798        self.process_welcome_retry_queue()?;
2799        self.process_relay_register_retries();
2800        self.process_relay_broadcast_report_timeouts();
2801        self.process_timed_out_acks()?;
2802
2803        // Throttle session reconciliation to avoid expensive storage I/O
2804        // (list_sessions → Keychain/Keystore) on every tick. Only run when
2805        // there's pending work AND enough time has elapsed since the last run.
2806        self.run_throttled_reconciliation("process_tick");
2807
2808        let _ = self.prune_expired_pending_global_front(Instant::now(), 256);
2809        self.pump_media_transfers();
2810        self.refresh_nostr_key_package_slots();
2811        self.refresh_nostr_discovery_claim();
2812        // A relay is free never to send end-of-stored-events, and that is the
2813        // only natural completion signal a resolution has. Without this sweep
2814        // a quiet relay turns a lookup into a hang rather than an empty answer.
2815        self.sweep_username_resolutions();
2816        self.cleanup_expired_entries();
2817        self.evaluate_relay_role();
2818        self.tick_telemetry_categories();
2819
2820        Ok(())
2821    }
2822
2823    /// Reports this device's condition to the forwarding governor and announces
2824    /// any change in whether it is acting as a relay (`RelayPromoted`,
2825    /// `RelayDemoted`, `RelayDemotedBattery`).
2826    ///
2827    /// Runs every tick independently of telemetry: these are app-facing events
2828    /// delivered through the `EventCallback` channel, which is live whether or
2829    /// not a telemetry sink is installed. It also keeps the
2830    /// `DeviceCapabilitySnapshot.relay_role` signal honest, since
2831    /// `tick_telemetry_categories` reads the standing immediately after.
2832    ///
2833    /// **The relay standing is observed, not predicted.** It says this device
2834    /// has been carrying other people's traffic, not that its battery and
2835    /// neighbor count suggest it could — those only bias *how much* it carries
2836    /// (`set_conditions`), continuously and with no threshold to cross. So the
2837    /// promotion here is a report about the mesh, and an app can trust
2838    /// `is_relay()` to mean the device is doing the work.
2839    ///
2840    /// Two things still transition on policy rather than on observation, and
2841    /// both are gates rather than predictions: relaying switched off in
2842    /// configuration, and the battery falling through the forwarding floor.
2843    /// Each stops forwarding outright, so the standing is surrendered at once
2844    /// instead of decaying over the activity windows — the window measures
2845    /// quiet, and this is refusal.
2846    ///
2847    /// Unlike the threshold policy this replaces, a device with no battery
2848    /// reading still participates: forwarding is what is being observed, and
2849    /// it happens whether or not the host has reported a level.
2850    fn evaluate_relay_role(&mut self) {
2851        let (_statuses, available) = self.transport_manager.snapshot_status_and_available();
2852        let (battery_level, is_charging) =
2853            device_battery_from_available(self.transport_manager.current_transport(), &available);
2854
2855        let eager = matches!(self.config.relay.relay_priority, RelayPriority::Always);
2856        // The bias ramp is measured from the level at which forwarding actually
2857        // stops, which is the configured minimum raised to the hard critical
2858        // floor — not the raw configured value, which may be set below it.
2859        // Passing the unclamped one would start the ramp under the gate and
2860        // scale a device that is about to stop forwarding altogether.
2861        let forwarding_floor = self.relay_battery_floor(is_charging);
2862        self.mesh_relay
2863            .set_conditions(battery_level, is_charging, eager, forwarding_floor);
2864        let relay = &self.config.relay;
2865
2866        // Relaying switched off in configuration.
2867        if !relay.allow_relay || matches!(relay.relay_priority, RelayPriority::Never) {
2868            if self.mesh_relay.force_inactive() {
2869                self.emit_event(Event::relay_demoted(
2870                    "relaying disabled by configuration".to_string(),
2871                ));
2872            }
2873            return;
2874        }
2875
2876        // Battery through the forwarding floor. Same gate `try_relay_message`
2877        // applies to each frame, evaluated from the reading already in hand.
2878        if !self.battery_allows_relaying_with(battery_level, is_charging) {
2879            if self.mesh_relay.force_inactive() {
2880                self.emit_event(Event::relay_demoted_battery(
2881                    battery_level.unwrap_or(0),
2882                    self.relay_battery_floor(is_charging),
2883                ));
2884            }
2885            return;
2886        }
2887
2888        match self.mesh_relay.observe_activity(Instant::now()) {
2889            Some(RelayActivity::Began { .. }) => {
2890                let connection_count = self.known_peers.len();
2891                self.emit_event(Event::relay_promoted(connection_count, battery_level));
2892            }
2893            Some(RelayActivity::Ceased) => {
2894                self.emit_event(Event::relay_demoted(
2895                    "no traffic carried for other devices recently".to_string(),
2896                ));
2897            }
2898            None => {}
2899        }
2900    }
2901
2902    /// The relay standing this device currently holds, for telemetry.
2903    fn relay_role(&self) -> RelayRole {
2904        if self.mesh_relay.is_active_relay() {
2905            RelayRole::Relay
2906        } else {
2907            RelayRole::Regular
2908        }
2909    }
2910
2911    /// Per-tick telemetry work: diff transport statuses, diff device
2912    /// capability, and emit a `MetricsFrame` when `metrics_cadence` has
2913    /// elapsed. No-ops unless a sink has been installed.
2914    ///
2915    /// Runs at the end of `process()` so reliability/queue state observed
2916    /// here is the same state the rest of the tick has already advanced.
2917    /// Snapshots `get_available_transports()` exactly once per tick and
2918    /// reuses the map across every downstream helper that needs it.
2919    ///
2920    /// If an earlier step in `process()` returns `Err(...)`, this method is
2921    /// skipped and telemetry emission for that interval is deferred to the
2922    /// next successful tick — the cadence guarantee relaxes under sustained
2923    /// partial failure.
2924    ///
2925    /// # Sink-panic semantics
2926    ///
2927    /// Each per-tick cursor is advanced *before* the emit call that
2928    /// observes it, so a panicking sink does not cause re-delivery of the
2929    /// record it panicked on. The transport-status path advances
2930    /// **per-transport**: when multiple transitions fall in a single tick,
2931    /// a panic on transition K commits K's entry (at-most-once for K) but
2932    /// leaves entries L..N in `transport_status_snapshot` at their previous
2933    /// values, so the next tick re-diffs and emits them fresh. This avoids
2934    /// silent data loss for transitions that never reached the sink.
2935    ///
2936    /// The device-capability and metrics-frame paths emit at most one
2937    /// record per tick each, so a simpler "advance then emit" discipline
2938    /// suffices there: a panic advances the cursor and the same record is
2939    /// not re-emitted on the next tick.
2940    fn tick_telemetry_categories(&mut self) {
2941        let Some(ctx) = self.telemetry.clone() else {
2942            return;
2943        };
2944        let now_ms = Utc::now().timestamp_millis();
2945        let now = Instant::now();
2946
2947        // One lock-per-transport pass: get statuses (for the transition
2948        // diff) and the available-only metrics map (for the metrics frame
2949        // and the device-capability diff). Reused across every helper
2950        // below.
2951        let (current_statuses, available) = self.transport_manager.snapshot_status_and_available();
2952
2953        // Transport-status diff: one record per changed transport. Commit
2954        // each transport's new snapshot entry *before* emitting its event
2955        // so a panic on event K is at-most-once for K; untouched entries
2956        // L..N stay at their previous values and get re-diffed next tick.
2957        let transitions =
2958            diff_transport_state(now_ms, &self.transport_status_snapshot, &current_statuses);
2959        for event in transitions {
2960            let transport = event.transport;
2961            match current_statuses.get(&transport) {
2962                Some(status) => {
2963                    self.transport_status_snapshot.insert(transport, *status);
2964                }
2965                None => {
2966                    self.transport_status_snapshot.remove(&transport);
2967                }
2968            }
2969            dispatch_record(&ctx.sink, &TelemetryRecord::TransportState(event));
2970        }
2971
2972        // Device capability diff. At-most-one emission per tick, so the
2973        // simple advance-before-emit pattern preserves at-most-once.
2974        let (battery_level, is_charging) =
2975            device_battery_from_available(self.transport_manager.current_transport(), &available);
2976        let relay_role = self.relay_role();
2977        let device_now = DeviceSnap::from_parts(battery_level, is_charging, relay_role);
2978        let device_change =
2979            diff_device_capability(now_ms, self.device_capability_snapshot, device_now);
2980        self.device_capability_snapshot = Some(device_now);
2981        if let Some(snapshot) = device_change {
2982            dispatch_record(&ctx.sink, &TelemetryRecord::Device(snapshot));
2983        }
2984
2985        // Periodic metrics snapshot. Rearm cadence before emit so a
2986        // panicking sink doesn't cause an immediate retry next tick.
2987        if let Some(cadence) = ctx.config.metrics_cadence() {
2988            let due = match self.last_metrics_emit_at {
2989                None => true,
2990                Some(prev) => now.saturating_duration_since(prev) >= cadence,
2991            };
2992            if due {
2993                let is_local_relay = matches!(relay_role, RelayRole::Relay);
2994                let frame = build_metrics_frame(
2995                    now_ms,
2996                    self.transport_manager.current_transport(),
2997                    &available,
2998                    &self.retry_queue,
2999                    &self.deduplicator,
3000                    &self.ack_manager,
3001                    self.known_peers.len(),
3002                    is_local_relay,
3003                );
3004                self.last_metrics_emit_at = Some(now);
3005                dispatch_record(
3006                    &ctx.sink,
3007                    &TelemetryRecord::MetricsSnapshot(Box::new(frame)),
3008                );
3009            }
3010        }
3011    }
3012
3013    /// Processes messages ready for retry from the retry queue.
3014    ///
3015    /// EDGE CASE HANDLING:
3016    /// - Checks transport availability before each retry attempt
3017    /// - Handles transport switch mid-retry
3018    /// - Properly tracks retry counts and transport failures
3019    fn process_retry_queue(&mut self) -> Result<()> {
3020        // Limit batch size to prevent blocking on large queues
3021        let max_batch_size = crate::constants::FLUSH_BATCH_LIMIT;
3022        let mut processed = 0;
3023
3024        while processed < max_batch_size {
3025            let mut entry = match self.retry_queue.dequeue_ready() {
3026                Some(e) => e,
3027                None => break,
3028            };
3029
3030            processed += 1;
3031            let previous_transport = self.transport_manager.current_transport();
3032            self.ensure_outbox_entry(&entry.message);
3033
3034            // Tier 2: re-seal against the peer's current session before this
3035            // resend, so a post-re-key epoch change no longer leaves the bytes
3036            // undecryptable. No-op for media/plaintext/unconfirmed sessions.
3037            self.reseal_for_resend_in_place(&mut entry.message);
3038
3039            let forced_transport = self.pinned_media_transport_for_message(&entry.message.id);
3040            let send_result = if let Some(transport) = forced_transport {
3041                self.transport_manager
3042                    .send_via_transport(&entry.message, transport)
3043            } else {
3044                self.transport_manager.send(&entry.message)
3045            };
3046            let current_transport =
3047                forced_transport.or_else(|| self.transport_manager.current_transport());
3048
3049            match send_result {
3050                Ok(()) => {
3051                    let ack_registered_now = self.ensure_ack_registration(&entry.message)?;
3052
3053                    if !ack_registered_now {
3054                        self.ack_manager.increment_retry_count(&entry.message.id);
3055                    }
3056                    self.mark_message_sent(
3057                        &entry.message,
3058                        current_transport,
3059                        Some(entry.retry_count.saturating_add(1)),
3060                    );
3061
3062                    if let Some(transport) = current_transport {
3063                        self.transport_manager.reset_retry_count(transport);
3064                    }
3065
3066                    debug!(
3067                        message_id = %entry.message.id,
3068                        retry_count = entry.retry_count,
3069                        transport = ?current_transport,
3070                        "Retry send succeeded"
3071                    );
3072                }
3073                Err(e) => {
3074                    // Re-enqueue with incremented retry count for backoff
3075                    let next_retry_at = self
3076                        .retry_queue
3077                        .enqueue(entry.message.clone(), entry.retry_count + 1);
3078
3079                    if let Some(transport) = forced_transport.or(previous_transport) {
3080                        self.transport_manager.record_retry_failure(transport);
3081                    }
3082
3083                    // Surface the schedule so apps can show retry state
3084                    // instead of inferring it from silence. None = the id
3085                    // was already queued; the earlier emission stands.
3086                    if let Some(retry_at) = next_retry_at {
3087                        self.emit_event(Event::message_retrying(
3088                            entry.message.id.clone(),
3089                            entry.message.recipient.as_str().to_string(),
3090                            entry.retry_count + 1,
3091                            retry_at.timestamp_millis(),
3092                        ));
3093                    }
3094
3095                    debug!(
3096                        message_id = %entry.message.id,
3097                        retry_count = entry.retry_count,
3098                        transport = ?current_transport,
3099                        error = %e,
3100                        "Retry send failed, will retry later"
3101                    );
3102                }
3103            }
3104        }
3105
3106        if processed > 0 {
3107            debug!(processed = processed, "Processed retry queue entries");
3108        }
3109
3110        Ok(())
3111    }
3112
3113    /// Processes timed out ACKs and handles retry or failure.
3114    fn process_timed_out_acks(&mut self) -> Result<()> {
3115        let timed_out = self.ack_manager.drain_timed_out();
3116        for pending in timed_out {
3117            let message_id = pending.message_id.clone();
3118
3119            if pending.retry_count >= self.config.reliability.retry.max_retries {
3120                self.handle_max_retries_exceeded(&message_id, pending.retry_count)?;
3121                continue;
3122            }
3123
3124            self.handle_ack_timeout_retry(&message_id, pending.retry_count)?;
3125        }
3126        Ok(())
3127    }
3128
3129    /// Handles a message that has exceeded maximum retries.
3130    fn handle_max_retries_exceeded(
3131        &mut self,
3132        message_id: &MessageId,
3133        retry_count: u32,
3134    ) -> Result<()> {
3135        // A plain DM whose recipient still holds a live unreachable-park
3136        // counter re-parks instead of settling: the exhausted budget was burnt
3137        // by reachability probes — sends that succeed locally without proving
3138        // the peer is back — not by a peer believed reachable (see
3139        // try_repark_exhausted_dm).
3140        if self.try_repark_exhausted_dm(message_id) {
3141            return Ok(());
3142        }
3143
3144        // Retry exhaustion is terminal for a connection request: settle the
3145        // pending entry and surface the typed undeliverable signal alongside
3146        // the generic message_failed, so apps keep connection-request
3147        // context without parsing reason strings.
3148        let undeliverable_recipient = self.take_undeliverable_connection_request(message_id);
3149
3150        let state = lock_shared_state(&self.shared_state).map_err(|e| {
3151            error!(
3152                "Failed to lock shared state for message failed event: {}",
3153                e
3154            );
3155            e
3156        })?;
3157        state.emit_event(Event::message_failed(
3158            message_id.clone(),
3159            "Max retries exceeded".to_string(),
3160            retry_count,
3161        ));
3162        if let Some(recipient) = undeliverable_recipient {
3163            warn!(
3164                recipient = %recipient,
3165                message_id = %message_id,
3166                "Connection request undeliverable: max retries exceeded"
3167            );
3168            state.emit_event(Event::connection_request_undeliverable(
3169                recipient,
3170                message_id.as_str(),
3171                "max_retries_exceeded",
3172            ));
3173        }
3174        drop(state);
3175
3176        self.handle_outbound_media_chunk_failed(message_id, "max retries exceeded");
3177        self.retry_queue.remove(&message_id.as_str());
3178        self.ack_manager.remove_ack(message_id);
3179        if let Some(entry) = self.remove_outbox_entry(message_id) {
3180            if let Some(transport) = entry.last_transport {
3181                self.transport_manager.record_delivery_failure(transport);
3182            }
3183        }
3184        Ok(())
3185    }
3186
3187    /// Handles retry logic for a timed out ACK.
3188    fn handle_ack_timeout_retry(&mut self, message_id: &MessageId, retry_count: u32) -> Result<()> {
3189        if let Some(entry) = self
3190            .outbox
3191            .get(message_id)
3192            .or_else(|| self.media_outbox.get(message_id))
3193        {
3194            let message_clone = entry.message.clone();
3195            let recipient = entry.message.recipient.as_str().to_string();
3196            let last_transport = entry.last_transport;
3197
3198            // enqueue is infallible (retry queue has no attempt limit)
3199            let next_retry_at = self.retry_queue.enqueue(message_clone, retry_count);
3200            if let Some(transport) = last_transport {
3201                self.transport_manager.record_retry_failure(transport);
3202            }
3203            if let Some(retry_at) = next_retry_at {
3204                self.emit_event(Event::message_retrying(
3205                    message_id.clone(),
3206                    recipient,
3207                    retry_count,
3208                    retry_at.timestamp_millis(),
3209                ));
3210            }
3211        } else {
3212            self.handle_missing_outbox_entry(message_id, retry_count)?;
3213        }
3214        Ok(())
3215    }
3216
3217    /// Emits a protocol event to all registered handlers.
3218    ///
3219    /// Silently no-ops if the shared state lock is poisoned.
3220    pub(crate) fn emit_event(&self, event: Event) {
3221        if let Ok(state) = lock_shared_state(&self.shared_state) {
3222            state.emit_event(event);
3223        }
3224    }
3225
3226    /// Emits a terminal settlement, or parks it until `start()` if the event
3227    /// pipeline is not live yet.
3228    ///
3229    /// Restore paths settle messages the app is still holding ids for. They run
3230    /// from `initialize_mls`, before `start()` and often before the app has
3231    /// installed its event callback, so emitting directly there would silently
3232    /// discard exactly the signals that exist to stop an id from hanging
3233    /// forever. Once running, this is a plain emit — the same call is used from
3234    /// `process()`-driven expiry, where no deferral is wanted.
3235    pub(crate) fn settle_restored_message_failure(&mut self, event: Event) {
3236        let running = self.event_pipeline_is_live();
3237        self.settle_one_restored_message_failure(event, running);
3238    }
3239
3240    /// Settles a batch, taking the shared-state lock once rather than once per
3241    /// event. Restore emits these in loops that a tampered or pre-split store
3242    /// can drive into the thousands.
3243    pub(crate) fn settle_restored_message_failures(
3244        &mut self,
3245        events: impl IntoIterator<Item = Event>,
3246    ) {
3247        let running = self.event_pipeline_is_live();
3248        for event in events {
3249            self.settle_one_restored_message_failure(event, running);
3250        }
3251    }
3252
3253    fn event_pipeline_is_live(&self) -> bool {
3254        lock_shared_state(&self.shared_state)
3255            .map(|state| state.state == ProtocolState::Running)
3256            .unwrap_or(false)
3257    }
3258
3259    fn settle_one_restored_message_failure(&mut self, event: Event, running: bool) {
3260        if running {
3261            self.emit_event(event);
3262            return;
3263        }
3264        // Every other accumulation on the restore path has an explicit ceiling
3265        // and logs what it dropped; this one is retained until `start()`, which
3266        // may never come, so it gets the same treatment. Keeping the *oldest*
3267        // is deliberate: the settlements a restore produces first are the ones
3268        // for records it examined first, and dropping those in favour of later
3269        // ones would bias the survivors by backend listing order.
3270        if self.deferred_restore_settlements.len() >= MAX_DEFERRED_RESTORE_SETTLEMENTS {
3271            self.suppressed_restore_settlements += 1;
3272            return;
3273        }
3274        self.deferred_restore_settlements.push(event);
3275    }
3276
3277    /// Returns an iterator over outbox messages (test-only).
3278    #[cfg(test)]
3279    pub(crate) fn outbox_messages(&self) -> impl Iterator<Item = &Message> {
3280        self.outbox.values().map(|e| &e.message)
3281    }
3282
3283    /// Returns the total number of entries across outbox and media_outbox (test-only).
3284    #[cfg(test)]
3285    pub(crate) fn outbox_entry_count(&self) -> usize {
3286        self.outbox.len() + self.media_outbox.len()
3287    }
3288
3289    /// Clears all outbox entries (test-only).
3290    #[cfg(test)]
3291    pub(crate) fn clear_outbox(&mut self) {
3292        self.outbox.clear();
3293    }
3294
3295    /// Returns `true` when `message_id` is anywhere in the reliability
3296    /// machinery — awaiting an ACK, held in the outbox, or queued for retry
3297    /// (test-only).
3298    ///
3299    /// Exists for the fire-and-forget frames (relay hints, delivery ACKs),
3300    /// whose correctness is defined by leaving *no* trace here: an un-ACKable
3301    /// frame that registers a pending ACK is retransmitted until the retry cap
3302    /// and then reported failed.
3303    #[cfg(test)]
3304    pub(crate) fn is_tracked_for_delivery(&self, message_id: &MessageId) -> bool {
3305        self.ack_manager.is_waiting_for_ack(message_id)
3306            || self.outbox.contains_key(message_id)
3307            || self.media_outbox.contains_key(message_id)
3308            || self.retry_queue.contains(&message_id.as_str())
3309    }
3310
3311    /// Returns a reference to the MLS manager Arc (test-only).
3312    #[cfg(test)]
3313    pub(crate) fn mls_manager_for_testing(&self) -> &Arc<RwLock<offline_protocol_mls::MlsManager>> {
3314        self.mls_manager
3315            .as_ref()
3316            .expect("MLS not initialized in test")
3317    }
3318
3319    /// Acquires a read guard on the MLS manager.
3320    ///
3321    /// Returns an error if MLS is not initialized or the lock is poisoned.
3322    pub(crate) fn read_mls_guard(
3323        &self,
3324    ) -> Result<std::sync::RwLockReadGuard<'_, offline_protocol_mls::MlsManager>> {
3325        let mls = self
3326            .mls_manager
3327            .as_ref()
3328            .ok_or_else(|| Error::Other("MLS not initialized".to_string()))?;
3329        mls.read()
3330            .map_err(|_| Error::Other("MLS lock poisoned".to_string()))
3331    }
3332}
3333
3334#[cfg(test)]
3335pub(crate) mod tests;