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