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::{dispatch_record, scrub_event, TelemetryContext, TelemetryRecord};
5use crate::Error;
6use chrono::{DateTime, Utc};
7use offline_protocol_core::{
8    ContentType, ForwardInfo, MediaMetadata, Message, MessageId, MessagePriority, ReplyContext,
9};
10use offline_protocol_transport::TransportType;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashSet, VecDeque};
13use std::sync::{Arc, Mutex};
14use std::time::Instant;
15use tracing::warn;
16
17/// Retry interval for persisting session confirmation after a transient storage error.
18pub(crate) const CONFIRMATION_RETRY_INTERVAL_SECS: i64 = 5;
19/// Probe interval for reconciling pending sessions after restart.
20pub(crate) const CONFIRMATION_PROBE_INTERVAL_SECS: i64 = 5;
21/// Minimum interval between 1:1 session re-keys for the same peer, triggered by
22/// an epoch-desync decrypt failure. A re-key is a full teardown + key-package
23/// re-exchange, so it is rate-limited well above the confirmation-probe cadence:
24/// one legit desync heals in a single round-trip, and this bounds a peer
25/// replaying stale-epoch ciphertext (or an injected wrong-epoch frame) to at
26/// most one re-key per this window rather than a storm. The floor is enforced
27/// unconditionally — a successful decrypt on the healed session does NOT reset
28/// it — so an attacker cannot defeat it by interleaving replays with legit
29/// traffic (see `schedule_session_rekey`).
30pub(crate) const REKEY_INTERVAL_SECS: i64 = 30;
31/// Number of welcome retry records processed per tick.
32pub(crate) const WELCOME_RETRY_BATCH_SIZE: usize = 20;
33/// Hard TTL for outbound welcome lifecycle records.
34pub(crate) const WELCOME_LIFECYCLE_TTL_SECS: i64 = 300;
35/// Jitter ratio applied to welcome retry backoff delays.
36pub(crate) const WELCOME_RETRY_JITTER_RATIO: f64 = 0.2;
37/// Timeout waiting for explicit internet send confirmation for welcome.
38pub(crate) const WELCOME_INTERNET_CONFIRM_TIMEOUT_SECS: i64 = 10;
39/// Timeout waiting for a mesh (BLE / WiFi-Direct) welcome to be confirmed by
40/// the peer proving the session (probe / ack / welcome / decrypt). A mesh
41/// `send()` returning Ok only means the local stack accepted the bytes, not
42/// that the multi-fragment Welcome reassembled on the peer — so the lifecycle
43/// stays non-terminal and the retry queue re-sends the whole Welcome after this
44/// window, recovering from a lost fragment. Slightly longer than the internet
45/// timeout to allow a slow multi-fragment Welcome to assemble plus the probe
46/// round-trip before paying to re-fragment and re-send it.
47pub(crate) const WELCOME_MESH_CONFIRM_TIMEOUT_SECS: i64 = 15;
48/// Retry cadence for a Welcome that has no transport carrier at all (the peer is
49/// simply unreachable right now). A send is guaranteed to fail with no carrier,
50/// so we do NOT burn the speculative send/transition/event churn on it every
51/// retry tick — we park it and re-check on this slow interval instead. The
52/// primary recovery is event-driven (`on_neighbor_discovered` → re-arm fires the
53/// instant a carrier surfaces the peer); this poll is only a safety net for a
54/// carrier returning without a fresh discovery event, so it is deliberately far
55/// slower than the data-plane retry interval to keep an offline device quiet.
56/// Also the base interval for the plain-DM unreachable reachability probe
57/// (`handle_recipient_unreachable_for_message`), which shares this
58/// escalation.
59pub(crate) const WELCOME_NO_CARRIER_RETRY_SECS: i64 = 15;
60/// Cap for the escalating retry interval of a welcome repeatedly parked
61/// `PeerUnreachable`, on every carrier (see
62/// `apply_recipient_unreachable_failure`). Each consecutive unreachable park
63/// doubles the interval from [`WELCOME_NO_CARRIER_RETRY_SECS`] up to this
64/// cap: DORS may keep selecting the internet path for the timed retry, and
65/// every such round trips another relay `DeliveryError` with the attempt
66/// refunded — without escalation that is an unbounded 15s resend loop into
67/// the relay for as long as the peer stays offline. At the cap the steady
68/// state matches the presence-rescue cadence (one send per 10 min), which is
69/// cheap and self-resolving. Shared by the plain-DM unreachable probe
70/// (`handle_recipient_unreachable_for_message`, per-peer
71/// `dm_unreachable_parks` counter) for the same reason — and there the
72/// escalation carries the whole bound, since that probe runs on every carrier
73/// (see `park_unreachable_dm`), including internet-only devices.
74pub(crate) const WELCOME_UNREACHABLE_RETRY_CAP_SECS: i64 = 600;
75/// Age limit for a welcome lifecycle to keep its peer on the presence
76/// watchlist (`welcome_pending_peers`). Without it the watch set only ever
77/// grows: every offline presence answer re-parks the record and pushes its
78/// `expires_at`, so a permanently-dead peer (abandoned install) is watched —
79/// and its parked lifecycle persisted — forever, and each such peer occupies
80/// rotation slots that delay presence rescue for live peers. Once unwatched,
81/// offline answers stop, `expires_at` stops being pushed, and the record
82/// ages out through normal expiry; recovery degrades to peer-initiated
83/// contact or mesh discovery, both of which rebuild the lifecycle.
84pub(crate) const WELCOME_WATCHLIST_MAX_AGE_SECS: i64 = 14 * 24 * 60 * 60;
85/// Backoff base for presence-driven welcome rescue. The first rescue for a
86/// peer is immediate; each subsequent rescue that still fails to prove the
87/// session doubles the wait (40s, 80s, 160s, ...) up to
88/// [`WELCOME_PRESENCE_RESCUE_MAX_SECS`]. Bounds the resend loop when a peer
89/// is provably online but can never confirm (stale key package after a
90/// reinstall, incompatible peer version) — without it the platform's 20s
91/// presence watch would re-send the multi-frame MLS welcome forever.
92pub(crate) const WELCOME_PRESENCE_RESCUE_BASE_SECS: i64 = 40;
93/// Cap for the presence-rescue backoff (10 minutes). Deliberately not a
94/// terminal state: a peer that stays online but never confirms keeps getting
95/// one rescue per cap interval, forever. That steady state is cheap (one
96/// multi-frame welcome per 10 min per such peer) and self-resolving — the
97/// lifecycle disappears the moment the session confirms — so a give-up
98/// threshold would only add a way to strand a recoverable session.
99pub(crate) const WELCOME_PRESENCE_RESCUE_MAX_SECS: i64 = 600;
100/// Well-known prefix for transport send-failure reasons meaning "the carrier
101/// is up but this recipient is unreachable on it" (e.g. the internet relay
102/// answered `DeliveryError` for an offline peer). Classified in
103/// `on_transport_send_failed` as authoritative proof the frame was dropped:
104/// instead of burning a retry attempt, a welcome refunds it and parks on an
105/// escalating reachability probe (`apply_recipient_unreachable_failure`), and
106/// a plain DM parks the same way (`park_unreachable_dm`). The escalation, not
107/// a carrier guard, is what keeps those probes from becoming a resend loop
108/// into the relay.
109///
110/// Cross-layer contract: the React Native platform bridges
111/// (`InternetManager.kt` / `InternetManager.swift`) hardcode this literal when
112/// calling `internet_send_failed_with_reason` — keep them in sync.
113pub(crate) const SEND_FAIL_REASON_RECIPIENT_UNREACHABLE: &str = "recipient_unreachable";
114
115/// Fallback token for a send failure that classifies as nothing more specific.
116pub(crate) const SEND_FAIL_REASON_TRANSPORT: &str = "transport_send_failed";
117/// A Welcome was written to a carrier that never confirmed it.
118pub(crate) const SEND_FAIL_REASON_CONFIRM_TIMEOUT: &str = "send_confirmation_timed_out";
119
120/// Every token [`classify_transport_send_error`] and
121/// [`send_failure_token`] can produce.
122///
123/// Membership is what makes classification idempotent: a value already drawn
124/// from this list re-classifies to itself, so a record written by one producer
125/// and re-read by the other is not degraded to the fallback. Keep it in sync
126/// with the two functions — the round-trip test
127/// `every_send_failure_token_classifies_to_itself` fails otherwise.
128pub(crate) const SEND_FAIL_REASON_TOKENS: &[&str] = &[
129    SEND_FAIL_REASON_RECIPIENT_UNREACHABLE,
130    SEND_FAIL_REASON_TRANSPORT,
131    SEND_FAIL_REASON_CONFIRM_TIMEOUT,
132    "transport_not_connected",
133    "peer_not_reachable",
134    "message_too_large",
135    "serialization_failed",
136    "crypto_failed",
137    "session_not_ready",
138    "recipient_blocked",
139    "media_transfer_limit",
140    "group_not_found",
141    "invalid_request",
142    "protocol_state_invalid",
143];
144
145/// Maps a platform-supplied or previously-stored send-failure string onto a
146/// fixed local vocabulary.
147///
148/// # Why the string cannot be passed through
149///
150/// The bridges build this string from the relay's `DeliveryError.reason`
151/// (`"recipient_unreachable: <relay text>"` — the shape is a cross-layer
152/// contract, see [`SEND_FAIL_REASON_RECIPIENT_UNREACHABLE`]), and the Nostr
153/// bridge from a relay's `OK` rejection text. Both tails are remote-chosen,
154/// unbounded, and not ours. They reach `MessageUndeliverable.reason`,
155/// `ConnectionRequestUndeliverable.reason` and — through
156/// `WelcomeLifecycleRecord::last_transport_error` —
157/// `WelcomeSendFailed.transport_error`, every one of which the telemetry
158/// scrubber ships verbatim beside a `recipient`/`peer_id` it *hashes*. An
159/// identifier in the tail therefore undoes the hashing in the same record, and
160/// even the honest relay writes one: its own wording renders the peer it
161/// concerns.
162///
163/// # Why the prefix survives
164///
165/// The `recipient_unreachable` token is not decoration — core prefix-matches it
166/// to fast-fail connection requests and to park plain DMs, and the bridges
167/// hardcode it. So the classification *is* the bare token: everything the
168/// protocol reads is kept, and only the prose tail is dropped.
169///
170/// Matching is prefix-based for that one token (the bridges append to it) and
171/// exact otherwise, with a closed fallback — a bridge or relay rewording
172/// degrades to [`SEND_FAIL_REASON_TRANSPORT`], never back to shipping its text.
173/// The `&'static str` return is load-bearing the same way
174/// [`GroupErrorPayload::classify_reason`] and `MlsError::privacy_safe_reason`
175/// are: it makes interpolating the input unrepresentable rather than merely
176/// discouraged.
177///
178/// Idempotent by construction, which matters twice: a record persisted before
179/// this existed is classified when it is *read* (the stored string may be raw
180/// relay prose), and a record written after it is classified again on the way
181/// out without changing.
182pub(crate) fn classify_transport_send_error(raw: &str) -> &'static str {
183    // Prefix, not equality: this is the one token the bridges extend.
184    if raw.starts_with(SEND_FAIL_REASON_RECIPIENT_UNREACHABLE) {
185        return SEND_FAIL_REASON_RECIPIENT_UNREACHABLE;
186    }
187    // Already classified: a re-read of a record either producer wrote. Returns
188    // the entry from the table rather than `raw`, which is what keeps the
189    // signature `&'static str`.
190    if let Some(token) = SEND_FAIL_REASON_TOKENS.iter().find(|t| **t == raw) {
191        return token;
192    }
193    match raw {
194        // The fixed literals the UniFFI layer substitutes when a bridge reports
195        // a failure without a reason of its own. Locally minted, but classified
196        // here too so the vocabulary has exactly one source.
197        "Internet transport send failed"
198        | "Reticulum transport send failed"
199        | "Nostr transport send failed" => SEND_FAIL_REASON_TRANSPORT,
200        "Welcome send confirmation timed out" => SEND_FAIL_REASON_CONFIRM_TIMEOUT,
201        // Everything else, including anything a relay or bridge chose.
202        _ => SEND_FAIL_REASON_TRANSPORT,
203    }
204}
205
206/// Classifies a locally raised send failure for an event field.
207///
208/// The sibling of [`classify_transport_send_error`] for errors this device
209/// produced rather than received. It exists for the same reason: the transport
210/// layer renders the counterparty into its own message —
211/// `PeerNotReachable("no mesh transport holds a link to {peer_id}…")`, and the
212/// same in the BLE and Wi-Fi Direct backends — so `format!("{}", err)` on a
213/// routine send failure ships the peer's address to a telemetry sink. On
214/// `MessageDeferred` that was the *only* identity in the record, since the
215/// event carried no field for the scrubber to hash.
216///
217/// The match over [`crate::Error`] is exhaustive with no `_` arm: it lives in
218/// the defining crate, so a new variant fails to compile here and forces the
219/// privacy decision where the variant is written. The transport arm delegates
220/// to `offline_protocol_transport::Error::code`, which is that crate's own
221/// exhaustive, documented-stable classifier — its doc names telemetry
222/// classification as the intended use. Matching its output needs a fallback
223/// arm (a foreign `#[non_exhaustive]` enum can grow a variant), and that
224/// fallback is closed: a new transport variant degrades to
225/// [`SEND_FAIL_REASON_TRANSPORT`], never to its rendered text.
226pub(crate) fn send_failure_token(err: &crate::Error) -> &'static str {
227    use crate::Error as E;
228    match err {
229        E::Transport(inner) => match inner.code() {
230            "TRANSPORT_NOT_AVAILABLE" => "transport_not_connected",
231            "PEER_NOT_REACHABLE" => "peer_not_reachable",
232            "MESSAGE_TOO_LARGE" => "message_too_large",
233            "SERIALIZATION_ERROR" => "serialization_failed",
234            "CRYPTO_ERROR" => "crypto_failed",
235            _ => SEND_FAIL_REASON_TRANSPORT,
236        },
237        E::NotStarted | E::AlreadyStarted | E::InvalidState(_) => "protocol_state_invalid",
238        E::InvalidConfiguration(_) | E::InvalidArgument(_) | E::PermissionDenied(_) => {
239            "invalid_request"
240        }
241        E::Core(_) | E::Serialization(_) => "serialization_failed",
242        E::Router(_) | E::Reliability(_) | E::Service(_) => SEND_FAIL_REASON_TRANSPORT,
243        E::Mls(_) | E::EncryptFailed(_) => "crypto_failed",
244        E::MlsNotInitialized | E::SessionNotReady(_) | E::NoKeyPackage(_) => "session_not_ready",
245        E::UserBlocked(_) => "recipient_blocked",
246        E::MediaTransferLimit(_) => "media_transfer_limit",
247        E::GroupNotFound(_) => "group_not_found",
248        E::Other(_) => SEND_FAIL_REASON_TRANSPORT,
249    }
250}
251/// Minimum interval between session reconciliation scans (list_sessions I/O).
252/// Keeps the expensive Keychain/Keystore I/O out of the hot path so that
253/// sendMessage() is not blocked by Mutex contention on every process tick.
254pub(crate) const RECONCILIATION_THROTTLE_MS: u64 = 2_000;
255/// Lamport clock ticks between storage persistence writes. Avoids a
256/// Keychain/Keystore write on every sent and received message. On crash
257/// recovery, at most this many ticks are lost, which is safe — the clock
258/// is only used for causal ordering and the gap is absorbed on the next
259/// merge with any peer.
260pub(crate) const LAMPORT_PERSIST_INTERVAL: u64 = 64;
261/// Seconds the Nostr receive watermark must advance before it is written back
262/// to protocol-state storage. Same debounce role as
263/// [`LAMPORT_PERSIST_INTERVAL`]: every inbound relay event moves the mark, and
264/// a storage write per message is exactly the I/O the Lamport debounce exists
265/// to avoid.
266///
267/// What an un-flushed gap costs is bounded and one-directional: a launch that
268/// loses up to this much advance re-fetches a slightly wider slice of relay
269/// history, which dedup absorbs. It can never *skip* messages, because the
270/// stale mark is always behind the live one.
271pub(crate) const NOSTR_WATERMARK_PERSIST_INTERVAL_SECS: i64 = 300;
272pub(crate) const MEDIA_TRANSFER_STALE_TIMEOUT_SECS: u64 = 300;
273/// Maximum number of tracked known peers for service discovery.
274pub(crate) const MAX_KNOWN_PEERS: usize = 1000;
275/// How long a known peer stays tracked without being re-seen.
276///
277/// Peers on carriers with no disconnect signal (Internet, Reticulum, Nostr,
278/// and WiFi Direct message-path senders) are only ever *added* to
279/// `known_peers`; this TTL is their eviction path, swept from the periodic
280/// `cleanup_expired_entries` tick. A second layer — least-recently-seen
281/// eviction when an insert hits `MAX_KNOWN_PEERS` — guarantees a newly
282/// discovered peer is always tracked even between sweeps.
283///
284/// Deliberately generous: a connected-but-quiet BLE peer refreshes only via
285/// platform advertisement sightings (BLE inbound messages do not route
286/// through the discovery hook), so a short TTL would evict it while still
287/// connected. Eviction is self-healing (the peer is re-tracked on its next
288/// advertisement or message), so erring long only delays hygiene.
289pub(crate) const KNOWN_PEER_TTL_SECS: u64 = 1800;
290
291/// Battery level below which a device stops carrying traffic for other
292/// people even while charging.
293///
294/// Re-exported from the router crate rather than restated here: message
295/// forwarding and the relay role apply the same floor, and a device must not
296/// keep carrying traffic at a level that would have stripped it of the role.
297/// Two copies of the number would eventually disagree, and nothing would
298/// notice.
299pub(crate) use offline_protocol_router::CRITICAL_RELAY_BATTERY_LEVEL;
300
301/// Metadata key for the Ed25519 signature over the control message content (base64).
302pub(crate) const CTRL_SIG_META_KEY: &str = "__ctrl_sig";
303/// Metadata key for the sender's Ed25519 public key (base64, 32 bytes raw).
304pub(crate) const CTRL_PK_META_KEY: &str = "__ctrl_pk";
305
306/// Domain separator prepended to the canonical signing payload.
307///
308/// Prevents cross-context signature reuse: a signature produced for control
309/// messages cannot be replayed in a future protocol extension that reuses the
310/// same MLS identity key but with a different domain separator.
311pub(crate) const CTRL_SIGN_DOMAIN: &[u8] = b"offline-ctrl-v1";
312
313/// Maximum number of peers retained in the encryption-capability set that gates
314/// inbound plaintext.
315///
316/// Sized to hold every legitimate source at once — the durable capability
317/// records plus the MLS session list — with room to spare, so a real deployment
318/// never reaches it. The cap exists because the set is keyed by a wire-claimed
319/// sender id and nothing else bounds it.
320///
321/// Note what does *not* bound it: requiring a signature. The control gate does
322/// now reject unsigned control frames before dispatch, so every marking path
323/// runs on a verified frame — and that changes nothing here. A signature proves
324/// the signer owns the address it claims; it does not prove the address belongs
325/// to anyone real, and minting one costs a keygen. An attacker spending keygens
326/// produces unlimited distinct well-signed peers, so "written only after a
327/// signature verifies" is not by itself a bound on anything.
328///
329/// It bounds the durable `encryption_capable_peers` storage category as well,
330/// because `OfflineProtocol::record_encryption_capable` persists only for a
331/// peer this cap admitted.
332///
333/// Unlike the other maps keyed by a wire-claimed id, this one **refuses** at
334/// capacity instead of resetting or evicting: forgetting a peer here is the
335/// fail-open direction. See `OfflineProtocol::mark_encryption_capable`.
336pub(crate) const MAX_ENCRYPTION_CAPABLE_PEERS: usize = 8192;
337
338/// Maximum number of blocked users to retain.
339pub(crate) const MAX_BLOCKED_USERS: usize = 10_000;
340
341/// Maximum number of peers tracked for once-per-peer
342/// `PlaintextReceiveRejected` warning suppression.
343///
344/// The keys are wire-claimed (attacker-controllable) sender ids, so the set
345/// resets at capacity instead of growing without bound: a flood of forged
346/// senders degrades the throttle to once-per-peer-per-generation while
347/// memory stays capped.
348pub(crate) const MAX_PLAINTEXT_RECEIVE_WARNED_PEERS: usize = 1000;
349
350/// Maximum number of peers tracked for once-per-peer-per-code control-gate
351/// warning suppression.
352///
353/// Every rejection the control gate reports is reachable from an unauthenticated
354/// frame carrying an attacker-chosen sender id — that is what a gate rejection
355/// *is* — so without a throttle an off-path injector turns the app's event
356/// stream into a flood and desensitizes operators to the one code that has no
357/// benign reading (`SENDER_ADDRESS_MISMATCH`). Bounded and reset at capacity for
358/// the same reason as [`MAX_PLAINTEXT_RECEIVE_WARNED_PEERS`]: the keys are
359/// attacker-controlled, so a forged-sender flood degrades the throttle to
360/// once-per-peer-per-generation rather than growing memory without bound.
361pub(crate) const MAX_CONTROL_GATE_WARNED_PEERS: usize = 1000;
362
363/// Maximum number of pending (received-but-unused) peer key packages retained
364/// in memory and in durable `MlsStorage`.
365///
366/// Keyed by the wire-claimed `sender`, so — like [`MAX_KNOWN_PEERS`] — an
367/// unpinned peer can flood distinct forged ids under the default config. Each
368/// entry is also written to durable secure storage (`persist_peer_key_package`,
369/// iOS Keychain / Android Keystore) and re-loaded on boot, so without this cap
370/// a flood grows durable storage without bound and re-inflates memory on every
371/// restart. At capacity a new peer evicts the soonest-to-expire entry and drops
372/// its persisted copy; the restore-on-boot loop is capped the same way.
373pub(crate) const MAX_PENDING_KEY_PACKAGES: usize = 1000;
374
375/// Maximum outbound messages waiting for one peer's MLS session.
376///
377/// This queue is durable and accepts application-controlled content, so a peer
378/// that never completes session establishment must not grow it without bound.
379/// The limit matches the default inbound pending-decryption per-peer bound.
380///
381/// A count alone does not bound memory or durable storage — message content is
382/// application-supplied — so this cap works together with
383/// [`MAX_PENDING_MESSAGE_BYTES_PER_PEER`]; whichever binds first wins.
384pub(crate) const MAX_PENDING_MESSAGES_PER_PEER: usize = 64;
385
386/// Maximum outbound messages waiting for MLS sessions across all peers.
387///
388/// At capacity the globally oldest message is settled as failed before the new
389/// message is admitted. The limit matches the default inbound pending queue.
390/// Paired with [`MAX_PENDING_MESSAGE_BYTES_GLOBAL`], as above.
391pub(crate) const MAX_PENDING_MESSAGES_GLOBAL: usize = 4096;
392
393/// Maximum pending entries expired in one `process()` tick.
394///
395/// Each expiry is a settlement *and* a durable delete, and the two have to stay
396/// paired — so what gets bounded is how many entries expire per pass, not how
397/// many deletes it may issue. Messages queued in a burst share a `queued_at` and
398/// so come due in a burst; without this, one tick of the 100 ms bindings loop
399/// could issue up to [`MAX_PENDING_MESSAGES_GLOBAL`] synchronous deletes, each a
400/// device barrier on every built-in provider.
401///
402/// The remainder is not deferred indefinitely: the entries left behind are still
403/// past their deadline, so `recompute_next_pending_message_expiry` leaves
404/// `next_pending_message_expiry` in the past and the very next tick drains
405/// another pass.
406pub(crate) const MAX_PENDING_EXPIRIES_PER_PASS: usize = 64;
407
408/// Maximum per-message records one launch writes migrating legacy queues.
409///
410/// The migration's writes are the one part of the pending restore that is not
411/// already bounded by a delete budget, and a `store` is the more expensive of
412/// the two: every built-in provider flushes the record *and* its directory. A
413/// pre-split install sitting near [`MAX_PENDING_MESSAGES_GLOBAL`] would
414/// otherwise pay thousands of device barriers inside `initialize_mls`, on the
415/// launch path, where a mobile watchdog is watching.
416///
417/// Sized to match [`crate::protocol::storage::MAX_RESTORE_PRUNE_DELETES`], so
418/// the whole walk's barrier count stays the same order of magnitude whichever
419/// half of it does the work. Checked per recipient and before its first write,
420/// so a queue is either migrated whole or left entirely on disk for the next
421/// launch — never split across the two layouts with nothing to reconcile it.
422pub(crate) const MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH: usize = 512;
423
424/// Maximum terminal settlements parked by restore for `start()` to drain.
425///
426/// The restore caps already bound how many can be produced, but they bound it
427/// as a sum across every category, and nothing drains this queue until
428/// `start()` — which an application that only calls `initialize_mls`, or that
429/// retries it against a store that keeps failing, may never reach. Sized above
430/// the largest single-category restore ([`MAX_PENDING_MESSAGES_GLOBAL`], the
431/// pending queue's own global cap) so no legitimate restore is ever truncated;
432/// past that point the count is reported instead of the events.
433pub(crate) const MAX_DEFERRED_RESTORE_SETTLEMENTS: usize = 2 * MAX_PENDING_MESSAGES_GLOBAL;
434
435/// Maximum size of application-supplied message content accepted at the public
436/// send boundary, in bytes.
437///
438/// Enforced at the boundary — not at transmit time — because a message queued
439/// behind MLS session establishment never reaches the transport's
440/// `DEFAULT_MAX_MESSAGE_SIZE` (1 MiB) check: it sits in the durable pending
441/// queue first. Without a boundary cap a handful of very large sends could
442/// exhaust mobile memory and protocol-state disk while still being formally
443/// "within" the count caps above.
444///
445/// 256 KiB leaves ample headroom under the 1 MiB transport ceiling for MLS
446/// ciphertext expansion, base64, and the JSON wire envelope, so anything this
447/// path accepts can actually be delivered. Larger payloads belong on the media
448/// path (`send_media`), which chunks.
449pub(crate) const MAX_MESSAGE_CONTENT_BYTES: usize = 256 * 1024;
450
451/// Maximum total serialized bytes of one peer's pending-session queue.
452///
453/// At capacity the peer's oldest entries are settled as failed until the
454/// incoming message fits, exactly like the count cap. Deliberately larger than
455/// [`MAX_MESSAGE_CONTENT_BYTES`] plus [`MAX_RICH_EXTRAS_BYTES`] so a single
456/// boundary-legal message always fits in an empty queue and admission can never
457/// livelock (pinned by `pending_queue_byte_budgets_admit_any_boundary_legal_message`).
458///
459/// # Durable cost
460///
461/// Pending messages are persisted one record per message
462/// ([`storage_keys::PENDING_MESSAGE_ENTRIES`]), so an enqueue writes its own
463/// entry and nothing else: filling one peer to this budget costs `budget` bytes
464/// written, not `budget × entries / 2`. The per-recipient layout this replaced
465/// re-serialized the peer's whole queue on every enqueue, which made the byte
466/// cost quadratic in the entry count.
467///
468/// Bytes were never the expensive half, though, and sizing this budget off them
469/// would be reading the wrong meter. Every built-in provider pays two device
470/// barriers per `store` (the record's own flush plus its directory's) and one
471/// per `delete`, *regardless of record size* — so a 500-byte write and a 2 MiB
472/// write cost the same in the resource that actually dominates. What the
473/// per-message layout buys is not primarily speed but that a record which will
474/// not open still names the message it lost; see
475/// [`storage_keys::PENDING_MESSAGE_ENTRIES`].
476///
477/// So this budget is still sized to bound a pathological queue rather than to
478/// describe a normal one — real queues hold a handful of short messages waiting
479/// on a handshake. Lowering it below `MAX_MESSAGE_CONTENT_BYTES +
480/// MAX_RICH_EXTRAS_BYTES` breaks admission outright.
481pub(crate) const MAX_PENDING_MESSAGE_BYTES_PER_PEER: usize = 2 * 1024 * 1024;
482
483/// Maximum total serialized bytes of the pending-session queue across all
484/// peers. Bounds the whole feature's footprint on a mobile device regardless of
485/// how many peers are mid-establishment.
486pub(crate) const MAX_PENDING_MESSAGE_BYTES_GLOBAL: usize = 16 * 1024 * 1024;
487
488/// Maximum size of a single persisted protocol-state record, in bytes.
489///
490/// Enforced on both sides of [`crate::ProtocolStateStorage`]: the SDK refuses to
491/// write a larger record, and refuses to *deserialize* a larger one on restore
492/// (dropping it instead), so a corrupted or tampered state file cannot turn
493/// into an unbounded allocation during initialization.
494///
495/// Sized above [`MAX_PENDING_MESSAGE_BYTES_PER_PEER`] with room for JSON and
496/// seal overhead. That was once tight rather than generous: under the
497/// per-recipient pending layout the largest legitimate record *was* one peer's
498/// full queue, so the two constants were one bound apart. Now that pending
499/// messages are keyed per id the largest legitimate record is a single message
500/// ([`MAX_MESSAGE_CONTENT_BYTES`] plus [`MAX_RICH_EXTRAS_BYTES`]) or one outbox
501/// entry, and the headroom is real. Keep the ordering anyway — a legacy
502/// per-recipient record still has to be readable for long enough to migrate it.
503pub(crate) const MAX_PROTOCOL_STATE_RECORD_BYTES: usize = 4 * 1024 * 1024;
504
505/// Maximum number of peers remembered in `key_package_sent_to` (the "already
506/// sent our key package to this peer" set).
507///
508/// Wire-claimed ids grow this set in lockstep with a key-package flood, so it
509/// resets at capacity like [`MAX_PLAINTEXT_RECEIVE_WARNED_PEERS`]: the only cost
510/// of forgetting a peer is a one-time idempotent re-send of our key package.
511pub(crate) const MAX_KEY_PACKAGE_SENT_TO: usize = 1000;
512
513/// Maximum number of peers tracked in `rekey_due_at` (the per-peer epoch-desync
514/// re-key floor).
515///
516/// The desync classification that reaches `schedule_session_rekey` is produced
517/// by OpenMLS *before* it authenticates anything, so the peer id keying this map
518/// is wire-claimed. The envelope/sender binding in `SessionManager::decrypt_message`
519/// already confines that id to peers we hold a session with, which bounds the map
520/// on its own; this cap is the same defence-in-depth every other wire-keyed map
521/// carries. Resets at capacity like [`MAX_KEY_PACKAGE_SENT_TO`] — forgetting a
522/// peer only costs one extra re-key being allowed through early.
523pub(crate) const MAX_REKEY_TRACKED_PEERS: usize = 1000;
524
525/// Maximum lifetime (in milliseconds) honored for a *received* peer key
526/// package's cached expiry.
527///
528/// `remaining_lifetime_ms` arrives on the wire unauthenticated and becomes the
529/// eviction sort key for [`MAX_PENDING_KEY_PACKAGES`] (the soonest-to-expire
530/// entry is evicted first). Without a ceiling, a flood of forged senders
531/// claiming a maximal lifetime would pin their entries as latest-to-expire and
532/// preferentially evict legitimate peers. Key packages are minted with a 30-day
533/// lifetime (`DEFAULT_KEY_PACKAGE_LIFETIME_SECS` in `offline-protocol-mls`), so
534/// a larger value is never legitimate. This bound is purely cache bookkeeping —
535/// OpenMLS enforces real key-package validity at use time — so clamping only
536/// affects when we drop the *cached* copy, never crypto correctness.
537pub(crate) const MAX_KEY_PACKAGE_LIFETIME_MS: u64 = 30 * 24 * 60 * 60 * 1000;
538
539/// Durable record that a peer has proved it runs MLS.
540///
541/// Written whenever a control message from that peer verifies — signature
542/// valid, and the signing key derives to the address the frame claims — and
543/// read back on `initialize_mls` to reseed
544/// [`OfflineProtocol::encryption_capable_peers`](crate::OfflineProtocol).
545///
546/// The timestamp is diagnostic, not a policy input. Its predecessor
547/// (`TofuEntry.last_seen_ms`) drove LRU eviction of a bounded pin store; there
548/// is no eviction here, because there is no longer any per-peer *secret* whose
549/// size needs bounding — only the fact, which the restore walk bounds on its
550/// own terms. Keeping the field costs nothing and makes a stale store readable
551/// by a human.
552#[derive(Clone, Debug, Serialize, Deserialize)]
553pub(crate) struct EncryptionCapableEntry {
554    /// Milliseconds since epoch (UTC) when this peer last proved it runs MLS.
555    pub(crate) last_seen_ms: i64,
556}
557
558/// Payload for key package exchange.
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub(crate) struct KeyPackagePayload {
561    /// User ID of the key package owner.
562    pub(crate) user_id: String,
563    /// Raw key package data.
564    pub(crate) key_package_data: Vec<u8>,
565    /// Remaining valid lifetime in milliseconds (relative, not absolute).
566    /// Receiver applies this to their local clock, avoiding clock skew issues.
567    #[serde(default)]
568    pub(crate) remaining_lifetime_ms: u64,
569    /// Legacy absolute timestamp field — ignored on receive, kept for
570    /// backward compatibility with old nodes that may still send it.
571    #[serde(default)]
572    pub(crate) timestamp_ms: u64,
573    /// When `true`, the sender has reset their MLS session state and the
574    /// receiver should discard any existing session for this peer before
575    /// establishing a new one.
576    ///
577    /// Primary use-case: post-unblock session convergence. When Alice unblocks
578    /// Bob, Alice's side deletes her MLS session and sends a fresh key package
579    /// with `session_reset: true`. Bob deletes his now-orphaned session and
580    /// auto-establishes a new one from Alice's key package, so both sides
581    /// converge on a single fresh MLS group.
582    #[serde(default)]
583    pub(crate) session_reset: bool,
584
585    /// Wire-format versions the sender can decode (e.g. `[1]` for binary v1).
586    /// Absent on legacy nodes (`#[serde(default)]` → empty → JSON only), so an
587    /// old peer is never sent a binary frame it cannot parse.
588    ///
589    /// Trust boundary: this rides in the plaintext `KeyPackagePayload` envelope
590    /// *alongside* the signed MLS `key_package_data`, not *inside* the
591    /// signature, so it is not cryptographically bound to the sender. A MITM on
592    /// the pre-session bootstrap could strip it (harmless JSON downgrade) or
593    /// forge `[1]` onto a legacy peer (making us emit binary that peer drops —
594    /// a targeted delivery DoS). This grants no new capability: such an attacker
595    /// already controls key-package delivery and could deny service outright.
596    /// The negotiation is a performance optimization, never a security control.
597    #[serde(default)]
598    pub(crate) wire_versions: Vec<u8>,
599
600    /// MLS envelope formats the sender can parse (e.g. `[1]` for the compact
601    /// envelope, [`MLS_ENVELOPE_COMPACT_V1`]). Absent on legacy nodes
602    /// (`#[serde(default)]` → empty → legacy JSON envelope only), so an old
603    /// peer is never sent an envelope it cannot parse.
604    ///
605    /// Distinct from `wire_versions`: that one is hop-local (which *frames*
606    /// the peer decodes), this one is end-to-end (which `__MLS_ENC__` payload
607    /// encodings the *recipient* parses after any number of relay hops).
608    ///
609    /// Trust boundary: identical to `wire_versions` above — a plaintext
610    /// envelope field, not signature-bound. Stripping it downgrades to the
611    /// JSON envelope (harmless); forging it onto a legacy peer makes us emit
612    /// envelopes that peer rejects with a `message_decryption_failed`
613    /// event (a targeted delivery DoS an attacker in that position already
614    /// has). A performance optimization, never a security control.
615    #[serde(default)]
616    pub(crate) env_versions: Vec<u8>,
617
618    /// Sealed rich-payload versions the sender can parse (e.g. `[1]` for
619    /// [`RICH_PAYLOAD_V1`]). Absent on legacy nodes (`#[serde(default)]` →
620    /// empty → plain text only), so an old peer is never sent a
621    /// `__RICH_V1__` body it would surface as raw JSON text.
622    ///
623    /// End-to-end like `env_versions` (what the *recipient* parses inside
624    /// the decrypted MLS plaintext), not hop-local like `wire_versions`.
625    ///
626    /// Trust boundary: identical to the two fields above — a plaintext
627    /// envelope field, not signature-bound. Stripping it downgrades to plain
628    /// text with the rich extras dropped (harmless); forging it onto a
629    /// legacy peer makes us seal bodies that peer renders as JSON text (a
630    /// nuisance an attacker in that position could match by corrupting
631    /// delivery outright). A feature negotiation, never a security control.
632    #[serde(default)]
633    pub(crate) rich_versions: Vec<u8>,
634
635    /// This install's Nostr public key (x-only, 64-char lowercase hex), so a
636    /// peer can seal Nostr gift wraps to a key only this install holds.
637    ///
638    /// `None` on legacy nodes and on installs with Nostr disabled. A peer
639    /// without it seals to our *publicly computable* key instead — deliverable
640    /// either way, but readable by anyone who guesses our user id, so the
641    /// difference is real privacy rather than a mere optimization.
642    ///
643    /// Trust boundary — **unlike the three capability lists above, this one is
644    /// only honoured from a signed key package.** All four ride in the same
645    /// plaintext envelope, but this field is consumed as a *destination key*,
646    /// not as a feature hint, so the distinction matters: a wrong capability
647    /// costs a fallback, whereas a wrong key here means envelope metadata is
648    /// sealed *to whoever supplied it* and is then readable off a public relay,
649    /// passively, for as long as the value stands.
650    ///
651    /// `build_canonical_payload` covers the whole `__MLS_KEY_PKG__` body under
652    /// the sender's Ed25519 signature, which the gate now verifies against the
653    /// key their address derives from — so on this prefix an unsigned frame no
654    /// longer reaches dispatch at all.
655    /// `handle_key_package_message` still consumes this field only when the gate
656    /// reports the frame was actually signed, which costs nothing:
657    /// a key package exists only once MLS is initialized, and `send_key_package_to`
658    /// signs unconditionally in that state, so every genuine package carrying
659    /// this field is signed.
660    ///
661    /// Stripping it is still possible for a network attacker and downgrades us
662    /// to the bootstrap key — which is a privacy downgrade, not a disclosure to
663    /// the attacker, and one they could equally achieve by dropping the packet.
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub(crate) nostr_pubkey: Option<String>,
666}
667
668/// Compact MLS envelope version advertised in
669/// [`KeyPackagePayload::env_versions`]: the `__MLS_ENC__` payload is base64 of
670/// [`offline_protocol_mls::EncryptedMessage::to_bytes`] instead of the legacy
671/// JSON form (whose `ciphertext` field renders as a ~3.6x integer array).
672/// Receivers distinguish the two by the byte after the prefix — `{` opens the
673/// JSON envelope and never occurs in base64.
674pub(crate) const MLS_ENVELOPE_COMPACT_V1: u8 = 1;
675
676/// Sealed rich-payload version advertised in
677/// [`KeyPackagePayload::rich_versions`]: the decrypted MLS plaintext may be
678/// `__RICH_V1__` + JSON of [`RichPayloadV1`], carrying reply context, rich
679/// media metadata, and forward attribution inside the AEAD boundary instead
680/// of on the relay-visible outer message.
681pub(crate) const RICH_PAYLOAD_V1: u8 = 1;
682
683/// Upper bound on the serialized size of the rich extras accepted by the
684/// rich send surface. Enforced at the API boundary (`send_message_with`),
685/// deliberately NOT at seal time: a message queued behind session
686/// establishment re-makes the seal decision at flush, and a seal-time
687/// failure there would re-queue the message forever. Bounding at the
688/// boundary means every queued extras blob is already known to seal.
689pub(crate) const MAX_RICH_EXTRAS_BYTES: usize = 32 * 1024;
690
691/// Rich fields accepted by the `send_message_with` surface. Only ever
692/// delivered inside the sealed [`RichPayloadV1`] body — toward a recipient
693/// that did not advertise [`RICH_PAYLOAD_V1`] they are silently dropped,
694/// never sent cleartext.
695#[derive(Debug, Clone, Default, Serialize, Deserialize)]
696pub(crate) struct RichSendExtras {
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub(crate) reply_context: Option<ReplyContext>,
699    #[serde(default, skip_serializing_if = "Option::is_none")]
700    pub(crate) media_metadata: Option<MediaMetadata>,
701    #[serde(default, skip_serializing_if = "Option::is_none")]
702    pub(crate) forward_info: Option<ForwardInfo>,
703}
704
705impl RichSendExtras {
706    /// Whether any rich field is present (empty extras never seal).
707    pub(crate) fn is_any(&self) -> bool {
708        self.reply_context.is_some() || self.media_metadata.is_some() || self.forward_info.is_some()
709    }
710
711    /// Enforces [`MAX_RICH_EXTRAS_BYTES`] on the serialized extras. Shared
712    /// by every rich send boundary (`send_message_with`, the forward paths,
713    /// and the group surface) — enforced there, never at seal time, so a
714    /// queued or re-sent message is always known to seal (see the
715    /// constant's doc). Empty extras always pass.
716    pub(crate) fn check_size(&self) -> crate::Result<()> {
717        if !self.is_any() {
718            return Ok(());
719        }
720        let extras_len = serde_json::to_vec(self)
721            .map_err(|e| Error::Serialization(e.to_string()))?
722            .len();
723        if extras_len > MAX_RICH_EXTRAS_BYTES {
724            return Err(Error::InvalidArgument(format!(
725                "Rich extras too large: {} bytes serialized (max {})",
726                extras_len, MAX_RICH_EXTRAS_BYTES
727            )));
728        }
729        Ok(())
730    }
731}
732
733/// Options for `OfflineProtocol::send_message_with`: priority and reply
734/// threading (as on `send_message`), plus the rich fields introduced with
735/// the sealed rich payload.
736///
737/// The rich fields (`reply_context`, `media_metadata`, `forward_info`) only
738/// ever travel inside the MLS-sealed `__RICH_V1__` body, and only toward
739/// recipients whose key package advertised `rich_versions` support. Toward
740/// anyone else they are silently dropped — never sent cleartext — so the
741/// message degrades to plain text with `reply_to_msg` threading intact.
742#[derive(Debug, Clone, Default)]
743pub struct SendMessageOptions {
744    /// Message priority (defaults to Medium).
745    pub priority: Option<MessagePriority>,
746    /// ID of the message this is replying to.
747    pub reply_to_msg: Option<String>,
748    /// Content type stamped on the outer message (defaults to Text). A
749    /// coarse rendering hint — the actual content stays MLS-sealed. Toward
750    /// a recipient that advertised the sealed rich payload, a copy travels
751    /// inside the sealed body — whenever extras seal, or the hint itself is
752    /// non-Text — and the receiver treats that copy as authoritative, so a
753    /// relay cannot rewrite the hint. Must not be
754    /// [`ContentType::FileChunk`] (an internal transport content type; the
755    /// receiver would route the message into its file-transfer manager and
756    /// drop it) — rejected as `InvalidArgument`.
757    pub content_type: Option<ContentType>,
758    /// Quoted-reply context, delivered sealed-only.
759    pub reply_context: Option<ReplyContext>,
760    /// Rich media metadata (cloud attachments, stickers — including any
761    /// `encryption_key`/`iv` secrets), delivered sealed-only.
762    pub media_metadata: Option<MediaMetadata>,
763    /// Forward attribution, delivered sealed-only.
764    pub forward_info: Option<ForwardInfo>,
765    /// Send via this specific transport (bypassing DORS selection), like
766    /// `send_message_via_transport`.
767    pub via_transport: Option<TransportType>,
768}
769
770/// Options for `OfflineProtocol::send_media_with`: the chunk-0 media
771/// metadata (as on `send_media`), plus the rich fields introduced with the
772/// sealed rich payload and an optional caller-supplied `file_id`.
773///
774/// The rich fields (`caption`, `reply_to_msg`, `reply_context`,
775/// `forward_info`) only ever travel inside the MLS-sealed chunk-0 plaintext
776/// (v2 media envelope), and only toward recipients whose key package
777/// advertised `rich_versions` support. Toward anyone else — including every
778/// plaintext (encryption opt-out) transfer — they are silently dropped,
779/// never sent cleartext, so the transfer degrades to what plain
780/// `send_media` sends.
781#[derive(Debug, Clone, Default)]
782pub struct MediaSendOptions {
783    /// Media metadata delivered with chunk 0 (as on `send_media`).
784    pub media_metadata: Option<MediaMetadata>,
785    /// Caption text, delivered sealed-only.
786    pub caption: Option<String>,
787    /// ID of the message this media replies to, delivered sealed-only.
788    pub reply_to_msg: Option<String>,
789    /// Quoted-reply context, delivered sealed-only.
790    pub reply_context: Option<ReplyContext>,
791    /// Forward attribution, delivered sealed-only.
792    pub forward_info: Option<ForwardInfo>,
793    /// Caller-supplied file id for the transfer (minted when absent). Must
794    /// not collide with an active outbound transfer; bounded to the wire
795    /// `file_id` field limit.
796    pub file_id: Option<String>,
797}
798
799/// The sealed rich body: what `__RICH_V1__` + JSON carries inside the MLS
800/// plaintext. `text` is the user-visible content; the optional fields are
801/// restored onto the inbound message by `apply_decrypted_content` *after*
802/// its outer-field strip, making the sealed body the sole trusted carrier
803/// for rich data on encrypted messages.
804#[derive(Clone, Serialize, Deserialize)]
805pub(crate) struct RichPayloadV1 {
806    pub(crate) text: String,
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    pub(crate) reply_context: Option<ReplyContext>,
809    #[serde(default, skip_serializing_if = "Option::is_none")]
810    pub(crate) media_metadata: Option<MediaMetadata>,
811    #[serde(default, skip_serializing_if = "Option::is_none")]
812    pub(crate) forward_info: Option<ForwardInfo>,
813    /// Sealed copy of the outer `content_type` rendering hint — the last
814    /// rich-adjacent field a relay could rewrite in transit. Additive after
815    /// the body first shipped: absent from bodies sealed by older senders,
816    /// in which case the outer value stands. When present it is
817    /// authoritative on receive (except `FileChunk`, refused there like at
818    /// the send boundary), so a relay can no longer restamp the rendering
819    /// hint — or worse, restamp it `FileChunk` and get the decrypted
820    /// message routed into the file-transfer manager and dropped. Fresh
821    /// sends with a non-Text hint seal a hint-only body even without
822    /// extras. Forwards seal their attribution and media metadata as
823    /// extras toward capable recipients; only legacy queued forwards
824    /// (persisted by older builds, outer-only) skip the hint-only seal,
825    /// since a sealed body would wipe their outer copies on restore.
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub(crate) content_type: Option<ContentType>,
828}
829
830impl RichPayloadV1 {
831    /// Parses a sealed `__RICH_V1__` body out of decrypted MLS plaintext.
832    ///
833    /// Shared by the DM restore (`apply_decrypted_content`) and the group
834    /// message inbound paths. Never capability-gated (mirroring envelope
835    /// parsing): whatever a peer chose to seal, we try to read. Returns
836    /// `None` when the prefix is absent or the body fails to parse (logged;
837    /// callers surface the raw text rather than dropping an authenticated
838    /// message). A sealed `FileChunk` content-type claim is refused here —
839    /// mirroring the send boundary — so a hostile sender can never steer a
840    /// decrypted message into the file-transfer manager, which would drop
841    /// it.
842    pub(crate) fn parse_sealed(plaintext: &str, sender: &str) -> Option<Self> {
843        let sealed = plaintext.strip_prefix(super::internal_prefixes::RICH_V1)?;
844        match serde_json::from_str::<Self>(sealed) {
845            Ok(mut rich) => {
846                if rich.content_type == Some(ContentType::FileChunk) {
847                    warn!(
848                        sender = %sender,
849                        "Sealed rich payload claims the internal FileChunk content type, ignoring the hint"
850                    );
851                    rich.content_type = None;
852                }
853                Some(rich)
854            }
855            Err(e) => {
856                warn!(
857                    sender = %sender,
858                    error = %e,
859                    "Failed to parse sealed rich payload, surfacing raw text"
860                );
861                None
862            }
863        }
864    }
865}
866
867/// An outbound connection request awaiting a transport outcome (see
868/// `OfflineProtocol::pending_connection_requests`).
869#[derive(Debug, Clone)]
870pub(crate) struct PendingConnectionRequest {
871    /// Recipient the request was addressed to.
872    pub(crate) recipient: String,
873    /// When the request was sent — entries older than
874    /// [`PENDING_CONNECTION_REQUEST_TTL`] are pruned on insert.
875    pub(crate) sent_at: std::time::Instant,
876}
877
878/// How long an outbound connection request stays correlatable to a
879/// transport failure. Past this window the entry is pruned: a DeliveryError
880/// that stale belongs to a request the app has long stopped showing a
881/// spinner for.
882///
883/// Deliberately wider than the bridges' 60s `RecipientInFlightTracker` TTL:
884/// that window anchors at the socket write, while this one anchors at
885/// `send_connection_request` — a request can dwell in the internet outbox
886/// (device offline, relay reconnecting) before its wire attempt, and this
887/// window must cover that dwell plus the bridge's correlation window.
888///
889/// Also deliberately wider than the worst-case default ACK retry schedule
890/// (10 retries, 1s initial delay, x2 backoff capped at 5 min, plus a 10s
891/// ACK timeout per attempt — up to ~910s end to end): retry exhaustion is
892/// the last settlement point that can still emit a typed undeliverable
893/// event, so the window must outlive it with headroom.
894pub(crate) const PENDING_CONNECTION_REQUEST_TTL: std::time::Duration =
895    std::time::Duration::from_secs(1800);
896
897/// Cap on tracked outbound connection requests (oldest evicted first).
898pub(crate) const MAX_PENDING_CONNECTION_REQUESTS: usize = 64;
899
900/// Upper bound (UTF-8 bytes) for `initial_message` on a connection request.
901/// The request is a plaintext High-priority control frame; an unbounded
902/// first message would fragment heavily over BLE and can exceed relay frame
903/// limits after the SDK already returned a message id, so oversized input
904/// fails loudly at the API instead.
905pub(crate) const MAX_INITIAL_MESSAGE_BYTES: usize = 4096;
906
907/// Payload for a connection request message.
908#[derive(Debug, Clone, Serialize, Deserialize)]
909pub(crate) struct ConnectionRequestPayload {
910    /// Display name of the sender.
911    pub(crate) sender_name: String,
912    /// Timestamp of the request (Unix ms).
913    pub(crate) timestamp_ms: i64,
914    /// Optional MLS key package data for encrypted session setup.
915    #[serde(skip_serializing_if = "Option::is_none")]
916    pub(crate) key_package: Option<Vec<u8>>,
917    /// Optional first message sent along with the request (`default` keeps
918    /// payloads from pre-initial-message senders parseable).
919    #[serde(default, skip_serializing_if = "Option::is_none")]
920    pub(crate) initial_message: Option<String>,
921}
922
923/// Payload for a connection accepted message.
924#[derive(Debug, Clone, Serialize, Deserialize)]
925pub(crate) struct ConnectionAcceptedPayload {
926    /// Display name of the accepting party.
927    pub(crate) accepted_by_name: String,
928    /// Timestamp of the acceptance (Unix ms).
929    #[serde(default)]
930    pub(crate) timestamp_ms: i64,
931    /// Optional MLS key package data for encrypted session setup.
932    #[serde(skip_serializing_if = "Option::is_none")]
933    pub(crate) key_package: Option<Vec<u8>>,
934}
935
936// --- Presence, typing, and read receipt payloads ---
937
938/// Maximum number of message IDs allowed in a single read receipt.
939pub(crate) const MAX_READ_RECEIPT_IDS: usize = 256;
940
941/// Payload for a presence update message.
942#[derive(Debug, Clone, Serialize, Deserialize)]
943pub(crate) struct PresencePayload {
944    /// Presence status.
945    pub(crate) status: PresenceStatus,
946    /// Timestamp of the update (Unix ms).
947    pub(crate) timestamp_ms: i64,
948}
949
950/// Payload for a typing indicator message.
951#[derive(Debug, Clone, Serialize, Deserialize)]
952pub(crate) struct TypingIndicatorPayload {
953    /// Opaque conversation identifier chosen by the sender (conventionally a
954    /// peer address for DMs, the group id for groups). Never parsed here.
955    pub(crate) conversation_id: String,
956    /// Whether the user is currently typing.
957    pub(crate) is_typing: bool,
958    /// Timestamp of the indicator (Unix ms).
959    pub(crate) timestamp_ms: i64,
960}
961
962/// Payload for a read receipt message.
963#[derive(Debug, Clone, Serialize, Deserialize)]
964pub(crate) struct ReadReceiptPayload {
965    /// IDs of the messages that were read.
966    pub(crate) message_ids: Vec<String>,
967    /// Timestamp when the messages were read (Unix ms).
968    pub(crate) timestamp_ms: i64,
969}
970
971// --- Group (relay) payloads ---
972
973#[derive(Debug, Clone, Serialize, Deserialize)]
974pub(crate) struct GroupCreatedPayload {
975    pub(crate) group_id: String,
976    pub(crate) name: String,
977}
978
979#[derive(Debug, Clone, Serialize, Deserialize)]
980pub(crate) struct GroupMessageReceivedPayload {
981    pub(crate) group_id: String,
982    pub(crate) sender: String,
983    pub(crate) content: String,
984    pub(crate) timestamp: String,
985    pub(crate) message_id: String,
986    #[serde(default, skip_serializing_if = "Option::is_none")]
987    pub(crate) reply_to_msg: Option<String>,
988    /// Forwarding attribution (present when the group message was forwarded).
989    #[serde(default, skip_serializing_if = "Option::is_none")]
990    pub(crate) forward_info: Option<offline_protocol_core::ForwardInfo>,
991}
992
993#[derive(Debug, Clone, Serialize, Deserialize)]
994pub(crate) struct GroupMemberAddedPayload {
995    pub(crate) group_id: String,
996    pub(crate) user_id: String,
997    pub(crate) added_by: String,
998    #[serde(skip_serializing_if = "Option::is_none")]
999    pub(crate) group_name: Option<String>,
1000}
1001
1002#[derive(Debug, Clone, Serialize, Deserialize)]
1003pub(crate) struct GroupMemberRemovedPayload {
1004    pub(crate) group_id: String,
1005    pub(crate) user_id: String,
1006    pub(crate) removed_by: String,
1007}
1008
1009#[derive(Debug, Clone, Serialize, Deserialize)]
1010pub(crate) struct GroupInfoMemberPayload {
1011    pub(crate) user_id: String,
1012    pub(crate) role: String,
1013    pub(crate) joined_at: String,
1014}
1015
1016#[derive(Debug, Clone, Serialize, Deserialize)]
1017pub(crate) struct GroupInfoPayload {
1018    pub(crate) group_id: String,
1019    pub(crate) name: String,
1020    pub(crate) created_by: String,
1021    pub(crate) created_at: String,
1022    pub(crate) members: Vec<GroupInfoMemberPayload>,
1023}
1024
1025#[derive(Debug, Clone, Serialize, Deserialize)]
1026pub(crate) struct UserGroupSummaryPayload {
1027    pub(crate) group_id: String,
1028    pub(crate) name: String,
1029    pub(crate) created_at: String,
1030}
1031
1032#[derive(Debug, Clone, Serialize, Deserialize)]
1033pub(crate) struct UserGroupsPayload {
1034    pub(crate) groups: Vec<UserGroupSummaryPayload>,
1035}
1036
1037#[derive(Debug, Clone, Serialize, Deserialize)]
1038pub(crate) struct GroupErrorPayload {
1039    /// The relay's own wording. **Never emitted on an event** — see
1040    /// [`GroupErrorPayload::classify_reason`]. Treat every read of this
1041    /// field as a read of untrusted, unbounded wire input.
1042    pub(crate) reason: String,
1043    /// Group the error concerns, when the relay scoped it (e.g. a
1044    /// registration sync denial). Used to drop the group from
1045    /// `relay_synced` so sends fall back to per-member delivery.
1046    #[serde(default, skip_serializing_if = "Option::is_none")]
1047    pub(crate) group_id: Option<String>,
1048}
1049
1050impl GroupErrorPayload {
1051    /// Maps the relay's free-text `reason` onto a fixed local vocabulary.
1052    ///
1053    /// `__GROUP_ERROR__` is a relay answer: it rides
1054    /// `RELAY_ANSWER_PREFIXES`, so on the relay ingest shape (Internet
1055    /// arrival, no transport peer identity) it is accepted **unsigned** —
1056    /// no key material, no session, no prior contact, just a frame on that
1057    /// socket. Signed, it is reachable to any peer over any transport, since
1058    /// the handler is deliberately not gated on the Internet path the way
1059    /// the `GROUP_CREATED` ack is. Either way `reason` is whatever the
1060    /// sender wrote: arbitrary content, arbitrary length, not ours.
1061    ///
1062    /// So the text does not travel any further. Nothing downstream needs it
1063    /// — the sync revocation keys off `group_id`, and the platform bridges
1064    /// already dual-emit the raw relay frame on the server-message channel
1065    /// for apps that want the relay's exact wording. What an event carries
1066    /// is this classification instead, which an attacker can at most steer
1067    /// between three harmless codes.
1068    ///
1069    /// Even an *honest* relay makes this necessary: its `Not a member of
1070    /// group {id}` renders an identifier into prose, smuggling past a
1071    /// telemetry scrubber that hashes `group_id` fields but ships free text
1072    /// verbatim by design.
1073    ///
1074    /// The `&'static str` return is load-bearing, the same way
1075    /// `MlsError::privacy_safe_reason` is: it makes interpolating wire input
1076    /// unrepresentable rather than merely discouraged. Matching is exact and
1077    /// the fallback is closed, so a relay rewording an error degrades to
1078    /// `error` — never back to shipping its text.
1079    pub(crate) fn classify_reason(&self) -> &'static str {
1080        match self.reason.as_str() {
1081            // Relay has no such group: invite links and relay fan-out for it
1082            // are dead, not merely refused.
1083            "Group not found" => "not_found",
1084            // Relay refused to register/sync the group for this caller.
1085            "Only admins can sync this group" => "sync_denied",
1086            // Everything else, including anything a forged frame chose.
1087            _ => "error",
1088        }
1089    }
1090}
1091
1092/// A received key package awaiting use for session creation.
1093#[derive(Debug, Clone, Serialize, Deserialize)]
1094pub(crate) struct ReceivedKeyPackage {
1095    /// Raw MLS key package bytes.
1096    pub(crate) key_package_data: Vec<u8>,
1097    /// Local wall-clock deadline (ms since epoch) computed from the sender's
1098    /// `remaining_lifetime_ms`, anchored to *our* clock at receive time.
1099    pub(crate) local_expires_at_ms: u64,
1100}
1101
1102/// Durable record of the end-to-end capability versions a peer last
1103/// advertised in its key package (`env_versions` / `rich_versions`).
1104///
1105/// Persisted separately from [`ReceivedKeyPackage`] because the cached key
1106/// package is deleted once a session is established, while the capabilities
1107/// must survive restarts for exactly those peers: mobile apps restart
1108/// constantly and MLS sessions persist, so without this record a rich send
1109/// right after relaunch silently degrades to bare text (and the compact
1110/// envelope to JSON) until the next live key-package exchange.
1111///
1112/// Stores the raw advertised versions, not the config-gated subset: the kill
1113/// switches (`compact_envelope_enabled` / `rich_payload_enabled`) gate use —
1114/// live recording, restore, and send — not knowledge, so toggling one across
1115/// restarts behaves the same as toggling it live.
1116///
1117/// `wire_versions` is deliberately absent: it is hop-local (which frames a
1118/// directly-connected peer decodes), and connection setup re-exchanges key
1119/// packages on discovery anyway, so persisting it would buy nothing.
1120#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1121pub(crate) struct PeerCapabilities {
1122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1123    pub(crate) env_versions: Vec<u8>,
1124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1125    pub(crate) rich_versions: Vec<u8>,
1126    /// Rich-payload versions a *group inviter* attested for this peer
1127    /// (carried on the group Add commit / Welcome), as opposed to the
1128    /// direct self-advertised `rich_versions` above. Kept separate because
1129    /// the trust differs: attestation is third-party and may be stale, so a
1130    /// direct key-package exchange overwrites the whole record (clearing
1131    /// this field) — direct knowledge is always authoritative. Consulted
1132    /// only by the group seal gate, never by DM sealing or envelope
1133    /// selection.
1134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1135    pub(crate) attested_rich_versions: Vec<u8>,
1136
1137    /// The peer's Nostr public key, from [`KeyPackagePayload::nostr_pubkey`].
1138    ///
1139    /// Persisted for the same reason as the capability lists: the cached key
1140    /// package is deleted once a session is established, and a peer met before
1141    /// a restart would otherwise silently fall back to bootstrap-key sealing —
1142    /// a privacy regression with no symptom — until the next live exchange.
1143    #[serde(default, skip_serializing_if = "Option::is_none")]
1144    pub(crate) nostr_pubkey: Option<String>,
1145}
1146
1147/// Length of an x-only secp256k1 public key in lowercase hex.
1148pub(crate) const NOSTR_PUBKEY_HEX_LEN: usize = 64;
1149
1150/// Validates a wire-supplied Nostr public key before it is persisted or used
1151/// as a sealing destination.
1152///
1153/// Shape only — the value is signature-bound to the sender, so this is not an
1154/// authenticity check. It exists so a malformed or oversized string cannot
1155/// enter a durable record, and it normalizes case so the same key never
1156/// persists under two spellings.
1157pub(crate) fn normalize_nostr_pubkey(candidate: &str) -> Option<String> {
1158    if candidate.len() != NOSTR_PUBKEY_HEX_LEN || !candidate.bytes().all(|b| b.is_ascii_hexdigit())
1159    {
1160        return None;
1161    }
1162    Some(candidate.to_ascii_lowercase())
1163}
1164
1165/// Cap on how many advertised version entries persist per capability list
1166/// in a [`PeerCapabilities`] record. The lists are unauthenticated wire
1167/// input stored raw (raw so unknown future versions survive a local
1168/// upgrade), and a hostile advertiser could otherwise bloat each durable
1169/// record up to the transport message size. Truncation only hurts the
1170/// advertiser — real senders list a handful of versions.
1171pub(crate) const MAX_PERSISTED_CAPABILITY_VERSIONS: usize = 8;
1172
1173impl PeerCapabilities {
1174    /// Builds a record from the wire-advertised version lists, truncating
1175    /// each to [`MAX_PERSISTED_CAPABILITY_VERSIONS`].
1176    pub(crate) fn from_advertised(
1177        env_versions: &[u8],
1178        rich_versions: &[u8],
1179        nostr_pubkey: Option<&str>,
1180    ) -> Self {
1181        Self {
1182            env_versions: env_versions
1183                .iter()
1184                .copied()
1185                .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
1186                .collect(),
1187            rich_versions: rich_versions
1188                .iter()
1189                .copied()
1190                .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
1191                .collect(),
1192            attested_rich_versions: Vec::new(),
1193            nostr_pubkey: nostr_pubkey.and_then(normalize_nostr_pubkey),
1194        }
1195    }
1196
1197    /// Whether any capability is advertised. Empty records are deleted
1198    /// rather than stored — the durable side of the downgrade semantics.
1199    pub(crate) fn is_any(&self) -> bool {
1200        !self.env_versions.is_empty()
1201            || !self.rich_versions.is_empty()
1202            || !self.attested_rich_versions.is_empty()
1203            || self.nostr_pubkey.is_some()
1204    }
1205}
1206
1207/// Outcome of [`OfflineProtocol::security_gate_control_message`].
1208///
1209/// [`OfflineProtocol`]: crate::OfflineProtocol
1210/// [`OfflineProtocol::security_gate_control_message`]: crate::OfflineProtocol
1211#[derive(Debug)]
1212pub(crate) enum ControlGateOutcome {
1213    /// The message may proceed to dispatch.
1214    ///
1215    /// `signed` is `true` **only** when an Ed25519 signature was present,
1216    /// verified, and produced by the key the sender's address derives from. It
1217    /// is deliberately `false` for both of the other ways a message reaches
1218    /// dispatch — a relay-originated answer, which no peer signs
1219    /// (`RELAY_ANSWER_PREFIXES`), and a prefix the gate does not cover at all —
1220    /// so a handler that keys on it fails closed without having to know which
1221    /// case it is in.
1222    ///
1223    /// Since unsigned control traffic is now refused outright, `false` no
1224    /// longer reaches any *gated* handler; the bit survives because those two
1225    /// ungated paths still produce it, and because a handler that treats a
1226    /// payload field as authenticated should say so at the point it does.
1227    Proceed { signed: bool },
1228    /// The gate rejected the message; the caller must return this result
1229    /// without dispatching.
1230    Rejected(InternalMessageResult),
1231}
1232
1233/// Result of processing an internal protocol message.
1234#[derive(Debug)]
1235pub(crate) enum InternalMessageResult {
1236    /// Message was consumed internally (don't surface to app).
1237    Consumed,
1238    /// Message was rejected by the security gate (spoofed sender, bad
1239    /// signature, unsigned control traffic, or a signing key that does not
1240    /// derive to the claimed sender address). Like `Consumed`, the message is not
1241    /// surfaced to the app — but unlike `Consumed`, a delivery ACK must NOT
1242    /// be sent back, to avoid confirming to the attacker that the target is
1243    /// online and processing messages.
1244    SecurityRejected,
1245    /// Message was not delivered, but the sender can still recover it by
1246    /// resending. Unlike `Consumed`, a delivery ACK must NOT be sent and the id
1247    /// must NOT stay dedup-marked: the message is provably not delivered, so
1248    /// the receiver must leave the sender's retry lever intact. The receive
1249    /// loop responds by unmarking the id (so a resend re-enters processing
1250    /// instead of hitting the duplicate re-ACK path) and skipping the ACK.
1251    ///
1252    /// Four conditions produce it, differing in whether the *frame* is worth
1253    /// keeping:
1254    ///
1255    /// - **Session not ready**: the MLS session/group is not established yet,
1256    ///   so the frame is queued for delayed decryption
1257    ///   (`enqueue_pending_decryption`). The queued copy is surfaced — and the
1258    ///   id re-marked — once the session confirms and the queue drains
1259    ///   (`process_pending_decryption`), which also sends the deferred delivery
1260    ///   ACK directly on the recorded arrival transport (so a sender that gave
1261    ///   up before the session confirmed still learns of delivery).
1262    /// - **Epoch desync**: the frame is sealed to a dead epoch, so it is *not*
1263    ///   queued (it could never drain) and a rate-limited re-key is triggered.
1264    /// - **Crypto/transport failure** with `crypto_recovery_enabled`: OpenMLS
1265    ///   consumed the ratchet generation on the failed attempt, so the frame is
1266    ///   likewise not queued — and no re-key is triggered, which stays
1267    ///   desync-only.
1268    /// - **Envelope parse failure** with `crypto_recovery_enabled`: the
1269    ///   `__MLS_ENC__` payload did not parse in any envelope form, so there is
1270    ///   no ciphertext to decrypt. Not queued either — an unparseable frame can
1271    ///   never become parseable.
1272    ///
1273    /// In the latter three, recovery is the sender's *resend* rather than this
1274    /// frame: Tier 2 re-seals each resend of an encrypted DM against a live
1275    /// generation, and a message that stays undeliverable settles as an honest
1276    /// `MessageFailed` instead of a lying "delivered". See the deferred-ACK and
1277    /// crypto-failure-recovery designs in CLAUDE.md.
1278    Deferred,
1279    /// Message was decrypted, here's the plaintext.
1280    Decrypted(String),
1281}
1282
1283/// Outcome of routing an inbound file-chunk message through
1284/// [`OfflineProtocol::handle_incoming_file_chunk`]. Distinguishes chunks that
1285/// were dealt with terminally (decrypted/assembled, or dropped for a permanent
1286/// reason) from chunks queued for delayed decryption — so the receive loop can
1287/// defer the ACK for the latter, exactly like the text `Deferred` path.
1288///
1289/// [`OfflineProtocol::handle_incoming_file_chunk`]: crate::OfflineProtocol
1290///
1291/// `#[must_use]`: the ACK/defer decision hinges on this outcome. Dropping it on
1292/// the floor silently reverts to the pre-deferred-ACK behavior (always ACK,
1293/// leave dedup-marked), reintroducing the queue-path silent-loss bug — so every
1294/// caller must branch on it.
1295#[must_use]
1296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1297pub(crate) enum ChunkOutcome {
1298    /// The chunk was decrypted/assembled or dropped for a terminal reason
1299    /// (parse failure, resource limit, permanent refusal). The sender should
1300    /// stop retrying either way, so the caller ACKs as before.
1301    Handled,
1302    /// The chunk was not delivered but the sender can still recover it by
1303    /// resending: it could not be decrypted yet (session not ready, so queued
1304    /// for delayed decryption), or it is undecryptable as it stands (epoch
1305    /// desync, or a crypto failure while `crypto_recovery_enabled`), or its
1306    /// media envelope did not decode at all (also `crypto_recovery_enabled`) —
1307    /// the latter shapes dropped without queueing. The caller must NOT ACK and
1308    /// must unmark the id, so the sender keeps retrying and the resend re-enters
1309    /// processing. For an undecryptable or undecodable chunk that recovery is
1310    /// the media outbox's `MediaResendRequired` path — chunks are re-encoded,
1311    /// never re-sealed.
1312    Deferred,
1313    /// The chunk was refused as illegitimate rather than undeliverable. Two
1314    /// shapes: an unencrypted chunk refused by the encryption policy, and an
1315    /// encrypted chunk that failed its identity binding — the envelope named
1316    /// another pair's session slot, or the MLS credential authenticated a
1317    /// different sender than the wire envelope claims.
1318    ///
1319    /// Like [`InternalMessageResult::SecurityRejected`] for text, the caller
1320    /// must NOT ACK (don't confirm to an injector that the target processes
1321    /// their messages) and must unmark the id (so a replay re-enters this gate
1322    /// instead of the duplicate re-ACK path), matching the plaintext-text
1323    /// rejection in the receive loop.
1324    Rejected,
1325}
1326
1327/// Pending message waiting for session establishment.
1328#[derive(Clone, Serialize, Deserialize)]
1329pub(crate) struct PendingMessage {
1330    /// Original plaintext content.
1331    pub(crate) content: String,
1332    /// Message priority.
1333    pub(crate) priority: MessagePriority,
1334    /// Message ID (preserved from initial creation).
1335    pub(crate) message_id: MessageId,
1336    /// Reply-to message ID if applicable.
1337    pub(crate) reply_to_msg: Option<MessageId>,
1338    /// Forwarding attribution (preserved so it survives the pending queue).
1339    #[serde(default, skip_serializing_if = "Option::is_none")]
1340    pub(crate) forwarded_from: Option<ForwardInfo>,
1341    /// Content type of the original message (preserved for forwarded non-text messages).
1342    #[serde(default)]
1343    pub(crate) content_type: ContentType,
1344    /// Media metadata (preserved for forwarded media messages).
1345    #[serde(default, skip_serializing_if = "Option::is_none")]
1346    pub(crate) media_metadata: Option<MediaMetadata>,
1347    /// Option-borne rich extras from the rich send surface. Kept separate
1348    /// from the legacy `forwarded_from`/`media_metadata` fields above: those
1349    /// flush as outer cleartext (shipped forward behavior), while these must
1350    /// flush inside the sealed rich body or be dropped — never cleartext.
1351    #[serde(default, skip_serializing_if = "Option::is_none")]
1352    pub(crate) rich: Option<RichSendExtras>,
1353    /// When the message *first* entered the pending queue.
1354    ///
1355    /// Drives `pending_message_max_lifetime_ms`, and is deliberately preserved
1356    /// across a re-queue (see [`PendingProvenance`]): a flush that finds the
1357    /// session still unavailable puts the entry back, and stamping a fresh
1358    /// timestamp there would let repeated reconciliation renew an "absolute"
1359    /// lifetime forever.
1360    pub(crate) queued_at: DateTime<Utc>,
1361    /// Serialized footprint of this entry, for the pending-queue byte budgets
1362    /// ([`MAX_PENDING_MESSAGE_BYTES_PER_PEER`] /
1363    /// [`MAX_PENDING_MESSAGE_BYTES_GLOBAL`]).
1364    ///
1365    /// Derived, so it is not persisted; it is (re)computed by
1366    /// [`PendingMessage::measure`] at the two points an entry comes into
1367    /// existence — admission and load-from-storage — and then simply travels
1368    /// with the entry as it is moved between the queue, a flush, and a
1369    /// re-queue.
1370    #[serde(skip)]
1371    pub(crate) serialized_bytes: usize,
1372}
1373
1374/// One persisted pending-queue record: a single queued message plus the peer
1375/// it is queued for.
1376///
1377/// The recipient rides *inside* the record because the key is the message id,
1378/// and the in-memory queue is a map keyed by recipient that has to be rebuilt
1379/// from records read in whatever order the store enumerates them. Same shape as
1380/// [`OutboxEntry`], whose recipient likewise travels in the record (inside its
1381/// `Message`) rather than in the key.
1382///
1383/// Deliberately a wrapper rather than a `recipient` field on [`PendingMessage`]:
1384/// in memory the recipient is already the map key, and a second copy there
1385/// would be free to drift from it.
1386#[derive(Serialize, Deserialize)]
1387pub(crate) struct PendingMessageRecord {
1388    pub(crate) recipient: String,
1389    pub(crate) message: PendingMessage,
1390}
1391
1392impl PendingMessage {
1393    /// Recomputes [`Self::serialized_bytes`] from the current field values.
1394    ///
1395    /// Measures what persistence actually costs (the serialized entry) rather
1396    /// than estimating from `content.len()`, so rich extras and forward
1397    /// attribution are counted. A serialization failure — which would also make
1398    /// the entry unpersistable — falls back to the content length so the entry
1399    /// still consumes budget rather than reading as free.
1400    pub(crate) fn measure(&mut self) {
1401        self.serialized_bytes = serde_json::to_vec(self)
1402            .map(|encoded| encoded.len())
1403            .unwrap_or_else(|_| self.content.len());
1404    }
1405}
1406
1407/// Identity carried by an outbound message that is re-entering the
1408/// pending-session queue rather than being queued for the first time.
1409///
1410/// Both fields exist to keep a re-queue from looking like a fresh send: the id
1411/// so `MessageSent`/`MessageDelivered`/`MessageFailed` stay correlatable with
1412/// what `send_message*` returned, and the timestamp so the absolute pending
1413/// lifetime is measured from first entry.
1414#[derive(Debug, Clone)]
1415pub(crate) struct PendingProvenance {
1416    /// The id the caller already holds.
1417    pub(crate) message_id: MessageId,
1418    /// When the message first entered the pending queue, or `None` when the
1419    /// re-queue does not come *from* that queue — the resend re-seal path
1420    /// passes an outbox id, and an entry it (all but unreachably) enqueues is
1421    /// starting its pending lifetime now.
1422    pub(crate) first_queued_at: Option<DateTime<Utc>>,
1423}
1424
1425impl PendingProvenance {
1426    /// Provenance for a message being put back into the pending queue.
1427    pub(crate) fn requeued(message: &PendingMessage) -> Self {
1428        Self {
1429            message_id: message.message_id.clone(),
1430            first_queued_at: Some(message.queued_at),
1431        }
1432    }
1433
1434    /// Provenance for a known id with no pending-queue history.
1435    pub(crate) fn for_id(message_id: MessageId) -> Self {
1436        Self {
1437            message_id,
1438            first_queued_at: None,
1439        }
1440    }
1441}
1442
1443/// Durable state for a peer MLS session.
1444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1445pub(crate) enum SessionState {
1446    Pending,
1447    Confirmed,
1448}
1449
1450impl SessionState {
1451    pub(crate) fn as_str(&self) -> &'static str {
1452        match self {
1453            Self::Pending => "Pending",
1454            Self::Confirmed => "Confirmed",
1455        }
1456    }
1457}
1458
1459/// Durable lifecycle states for outbound Welcome delivery.
1460#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1461pub(crate) enum WelcomeDeliveryState {
1462    Created,
1463    SendAttempted,
1464    Sent,
1465    Failed,
1466    Expired,
1467}
1468
1469impl WelcomeDeliveryState {
1470    pub(crate) fn as_str(&self) -> &'static str {
1471        match self {
1472            Self::Created => "Created",
1473            Self::SendAttempted => "SendAttempted",
1474            Self::Sent => "Sent",
1475            Self::Failed => "Failed",
1476            Self::Expired => "Expired",
1477        }
1478    }
1479}
1480
1481/// Per-peer throttle for presence-driven welcome rescue (see
1482/// `OfflineProtocol::on_peer_presence`). Deliberately in-memory only: a
1483/// restart resets the backoff, and the one free rescue that buys is useful
1484/// after a restart anyway.
1485#[derive(Debug, Clone)]
1486pub(crate) struct PresenceRescueThrottle {
1487    pub(crate) next_allowed_at: DateTime<Utc>,
1488    /// Consecutive rescues without the session confirming; drives the
1489    /// exponential backoff exponent.
1490    pub(crate) rescues: u32,
1491}
1492
1493/// Durable metadata for outbound Welcome reliability handling.
1494#[derive(Debug, Clone, Serialize, Deserialize)]
1495pub(crate) struct WelcomeLifecycleRecord {
1496    pub(crate) peer_id: String,
1497    pub(crate) group_id: String,
1498    pub(crate) state: WelcomeDeliveryState,
1499    pub(crate) attempt: u32,
1500    /// Consecutive `PeerUnreachable` parks (relay `DeliveryError` verdicts)
1501    /// since the last reachability edge; drives the escalating retry
1502    /// interval capped at [`WELCOME_UNREACHABLE_RETRY_CAP_SECS`]. Reset on
1503    /// re-arm (presence online / neighbor discovered). Defaulted for
1504    /// records persisted before the field existed.
1505    #[serde(default)]
1506    pub(crate) unreachable_parks: u32,
1507    pub(crate) welcome_message: Message,
1508    pub(crate) next_retry_at: Option<DateTime<Utc>>,
1509    pub(crate) last_reason_code: Option<crate::events::WelcomeReasonCode>,
1510    pub(crate) last_transport_error: Option<String>,
1511    pub(crate) created_at: DateTime<Utc>,
1512    pub(crate) expires_at: DateTime<Utc>,
1513}
1514
1515/// Storage key types for message persistence.
1516pub(crate) mod storage_keys {
1517    /// Legacy key type for pending encrypted messages, keyed by *recipient*,
1518    /// each record holding that peer's whole queue.
1519    ///
1520    /// Superseded by [`PENDING_MESSAGE_ENTRIES`]. Nothing writes this category
1521    /// any more: `restore_pending_messages` reads it, migrates each queue into
1522    /// the per-message layout, and deletes the record. It is read-only
1523    /// migration scaffolding, and retires on the same trigger as the
1524    /// [`ADOPTABLE_STATE_KEY_TYPES`] sweep — an install that skips the
1525    /// migrating release entirely still needs it.
1526    pub const PENDING_MESSAGES: &str = "pending_messages";
1527    /// Key type for pending encrypted messages, keyed by message id — one
1528    /// record per queued message, like [`OUTBOX`].
1529    ///
1530    /// Keying by id rather than by recipient is what makes a lost record
1531    /// *individually settleable*: the id the application is holding is the key,
1532    /// so a record that will not open still names the message it destroyed. The
1533    /// per-recipient layout could only report the loss per peer, because every
1534    /// id was inside the record that would not open.
1535    pub const PENDING_MESSAGE_ENTRIES: &str = "pending_message_entries";
1536    /// Key type for persisted per-peer MLS session confirmation state.
1537    pub const SESSION_STATES: &str = "session_states";
1538    /// Key type for persisted per-peer received key packages (survives restart).
1539    pub const PEER_KEY_PACKAGES: &str = "peer_key_packages";
1540    /// Key type for persisted per-peer advertised capability versions
1541    /// (env/rich), which must outlive the key package entry above — that one
1542    /// is deleted once a session is established (see
1543    /// [`super::PeerCapabilities`]).
1544    pub const PEER_CAPABILITIES: &str = "peer_capabilities";
1545    /// Key type for persisted per-peer outbound welcome lifecycle state.
1546    pub const WELCOME_LIFECYCLES: &str = "welcome_lifecycles";
1547    /// Key type for persisted store-and-forward outbox entries, keyed by
1548    /// message id. Holds only main-outbox (non-media) entries so undelivered
1549    /// messages survive app restarts; the media (file-chunk) outbox is
1550    /// intentionally excluded because file transfers are not persisted and
1551    /// must be re-initiated by the app after a restart.
1552    pub const OUTBOX: &str = "outbox";
1553    /// Key type for persisted outbound media transfer descriptors, keyed by
1554    /// file id. Descriptor-only (never chunk bytes): a descriptor surviving
1555    /// into a restore marks a transfer the app must re-initiate, surfaced
1556    /// via `MediaResendRequired`.
1557    pub const MEDIA_DESCRIPTORS: &str = "media_descriptors";
1558    /// Key type for the Lamport clock value.
1559    pub const LAMPORT_CLOCK: &str = "lamport_clock";
1560    /// Key ID for the single Lamport clock entry.
1561    pub const LAMPORT_CLOCK_ID: &str = "current";
1562    /// Key type for the durable record that a peer has proved it runs MLS.
1563    ///
1564    /// Successor to the `tofu_keys` category, which stored a pinned public key
1565    /// per peer. The pin is gone — an address *is* its key's hash, so nothing
1566    /// needs storing to check one — but the store had a second job the
1567    /// derivation does not do: it was the half of
1568    /// `OfflineProtocol::encryption_capable_peers` that survived a restart, and
1569    /// therefore what keeps the plaintext-downgrade gate shut for a peer whose
1570    /// session was torn down. That job is all this category still does, so the
1571    /// value is a timestamp and nothing else.
1572    ///
1573    /// A leftover `tofu_keys` record is left where it is, and no migration is
1574    /// written for it. Records from before the identity change are keyed by a
1575    /// username, so no live peer id can ever read them back — they are simply
1576    /// unreachable. Records written in the window *between* the identity
1577    /// change and the pin store's deletion are keyed by an address and would
1578    /// still resolve, so a peer marked capable there with no live session
1579    /// loses that durable mark across the upgrade: it falls back to the
1580    /// `is_session_confirmed` check until the next key-package exchange
1581    /// re-marks it. That is the fail-closed direction and it costs one
1582    /// exchange, which is why migrating them is not worth the code while no
1583    /// fleet is deployed.
1584    pub const ENCRYPTION_CAPABLE_PEERS: &str = "encryption_capable_peers";
1585    /// Key type for persisted blocked user entries.
1586    pub const BLOCKED_USERS: &str = "blocked_users";
1587    /// Key type for the persistent per-install telemetry scrub secret.
1588    pub const SCRUB_SECRET: &str = "scrub_secret";
1589    /// Key ID for the single scrub-secret entry.
1590    pub const SCRUB_SECRET_ID: &str = "current";
1591    /// Key type for the persistent per-install Nostr transport signing secret.
1592    pub const NOSTR_SIGNING_SECRET: &str = "nostr_signing_secret";
1593    /// Key ID for the single Nostr signing-secret entry.
1594    pub const NOSTR_SIGNING_SECRET_ID: &str = "current";
1595    /// Key type for the Nostr receive watermark: the newest event `created_at`
1596    /// (unix seconds) this install has accepted from a relay.
1597    ///
1598    /// Unlike the signing secret this is *protocol-state*, not secure storage:
1599    /// it is a coarse timestamp, not key material, and losing it costs one
1600    /// wider backfill window rather than an identity.
1601    pub const NOSTR_WATERMARK: &str = "nostr_watermark";
1602    /// Key ID for the single Nostr receive-watermark entry.
1603    pub const NOSTR_WATERMARK_ID: &str = "current";
1604    /// Key type for the Nostr key-package publication slot map: which MLS key
1605    /// package currently stands in each published addressable slot.
1606    ///
1607    /// Protocol state rather than secure storage, on the same reasoning as the
1608    /// watermark: the values are slot labels and package ids, both of which are
1609    /// already public in the published record itself. Losing it costs a round
1610    /// of republication under fresh slot ids, not an identity — the stale
1611    /// records left at the old slots expire with their key packages.
1612    pub const NOSTR_KEY_PACKAGE_SLOTS: &str = "nostr_key_package_slots";
1613    /// Key ID for the single Nostr publication-slot entry.
1614    pub const NOSTR_KEY_PACKAGE_SLOTS_ID: &str = "current";
1615    /// Key type for the per-install key that seals sensitive protocol-state
1616    /// records at rest.
1617    ///
1618    /// Lives in *secure* storage — it is the one piece of the protocol-state
1619    /// domain that must be credential-backed, because it is what gives the
1620    /// install-scoped container's contents their confidentiality (see
1621    /// [`crate::protocol::state_crypto`]).
1622    pub const STATE_RECORD_KEY: &str = "protocol_state_record_key";
1623    /// Key ID for the single protocol-state record key entry.
1624    pub const STATE_RECORD_KEY_ID: &str = "current";
1625    /// Key type for peers we are the both-create "owner" of and are awaiting a
1626    /// group-aware decrypt from before confirming (see
1627    /// [`crate::protocol::OfflineProtocol`]'s `both_create_awaiting_decrypt`).
1628    /// Persisted so an owner restart mid-convergence cannot let a stale plaintext
1629    /// probe/ack prematurely confirm and strand the peer on a divergent group.
1630    pub const BOTH_CREATE_AWAITING_DECRYPT: &str = "both_create_awaiting_decrypt";
1631    /// Key type for the marker recording that pre-split protocol state has been
1632    /// adopted out of secure storage (see
1633    /// `OfflineProtocol::adopt_legacy_protocol_state`). Lives in *protocol
1634    /// state* storage, so a reinstall — which drops that container — correctly
1635    /// re-runs the sweep against whatever the credential store still holds.
1636    pub const STATE_ADOPTION: &str = "protocol_state_adoption";
1637    /// Key ID for the single state-adoption marker entry.
1638    pub const STATE_ADOPTION_ID: &str = "v1";
1639
1640    /// Every key type that moved from secure storage into protocol-state
1641    /// storage when the two domains were split.
1642    ///
1643    /// Unlike the MLS key-type set — which is open, because OpenMLS contributes
1644    /// its own labels through `storage_adapter.rs` — this set is closed and
1645    /// declared right here, which is what makes a one-shot bulk adoption
1646    /// possible at all (see `OfflineProtocol::adopt_legacy_protocol_state`).
1647    /// A new protocol-state category added *after* the split must NOT be added
1648    /// here: there is no pre-split data for it to inherit, and listing it would
1649    /// only cost a pointless enumeration of the credential store.
1650    ///
1651    /// # Removal
1652    ///
1653    /// This list, `OfflineProtocol::adopt_legacy_protocol_state`, and the
1654    /// `STATE_ADOPTION` marker are one-shot migration scaffolding for installs
1655    /// upgrading *across* the storage split. They stop doing anything once no
1656    /// supported install can still be running a pre-split build — an install
1657    /// that skips the split release entirely still needs them, so the trigger
1658    /// is not "one release later". Delete them only when the oldest supported
1659    /// upgrade path starts at or after the release that introduced the split,
1660    /// and delete all three together: leaving the marker behind without the
1661    /// sweep would make a later re-introduction silently skip itself.
1662    pub const ADOPTABLE_STATE_KEY_TYPES: &[&str] = &[
1663        BLOCKED_USERS,
1664        OUTBOX,
1665        PENDING_MESSAGES,
1666        SESSION_STATES,
1667        WELCOME_LIFECYCLES,
1668        PEER_KEY_PACKAGES,
1669        PEER_CAPABILITIES,
1670        MEDIA_DESCRIPTORS,
1671        BOTH_CREATE_AWAITING_DECRYPT,
1672        LAMPORT_CLOCK,
1673    ];
1674}
1675
1676/// Protocol state.
1677#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1678pub enum ProtocolState {
1679    /// Protocol is not started.
1680    Stopped,
1681    /// Protocol is running.
1682    Running,
1683    /// Protocol is paused (background mode).
1684    Paused,
1685}
1686
1687/// Shared state protected by mutex.
1688pub(crate) struct SharedState {
1689    /// Current protocol state.
1690    pub(crate) state: ProtocolState,
1691
1692    /// Event handlers registered by the application.
1693    pub(crate) event_handlers: Vec<EventCallback>,
1694
1695    /// Received messages queue.
1696    pub(crate) received_messages: VecDeque<Message>,
1697
1698    /// Installed telemetry context. When present, `emit_event` additionally
1699    /// forwards every protocol event to `ctx.sink` as a
1700    /// `TelemetryRecord::Protocol`, with identifier scrubbing applied per
1701    /// `ctx.config`. Set via `OfflineProtocol::install_telemetry_sink`.
1702    pub(crate) telemetry: Option<Arc<TelemetryContext>>,
1703}
1704
1705impl SharedState {
1706    pub(crate) fn new() -> Self {
1707        Self {
1708            state: ProtocolState::Stopped,
1709            event_handlers: Vec::new(),
1710            received_messages: VecDeque::new(),
1711            telemetry: None,
1712        }
1713    }
1714
1715    pub(crate) fn emit_event(&self, event: Event) {
1716        // Legacy `EventCallback` handlers run first and receive the raw
1717        // event. This preserves the pre-telemetry contract — any app that
1718        // relied on `on_event` sees exactly what it used to.
1719        //
1720        // Each handler call is panic-isolated so a faulty handler cannot
1721        // unwind through this method while a `MutexGuard<SharedState>` is
1722        // live in the caller's frame — that would poison the shared-state
1723        // mutex and silently degrade every subsequent SDK operation.
1724        for handler in &self.event_handlers {
1725            let event_for_handler = event.clone();
1726            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1727                handler(event_for_handler);
1728            }));
1729            if result.is_err() {
1730                tracing::error!(
1731                    event = ?event,
1732                    "EventCallback panicked; continuing. Handlers must not panic — see OfflineProtocol::on_event.",
1733                );
1734            }
1735        }
1736        // Sink fan-out runs after, gated on an installed context. Identifier
1737        // fields are scrubbed per the installed config before crossing the
1738        // sink boundary so long-lived pseudonyms don't leak to third-party
1739        // sinks by default. When scrubbing is disabled
1740        // (`TelemetryConfig::with_scrub_ids(false)`), `scrub_event` returns
1741        // a borrowed reference and the sink sees the raw event.
1742        //
1743        // Dispatch goes through `dispatch_record` so a panicking sink is
1744        // caught and logged rather than unwinding through the caller's live
1745        // `MutexGuard<SharedState>` — see the helper's docstring.
1746        if let Some(ctx) = &self.telemetry {
1747            let scrubbed = scrub_event::scrub_event(&event, &ctx.scrubber);
1748            let record = TelemetryRecord::Protocol(Box::new(scrubbed.into_owned()));
1749            dispatch_record(&ctx.sink, &record);
1750        }
1751    }
1752}
1753
1754/// Helper function to lock a mutex and convert poison errors to protocol errors.
1755pub(crate) fn lock_shared_state(
1756    state: &Arc<Mutex<SharedState>>,
1757) -> std::result::Result<std::sync::MutexGuard<'_, SharedState>, Error> {
1758    state
1759        .lock()
1760        .map_err(|_| Error::Other("Shared state mutex poisoned".to_string()))
1761}
1762
1763/// Provenance kept on an outbox entry so an encrypted DM can be *re-sealed*
1764/// against the peer's current MLS session on each resend, instead of replaying
1765/// the ciphertext bytes sealed at first send (which become permanently
1766/// undecryptable once the peer re-keys to a new epoch). Mirrors the fields the
1767/// pre-send [`PendingMessage`] carries, so both re-seal through the same
1768/// `prepare_outbound_content` chokepoint. Only populated for main-outbox
1769/// (non-media) encrypted DMs; absent (`None`) means verbatim replay — the
1770/// fallback for plaintext sends and media chunks.
1771///
1772/// **Memory-only by design.** This holds the message *plaintext*, so it is never
1773/// persisted (see the `#[serde(skip)]` on [`OutboxEntry::reseal`]): persisting it
1774/// would broaden plaintext-at-rest to every sent-but-unACKed encrypted DM for the
1775/// full outbox lifetime, weakening forward secrecy in exchange for only a narrow
1776/// cross-restart reseal benefit. After a restart the restored entry replays
1777/// verbatim; if that resend hits a desync, Tier 1 (un-ACK + re-key) still keeps
1778/// delivery honest rather than silently losing the message.
1779#[derive(Debug, Clone)]
1780pub(crate) struct OutboxReseal {
1781    /// Original plaintext content.
1782    pub(crate) content: String,
1783    /// Message priority.
1784    pub(crate) priority: MessagePriority,
1785    /// Reply-to message ID if applicable.
1786    pub(crate) reply_to_msg: Option<MessageId>,
1787    /// Forwarding attribution.
1788    pub(crate) forwarded_from: Option<ForwardInfo>,
1789    /// Content type of the original message.
1790    pub(crate) content_type: ContentType,
1791    /// Media metadata (cleartext-outer fallback provenance).
1792    pub(crate) media_metadata: Option<MediaMetadata>,
1793    /// Sealed-only rich extras (reply context, rich media metadata, forward
1794    /// info) — re-evaluated against current capability at re-seal time.
1795    pub(crate) rich: Option<RichSendExtras>,
1796}
1797
1798#[derive(Debug, Clone, Serialize, Deserialize)]
1799pub(crate) struct OutboxEntry {
1800    pub(crate) message: Message,
1801    pub(crate) attempt_count: u32,
1802    pub(crate) first_sent_at: DateTime<Utc>,
1803    pub(crate) last_sent_at: DateTime<Utc>,
1804    pub(crate) last_transport: Option<TransportType>,
1805    /// Re-seal provenance; `None` for verbatim-replay entries (plaintext or
1806    /// media). **Memory-only** (`#[serde(skip)]`): it carries the message
1807    /// plaintext, which must never be persisted (see [`OutboxReseal`]). A
1808    /// restored entry deserializes with `reseal: None` and therefore replays
1809    /// verbatim.
1810    #[serde(skip)]
1811    pub(crate) reseal: Option<OutboxReseal>,
1812}
1813
1814#[derive(Clone)]
1815pub(crate) struct PendingMediaMetadataEntry {
1816    pub(crate) content_type: ContentType,
1817    pub(crate) media_metadata: Option<MediaMetadata>,
1818    pub(crate) last_updated_at: Instant,
1819    /// Sender of the file transfer (used to drain partial transfers on block).
1820    pub(crate) sender: String,
1821    /// Rich extras from the sealed chunk-0 plaintext. Never populated from
1822    /// wire (legacy plaintext) chunks — the sealed body is the only trusted
1823    /// carrier.
1824    pub(crate) rich_extras: Option<crate::media_envelope::MediaRichExtras>,
1825    /// The chunk-0 outer `Message` timestamp (wall-clock ms) — the sender's
1826    /// send time, surfaced on `FileReceived` for display ordering.
1827    pub(crate) timestamp_ms: i64,
1828}
1829
1830#[derive(Clone)]
1831pub(crate) struct OutboundMediaTransfer {
1832    pub(crate) content_type: ContentType,
1833    pub(crate) recipient: String,
1834    pub(crate) pinned_transport: TransportType,
1835    pub(crate) total_chunks: u32,
1836    pub(crate) delivered_chunks: HashSet<u32>,
1837    pub(crate) last_updated_at: Instant,
1838    pub(crate) media_metadata: Option<MediaMetadata>,
1839    /// Rich extras sealed into chunk 0 (already capability-gated at the
1840    /// `send_media_with` boundary). Kept on the transfer because chunk
1841    /// batches are (re-)encoded via `pump_media_transfers` too.
1842    pub(crate) rich_extras: Option<crate::media_envelope::MediaRichExtras>,
1843}
1844
1845/// Crash-scoped descriptor of an in-flight outbound media transfer.
1846///
1847/// Persisted (no chunk bytes — see commit 42d1b86's rationale: resurrected
1848/// chunks can never complete, and per-chunk secure-storage writes are
1849/// expensive) when a transfer starts and deleted whenever the in-memory
1850/// transfer is removed (completed, aborted, or stale-swept). A descriptor
1851/// found on restore therefore means the app died mid-transfer:
1852/// [`crate::events::Event::MediaResendRequired`] is emitted so the app can
1853/// re-supply the bytes via `send_media_with` under the same `file_id`,
1854/// validated against `file_checksum`.
1855#[derive(Clone, Serialize, Deserialize)]
1856pub(crate) struct MediaTransferDescriptor {
1857    pub(crate) file_id: String,
1858    pub(crate) recipient: String,
1859    pub(crate) file_name: String,
1860    pub(crate) file_size: u64,
1861    /// SHA-256 hex of the plaintext file bytes (same value every chunk
1862    /// carries as `FileChunk::file_checksum`).
1863    pub(crate) file_checksum: String,
1864    pub(crate) content_type: ContentType,
1865    /// Wall-clock start of the transfer; restore prunes by
1866    /// `outbox_max_lifetime_ms` age.
1867    pub(crate) queued_at: DateTime<Utc>,
1868}
1869
1870pub(crate) enum OutboundSendPreparation {
1871    Ready(String),
1872    Queued(MessageId),
1873}
1874
1875#[cfg(test)]
1876mod send_failure_classification_tests {
1877    use super::*;
1878
1879    /// Every token either producer can mint classifies to itself.
1880    ///
1881    /// This is what lets the two functions compose. A value written by
1882    /// `send_failure_token` is later re-read from a persisted welcome
1883    /// lifecycle and passed through `classify_transport_send_error`; without
1884    /// idempotence that round trip would degrade a precise token to the
1885    /// generic fallback on every emit.
1886    #[test]
1887    fn every_send_failure_token_classifies_to_itself() {
1888        for token in SEND_FAIL_REASON_TOKENS {
1889            assert_eq!(
1890                classify_transport_send_error(token),
1891                *token,
1892                "{token} must survive a re-classification"
1893            );
1894        }
1895    }
1896
1897    /// The vocabulary table really is the vocabulary.
1898    ///
1899    /// `classify_transport_send_error` answers from the table, so it cannot
1900    /// disagree with it — but `send_failure_token` matches independently, and a
1901    /// token added there and forgotten here would classify to the fallback on
1902    /// the next round trip. Walking the transport codes catches that for the
1903    /// arm most likely to grow.
1904    #[test]
1905    fn transport_error_tokens_are_in_the_vocabulary() {
1906        use offline_protocol_transport::Error as T;
1907        let cases = [
1908            T::TransportNotAvailable("ble".into()),
1909            T::PeerNotReachable("bob".into()),
1910            T::SendFailed("x".into()),
1911            T::ReceiveFailed("x".into()),
1912            T::ConfigurationError("x".into()),
1913            T::SerializationError("x".into()),
1914            T::MessageTooLarge(2, 1),
1915            T::CryptoError("x".into()),
1916            T::Other("x".into()),
1917        ];
1918        for case in cases {
1919            let token = send_failure_token(&crate::Error::Transport(case.clone()));
1920            assert!(
1921                SEND_FAIL_REASON_TOKENS.contains(&token),
1922                "{case:?} produced {token}, which is not in SEND_FAIL_REASON_TOKENS"
1923            );
1924        }
1925    }
1926
1927    /// A relay's prose is dropped and its token kept.
1928    ///
1929    /// The prefix is a cross-layer contract the bridges hardcode and core
1930    /// prefix-matches to park a DM, so it must survive verbatim; everything
1931    /// the relay appended to it must not.
1932    #[test]
1933    fn relay_prose_is_dropped_and_the_token_kept() {
1934        assert_eq!(
1935            classify_transport_send_error(
1936                "recipient_unreachable: Recipient is offline and push notification could not be sent"
1937            ),
1938            SEND_FAIL_REASON_RECIPIENT_UNREACHABLE
1939        );
1940        // The relay's real vocabulary, all of it, collapses to one token.
1941        for prose in [
1942            "Recipient connection lost and push notification could not be sent",
1943            "Push notification already sent for this message; recipient unreachable",
1944            "Recipient unreachable and the message is too large to deliver by push",
1945            "Group delivery did not settle for this recipient in time",
1946        ] {
1947            assert_eq!(
1948                classify_transport_send_error(&format!("recipient_unreachable: {prose}")),
1949                SEND_FAIL_REASON_RECIPIENT_UNREACHABLE
1950            );
1951        }
1952    }
1953
1954    /// Anything unrecognized fails closed to the fallback, never to itself.
1955    #[test]
1956    fn unknown_text_falls_back_and_is_never_echoed() {
1957        for raw in [
1958            "Relay rejected event: blocked",
1959            "off1qyvh9kjj4vy8f943a22qvxsct5s9ydew35v2dl2c is not connected",
1960            "",
1961        ] {
1962            let classified = classify_transport_send_error(raw);
1963            assert_eq!(classified, SEND_FAIL_REASON_TRANSPORT);
1964            assert!(
1965                SEND_FAIL_REASON_TOKENS.contains(&classified),
1966                "the fallback must itself be a token"
1967            );
1968        }
1969    }
1970
1971    /// The literals the UniFFI layer substitutes are classified too, so the
1972    /// vocabulary has exactly one source rather than two that drift.
1973    #[test]
1974    fn uniffi_substituted_literals_are_classified() {
1975        for literal in [
1976            "Internet transport send failed",
1977            "Reticulum transport send failed",
1978            "Nostr transport send failed",
1979        ] {
1980            assert_eq!(
1981                classify_transport_send_error(literal),
1982                SEND_FAIL_REASON_TRANSPORT
1983            );
1984        }
1985        assert_eq!(
1986            classify_transport_send_error("Welcome send confirmation timed out"),
1987            SEND_FAIL_REASON_CONFIRM_TIMEOUT
1988        );
1989    }
1990}