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