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