Skip to main content

offline_protocol/protocol/
mod.rs

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