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