Skip to main content

offline_protocol/protocol/
types.rs

1//! Type definitions, constants, and shared state for the protocol engine.
2
3use crate::events::{Event, EventCallback, PresenceStatus};
4use crate::telemetry::{
5    dispatch_protocol_event, dispatch_record, scrub_event, TelemetryContext, TelemetryRecord,
6};
7use crate::Error;
8use chrono::{DateTime, Utc};
9use offline_protocol_core::time::Instant;
10use offline_protocol_core::{
11    AppId, ContentType, ForwardInfo, MediaMetadata, Message, MessageId, MessagePriority,
12    ReplyContext,
13};
14use offline_protocol_transport::TransportType;
15use serde::{Deserialize, Serialize};
16use std::collections::{HashSet, VecDeque};
17use std::sync::{Arc, Mutex};
18use tracing::warn;
19
20/// A carrier that speaks the gateway contract, and can therefore answer about
21/// a third party's reachability.
22///
23/// Two of the five transports qualify, and the type exists so the other three
24/// cannot be passed where a gateway answer is expected. That is not a
25/// theoretical tidiness: every effect of a presence answer is carrier-scoped,
26/// so a wrong carrier records a fact against a transport that never claimed
27/// it and pins an un-park flush to a transport that never proved anything,
28/// neither of which fails loudly.
29///
30/// - **BLE and Wi-Fi Direct** are zone carriers. A neighbour being in range
31///   is a fact about this device's own link, not a claim about a third party,
32///   and it arrives through discovery rather than through a verb.
33/// - **Nostr** structurally cannot answer: a broadcast relay reports no
34///   per-recipient delivery, so it is a carrier and never a gateway. See
35///   [ADR 0017](../../../docs/adr/0017-nostr-is-a-carrier-not-a-gateway.md).
36///   This is a permanent gap, recorded here in the type so it stops being
37///   rediscovered as a bug.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum GatewayCarrier {
40    /// The internet relay, which implemented every verb before the contract
41    /// named them.
42    Internet,
43    /// A gateway daemon reached over local IP, through the transport slot
44    /// named `Reticulum` after its reference backbone. The variant names
45    /// the carrier, not the backbone: what stands behind the daemon is the
46    /// gateway's property, and this device learns of it only as a
47    /// capability token it never reads (ADR 0026).
48    Reticulum,
49}
50
51impl GatewayCarrier {
52    /// The transport this gateway's answers are recorded against.
53    pub fn transport(self) -> TransportType {
54        match self {
55            Self::Internet => TransportType::Internet,
56            Self::Reticulum => TransportType::Reticulum,
57        }
58    }
59
60    /// How a presence answer from this gateway is labelled to the app.
61    pub fn presence_source(self) -> crate::events::PresenceSource {
62        match self {
63            Self::Internet => crate::events::PresenceSource::Internet,
64            Self::Reticulum => crate::events::PresenceSource::Reticulum,
65        }
66    }
67
68    /// The gateway behind `transport`, or `None` for a carrier that answers
69    /// no verdicts.
70    ///
71    /// Exhaustive by construction rather than by a wildcard: a sixth
72    /// transport must be classified here deliberately, and the wrong default
73    /// is the silent one. A carrier that returns `None` is not broken; it
74    /// simply never contradicts the blanket "up means reachable" assumption,
75    /// which is the residual `docs/mesh.md` records.
76    pub fn from_transport(transport: TransportType) -> Option<Self> {
77        match transport {
78            TransportType::Internet => Some(Self::Internet),
79            TransportType::Reticulum => Some(Self::Reticulum),
80            TransportType::BLE | TransportType::WiFiDirect | TransportType::Nostr => None,
81        }
82    }
83}
84
85/// Retry interval for persisting session confirmation after a transient storage error.
86pub(crate) const CONFIRMATION_RETRY_INTERVAL_SECS: i64 = 5;
87/// Probe interval for reconciling pending sessions after restart.
88pub(crate) const CONFIRMATION_PROBE_INTERVAL_SECS: i64 = 5;
89/// Minimum interval between 1:1 session re-keys for the same peer, triggered by
90/// an epoch-desync decrypt failure. A re-key is a full teardown + key-package
91/// re-exchange, so it is rate-limited well above the confirmation-probe cadence:
92/// one legit desync heals in a single round-trip, and this bounds a peer
93/// replaying stale-epoch ciphertext (or an injected wrong-epoch frame) to at
94/// most one re-key per this window rather than a storm. The floor is enforced
95/// unconditionally — a successful decrypt on the healed session does NOT reset
96/// it — so an attacker cannot defeat it by interleaving replays with legit
97/// traffic (see `schedule_session_rekey`).
98pub(crate) const REKEY_INTERVAL_SECS: i64 = 30;
99/// Minimum interval between two pushes of our key package to the same peer
100/// while no session with that peer exists.
101///
102/// The first push is fire-once over a carrier that may lose it without saying
103/// so (a superseded peer stream drops its late frames, `docs/spec/stream-framing.md`), so a
104/// push that produced no session is re-armed. The floor is what keeps the
105/// re-arm from becoming a key-package storm: discovery fires on every inbound
106/// body, and a peer that never answers (an old SDK, encryption opted out) gets
107/// one advertisement per window rather than one per frame. The window doubles
108/// with each repeat up to [`KEY_PACKAGE_RESEND_CAP_SECS`], so that peer is not
109/// sent a key package every 30 seconds for as long as it stays in range.
110pub(crate) const KEY_PACKAGE_RESEND_INTERVAL_SECS: u64 = 30;
111/// Ceiling on the doubling key-package resend window, matching
112/// [`WELCOME_UNREACHABLE_RETRY_CAP_SECS`].
113pub(crate) const KEY_PACKAGE_RESEND_CAP_SECS: u64 = 600;
114// The wait doubles at most five times (`<< min(5)`, which also keeps the
115// shift from wrapping the wait to zero); this keeps the cap reachable.
116const _: () = assert!(KEY_PACKAGE_RESEND_INTERVAL_SECS << 5 >= KEY_PACKAGE_RESEND_CAP_SECS);
117const _: () = assert!(KEY_PACKAGE_RESEND_CAP_SECS as i64 == WELCOME_UNREACHABLE_RETRY_CAP_SECS);
118
119/// Seconds to wait before repeating a key-package push that has already been
120/// repeated `repeats` times: the interval, doubled per repeat, capped.
121pub(crate) fn key_package_resend_wait_secs(repeats: u32) -> u64 {
122    (KEY_PACKAGE_RESEND_INTERVAL_SECS << repeats.min(5)).min(KEY_PACKAGE_RESEND_CAP_SECS)
123}
124/// Number of welcome retry records processed per tick.
125pub(crate) const WELCOME_RETRY_BATCH_SIZE: usize = 20;
126/// Hard TTL for outbound welcome lifecycle records.
127pub(crate) const WELCOME_LIFECYCLE_TTL_SECS: i64 = 300;
128/// Jitter ratio applied to welcome retry backoff delays.
129pub(crate) const WELCOME_RETRY_JITTER_RATIO: f64 = 0.2;
130/// Timeout waiting for explicit internet send confirmation for welcome.
131pub(crate) const WELCOME_INTERNET_CONFIRM_TIMEOUT_SECS: i64 = 10;
132/// Timeout waiting for a mesh (BLE / WiFi-Direct) welcome to be confirmed by
133/// the peer proving the session (probe / ack / welcome / decrypt). A mesh
134/// `send()` returning Ok only means the local stack accepted the bytes, not
135/// that the multi-fragment Welcome reassembled on the peer — so the lifecycle
136/// stays non-terminal and the retry queue re-sends the whole Welcome after this
137/// window, recovering from a lost fragment. Slightly longer than the internet
138/// timeout to allow a slow multi-fragment Welcome to assemble plus the probe
139/// round-trip before paying to re-fragment and re-send it.
140pub(crate) const WELCOME_MESH_CONFIRM_TIMEOUT_SECS: i64 = 15;
141/// Retry cadence for a Welcome that has no transport carrier at all (the peer is
142/// simply unreachable right now). A send is guaranteed to fail with no carrier,
143/// so we do NOT burn the speculative send/transition/event churn on it every
144/// retry tick — we park it and re-check on this slow interval instead. The
145/// primary recovery is event-driven (`on_neighbor_discovered` → re-arm fires the
146/// instant a carrier surfaces the peer); this poll is only a safety net for a
147/// carrier returning without a fresh discovery event, so it is deliberately far
148/// slower than the data-plane retry interval to keep an offline device quiet.
149/// Also the base interval for the plain-DM unreachable reachability probe
150/// (`handle_recipient_unreachable_for_message`), which shares this
151/// escalation.
152pub(crate) const WELCOME_NO_CARRIER_RETRY_SECS: i64 = 15;
153/// Cap for the escalating retry interval of a welcome repeatedly parked
154/// `PeerUnreachable`, on every carrier (see
155/// `apply_recipient_unreachable_failure`). Each consecutive unreachable park
156/// doubles the interval from [`WELCOME_NO_CARRIER_RETRY_SECS`] up to this
157/// cap: DORS may keep selecting the internet path for the timed retry, and
158/// every such round trips another relay `DeliveryError` with the attempt
159/// refunded — without escalation that is an unbounded 15s resend loop into
160/// the relay for as long as the peer stays offline. At the cap the steady
161/// state matches the presence-rescue cadence (one send per 10 min), which is
162/// cheap and self-resolving. Shared by the plain-DM unreachable probe
163/// (`handle_recipient_unreachable_for_message`, per-peer
164/// `dm_unreachable_parks` counter) for the same reason — and there the
165/// escalation carries the whole bound, since that probe runs on every carrier
166/// (see `park_unreachable_dm`), including internet-only devices.
167pub(crate) const WELCOME_UNREACHABLE_RETRY_CAP_SECS: i64 = 600;
168/// Only used when `RetryConfig::edge_driven_unreachable_dm` is opted in. How
169/// many escalating timed probes a plain DM to an unreachable peer gets before
170/// it goes edge-driven (stops being timed-probed and rests in the outbox until
171/// the peer next proves reachable). One probe (plus the original send) gives a
172/// brief blip tolerance; beyond that, recovery is the reachability edge — which
173/// in an opted-in deployment is reliable (peers interact / advertise presence
174/// on return), so more blind probes only lengthen the transient after a mass
175/// disconnect. Default-off deployments never consult this (they probe forever
176/// at the documented 15s->600s cap).
177pub(crate) const DM_UNREACHABLE_PROBE_LIMIT: u32 = 1;
178/// Only used when `RetryConfig::edge_driven_unreachable_dm` is opted in. On the
179/// startup/reconnect re-drive, a still-undelivered outbox message first sent
180/// longer ago than this is left edge-driven rather than re-driven, so a restart
181/// does not re-ramp a durably-failing backlog. Default-off deployments never
182/// consult this.
183pub(crate) const OUTBOX_DURABLE_UNREACHABLE_AGE_SECS: i64 = 300;
184/// Only used when `RetryConfig::edge_driven_unreachable_dm` is opted in.
185/// Sustained rate (per second) and burst budget for the RETRY/PROBE resend path
186/// (`process_retry_queue`). Set below the reference relay's per-connection limit
187/// (10/s sustained, 30 burst) so resends to a fleet of unreachable peers can
188/// never burst the connection into a rate-limit disconnect. Only resends are
189/// bounded; first-attempt sends, ACKs, control frames and handshakes are never
190/// throttled.
191pub(crate) const RETRY_DRAIN_RATE_PER_SEC: f64 = 8.0;
192pub(crate) const RETRY_DRAIN_BURST: f64 = 24.0;
193/// Age limit for a welcome lifecycle to keep its peer on the presence
194/// watchlist (`welcome_pending_peers`). Without it the watch set only ever
195/// grows: every offline presence answer re-parks the record and pushes its
196/// `expires_at`, so a permanently-dead peer (abandoned install) is watched —
197/// and its parked lifecycle persisted — forever, and each such peer occupies
198/// rotation slots that delay presence rescue for live peers. Once unwatched,
199/// offline answers stop, `expires_at` stops being pushed, and the record
200/// ages out through normal expiry; recovery degrades to peer-initiated
201/// contact or mesh discovery, both of which rebuild the lifecycle.
202pub(crate) const WELCOME_WATCHLIST_MAX_AGE_SECS: i64 = 14 * 24 * 60 * 60;
203/// Backoff base for presence-driven welcome rescue. The first rescue for a
204/// peer is immediate; each subsequent rescue that still fails to prove the
205/// session doubles the wait (40s, 80s, 160s, ...) up to
206/// [`WELCOME_PRESENCE_RESCUE_MAX_SECS`]. Bounds the resend loop when a peer
207/// is provably online but can never confirm (stale key package after a
208/// reinstall, incompatible peer version) — without it the platform's 20s
209/// presence watch would re-send the multi-frame MLS welcome forever.
210pub(crate) const WELCOME_PRESENCE_RESCUE_BASE_SECS: i64 = 40;
211/// Cap for the presence-rescue backoff (10 minutes). Deliberately not a
212/// terminal state: a peer that stays online but never confirms keeps getting
213/// one rescue per cap interval, forever. That steady state is cheap (one
214/// multi-frame welcome per 10 min per such peer) and self-resolving — the
215/// lifecycle disappears the moment the session confirms — so a give-up
216/// threshold would only add a way to strand a recoverable session.
217pub(crate) const WELCOME_PRESENCE_RESCUE_MAX_SECS: i64 = 600;
218/// Well-known prefix for transport send-failure reasons meaning "the carrier
219/// is up but this recipient is unreachable on it" (e.g. the internet relay
220/// answered `DeliveryError` for an offline peer). Classified in
221/// `on_transport_send_failed` as authoritative proof the frame was dropped:
222/// instead of burning a retry attempt, a welcome refunds it and parks on an
223/// escalating reachability probe (`apply_recipient_unreachable_failure`), and
224/// a plain DM parks the same way (`park_unreachable_dm`). The escalation, not
225/// a carrier guard, is what keeps those probes from becoming a resend loop
226/// into the relay.
227///
228/// Cross-layer contract: the React Native platform bridges
229/// (`InternetManager.kt` / `InternetManager.swift`) hardcode this literal when
230/// calling `internet_send_failed_with_reason` — keep them in sync.
231pub(crate) const SEND_FAIL_REASON_RECIPIENT_UNREACHABLE: &str = "recipient_unreachable";
232
233/// The relay accepted a frame for a recipient with no live socket and handed
234/// it to a push notification (`MessageSent { pushed: true }`). Not a failure:
235/// the push may deliver it. What it does establish is that the recipient is
236/// not on the relay, and that the push is the only thing carrying this frame:
237/// a relay whose mailbox also holds a copy says so with `stored: true`, which
238/// the bridges report as [`SEND_FAIL_REASON_RELAY_PUSHED_STORED`] instead.
239///
240/// Deliberately its own token rather than a `recipient_unreachable` tail.
241/// That prefix fast-fails connection requests and moves Welcomes to `Failed`,
242/// both wrong for a frame that may have arrived. This token parks plain DMs
243/// only (`park_relay_pushed_dm`), and connection requests and Welcomes stay
244/// on their ordinary path.
245///
246/// Cross-layer contract: the React Native platform bridges
247/// (`InternetManager.kt` / `InternetManager.swift`) and the Python relay
248/// client (`internet_manager.py`) pass this exact literal to
249/// `internet_send_failed_with_reason`; pinned on the React Native side by
250/// `react_native_relay_parks_a_pushed_message_sent` and on the Python side by
251/// `TestMessageSentPushed`. That call must not also score the report as a send
252/// failure; see `OfflineProtocol::send_report_is_carrier_failure`.
253pub(crate) const SEND_FAIL_REASON_RELAY_PUSHED: &str = "relay_pushed";
254
255/// The relay's mailbox holds this frame for the recipient's next connection
256/// (`DeliveryError { stored: true }`, on a relay advertising `mailbox_v1`).
257///
258/// The socket write still missed, so this is the `recipient_unreachable`
259/// verdict in every respect but one: the frame is not lost, and the relay
260/// re-sends it the moment the recipient connects. A plain DM is therefore
261/// parked *edge-driven* — no escalating reachability probe — because a probe
262/// would only rewrite the copy the relay already holds. That is what the
263/// relay's own contract asks for ("do not retry faster on its account"), and
264/// what keeps a fleet whose peers are merely asleep from paying relay traffic
265/// for a redelivery the relay is already committed to. Recovery without the
266/// probe is the reachability edge, and the relay's drain either way.
267///
268/// Everything that is not a parkable plain DM is deliberately unchanged: the
269/// token is normalized back to [`SEND_FAIL_REASON_RECIPIENT_UNREACHABLE`] in
270/// `on_transport_send_failed_via`, so connection requests still fast-fail and
271/// Welcomes still park on their own lifecycle. Both carry their own proof of
272/// arrival, and a held copy is not a reason to stop waiting for it.
273///
274/// Cross-layer contract: the React Native platform bridges
275/// (`InternetManager.kt` / `InternetManager.swift`) and the Python relay
276/// client (`internet_manager.py`) pass this exact literal, and only for the
277/// one id the relay named — `stored` is a statement about a single message,
278/// never about the recipient's other in-flight frames. Pinned on the React
279/// Native side by `react_native_relay_parks_a_stored_delivery_error`.
280pub(crate) const SEND_FAIL_REASON_RELAY_STORED: &str = "relay_stored";
281
282/// [`SEND_FAIL_REASON_RELAY_PUSHED`] for a frame the mailbox also holds
283/// (`MessageSent { pushed: true, stored: true }`).
284///
285/// Parks exactly as `relay_pushed` does — plain DMs only, no
286/// `MessageUndeliverable`, kept out of the carrier's failure accounting —
287/// except that the park schedules no reachability probe, for the reason
288/// [`SEND_FAIL_REASON_RELAY_STORED`] gives.
289///
290/// Two tokens rather than one token and a flag because the two origins differ
291/// for everything that is *not* a plain DM: a pushed frame must not fast-fail
292/// a connection request, and a missed one must. Collapsing them would trade
293/// that distinction for one fewer string.
294pub(crate) const SEND_FAIL_REASON_RELAY_PUSHED_STORED: &str = "relay_pushed_stored";
295
296/// Fallback token for a send failure that classifies as nothing more specific.
297pub(crate) const SEND_FAIL_REASON_TRANSPORT: &str = "transport_send_failed";
298/// A Welcome was written to a carrier that never confirmed it.
299pub(crate) const SEND_FAIL_REASON_CONFIRM_TIMEOUT: &str = "send_confirmation_timed_out";
300
301/// Every token [`classify_transport_send_error`] and
302/// [`send_failure_token`] can produce.
303///
304/// Membership is what makes classification idempotent: a value already drawn
305/// from this list re-classifies to itself, so a record written by one producer
306/// and re-read by the other is not degraded to the fallback. Keep it in sync
307/// with the two functions — the round-trip test
308/// `every_send_failure_token_classifies_to_itself` fails otherwise.
309pub(crate) const SEND_FAIL_REASON_TOKENS: &[&str] = &[
310    SEND_FAIL_REASON_RECIPIENT_UNREACHABLE,
311    SEND_FAIL_REASON_RELAY_PUSHED,
312    SEND_FAIL_REASON_RELAY_STORED,
313    SEND_FAIL_REASON_RELAY_PUSHED_STORED,
314    SEND_FAIL_REASON_TRANSPORT,
315    SEND_FAIL_REASON_CONFIRM_TIMEOUT,
316    "transport_not_connected",
317    "peer_not_reachable",
318    "message_too_large",
319    "serialization_failed",
320    "crypto_failed",
321    "session_not_ready",
322    "recipient_blocked",
323    "media_transfer_limit",
324    "group_not_found",
325    "invalid_request",
326    "protocol_state_invalid",
327];
328
329/// Maps a platform-supplied or previously-stored send-failure string onto a
330/// fixed local vocabulary.
331///
332/// # Why the string cannot be passed through
333///
334/// The bridges build this string from the relay's `DeliveryError.reason`
335/// (`"recipient_unreachable: <relay text>"` — the shape is a cross-layer
336/// contract, see [`SEND_FAIL_REASON_RECIPIENT_UNREACHABLE`]), and the Nostr
337/// bridge from a relay's `OK` rejection text. Both tails are remote-chosen,
338/// unbounded, and not ours. They reach `MessageUndeliverable.reason`,
339/// `ConnectionRequestUndeliverable.reason` and — through
340/// `WelcomeLifecycleRecord::last_transport_error` —
341/// `WelcomeSendFailed.transport_error`, every one of which the telemetry
342/// scrubber ships verbatim beside a `recipient`/`peer_id` it *hashes*. An
343/// identifier in the tail therefore undoes the hashing in the same record, and
344/// even the honest relay writes one: its own wording renders the peer it
345/// concerns.
346///
347/// # Why the prefix survives
348///
349/// The `recipient_unreachable` token is not decoration — core prefix-matches it
350/// to fast-fail connection requests and to park plain DMs, and the bridges
351/// hardcode it. So the classification *is* the bare token: everything the
352/// protocol reads is kept, and only the prose tail is dropped.
353///
354/// Matching is prefix-based for that one token (the bridges append to it) and
355/// exact otherwise, with a closed fallback — a bridge or relay rewording
356/// degrades to [`SEND_FAIL_REASON_TRANSPORT`], never back to shipping its text.
357/// The `&'static str` return is load-bearing the same way
358/// [`GroupErrorPayload::classify_reason`] and `MlsError::privacy_safe_reason`
359/// are: it makes interpolating the input unrepresentable rather than merely
360/// discouraged.
361///
362/// Idempotent by construction, which matters twice: a record persisted before
363/// this existed is classified when it is *read* (the stored string may be raw
364/// relay prose), and a record written after it is classified again on the way
365/// out without changing.
366pub(crate) fn classify_transport_send_error(raw: &str) -> &'static str {
367    // Prefix, not equality: this is the one token the bridges extend.
368    if raw.starts_with(SEND_FAIL_REASON_RECIPIENT_UNREACHABLE) {
369        return SEND_FAIL_REASON_RECIPIENT_UNREACHABLE;
370    }
371    // Already classified: a re-read of a record either producer wrote. Returns
372    // the entry from the table rather than `raw`, which is what keeps the
373    // signature `&'static str`.
374    if let Some(token) = SEND_FAIL_REASON_TOKENS.iter().find(|t| **t == raw) {
375        return token;
376    }
377    match raw {
378        // The fixed literals the UniFFI layer substitutes when a bridge reports
379        // a failure without a reason of its own. Locally minted, but classified
380        // here too so the vocabulary has exactly one source.
381        "Internet transport send failed"
382        | "Reticulum transport send failed"
383        | "Nostr transport send failed" => SEND_FAIL_REASON_TRANSPORT,
384        "Welcome send confirmation timed out" => SEND_FAIL_REASON_CONFIRM_TIMEOUT,
385        // Everything else, including anything a relay or bridge chose.
386        _ => SEND_FAIL_REASON_TRANSPORT,
387    }
388}
389
390/// The pending-ACK table was full, so the frame was held back instead of
391/// transmitted. Not a transport fault: the retry queue sends it as soon as an
392/// acknowledgement frees a slot. A link carrying less than the app offers
393/// produces this steadily, and it is the signal to slow down.
394pub(crate) const SEND_DEFER_REASON_ACK_CAPACITY: &str = "ack_capacity";
395
396/// The peer already had [`PEER_SEND_WINDOW`] frames over a mesh link awaiting
397/// its acknowledgement, so the frame waits in the outbox and goes out, oldest
398/// first, as acknowledgements come back. Not a fault: it is what a link
399/// carrying less than the app offers looks like.
400pub(crate) const SEND_DEFER_REASON_PEER_WINDOW: &str = "peer_window";
401
402/// How many frames this device keeps in flight to one peer over a mesh link
403/// (Bluetooth LE, Wi-Fi Direct) before it holds the next one back.
404///
405/// Without a window the sender handed the carrier every new frame, every
406/// resend and an acknowledgement per received frame, into one unbounded queue,
407/// on a timer that starts at enqueue. In the 2026-10-19 volume test (run 4)
408/// the Android side's indication path carried 12 to 16 frames a second against
409/// 15 offered: the acknowledgement round trip passed the timeout, every resend
410/// cost the receiver another acknowledgement on the saturated path, that
411/// path's rate halved, and both phones retried 50,000 times and evicted 6,000
412/// messages as "Outbox capacity exceeded". A window bounds the queue, and with
413/// it the round trip, the resends and the receiver's ratchet gap, at any link
414/// rate. It costs no throughput while it exceeds what the link carries in one
415/// unloaded round trip: about six messages on that link, counting the
416/// receiver's acknowledgement coalescing (`ACK_COALESCE_WINDOW`) and a
417/// processing tick on each side. Sixteen leaves margin, and keeps the
418/// round trip under about three seconds there, well inside the timeout.
419///
420/// Only the mesh carriers: a relay mailbox is store-and-forward, and holding
421/// a message back from it would strand it on a device the user may close.
422pub(crate) const PEER_SEND_WINDOW: usize = 16;
423
424// Every in-window frame can be re-sealed on each of its attempts, and the
425// re-seal budget must never trip under the window: past it resends go out
426// verbatim, and a verbatim copy more than the out-of-order tolerance behind
427// the receiver never decrypts (run 4: 3,308 such failures).
428const _: () = assert!(
429    (PEER_SEND_WINDOW as u64)
430        * (1 + offline_protocol_reliability::constants::DEFAULT_MAX_RETRIES as u64)
431        < RESEAL_GAP_BUDGET
432);
433
434/// The smallest forward window any receiver of a 1:1 session has: a phone's
435/// OpenMLS group (configured) or a leaf's mls-rs one (fixed by mls-rs, and
436/// smaller). The re-seal budget is a fraction of this, not of the phone's
437/// window: derived from the phone's 8192, it let a sender burn 2,048
438/// generations past a leaf that can fast-forward over 1,024.
439pub(crate) const RECEIVER_FORWARD_WINDOW_FLOOR: u64 = {
440    let phone = offline_protocol_mls::group::SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE as u64;
441    let leaf = offline_protocol_mls::group::LEAF_SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE as u64;
442    if phone < leaf {
443        phone
444    } else {
445        leaf
446    }
447};
448
449/// How many frames a sender may seal for one peer past the last one that peer
450/// acknowledged before resends stop being re-sealed and go out verbatim.
451///
452/// A receiver cannot decrypt a frame more than its forward window past the
453/// newest it has decrypted. A quarter of the smallest window any receiver has
454/// ([`RECEIVER_FORWARD_WINDOW_FLOOR`]) leaves the rest to new messages, which
455/// still seal. The per-peer window keeps unacknowledged seals growing only by
456/// resends of at most [`PEER_SEND_WINDOW`] frames, so the budget trips only on
457/// a link that has carried no acknowledgement for a whole retry ladder; the
458/// compile-time assertion above keeps it above that ladder.
459pub(crate) const RESEAL_GAP_BUDGET: u64 = RECEIVER_FORWARD_WINDOW_FLOOR / 4;
460
461/// Past [`RESEAL_GAP_BUDGET`], a resend whose bytes the receiver can no
462/// longer decrypt is still re-sealed, until the gap reaches this.
463///
464/// A verbatim copy sealed more than the out-of-order tolerance before the
465/// newest frame this device sealed is refused by a receiver that has
466/// decrypted anything since, and refused without an acknowledgement on a
467/// mesh link, so it was resent until its ladder ran out: in run 4 of the
468/// 2026-10-19 volume test, 525 such messages produced 3,308 decrypt
469/// failures and were never delivered. Re-sealing one spends a generation,
470/// which is what the budget rations, so the exception stops at half the
471/// smallest forward window ([`RECEIVER_FORWARD_WINDOW_FLOOR`]): the receiver
472/// going deaf is the failure the budget exists for, and a wasted resend is
473/// not.
474pub(crate) const RESEAL_HARD_LIMIT: u64 = RECEIVER_FORWARD_WINDOW_FLOOR / 2;
475
476/// The longest an acknowledgement timeout is postponed for a frame that is
477/// still waiting in a local transport queue, measured from the attempt.
478///
479/// Bounded so a queue that never drains (a link that died without telling
480/// the transport) still reaches the retry ladder, and through it the other
481/// carriers and the mesh, within a minute.
482pub(crate) const QUEUED_ACK_GRACE_MAX: std::time::Duration = std::time::Duration::from_secs(60);
483
484/// Classifies a locally raised send failure for an event field.
485///
486/// The sibling of [`classify_transport_send_error`] for errors this device
487/// produced rather than received. It exists for the same reason: the transport
488/// layer renders the counterparty into its own message —
489/// `PeerNotReachable("no mesh transport holds a link to {peer_id}…")`, and the
490/// same in the BLE and Wi-Fi Direct backends — so `format!("{}", err)` on a
491/// routine send failure ships the peer's address to a telemetry sink. On
492/// `MessageDeferred` that was the *only* identity in the record, since the
493/// event carried no field for the scrubber to hash.
494///
495/// The match over [`crate::Error`] is exhaustive with no `_` arm: it lives in
496/// the defining crate, so a new variant fails to compile here and forces the
497/// privacy decision where the variant is written. The transport arm delegates
498/// to `offline_protocol_transport::Error::code`, which is that crate's own
499/// exhaustive, documented-stable classifier — its doc names telemetry
500/// classification as the intended use. Matching its output needs a fallback
501/// arm (a foreign `#[non_exhaustive]` enum can grow a variant), and that
502/// fallback is closed: a new transport variant degrades to
503/// [`SEND_FAIL_REASON_TRANSPORT`], never to its rendered text.
504pub(crate) fn send_failure_token(err: &crate::Error) -> &'static str {
505    use crate::Error as E;
506    match err {
507        E::Transport(inner) => match inner.code() {
508            "TRANSPORT_NOT_AVAILABLE" => "transport_not_connected",
509            "PEER_NOT_REACHABLE" => "peer_not_reachable",
510            "MESSAGE_TOO_LARGE" => "message_too_large",
511            "SERIALIZATION_ERROR" => "serialization_failed",
512            "CRYPTO_ERROR" => "crypto_failed",
513            _ => SEND_FAIL_REASON_TRANSPORT,
514        },
515        E::NotStarted | E::AlreadyStarted | E::InvalidState(_) => "protocol_state_invalid",
516        E::InvalidConfiguration(_)
517        | E::InvalidArgument(_)
518        | E::PermissionDenied(_)
519        | E::TelemetryConfigInvalid(_) => "invalid_request",
520        E::Core(_) | E::Serialization(_) => "serialization_failed",
521        E::Router(_) | E::Reliability(_) | E::Service(_) => SEND_FAIL_REASON_TRANSPORT,
522        E::Mls(_) | E::EncryptFailed(_) => "crypto_failed",
523        E::MlsNotInitialized | E::SessionNotReady(_) | E::NoKeyPackage(_) => "session_not_ready",
524        E::UserBlocked(_) => "recipient_blocked",
525        E::MediaTransferLimit(_) => "media_transfer_limit",
526        E::GroupNotFound(_) => "group_not_found",
527        // Data-layer failures cannot reach a send path today: nothing in the
528        // data layer sends. They are classified anyway because this match is
529        // where the privacy decision is forced, and "unreachable" is a claim
530        // that stops being true the moment replication lands. Each token is
531        // locally minted and carries no identifier: the errors themselves
532        // interpolate only byte counts and a decode detail, never a space,
533        // document or peer.
534        E::DataDisabled | E::DataStorageUnavailable => "data_unavailable",
535        E::DocTooLarge { .. } => "data_doc_too_large",
536        E::DataCorrupted(_) => "data_corrupt",
537        E::Other(_) => SEND_FAIL_REASON_TRANSPORT,
538    }
539}
540/// Minimum interval between session reconciliation scans (list_sessions I/O).
541/// Keeps the expensive Keychain/Keystore I/O out of the hot path so that
542/// sendMessage() is not blocked by Mutex contention on every process tick.
543pub(crate) const RECONCILIATION_THROTTLE_MS: u64 = 2_000;
544/// Lamport clock ticks between storage persistence writes. Avoids a
545/// Keychain/Keystore write on every sent and received message. On crash
546/// recovery, at most this many ticks are lost, which is safe — the clock
547/// is only used for causal ordering and the gap is absorbed on the next
548/// merge with any peer.
549pub(crate) const LAMPORT_PERSIST_INTERVAL: u64 = 64;
550
551/// Most seen ids one `DedupSeenRecord` carries. Matches the default
552/// `max_tracked_messages`; a larger configured tracker persists its newest
553/// ids only, which are the ones a replay is most likely to repeat.
554pub(crate) const MAX_PERSISTED_DEDUP_IDS: usize = 5000;
555
556/// Seen-set changes that force a write before the time cadence elapses. A
557/// burst of inbound traffic is exactly when the set is worth having on disk,
558/// and 32 changes at ~60 bytes each is well under the cost of one write.
559pub(crate) const DEDUP_PERSIST_DIRTY_THRESHOLD: u32 = 32;
560
561/// How long a dirty seen set may wait for the next `process()` tick before it
562/// is written. Bounds what a crash loses to a few seconds of receipts.
563pub(crate) const DEDUP_PERSIST_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
564
565/// The deduplicator's seen set as persisted under
566/// [`storage_keys::DEDUP_SEEN_IDS`].
567///
568/// The deduplicator was in-memory only, so after a restart the socket copy of
569/// a message the app had already consumed from a push injection was not
570/// recognised as a duplicate: it went to the ratchet, whose generation for
571/// that message was already spent, and surfaced as a decryption failure — a
572/// spurious signal the app counts toward its split-brain breaker, which then
573/// tears down a healthy session. Persisting the ids makes the second copy
574/// recognisable across the restart. Ids and timestamps only; nothing here is
575/// content.
576///
577/// `version` is the forward-compatibility hinge: an unknown version is dropped
578/// rather than guessed at, and the set simply starts empty.
579#[derive(Serialize, Deserialize)]
580pub(crate) struct DedupSeenRecord {
581    #[serde(default = "dedup_seen_record_version")]
582    pub(crate) version: u8,
583    pub(crate) entries: Vec<offline_protocol_reliability::SeenId>,
584}
585
586/// The only record version this build writes or reads.
587pub(crate) const DEDUP_SEEN_RECORD_VERSION: u8 = 1;
588
589fn dedup_seen_record_version() -> u8 {
590    DEDUP_SEEN_RECORD_VERSION
591}
592/// Seconds the Nostr receive watermark must advance before it is written back
593/// to protocol-state storage. Same debounce role as
594/// [`LAMPORT_PERSIST_INTERVAL`]: every inbound relay event moves the mark, and
595/// a storage write per message is exactly the I/O the Lamport debounce exists
596/// to avoid.
597///
598/// What an un-flushed gap costs is bounded and one-directional: a launch that
599/// loses up to this much advance re-fetches a slightly wider slice of relay
600/// history, which dedup absorbs. It can never *skip* messages, because the
601/// stale mark is always behind the live one.
602pub(crate) const NOSTR_WATERMARK_PERSIST_INTERVAL_SECS: i64 = 300;
603pub(crate) const MEDIA_TRANSFER_STALE_TIMEOUT_SECS: u64 = 300;
604/// Maximum number of tracked known peers for service discovery.
605pub(crate) const MAX_KNOWN_PEERS: usize = 1000;
606/// How long a known peer stays tracked without being re-seen.
607///
608/// Peers on carriers with no disconnect signal (Internet, Reticulum, Nostr,
609/// and WiFi Direct message-path senders) are only ever *added* to
610/// `known_peers`; this TTL is their eviction path, swept from the periodic
611/// `cleanup_expired_entries` tick. A second layer — least-recently-seen
612/// eviction when an insert hits `MAX_KNOWN_PEERS` — guarantees a newly
613/// discovered peer is always tracked even between sweeps.
614///
615/// Deliberately generous: a connected-but-quiet BLE peer refreshes only via
616/// platform advertisement sightings (BLE inbound messages do not route
617/// through the discovery hook), so a short TTL would evict it while still
618/// connected. Eviction is self-healing (the peer is re-tracked on its next
619/// advertisement or message), so erring long only delays hygiene.
620pub(crate) const KNOWN_PEER_TTL_SECS: u64 = 1800;
621
622/// Battery level below which a device stops carrying traffic for other
623/// people even while charging.
624///
625/// Re-exported from the router crate rather than restated here: message
626/// forwarding and the relay role apply the same floor, and a device must not
627/// keep carrying traffic at a level that would have stripped it of the role.
628/// Two copies of the number would eventually disagree, and nothing would
629/// notice.
630pub(crate) use offline_protocol_router::CRITICAL_RELAY_BATTERY_LEVEL;
631
632/// The metadata keys a signed control frame carries: the Ed25519 signature
633/// over the canonical payload, and the public key it verifies against, both
634/// base64.
635///
636/// Re-exported rather than restated because the payload they accompany is
637/// built by `offline_protocol_sealed::control_signing_payload`, which a leaf
638/// node calls too. `CTRL_SIGN_DOMAIN` lives there as well and is no longer
639/// named in this crate outside its tests: nothing here builds the payload any
640/// more. A key or a domain declared in two places is one that can differ in
641/// one of them, and the only symptom is a signature that stops verifying.
642pub(crate) use offline_protocol_sealed::{CTRL_PK_META_KEY, CTRL_SIG_META_KEY};
643
644/// Maximum number of peers retained in the encryption-capability set that gates
645/// inbound plaintext.
646///
647/// Sized to hold every legitimate source at once — the durable capability
648/// records plus the MLS session list — with room to spare, so a real deployment
649/// never reaches it. The cap exists because the set is keyed by a wire-claimed
650/// sender id and nothing else bounds it.
651///
652/// Note what does *not* bound it: requiring a signature. The control gate does
653/// now reject unsigned control frames before dispatch, so every marking path
654/// runs on a verified frame — and that changes nothing here. A signature proves
655/// the signer owns the address it claims; it does not prove the address belongs
656/// to anyone real, and minting one costs a keygen. An attacker spending keygens
657/// produces unlimited distinct well-signed peers, so "written only after a
658/// signature verifies" is not by itself a bound on anything.
659///
660/// It bounds the durable `encryption_capable_peers` storage category as well,
661/// because `OfflineProtocol::record_encryption_capable` persists only for a
662/// peer this cap admitted.
663///
664/// Unlike the other maps keyed by a wire-claimed id, this one **refuses** at
665/// capacity instead of resetting or evicting: forgetting a peer here is the
666/// fail-open direction. See `OfflineProtocol::mark_encryption_capable`.
667pub(crate) const MAX_ENCRYPTION_CAPABLE_PEERS: usize = 8192;
668
669/// Maximum number of blocked users to retain.
670pub(crate) const MAX_BLOCKED_USERS: usize = 10_000;
671
672/// Maximum number of peers tracked for once-per-peer
673/// `PlaintextReceiveRejected` warning suppression.
674///
675/// The keys are wire-claimed (attacker-controllable) sender ids, so the set
676/// resets at capacity instead of growing without bound: a flood of forged
677/// senders degrades the throttle to once-per-peer-per-generation while
678/// memory stays capped.
679pub(crate) const MAX_PLAINTEXT_RECEIVE_WARNED_PEERS: usize = 1000;
680
681/// Maximum number of peers tracked for once-per-peer-per-code control-gate
682/// warning suppression.
683///
684/// Every rejection the control gate reports is reachable from an unauthenticated
685/// frame carrying an attacker-chosen sender id — that is what a gate rejection
686/// *is* — so without a throttle an off-path injector turns the app's event
687/// stream into a flood and desensitizes operators to the one code that has no
688/// benign reading (`SENDER_ADDRESS_MISMATCH`). Bounded and reset at capacity for
689/// the same reason as [`MAX_PLAINTEXT_RECEIVE_WARNED_PEERS`]: the keys are
690/// attacker-controlled, so a forged-sender flood degrades the throttle to
691/// once-per-peer-per-generation rather than growing memory without bound.
692pub(crate) const MAX_CONTROL_GATE_WARNED_PEERS: usize = 1000;
693
694/// Maximum number of pending (received-but-unused) peer key packages retained
695/// in memory and in durable `MlsStorage`.
696///
697/// Keyed by the wire-claimed `sender`, so — like [`MAX_KNOWN_PEERS`] — an
698/// unpinned peer can flood distinct forged ids under the default config. Each
699/// entry is also written to durable secure storage (`persist_peer_key_package`,
700/// iOS Keychain / Android Keystore) and re-loaded on boot, so without this cap
701/// a flood grows durable storage without bound and re-inflates memory on every
702/// restart. At capacity a new peer evicts the soonest-to-expire entry and drops
703/// its persisted copy; the restore-on-boot loop is capped the same way.
704pub(crate) const MAX_PENDING_KEY_PACKAGES: usize = 1000;
705
706/// Maximum outbound messages waiting for one peer's MLS session.
707///
708/// This queue is durable and accepts application-controlled content, so a peer
709/// that never completes session establishment must not grow it without bound.
710/// The limit matches the default inbound pending-decryption per-peer bound.
711///
712/// A count alone does not bound memory or durable storage — message content is
713/// application-supplied — so this cap works together with
714/// [`MAX_PENDING_MESSAGE_BYTES_PER_PEER`]; whichever binds first wins.
715pub(crate) const MAX_PENDING_MESSAGES_PER_PEER: usize = 64;
716
717/// Maximum outbound messages waiting for MLS sessions across all peers.
718///
719/// At capacity the globally oldest message is settled as failed before the new
720/// message is admitted. The limit matches the default inbound pending queue.
721/// Paired with [`MAX_PENDING_MESSAGE_BYTES_GLOBAL`], as above.
722pub(crate) const MAX_PENDING_MESSAGES_GLOBAL: usize = 4096;
723
724/// Maximum pending entries expired in one `process()` tick.
725///
726/// Each expiry is a settlement *and* a durable delete, and the two have to stay
727/// paired — so what gets bounded is how many entries expire per pass, not how
728/// many deletes it may issue. Messages queued in a burst share a `queued_at` and
729/// so come due in a burst; without this, one tick of the 100 ms bindings loop
730/// could issue up to [`MAX_PENDING_MESSAGES_GLOBAL`] synchronous deletes, each a
731/// device barrier on every built-in provider.
732///
733/// The remainder is not deferred indefinitely: the entries left behind are still
734/// past their deadline, so `recompute_next_pending_message_expiry` leaves
735/// `next_pending_message_expiry` in the past and the very next tick drains
736/// another pass.
737pub(crate) const MAX_PENDING_EXPIRIES_PER_PASS: usize = 64;
738
739/// Maximum per-message records one launch writes migrating legacy queues.
740///
741/// The migration's writes are the one part of the pending restore that is not
742/// already bounded by a delete budget, and a `store` is the more expensive of
743/// the two: every built-in provider flushes the record *and* its directory. A
744/// pre-split install sitting near [`MAX_PENDING_MESSAGES_GLOBAL`] would
745/// otherwise pay thousands of device barriers inside `initialize_mls`, on the
746/// launch path, where a mobile watchdog is watching.
747///
748/// Sized to match [`crate::protocol::storage::MAX_RESTORE_PRUNE_DELETES`], so
749/// the whole walk's barrier count stays the same order of magnitude whichever
750/// half of it does the work. Checked per recipient and before its first write,
751/// so a queue is either migrated whole or left entirely on disk for the next
752/// launch — never split across the two layouts with nothing to reconcile it.
753pub(crate) const MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH: usize = 512;
754
755/// Maximum terminal settlements parked by restore for `start()` to drain.
756///
757/// The restore caps already bound how many can be produced, but they bound it
758/// as a sum across every category, and nothing drains this queue until
759/// `start()` — which an application that only calls `initialize_mls`, or that
760/// retries it against a store that keeps failing, may never reach. Sized above
761/// the largest single-category restore ([`MAX_PENDING_MESSAGES_GLOBAL`], the
762/// pending queue's own global cap) so no legitimate restore is ever truncated;
763/// past that point the count is reported instead of the events.
764pub(crate) const MAX_DEFERRED_RESTORE_SETTLEMENTS: usize = 2 * MAX_PENDING_MESSAGES_GLOBAL;
765
766/// Maximum size of application-supplied message content accepted at the public
767/// send boundary, in bytes.
768///
769/// Enforced at the boundary — not at transmit time — because a message queued
770/// behind MLS session establishment never reaches the transport's
771/// `DEFAULT_MAX_MESSAGE_SIZE` (1 MiB) check: it sits in the durable pending
772/// queue first. Without a boundary cap a handful of very large sends could
773/// exhaust mobile memory and protocol-state disk while still being formally
774/// "within" the count caps above.
775///
776/// 256 KiB leaves ample headroom under the 1 MiB transport ceiling for MLS
777/// ciphertext expansion, base64, and the JSON wire envelope, so anything this
778/// path accepts can actually be delivered. Larger payloads belong on the media
779/// path (`send_media`), which chunks.
780pub(crate) const MAX_MESSAGE_CONTENT_BYTES: usize = 256 * 1024;
781
782/// Maximum total serialized bytes of one peer's pending-session queue.
783///
784/// At capacity the peer's oldest entries are settled as failed until the
785/// incoming message fits, exactly like the count cap. Deliberately larger than
786/// [`MAX_MESSAGE_CONTENT_BYTES`] plus [`MAX_RICH_EXTRAS_BYTES`] so a single
787/// boundary-legal message always fits in an empty queue and admission can never
788/// livelock (pinned by `pending_queue_byte_budgets_admit_any_boundary_legal_message`).
789///
790/// # Durable cost
791///
792/// Pending messages are persisted one record per message
793/// ([`storage_keys::PENDING_MESSAGE_ENTRIES`]), so an enqueue writes its own
794/// entry and nothing else: filling one peer to this budget costs `budget` bytes
795/// written, not `budget × entries / 2`. The per-recipient layout this replaced
796/// re-serialized the peer's whole queue on every enqueue, which made the byte
797/// cost quadratic in the entry count.
798///
799/// Bytes were never the expensive half, though, and sizing this budget off them
800/// would be reading the wrong meter. Every built-in provider pays two device
801/// barriers per `store` (the record's own flush plus its directory's) and one
802/// per `delete`, *regardless of record size* — so a 500-byte write and a 2 MiB
803/// write cost the same in the resource that actually dominates. What the
804/// per-message layout buys is not primarily speed but that a record which will
805/// not open still names the message it lost; see
806/// [`storage_keys::PENDING_MESSAGE_ENTRIES`].
807///
808/// So this budget is still sized to bound a pathological queue rather than to
809/// describe a normal one — real queues hold a handful of short messages waiting
810/// on a handshake. Lowering it below `MAX_MESSAGE_CONTENT_BYTES +
811/// MAX_RICH_EXTRAS_BYTES` breaks admission outright.
812pub(crate) const MAX_PENDING_MESSAGE_BYTES_PER_PEER: usize = 2 * 1024 * 1024;
813
814/// Maximum total serialized bytes of the pending-session queue across all
815/// peers. Bounds the whole feature's footprint on a mobile device regardless of
816/// how many peers are mid-establishment.
817pub(crate) const MAX_PENDING_MESSAGE_BYTES_GLOBAL: usize = 16 * 1024 * 1024;
818
819/// Maximum size of a single persisted protocol-state record, in bytes.
820///
821/// Enforced on both sides of [`crate::ProtocolStateStorage`]: the SDK refuses to
822/// write a larger record, and refuses to *deserialize* a larger one on restore
823/// (dropping it instead), so a corrupted or tampered state file cannot turn
824/// into an unbounded allocation during initialization.
825///
826/// Sized above [`MAX_PENDING_MESSAGE_BYTES_PER_PEER`] with room for JSON and
827/// seal overhead. That was once tight rather than generous: under the
828/// per-recipient pending layout the largest legitimate record *was* one peer's
829/// full queue, so the two constants were one bound apart. Now that pending
830/// messages are keyed per id the largest legitimate record is a single message
831/// ([`MAX_MESSAGE_CONTENT_BYTES`] plus [`MAX_RICH_EXTRAS_BYTES`]) or one outbox
832/// entry, and the headroom is real. Keep the ordering anyway — a legacy
833/// per-recipient record still has to be readable for long enough to migrate it.
834pub(crate) const MAX_PROTOCOL_STATE_RECORD_BYTES: usize = 4 * 1024 * 1024;
835
836/// Maximum number of peers remembered in `key_package_sent_to` (the peers we
837/// have pushed our key package to, with when we last did).
838///
839/// Wire-claimed ids grow this set in lockstep with a key-package flood, so it
840/// resets at capacity like [`MAX_PLAINTEXT_RECEIVE_WARNED_PEERS`]: the only cost
841/// of forgetting a peer is a one-time idempotent re-send of our key package.
842pub(crate) const MAX_KEY_PACKAGE_SENT_TO: usize = 1000;
843
844/// Maximum number of peers tracked in `rekey_due_at` (the per-peer epoch-desync
845/// re-key floor).
846///
847/// The desync classification that reaches `schedule_session_rekey` is produced
848/// by OpenMLS *before* it authenticates anything, so the peer id keying this map
849/// is wire-claimed. The envelope/sender binding in `SessionManager::decrypt_message`
850/// already confines that id to peers we hold a session with, which bounds the map
851/// on its own; this cap is the same defence-in-depth every other wire-keyed map
852/// carries. Resets at capacity like [`MAX_KEY_PACKAGE_SENT_TO`] — forgetting a
853/// peer only costs one extra re-key being allowed through early.
854pub(crate) const MAX_REKEY_TRACKED_PEERS: usize = 1000;
855
856/// A corroboration challenge's nonce, and the session it was issued under
857/// (its establishment stamp).
858pub(crate) type CorroborationChallenge = (
859    [u8; offline_protocol_sealed::SESSION_CORROBORATION_NONCE_LEN],
860    Option<u64>,
861);
862
863/// Maximum lifetime (in milliseconds) honored for a *received* peer key
864/// package's cached expiry.
865///
866/// `remaining_lifetime_ms` arrives on the wire unauthenticated and becomes the
867/// eviction sort key for [`MAX_PENDING_KEY_PACKAGES`] (the soonest-to-expire
868/// entry is evicted first). Without a ceiling, a flood of forged senders
869/// claiming a maximal lifetime would pin their entries as latest-to-expire and
870/// preferentially evict legitimate peers. Key packages are minted with a 30-day
871/// lifetime (`DEFAULT_KEY_PACKAGE_LIFETIME_SECS` in `offline-protocol-mls`), so
872/// a larger value is never legitimate. This bound is purely cache bookkeeping —
873/// OpenMLS enforces real key-package validity at use time — so clamping only
874/// affects when we drop the *cached* copy, never crypto correctness.
875pub(crate) const MAX_KEY_PACKAGE_LIFETIME_MS: u64 = 30 * 24 * 60 * 60 * 1000;
876
877/// Durable record that a peer has proved it runs MLS.
878///
879/// Written whenever a control message from that peer verifies — signature
880/// valid, and the signing key derives to the address the frame claims — and
881/// read back on `initialize_mls` to reseed
882/// [`OfflineProtocol::encryption_capable_peers`](crate::OfflineProtocol).
883///
884/// The timestamp is diagnostic, not a policy input. Its predecessor
885/// (`TofuEntry.last_seen_ms`) drove LRU eviction of a bounded pin store; there
886/// is no eviction here, because there is no longer any per-peer *secret* whose
887/// size needs bounding — only the fact, which the restore walk bounds on its
888/// own terms. Keeping the field costs nothing and makes a stale store readable
889/// by a human.
890#[derive(Clone, Debug, Serialize, Deserialize)]
891pub(crate) struct EncryptionCapableEntry {
892    /// Milliseconds since epoch (UTC) when this peer last proved it runs MLS.
893    pub(crate) last_seen_ms: i64,
894    /// Whether this peer has ever presented a control-frame signature over the
895    /// freshness-bound payload (`offline-ctrl-v2`).
896    ///
897    /// A **ratchet**, and the only reason the freshness check cannot be
898    /// side-stepped: without it an attacker replays a capture from before the
899    /// peer upgraded, the older payload verifies exactly as it always did, and
900    /// the whole check is bypassed by choosing an old enough recording. Once
901    /// this is set, that peer's v1 control frames are refused.
902    ///
903    /// It rides here rather than in [`PeerCapabilities`] because the two record
904    /// different things. That one holds what a peer *advertised*, is
905    /// overwritten wholesale by each fresh key package, and answers "what
906    /// should we send them"; this one holds what a peer *proved*, must never be
907    /// cleared by anything a network attacker can cause, and answers "what will
908    /// we accept from them". Storing it in a record that is routinely
909    /// overwritten is how a ratchet silently stops being one.
910    ///
911    /// `#[serde(default)]` gives `false` for records written before the field
912    /// existed, which is correct: an install that has never verified a v2
913    /// signature from a peer has not proved anything about them.
914    #[serde(default)]
915    pub(crate) ctrl_freshness_proved: bool,
916    /// The signed timestamp of the most recent session reset acted on from
917    /// this peer, or `0` if none.
918    ///
919    /// A high-water mark, and what turns the freshness *window* into an actual
920    /// closure for the one directive that destroys state. The window alone
921    /// bounds a captured reset to thirty days; it does not stop the frame
922    /// being spent inside them, and the receive deduplicator forgets a message
923    /// id after an hour. Without this, a recording tears the pair's session
924    /// down once an hour for a month.
925    ///
926    /// Only ever read from and written by a frame whose timestamp is *inside
927    /// the signature*. On a frame signed under the older payload the timestamp
928    /// is attacker-rewritable, so honouring one here would let anyone park
929    /// this mark at `i64::MAX` and permanently deny that peer the ability to
930    /// heal a forked session, which is a worse failure than the one being
931    /// fixed.
932    #[serde(default)]
933    pub(crate) last_reset_ms: i64,
934    /// Whether this peer has ever sent us a service frame sealed inside the
935    /// 1:1 session, which proves it routes the sealed form.
936    ///
937    /// A **ratchet**, here rather than in [`PeerCapabilities`] for the reason
938    /// `ctrl_freshness_proved` is: that record is what a peer advertised and
939    /// every key package overwrites it. Kept there, one replayed key package
940    /// from before the peer upgraded would erase the proof and send that
941    /// peer's service bodies in signed plaintext again, and a key package
942    /// signed under `offline-ctrl-v1` is admitted at any age (the key-package
943    /// escape), so no freshness window bounds that replay.
944    ///
945    /// `#[serde(default)]` gives `false` for records written before the field
946    /// existed, which is correct: nothing was proved.
947    #[serde(default)]
948    pub(crate) svc_sealed_proved: bool,
949}
950
951/// Payload for key package exchange, and the compact envelope version it
952/// advertises.
953///
954/// Both live in `offline-protocol-sealed`: a leaf node builds and reads this
955/// payload with a different MLS implementation, and two ends that disagree
956/// about its shape cannot negotiate anything. Re-exported here so every
957/// existing use site is unchanged.
958pub(crate) use offline_protocol_sealed::{KeyPackagePayload, MLS_ENVELOPE_COMPACT_V1};
959
960/// Sealed rich-payload version advertised in
961/// [`KeyPackagePayload::rich_versions`]: the decrypted MLS plaintext may be
962/// `__RICH_V1__` + JSON of [`RichPayloadV1`], carrying reply context, rich
963/// media metadata, and forward attribution inside the AEAD boundary instead
964/// of on the relay-visible outer message.
965pub(crate) const RICH_PAYLOAD_V1: u8 = 1;
966
967/// Upper bound on the serialized size of the rich extras accepted by the
968/// rich send surface. Enforced at the API boundary (`send_message_with`),
969/// deliberately NOT at seal time: a message queued behind session
970/// establishment re-makes the seal decision at flush, and a seal-time
971/// failure there would re-queue the message forever. Bounding at the
972/// boundary means every queued extras blob is already known to seal.
973pub(crate) const MAX_RICH_EXTRAS_BYTES: usize = 32 * 1024;
974
975/// Replicated-document sync version advertised in
976/// [`KeyPackagePayload::data_versions`]: the decrypted MLS plaintext may be
977/// `__DATA_V1__` + JSON of a sync frame (version-vector exchange, delta
978/// push, or snapshot offer).
979///
980/// One number covers both the frame schema and the document encoding the
981/// frames carry. Splitting them would let a peer advertise a frame schema it
982/// speaks around an encoding it cannot read, and the failure would surface
983/// as an unreadable document rather than as a refused negotiation.
984pub(crate) const DATA_SYNC_V1: u8 = 1;
985
986/// Group-space replication, advertised in
987/// [`KeyPackagePayload::data_versions`] alongside [`DATA_SYNC_V1`]: the
988/// sender intercepts `__DATA_V1__` frames arriving inside a *group*
989/// ciphertext, not only inside a 1:1 one.
990///
991/// A separate entry rather than a bump, and the reason is a compatibility
992/// trap rather than tidiness. A build that speaks only [`DATA_SYNC_V1`]
993/// advertises it, holds group sessions, and has no group interception at
994/// all: every group path emits whatever decrypts as a chat message, so a
995/// group sync frame sent to one would surface to its user as literal
996/// `__DATA_V1__{...}` text. Gating group sends on this entry is what keeps
997/// that frame from ever being sent, and appending rather than replacing is
998/// what keeps 1:1 replication working with those same peers.
999pub(crate) const DATA_GROUP_V1: u8 = 2;
1000
1001/// Attachment and snapshot carriage over the media path, advertised in
1002/// [`KeyPackagePayload::data_versions`] alongside [`DATA_SYNC_V1`]: the
1003/// sender understands the `need_blob` and `blob_gone` frame kinds, and
1004/// routes a media transfer marked as data-purposed into the data layer
1005/// instead of surfacing it to its user as a received file.
1006///
1007/// A third entry rather than a bump, for the reason the second one exists.
1008/// A build speaking only [`DATA_SYNC_V1`] replicates documents correctly and
1009/// has no arm for either new frame kind; it consumes them (the parser reads
1010/// the version first and the body second, so an unknown kind is dropped
1011/// rather than surfaced) but answers nothing, so a fetch aimed at it waits
1012/// forever. Worse, such a build receiving a data-purposed media transfer
1013/// emits `FileReceived` and hands a person a CRDT snapshot as a downloaded
1014/// file. Gating on this entry is what stops both.
1015///
1016/// v1 carries blobs on 1:1 sessions only, so this entry has no attested
1017/// sibling: group members never exchange key packages with each other, and
1018/// there is nothing about group carriage for an inviter to attest.
1019pub(crate) const DATA_MEDIA_V1: u8 = 3;
1020
1021/// Document removals, advertised in [`KeyPackagePayload::data_versions`]
1022/// alongside [`DATA_SYNC_V1`]: the sender reads the `gone` field of a
1023/// version offer, and removes what a floor covers instead of offering it
1024/// back.
1025///
1026/// A fourth entry rather than a bump, for the reason the second and third
1027/// exist, and here the compatibility story is unusually gentle. A build
1028/// without this entry ignores an unknown field on a frame kind it knows, so
1029/// it keeps its copy and re-offers it forever; this device refuses each
1030/// offer and each blob against its own floor, so the removal still holds
1031/// everywhere it is known. Nothing is surfaced wrongly and nothing loops:
1032/// the cost is one offer per exchange that is answered with nothing.
1033///
1034/// Gating the *field* on the entry is therefore a traffic decision rather
1035/// than a safety one, which is why a group carries it ungated: one
1036/// ciphertext reaches the whole roster, so there is no per-member choice to
1037/// make, and the roster-wide gate on [`DATA_GROUP_V1`] already decides
1038/// whether the frame may be sent at all.
1039pub(crate) const DATA_TOMBSTONE_V1: u8 = 4;
1040
1041/// Interest-scoped replication, advertised in
1042/// [`KeyPackagePayload::data_versions`] alongside [`DATA_SYNC_V1`]: the
1043/// sender reads the `want` field of a version offer and answers only the
1044/// documents it names.
1045///
1046/// A fifth entry rather than a bump, and like the fourth it gates traffic
1047/// rather than correctness. A peer without it ignores the field and answers
1048/// with everything it holds, and the asking side refuses what it did not ask
1049/// for on arrival, so the narrowing holds regardless; what it does not do is
1050/// save the radio. Gating means a narrowed space says so only where saying so
1051/// changes what comes back.
1052///
1053/// The half that does not depend on this entry is the local refusal, and it
1054/// is the reason interest is safe to ship before any peer speaks it.
1055pub(crate) const DATA_INTEREST_V1: u8 = 5;
1056
1057/// Attachment bytes inside a group, advertised in
1058/// [`KeyPackagePayload::data_versions`] alongside [`DATA_SYNC_V1`]: the
1059/// sender answers a `need_blob` that arrives inside a group ciphertext, and
1060/// carries the bytes back as `chunk` frames under the group key.
1061///
1062/// A sixth entry rather than a bump, and the first `data_versions` entry
1063/// since the second with an attested sibling. [`DATA_MEDIA_V1`] has none
1064/// because carriage was 1:1 and there was nothing about it for a group
1065/// inviter to attest; this one is about a group, so there is.
1066///
1067/// What it gates is a better answer to a question, not safety. A member
1068/// without it receives a `need_blob` it understands the shape of and
1069/// refuses, and receives `chunk` frames whose kind it does not know and
1070/// drops: the version is read before the body, so an unknown kind is
1071/// consumed rather than surfaced. Nothing is shown to anybody wrongly. What
1072/// the entry buys is refusing a fetch at the call instead of leaving an
1073/// application waiting out the silence timeout for an answer that was never
1074/// coming.
1075pub(crate) const DATA_GROUP_BLOB_V1: u8 = 6;
1076
1077/// The custody receipt, advertised in [`KeyPackagePayload::data_versions`]
1078/// alongside [`DATA_SYNC_V1`]: the peer parses the `__CUSTODY_RECEIPT__`
1079/// control frame, so a custodian that accepted one of its replication frames
1080/// may answer with a receipt (`docs/spec/custody.md`).
1081///
1082/// It gates one frame in one direction and nothing else. A deposit request is
1083/// a metadata key an unaware receiver ignores, so a depositor writes it toward
1084/// any neighbour, and acceptance is decided by the custodian's quotas. The
1085/// gate exists because a peer without the entry does not know the prefix as a
1086/// control frame: with encryption on it refuses the receipt as inbound
1087/// plaintext and records a security refusal, and on a plaintext-only
1088/// deployment it shows the receipt to its user as a message.
1089///
1090/// In the replication family rather than a list of its own because custody
1091/// carries replication frames only. No attested sibling: the receipt is a 1:1
1092/// control frame between neighbours, and a group inviter has nothing to say
1093/// about one.
1094pub(crate) const DATA_CUSTODY_V1: u8 = 7;
1095
1096/// Rich fields accepted by the `send_message_with` surface. Only ever
1097/// delivered inside the sealed [`RichPayloadV1`] body — toward a recipient
1098/// that did not advertise [`RICH_PAYLOAD_V1`] they are silently dropped,
1099/// never sent cleartext.
1100#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1101pub(crate) struct RichSendExtras {
1102    #[serde(default, skip_serializing_if = "Option::is_none")]
1103    pub(crate) reply_context: Option<ReplyContext>,
1104    #[serde(default, skip_serializing_if = "Option::is_none")]
1105    pub(crate) media_metadata: Option<MediaMetadata>,
1106    #[serde(default, skip_serializing_if = "Option::is_none")]
1107    pub(crate) forward_info: Option<ForwardInfo>,
1108}
1109
1110impl RichSendExtras {
1111    /// Whether any rich field is present (empty extras never seal).
1112    pub(crate) fn is_any(&self) -> bool {
1113        self.reply_context.is_some() || self.media_metadata.is_some() || self.forward_info.is_some()
1114    }
1115
1116    /// Enforces [`MAX_RICH_EXTRAS_BYTES`] on the serialized extras. Shared
1117    /// by every rich send boundary (`send_message_with`, the forward paths,
1118    /// and the group surface) — enforced there, never at seal time, so a
1119    /// queued or re-sent message is always known to seal (see the
1120    /// constant's doc). Empty extras always pass.
1121    pub(crate) fn check_size(&self) -> crate::Result<()> {
1122        if !self.is_any() {
1123            return Ok(());
1124        }
1125        let extras_len = serde_json::to_vec(self)
1126            .map_err(|e| Error::Serialization(e.to_string()))?
1127            .len();
1128        if extras_len > MAX_RICH_EXTRAS_BYTES {
1129            return Err(Error::InvalidArgument(format!(
1130                "Rich extras too large: {} bytes serialized (max {})",
1131                extras_len, MAX_RICH_EXTRAS_BYTES
1132            )));
1133        }
1134        Ok(())
1135    }
1136}
1137
1138/// Options for `OfflineProtocol::send_message_with`: priority and reply
1139/// threading (as on `send_message`), plus the rich fields introduced with
1140/// the sealed rich payload.
1141///
1142/// The rich fields (`reply_context`, `media_metadata`, `forward_info`) only
1143/// ever travel inside the MLS-sealed `__RICH_V1__` body, and only toward
1144/// recipients whose key package advertised `rich_versions` support. Toward
1145/// anyone else they are silently dropped — never sent cleartext — so the
1146/// message degrades to plain text with `reply_to_msg` threading intact.
1147#[derive(Debug, Clone, Default)]
1148pub struct SendMessageOptions {
1149    /// Message priority (defaults to Medium).
1150    pub priority: Option<MessagePriority>,
1151    /// ID of the message this is replying to.
1152    pub reply_to_msg: Option<String>,
1153    /// Content type stamped on the outer message (defaults to Text). A
1154    /// coarse rendering hint — the actual content stays MLS-sealed. Toward
1155    /// a recipient that advertised the sealed rich payload, a copy travels
1156    /// inside the sealed body — whenever extras seal, or the hint itself is
1157    /// non-Text — and the receiver treats that copy as authoritative, so a
1158    /// relay cannot rewrite the hint. Must not be
1159    /// [`ContentType::FileChunk`] (an internal transport content type; the
1160    /// receiver would route the message into its file-transfer manager and
1161    /// drop it) — rejected as `InvalidArgument`.
1162    pub content_type: Option<ContentType>,
1163    /// Quoted-reply context, delivered sealed-only.
1164    pub reply_context: Option<ReplyContext>,
1165    /// Rich media metadata (cloud attachments, stickers — including any
1166    /// `encryption_key`/`iv` secrets), delivered sealed-only.
1167    pub media_metadata: Option<MediaMetadata>,
1168    /// Forward attribution, delivered sealed-only.
1169    pub forward_info: Option<ForwardInfo>,
1170    /// Send via this specific transport (bypassing DORS selection), like
1171    /// `send_message_via_transport`.
1172    pub via_transport: Option<TransportType>,
1173    /// Application id stamped on this message, in place of the configured
1174    /// [`crate::ProtocolConfig::app_id`].
1175    ///
1176    /// For an instance that serves several local applications behind one
1177    /// identity: the receiver's `MessageReceived.app_id` reports it, so the
1178    /// receiving instance can route the message to the right client. It
1179    /// changes the stamp on this one frame only, never the storage
1180    /// namespace, the MLS session, the BLE app tag, or the id on the
1181    /// acknowledgements and control frames the engine sends for it.
1182    ///
1183    /// It travels in the clear, outside the sealed body and outside every
1184    /// signature, so any carrier can read and rewrite it (threat model
1185    /// R17). Treat it as routing metadata, never as an authorization input.
1186    /// Held to the same rules as the configured id; an invalid value fails
1187    /// the call with `InvalidArgument` before anything is queued.
1188    pub app_id: Option<String>,
1189    /// Send once over the live direct link to the recipient, never
1190    /// acknowledged or retried (ADR 0032): for state the next send replaces,
1191    /// such as a game's positions. Fails with `PeerNotReachable` when there
1192    /// is no such link and with `SessionNotReady` when encryption is on and
1193    /// no confirmed session exists; nothing is queued either way. The
1194    /// message produces `MessageSent` and never `MessageDelivered` or
1195    /// `MessageFailed`. Plain content only: reply threading, rich extras, a
1196    /// non-Text content type or `via_transport` is refused with
1197    /// `InvalidArgument`.
1198    pub ephemeral: bool,
1199}
1200
1201/// Options for `OfflineProtocol::send_media_with`: the chunk-0 media
1202/// metadata (as on `send_media`), plus the rich fields introduced with the
1203/// sealed rich payload and an optional caller-supplied `file_id`.
1204///
1205/// The rich fields (`caption`, `reply_to_msg`, `reply_context`,
1206/// `forward_info`) only ever travel inside the MLS-sealed chunk-0 plaintext
1207/// (v2 media envelope), and only toward recipients whose key package
1208/// advertised `rich_versions` support. Toward anyone else — including every
1209/// plaintext (encryption opt-out) transfer — they are silently dropped,
1210/// never sent cleartext, so the transfer degrades to what plain
1211/// `send_media` sends.
1212#[derive(Debug, Clone, Default)]
1213pub struct MediaSendOptions {
1214    /// Media metadata delivered with chunk 0 (as on `send_media`).
1215    pub media_metadata: Option<MediaMetadata>,
1216    /// Caption text, delivered sealed-only.
1217    pub caption: Option<String>,
1218    /// ID of the message this media replies to, delivered sealed-only.
1219    pub reply_to_msg: Option<String>,
1220    /// Quoted-reply context, delivered sealed-only.
1221    pub reply_context: Option<ReplyContext>,
1222    /// Forward attribution, delivered sealed-only.
1223    pub forward_info: Option<ForwardInfo>,
1224    /// Caller-supplied file id for the transfer (minted when absent). Must
1225    /// not collide with an active outbound transfer; bounded to the wire
1226    /// `file_id` field limit.
1227    pub file_id: Option<String>,
1228    /// Application id stamped on every chunk of this transfer, in place of
1229    /// the configured one. Same meaning, same limits and the same cleartext
1230    /// caveat as [`SendMessageOptions::app_id`].
1231    pub app_id: Option<String>,
1232}
1233
1234/// The sealed rich body: what `__RICH_V1__` + JSON carries inside the MLS
1235/// plaintext. `text` is the user-visible content; the optional fields are
1236/// restored onto the inbound message by `apply_decrypted_content` *after*
1237/// its outer-field strip, making the sealed body the sole trusted carrier
1238/// for rich data on encrypted messages.
1239#[derive(Clone, Serialize, Deserialize)]
1240pub(crate) struct RichPayloadV1 {
1241    pub(crate) text: String,
1242    #[serde(default, skip_serializing_if = "Option::is_none")]
1243    pub(crate) reply_context: Option<ReplyContext>,
1244    #[serde(default, skip_serializing_if = "Option::is_none")]
1245    pub(crate) media_metadata: Option<MediaMetadata>,
1246    #[serde(default, skip_serializing_if = "Option::is_none")]
1247    pub(crate) forward_info: Option<ForwardInfo>,
1248    /// Sealed copy of the outer `content_type` rendering hint — the last
1249    /// rich-adjacent field a relay could rewrite in transit. Additive after
1250    /// the body first shipped: absent from bodies sealed by older senders,
1251    /// in which case the outer value stands. When present it is
1252    /// authoritative on receive (except `FileChunk`, refused there like at
1253    /// the send boundary), so a relay can no longer restamp the rendering
1254    /// hint — or worse, restamp it `FileChunk` and get the decrypted
1255    /// message routed into the file-transfer manager and dropped. Fresh
1256    /// sends with a non-Text hint seal a hint-only body even without
1257    /// extras. Forwards seal their attribution and media metadata as
1258    /// extras toward capable recipients; only legacy queued forwards
1259    /// (persisted by older builds, outer-only) skip the hint-only seal,
1260    /// since a sealed body would wipe their outer copies on restore.
1261    #[serde(default, skip_serializing_if = "Option::is_none")]
1262    pub(crate) content_type: Option<ContentType>,
1263}
1264
1265impl RichPayloadV1 {
1266    /// Parses a sealed `__RICH_V1__` body out of decrypted MLS plaintext.
1267    ///
1268    /// Shared by the DM restore (`apply_decrypted_content`) and the group
1269    /// message inbound paths. Never capability-gated (mirroring envelope
1270    /// parsing): whatever a peer chose to seal, we try to read. Returns
1271    /// `None` when the prefix is absent or the body fails to parse (logged;
1272    /// callers surface the raw text rather than dropping an authenticated
1273    /// message). A sealed `FileChunk` content-type claim is refused here —
1274    /// mirroring the send boundary — so a hostile sender can never steer a
1275    /// decrypted message into the file-transfer manager, which would drop
1276    /// it.
1277    pub(crate) fn parse_sealed(plaintext: &str, sender: &str) -> Option<Self> {
1278        let sealed = plaintext.strip_prefix(super::internal_prefixes::RICH_V1)?;
1279        match serde_json::from_str::<Self>(sealed) {
1280            Ok(mut rich) => {
1281                if rich.content_type == Some(ContentType::FileChunk) {
1282                    warn!(
1283                        sender = %sender,
1284                        "Sealed rich payload claims the internal FileChunk content type, ignoring the hint"
1285                    );
1286                    rich.content_type = None;
1287                }
1288                Some(rich)
1289            }
1290            Err(e) => {
1291                warn!(
1292                    sender = %sender,
1293                    error = %e,
1294                    "Failed to parse sealed rich payload, surfacing raw text"
1295                );
1296                None
1297            }
1298        }
1299    }
1300}
1301
1302/// An outbound connection request awaiting a transport outcome (see
1303/// `OfflineProtocol::pending_connection_requests`).
1304#[derive(Debug, Clone)]
1305pub(crate) struct PendingConnectionRequest {
1306    /// Recipient the request was addressed to.
1307    pub(crate) recipient: String,
1308    /// When the request was sent — entries older than
1309    /// [`PENDING_CONNECTION_REQUEST_TTL`] are pruned on insert.
1310    pub(crate) sent_at: offline_protocol_core::time::Instant,
1311}
1312
1313/// How long an outbound connection request stays correlatable to a
1314/// transport failure. Past this window the entry is pruned: a DeliveryError
1315/// that stale belongs to a request the app has long stopped showing a
1316/// spinner for.
1317///
1318/// Deliberately wider than the bridges' 60s `RecipientInFlightTracker` TTL:
1319/// that window anchors at the socket write, while this one anchors at
1320/// `send_connection_request` — a request can dwell in the internet outbox
1321/// (device offline, relay reconnecting) before its wire attempt, and this
1322/// window must cover that dwell plus the bridge's correlation window.
1323///
1324/// Also deliberately wider than the worst-case default ACK retry schedule
1325/// (10 retries, 1s initial delay, x2 backoff capped at 5 min, plus a 10s
1326/// ACK timeout per attempt — up to ~910s end to end): retry exhaustion is
1327/// the last settlement point that can still emit a typed undeliverable
1328/// event, so the window must outlive it with headroom.
1329pub(crate) const PENDING_CONNECTION_REQUEST_TTL: std::time::Duration =
1330    std::time::Duration::from_secs(1800);
1331
1332/// Cap on tracked outbound connection requests (oldest evicted first).
1333pub(crate) const MAX_PENDING_CONNECTION_REQUESTS: usize = 64;
1334
1335/// Upper bound (UTF-8 bytes) for `initial_message` on a connection request.
1336/// The request is a plaintext High-priority control frame; an unbounded
1337/// first message would fragment heavily over BLE and can exceed relay frame
1338/// limits after the SDK already returned a message id, so oversized input
1339/// fails loudly at the API instead.
1340pub(crate) const MAX_INITIAL_MESSAGE_BYTES: usize = 4096;
1341
1342/// Payload for a connection request message.
1343#[derive(Debug, Clone, Serialize, Deserialize)]
1344pub(crate) struct ConnectionRequestPayload {
1345    /// Display name of the sender.
1346    pub(crate) sender_name: String,
1347    /// Timestamp of the request (Unix ms).
1348    pub(crate) timestamp_ms: i64,
1349    /// Optional MLS key package data for encrypted session setup.
1350    #[serde(skip_serializing_if = "Option::is_none")]
1351    pub(crate) key_package: Option<Vec<u8>>,
1352    /// Optional first message sent along with the request (`default` keeps
1353    /// payloads from pre-initial-message senders parseable).
1354    #[serde(default, skip_serializing_if = "Option::is_none")]
1355    pub(crate) initial_message: Option<String>,
1356}
1357
1358/// Payload for a connection accepted message.
1359#[derive(Debug, Clone, Serialize, Deserialize)]
1360pub(crate) struct ConnectionAcceptedPayload {
1361    /// Display name of the accepting party.
1362    pub(crate) accepted_by_name: String,
1363    /// Timestamp of the acceptance (Unix ms).
1364    #[serde(default)]
1365    pub(crate) timestamp_ms: i64,
1366    /// Optional MLS key package data for encrypted session setup.
1367    #[serde(skip_serializing_if = "Option::is_none")]
1368    pub(crate) key_package: Option<Vec<u8>>,
1369}
1370
1371// --- Presence, typing, and read receipt payloads ---
1372
1373/// Maximum number of message IDs allowed in a single read receipt.
1374pub(crate) const MAX_READ_RECEIPT_IDS: usize = 256;
1375
1376/// Payload for a presence update message.
1377#[derive(Debug, Clone, Serialize, Deserialize)]
1378pub(crate) struct PresencePayload {
1379    /// Presence status.
1380    pub(crate) status: PresenceStatus,
1381    /// Timestamp of the update (Unix ms).
1382    pub(crate) timestamp_ms: i64,
1383}
1384
1385/// Payload for a typing indicator message.
1386#[derive(Debug, Clone, Serialize, Deserialize)]
1387pub(crate) struct TypingIndicatorPayload {
1388    /// Opaque conversation identifier chosen by the sender (conventionally a
1389    /// peer address for DMs, the group id for groups). Never parsed here.
1390    pub(crate) conversation_id: String,
1391    /// Whether the user is currently typing.
1392    pub(crate) is_typing: bool,
1393    /// Timestamp of the indicator (Unix ms).
1394    pub(crate) timestamp_ms: i64,
1395}
1396
1397/// Payload for a read receipt message.
1398#[derive(Debug, Clone, Serialize, Deserialize)]
1399pub(crate) struct ReadReceiptPayload {
1400    /// IDs of the messages that were read.
1401    pub(crate) message_ids: Vec<String>,
1402    /// Timestamp when the messages were read (Unix ms).
1403    pub(crate) timestamp_ms: i64,
1404}
1405
1406// --- Group (relay) payloads ---
1407
1408#[derive(Debug, Clone, Serialize, Deserialize)]
1409pub(crate) struct GroupCreatedPayload {
1410    pub(crate) group_id: String,
1411    pub(crate) name: String,
1412}
1413
1414#[derive(Debug, Clone, Serialize, Deserialize)]
1415pub(crate) struct GroupMessageReceivedPayload {
1416    pub(crate) group_id: String,
1417    pub(crate) sender: String,
1418    pub(crate) content: String,
1419    pub(crate) timestamp: String,
1420    pub(crate) message_id: String,
1421    #[serde(default, skip_serializing_if = "Option::is_none")]
1422    pub(crate) reply_to_msg: Option<String>,
1423    /// Forwarding attribution (present when the group message was forwarded).
1424    #[serde(default, skip_serializing_if = "Option::is_none")]
1425    pub(crate) forward_info: Option<offline_protocol_core::ForwardInfo>,
1426}
1427
1428#[derive(Debug, Clone, Serialize, Deserialize)]
1429pub(crate) struct GroupMemberAddedPayload {
1430    pub(crate) group_id: String,
1431    pub(crate) user_id: String,
1432    pub(crate) added_by: String,
1433    #[serde(skip_serializing_if = "Option::is_none")]
1434    pub(crate) group_name: Option<String>,
1435}
1436
1437#[derive(Debug, Clone, Serialize, Deserialize)]
1438pub(crate) struct GroupMemberRemovedPayload {
1439    pub(crate) group_id: String,
1440    pub(crate) user_id: String,
1441    pub(crate) removed_by: String,
1442}
1443
1444#[derive(Debug, Clone, Serialize, Deserialize)]
1445pub(crate) struct GroupInfoMemberPayload {
1446    pub(crate) user_id: String,
1447    pub(crate) role: String,
1448    pub(crate) joined_at: String,
1449}
1450
1451#[derive(Debug, Clone, Serialize, Deserialize)]
1452pub(crate) struct GroupInfoPayload {
1453    pub(crate) group_id: String,
1454    pub(crate) name: String,
1455    pub(crate) created_by: String,
1456    pub(crate) created_at: String,
1457    pub(crate) members: Vec<GroupInfoMemberPayload>,
1458}
1459
1460#[derive(Debug, Clone, Serialize, Deserialize)]
1461pub(crate) struct UserGroupSummaryPayload {
1462    pub(crate) group_id: String,
1463    pub(crate) name: String,
1464    pub(crate) created_at: String,
1465}
1466
1467#[derive(Debug, Clone, Serialize, Deserialize)]
1468pub(crate) struct UserGroupsPayload {
1469    pub(crate) groups: Vec<UserGroupSummaryPayload>,
1470}
1471
1472#[derive(Debug, Clone, Serialize, Deserialize)]
1473pub(crate) struct GroupErrorPayload {
1474    /// The relay's own wording. **Never emitted on an event** — see
1475    /// [`GroupErrorPayload::classify_reason`]. Treat every read of this
1476    /// field as a read of untrusted, unbounded wire input.
1477    pub(crate) reason: String,
1478    /// Group the error concerns, when the relay scoped it (e.g. a
1479    /// registration sync denial). Used to drop the group from
1480    /// `relay_synced` so sends fall back to per-member delivery.
1481    #[serde(default, skip_serializing_if = "Option::is_none")]
1482    pub(crate) group_id: Option<String>,
1483}
1484
1485impl GroupErrorPayload {
1486    /// Maps the relay's free-text `reason` onto a fixed local vocabulary.
1487    ///
1488    /// `__GROUP_ERROR__` is a relay answer: it rides
1489    /// `RELAY_ANSWER_PREFIXES`, so on the relay ingest shape (Internet
1490    /// arrival, no transport peer identity) it is accepted **unsigned** —
1491    /// no key material, no session, no prior contact, just a frame on that
1492    /// socket. Signed, it is reachable to any peer over any transport, since
1493    /// the handler is deliberately not gated on the Internet path the way
1494    /// the `GROUP_CREATED` ack is. Either way `reason` is whatever the
1495    /// sender wrote: arbitrary content, arbitrary length, not ours.
1496    ///
1497    /// So the text does not travel any further. Nothing downstream needs it
1498    /// — the sync revocation keys off `group_id`, and the platform bridges
1499    /// already dual-emit the raw relay frame on the server-message channel
1500    /// for apps that want the relay's exact wording. What an event carries
1501    /// is this classification instead, which an attacker can at most steer
1502    /// between three harmless codes.
1503    ///
1504    /// Even an *honest* relay makes this necessary: its `Not a member of
1505    /// group {id}` renders an identifier into prose, smuggling past a
1506    /// telemetry scrubber that hashes `group_id` fields but ships free text
1507    /// verbatim by design.
1508    ///
1509    /// The `&'static str` return is load-bearing, the same way
1510    /// `MlsError::privacy_safe_reason` is: it makes interpolating wire input
1511    /// unrepresentable rather than merely discouraged. Matching is exact and
1512    /// the fallback is closed, so a relay rewording an error degrades to
1513    /// `error` — never back to shipping its text.
1514    pub(crate) fn classify_reason(&self) -> &'static str {
1515        match self.reason.as_str() {
1516            // Relay has no such group: invite links and relay fan-out for it
1517            // are dead, not merely refused.
1518            "Group not found" => "not_found",
1519            // Relay refused to register/sync the group for this caller.
1520            "Only admins can sync this group" => "sync_denied",
1521            // Everything else, including anything a forged frame chose.
1522            _ => "error",
1523        }
1524    }
1525}
1526
1527/// A received key package awaiting use for session creation.
1528#[derive(Debug, Clone, Serialize, Deserialize)]
1529pub(crate) struct ReceivedKeyPackage {
1530    /// Raw MLS key package bytes.
1531    pub(crate) key_package_data: Vec<u8>,
1532    /// Local wall-clock deadline (ms since epoch) computed from the sender's
1533    /// `remaining_lifetime_ms`, anchored to *our* clock at receive time.
1534    pub(crate) local_expires_at_ms: u64,
1535}
1536
1537/// Delivery ACKs held for one peer until they go out as one frame (see
1538/// `OfflineProtocol::coalesced_acks_out`).
1539#[derive(Debug, Clone)]
1540pub(crate) struct CoalescedAcks {
1541    /// The carrier the answered frames arrived on, which the ACK goes back
1542    /// over. A frame from another carrier starts a new batch.
1543    pub(crate) transport: offline_protocol_transport::TransportType,
1544    /// When the first held ACK was owed.
1545    pub(crate) since: offline_protocol_core::time::Instant,
1546    /// The first answered frame's hop count, as a single ACK reports it.
1547    pub(crate) hop_count: String,
1548    /// The answered message ids, oldest first.
1549    pub(crate) ids: Vec<String>,
1550}
1551
1552/// Durable record of the end-to-end capability versions a peer last
1553/// advertised in its key package (`env_versions` / `rich_versions`).
1554///
1555/// Persisted separately from [`ReceivedKeyPackage`] because the cached key
1556/// package is deleted once a session is established, while the capabilities
1557/// must survive restarts for exactly those peers: mobile apps restart
1558/// constantly and MLS sessions persist, so without this record a rich send
1559/// right after relaunch silently degrades to bare text (and the compact
1560/// envelope to JSON) until the next live key-package exchange.
1561///
1562/// Stores the raw advertised versions, not the config-gated subset: the kill
1563/// switches (`compact_envelope_enabled` / `rich_payload_enabled`) gate use —
1564/// live recording, restore, and send — not knowledge, so toggling one across
1565/// restarts behaves the same as toggling it live.
1566///
1567/// `wire_versions` is deliberately absent: it is hop-local (which frames a
1568/// directly-connected peer decodes), and connection setup re-exchanges key
1569/// packages on discovery anyway, so persisting it would buy nothing.
1570#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1571pub(crate) struct PeerCapabilities {
1572    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1573    pub(crate) env_versions: Vec<u8>,
1574    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1575    pub(crate) rich_versions: Vec<u8>,
1576    /// Sync versions the peer advertised, from
1577    /// [`KeyPackagePayload::data_versions`]. Persisted for the same reason
1578    /// as the two above: without it a document edited right after relaunch
1579    /// would not replicate until the next live key-package exchange, and a
1580    /// sync that silently does not happen looks exactly like a peer with
1581    /// nothing to say.
1582    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1583    pub(crate) data_versions: Vec<u8>,
1584    /// Rich-payload versions a *group inviter* attested for this peer
1585    /// (carried on the group Add commit / Welcome), as opposed to the
1586    /// direct self-advertised `rich_versions` above. Kept separate because
1587    /// the trust differs: attestation is third-party and may be stale, so a
1588    /// direct key-package exchange overwrites the whole record (clearing
1589    /// this field) — direct knowledge is always authoritative. Consulted
1590    /// only by the group seal gate, never by DM sealing or envelope
1591    /// selection.
1592    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1593    pub(crate) attested_rich_versions: Vec<u8>,
1594    /// Sync versions a *group inviter* attested for this peer, carried on
1595    /// the group Add commit / Welcome exactly like
1596    /// [`Self::attested_rich_versions`] and kept separate from the direct
1597    /// `data_versions` above for the same reason: attestation is
1598    /// third-party and may be stale, so a direct key-package exchange
1599    /// overwrites the whole record and clears this field.
1600    ///
1601    /// Group members never exchange key packages with each other, so
1602    /// without this a group would replicate only among the subset of
1603    /// members that happen to have met directly, and the rest would sit
1604    /// there editing documents nobody receives. Consulted only by the
1605    /// group replication gate, never by 1:1 sync (which always has direct
1606    /// knowledge: session establishment exchanges key packages).
1607    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1608    pub(crate) attested_data_versions: Vec<u8>,
1609
1610    /// Control-frame signing versions the peer advertised, from
1611    /// [`KeyPackagePayload::ctrl_versions`]. Persisted for the same reason as
1612    /// the lists above: without it, a peer met before a restart would be sent
1613    /// the older signing payload until the next live key-package exchange, and
1614    /// a peer that has *proved* it signs the newer one refuses that.
1615    ///
1616    /// This is the advertised half only. What the peer proved lives in
1617    /// [`EncryptionCapableEntry::ctrl_freshness_proved`], deliberately in a
1618    /// record this one's wholesale overwrite cannot reach.
1619    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1620    pub(crate) ctrl_versions: Vec<u8>,
1621
1622    /// Sealed service frame versions the peer advertised, from
1623    /// [`KeyPackagePayload::svc_versions`], recorded only from a signed key
1624    /// package. Persisted for the same reason as the lists above: without it
1625    /// a peer met before a restart would be sent its service bodies in signed
1626    /// plaintext until the next live key-package exchange.
1627    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1628    pub(crate) svc_versions: Vec<u8>,
1629
1630    /// Delivery-ACK statuses the peer reads, from
1631    /// [`KeyPackagePayload::ack_versions`]. Persisted for the same reason as
1632    /// the lists above: a peer met before a restart would otherwise get no
1633    /// `undecryptable` answer until its next key package, and a relay mailbox
1634    /// would keep redelivering its dead frames meanwhile.
1635    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1636    pub(crate) ack_versions: Vec<u8>,
1637
1638    /// The peer's Nostr public key, from [`KeyPackagePayload::nostr_pubkey`].
1639    ///
1640    /// Persisted for the same reason as the capability lists: the cached key
1641    /// package is deleted once a session is established, and a peer met before
1642    /// a restart would otherwise silently fall back to bootstrap-key sealing —
1643    /// a privacy regression with no symptom — until the next live exchange.
1644    #[serde(default, skip_serializing_if = "Option::is_none")]
1645    pub(crate) nostr_pubkey: Option<String>,
1646}
1647
1648/// Length of an x-only secp256k1 public key in lowercase hex.
1649pub(crate) const NOSTR_PUBKEY_HEX_LEN: usize = 64;
1650
1651/// Validates a wire-supplied Nostr public key before it is persisted or used
1652/// as a sealing destination.
1653///
1654/// Shape only — the value is signature-bound to the sender, so this is not an
1655/// authenticity check. It exists so a malformed or oversized string cannot
1656/// enter a durable record, and it normalizes case so the same key never
1657/// persists under two spellings.
1658pub(crate) fn normalize_nostr_pubkey(candidate: &str) -> Option<String> {
1659    if candidate.len() != NOSTR_PUBKEY_HEX_LEN || !candidate.bytes().all(|b| b.is_ascii_hexdigit())
1660    {
1661        return None;
1662    }
1663    Some(candidate.to_ascii_lowercase())
1664}
1665
1666/// Cap on how many advertised version entries persist per capability list
1667/// in a [`PeerCapabilities`] record. The lists are unauthenticated wire
1668/// input stored raw (raw so unknown future versions survive a local
1669/// upgrade), and a hostile advertiser could otherwise bloat each durable
1670/// record up to the transport message size. Truncation only hurts the
1671/// advertiser — real senders list a handful of versions.
1672pub(crate) const MAX_PERSISTED_CAPABILITY_VERSIONS: usize = 8;
1673
1674impl PeerCapabilities {
1675    /// Builds a record from the wire-advertised version lists, truncating
1676    /// each to [`MAX_PERSISTED_CAPABILITY_VERSIONS`].
1677    pub(crate) fn from_advertised(
1678        env_versions: &[u8],
1679        rich_versions: &[u8],
1680        data_versions: &[u8],
1681        ctrl_versions: &[u8],
1682        svc_versions: &[u8],
1683        ack_versions: &[u8],
1684        nostr_pubkey: Option<&str>,
1685    ) -> Self {
1686        Self {
1687            env_versions: env_versions
1688                .iter()
1689                .copied()
1690                .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
1691                .collect(),
1692            rich_versions: rich_versions
1693                .iter()
1694                .copied()
1695                .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
1696                .collect(),
1697            data_versions: data_versions
1698                .iter()
1699                .copied()
1700                .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
1701                .collect(),
1702            ctrl_versions: ctrl_versions
1703                .iter()
1704                .copied()
1705                .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
1706                .collect(),
1707            svc_versions: svc_versions
1708                .iter()
1709                .copied()
1710                .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
1711                .collect(),
1712            ack_versions: ack_versions
1713                .iter()
1714                .copied()
1715                .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
1716                .collect(),
1717            attested_rich_versions: Vec::new(),
1718            attested_data_versions: Vec::new(),
1719            nostr_pubkey: nostr_pubkey.and_then(normalize_nostr_pubkey),
1720        }
1721    }
1722
1723    /// Whether any capability is advertised. Empty records are deleted
1724    /// rather than stored — the durable side of the downgrade semantics.
1725    pub(crate) fn is_any(&self) -> bool {
1726        !self.env_versions.is_empty()
1727            || !self.rich_versions.is_empty()
1728            || !self.data_versions.is_empty()
1729            || !self.ctrl_versions.is_empty()
1730            || !self.svc_versions.is_empty()
1731            || !self.ack_versions.is_empty()
1732            || !self.attested_rich_versions.is_empty()
1733            || !self.attested_data_versions.is_empty()
1734            || self.nostr_pubkey.is_some()
1735    }
1736}
1737
1738/// Which canonical payload a control frame's signature verified against.
1739///
1740/// Both are legitimate: [`Self::V1`] is what every peer signed before the
1741/// freshness-bound payload existed, and refusing it outright would refuse
1742/// first contact with any peer that has not upgraded. What the distinction
1743/// buys is that the two can be treated differently once a peer has *proved* it
1744/// can produce the newer one.
1745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1746pub(crate) enum ControlSigVersion {
1747    /// `offline-ctrl-v1`: sender, id, recipient, content. States no freshness,
1748    /// so a signature over it never expires.
1749    V1,
1750    /// `offline-ctrl-v2`: the four fields above plus the frame's timestamp.
1751    V2,
1752}
1753
1754/// What verifying a control frame's signature established.
1755#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1756pub(crate) enum ControlVerification {
1757    /// No signature metadata at all. The caller decides policy; for a
1758    /// security-gated prefix that policy is refusal.
1759    Unsigned,
1760    /// A signature verified, and the key that made it derives to the address
1761    /// the frame claims to come from.
1762    Verified(ControlSigVersion),
1763}
1764
1765/// Outcome of [`OfflineProtocol::security_gate_control_message`].
1766///
1767/// [`OfflineProtocol`]: crate::OfflineProtocol
1768/// [`OfflineProtocol::security_gate_control_message`]: crate::OfflineProtocol
1769#[derive(Debug)]
1770pub(crate) enum ControlGateOutcome {
1771    /// The message may proceed to dispatch.
1772    ///
1773    /// `signed` is `true` **only** when an Ed25519 signature was present,
1774    /// verified, and produced by the key the sender's address derives from. It
1775    /// is deliberately `false` for both of the other ways a message reaches
1776    /// dispatch — a relay-originated answer, which no peer signs
1777    /// (`RELAY_ANSWER_PREFIXES`), and a prefix the gate does not cover at all —
1778    /// so a handler that keys on it fails closed without having to know which
1779    /// case it is in.
1780    ///
1781    /// Since unsigned control traffic is now refused outright, `false` no
1782    /// longer reaches any *gated* handler; the bit survives because those two
1783    /// ungated paths still produce it, and because a handler that treats a
1784    /// payload field as authenticated should say so at the point it does.
1785    ///
1786    /// `freshness_bound` is the strictly narrower fact: the signature verified
1787    /// against the payload that covers the frame's timestamp, **and** that
1788    /// timestamp was inside the window this node allows. It is what a handler
1789    /// consults before acting on a directive that destroys state, because such
1790    /// a directive on a frame that could be a recording is a replayable way to
1791    /// destroy that state (issue 403).
1792    ///
1793    /// It is deliberately the *fact* and not the *policy*: it stays `false`
1794    /// when freshness enforcement is switched off, and the handler applies the
1795    /// switch itself. A flag that folded the config in would read as "this
1796    /// frame is fresh" while meaning "nobody looked", which is the reading that
1797    /// gets copied to the next call site.
1798    Proceed { signed: bool, freshness_bound: bool },
1799    /// The gate rejected the message; the caller must return this result
1800    /// without dispatching.
1801    Rejected(InternalMessageResult),
1802}
1803
1804/// Result of processing an internal protocol message.
1805#[derive(Debug)]
1806pub(crate) enum InternalMessageResult {
1807    /// Message was consumed internally (don't surface to app).
1808    Consumed,
1809    /// Message was rejected by the security gate (spoofed sender, bad
1810    /// signature, unsigned control traffic, or a signing key that does not
1811    /// derive to the claimed sender address). Like `Consumed`, the message is not
1812    /// surfaced to the app — but unlike `Consumed`, a delivery ACK must NOT
1813    /// be sent back, to avoid confirming to the attacker that the target is
1814    /// online and processing messages.
1815    SecurityRejected,
1816    /// Message was not delivered, but the sender can still recover it by
1817    /// resending. Unlike `Consumed`, a delivery ACK must NOT be sent and the id
1818    /// must NOT stay dedup-marked: the message is provably not delivered, so
1819    /// the receiver must leave the sender's retry lever intact. The receive
1820    /// loop responds by unmarking the id (so a resend re-enters processing
1821    /// instead of hitting the duplicate re-ACK path) and skipping the ACK.
1822    ///
1823    /// Two conditions produce it:
1824    ///
1825    /// - **Session not ready**: the MLS session/group is not established yet,
1826    ///   so the frame is queued for delayed decryption
1827    ///   (`enqueue_pending_decryption`). The queued copy is surfaced — and the
1828    ///   id re-marked — once the session confirms and the queue drains
1829    ///   (`process_pending_decryption`), which also sends the deferred delivery
1830    ///   ACK directly on the recorded arrival transport (so a sender that gave
1831    ///   up before the session confirmed still learns of delivery).
1832    /// - **Envelope parse failure** with `crypto_recovery_enabled`: the
1833    ///   `__MLS_ENC__` payload did not parse in any envelope form, so there is
1834    ///   no ciphertext to decrypt. Not queued (an unparseable frame can never
1835    ///   become parseable) and not answered, since for an injector silence
1836    ///   reveals less than an answer. Recovery is the sender's resend.
1837    ///
1838    /// See `docs/state-machines/delivery-and-acks.md` for the
1839    /// deferred-acknowledgement atom and the decrypt-failure classification.
1840    Deferred,
1841    /// The frame can never decrypt, though the message behind it may: an epoch
1842    /// desync (sealed to an epoch that is not ours, and a re-key is scheduled)
1843    /// or a crypto/transport failure with `crypto_recovery_enabled` (OpenMLS
1844    /// spent the ratchet generation on the failed attempt). Not queued.
1845    ///
1846    /// Handled like `Deferred` (id unmarked, no delivery ACK) except that
1847    /// the frame is answered with an `undecryptable` ACK
1848    /// ([`crate::constants::ACK_STATUS_UNDECRYPTABLE`]) when it arrived over
1849    /// the internet relay and its sender advertised that it reads one; any
1850    /// other frame gets no answer. Silence left a relay
1851    /// holding the frame to redeliver it on every connect, failing again each
1852    /// time; the answer lets the relay drop it and tells the sender to resend
1853    /// the message once, re-sealed under its current session when it can.
1854    Undecryptable,
1855    /// Message was decrypted, here's the plaintext.
1856    Decrypted(String),
1857}
1858
1859/// Outcome of routing an inbound file-chunk message through
1860/// [`OfflineProtocol::handle_incoming_file_chunk`]. Distinguishes chunks that
1861/// were dealt with terminally (decrypted/assembled, or dropped for a permanent
1862/// reason) from chunks queued for delayed decryption — so the receive loop can
1863/// defer the ACK for the latter, exactly like the text `Deferred` path.
1864///
1865/// [`OfflineProtocol::handle_incoming_file_chunk`]: crate::OfflineProtocol
1866///
1867/// `#[must_use]`: the ACK/defer decision hinges on this outcome. Dropping it on
1868/// the floor silently reverts to the pre-deferred-ACK behavior (always ACK,
1869/// leave dedup-marked), reintroducing the queue-path silent-loss bug — so every
1870/// caller must branch on it.
1871#[must_use]
1872#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1873pub(crate) enum ChunkOutcome {
1874    /// The chunk was decrypted/assembled or dropped for a terminal reason
1875    /// (parse failure, resource limit, permanent refusal). The sender should
1876    /// stop retrying either way, so the caller ACKs as before.
1877    Handled,
1878    /// The chunk was not delivered but the sender can still recover it by
1879    /// resending: it could not be decrypted yet (session not ready, so queued
1880    /// for delayed decryption), or it is undecryptable as it stands (epoch
1881    /// desync, or a crypto failure while `crypto_recovery_enabled`), or its
1882    /// media envelope did not decode at all (also `crypto_recovery_enabled`) —
1883    /// the latter shapes dropped without queueing. The caller must NOT ACK and
1884    /// must unmark the id, so the sender keeps retrying and the resend re-enters
1885    /// processing. For an undecryptable or undecodable chunk that recovery is
1886    /// the media outbox's `MediaResendRequired` path — chunks are re-encoded,
1887    /// never re-sealed.
1888    Deferred,
1889    /// The chunk was refused as illegitimate rather than undeliverable. Two
1890    /// shapes: an unencrypted chunk refused by the encryption policy, and an
1891    /// encrypted chunk that failed its identity binding — the envelope named
1892    /// another pair's session slot, or the MLS credential authenticated a
1893    /// different sender than the wire envelope claims.
1894    ///
1895    /// Like [`InternalMessageResult::SecurityRejected`] for text, the caller
1896    /// must NOT ACK (don't confirm to an injector that the target processes
1897    /// their messages) and must unmark the id (so a replay re-enters this gate
1898    /// instead of the duplicate re-ACK path), matching the plaintext-text
1899    /// rejection in the receive loop.
1900    Rejected,
1901}
1902
1903/// Pending message waiting for session establishment.
1904#[derive(Clone, Serialize, Deserialize)]
1905pub(crate) struct PendingMessage {
1906    /// Original plaintext content.
1907    pub(crate) content: String,
1908    /// Message priority.
1909    pub(crate) priority: MessagePriority,
1910    /// Message ID (preserved from initial creation).
1911    pub(crate) message_id: MessageId,
1912    /// Reply-to message ID if applicable.
1913    pub(crate) reply_to_msg: Option<MessageId>,
1914    /// Forwarding attribution (preserved so it survives the pending queue).
1915    #[serde(default, skip_serializing_if = "Option::is_none")]
1916    pub(crate) forwarded_from: Option<ForwardInfo>,
1917    /// Content type of the original message (preserved for forwarded non-text messages).
1918    #[serde(default)]
1919    pub(crate) content_type: ContentType,
1920    /// Media metadata (preserved for forwarded media messages).
1921    #[serde(default, skip_serializing_if = "Option::is_none")]
1922    pub(crate) media_metadata: Option<MediaMetadata>,
1923    /// Option-borne rich extras from the rich send surface. Kept separate
1924    /// from the legacy `forwarded_from`/`media_metadata` fields above: those
1925    /// flush as outer cleartext (shipped forward behavior), while these must
1926    /// flush inside the sealed rich body or be dropped — never cleartext.
1927    #[serde(default, skip_serializing_if = "Option::is_none")]
1928    pub(crate) rich: Option<RichSendExtras>,
1929    /// Per-send application id from the rich send surface, `None` for the
1930    /// configured one.
1931    ///
1932    /// Kept on the entry because the flush rebuilds the outer message from
1933    /// this record: without it, a send parked while the session was not
1934    /// ready would go out under the configured id and be routed to the
1935    /// wrong application on the receiving instance. Serde-defaulted, so a
1936    /// record queued before the field existed restores as `None`, which is
1937    /// the id it was queued with.
1938    #[serde(default, skip_serializing_if = "Option::is_none")]
1939    pub(crate) app_id: Option<String>,
1940    /// When the message *first* entered the pending queue.
1941    ///
1942    /// Drives `pending_message_max_lifetime_ms`, and is deliberately preserved
1943    /// across a re-queue (see [`PendingProvenance`]): a flush that finds the
1944    /// session still unavailable puts the entry back, and stamping a fresh
1945    /// timestamp there would let repeated reconciliation renew an "absolute"
1946    /// lifetime forever.
1947    pub(crate) queued_at: DateTime<Utc>,
1948    /// Serialized footprint of this entry, for the pending-queue byte budgets
1949    /// ([`MAX_PENDING_MESSAGE_BYTES_PER_PEER`] /
1950    /// [`MAX_PENDING_MESSAGE_BYTES_GLOBAL`]).
1951    ///
1952    /// Derived, so it is not persisted; it is (re)computed by
1953    /// [`PendingMessage::measure`] at the two points an entry comes into
1954    /// existence — admission and load-from-storage — and then simply travels
1955    /// with the entry as it is moved between the queue, a flush, and a
1956    /// re-queue.
1957    #[serde(skip)]
1958    pub(crate) serialized_bytes: usize,
1959}
1960
1961/// One persisted pending-queue record: a single queued message plus the peer
1962/// it is queued for.
1963///
1964/// The recipient rides *inside* the record because the key is the message id,
1965/// and the in-memory queue is a map keyed by recipient that has to be rebuilt
1966/// from records read in whatever order the store enumerates them. Same shape as
1967/// [`OutboxEntry`], whose recipient likewise travels in the record (inside its
1968/// `Message`) rather than in the key.
1969///
1970/// Deliberately a wrapper rather than a `recipient` field on [`PendingMessage`]:
1971/// in memory the recipient is already the map key, and a second copy there
1972/// would be free to drift from it.
1973#[derive(Serialize, Deserialize)]
1974pub(crate) struct PendingMessageRecord {
1975    pub(crate) recipient: String,
1976    pub(crate) message: PendingMessage,
1977}
1978
1979/// One inbound encrypted frame parked in the pending-decryption queue, as
1980/// persisted under [`storage_keys::PENDING_DECRYPT_ENTRIES`].
1981///
1982/// The queue itself is in-memory only (`PendingDecryptionQueue`); this record
1983/// is what lets an entry survive an app restart. Without it a frame that
1984/// arrived before its session was ready was lost the moment the process died
1985/// — and with a relay that pushes ciphertext without store-and-forward there
1986/// is no second copy to ask for, so a restart during a slow handshake was a
1987/// silently lost message.
1988///
1989/// Keyed by message id, like [`PendingMessageRecord`], so a drain, a prune or
1990/// an overflow drop deletes exactly the record it settled. `peer_id` rides
1991/// inside the record because the in-memory queue is a per-peer map rebuilt
1992/// from records the store enumerates in no particular order.
1993///
1994/// `first_received_at_ms` is wall-clock, not the `Instant` the in-memory entry
1995/// carries: an `Instant` does not survive a restart, so on restore the entry is
1996/// re-stamped with a fresh `Instant::now()` (its in-memory TTL restarts) and
1997/// this field bounds the *total* time on disk instead —
1998/// `PENDING_DECRYPT_PERSISTED_MAX_AGE_MS`.
1999///
2000/// `version` is a forward-compatibility hinge: a reader that sees a version it
2001/// does not know treats the record as corrupt and drops it rather than
2002/// guessing at fields. Records written before the field existed default to 1.
2003#[derive(Serialize, Deserialize)]
2004pub(crate) struct PendingDecryptRecord {
2005    #[serde(default = "pending_decrypt_record_version")]
2006    pub(crate) version: u8,
2007    pub(crate) peer_id: String,
2008    pub(crate) message: Message,
2009    pub(crate) first_received_at_ms: i64,
2010    #[serde(default)]
2011    pub(crate) received_via: Option<TransportType>,
2012}
2013
2014/// The only record version this build writes or reads.
2015pub(crate) const PENDING_DECRYPT_RECORD_VERSION: u8 = 1;
2016
2017fn pending_decrypt_record_version() -> u8 {
2018    PENDING_DECRYPT_RECORD_VERSION
2019}
2020
2021/// One frame in custody, persisted under [`storage_keys::CUSTODY`].
2022///
2023/// The frame as it arrived with the deposit request removed and the hop
2024/// fields as the forwarding path adjusted them; the depositor, which is both
2025/// the frame's sender and the peer it arrived from; the wall-clock acceptance
2026/// time in Unix milliseconds, never a monotonic instant; and the class token.
2027/// `version` is the same forward-compatibility hinge as
2028/// [`PendingDecryptRecord::version`].
2029#[derive(Serialize, Deserialize)]
2030pub(crate) struct CustodyRecord {
2031    #[serde(default = "custody_record_version")]
2032    pub(crate) version: u8,
2033    pub(crate) depositor: String,
2034    pub(crate) message: Message,
2035    pub(crate) accepted_at_ms: i64,
2036    pub(crate) class: String,
2037}
2038
2039/// The only custody record version this build writes or reads.
2040pub(crate) const CUSTODY_RECORD_VERSION: u8 = 1;
2041
2042fn custody_record_version() -> u8 {
2043    CUSTODY_RECORD_VERSION
2044}
2045
2046impl PendingMessage {
2047    /// Recomputes [`Self::serialized_bytes`] from the current field values.
2048    ///
2049    /// Measures what persistence actually costs (the serialized entry) rather
2050    /// than estimating from `content.len()`, so rich extras and forward
2051    /// attribution are counted. A serialization failure — which would also make
2052    /// the entry unpersistable — falls back to the content length so the entry
2053    /// still consumes budget rather than reading as free.
2054    pub(crate) fn measure(&mut self) {
2055        self.serialized_bytes = serde_json::to_vec(self)
2056            .map(|encoded| encoded.len())
2057            .unwrap_or_else(|_| self.content.len());
2058    }
2059}
2060
2061/// Identity carried by an outbound message that is re-entering the
2062/// pending-session queue rather than being queued for the first time.
2063///
2064/// Both fields exist to keep a re-queue from looking like a fresh send: the id
2065/// so `MessageSent`/`MessageDelivered`/`MessageFailed` stay correlatable with
2066/// what `send_message*` returned, and the timestamp so the absolute pending
2067/// lifetime is measured from first entry.
2068#[derive(Debug, Clone)]
2069pub(crate) struct PendingProvenance {
2070    /// The id the caller already holds.
2071    pub(crate) message_id: MessageId,
2072    /// When the message first entered the pending queue, or `None` when the
2073    /// re-queue does not come *from* that queue — the resend re-seal path
2074    /// passes an outbox id, and an entry it (all but unreachably) enqueues is
2075    /// starting its pending lifetime now.
2076    pub(crate) first_queued_at: Option<DateTime<Utc>>,
2077}
2078
2079impl PendingProvenance {
2080    /// Provenance for a message being put back into the pending queue.
2081    pub(crate) fn requeued(message: &PendingMessage) -> Self {
2082        Self {
2083            message_id: message.message_id.clone(),
2084            first_queued_at: Some(message.queued_at),
2085        }
2086    }
2087
2088    /// Provenance for a known id with no pending-queue history.
2089    pub(crate) fn for_id(message_id: MessageId) -> Self {
2090        Self {
2091            message_id,
2092            first_queued_at: None,
2093        }
2094    }
2095}
2096
2097/// Durable state for a peer MLS session.
2098#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2099pub(crate) enum SessionState {
2100    Pending,
2101    Confirmed,
2102}
2103
2104impl SessionState {
2105    pub(crate) fn as_str(&self) -> &'static str {
2106        match self {
2107            Self::Pending => "Pending",
2108            Self::Confirmed => "Confirmed",
2109        }
2110    }
2111}
2112
2113/// Durable lifecycle states for outbound Welcome delivery.
2114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2115pub(crate) enum WelcomeDeliveryState {
2116    Created,
2117    SendAttempted,
2118    Sent,
2119    Failed,
2120    Expired,
2121}
2122
2123impl WelcomeDeliveryState {
2124    pub(crate) fn as_str(&self) -> &'static str {
2125        match self {
2126            Self::Created => "Created",
2127            Self::SendAttempted => "SendAttempted",
2128            Self::Sent => "Sent",
2129            Self::Failed => "Failed",
2130            Self::Expired => "Expired",
2131        }
2132    }
2133}
2134
2135/// Per-peer throttle for presence-driven welcome rescue (see
2136/// `OfflineProtocol::on_peer_presence`). Deliberately in-memory only: a
2137/// restart resets the backoff, and the one free rescue that buys is useful
2138/// after a restart anyway.
2139#[derive(Debug, Clone)]
2140pub(crate) struct PresenceRescueThrottle {
2141    pub(crate) next_allowed_at: DateTime<Utc>,
2142    /// Consecutive rescues without the session confirming; drives the
2143    /// exponential backoff exponent.
2144    pub(crate) rescues: u32,
2145}
2146
2147/// Durable metadata for outbound Welcome reliability handling.
2148#[derive(Debug, Clone, Serialize, Deserialize)]
2149pub(crate) struct WelcomeLifecycleRecord {
2150    pub(crate) peer_id: String,
2151    pub(crate) group_id: String,
2152    pub(crate) state: WelcomeDeliveryState,
2153    pub(crate) attempt: u32,
2154    /// Consecutive `PeerUnreachable` parks (relay `DeliveryError` verdicts)
2155    /// since the last reachability edge; drives the escalating retry
2156    /// interval capped at [`WELCOME_UNREACHABLE_RETRY_CAP_SECS`]. Reset on
2157    /// re-arm (presence online / neighbor discovered). Defaulted for
2158    /// records persisted before the field existed.
2159    #[serde(default)]
2160    pub(crate) unreachable_parks: u32,
2161    pub(crate) welcome_message: Message,
2162    pub(crate) next_retry_at: Option<DateTime<Utc>>,
2163    pub(crate) last_reason_code: Option<crate::events::WelcomeReasonCode>,
2164    pub(crate) last_transport_error: Option<String>,
2165    pub(crate) created_at: DateTime<Utc>,
2166    pub(crate) expires_at: DateTime<Utc>,
2167}
2168
2169/// Storage key types for message persistence.
2170pub(crate) mod storage_keys {
2171    /// Legacy key type for pending encrypted messages, keyed by *recipient*,
2172    /// each record holding that peer's whole queue.
2173    ///
2174    /// Superseded by [`PENDING_MESSAGE_ENTRIES`]. Nothing writes this category
2175    /// any more: `restore_pending_messages` reads it, migrates each queue into
2176    /// the per-message layout, and deletes the record. It is read-only
2177    /// migration scaffolding, and retires on the same trigger as the
2178    /// [`ADOPTABLE_STATE_KEY_TYPES`] sweep — an install that skips the
2179    /// migrating release entirely still needs it.
2180    pub const PENDING_MESSAGES: &str = "pending_messages";
2181    /// Key type for pending encrypted messages, keyed by message id — one
2182    /// record per queued message, like [`OUTBOX`].
2183    ///
2184    /// Keying by id rather than by recipient is what makes a lost record
2185    /// *individually settleable*: the id the application is holding is the key,
2186    /// so a record that will not open still names the message it destroyed. The
2187    /// per-recipient layout could only report the loss per peer, because every
2188    /// id was inside the record that would not open.
2189    pub const PENDING_MESSAGE_ENTRIES: &str = "pending_message_entries";
2190    /// Inbound ciphertext parked in the pending-decryption queue, one record
2191    /// per message keyed by message id — the receive-side mirror of
2192    /// [`PENDING_MESSAGE_ENTRIES`]. See `PendingDecryptRecord`.
2193    pub const PENDING_DECRYPT_ENTRIES: &str = "pending_decrypt_entries";
2194
2195    /// Frames held in custody for a neighbour (`docs/spec/custody.md`), one
2196    /// record per held frame keyed by its message id. Sealed like the
2197    /// pending-decrypt records: other people's ciphertext plus routing
2198    /// metadata about them, outliving the process. Post-split only, and
2199    /// absent from [`ADOPTABLE_STATE_KEY_TYPES`] for the same reason as
2200    /// [`PENDING_DECRYPT_ENTRIES`].
2201    pub const CUSTODY: &str = "custody_entries";
2202    /// Key type for persisted per-peer MLS session confirmation state.
2203    pub const SESSION_STATES: &str = "session_states";
2204    /// Key type for persisted per-peer received key packages (survives restart).
2205    pub const PEER_KEY_PACKAGES: &str = "peer_key_packages";
2206    /// Key type for persisted per-peer advertised capability versions
2207    /// (env/rich), which must outlive the key package entry above — that one
2208    /// is deleted once a session is established (see
2209    /// [`super::PeerCapabilities`]).
2210    pub const PEER_CAPABILITIES: &str = "peer_capabilities";
2211    /// Key type for persisted per-peer outbound welcome lifecycle state.
2212    pub const WELCOME_LIFECYCLES: &str = "welcome_lifecycles";
2213    /// Key type for persisted store-and-forward outbox entries, keyed by
2214    /// message id. Holds only main-outbox (non-media) entries so undelivered
2215    /// messages survive app restarts; the media (file-chunk) outbox is
2216    /// intentionally excluded because file transfers are not persisted and
2217    /// must be re-initiated by the app after a restart.
2218    pub const OUTBOX: &str = "outbox";
2219    /// Key type for persisted outbound media transfer descriptors, keyed by
2220    /// file id. Descriptor-only (never chunk bytes): a descriptor surviving
2221    /// into a restore marks a transfer the app must re-initiate, surfaced
2222    /// via `MediaResendRequired`.
2223    pub const MEDIA_DESCRIPTORS: &str = "media_descriptors";
2224    /// Key type for the Lamport clock value.
2225    pub const LAMPORT_CLOCK: &str = "lamport_clock";
2226    /// Key ID for the single Lamport clock entry.
2227    pub const LAMPORT_CLOCK_ID: &str = "current";
2228    /// The deduplicator's exact-mode seen set, one record for the whole set.
2229    /// See `DedupSeenRecord`.
2230    pub const DEDUP_SEEN_IDS: &str = "dedup_seen_ids";
2231    /// Key ID for the single seen-set record.
2232    pub const DEDUP_SEEN_IDS_ID: &str = "current";
2233    /// Key type for the durable record that a peer has proved it runs MLS.
2234    ///
2235    /// Successor to the `tofu_keys` category, which stored a pinned public key
2236    /// per peer. The pin is gone — an address *is* its key's hash, so nothing
2237    /// needs storing to check one — but the store had a second job the
2238    /// derivation does not do: it was the half of
2239    /// `OfflineProtocol::encryption_capable_peers` that survived a restart, and
2240    /// therefore what keeps the plaintext-downgrade gate shut for a peer whose
2241    /// session was torn down. That job is all this category still does, so the
2242    /// value is a timestamp and nothing else.
2243    ///
2244    /// A leftover `tofu_keys` record is left where it is, and no migration is
2245    /// written for it. Records from before the identity change are keyed by a
2246    /// username, so no live peer id can ever read them back — they are simply
2247    /// unreachable. Records written in the window *between* the identity
2248    /// change and the pin store's deletion are keyed by an address and would
2249    /// still resolve, so a peer marked capable there with no live session
2250    /// loses that durable mark across the upgrade: it falls back to the
2251    /// `is_session_confirmed` check until the next key-package exchange
2252    /// re-marks it. That is the fail-closed direction and it costs one
2253    /// exchange, which is why migrating them is not worth the code while no
2254    /// fleet is deployed.
2255    pub const ENCRYPTION_CAPABLE_PEERS: &str = "encryption_capable_peers";
2256    /// Key type for persisted blocked user entries.
2257    pub const BLOCKED_USERS: &str = "blocked_users";
2258    /// Key type for the persistent per-install telemetry scrub secret.
2259    pub const SCRUB_SECRET: &str = "scrub_secret";
2260    /// Key ID for the single scrub-secret entry.
2261    pub const SCRUB_SECRET_ID: &str = "current";
2262    /// Key type for the persistent per-install Nostr transport signing secret.
2263    pub const NOSTR_SIGNING_SECRET: &str = "nostr_signing_secret";
2264    /// Key ID for the single Nostr signing-secret entry.
2265    pub const NOSTR_SIGNING_SECRET_ID: &str = "current";
2266    /// Key type for the Nostr receive watermark: the newest event `created_at`
2267    /// (unix seconds) this install has accepted from a relay.
2268    ///
2269    /// Unlike the signing secret this is *protocol-state*, not secure storage:
2270    /// it is a coarse timestamp, not key material, and losing it costs one
2271    /// wider backfill window rather than an identity.
2272    pub const NOSTR_WATERMARK: &str = "nostr_watermark";
2273    /// Key ID for the single Nostr receive-watermark entry.
2274    pub const NOSTR_WATERMARK_ID: &str = "current";
2275    /// Key type for the Nostr key-package publication slot map: which MLS key
2276    /// package currently stands in each published addressable slot.
2277    ///
2278    /// Protocol state rather than secure storage, on the same reasoning as the
2279    /// watermark: the values are slot labels and package ids, both of which are
2280    /// already public in the published record itself. Losing it costs a round
2281    /// of republication under fresh slot ids, not an identity — the stale
2282    /// records left at the old slots expire with their key packages.
2283    pub const NOSTR_KEY_PACKAGE_SLOTS: &str = "nostr_key_package_slots";
2284    /// Key ID for the single Nostr publication-slot entry.
2285    pub const NOSTR_KEY_PACKAGE_SLOTS_ID: &str = "current";
2286    /// Key type for this install's published username discovery claim.
2287    ///
2288    /// Records *which* username was last published, so a profile change or a
2289    /// switched-off feature can retract the previous claim. Without it a
2290    /// renamed install leaves its old name standing in the directory
2291    /// indefinitely, pointing at an address that is still live — the one
2292    /// failure a directory must not have, since retraction is the only control
2293    /// a claimant holds.
2294    ///
2295    /// Protocol state rather than secure storage, on the same reasoning as the
2296    /// slot map: the value is a username that this install already published
2297    /// in a public place. Losing it costs an un-retractable stale claim, which
2298    /// expires only when a resolver's second hop fails.
2299    pub const NOSTR_DISCOVERY_CLAIM: &str = "nostr_discovery_claim";
2300    /// Key ID for the single discovery-claim entry.
2301    pub const NOSTR_DISCOVERY_CLAIM_ID: &str = "current";
2302    /// Key type for the per-install key that seals sensitive protocol-state
2303    /// records at rest.
2304    ///
2305    /// Lives in *secure* storage — it is the one piece of the protocol-state
2306    /// domain that must be credential-backed, because it is what gives the
2307    /// install-scoped container's contents their confidentiality (see
2308    /// [`crate::protocol::state_crypto`]).
2309    pub const STATE_RECORD_KEY: &str = "protocol_state_record_key";
2310    /// Key ID for the single protocol-state record key entry.
2311    pub const STATE_RECORD_KEY_ID: &str = "current";
2312    /// Key type for peers we are the both-create "owner" of and are awaiting a
2313    /// group-aware decrypt from before confirming (see
2314    /// [`crate::protocol::OfflineProtocol`]'s `both_create_awaiting_decrypt`).
2315    /// Persisted so an owner restart mid-convergence cannot let a stale plaintext
2316    /// probe/ack prematurely confirm and strand the peer on a divergent group.
2317    pub const BOTH_CREATE_AWAITING_DECRYPT: &str = "both_create_awaiting_decrypt";
2318    /// Key type for the marker recording that pre-split protocol state has been
2319    /// adopted out of secure storage (see
2320    /// `OfflineProtocol::adopt_legacy_protocol_state`). Lives in *protocol
2321    /// state* storage, so a reinstall — which drops that container — correctly
2322    /// re-runs the sweep against whatever the credential store still holds.
2323    pub const STATE_ADOPTION: &str = "protocol_state_adoption";
2324    /// Key ID for the single state-adoption marker entry.
2325    pub const STATE_ADOPTION_ID: &str = "v1";
2326
2327    /// Key type for compacted replicated documents, one record per document.
2328    ///
2329    /// Key ID is `{space}/{doc}`. The space is validated by
2330    /// `offline_protocol_data::validate_space_name` and the document by
2331    /// `validate_name`; neither charset includes the separator, so the
2332    /// composed key always parses back into the two parts it was built
2333    /// from. (The space charset additionally allows `:`, so a group space
2334    /// can be named by its `group:<uuid>` id — inert here, since keys are
2335    /// parsed by stripping the space prefix and splitting on `/`.)
2336    ///
2337    /// Post-split only: deliberately absent from
2338    /// [`ADOPTABLE_STATE_KEY_TYPES`], which has no pre-split data to inherit
2339    /// for it.
2340    pub const DATA_DOCS: &str = "data_docs";
2341    /// Key type for the uncompacted commit log of a replicated document.
2342    ///
2343    /// Key ID is `{space}/{doc}/{seq:016x}`, one record per persisted commit
2344    /// batch. Append-only by construction: a commit writes a new record and
2345    /// never rewrites an existing one, which is what keeps flash churn
2346    /// proportional to what changed rather than to document size.
2347    ///
2348    /// Post-split only: deliberately absent from
2349    /// [`ADOPTABLE_STATE_KEY_TYPES`], which has no pre-split data to inherit
2350    /// for it.
2351    pub const DATA_DELTA_LOG: &str = "data_delta_log";
2352    /// Key type for per-space document indexes and compaction bookkeeping.
2353    ///
2354    /// Key ID is the space id. Sync watermarks join this record when
2355    /// replication lands; today it carries the document list and nothing
2356    /// else. Sequence numbers and sizes are derived from the delta records
2357    /// themselves at open, so this record can never disagree with the store.
2358    ///
2359    /// Post-split only: deliberately absent from
2360    /// [`ADOPTABLE_STATE_KEY_TYPES`], which has no pre-split data to inherit
2361    /// for it.
2362    pub const DATA_SPACES: &str = "data_spaces";
2363    /// Key type for per-space replication bookkeeping.
2364    ///
2365    /// Key ID is the space id. Holds the containment state the sync path
2366    /// needs to survive a crash: the digest of the blob currently being
2367    /// handed to the document engine, and the digests of blobs that were
2368    /// in flight when a previous run died.
2369    ///
2370    /// Kept out of [`DATA_SPACES`] deliberately. That record is a cache
2371    /// reconciled from `list_keys` at open, so anything in it may be
2372    /// rebuilt from the store at any time; this one records something the
2373    /// store cannot show, and rebuilding it from the listing would silently
2374    /// answer "nothing was in flight" after exactly the crash it exists to
2375    /// remember.
2376    ///
2377    /// Post-split only: deliberately absent from
2378    /// [`ADOPTABLE_STATE_KEY_TYPES`], which has no pre-split data to inherit
2379    /// for it.
2380    pub const DATA_SYNC: &str = "data_sync";
2381    /// Key type for what a replicated document's name says after a removal.
2382    ///
2383    /// Key ID is `{space}/{doc}`, the same composition [`DATA_DOCS`] uses.
2384    /// The value carries the removal floor (the document's version at the
2385    /// moment it was removed) and whether the name currently holds a
2386    /// document again.
2387    ///
2388    /// A separate category rather than a field on [`DATA_SPACES`] for the
2389    /// reason that record is a cache: it is reconciled from `list_keys` at
2390    /// open, so a removal recorded there would be rebuilt away by exactly
2391    /// the listing that still shows the records a half-finished removal
2392    /// left behind. It is written *before* those records are deleted, so a
2393    /// crash in between leaves a name this category already calls removed,
2394    /// and the next open finishes the job. The reverse order is the
2395    /// resurrection the whole mechanism exists to prevent.
2396    ///
2397    /// An absent record means a live document that has never been removed,
2398    /// so a store written before removals existed reads correctly with no
2399    /// migration.
2400    ///
2401    /// Post-split only: deliberately absent from
2402    /// [`ADOPTABLE_STATE_KEY_TYPES`], which has no pre-split data to inherit
2403    /// for it.
2404    pub const DATA_DOC_META: &str = "data_doc_meta";
2405
2406    /// Every key type that moved from secure storage into protocol-state
2407    /// storage when the two domains were split.
2408    ///
2409    /// Unlike the MLS key-type set — which is open, because OpenMLS contributes
2410    /// its own labels through `storage_adapter.rs` — this set is closed and
2411    /// declared right here, which is what makes a one-shot bulk adoption
2412    /// possible at all (see `OfflineProtocol::adopt_legacy_protocol_state`).
2413    /// A new protocol-state category added *after* the split must NOT be added
2414    /// here: there is no pre-split data for it to inherit, and listing it would
2415    /// only cost a pointless enumeration of the credential store.
2416    ///
2417    /// # Removal
2418    ///
2419    /// This list, `OfflineProtocol::adopt_legacy_protocol_state`, and the
2420    /// `STATE_ADOPTION` marker are one-shot migration scaffolding for installs
2421    /// upgrading *across* the storage split. They stop doing anything once no
2422    /// supported install can still be running a pre-split build — an install
2423    /// that skips the split release entirely still needs them, so the trigger
2424    /// is not "one release later". Delete them only when the oldest supported
2425    /// upgrade path starts at or after the release that introduced the split,
2426    /// and delete all three together: leaving the marker behind without the
2427    /// sweep would make a later re-introduction silently skip itself.
2428    pub const ADOPTABLE_STATE_KEY_TYPES: &[&str] = &[
2429        BLOCKED_USERS,
2430        OUTBOX,
2431        PENDING_MESSAGES,
2432        SESSION_STATES,
2433        WELCOME_LIFECYCLES,
2434        PEER_KEY_PACKAGES,
2435        PEER_CAPABILITIES,
2436        MEDIA_DESCRIPTORS,
2437        BOTH_CREATE_AWAITING_DECRYPT,
2438        LAMPORT_CLOCK,
2439    ];
2440}
2441
2442/// Protocol state.
2443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2444pub enum ProtocolState {
2445    /// Protocol is not started.
2446    Stopped,
2447    /// Protocol is running.
2448    Running,
2449    /// Protocol is paused (background mode).
2450    Paused,
2451}
2452
2453/// Shared state protected by mutex.
2454pub(crate) struct SharedState {
2455    /// Current protocol state.
2456    pub(crate) state: ProtocolState,
2457
2458    /// Event handlers registered by the application.
2459    pub(crate) event_handlers: Vec<EventCallback>,
2460
2461    /// Received messages queue.
2462    pub(crate) received_messages: VecDeque<Message>,
2463
2464    /// Installed telemetry context. When present, `emit_event` additionally
2465    /// forwards every protocol event to `ctx.sink` as a
2466    /// `TelemetryRecord::Protocol`, with identifier scrubbing applied per
2467    /// `ctx.config`. Set via `OfflineProtocol::install_telemetry_sink`.
2468    pub(crate) telemetry: Option<Arc<TelemetryContext>>,
2469
2470    /// Handler calls that panicked and were isolated by [`Self::emit_event`].
2471    /// Each one is an event at least one handler never saw, after the engine
2472    /// had already acted on what it reports (#514), so it is counted where a
2473    /// test can read it rather than only logged.
2474    pub(crate) handler_panics: std::sync::atomic::AtomicU64,
2475}
2476
2477impl SharedState {
2478    pub(crate) fn new() -> Self {
2479        Self {
2480            state: ProtocolState::Stopped,
2481            event_handlers: Vec::new(),
2482            received_messages: VecDeque::new(),
2483            telemetry: None,
2484            handler_panics: std::sync::atomic::AtomicU64::new(0),
2485        }
2486    }
2487
2488    pub(crate) fn emit_event(&self, event: Event) {
2489        self.notify_handlers(&event);
2490        self.forward_to_telemetry(&event);
2491    }
2492
2493    /// [`Self::emit_event`] without the telemetry sink: the app's handlers
2494    /// see the event exactly as they always have, and the pipe never does.
2495    ///
2496    /// For engine traffic that reuses an app-facing event type but is not
2497    /// what that type's telemetry column counts. A delivery acknowledgement
2498    /// for a media chunk or an internal frame (a connection request, a key
2499    /// package, a Welcome, a data-sync frame) is one: forwarded, it counted
2500    /// as a delivered message on the dashboard, once per chunk, against a
2501    /// "sent" figure that never included any of them.
2502    pub(crate) fn emit_event_to_app(&self, event: Event) {
2503        self.notify_handlers(&event);
2504    }
2505
2506    /// A message's terminal event (`MessageDelivered` or `MessageFailed`):
2507    /// always to the app's handlers, and to telemetry only when `telemetry`
2508    /// is set, which callers derive from [`OutboxEntry::app_send`]. One
2509    /// method so every settle site applies the rule the same way.
2510    pub(crate) fn emit_settlement(&self, event: Event, telemetry: bool) {
2511        self.notify_handlers(&event);
2512        if telemetry {
2513            self.forward_to_telemetry(&event);
2514        }
2515    }
2516
2517    fn notify_handlers(&self, event: &Event) {
2518        // Legacy `EventCallback` handlers run first and receive the raw
2519        // event. This preserves the pre-telemetry contract — any app that
2520        // relied on `on_event` sees exactly what it used to.
2521        //
2522        // Each handler call is panic-isolated so a faulty handler cannot
2523        // unwind through this method while a `MutexGuard<SharedState>` is
2524        // live in the caller's frame — that would poison the shared-state
2525        // mutex and silently degrade every subsequent SDK operation.
2526        for handler in &self.event_handlers {
2527            let event_for_handler = event.clone();
2528            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2529                handler(event_for_handler);
2530            }));
2531            if result.is_err() {
2532                // Counted, and named by type only: the event's fields carry
2533                // senders, peers and message content, and an embedder's own
2534                // subscriber would print them. The event is already settled
2535                // in the engine (a received message is ACKed, a delivered one
2536                // has left the outbox), so this is a message the app will not
2537                // hear about.
2538                self.handler_panics
2539                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2540                tracing::error!(
2541                    event = event.telemetry_name(),
2542                    "EventCallback panicked; this handler missed the event. Handlers must not panic, see OfflineProtocol::on_event.",
2543                );
2544            }
2545        }
2546    }
2547
2548    fn forward_to_telemetry(&self, event: &Event) {
2549        // Sink fan-out runs after, gated on an installed context. The pipe,
2550        // the one production sink, consumes every event by reference in
2551        // `dispatch_protocol_event` and reads no identifier from it, so it
2552        // never reaches the scrub below. Only a sink that declines that
2553        // borrowed offer does: `scrub_event` hashes its identifier fields per
2554        // the installed config, or with
2555        // `TelemetryConfig::with_scrub_ids(false)` borrows the event as is.
2556        //
2557        // Dispatch goes through `dispatch_record` so a panicking sink is
2558        // caught and logged rather than unwinding through the caller's live
2559        // `MutexGuard<SharedState>` — see the helper's docstring.
2560        if let Some(ctx) = &self.telemetry {
2561            // The gate comes first so a disabled pipe stops the clone too,
2562            // and the borrowed offer comes next so a sink that collects by
2563            // name never pays for an event it drops.
2564            if !ctx.enabled() || dispatch_protocol_event(&ctx.sink, event) {
2565                return;
2566            }
2567            let scrubbed = scrub_event::scrub_event(event, &ctx.scrubber);
2568            let record = TelemetryRecord::Protocol(Box::new(scrubbed.into_owned()));
2569            dispatch_record(&ctx.sink, &record);
2570        }
2571    }
2572}
2573
2574/// Locks the shared state, recovering it if an earlier holder panicked.
2575///
2576/// Recovering rather than refusing is what keeps events flowing (#514). The
2577/// callers that emit `message_received` and `message_delivered` act first
2578/// (the ACK is sent, the outbox entry removed) and lock to emit afterwards, so
2579/// a refusal here does not undo anything: it silently drops the one event
2580/// that reports what was done, and then every later one too, since a std
2581/// mutex stays poisoned for good. Nothing under this lock is left
2582/// half-written by a panic that matters: handler and sink calls, the only
2583/// foreign code run under it, are panic-isolated in
2584/// [`SharedState::emit_event`], and the remaining writes are single pushes
2585/// and pops on a queue or a field store.
2586///
2587/// Still returns a `Result`, so the call sites' existing handling stays
2588/// valid, but never `Err`.
2589pub(crate) fn lock_shared_state(
2590    state: &Arc<Mutex<SharedState>>,
2591) -> std::result::Result<std::sync::MutexGuard<'_, SharedState>, Error> {
2592    Ok(state.lock().unwrap_or_else(|poisoned| {
2593        tracing::error!("Shared state mutex was poisoned by a panic; recovering it");
2594        state.clear_poison();
2595        poisoned.into_inner()
2596    }))
2597}
2598
2599/// Provenance kept on an outbox entry so an encrypted DM can be *re-sealed*
2600/// against the peer's current MLS session on each resend, instead of replaying
2601/// the ciphertext bytes sealed at first send (which become permanently
2602/// undecryptable once the peer re-keys to a new epoch). Mirrors the fields the
2603/// pre-send [`PendingMessage`] carries, so both re-seal through the same
2604/// `prepare_outbound_content` chokepoint. Only populated for main-outbox
2605/// (non-media) encrypted DMs; absent (`None`) means verbatim replay — the
2606/// fallback for plaintext sends and media chunks.
2607///
2608/// **Memory-only by design.** This holds the message *plaintext*, so it is never
2609/// persisted (see the `#[serde(skip)]` on [`OutboxEntry::reseal`]): persisting it
2610/// would broaden plaintext-at-rest to every sent-but-unACKed encrypted DM for the
2611/// full outbox lifetime, weakening forward secrecy in exchange for only a narrow
2612/// cross-restart reseal benefit. After a restart the restored entry replays
2613/// verbatim; if that resend hits a desync, Tier 1 (un-ACK + re-key) still keeps
2614/// delivery honest rather than silently losing the message.
2615#[derive(Debug, Clone)]
2616pub(crate) struct OutboxReseal {
2617    /// Original plaintext content.
2618    pub(crate) content: String,
2619    /// Message priority.
2620    pub(crate) priority: MessagePriority,
2621    /// Reply-to message ID if applicable.
2622    pub(crate) reply_to_msg: Option<MessageId>,
2623    /// Forwarding attribution.
2624    pub(crate) forwarded_from: Option<ForwardInfo>,
2625    /// Content type of the original message.
2626    pub(crate) content_type: ContentType,
2627    /// Media metadata (cleartext-outer fallback provenance).
2628    pub(crate) media_metadata: Option<MediaMetadata>,
2629    /// Sealed-only rich extras (reply context, rich media metadata, forward
2630    /// info) — re-evaluated against current capability at re-seal time.
2631    pub(crate) rich: Option<RichSendExtras>,
2632    /// Where the bytes currently on the ladder sit in the session's sealing
2633    /// order. An acknowledgement for this message proves the peer decrypted
2634    /// that far, which is what lets later re-seals go ahead (see
2635    /// `RESEAL_GAP_BUDGET`). Set when the message is staged and on every
2636    /// re-seal; `None` when it could not be read.
2637    pub(crate) seal_mark: Option<offline_protocol_mls::SealMark>,
2638}
2639
2640#[derive(Debug, Clone, Serialize, Deserialize)]
2641pub(crate) struct OutboxEntry {
2642    pub(crate) message: Message,
2643    pub(crate) attempt_count: u32,
2644    pub(crate) first_sent_at: DateTime<Utc>,
2645    pub(crate) last_sent_at: DateTime<Utc>,
2646    pub(crate) last_transport: Option<TransportType>,
2647    /// Re-seal provenance; `None` for verbatim-replay entries (plaintext or
2648    /// media). **Memory-only** (`#[serde(skip)]`): it carries the message
2649    /// plaintext, which must never be persisted (see [`OutboxReseal`]). A
2650    /// restored entry deserializes with `reseal: None` and therefore replays
2651    /// verbatim.
2652    #[serde(skip)]
2653    pub(crate) reseal: Option<OutboxReseal>,
2654    /// The relay has handed this frame to a device push at least once
2655    /// (`MessageSent { pushed: true }`, see [`SEND_FAIL_REASON_RELAY_PUSHED`]).
2656    ///
2657    /// Sticky for the entry's lifetime, and persisted, because of what a later
2658    /// relay verdict for the same id means. The relay remembers which
2659    /// `(sender, recipient, message_id)` triples it has pushed and answers a
2660    /// retry of one with `DeliveryError` (`already_pushed`) instead of a
2661    /// second notification, and the bridges cannot tell that verdict from a
2662    /// plain "recipient offline": both reach the core as
2663    /// `recipient_unreachable`. So the reachability probe a park schedules
2664    /// earns a `DeliveryError` fifteen seconds after every push, and without
2665    /// this flag the app would be told `MessageUndeliverable` about a message
2666    /// the push may already have delivered. With it, that verdict still
2667    /// re-parks the entry (the recipient is not on the relay) but emits
2668    /// nothing app-facing. A legacy record restores as `false`, so the first
2669    /// probe after upgrading emits once; nothing is lost.
2670    #[serde(default)]
2671    pub(crate) relay_pushed: bool,
2672    /// The application sent this message: it went through the app send path
2673    /// (`send_message`, `forward_message`, a rich send), which is the path
2674    /// that reports `MessageSent`. Internal frames share the outbox and the
2675    /// acknowledgement machinery but are never set.
2676    ///
2677    /// Read when the message is acknowledged, to decide whether the delivery
2678    /// is reported to telemetry. Telemetry's delivered count is compared with
2679    /// its sent count, so both must count the same messages; counting every
2680    /// acknowledged frame put connection requests, key packages and Welcomes
2681    /// in delivered and never in sent.
2682    ///
2683    /// Persisted. A legacy record restores as `false`, so a delivery restored
2684    /// across the upgrade still reaches `on_event` and is only left out of
2685    /// telemetry.
2686    #[serde(default)]
2687    pub(crate) app_send: bool,
2688}
2689
2690#[derive(Clone)]
2691pub(crate) struct PendingMediaMetadataEntry {
2692    pub(crate) content_type: ContentType,
2693    pub(crate) media_metadata: Option<MediaMetadata>,
2694    pub(crate) last_updated_at: Instant,
2695    /// Sender of the file transfer (used to drain partial transfers on block).
2696    pub(crate) sender: String,
2697    /// Rich extras from the sealed chunk-0 plaintext. Never populated from
2698    /// wire (legacy plaintext) chunks — the sealed body is the only trusted
2699    /// carrier.
2700    pub(crate) rich_extras: Option<crate::media_envelope::MediaRichExtras>,
2701    /// The chunk-0 outer `Message` timestamp (wall-clock ms) — the sender's
2702    /// send time, surfaced on `FileReceived` for display ordering.
2703    pub(crate) timestamp_ms: i64,
2704    /// The chunk-0 outer `Message` app id, surfaced on `FileReceived` so an
2705    /// instance serving several applications can route the file. Without
2706    /// it a per-send media app id would reach the wire and stop there.
2707    pub(crate) app_id: String,
2708    /// Set when the sealed chunk-0 plaintext said this transfer belongs to
2709    /// the replicated-document layer. Its presence is what keeps every
2710    /// file-facing event of this transfer from firing: progress, completion
2711    /// and failure alike are the data layer's business, not a person's.
2712    ///
2713    /// Never populated from a legacy plaintext chunk. The field lives inside
2714    /// the sealed body precisely so an unauthenticated chunk cannot claim it.
2715    pub(crate) data_purpose: Option<crate::media_envelope::DataPurpose>,
2716}
2717
2718#[derive(Clone)]
2719pub(crate) struct OutboundMediaTransfer {
2720    pub(crate) content_type: ContentType,
2721    pub(crate) recipient: String,
2722    pub(crate) pinned_transport: TransportType,
2723    pub(crate) total_chunks: u32,
2724    pub(crate) delivered_chunks: HashSet<u32>,
2725    pub(crate) last_updated_at: Instant,
2726    pub(crate) media_metadata: Option<MediaMetadata>,
2727    /// Rich extras sealed into chunk 0 (already capability-gated at the
2728    /// `send_media_with` boundary). Kept on the transfer because chunk
2729    /// batches are (re-)encoded via `pump_media_transfers` too.
2730    pub(crate) rich_extras: Option<crate::media_envelope::MediaRichExtras>,
2731    /// Set when this SDK started the transfer for the replicated-document
2732    /// layer rather than on an application's behalf, and kept here for the
2733    /// same reason as the extras above: `pump_media_transfers` re-encodes
2734    /// later batches and has only the transfer to read from.
2735    ///
2736    /// No public send surface can produce one.
2737    pub(crate) data_purpose: Option<crate::media_envelope::DataPurpose>,
2738    /// Per-send application id, `None` for the configured one. Kept here
2739    /// for the same reason as the extras: `pump_media_transfers` builds
2740    /// later chunks from the transfer alone, and a chunk stamped with the
2741    /// configured id would be routed to a different application than
2742    /// chunk 0 on the receiving instance.
2743    pub(crate) app_id: Option<AppId>,
2744}
2745
2746/// Crash-scoped descriptor of an in-flight outbound media transfer.
2747///
2748/// Persisted (no chunk bytes — see commit 42d1b86's rationale: resurrected
2749/// chunks can never complete, and per-chunk secure-storage writes are
2750/// expensive) when a transfer starts and deleted whenever the in-memory
2751/// transfer is removed (completed, aborted, or stale-swept). A descriptor
2752/// found on restore therefore means the app died mid-transfer:
2753/// [`crate::events::Event::MediaResendRequired`] is emitted so the app can
2754/// re-supply the bytes via `send_media_with` under the same `file_id`,
2755/// validated against `file_checksum`.
2756#[derive(Clone, Serialize, Deserialize)]
2757pub(crate) struct MediaTransferDescriptor {
2758    pub(crate) file_id: String,
2759    pub(crate) recipient: String,
2760    pub(crate) file_name: String,
2761    pub(crate) file_size: u64,
2762    /// SHA-256 hex of the plaintext file bytes (same value every chunk
2763    /// carries as `FileChunk::file_checksum`).
2764    pub(crate) file_checksum: String,
2765    pub(crate) content_type: ContentType,
2766    /// Wall-clock start of the transfer; restore prunes by
2767    /// `outbox_max_lifetime_ms` age.
2768    pub(crate) queued_at: DateTime<Utc>,
2769    /// Set when the transfer belonged to the replicated-document layer.
2770    ///
2771    /// Persisted for one reason: so restore can tell these apart and NOT ask
2772    /// an application to re-supply them. There is no public API that could
2773    /// answer such a request without stripping the purpose, and a resend
2774    /// with the purpose stripped is a document snapshot delivered to the
2775    /// peer's user as a downloaded file, which is the exact outcome the
2776    /// capability gate and the envelope version exist to prevent, reached
2777    /// through this SDK's own recovery path.
2778    ///
2779    /// Serde-defaulted, so a descriptor written before this field existed
2780    /// still parses and reads as an ordinary transfer, which is what it was.
2781    #[serde(default, skip_serializing_if = "Option::is_none")]
2782    pub(crate) data_purpose: Option<crate::media_envelope::DataPurpose>,
2783    /// Per-send application id of the interrupted transfer, `None` for the
2784    /// configured one. Persisted so `MediaResendRequired` can name the
2785    /// application that must re-supply the bytes; an instance serving
2786    /// several has no other way to know. Serde-defaulted like the purpose.
2787    #[serde(default, skip_serializing_if = "Option::is_none")]
2788    pub(crate) app_id: Option<String>,
2789}
2790
2791pub(crate) enum OutboundSendPreparation {
2792    Ready(String),
2793    Queued(MessageId),
2794}
2795
2796/// Pins that every signing domain, live or reserved, is mutually non-prefixing.
2797///
2798/// # Why this test exists here, of all places
2799///
2800/// Four domains are in production and they live in three languages and two
2801/// repositories: `offline-ctrl-v1` (this crate), `offline-disc-v1` and
2802/// `offline-invite-v1` (the MLS crate), and `offline-relay-addr-v1` (the relay
2803/// server, plus hand-mirrored copies in the iOS and Android bridges). Nothing
2804/// can import all four, so this module is the one place they can be compared at
2805/// all: it is the highest crate that can see two of them, and the fourth is
2806/// pinned as a literal.
2807///
2808/// A fifth is reserved and covered here too, for the reason reservation exists:
2809/// non-prefixing is a property of the whole set, so a domain that is specified
2810/// but not yet emitted still has to be held against every other one. A guard
2811/// that watched only live domains would happily accept a new live domain that
2812/// prefixes a reserved one, and the collision would be discovered by the
2813/// implementation that finally emits it.
2814#[cfg(test)]
2815mod signing_domain_tests {
2816    use offline_protocol_mls::discovery::DISCOVERY_SIGN_DOMAIN;
2817    use offline_protocol_mls::invite::INVITE_SIGN_DOMAIN;
2818
2819    use offline_protocol_sealed::{CTRL_SIGN_DOMAIN, CTRL_SIGN_DOMAIN_V2};
2820
2821    /// The relay's address-proof domain.
2822    ///
2823    /// A literal because it is defined in the relay-server repository and
2824    /// hand-mirrored in `AddressDeclarationPolicy.swift` and
2825    /// `AddressDeclarationPolicy.kt` — there is no Rust constant in this
2826    /// workspace to import. If it ever changes there, this test does not
2827    /// notice, which is the accepted cost of a cross-repository constant and is
2828    /// why it is spelled out with this comment attached.
2829    const RELAY_ADDR_SIGN_DOMAIN: &[u8] = b"offline-relay-addr-v1";
2830
2831    /// The gateway's address-proof domain: **reserved, not yet emitted.**
2832    ///
2833    /// Specified by `docs/spec/gateway-contract.md` and registered in the
2834    /// signing-domain table of `docs/spec/username-discovery.md`. No code signs
2835    /// under it yet, which is precisely why it is pinned before anything does:
2836    /// it must be chosen against the whole set, and it must stay distinct from
2837    /// [`RELAY_ADDR_SIGN_DOMAIN`] so a proof harvested by a hostile gateway
2838    /// cannot be replayed against the relay, or the reverse.
2839    const GATEWAY_ADDR_SIGN_DOMAIN: &[u8] = b"offline-gateway-addr-v1";
2840
2841    /// No domain, live or reserved, may be a prefix of another.
2842    ///
2843    /// The canonical payload is `domain ‖ Σ(u32be(len) ‖ field)`, and the
2844    /// **domain itself is not length-prefixed**. So if one domain were a prefix
2845    /// of another, a signature made under the shorter domain could be replayed
2846    /// as one made under the longer: an attacker chooses a first field whose
2847    /// leading bytes supply the rest of the longer domain, and the two payloads
2848    /// become byte-identical. Length-prefixing the fields does not prevent it,
2849    /// because the collision happens before the first length prefix.
2850    ///
2851    /// The concrete damage this stops is recorded in the addressing work: a
2852    /// hostile relay that could make an address-proof signature verify as a
2853    /// control-frame signature would harvest a replayable control frame from
2854    /// every device that ever authenticated to it.
2855    #[test]
2856    fn signing_domains_are_mutually_non_prefixing() {
2857        let domains: [(&str, &[u8]); 6] = [
2858            ("offline-ctrl-v1", CTRL_SIGN_DOMAIN),
2859            ("offline-ctrl-v2", CTRL_SIGN_DOMAIN_V2),
2860            ("offline-disc-v1", DISCOVERY_SIGN_DOMAIN),
2861            ("offline-invite-v1", INVITE_SIGN_DOMAIN),
2862            ("offline-relay-addr-v1", RELAY_ADDR_SIGN_DOMAIN),
2863            ("offline-gateway-addr-v1", GATEWAY_ADDR_SIGN_DOMAIN),
2864        ];
2865
2866        for (a_name, a) in domains {
2867            for (b_name, b) in domains {
2868                if a_name == b_name {
2869                    continue;
2870                }
2871                // Both the constant's name and its *value* are reported: a
2872                // failure is usually caused by an edited value, and a message
2873                // naming only the constant sends the reader to the wrong place.
2874                assert!(
2875                    !a.starts_with(b),
2876                    "signing domain {} ({:?}) is a prefix of {} ({:?}), which \
2877                     lets a signature made under one verify under the other",
2878                    b_name,
2879                    String::from_utf8_lossy(b),
2880                    a_name,
2881                    String::from_utf8_lossy(a)
2882                );
2883            }
2884        }
2885    }
2886
2887    /// The literals are what the rest of the system, and every other
2888    /// implementation, actually expects. A renamed constant that still passes
2889    /// the non-prefix test above would silently invalidate every signature in
2890    /// the field, so the spellings are pinned too.
2891    #[test]
2892    fn signing_domains_have_their_published_spellings() {
2893        assert_eq!(CTRL_SIGN_DOMAIN, b"offline-ctrl-v1");
2894        assert_eq!(CTRL_SIGN_DOMAIN_V2, b"offline-ctrl-v2");
2895        assert_eq!(DISCOVERY_SIGN_DOMAIN, b"offline-disc-v1");
2896        assert_eq!(INVITE_SIGN_DOMAIN, b"offline-invite-v1");
2897        assert_eq!(RELAY_ADDR_SIGN_DOMAIN, b"offline-relay-addr-v1");
2898        assert_eq!(GATEWAY_ADDR_SIGN_DOMAIN, b"offline-gateway-addr-v1");
2899    }
2900
2901    /// All six must be distinct, which non-prefixing already implies for
2902    /// unequal strings but not for equal ones: two identical domains are
2903    /// prefixes of each other, and the loop above skips same-name pairs.
2904    #[test]
2905    fn signing_domains_are_distinct() {
2906        let domains: [&[u8]; 6] = [
2907            CTRL_SIGN_DOMAIN,
2908            CTRL_SIGN_DOMAIN_V2,
2909            DISCOVERY_SIGN_DOMAIN,
2910            INVITE_SIGN_DOMAIN,
2911            RELAY_ADDR_SIGN_DOMAIN,
2912            GATEWAY_ADDR_SIGN_DOMAIN,
2913        ];
2914        for (i, a) in domains.iter().enumerate() {
2915            for b in domains.iter().skip(i + 1) {
2916                assert_ne!(a, b, "two signing domains are the same string");
2917            }
2918        }
2919    }
2920}
2921
2922#[cfg(test)]
2923mod send_failure_classification_tests {
2924    use super::*;
2925
2926    /// Every token either producer can mint classifies to itself.
2927    ///
2928    /// This is what lets the two functions compose. A value written by
2929    /// `send_failure_token` is later re-read from a persisted welcome
2930    /// lifecycle and passed through `classify_transport_send_error`; without
2931    /// idempotence that round trip would degrade a precise token to the
2932    /// generic fallback on every emit.
2933    #[test]
2934    fn every_send_failure_token_classifies_to_itself() {
2935        for token in SEND_FAIL_REASON_TOKENS {
2936            assert_eq!(
2937                classify_transport_send_error(token),
2938                *token,
2939                "{token} must survive a re-classification"
2940            );
2941        }
2942    }
2943
2944    /// The vocabulary table really is the vocabulary.
2945    ///
2946    /// `classify_transport_send_error` answers from the table, so it cannot
2947    /// disagree with it — but `send_failure_token` matches independently, and a
2948    /// token added there and forgotten here would classify to the fallback on
2949    /// the next round trip. Walking the transport codes catches that for the
2950    /// arm most likely to grow.
2951    #[test]
2952    fn transport_error_tokens_are_in_the_vocabulary() {
2953        use offline_protocol_transport::Error as T;
2954        let cases = [
2955            T::TransportNotAvailable("ble".into()),
2956            T::PeerNotReachable("bob".into()),
2957            T::SendFailed("x".into()),
2958            T::ReceiveFailed("x".into()),
2959            T::ConfigurationError("x".into()),
2960            T::SerializationError("x".into()),
2961            T::MessageTooLarge(2, 1),
2962            T::CryptoError("x".into()),
2963            T::Other("x".into()),
2964        ];
2965        for case in cases {
2966            let token = send_failure_token(&crate::Error::Transport(case.clone()));
2967            assert!(
2968                SEND_FAIL_REASON_TOKENS.contains(&token),
2969                "{case:?} produced {token}, which is not in SEND_FAIL_REASON_TOKENS"
2970            );
2971        }
2972    }
2973
2974    /// A relay's prose is dropped and its token kept.
2975    ///
2976    /// The prefix is a cross-layer contract the bridges hardcode and core
2977    /// prefix-matches to park a DM, so it must survive verbatim; everything
2978    /// the relay appended to it must not.
2979    #[test]
2980    fn relay_prose_is_dropped_and_the_token_kept() {
2981        assert_eq!(
2982            classify_transport_send_error(
2983                "recipient_unreachable: Recipient is offline and push notification could not be sent"
2984            ),
2985            SEND_FAIL_REASON_RECIPIENT_UNREACHABLE
2986        );
2987        // The relay's real vocabulary, all of it, collapses to one token.
2988        for prose in [
2989            "Recipient connection lost and push notification could not be sent",
2990            "Push notification already sent for this message; recipient unreachable",
2991            "Recipient unreachable and the message is too large to deliver by push",
2992            "Group delivery did not settle for this recipient in time",
2993        ] {
2994            assert_eq!(
2995                classify_transport_send_error(&format!("recipient_unreachable: {prose}")),
2996                SEND_FAIL_REASON_RECIPIENT_UNREACHABLE
2997            );
2998        }
2999    }
3000
3001    /// The relay-pushed answer is its own token, not a `recipient_unreachable`
3002    /// tail. The bridges pass this literal (pinned on their side by
3003    /// `react_native_relay_parks_a_pushed_message_sent`), and reading it under
3004    /// the unreachable prefix would fast-fail connection requests and fail
3005    /// Welcomes that the push may have delivered.
3006    #[test]
3007    fn relay_pushed_is_its_own_token() {
3008        assert_eq!(SEND_FAIL_REASON_RELAY_PUSHED, "relay_pushed");
3009        assert!(!SEND_FAIL_REASON_RELAY_PUSHED.starts_with(SEND_FAIL_REASON_RECIPIENT_UNREACHABLE));
3010        assert_eq!(
3011            classify_transport_send_error("relay_pushed"),
3012            SEND_FAIL_REASON_RELAY_PUSHED
3013        );
3014    }
3015
3016    /// The mailbox tokens are their own too, for the same reason: neither may
3017    /// be read under the `recipient_unreachable` prefix, and each must
3018    /// classify to itself so a persisted record survives a round trip.
3019    #[test]
3020    fn stored_answers_are_their_own_tokens() {
3021        assert_eq!(SEND_FAIL_REASON_RELAY_STORED, "relay_stored");
3022        assert_eq!(SEND_FAIL_REASON_RELAY_PUSHED_STORED, "relay_pushed_stored");
3023        for token in [
3024            SEND_FAIL_REASON_RELAY_STORED,
3025            SEND_FAIL_REASON_RELAY_PUSHED_STORED,
3026        ] {
3027            assert!(!token.starts_with(SEND_FAIL_REASON_RECIPIENT_UNREACHABLE));
3028            assert_eq!(classify_transport_send_error(token), token);
3029        }
3030        // `relay_pushed_stored` must not be read as `relay_pushed` with a
3031        // tail: classification is exact for these, so the longer token keeps
3032        // its own meaning rather than degrading to the shorter one.
3033        assert_ne!(
3034            classify_transport_send_error(SEND_FAIL_REASON_RELAY_PUSHED_STORED),
3035            SEND_FAIL_REASON_RELAY_PUSHED
3036        );
3037    }
3038
3039    /// A push is parked in the core but is not a failed send, so the reports
3040    /// that must stay out of a carrier's failure accounting are the two exact
3041    /// push tokens. Anything else, including relay text that merely contains
3042    /// one, still counts — and `relay_stored` counts, because it answers a
3043    /// socket write that really did miss.
3044    #[test]
3045    fn only_a_relay_push_report_is_kept_out_of_carrier_failures() {
3046        use crate::OfflineProtocol;
3047        for reason in ["relay_pushed", "relay_pushed_stored"] {
3048            assert!(
3049                !OfflineProtocol::send_report_is_carrier_failure(Some(reason)),
3050                "{reason} is a push, not a failed send"
3051            );
3052        }
3053        for reason in [
3054            Some("recipient_unreachable: Recipient is offline"),
3055            Some("Internet transport send failed"),
3056            Some("relay_pushed: extra"),
3057            Some("relay_stored"),
3058            None,
3059        ] {
3060            assert!(
3061                OfflineProtocol::send_report_is_carrier_failure(reason),
3062                "{reason:?} is a send failure"
3063            );
3064        }
3065    }
3066
3067    /// Anything unrecognized fails closed to the fallback, never to itself.
3068    #[test]
3069    fn unknown_text_falls_back_and_is_never_echoed() {
3070        for raw in [
3071            "Relay rejected event: blocked",
3072            "off1qyvh9kjj4vy8f943a22qvxsct5s9ydew35v2dl2c is not connected",
3073            "",
3074        ] {
3075            let classified = classify_transport_send_error(raw);
3076            assert_eq!(classified, SEND_FAIL_REASON_TRANSPORT);
3077            assert!(
3078                SEND_FAIL_REASON_TOKENS.contains(&classified),
3079                "the fallback must itself be a token"
3080            );
3081        }
3082    }
3083
3084    /// The literals the UniFFI layer substitutes are classified too, so the
3085    /// vocabulary has exactly one source rather than two that drift.
3086    #[test]
3087    fn uniffi_substituted_literals_are_classified() {
3088        for literal in [
3089            "Internet transport send failed",
3090            "Reticulum transport send failed",
3091            "Nostr transport send failed",
3092        ] {
3093            assert_eq!(
3094                classify_transport_send_error(literal),
3095                SEND_FAIL_REASON_TRANSPORT
3096            );
3097        }
3098        assert_eq!(
3099            classify_transport_send_error("Welcome send confirmation timed out"),
3100            SEND_FAIL_REASON_CONFIRM_TIMEOUT
3101        );
3102    }
3103}
3104
3105/// Holds the spec's conformance-vector index against the files on disk.
3106///
3107/// # Why this test exists here, of all places
3108///
3109/// `docs/spec/README.md` carries a table naming every vector file and the
3110/// chapter it pins, and that table is what a second implementer reads to find
3111/// out what they can test themselves against. It is maintained by hand and
3112/// nothing compared it to the tree, so it was correct only by attention.
3113///
3114/// The failure is quiet in both directions. A vector file added without a row
3115/// is a conformance surface nobody outside this repository knows exists. A row
3116/// left behind by a moved or renamed file sends an implementer looking for
3117/// something that is not there, and the reasonable conclusion is that the
3118/// protocol has no vectors for that chapter rather than that the index is
3119/// stale.
3120///
3121/// It lives beside the signing-domain guard for the same reason that one is
3122/// here: this is the cross-cutting registry module, and the check needs no
3123/// crate's code at all, only the repository tree.
3124#[cfg(test)]
3125mod spec_vector_index_tests {
3126    use std::path::{Path, PathBuf};
3127
3128    fn repo() -> PathBuf {
3129        Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
3130    }
3131
3132    /// The one vector file that is not a conformance surface for a chapter of
3133    /// this specification.
3134    ///
3135    /// The NIP-44 vectors are the upstream specification's own, vendored for
3136    /// the Nostr carrier's sealing and pinned to the checksum that
3137    /// specification publishes. `spec/README.md` lists them anyway, with that
3138    /// stated in place of a chapter, because a reader who finds the file in the
3139    /// tree needs to know why it is different. `spec/conformance.md` does not,
3140    /// because its table is the set of surfaces this specification defines, and
3141    /// a row for someone else's vectors would make that table mean something
3142    /// looser.
3143    ///
3144    /// Named here rather than left as a silent one-way check, because an
3145    /// exemption a test does not state is indistinguishable from a test that
3146    /// was never finished.
3147    const NOT_A_CHAPTER_SURFACE: &str = "nip44.vectors.json";
3148
3149    /// The two documents that index the vectors, and what each is not required
3150    /// to name.
3151    ///
3152    /// `spec/README.md` is the table of contents a reader lands on;
3153    /// `spec/conformance.md` repeats the list with the direction each file
3154    /// pins. The duplication is deliberate, because a conformance chapter that
3155    /// sent the reader elsewhere for the list of what to test against would be
3156    /// missing its own subject. Both are checked in both directions, so the
3157    /// duplication cannot become a disagreement.
3158    const INDEXES: [(&str, &[&str]); 2] = [
3159        ("docs/spec/README.md", &[]),
3160        ("docs/spec/conformance.md", &[NOT_A_CHAPTER_SURFACE]),
3161    ];
3162
3163    /// One index document, or `None` where the repo tree is absent:
3164    /// `cargo package` cannot carry `docs/`, so a published crate's tests must
3165    /// still build.
3166    fn index(name: &str) -> Option<String> {
3167        std::fs::read_to_string(repo().join(name)).ok().or_else(|| {
3168            eprintln!("spec tree not present, skipping the vector index checks");
3169            None
3170        })
3171    }
3172
3173    /// Every vector file either index names exists.
3174    #[test]
3175    fn every_indexed_vector_file_exists() {
3176        for (doc, _) in INDEXES {
3177            let Some(text) = index(doc) else { return };
3178
3179            let mut named = 0;
3180            for token in text.split('`') {
3181                if token.ends_with(".vectors.json") && token.starts_with("crates/") {
3182                    named += 1;
3183                    assert!(
3184                        repo().join(token).is_file(),
3185                        "{doc} names {token}, which is not in the tree"
3186                    );
3187                }
3188            }
3189            assert!(
3190                named >= 8,
3191                "the scan of {doc} found only {named} vector files, so it is no \
3192                 longer reading the table and would pass against anything"
3193            );
3194        }
3195    }
3196
3197    /// Every vector file in the tree is named by the index.
3198    ///
3199    /// This is the direction that catches a surface shipped without being
3200    /// published, which is the one a reviewer cannot see: the tests pass, the
3201    /// file is real, and only an outside implementer ever notices it is
3202    /// undocumented.
3203    #[test]
3204    fn every_vector_file_in_the_tree_is_indexed() {
3205        let mut found = Vec::new();
3206        for crate_dir in std::fs::read_dir(repo().join("crates")).expect("crates/") {
3207            let data = crate_dir.expect("an entry").path().join("tests/data");
3208            let Ok(entries) = std::fs::read_dir(&data) else {
3209                continue;
3210            };
3211            for entry in entries {
3212                let path = entry.expect("an entry").path();
3213                let name = path
3214                    .file_name()
3215                    .expect("a name")
3216                    .to_string_lossy()
3217                    .to_string();
3218                if name.ends_with(".vectors.json") {
3219                    // Compared against a path written in a markdown table, so
3220                    // the separator has to be the one the document uses, not
3221                    // the one this platform builds. `Path::join` yields
3222                    // backslashes on Windows and the table is all forward
3223                    // slashes, so without the replace, every comparison below
3224                    // fails there and passes everywhere else.
3225                    //
3226                    // Falling back to the bare file name if the prefix will not
3227                    // strip would be worse than failing: the table holds full
3228                    // paths that end in that name, so `contains` would match on
3229                    // any of them and the check would quietly degrade to
3230                    // asserting the file is mentioned somewhere.
3231                    let relative = path.strip_prefix(repo()).unwrap_or_else(|_| {
3232                        panic!("{} is not under the repo root", path.display())
3233                    });
3234                    found.push(relative.to_string_lossy().replace('\\', "/"));
3235                }
3236            }
3237        }
3238
3239        assert!(
3240            !found.is_empty(),
3241            "no vector files were found, so this test proves nothing"
3242        );
3243
3244        for (doc, exempt) in INDEXES {
3245            let Some(text) = index(doc) else { return };
3246            for path in &found {
3247                if exempt.iter().any(|name| path.ends_with(name)) {
3248                    continue;
3249                }
3250                assert!(
3251                    text.contains(path),
3252                    "{path} is a conformance surface that {doc} does not list, \
3253                     so nobody outside this repository knows it exists"
3254                );
3255            }
3256        }
3257    }
3258}