Skip to main content

offline_protocol/
group_mesh.rs

1//! Group messaging — MLS-encrypted, transport-agnostic group operations.
2//!
3//! This module implements group creation, member invite/remove/leave,
4//! encrypted message fan-out, commit distribution, and pending commit
5//! buffering for out-of-order delivery.
6//!
7//! Groups are transport-agnostic: messages route through whichever transport
8//! DORS selects (BLE, WiFi Direct, or Internet). When Internet is available
9//! and a relay server supports it, the protocol can send a single relay
10//! broadcast instead of O(N) per-member fan-out.
11
12use crate::protocol::{
13    base64_decode, base64_encode, internal_prefixes, GroupMemberRemovedPayload,
14    InternalMessageResult, OfflineProtocol, RichPayloadV1, RichSendExtras, RICH_PAYLOAD_V1,
15};
16use crate::{Error, Event, Result};
17use chrono::{DateTime, Utc};
18use offline_protocol_core::{
19    ContentType, ForwardInfo, MediaMetadata, Message, MessageId, MessagePriority,
20};
21use offline_protocol_mls::GroupRole;
22use offline_protocol_transport::TransportType;
23use serde::{Deserialize, Serialize};
24use std::collections::{HashMap, HashSet, VecDeque};
25use std::time::{Duration as StdDuration, Instant};
26use tracing::{debug, error, info, warn};
27
28/// TTL for group message dedup entries (5 minutes).
29pub(super) const GROUP_MESSAGE_DEDUP_TTL_SECS: u64 = 300;
30/// Maximum number of group message dedup entries before forced cleanup.
31pub(super) const MAX_GROUP_MESSAGE_DEDUP_ENTRIES: usize = 10_000;
32/// Maximum allowed base64-encoded payload size for incoming group messages (1 MB).
33pub(crate) const MAX_BASE64_PAYLOAD_SIZE: usize = 1_048_576;
34/// How long an outstanding relay group registration waits for its
35/// `__GROUP_CREATED__` answer before the correlation entry is expired. The
36/// relay answers within a round trip, so this is generous slack; what it
37/// bounds is the *forged-ack acceptance window* against a relay that never
38/// answers (prefix-unaware echo relay, legacy server).
39pub(super) const RELAY_REGISTER_ACK_TIMEOUT_SECS: i64 = 30;
40/// Registration sends per connection before giving up on relay sync for a
41/// group. An unanswered registration is re-sent on expiry (the frame may
42/// have been lost); past this budget the group simply stays unsynced and
43/// sends take the always-correct per-member fan-out path. Reset by the
44/// internet 0→1 re-sync, which re-registers from scratch.
45pub(super) const RELAY_REGISTER_MAX_ATTEMPTS: u32 = 3;
46/// How long a relay group broadcast waits for its settled `GroupMessageSent`
47/// delivery report before being re-sent. The relay emits the report only
48/// after its whole fan-out settles, which is bounded by its 45 s wall-clock
49/// budget (`GROUP_FANOUT_DEADLINE_SECS` relay-side) plus a possible wait for
50/// a relay-wide fan-out slot — so this sits above that budget, not above a
51/// round trip. An entry older than this is presumed lost with its frame or
52/// connection, not merely slow.
53pub(super) const RELAY_BROADCAST_REPORT_TIMEOUT_SECS: i64 = 60;
54/// Broadcast sends per logical group message (initial send plus re-sends on
55/// report timeout) before the sender stops trusting the broadcast path and
56/// falls back to per-member fan-out for the whole roster. Re-sent broadcasts
57/// are safe: the relay echoes the client-supplied `message_id`, so receiver
58/// dedup and the relay's push dedup both hold across attempts.
59pub(super) const RELAY_BROADCAST_MAX_ATTEMPTS: u32 = 3;
60/// Upper bound on broadcasts awaiting a delivery report at once. Each entry
61/// holds a full ciphertext copy and lives for up to
62/// `RELAY_BROADCAST_REPORT_TIMEOUT_SECS * RELAY_BROADCAST_MAX_ATTEMPTS`, so
63/// against a relay that accepts broadcasts but never reports, an app sending
64/// steadily would otherwise grow the tracker without limit. At the cap the
65/// oldest entry is downgraded to per-member fan-out, which needs no report
66/// to be correct — the bound costs uplink, never delivery.
67pub(super) const MAX_RELAY_BROADCAST_PENDING: usize = 64;
68/// Relay capability token gating the group broadcast path: the v2 settled
69/// delivery contract (client `message_id` accept/echo, group push fallback,
70/// the settled `GroupMessageSent` delivery report, `forward_info`
71/// passthrough) plus — what v3 adds — an address-aware relay group path:
72/// members are named in the identifier space the roster was registered in
73/// (this SDK registers the MLS roster, i.e. `off1…` addresses), fan-out and
74/// push resolve those names to accounts relay-side, and group sender
75/// attribution carries the sender's declared address. The relay advertises
76/// it (with the granular `group_*` tokens it aggregates) in its
77/// `Authenticated` answer; the bridge injects the list via
78/// `set_relay_capabilities` before reporting the transport up.
79///
80/// A `group_delivery_v2` relay deliberately fails this gate. Against its
81/// username-keyed group path, a post-addressing broadcast is worse than
82/// useless: the relay cannot resolve address-registered members to
83/// connections (the broadcast reaches nobody it was registered for), its
84/// report names members in username space (the roster set-difference
85/// re-issues to the whole roster), and any copy it does deliver arrives
86/// attributed by username — failing the SEC-M1 wire-sender/credential match
87/// and burning the ciphertext on a copy that is then rejected. Failing the
88/// gate keeps every group send on the per-member fan-out path, which post-#27
89/// relays route and attribute correctly by address.
90pub(crate) const RELAY_CAP_GROUP_DELIVERY_V3: &str = "group_delivery_v3";
91/// Upper bound on stored relay capability tokens, and on the byte length of
92/// each token. The list is wire-supplied by the relay — bounded so a hostile
93/// or broken relay cannot grow resident memory with it.
94pub(super) const MAX_RELAY_CAPABILITIES: usize = 64;
95/// See [`MAX_RELAY_CAPABILITIES`].
96pub(super) const MAX_RELAY_CAPABILITY_TOKEN_BYTES: usize = 128;
97/// Maximum number of buffered out-of-order commits per group.
98pub(super) const MAX_PENDING_COMMITS_PER_GROUP: usize = 8;
99/// TTL for buffered pending commits. Must be at least
100/// `GROUP_MESSAGE_DEDUP_TTL_SECS`: the commit's message ID is already in the
101/// dedup table, so the buffered copy is its only path to processing — a
102/// shorter TTL would open a window where the buffer has given up while the
103/// dedup entry still rejects every redelivery, losing the commit for good.
104/// Matching the dedup TTL means that by the time the buffer expires, a
105/// sender-side redelivery can be accepted fresh.
106pub(super) const PENDING_COMMIT_TTL_SECS: u64 = GROUP_MESSAGE_DEDUP_TTL_SECS;
107/// Global cap on buffered pending commits across all groups.
108pub(super) const MAX_PENDING_COMMITS_TOTAL: usize = 64;
109/// Global cap on buffered commit payload bytes across all groups (4 MiB).
110pub(super) const MAX_PENDING_COMMIT_TOTAL_BYTES: usize = 4 * 1024 * 1024;
111/// Global cap on distinct group IDs holding buffered pending commits.
112pub(super) const MAX_PENDING_COMMIT_GROUPS: usize = 16;
113/// Maximum number of buffered out-of-order group application messages per group.
114pub(super) const MAX_PENDING_GROUP_MESSAGES_PER_GROUP: usize = 16;
115/// TTL for buffered out-of-order group application messages. Must be at
116/// least `GROUP_MESSAGE_DEDUP_TTL_SECS` — same reasoning as
117/// `PENDING_COMMIT_TTL_SECS`: the buffered copy is the message's only path
118/// to delivery while its dedup entry blocks redelivery.
119pub(super) const PENDING_GROUP_MESSAGE_TTL_SECS: u64 = GROUP_MESSAGE_DEDUP_TTL_SECS;
120/// Global cap on buffered group application messages across all groups.
121pub(super) const MAX_PENDING_GROUP_MESSAGES_TOTAL: usize = 256;
122/// Global cap on buffered group application ciphertext bytes (base64 length)
123/// across all groups (8 MiB).
124pub(super) const MAX_PENDING_GROUP_MESSAGE_TOTAL_BYTES: usize = 8 * 1024 * 1024;
125/// Global cap on distinct group IDs holding buffered group application
126/// messages. Bounds the width of a one-entry-per-fabricated-group spread
127/// flood and the map-key memory it retains (group IDs are wire-supplied,
128/// up to `GroupId::MAX_LEN` bytes each).
129pub(super) const MAX_PENDING_GROUP_MESSAGE_GROUPS: usize = 32;
130/// Timeout before the next eligible member re-elects itself for a leave
131/// remove-commit when the original elected remover fails (30 seconds).
132pub(super) const LEAVE_ELECTION_TIMEOUT_SECS: u64 = 30;
133/// Delay after detecting a potential epoch fork before the leader issues a
134/// resync commit (30 seconds — gives time for buffered commits to drain and
135/// delayed commits to arrive, reducing false positive fork detections).
136pub(super) const EPOCH_FORK_RESOLUTION_DELAY_SECS: u64 = 30;
137/// Maximum number of tracked epoch fork states to prevent unbounded growth.
138pub(super) const MAX_EPOCH_FORK_ENTRIES: usize = 32;
139/// Maximum number of tracked pending leave elections to prevent unbounded growth.
140pub(super) const MAX_PENDING_LEAVE_ELECTIONS: usize = 64;
141/// Maximum lifetime for a leave election before it is abandoned (5 minutes).
142/// After this, the election is cleaned up regardless of whether the member
143/// was removed — prevents infinite retry loops when all candidates fail.
144pub(super) const LEAVE_ELECTION_MAX_LIFETIME_SECS: u64 = 300;
145/// Minimum cooldown between re-election attempts for the same leave election
146/// to prevent spamming MLS operations on every process tick (5 seconds).
147pub(super) const LEAVE_ELECTION_ATTEMPT_COOLDOWN_SECS: u64 = 5;
148/// Minimum interval between `GroupUnauthorizedMembershipChange` reports for
149/// the same (group, committer) pair (5 minutes). Divergent role metadata —
150/// e.g. two members auto-promoting different admins — re-fires the judgment
151/// on every commit from the "wrong" admin until Stage 2 adds reconciliation;
152/// without a window that is an unbounded stream of security events and log
153/// lines for one underlying condition. Roster events still carry
154/// `authorized: Some(false)` on every occurrence.
155pub(super) const UNAUTHORIZED_REPORT_SUPPRESS_SECS: u64 = 300;
156/// Maximum number of tracked (group, committer) report timestamps to prevent
157/// unbounded growth (both key halves are wire-influenced strings).
158pub(super) const MAX_UNAUTHORIZED_REPORT_ENTRIES: usize = 256;
159
160/// Correlation state for one in-flight relay group registration.
161#[derive(Debug, Clone)]
162pub(crate) struct RelayRegisterPending {
163    /// When the outstanding `__GRP_RELAY_REG__` frame was (last) sent;
164    /// entries older than [`RELAY_REGISTER_ACK_TIMEOUT_SECS`] are expired
165    /// on the process tick.
166    pub(crate) armed_at: DateTime<Utc>,
167    /// Registration sends on this connection, counted against
168    /// [`RELAY_REGISTER_MAX_ATTEMPTS`].
169    pub(crate) attempts: u32,
170}
171
172/// Correlation state for one in-flight relay group broadcast awaiting its
173/// settled `GroupMessageSent` delivery report.
174///
175/// Keyed (in `GroupMeshState::relay_broadcast_pending`) by the logical group
176/// message id carried in the broadcast payload and echoed back by a v2
177/// relay. Holds everything needed to (a) re-send the broadcast under the
178/// same logical id when the report is lost, and (b) rebuild per-member
179/// `__GRP_MLS_MSG__` copies for members the report names as missed or does
180/// not name at all (the relay's roster can be a strict subset of the MLS
181/// roster).
182#[derive(Debug, Clone)]
183pub(crate) struct RelayBroadcastPending {
184    /// Group the broadcast belongs to.
185    pub(crate) group_id: String,
186    /// Base64-encoded MLS application ciphertext, reused verbatim for
187    /// broadcast re-sends and per-member backstop copies (MLS application
188    /// ciphertext is recipient-independent within a group).
189    pub(crate) ciphertext_b64: String,
190    /// MLS epoch at which the message was encrypted (informational).
191    pub(crate) epoch: u64,
192    /// Optional reply-to message id.
193    pub(crate) reply_to: Option<String>,
194    /// Hop-visible forward attribution; `None` when sealed into the
195    /// ciphertext as rich extras.
196    pub(crate) forward_info: Option<ForwardInfo>,
197    /// Priority of the original send, inherited by backstop copies.
198    pub(crate) priority: MessagePriority,
199    /// When the outstanding broadcast frame was (last) sent; entries older
200    /// than [`RELAY_BROADCAST_REPORT_TIMEOUT_SECS`] are re-sent or downgraded
201    /// on the process tick.
202    pub(crate) armed_at: DateTime<Utc>,
203    /// Broadcast sends for this logical message, counted against
204    /// [`RELAY_BROADCAST_MAX_ATTEMPTS`].
205    pub(crate) attempts: u32,
206}
207
208/// Which gate refused a leaf, for the `GroupLeafIdentityUnproven` rate-limit
209/// key.
210///
211/// Part of the key for the same reason `unauthorized_change_reports` carries
212/// its `enforced` flag: the two are different signals with different urgency,
213/// and neither may suppress the other's first report inside one window. A
214/// declined invite is a stranger's group you never joined; a refused commit
215/// accuses a member of a group you are already in.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
217pub(crate) enum UnprovenLeafSite {
218    /// A group invite declined because its ratchet tree carried the leaf.
219    WelcomeDeclined,
220    /// A membership commit refused because it would have installed the leaf.
221    CommitRefused,
222    /// A roster read skipped the leaf because it was already seated in local
223    /// group state.
224    ///
225    /// The odd one out, and the reason it needs its own site: the other two
226    /// refuse a frame *arriving*, so nothing is installed and there is a
227    /// delivering peer to name. This one reports a leaf that is already in the
228    /// tree, holding live group secrets — the wire gates cannot have admitted
229    /// it, so it arrived by a direct write to the install-scoped provider store
230    /// or by a build predating those gates. There is no delivering peer, so the
231    /// report is attributed to this device (see
232    /// [`OfflineProtocol::report_unproven_leaf`]), and the remedy it implies is
233    /// different too: abandon the group rather than evict a member, because the
234    /// leaf can already read everything sent to it.
235    RosterEntry,
236}
237
238/// Claims a rate-limit window for `key`, returning whether the caller should
239/// report.
240///
241/// Shared by the two security reporters so their suppression, map bounding and
242/// eviction order cannot drift apart. Bounds the map by dropping lapsed windows
243/// first and only then evicting the oldest live entry — the longest-suppressed
244/// one, which is the safe direction to lose.
245fn claim_report_window<K: Eq + std::hash::Hash + Clone>(
246    reports: &mut HashMap<K, Instant>,
247    key: K,
248) -> bool {
249    let now = Instant::now();
250    let suppressed = reports.get(&key).is_some_and(|last| {
251        now.duration_since(*last).as_secs() < UNAUTHORIZED_REPORT_SUPPRESS_SECS
252    });
253    if suppressed {
254        return false;
255    }
256
257    if reports.len() >= MAX_UNAUTHORIZED_REPORT_ENTRIES {
258        reports.retain(|_, last| {
259            now.duration_since(*last).as_secs() < UNAUTHORIZED_REPORT_SUPPRESS_SECS
260        });
261    }
262    if reports.len() >= MAX_UNAUTHORIZED_REPORT_ENTRIES {
263        if let Some(oldest) = reports
264            .iter()
265            .min_by_key(|(_, t)| **t)
266            .map(|(k, _)| k.clone())
267        {
268            reports.remove(&oldest);
269        }
270    }
271    reports.insert(key, now);
272    true
273}
274
275/// Bundled state for group messaging.
276///
277/// Groups together the cached member lists, dedup table, pending commit
278/// buffer, and relay sync tracking so that `OfflineProtocol` doesn't carry
279/// these as individual fields.
280#[derive(Default)]
281pub(crate) struct GroupMeshState {
282    /// Cached group membership lists for fan-out without holding MLS lock.
283    /// Maps group_id -> list of member user IDs.
284    pub(crate) members: HashMap<String, Vec<String>>,
285
286    /// Deduplication cache for group messages received via multiple paths.
287    /// Key: message ID, Value: when first seen.
288    pub(crate) message_dedup: HashMap<String, Instant>,
289
290    /// Buffer for out-of-order MLS commits that failed to decrypt.
291    /// Maps group_id -> deque of pending commits awaiting retry.
292    /// When a commit succeeds for a group, buffered commits are drained and retried.
293    pub(crate) pending_commits: HashMap<String, VecDeque<PendingCommit>>,
294
295    /// Buffer for group application messages that failed MLS decryption
296    /// because local group state lagged the sender's (Welcome not yet
297    /// processed, or epoch behind). Drained after a successful Welcome join
298    /// or commit for the group. Maps group_id -> deque of pending messages.
299    pub(crate) pending_group_messages: HashMap<String, VecDeque<PendingGroupMessage>>,
300
301    /// Group IDs that have been successfully registered with the relay server.
302    /// Cleared when Internet transport disconnects so that groups are re-synced
303    /// when connectivity returns.
304    pub(crate) relay_synced: HashSet<String>,
305
306    /// Group IDs with a relay registration in flight (a `__GRP_RELAY_REG__`
307    /// frame enqueued, no relay answer yet). The `__GROUP_CREATED__` ack only
308    /// sets `relay_synced` for groups in this map: the relay forwards peer
309    /// message content verbatim, so without this correlation any peer that
310    /// knows a group id could forge the ack over the internet path and route
311    /// our broadcasts into a relay that never registered the group. Cleared
312    /// per group on ack, group-scoped `__GROUP_ERROR__`, leave/removal, and
313    /// wholesale when the Internet transport drops (the answer can never
314    /// arrive). Entries a relay never answers are expired on the process
315    /// tick ([`RELAY_REGISTER_ACK_TIMEOUT_SECS`]) and re-registered up to
316    /// [`RELAY_REGISTER_MAX_ATTEMPTS`] times — a relay that never acks
317    /// (prefix-unaware echo relay, legacy server) must not leave the
318    /// acceptance window armed indefinitely for a forged ack to claim.
319    pub(crate) relay_register_pending: HashMap<String, RelayRegisterPending>,
320
321    /// Capability tokens the connected relay advertised in its
322    /// `Authenticated` answer, injected by the platform bridge via
323    /// `set_relay_capabilities` *before* it reports the Internet transport
324    /// up — the false→true flush must already see them. Empty for relays
325    /// that predate the advertisement. Cleared when Internet drops: a
326    /// reconnect may land on a different relay.
327    pub(crate) relay_capabilities: HashSet<String>,
328
329    /// In-flight relay group broadcasts awaiting their settled
330    /// `GroupMessageSent` delivery report, keyed by logical group message
331    /// id. Settled by `handle_relay_group_delivery_report` (which re-sends
332    /// per-member copies to members the relay did not reach), re-sent on
333    /// report timeout, and downgraded to full per-member fan-out when the
334    /// attempt budget is exhausted or the Internet transport drops (the
335    /// report can never arrive on a dead connection).
336    pub(crate) relay_broadcast_pending: HashMap<String, RelayBroadcastPending>,
337
338    /// Whether Internet transport was available on the last `process()` tick.
339    /// Used for edge-detection: sync groups on 0→1 transition, clear on 1→0.
340    pub(crate) internet_was_available: bool,
341
342    /// Pending leave elections awaiting a remove-commit from the elected member.
343    /// Key: (group_id, leaving_member_id) — uses a tuple to avoid ambiguity
344    /// from string concatenation when IDs contain separator characters.
345    pub(crate) pending_leave_elections: HashMap<(String, String), PendingLeaveElection>,
346
347    /// Suspected epoch forks awaiting resolution. Key: group_id.
348    pub(crate) epoch_forks: HashMap<String, EpochForkState>,
349
350    /// When an unauthorized membership change by (group_id, committer,
351    /// enforced) was last reported, for rate-limiting the security event —
352    /// see [`UNAUTHORIZED_REPORT_SUPPRESS_SECS`]. Bounded by
353    /// [`MAX_UNAUTHORIZED_REPORT_ENTRIES`] with oldest-entry eviction.
354    ///
355    /// The `enforced` flag is part of the key on purpose: an applied change
356    /// and a refused one are different signals with different urgency (the
357    /// refusal doubles as a partition alarm), so neither may suppress the
358    /// other's first report inside one window.
359    pub(crate) unauthorized_change_reports: HashMap<(String, String, bool), Instant>,
360
361    /// When an unproven-leaf refusal by (group_id, delivering peer, site) was
362    /// last reported, for rate-limiting `GroupLeafIdentityUnproven` — same
363    /// window and same bound as [`Self::unauthorized_change_reports`], kept
364    /// separate so neither signal can suppress the other's first report.
365    ///
366    /// Needed because the commit half of this report is driven by an
367    /// authenticated group member: refusals are permanent and cheap, so an
368    /// insider can produce one per frame, and an unthrottled event channel
369    /// buries the signal in its own repetition.
370    ///
371    /// [`UnprovenLeafSite`] is part of the key for the same reason
372    /// `unauthorized_change_reports` keys on `enforced`: a declined invite must
373    /// not swallow the first refused *commit*, which is the more urgent signal
374    /// of the two. Relying on the two never colliding — today they cannot,
375    /// because `handle_group_mls_welcome` returns early for a group already
376    /// joined — would hide that guarantee in a different function.
377    pub(crate) unproven_leaf_reports: HashMap<(String, String, UnprovenLeafSite), Instant>,
378}
379
380/// Outcome of attempting to process an MLS Commit.
381enum CommitOutcome {
382    /// Successfully processed; contains the group ID.
383    Success(String),
384    /// MLS decryption failed transiently (e.g., out-of-order delivery);
385    /// the commit may succeed after a prior commit advances the epoch.
386    /// Contains the group ID for buffering.
387    Retriable(String),
388    /// Permanently failed (parse error, bad ciphertext, MLS unavailable);
389    /// should not be retried.
390    Rejected,
391}
392
393/// Verdict from [`OfflineProtocol::judge_membership_change`] for one commit's
394/// applied membership delta.
395pub(crate) struct MembershipJudgment {
396    /// Whether the authenticated committer is a known admin of the group.
397    /// Gates trust in the commit-payload metadata (role, rich attestation).
398    pub(crate) sender_is_admin: bool,
399    /// Whether the change as a whole was authorized: an admin committed it
400    /// *and* the commit's own framing agrees with the delta it produced.
401    pub(crate) authorized: bool,
402}
403
404/// Outcome of attempting to decrypt a group application message.
405enum GroupDecryptOutcome {
406    /// Decrypted application data.
407    Plaintext(Vec<u8>),
408    /// MLS consumed a Commit or Proposal that arrived via the message
409    /// channel (e.g., due to reordering) — not application data.
410    NonApplication,
411    /// Wire sender does not match the MLS-authenticated sender (SEC-M1).
412    SecurityRejected,
413    /// Local group state lags the sender's (no group yet, or epoch behind);
414    /// the message may decrypt after a Welcome or commit lands, so it is
415    /// worth buffering for deferred retry.
416    Retriable,
417    /// No local group state AND the payload is not MLS wire framing at
418    /// all — this is not a welcome-racing ciphertext. On the mesh path
419    /// that means garbage (dropped); on the relay path it means a legacy
420    /// relay-only group message whose plaintext happens to be valid
421    /// base64, which must be emitted raw rather than buffered and lost.
422    NotMlsCiphertext,
423    /// A commit riding the application channel was refused before merging by
424    /// `GroupConfig::enforce_admin_commits`.
425    ///
426    /// This variant is why enforcement lives in the MLS layer rather than in
427    /// the commit handler: a commit ciphertext wrapped in an ordinary
428    /// `__GRP_MLS_MSG__` frame reaches `merge_staged_commit` through *this*
429    /// path, which otherwise treats it as benign reordering
430    /// ([`Self::NonApplication`]). Enforcing only on the commit-framed path
431    /// would leave that reframing as a bypass.
432    ///
433    /// Permanent, like the commit-path rejection: the same ciphertext can
434    /// never become authorized, so it must not be buffered and retried.
435    PolicyRejected {
436        /// The MLS-authenticated committer whose commit was refused.
437        committer: String,
438        /// User ids the refused commit would have added, sorted.
439        added: Vec<String>,
440        /// User ids the refused commit would have removed, sorted.
441        removed: Vec<String>,
442    },
443    /// Permanently failed; not worth retrying.
444    Failed,
445}
446
447// --- Group (mesh/MLS) payloads ---
448
449/// Options for `OfflineProtocol::send_group_message_with`: priority and
450/// reply threading (as on `send_group_message`), plus the rich media fields.
451///
452/// The rich fields (`content_type`, `media_metadata` — including any
453/// cloud-media `encryption_key`/`iv` secrets) only ever travel inside the
454/// MLS-sealed `__RICH_V1__` body of the group ciphertext, and only when
455/// *every* other group member advertised `rich_versions` support (one
456/// ciphertext serves the whole group, so one legacy or unknown-capability
457/// member forces the drop). Otherwise they are silently dropped — never
458/// sent cleartext — and the message degrades to what `send_group_message`
459/// sends.
460#[derive(Debug, Clone, Default)]
461pub struct GroupSendOptions {
462    /// Message priority (defaults to Medium).
463    pub priority: Option<MessagePriority>,
464    /// ID of the message this is replying to.
465    pub reply_to_msg: Option<String>,
466    /// Content-type rendering hint (text, image, video, ...), delivered
467    /// sealed-only. Must not be [`ContentType::FileChunk`].
468    pub content_type: Option<ContentType>,
469    /// Rich media metadata (cloud attachments — including the
470    /// `encryption_key`/`iv` secrets), delivered sealed-only.
471    pub media_metadata: Option<MediaMetadata>,
472}
473
474/// Snapshot of whether a rich group send right now would seal its extras,
475/// from `OfflineProtocol::group_rich_readiness`. Point-in-time and
476/// advisory: capability knowledge changes with key-package exchanges,
477/// attested adds, and restarts, and the send path re-evaluates the gate
478/// itself — this exists so apps can warn before sending (e.g. gray out the
479/// attachment button) instead of learning from `GroupRichExtrasDropped`
480/// after the drop.
481#[derive(Debug, Clone)]
482pub struct GroupRichReadiness {
483    /// True when every other member is known rich-capable and the local
484    /// kill switch is on — rich extras would seal.
485    pub ready: bool,
486    /// Members not known to parse the sealed rich payload (unknown and
487    /// known-non-support are indistinguishable). Empty when `ready`, and
488    /// also empty when only the local kill switch blocks sealing.
489    pub unknown_members: Vec<String>,
490}
491
492/// Relay-side registration state of a group, from
493/// `OfflineProtocol::group_relay_sync_state`. Point-in-time: transitions
494/// are surfaced as `GroupRelaySyncChanged` events.
495#[derive(Debug, Clone, Copy, PartialEq, Eq)]
496pub enum RelaySyncState {
497    /// The relay positively acknowledged the group's registration on the
498    /// current connection — relay-dependent server commands for the group
499    /// (invite links, server-side fan-out) can be issued.
500    Synced,
501    /// A registration was sent and its acknowledgment is outstanding.
502    /// The SDK re-sends on a timer and gives up after a bounded number of
503    /// attempts (`GroupRelaySyncChanged { reason: "ack_timeout" }`).
504    Pending,
505    /// No registration is in flight — the group is unknown locally, the
506    /// Internet transport is down, relay grouping is disabled, or a prior
507    /// attempt was answered with an error / timed out.
508    Unsynced,
509}
510
511/// Payload for MLS-encrypted group messages sent via mesh.
512#[derive(Debug, Clone, Serialize, Deserialize)]
513pub(crate) struct GroupMlsMessagePayload {
514    /// MLS group identifier.
515    pub(crate) group_id: String,
516    /// Base64-encoded MLS ciphertext.
517    pub(crate) ciphertext: String,
518    /// MLS epoch at which the message was encrypted.
519    pub(crate) epoch: u64,
520    /// Optional reply-to message ID.
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub(crate) reply_to: Option<String>,
523    /// Optional forwarding attribution (present when message was forwarded).
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub(crate) forward_info: Option<offline_protocol_core::ForwardInfo>,
526    /// Logical id of the group message this frame carries, present when the
527    /// frame re-issues a relay broadcast (the delivery-report backstop or
528    /// the lost-report fallback). Lets the receiver emit the same app-facing
529    /// message id every member sees for this logical message, and absorb a
530    /// cross-path duplicate when the relay's own copy also arrived. Absent
531    /// on the ordinary per-member fan-out path, where the envelope id is
532    /// the only id. Unauthenticated at parse time — the receiver never
533    /// *marks* it as seen before the ciphertext MLS-decrypts (see
534    /// `handle_group_mls_msg_via`), so a non-member cannot poison the id to
535    /// suppress a message it could not itself have sent.
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    pub(crate) message_id: Option<String>,
538}
539
540/// Payload for MLS Welcome messages (group invites) sent via mesh.
541#[derive(Debug, Clone, Serialize, Deserialize)]
542pub(crate) struct GroupMlsWelcomePayload {
543    /// MLS group identifier.
544    pub(crate) group_id: String,
545    /// Human-readable group name.
546    pub(crate) group_name: Option<String>,
547    /// Base64-encoded MLS Welcome data.
548    pub(crate) welcome_data: String,
549    /// Current member list (user IDs) at the time of invite.
550    pub(crate) member_list: Vec<String>,
551    /// Role assignments: user_id -> role.
552    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
553    pub(crate) member_roles: HashMap<String, GroupRole>,
554    /// Rich-payload versions the inviter attests per existing member
555    /// (additive; absent from old SDKs), so the joiner can seal rich extras
556    /// toward members it never directly exchanged key packages with.
557    /// Entries only appear for members the inviter knows capable — absence
558    /// means "no information", never a downgrade. Recipient-side trust
559    /// bounds: entries are ignored for user ids outside the joined MLS
560    /// roster, and a later direct key-package exchange with a member
561    /// overrides its attested entry.
562    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
563    pub(crate) member_rich: HashMap<String, Vec<u8>>,
564    /// The group creator the inviter has on record (additive; absent from
565    /// old SDKs and from inviters whose own metadata never learned it).
566    ///
567    /// Without this, a joiner's metadata is materialized by `set_member_role`
568    /// via `GroupMetadata::new(None)`, leaving `created_by` permanently
569    /// `None` — so `check_is_admin`'s creator fallback is dead code for
570    /// every joiner, and a group whose role snapshot arrived incomplete
571    /// resolves to deny-by-default. Propagating it makes the fallback
572    /// reachable.
573    ///
574    /// Trust bounds mirror [`Self::member_roles`]: this is the inviter's
575    /// unauthenticated claim, adopted only when the joiner has no creator on
576    /// record (first write wins, see `MlsManager::set_group_creator`), and
577    /// deliberately **not** bounded to the joined MLS roster — the creator of
578    /// record may have already left the group. It feeds only the admin
579    /// *fallback* used for send-side gating and the authorization report; it
580    /// is never consulted by receive-side commit enforcement, which fails
581    /// open on an unknown admin set rather than trusting a single claim.
582    #[serde(default, skip_serializing_if = "Option::is_none")]
583    pub(crate) created_by: Option<String>,
584}
585
586/// Type of group membership commit operation.
587#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
588#[serde(rename_all = "lowercase")]
589pub(crate) enum GroupCommitType {
590    /// A member was added to the group.
591    Add,
592    /// A member was removed from the group.
593    Remove,
594    /// A key update commit (epoch advancement without membership change).
595    /// Used for epoch fork resolution.
596    KeyUpdate,
597}
598
599/// Payload for MLS Commit messages (membership changes) sent via mesh.
600#[derive(Debug, Clone, Serialize, Deserialize)]
601pub(crate) struct GroupMlsCommitPayload {
602    /// MLS group identifier.
603    pub(crate) group_id: String,
604    /// Type of commit operation.
605    pub(crate) commit_type: GroupCommitType,
606    /// Base64-encoded MLS commit ciphertext.
607    pub(crate) ciphertext: String,
608    /// MLS epoch at which the commit was created.
609    pub(crate) epoch: u64,
610    /// User ID of the affected member (added or removed).
611    #[serde(skip_serializing_if = "Option::is_none")]
612    pub(crate) affected_member: Option<String>,
613    /// Role assigned to the affected member (for Add commits).
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub(crate) role: Option<GroupRole>,
616    /// Rich-payload versions the inviter attests for the added member
617    /// (additive; absent from old SDKs or when the inviter has no
618    /// knowledge), so existing members — who never exchange key packages
619    /// with the newcomer — can keep sealing rich extras. Recipient-side
620    /// trust bounds mirror `role`: honored only for the member the MLS
621    /// state delta actually added and only from an admin sender, and a
622    /// later direct key-package exchange overrides it.
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub(crate) affected_member_rich: Option<Vec<u8>>,
625}
626
627/// Payload for group leave notifications sent via mesh.
628#[derive(Debug, Clone, Serialize, Deserialize)]
629pub(crate) struct GroupMlsLeavePayload {
630    /// MLS group identifier.
631    pub(crate) group_id: String,
632    /// User ID of the leaving member.
633    pub(crate) leaving_member: String,
634}
635
636/// Payload for group role change notifications sent via mesh.
637#[derive(Debug, Clone, Serialize, Deserialize)]
638pub(crate) struct GroupRoleChangePayload {
639    /// MLS group identifier.
640    pub(crate) group_id: String,
641    /// User ID of the member whose role changed.
642    pub(crate) target_user_id: String,
643    /// New role.
644    pub(crate) new_role: GroupRole,
645    /// User ID of who changed the role.
646    pub(crate) changed_by: String,
647}
648
649/// Payload for group rename notifications sent via mesh.
650#[derive(Debug, Clone, Serialize, Deserialize)]
651pub(crate) struct GroupRenamePayload {
652    /// MLS group identifier.
653    pub(crate) group_id: String,
654    /// New human-readable group name.
655    pub(crate) new_name: String,
656    /// User ID of who renamed the group.
657    pub(crate) renamed_by: String,
658}
659
660// --- Relay optimization payloads ---
661
662/// Payload for registering/updating a group with the relay server.
663///
664/// Sent as a `__GRP_RELAY_REG__`-prefixed internal message to the user's
665/// own ID via Internet transport. The relay server does NOT intercept the
666/// prefix — a prefix-unaware relay just echoes the self-addressed frame
667/// back. It is the platform bridge translator (the relay adapter) that
668/// recognizes the frame via `internet_control_op` and replaces it with a
669/// relay-native `CreateGroup` plus admin-gated member deltas, so the relay
670/// learns the group → member mapping for server-side fan-out of future
671/// `__GRP_RELAY_BCAST__` messages. A transport without such an adapter
672/// leaves the group unsynced (per-member fan-out) — see
673/// `try_relay_register_group`.
674#[derive(Debug, Clone, Serialize, Deserialize)]
675pub(crate) struct RelayGroupRegistrationPayload {
676    /// MLS group identifier.
677    pub(crate) group_id: String,
678    /// Human-readable group name (for display/search on relay).
679    #[serde(skip_serializing_if = "Option::is_none")]
680    pub(crate) group_name: Option<String>,
681    /// Current member user IDs.
682    pub(crate) members: Vec<String>,
683    /// Whether the registering user is a group admin, when locally
684    /// determinable. The bridge translator uses this to skip the
685    /// `AddGroupMember`/`RemoveGroupMember` deltas a non-admin would be
686    /// denied (the relay restricts membership mutation to admins) — the
687    /// denial would otherwise arrive as a group-scoped `GroupError` that
688    /// revokes `relay_synced` and surfaces as an app-visible error on every
689    /// reconnect. Absent means unknown: the bridge falls back to
690    /// send-and-learn.
691    #[serde(default, skip_serializing_if = "Option::is_none")]
692    pub(crate) is_admin: Option<bool>,
693}
694
695/// Payload for a relay-broadcast of an MLS-encrypted group message.
696///
697/// Sent as a `__GRP_RELAY_BCAST__`-prefixed internal message to the user's
698/// own ID via Internet transport. The relay server does NOT intercept the
699/// prefix; the platform bridge translator replaces the frame with a
700/// relay-native `SendGroupMessage`, and the relay's fan-out arrives at each
701/// member as a `__GROUP_MSG__` frame injected by *their* bridge. This path
702/// is only taken once the relay has positively acknowledged the group's
703/// registration (`relay_synced`) — on any other relay the frame would be
704/// echoed back and the content silently lost.
705#[derive(Debug, Clone, Serialize, Deserialize)]
706pub(crate) struct RelayGroupBroadcastPayload {
707    /// MLS group identifier.
708    pub(crate) group_id: String,
709    /// Base64-encoded MLS ciphertext.
710    pub(crate) ciphertext: String,
711    /// MLS epoch at which the message was encrypted.
712    pub(crate) epoch: u64,
713    /// Optional reply-to message ID.
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub(crate) reply_to: Option<String>,
716    /// Optional forwarding attribution (present when message was forwarded).
717    #[serde(default, skip_serializing_if = "Option::is_none")]
718    pub(crate) forward_info: Option<offline_protocol_core::ForwardInfo>,
719    /// Logical message id for this group message, stable across broadcast
720    /// re-sends of the same logical message. The bridge translator stamps it
721    /// onto the relay `SendGroupMessage` frame; a v2 relay echoes it to
722    /// every member (receiver dedup), keys its push dedup by it, and names
723    /// it in the settled `GroupMessageSent` delivery report (sender
724    /// correlation). `default` only so old captured frames in tests still
725    /// deserialize — production frames always carry it.
726    #[serde(default)]
727    pub(crate) message_id: String,
728}
729
730/// A relay `GroupMessageSent` settled delivery report, forwarded verbatim
731/// (as the raw server frame JSON) by the platform bridge into
732/// `handle_relay_group_delivery_report`. Field names follow the relay wire
733/// contract; the enclosing `type` tag and `timestamp` are ignored. All
734/// three member lists default to empty so a v1 relay's bare receipt (no
735/// lists) still parses — it settles the entry with everyone unreached,
736/// which per-member re-issue then covers.
737///
738/// Member-name namespace: a `group_delivery_v3` relay names members by the
739/// identifiers the roster was registered under — for this SDK, the MLS
740/// roster's `off1…` addresses — which is what makes the set difference in
741/// `handle_relay_group_delivery_report` meaningful. A v2 relay names
742/// members by relay-account *username*, a namespace that never intersects
743/// the address roster; the subtraction then removes nothing and the whole
744/// roster is re-issued per-member. That degradation is the fail-safe, not
745/// the contract — the broadcast gate requires v3 precisely so reports in
746/// the wrong namespace only ever arrive from relays this SDK no longer
747/// broadcasts through (e.g. a capability set that changed mid-flight).
748#[derive(Debug, Clone, Deserialize)]
749pub(crate) struct RelayGroupDeliveryReport {
750    /// Group the report is for.
751    pub(crate) group_id: String,
752    /// The logical message id, echoed from the broadcast frame.
753    pub(crate) message_id: String,
754    /// Members whose relay socket write was confirmed (a server-side
755    /// write-ack, not an application-level delivery ACK).
756    #[serde(default)]
757    pub(crate) delivered: Vec<String>,
758    /// Members unreachable over a socket who took a device push.
759    #[serde(default)]
760    pub(crate) pushed: Vec<String>,
761    /// Members the relay reached neither way.
762    #[serde(default)]
763    pub(crate) missed: Vec<RelayGroupDeliveryMiss>,
764}
765
766/// One recipient a relay group fan-out could not reach.
767#[derive(Debug, Clone, Deserialize)]
768pub(crate) struct RelayGroupDeliveryMiss {
769    /// The member's identifier as the relay knows it. Wire field name is
770    /// `username` for relay-contract stability, but under `group_delivery_v3`
771    /// the value is the registered member id (an `off1…` address for this
772    /// SDK); only v2 relays put an account username here (see the namespace
773    /// note on [`RelayGroupDeliveryReport`]).
774    pub(crate) username: String,
775    /// Stable machine-readable code (`offline_no_push`,
776    /// `connection_lost_no_push`, `undeliverable`, `already_pushed`,
777    /// `too_large_for_push`, `fanout_deadline`, …). Treated as opaque —
778    /// logged, never matched on — so new relay codes cannot break parsing.
779    #[serde(default)]
780    pub(crate) reason: String,
781}
782
783/// A commit that arrived out-of-order and is waiting to be processed.
784///
785/// In mesh networks, messages can arrive out of order. If a Commit arrives
786/// before a prior Commit, MLS decryption will fail. We buffer it here and
787/// retry after successfully processing a later commit for the same group.
788#[derive(Debug, Clone)]
789pub(crate) struct PendingCommit {
790    /// The original sender of this commit.
791    pub(crate) sender: String,
792    /// The original message ID (already recorded in the dedup table).
793    /// Released when the buffered copy is dropped undelivered by cap
794    /// eviction, so a redelivery is accepted fresh instead of being
795    /// rejected as a replay of a copy that no longer exists anywhere.
796    pub(crate) message_id: String,
797    /// The raw JSON data (after prefix strip) for replay.
798    pub(crate) data: String,
799    /// When this pending commit was first buffered.
800    pub(crate) buffered_at: Instant,
801    /// Number of times this commit has been retried and still failed.
802    /// A commit that expires with `retry_count > 0` is a strong signal
803    /// of epoch mismatch (true fork), not just slow delivery.
804    pub(crate) retry_count: u32,
805}
806
807/// A group application message that arrived before local MLS state could
808/// decrypt it (Welcome not yet processed, or epoch behind the sender's).
809///
810/// The Welcome and the first group message are sent back-to-back and can
811/// arrive out of order across transports. The ciphertext is buffered at
812/// first arrival — the message-ID dedup table keeps rejecting redeliveries,
813/// so this buffered copy is the message's only path to delivery.
814#[derive(Debug, Clone)]
815pub(crate) struct PendingGroupMessage {
816    /// The original wire sender of this message.
817    pub(crate) sender: String,
818    /// The original message ID (already recorded in the dedup table).
819    pub(crate) message_id: String,
820    /// Logical group message id carried in the frame's payload, when the
821    /// frame re-issued a relay broadcast (`GroupMlsMessagePayload::message_id`).
822    /// On delivery the drain emits this id, so every member sees the same
823    /// app-facing id whichever path reached them; the deferred delivery ACK
824    /// is keyed on `message_id` regardless, since that is the envelope the
825    /// sender's ack_manager awaits. A rival copy of the same logical message
826    /// cannot double-deliver: whichever copy decrypts first spends the
827    /// ratchet generation, and the drain drops any later copy of an id it
828    /// already delivered. `None` on the ordinary fan-out and relay paths.
829    pub(crate) logical_id: Option<String>,
830    /// Base64-encoded MLS application ciphertext.
831    pub(crate) ciphertext_b64: String,
832    /// Relay-provided timestamp; `None` for mesh messages (stamped at emit).
833    pub(crate) timestamp: Option<String>,
834    /// Optional ID of the message this one replies to.
835    pub(crate) reply_to: Option<String>,
836    /// Optional forwarding attribution.
837    pub(crate) forward_info: Option<offline_protocol_core::ForwardInfo>,
838    /// When this message was first buffered.
839    pub(crate) buffered_at: Instant,
840    /// Transport the frame arrived on, recorded so the drain can send the
841    /// deferred delivery ACK directly on it once the message finally decrypts
842    /// (see the deferred-ACK atom in CLAUDE.md). `None` for the relay path
843    /// (the relay sender is not ACK-gated) and for transport-less test enqueue
844    /// — in both cases the drain ACK is a correct no-op.
845    pub(crate) received_via: Option<TransportType>,
846}
847
848/// Tracks a leave election where we're waiting for the elected remover to
849/// issue a remove-commit. If the timeout expires and the member is still
850/// in the group, the next eligible member re-elects itself.
851#[derive(Debug, Clone)]
852pub(crate) struct PendingLeaveElection {
853    /// Group the leave is for.
854    pub(crate) group_id: String,
855    /// User ID of the member who is leaving.
856    pub(crate) leaving_member: String,
857    /// When we received the leave notification.
858    pub(crate) received_at: Instant,
859    /// When we last attempted to issue a remove-commit for this election.
860    /// Used as a cooldown to prevent spamming MLS operations on every
861    /// process tick when the current candidate fails repeatedly.
862    pub(crate) last_attempt_at: Option<Instant>,
863}
864
865/// Tracks a suspected epoch fork for a group.
866///
867/// An epoch fork occurs when two members issue concurrent commits at the
868/// same epoch (e.g., two admins adding different members simultaneously).
869/// Members who accepted commit A are on one branch, those who accepted
870/// commit B are on another. Detected when buffered commits expire without
871/// resolution.
872#[derive(Debug, Clone)]
873pub(crate) struct EpochForkState {
874    /// Group with the suspected fork.
875    pub(crate) group_id: String,
876    /// Our local epoch when the fork was detected, or `None` if MLS was unavailable.
877    pub(crate) local_epoch: Option<u64>,
878    /// When the fork was first suspected.
879    pub(crate) detected_at: Instant,
880    /// Whether the leader has already attempted resolution.
881    pub(crate) resolution_attempted: bool,
882}
883
884/// Global limits enforced by [`enforce_global_buffer_bound`], grouped so the
885/// three same-typed caps cannot be transposed at a call site.
886struct GlobalBufferCaps {
887    /// Maximum distinct group IDs in the buffer map.
888    max_groups: usize,
889    /// Maximum buffered entries across all groups.
890    max_entries: usize,
891    /// Maximum buffered payload bytes across all groups.
892    max_bytes: usize,
893}
894
895const PENDING_COMMIT_CAPS: GlobalBufferCaps = GlobalBufferCaps {
896    max_groups: MAX_PENDING_COMMIT_GROUPS,
897    max_entries: MAX_PENDING_COMMITS_TOTAL,
898    max_bytes: MAX_PENDING_COMMIT_TOTAL_BYTES,
899};
900
901const PENDING_GROUP_MESSAGE_CAPS: GlobalBufferCaps = GlobalBufferCaps {
902    max_groups: MAX_PENDING_GROUP_MESSAGE_GROUPS,
903    max_entries: MAX_PENDING_GROUP_MESSAGES_TOTAL,
904    max_bytes: MAX_PENDING_GROUP_MESSAGE_TOTAL_BYTES,
905};
906
907/// Enforces a global bound — entry count, payload bytes, and distinct group
908/// count — across all per-group pending buffers, evicting until an incoming
909/// entry of `incoming_bytes` for `incoming_group` fits. Returns the entries
910/// evicted to make room so callers can release per-entry state (e.g. the
911/// replay-dedup ID that would otherwise keep rejecting a redelivery of the
912/// evicted message), or `None` (evicting nothing) when the incoming entry
913/// alone exceeds the byte budget — the caller must drop it. Without that
914/// guard a single oversized entry would purge every buffered entry across
915/// all groups and then land anyway, blowing the cap it was meant to respect.
916///
917/// Group IDs on these buffers come straight off the wire, and the buffered
918/// case (`GroupNotFound`) is precisely the pre-join, unauthenticated one —
919/// per-group caps alone leave total retention open-ended across
920/// attacker-chosen group IDs.
921///
922/// Freeing a *group slot* (admitting a new group ID at the group cap)
923/// evicts the single largest per-group buffer wholesale: emptying some
924/// group is the only way to free a slot, and doing it one entry at a time
925/// would round-robin across level-sized buffers — purging nearly every
926/// buffered entry map-wide before any single group emptied. Evicting one
927/// group as a unit bounds the damage at one per-group buffer and
928/// preferentially removes a concentrated flood over honest few-entry
929/// groups.
930///
931/// Entry/byte pressure then evicts the oldest entry of the *largest*
932/// remaining buffer (tie-broken by age), so a flood that concentrates in a
933/// few group IDs is evicted before honest welcome-race entries, which are
934/// few per group. A flood that instead spreads one entry per fabricated
935/// group ID levels the buffer sizes and degrades eviction to
936/// globally-oldest; the group cap bounds how wide such a spread can reach
937/// (and the map-key memory it retains), but within it a sustained spread
938/// flood can still push out an honest entry. Callers mitigate that by
939/// releasing the evicted entry's dedup ID, turning the loss into a
940/// redelivery retry instead of a permanent one — full protection would
941/// need sender-level accounting, which none of these pre-join paths have.
942fn enforce_global_buffer_bound<T>(
943    map: &mut HashMap<String, VecDeque<T>>,
944    incoming_group: &str,
945    caps: &GlobalBufferCaps,
946    incoming_bytes: usize,
947    entry_bytes: impl Fn(&T) -> usize,
948    buffered_at: impl Fn(&T) -> Instant,
949) -> Option<Vec<T>> {
950    if incoming_bytes > caps.max_bytes {
951        return None;
952    }
953    let (mut entries, mut bytes) = map
954        .values()
955        .flat_map(|buf| buf.iter())
956        .fold((0usize, 0usize), |(n, b), e| (n + 1, b + entry_bytes(e)));
957    // Each deque is in arrival order, so its front is its oldest entry.
958    // Rank groups by (buffer length, entry age): largest buffer first,
959    // oldest front entry breaking ties.
960    let pick_victim = |map: &HashMap<String, VecDeque<T>>| {
961        map.iter()
962            .filter_map(|(gid, buf)| {
963                buf.front()
964                    .map(|e| ((buf.len(), std::cmp::Reverse(buffered_at(e))), gid))
965            })
966            .max_by_key(|(rank, _)| *rank)
967            .map(|(_, gid)| gid.clone())
968    };
969    let mut all_evicted = Vec::new();
970    while map.len() >= caps.max_groups && !map.contains_key(incoming_group) {
971        let Some(victim_group) = pick_victim(map) else {
972            break;
973        };
974        warn!(
975            group_id = %victim_group,
976            "Pending buffers at group capacity, evicting largest group buffer wholesale"
977        );
978        if let Some(buf) = map.remove(&victim_group) {
979            entries -= buf.len();
980            bytes -= buf.iter().map(&entry_bytes).sum::<usize>();
981            all_evicted.extend(buf);
982        }
983    }
984    while entries >= caps.max_entries || bytes + incoming_bytes > caps.max_bytes {
985        let Some(victim_group) = pick_victim(map) else {
986            break;
987        };
988        warn!(
989            group_id = %victim_group,
990            "Pending buffer at global capacity, evicting oldest entry of largest group buffer"
991        );
992        if let Some(buf) = map.get_mut(&victim_group) {
993            if let Some(evicted) = buf.pop_front() {
994                entries -= 1;
995                bytes -= entry_bytes(&evicted);
996                all_evicted.push(evicted);
997            }
998            if buf.is_empty() {
999                map.remove(&victim_group);
1000            }
1001        }
1002    }
1003    Some(all_evicted)
1004}
1005
1006impl OfflineProtocol {
1007    /// Transport-less convenience wrapper over [`Self::handle_group_mls_msg_via`],
1008    /// used only by tests; production always knows the arrival transport.
1009    #[cfg(test)]
1010    pub(crate) fn handle_group_mls_msg(
1011        &mut self,
1012        message: &Message,
1013        sender: &str,
1014        data: &str,
1015    ) -> InternalMessageResult {
1016        self.handle_group_mls_msg_via(message, sender, data, None)
1017    }
1018
1019    /// Handles an incoming MLS-encrypted group message, with the transport the
1020    /// frame arrived on.
1021    ///
1022    /// Returns [`InternalMessageResult::SecurityRejected`] when the wire
1023    /// sender does not match the MLS-authenticated sender (SEC-M1), so the
1024    /// caller suppresses the delivery ACK exactly like the `__MLS_ENC__`
1025    /// path; a genuine crypto/parse failure consumes the message.
1026    ///
1027    /// Deferred-ACK atom (mesh-group analog of the DM/media fix, PR #223): a
1028    /// message buffered because local group state is not ready yet returns
1029    /// [`InternalMessageResult::Deferred`], so the receive loop skips the
1030    /// delivery ACK and unmarks the transport-level dedup — keeping the
1031    /// sender's per-member `ack_manager` retransmitting until the message is
1032    /// actually delivered on drain (`drain_pending_group_messages` sends the
1033    /// deferred ACK on `received_via`). Without this the buffered-but-ACKed
1034    /// copy could be evicted/expired before a commit drained it and be lost,
1035    /// even though the group-level `release_replay_protection` already clears
1036    /// dedup on drop. The group-level `message_dedup` stays marked across the
1037    /// pending lifetime (replay-amplification defense + authoritative
1038    /// double-delivery guard), so — unlike the DM path — the drain does not
1039    /// re-mark the transport dedup.
1040    pub(crate) fn handle_group_mls_msg_via(
1041        &mut self,
1042        message: &Message,
1043        sender: &str,
1044        data: &str,
1045        arrival_transport: Option<TransportType>,
1046    ) -> InternalMessageResult {
1047        let payload = match serde_json::from_str::<GroupMlsMessagePayload>(data) {
1048            Ok(p) => p,
1049            Err(_) => {
1050                warn!(sender = %sender, "Failed to parse GroupMlsMessage payload");
1051                return InternalMessageResult::Consumed;
1052            }
1053        };
1054
1055        // Dedup check using the unique message ID. When the payload carries a
1056        // logical id (a re-issued relay broadcast), the same logical message
1057        // may already have arrived via the relay path under that id — check
1058        // both keys so a cross-path duplicate is absorbed here instead of
1059        // burning an MLS decrypt on a ciphertext whose ratchet generation was
1060        // already consumed (which would classify Retriable and buffer noise).
1061        let dedup_key = message.id.as_str().to_string();
1062        let logical_key = payload.message_id.clone();
1063        let seen = self.group_mesh.message_dedup.contains_key(&dedup_key)
1064            || logical_key
1065                .as_deref()
1066                .is_some_and(|l| self.group_mesh.message_dedup.contains_key(l));
1067        if seen {
1068            // Already seen. If the buffered copy is still awaiting decryption,
1069            // this is a sender retransmit of an undelivered message: defer (no
1070            // ACK) so the sender keeps retrying until we actually deliver on
1071            // drain. Returning before decrypt also preserves the
1072            // replay-amplification defense (one MLS crypto op per id). If the
1073            // id is not buffered, it was already delivered (a dropped-and-
1074            // released id would not be in the dedup table at all), so treat it
1075            // as a normal duplicate and re-ACK to let the sender stop.
1076            let pending = self.is_group_message_pending(&payload.group_id, &dedup_key)
1077                || logical_key
1078                    .as_deref()
1079                    .is_some_and(|l| self.is_group_message_pending(&payload.group_id, l));
1080            if pending {
1081                debug!(
1082                    group_id = %payload.group_id,
1083                    msg_id = %dedup_key,
1084                    "Duplicate of a still-pending group message, deferring ACK"
1085                );
1086                return InternalMessageResult::Deferred;
1087            }
1088            debug!(
1089                group_id = %payload.group_id,
1090                msg_id = %dedup_key,
1091                "Duplicate group message, skipping"
1092            );
1093            return InternalMessageResult::Consumed;
1094        }
1095
1096        // Mark as seen BEFORE attempting decode/decrypt to prevent replay
1097        // amplification: an adversary replaying the same message (even with a bad
1098        // epoch) should only trigger one MLS crypto operation. Only the envelope
1099        // id is marked here — the payload's logical id is unauthenticated wire
1100        // input until the ciphertext MLS-decrypts, and marking it pre-decrypt
1101        // would let a non-member poison a logical id to suppress the genuine
1102        // copy. It is marked below, on successful decrypt only.
1103        self.group_mesh
1104            .message_dedup
1105            .insert(dedup_key, Instant::now());
1106        if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
1107            self.cleanup_group_message_dedup();
1108        }
1109
1110        // Size guard before base64 decode
1111        let ciphertext_bytes = match base64_decode(&payload.ciphertext) {
1112            Ok(bytes) => bytes,
1113            Err(e) => {
1114                warn!(group_id = %payload.group_id, error = %e, "Failed to decode group message ciphertext");
1115                return InternalMessageResult::Consumed;
1116            }
1117        };
1118
1119        match self.decrypt_group_application(&payload.group_id, ciphertext_bytes, sender) {
1120            GroupDecryptOutcome::Plaintext(plaintext) => {
1121                let Ok(text) = String::from_utf8(plaintext) else {
1122                    warn!(
1123                        group_id = %payload.group_id,
1124                        "Decrypted group payload is not valid UTF-8, dropping"
1125                    );
1126                    return InternalMessageResult::Consumed;
1127                };
1128                // Decryption authenticated the sender as a group member, so
1129                // the payload's logical id (if any) can now be trusted: mark
1130                // it delivered so the relay path's copy of the same logical
1131                // message dedups against this one, and emit it as the
1132                // app-facing id so every member sees the same id for this
1133                // logical message regardless of arrival path.
1134                let msg_id = logical_key.unwrap_or_else(|| message.id.as_str().to_string());
1135                if msg_id != message.id.as_str() {
1136                    self.group_mesh
1137                        .message_dedup
1138                        .insert(msg_id.clone(), Instant::now());
1139                }
1140                let timestamp = chrono::Utc::now().to_rfc3339();
1141                info!(group_id = %payload.group_id, "Decrypted mesh group message");
1142                let (content, media_metadata, content_type, forward_info_event) =
1143                    Self::restore_group_rich(text, payload.forward_info, sender);
1144                self.emit_event(Event::group_message_received(
1145                    payload.group_id,
1146                    sender.to_string(),
1147                    content,
1148                    timestamp,
1149                    msg_id,
1150                    payload.reply_to,
1151                    forward_info_event,
1152                    media_metadata,
1153                    content_type,
1154                ));
1155                InternalMessageResult::Consumed
1156            }
1157            GroupDecryptOutcome::SecurityRejected => InternalMessageResult::SecurityRejected,
1158            GroupDecryptOutcome::Retriable => {
1159                let group_id = payload.group_id.clone();
1160                self.buffer_pending_group_message(
1161                    &group_id,
1162                    PendingGroupMessage {
1163                        sender: sender.to_string(),
1164                        message_id: message.id.as_str().to_string(),
1165                        logical_id: logical_key,
1166                        ciphertext_b64: payload.ciphertext,
1167                        timestamp: None,
1168                        reply_to: payload.reply_to,
1169                        forward_info: payload.forward_info,
1170                        buffered_at: Instant::now(),
1171                        received_via: arrival_transport,
1172                    },
1173                );
1174                // Buffered, not delivered: defer the ACK so the sender keeps
1175                // retransmitting until the drain surfaces it. The drain then
1176                // ACKs directly on `arrival_transport`.
1177                InternalMessageResult::Deferred
1178            }
1179            GroupDecryptOutcome::NonApplication => {
1180                // MLS consumed a commit or proposal riding the application
1181                // channel — group state may have advanced, which can unblock
1182                // buffered commits and messages exactly like a commit-channel
1183                // success.
1184                self.drain_pending_commits(&payload.group_id);
1185                self.drain_pending_group_messages(&payload.group_id);
1186                InternalMessageResult::Consumed
1187            }
1188            GroupDecryptOutcome::PolicyRejected {
1189                committer,
1190                added,
1191                removed,
1192            } => {
1193                // A commit reframed onto the application channel, refused by
1194                // enforcement before merging. Consume it: the rejection is
1195                // permanent, so buffering would retry a ciphertext that can
1196                // never be authorized.
1197                self.report_unauthorized_membership_change(
1198                    &payload.group_id,
1199                    &committer,
1200                    &added,
1201                    &removed,
1202                    "sender_not_admin",
1203                    true,
1204                );
1205                InternalMessageResult::Consumed
1206            }
1207            // The mesh channel carries MLS ciphertext by protocol, so a
1208            // payload that is not MLS framing is garbage, not legacy content.
1209            GroupDecryptOutcome::NotMlsCiphertext | GroupDecryptOutcome::Failed => {
1210                InternalMessageResult::Consumed
1211            }
1212        }
1213    }
1214
1215    /// Decrypts a group application ciphertext, classifying the failure
1216    /// modes shared by the mesh (`handle_group_mls_msg`), relay
1217    /// (`handle_relay_group_message_with_mls`), and deferred-retry
1218    /// (`drain_pending_group_messages`) inbound paths.
1219    fn decrypt_group_application(
1220        &self,
1221        group_id: &str,
1222        ciphertext: Vec<u8>,
1223        sender: &str,
1224    ) -> GroupDecryptOutcome {
1225        let mls_guard = match self.read_mls_guard() {
1226            Ok(guard) => guard,
1227            Err(e) => {
1228                warn!(group_id = %group_id, error = %e, "MLS unavailable, dropping group message");
1229                return GroupDecryptOutcome::Failed;
1230            }
1231        };
1232        let gid = match offline_protocol_mls::GroupId::new(group_id) {
1233            Ok(gid) => gid,
1234            Err(e) => {
1235                warn!(group_id = %group_id, error = %e, "Dropping group message with invalid group id");
1236                return GroupDecryptOutcome::Failed;
1237            }
1238        };
1239        // The epoch field is not used for decryption — OpenMLS determines
1240        // the epoch from the ciphertext header itself.
1241        let encrypted = offline_protocol_mls::EncryptedMessage {
1242            group_id: gid,
1243            message_type: offline_protocol_mls::MlsMessageType::Application,
1244            epoch: 0,
1245            ciphertext,
1246            sender_id: sender.to_string(),
1247            timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
1248        };
1249        match mls_guard.decrypt_from_group(&encrypted, sender) {
1250            Ok(Some(plaintext)) => GroupDecryptOutcome::Plaintext(plaintext),
1251            Ok(None) => {
1252                // Ok(None) means MLS consumed a Commit or Proposal — not application
1253                // data. This is normal for non-application messages that arrive via the
1254                // group message channel (e.g., due to message reordering).
1255                debug!(
1256                    group_id = %group_id,
1257                    "MLS returned no plaintext (commit/proposal consumed), not an application message"
1258                );
1259                GroupDecryptOutcome::NonApplication
1260            }
1261            Err(offline_protocol_mls::MlsError::SenderIdentityMismatch {
1262                claimed,
1263                authenticated,
1264            }) => {
1265                error!(
1266                    group_id = %group_id,
1267                    claimed = %claimed,
1268                    authenticated = %authenticated,
1269                    "SECURITY: wire sender does not match MLS-authenticated sender, rejecting group message"
1270                );
1271                GroupDecryptOutcome::SecurityRejected
1272            }
1273            // The same class as SEC-M1 and the same disposition: a permanent
1274            // refusal to attribute this frame to anyone. A leaf whose key does
1275            // not hash to its own credential can never become legitimate, so
1276            // this must not be buffered — and it must withhold the ACK and
1277            // unmark the id, which is what `SecurityRejected` already carries.
1278            Err(e @ offline_protocol_mls::MlsError::LeafAddressMismatch { .. }) => {
1279                error!(
1280                    group_id = %group_id,
1281                    sender = %sender,
1282                    error = %e,
1283                    "SECURITY: group leaf does not prove the identity it claims, rejecting group message"
1284                );
1285                GroupDecryptOutcome::SecurityRejected
1286            }
1287            Err(e @ offline_protocol_mls::MlsError::UnsupportedSender { .. }) => {
1288                error!(
1289                    group_id = %group_id,
1290                    sender = %sender,
1291                    error = %e,
1292                    "SECURITY: rejecting a group message from an unsupported sender role"
1293                );
1294                GroupDecryptOutcome::SecurityRejected
1295            }
1296            Err(offline_protocol_mls::MlsError::CommitNotAuthorized {
1297                committer,
1298                added,
1299                removed,
1300            }) => GroupDecryptOutcome::PolicyRejected {
1301                committer,
1302                added,
1303                removed,
1304            },
1305            Err(e @ offline_protocol_mls::MlsError::GroupNotFound(_)) => {
1306                // No local group state yet. Only buffer what is actually MLS
1307                // wire framing — a racing ciphertext parses, legacy relay
1308                // plaintext that happens to be valid base64 does not.
1309                // (`Decryption` failures below skip this check: decryption
1310                // only runs after framing already parsed.)
1311                if offline_protocol_mls::is_mls_framed(&encrypted.ciphertext) {
1312                    warn!(
1313                        group_id = %group_id,
1314                        error = %e,
1315                        "Failed to decrypt group message (may be out-of-order, buffering)"
1316                    );
1317                    GroupDecryptOutcome::Retriable
1318                } else {
1319                    debug!(
1320                        group_id = %group_id,
1321                        "Group message payload is not MLS framing and no local group state exists"
1322                    );
1323                    GroupDecryptOutcome::NotMlsCiphertext
1324                }
1325            }
1326            Err(
1327                e @ (offline_protocol_mls::MlsError::Decryption(_)
1328                | offline_protocol_mls::MlsError::SessionDesync(_)),
1329            ) => {
1330                // Epoch-lagged ciphertext — may decrypt once a commit
1331                // advances local group state. `SessionDesync` is the same
1332                // epoch-mismatch condition surfaced with a distinct variant for
1333                // the 1:1 re-key path; on the group path it buffers exactly as
1334                // `Decryption` always has (the group's own recovery is the
1335                // commit-driven drain, not a re-key).
1336                warn!(
1337                    group_id = %group_id,
1338                    error = %e,
1339                    "Failed to decrypt group message (may be out-of-order, buffering)"
1340                );
1341                GroupDecryptOutcome::Retriable
1342            }
1343            Err(e) => {
1344                warn!(group_id = %group_id, error = %e, "Failed to decrypt group message");
1345                GroupDecryptOutcome::Failed
1346            }
1347        }
1348    }
1349
1350    /// Handles an incoming MLS Welcome message (group invite).
1351    pub(crate) fn handle_group_mls_welcome(&mut self, message_id: &str, sender: &str, data: &str) {
1352        let payload = match serde_json::from_str::<GroupMlsWelcomePayload>(data) {
1353            Ok(p) => p,
1354            Err(_) => {
1355                warn!(sender = %sender, "Failed to parse GroupMlsWelcome payload");
1356                return;
1357            }
1358        };
1359
1360        info!(group_id = %payload.group_id, "Received mesh group Welcome");
1361
1362        // Dedup check — prevents double-join when the same Welcome arrives via
1363        // multiple transports nearly simultaneously (TOCTOU race on members cache).
1364        let dedup_key = message_id.to_string();
1365        if self.group_mesh.message_dedup.contains_key(&dedup_key) {
1366            debug!(group_id = %payload.group_id, msg_id = %dedup_key, "Duplicate Welcome message, skipping");
1367            return;
1368        }
1369        self.group_mesh
1370            .message_dedup
1371            .insert(dedup_key, Instant::now());
1372        if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
1373            self.cleanup_group_message_dedup();
1374        }
1375
1376        // Skip duplicate Welcome — we're already a member
1377        if self.group_mesh.members.contains_key(&payload.group_id) {
1378            debug!(group_id = %payload.group_id, "Ignoring duplicate Welcome — already a member of this group");
1379            return;
1380        }
1381
1382        let welcome_bytes = match base64_decode(&payload.welcome_data) {
1383            Ok(bytes) => bytes,
1384            Err(e) => {
1385                warn!(group_id = %payload.group_id, error = %e, "Failed to decode welcome data");
1386                return;
1387            }
1388        };
1389
1390        // Join group via MLS, then update cache
1391        let mls_guard = match self.read_mls_guard() {
1392            Ok(guard) => guard,
1393            Err(e) => {
1394                warn!(group_id = %payload.group_id, error = %e, "MLS unavailable, dropping group welcome");
1395                return;
1396            }
1397        };
1398        let gid = match offline_protocol_mls::GroupId::new(&payload.group_id) {
1399            Ok(gid) => gid,
1400            Err(e) => {
1401                warn!(group_id = %payload.group_id, error = %e, "Dropping group welcome with invalid group id");
1402                return;
1403            }
1404        };
1405        let welcome = offline_protocol_mls::WelcomeMessage {
1406            group_id: gid.clone(),
1407            welcome_data: welcome_bytes,
1408            inviter_id: sender.to_string(),
1409            group_name: payload.group_name.clone(),
1410            timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
1411        };
1412        // Collected under the MLS guard, emitted after it drops: `emit_event`
1413        // takes the shared state, and holding both is the lock order this
1414        // module avoids everywhere else.
1415        let mut unproven_leaf_invite = false;
1416        let join_result = match mls_guard.join_group(&welcome) {
1417            Ok(group_info) => Some(group_info.members.clone()),
1418            Err(e) => {
1419                warn!(group_id = %payload.group_id, error = %e, "Failed to join mesh group");
1420                // A forged ratchet tree is the one join failure that is an
1421                // accusation rather than a fault: the inviter built a leaf
1422                // around an identity they cannot prove. Everything else here
1423                // (a spent key package, a storage error) is noise, so only
1424                // this class is surfaced.
1425                if matches!(
1426                    e,
1427                    offline_protocol_mls::MlsError::LeafAddressMismatch { .. }
1428                        | offline_protocol_mls::MlsError::UnsupportedSender { .. }
1429                ) {
1430                    unproven_leaf_invite = true;
1431                }
1432                None
1433            }
1434        };
1435        drop(mls_guard);
1436
1437        if unproven_leaf_invite {
1438            let group_id = payload.group_id.clone();
1439            self.report_unproven_leaf(
1440                &group_id,
1441                sender,
1442                UnprovenLeafSite::WelcomeDeclined,
1443                // Identifier-free for the same reason as the commit site: a
1444                // `SecurityWarning`'s `reason` is not scrubbed, and the error
1445                // names the impersonated address. The detail stays in the log.
1446                "Group invite declined: it carried an identity claim this device could not \
1447                 verify, so messages in this group could not be reliably attributed"
1448                    .to_string(),
1449            );
1450        }
1451
1452        if let Some(members) = join_result {
1453            let group_id = payload.group_id.clone();
1454
1455            // Record the inviter's rich-capability attestations for members
1456            // we have never (and may never) directly exchange key packages
1457            // with — without them every rich send into this group drops its
1458            // extras. Bounded to the authoritative MLS roster we just
1459            // joined: entries for non-members are attacker-suppliable dead
1460            // weight and are ignored. `record_attested_rich` skips self,
1461            // non-V1 lists, and peers we already know directly.
1462            for (user_id, versions) in &payload.member_rich {
1463                if members.contains(user_id) {
1464                    self.record_attested_rich(user_id, versions);
1465                }
1466            }
1467
1468            self.group_mesh.members.insert(group_id.clone(), members);
1469
1470            // Store member roles and the creator of record from the welcome
1471            // payload. The creator is what makes `check_is_admin`'s fallback
1472            // reachable for a joiner: without it our metadata is materialized
1473            // by `set_member_role` via `GroupMetadata::new(None)`, so a group
1474            // whose role snapshot arrived incomplete would resolve to
1475            // deny-by-default with no way back. Deliberately not bounded to
1476            // the roster we just joined — the creator of record may already
1477            // have left. `set_group_creator` is first-write-wins, so a
1478            // duplicate or later Welcome cannot rewrite it.
1479            if !payload.member_roles.is_empty() || payload.created_by.is_some() {
1480                if let Ok(mls_guard) = self.read_mls_guard() {
1481                    for (user_id, role) in &payload.member_roles {
1482                        if let Err(e) = mls_guard.set_member_role(&gid, user_id, *role) {
1483                            warn!(user_id = %user_id, error = %e, "Failed to store member role from welcome");
1484                        }
1485                    }
1486                    if let Some(creator) = &payload.created_by {
1487                        if let Err(e) = mls_guard.set_group_creator(&gid, creator) {
1488                            warn!(creator = %creator, error = %e, "Failed to store group creator from welcome");
1489                        }
1490                    }
1491                }
1492            }
1493
1494            // Send a fresh key package to the inviter. The inviter consumed our
1495            // previous key package to create this Welcome, so they need a new one
1496            // for future group invites. Clear the tracking flag first so the send
1497            // isn't suppressed as a duplicate.
1498            self.key_package_sent_to.remove(sender);
1499            if let Err(e) = self.send_key_package_to(sender, false) {
1500                debug!(
1501                    inviter = %sender,
1502                    error = %e,
1503                    "Failed to send fresh key package to inviter after Welcome (will retry on discovery)"
1504                );
1505            }
1506
1507            self.emit_event(Event::group_member_added(
1508                group_id.clone(),
1509                self.local_id.clone(),
1510                sender.to_string(),
1511                payload.group_name.clone(),
1512                // Not evaluated: this Welcome is what creates our view of the
1513                // group, so there is no prior role state to judge the inviter
1514                // against. `Some(true)` here would claim a check that cannot
1515                // exist on this path.
1516                None,
1517            ));
1518
1519            // A commit or group message can race ahead of its Welcome (they
1520            // are sent back-to-back and may take different paths); now that
1521            // the group exists locally, retry anything that was buffered.
1522            // Commits first — an epoch-advancing commit may be exactly what
1523            // a buffered message is waiting for.
1524            self.drain_pending_commits(&group_id);
1525            self.drain_pending_group_messages(&group_id);
1526        }
1527    }
1528
1529    /// Handles an incoming MLS Commit message (membership change).
1530    ///
1531    /// Validates the `affected_member` claim against actual MLS state delta
1532    /// to prevent forged membership events.
1533    ///
1534    /// If decryption fails (e.g., due to out-of-order delivery in a mesh network),
1535    /// the commit is buffered for deferred retry. When a subsequent commit for the
1536    /// same group succeeds, buffered commits are drained and retried.
1537    pub(crate) fn handle_group_mls_commit(&mut self, message_id: &str, sender: &str, data: &str) {
1538        // Dedup check — prevents processing the same commit twice when it
1539        // arrives via multiple transports. Without this, the second copy fails
1540        // at MLS (wrong epoch) and gets buffered as a "retriable" pending
1541        // commit, wasting space and potentially triggering false fork detection.
1542        let dedup_key = message_id.to_string();
1543        if self.group_mesh.message_dedup.contains_key(&dedup_key) {
1544            debug!(msg_id = %dedup_key, "Duplicate commit message, skipping");
1545            return;
1546        }
1547        self.group_mesh
1548            .message_dedup
1549            .insert(dedup_key, Instant::now());
1550        if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
1551            self.cleanup_group_message_dedup();
1552        }
1553
1554        match self.process_commit_core(sender, data) {
1555            CommitOutcome::Success(group_id) => {
1556                self.drain_pending_commits(&group_id);
1557                // A commit that advanced the epoch may unblock buffered
1558                // application messages encrypted at the newer epoch.
1559                self.drain_pending_group_messages(&group_id);
1560            }
1561            CommitOutcome::Retriable(group_id) => {
1562                self.buffer_pending_commit(&group_id, message_id, sender, data);
1563            }
1564            CommitOutcome::Rejected => {}
1565        }
1566    }
1567
1568    /// Core commit processing logic. Returns an outcome indicating success,
1569    /// retriable failure, or permanent rejection.
1570    ///
1571    /// Does **not** buffer on failure — callers decide whether to buffer based
1572    /// on the returned outcome.
1573    fn process_commit_core(&mut self, sender: &str, data: &str) -> CommitOutcome {
1574        let payload = match serde_json::from_str::<GroupMlsCommitPayload>(data) {
1575            Ok(p) => p,
1576            Err(_) => {
1577                warn!(sender = %sender, "Failed to parse GroupMlsCommit payload");
1578                return CommitOutcome::Rejected;
1579            }
1580        };
1581
1582        info!(
1583            group_id = %payload.group_id,
1584            commit_type = ?payload.commit_type,
1585            "Received mesh group Commit"
1586        );
1587
1588        if payload.ciphertext.is_empty() {
1589            warn!(
1590                group_id = %payload.group_id,
1591                "Received Commit with empty ciphertext, cannot advance MLS epoch"
1592            );
1593            return CommitOutcome::Rejected;
1594        }
1595
1596        let ciphertext_bytes = match base64_decode(&payload.ciphertext) {
1597            Ok(bytes) => bytes,
1598            Err(e) => {
1599                warn!(group_id = %payload.group_id, error = %e, "Failed to decode commit ciphertext");
1600                return CommitOutcome::Rejected;
1601            }
1602        };
1603
1604        let mls_guard = match self.read_mls_guard() {
1605            Ok(guard) => guard,
1606            Err(e) => {
1607                warn!(group_id = %payload.group_id, error = %e, "MLS unavailable, dropping group commit");
1608                return CommitOutcome::Rejected;
1609            }
1610        };
1611
1612        let gid = match offline_protocol_mls::GroupId::new(&payload.group_id) {
1613            Ok(gid) => gid,
1614            Err(e) => {
1615                warn!(group_id = %payload.group_id, error = %e, "Rejecting group commit with invalid group id");
1616                return CommitOutcome::Rejected;
1617            }
1618        };
1619
1620        // Capture members before commit for delta validation. `None` marks a
1621        // failed read: this and the post-commit refresh below are the two
1622        // inputs to the membership delta, and a transient storage failure
1623        // (`MlsStorage` is a platform keychain/keystore) silently defaulting
1624        // to an empty roster would fabricate a full-roster delta — and, with
1625        // it, a security report naming an innocent committer as having added
1626        // every member. `Ok(None)` (no local group yet) stays an empty
1627        // roster: past a successful decrypt it is unreachable anyway.
1628        //
1629        // Deliberately MLS-derived, not the `group_mesh.members` cache: relay
1630        // reconciliation can splice ids into the cache that MLS never
1631        // admitted, and a cache-derived "before" would report such a phantom
1632        // as an unauthorized removal on the next real commit.
1633        let members_before: Option<HashSet<String>> = match mls_guard.get_group_info(&gid) {
1634            Ok(info) => Some(
1635                info.map(|i| i.members.into_iter().collect())
1636                    .unwrap_or_default(),
1637            ),
1638            Err(e) => {
1639                warn!(
1640                    group_id = %payload.group_id,
1641                    error = %e,
1642                    "Failed to read pre-commit roster — this commit's membership delta will not be derived or judged"
1643                );
1644                None
1645            }
1646        };
1647
1648        // Process Commit via MLS to advance epoch (single lock acquisition)
1649        let encrypted = offline_protocol_mls::EncryptedMessage {
1650            group_id: gid.clone(),
1651            message_type: offline_protocol_mls::MlsMessageType::Commit,
1652            epoch: payload.epoch,
1653            ciphertext: ciphertext_bytes,
1654            sender_id: sender.to_string(),
1655            timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
1656        };
1657        let mls_result = mls_guard.decrypt_from_group(&encrypted, sender);
1658        drop(mls_guard);
1659
1660        match mls_result {
1661            Ok(_) => {}
1662            Err(ref e) => {
1663                // If this is a Remove commit targeting us, we can't decrypt it
1664                // because the admin already removed us from the MLS group. But the
1665                // commit payload metadata tells us we were removed, so handle it
1666                // gracefully: emit the event, clean up local state, and reject the
1667                // commit (don't buffer it — it will never succeed).
1668                //
1669                // SECURITY: Verify the sender is an admin before trusting the
1670                // unencrypted commit metadata. Without this check, a non-admin
1671                // group member could forge a commit with garbage ciphertext and
1672                // affected_member = victim to force group eviction.
1673                let self_id = self.local_id.clone();
1674                if matches!(payload.commit_type, GroupCommitType::Remove)
1675                    && payload.affected_member.as_deref() == Some(&self_id)
1676                    && self
1677                        .check_is_admin(&payload.group_id, sender)
1678                        .unwrap_or(false)
1679                {
1680                    info!(
1681                        group_id = %payload.group_id,
1682                        "Received remove-commit targeting us — cleaning up local state"
1683                    );
1684                    // Clean up local MLS state
1685                    if let Ok(mls_guard) = self.read_mls_guard() {
1686                        let _ = mls_guard.leave_group(&gid);
1687                    }
1688                    self.group_mesh.members.remove(&payload.group_id);
1689                    let was_synced = self.group_mesh.relay_synced.remove(&payload.group_id);
1690                    let was_pending = self
1691                        .group_mesh
1692                        .relay_register_pending
1693                        .remove(&payload.group_id)
1694                        .is_some();
1695                    if was_synced || was_pending {
1696                        self.emit_event(Event::group_relay_sync_changed(
1697                            payload.group_id.clone(),
1698                            false,
1699                            "removed",
1700                        ));
1701                    }
1702                    self.group_mesh
1703                        .pending_group_messages
1704                        .remove(&payload.group_id);
1705                    // Also drop buffered commits: they can never apply now,
1706                    // and a stale one retried after a rapid re-invite would
1707                    // expire with retry_count > 0 and falsely flag an epoch
1708                    // fork.
1709                    self.group_mesh.pending_commits.remove(&payload.group_id);
1710                    self.emit_event(Event::group_member_removed(
1711                        payload.group_id.clone(),
1712                        self_id,
1713                        sender.to_string(),
1714                        // Reached only through the `check_is_admin` gate above.
1715                        Some(true),
1716                    ));
1717                    return CommitOutcome::Rejected;
1718                }
1719
1720                // Enforcement refused this commit before merging (only
1721                // possible with `GroupConfig::enforce_admin_commits` on).
1722                // Report it on the same signal the applied case uses, marked
1723                // `enforced` so the app can tell "this happened, undo it"
1724                // from "this was blocked and we are now behind the group's
1725                // epoch". Nothing merged, so no roster event accompanies it.
1726                if let offline_protocol_mls::MlsError::CommitNotAuthorized {
1727                    committer,
1728                    added,
1729                    removed,
1730                } = e
1731                {
1732                    let (committer, added, removed) =
1733                        (committer.clone(), added.clone(), removed.clone());
1734                    self.report_unauthorized_membership_change(
1735                        &payload.group_id,
1736                        &committer,
1737                        &added,
1738                        &removed,
1739                        "sender_not_admin",
1740                        true,
1741                    );
1742                    return CommitOutcome::Rejected;
1743                }
1744
1745                // Classify MLS errors: permanent failures should not be retried,
1746                // only failures caused by lagging local group state are worth
1747                // buffering. GroupNotFound is NOT permanent — a commit can
1748                // outrun our Welcome (they may take different transports), so
1749                // it is buffered and retried after the join lands, exactly
1750                // like application messages. A spoofed sender (SEC-M1) is
1751                // permanent — retrying the same forged commit can never
1752                // succeed.
1753                //
1754                // The leaf-binding refusals are permanent for the same reason,
1755                // and getting this wrong is worse here than merely wasteful. A
1756                // leaf whose key does not hash to its own credential can never
1757                // start doing so, and an unsupported sender role never becomes
1758                // supported, so every retry re-runs a full MLS `process_message`
1759                // on a frame with no reachable success. Worse, a buffered commit
1760                // that expires with `retry_count > 0` is read as an epoch fork
1761                // (`drain_pending_commits`), which emits a false
1762                // `group_epoch_fork_detected` and has the elected leader fan an
1763                // `update_keys` commit out to the whole group — so one forged
1764                // commit, from any member, would buy an attacker a group-wide
1765                // re-key round and a bogus security alarm. Buffering also spends
1766                // one of `MAX_PENDING_COMMITS_PER_GROUP` slots, evicting
1767                // legitimate out-of-order commits.
1768                let is_permanent = matches!(
1769                    e,
1770                    offline_protocol_mls::MlsError::Deserialization(_)
1771                        | offline_protocol_mls::MlsError::InvalidMessage(_)
1772                        | offline_protocol_mls::MlsError::Storage(_)
1773                        | offline_protocol_mls::MlsError::SenderIdentityMismatch { .. }
1774                        | offline_protocol_mls::MlsError::LeafAddressMismatch { .. }
1775                        | offline_protocol_mls::MlsError::UnsupportedSender { .. }
1776                );
1777                if is_permanent {
1778                    error!(
1779                        group_id = %payload.group_id,
1780                        epoch = payload.epoch,
1781                        error = %e,
1782                        "Permanently failed to process group commit (not retriable)"
1783                    );
1784                    // The commit half of `GroupLeafIdentityUnproven`. This is
1785                    // the more serious of the two sites the code emits it from:
1786                    // a Welcome refusal declines an invite from a stranger, but
1787                    // this accuses a member of a group the user is already in.
1788                    // `sender` is the peer that delivered the commit and it is
1789                    // proved — `__GRP_MLS_COMMIT__` is security-gated, so the
1790                    // sender signed with the key its own address derives from
1791                    // — while the identity the forged leaf claimed stays in the
1792                    // diagnostic text.
1793                    if matches!(
1794                        e,
1795                        offline_protocol_mls::MlsError::LeafAddressMismatch { .. }
1796                            | offline_protocol_mls::MlsError::UnsupportedSender { .. }
1797                    ) {
1798                        // Variant-neutral summary: the `UnsupportedSender`
1799                        // half seats no leaf at all (an `ExternalSenders`
1800                        // extension, or a non-member sender role), so the
1801                        // "identity nobody proved" wording would be wrong for
1802                        // it.
1803                        //
1804                        // The error is deliberately NOT interpolated: it names
1805                        // the impersonated address, and the telemetry scrubber
1806                        // hashes only `peer_id` on a `SecurityWarning` — it
1807                        // ships `reason` verbatim. Leaking a third party's
1808                        // address to a sink running `scrub_ids: true` is the
1809                        // exact trap the `convergence_diag` detail rule names.
1810                        // The full error is in the `error!` above.
1811                        let detail = "Group membership change refused: it carried an identity \
1812                                      claim this device could not verify, so messages in this \
1813                                      group could not be reliably attributed"
1814                            .to_string();
1815                        let group_id = payload.group_id.clone();
1816                        self.report_unproven_leaf(
1817                            &group_id,
1818                            sender,
1819                            UnprovenLeafSite::CommitRefused,
1820                            detail,
1821                        );
1822                    }
1823                    return CommitOutcome::Rejected;
1824                }
1825                error!(
1826                    group_id = %payload.group_id,
1827                    epoch = payload.epoch,
1828                    error = %e,
1829                    "Failed to process group commit (may be out-of-order, buffering)"
1830                );
1831                return CommitOutcome::Retriable(payload.group_id);
1832            }
1833        }
1834
1835        // Refresh cache and compute the actual membership delta. Like the
1836        // pre-commit read above, a failed refresh must not default to an
1837        // empty roster — so when either read failed, the delta is unknowable
1838        // and everything derived from it (roster events, the authorization
1839        // judgment, leave-election cleanup, auto-promotion) is skipped for
1840        // this commit rather than fabricated. The commit itself has already
1841        // merged; the member cache and events self-heal on the next
1842        // successful commit or refresh.
1843        let members_after: Option<HashSet<String>> = match self
1844            .refresh_group_members(&payload.group_id)
1845        {
1846            Ok(members) => Some(members.into_iter().collect()),
1847            Err(e) => {
1848                warn!(
1849                    group_id = %payload.group_id,
1850                    error = %e,
1851                    "Failed to refresh post-commit roster — this commit's membership delta will not be derived or judged"
1852                );
1853                None
1854            }
1855        };
1856        let (Some(members_before), Some(members_after)) = (members_before, members_after) else {
1857            if matches!(payload.commit_type, GroupCommitType::KeyUpdate) {
1858                self.group_mesh.epoch_forks.remove(&payload.group_id);
1859            }
1860            return CommitOutcome::Success(payload.group_id);
1861        };
1862
1863        // Sorted so event payloads and log lines are deterministic.
1864        let mut actual_added: Vec<String> =
1865            members_after.difference(&members_before).cloned().collect();
1866        actual_added.sort();
1867        let mut actual_removed: Vec<String> =
1868            members_before.difference(&members_after).cloned().collect();
1869        actual_removed.sort();
1870
1871        let has_membership_change = !actual_added.is_empty() || !actual_removed.is_empty();
1872
1873        // Validate claimed affected_member against actual MLS delta. A
1874        // mismatch means the commit's unencrypted framing disagrees with the
1875        // authenticated MLS state change it actually produced — forged or
1876        // replayed metadata.
1877        let mut claim_matches_delta = true;
1878        if let Some(claimed) = &payload.affected_member {
1879            let valid = match payload.commit_type {
1880                GroupCommitType::Add => actual_added.contains(claimed),
1881                GroupCommitType::Remove => actual_removed.contains(claimed),
1882                GroupCommitType::KeyUpdate => true, // No membership change expected
1883            };
1884            if !valid && has_membership_change {
1885                claim_matches_delta = false;
1886                error!(
1887                    group_id = %payload.group_id,
1888                    sender = %sender,
1889                    claimed = %claimed,
1890                    actual_added = ?actual_added,
1891                    actual_removed = ?actual_removed,
1892                    "SECURITY: Commit affected_member does not match actual MLS state delta — possible forgery"
1893                );
1894            }
1895        }
1896
1897        // No delta, no judgment: a pure KeyUpdate changes no membership and
1898        // needs no admin (fork-resolution KeyUpdates are issued by the
1899        // deterministic leader, who is often not one).
1900        let judgment = if has_membership_change {
1901            Some(self.judge_membership_change(
1902                &payload.group_id,
1903                sender,
1904                &actual_added,
1905                &actual_removed,
1906                claim_matches_delta,
1907            ))
1908        } else {
1909            None
1910        };
1911
1912        // Commit-payload metadata (role, rich attestation) is honored only
1913        // for the member the MLS delta actually added and only from an
1914        // admin sender — adds are admin-only, so a non-admin sender means
1915        // forged metadata on a replayed/crafted frame.
1916        let metadata_sender_is_admin =
1917            judgment.as_ref().is_some_and(|j| j.sender_is_admin) && !actual_added.is_empty();
1918        // What the roster events below carry. `None` never reaches them: an
1919        // empty delta emits no events.
1920        let authorized = judgment.map(|j| j.authorized);
1921
1922        // Emit events based on actual MLS membership changes, not claimed affected_member
1923        for member in &actual_added {
1924            // Store the role from the commit payload only for the specific affected member,
1925            // and only if the sender is an admin (prevents non-admins from injecting elevated roles).
1926            if let (Some(role), Some(affected)) = (&payload.role, &payload.affected_member) {
1927                if member == affected {
1928                    if metadata_sender_is_admin {
1929                        if let Ok(mls_guard) = self.read_mls_guard() {
1930                            if let Err(e) = mls_guard.set_member_role(&gid, member, *role) {
1931                                warn!(user_id = %member, error = %e, "Failed to store member role from commit");
1932                            }
1933                        }
1934                    } else {
1935                        warn!(
1936                            sender = %sender,
1937                            group_id = %payload.group_id,
1938                            "Ignoring role from commit: sender is not admin"
1939                        );
1940                    }
1941                }
1942            }
1943            // Record the inviter's rich-capability attestation for the
1944            // newcomer — we existing members never exchange key packages
1945            // with them, and without this one unknown member drops rich
1946            // extras for the whole group. Same trust bounds as the role
1947            // above; a later direct exchange overrides it.
1948            if let (Some(versions), Some(affected)) =
1949                (&payload.affected_member_rich, &payload.affected_member)
1950            {
1951                if member == affected && metadata_sender_is_admin {
1952                    self.record_attested_rich(member, versions);
1953                }
1954            }
1955            self.emit_event(Event::group_member_added(
1956                payload.group_id.clone(),
1957                member.clone(),
1958                sender.to_string(),
1959                None,
1960                authorized,
1961            ));
1962        }
1963        for member in &actual_removed {
1964            // Clean up role metadata for removed members
1965            if let Ok(mls_guard) = self.read_mls_guard() {
1966                if let Err(e) = mls_guard.remove_member_role(&gid, member) {
1967                    warn!(user_id = %member, error = %e, "Failed to clean up role metadata for removed member");
1968                }
1969            }
1970            self.emit_event(Event::group_member_removed(
1971                payload.group_id.clone(),
1972                member.clone(),
1973                sender.to_string(),
1974                authorized,
1975            ));
1976            // Clear any pending leave election for this member — the remove
1977            // has been committed successfully.
1978            self.group_mesh
1979                .pending_leave_elections
1980                .remove(&(payload.group_id.clone(), member.to_string()));
1981        }
1982
1983        // Auto-promote if removal left zero admins and other members remain
1984        if !actual_removed.is_empty() {
1985            let remaining = self
1986                .group_mesh
1987                .members
1988                .get(&payload.group_id)
1989                .cloned()
1990                .unwrap_or_default();
1991            self.auto_promote_if_no_admin(&payload.group_id, &remaining);
1992        }
1993
1994        // Only clear fork tracking when a KeyUpdate commit succeeds — these
1995        // are the resolution commits issued by the leader. Regular Add/Remove
1996        // commits from the same branch succeeding doesn't prove the fork is
1997        // resolved; members on the other branch are still diverged.
1998        if matches!(payload.commit_type, GroupCommitType::KeyUpdate) {
1999            self.group_mesh.epoch_forks.remove(&payload.group_id);
2000        }
2001
2002        CommitOutcome::Success(payload.group_id)
2003    }
2004
2005    /// Judges one **applied** membership delta against the local admin
2006    /// overlay, reporting an unauthorized change (rate-limited) via
2007    /// `GroupUnauthorizedMembershipChange`.
2008    ///
2009    /// This deliberately does **not** gate the membership change. MLS
2010    /// authorizes membership by MLS membership — RFC 9420 leaves access
2011    /// control to the application — and by the time the delta exists,
2012    /// `decrypt_from_group` has already merged the commit, advancing our
2013    /// epoch irreversibly. The only way to reject would be to refuse the
2014    /// merge, which forks us permanently from every peer that accepted it
2015    /// (see `check_epoch_forks`: members left on a forked branch have to be
2016    /// re-invited by the app, and a fork the local role metadata caused —
2017    /// it is best-effort and can legitimately lag — would be a partition
2018    /// with no attacker involved). An unrecoverable partition is a worse
2019    /// failure than an insider membership change, so the change is applied
2020    /// and reported truthfully.
2021    ///
2022    /// Unlike the commit-metadata gate in `process_commit_core`, this covers
2023    /// removals too. When receive-side enforcement is enabled
2024    /// (`GroupConfig::enforce_admin_commits`) the *rejection* happens earlier,
2025    /// pre-merge in the MLS layer, and never reaches this function — but it
2026    /// reports through the same [`Self::report_unauthorized_membership_change`]
2027    /// so both outcomes surface on one signal.
2028    pub(crate) fn judge_membership_change(
2029        &mut self,
2030        group_id: &str,
2031        sender: &str,
2032        added: &[String],
2033        removed: &[String],
2034        claim_matches_delta: bool,
2035    ) -> MembershipJudgment {
2036        let sender_is_admin = self.check_is_admin(group_id, sender).unwrap_or(false);
2037
2038        // A membership change is authorized only when an admin committed it
2039        // *and* the commit's own framing agrees with the delta it produced.
2040        let authorized = sender_is_admin && claim_matches_delta;
2041
2042        if !authorized {
2043            let reason = if sender_is_admin {
2044                "affected_member_mismatch"
2045            } else {
2046                "sender_not_admin"
2047            };
2048            self.report_unauthorized_membership_change(
2049                group_id, sender, added, removed, reason, false,
2050            );
2051        }
2052
2053        MembershipJudgment {
2054            sender_is_admin,
2055            authorized,
2056        }
2057    }
2058
2059    /// Emits the rate-limited `GroupLeafIdentityUnproven` report.
2060    ///
2061    /// Shared by the three group-side sites that find a leaf which does not
2062    /// derive its own credential: declining a Welcome whose ratchet tree
2063    /// contains one, refusing a commit that would install one, and a roster read
2064    /// skipping one already seated in local state.
2065    ///
2066    /// `peer` is who the finding is attributed to, and it is not the same kind
2067    /// of thing in all three. For the two wire sites it is the peer that
2068    /// delivered the forgery — the inviter or the committing sender — which is
2069    /// proved, both frames being security-gated against the sender's own
2070    /// address. For [`UnprovenLeafSite::RosterEntry`] there is no delivering
2071    /// peer at all: the leaf is already in the tree and no wire gate admitted
2072    /// it, so the caller passes this device's own id, the same attribution
2073    /// `RelayAddressDeclarationRefused` uses when the finding is about this
2074    /// device rather than a peer's behaviour. Apps must therefore read `peer_id`
2075    /// on this code as "who this concerns", not "who to blame".
2076    ///
2077    /// The identity the forged leaf claimed stays in `detail`, which is
2078    /// diagnostic text.
2079    ///
2080    /// Rate-limited per `(group, peer, site)` through the shared
2081    /// [`claim_report_window`], on the same window and bound as
2082    /// [`Self::report_unauthorized_membership_change`]. The commit half makes
2083    /// this necessary rather than tidy: refusals are permanent and cost the
2084    /// sender nothing, so an authenticated member can emit one per frame and
2085    /// bury the signal in its own repetition. `site` is in the key so a
2086    /// declined invite cannot swallow the first refused *commit* — see
2087    /// [`UnprovenLeafSite`].
2088    ///
2089    /// `detail` must stay **identifier-free**: it becomes the event `reason`,
2090    /// which the telemetry scrubber passes through verbatim while hashing only
2091    /// `peer_id`. The impersonated address belongs in the log, not here.
2092    fn report_unproven_leaf(
2093        &mut self,
2094        group_id: &str,
2095        peer: &str,
2096        site: UnprovenLeafSite,
2097        detail: String,
2098    ) -> bool {
2099        let key = (group_id.to_string(), peer.to_string(), site);
2100        if !claim_report_window(&mut self.group_mesh.unproven_leaf_reports, key) {
2101            debug!(
2102                group_id = %group_id,
2103                sender = %peer,
2104                ?site,
2105                "Suppressing repeated unproven-leaf report within the rate-limit window"
2106            );
2107            return false;
2108        }
2109
2110        self.emit_event(Event::security_warning(
2111            peer.to_string(),
2112            crate::events::SecurityWarningCode::GroupLeafIdentityUnproven,
2113            detail,
2114        ));
2115        true
2116    }
2117
2118    /// Emits the rate-limited unauthorized-membership-change report.
2119    ///
2120    /// Shared by both outcomes so apps see one signal regardless of
2121    /// configuration: `enforced == false` means the change was applied and is
2122    /// reported after the fact (the default), `enforced == true` means the
2123    /// commit was refused before merging and no membership change occurred.
2124    ///
2125    /// Rate-limited per `(group, committer, enforced)` because divergent role
2126    /// metadata would otherwise re-fire on every commit from the same peer.
2127    /// `enforced` is part of the key so the two outcomes never suppress each
2128    /// other: a refusal is a partition alarm — the device stopped merging and
2129    /// now trails the group's epoch — and losing the first one of those behind
2130    /// an earlier report-only event would hide the very condition the app is
2131    /// told to act on. Within one outcome class the window still collapses
2132    /// repeats, which is what the limit is for. The tracking map is bounded:
2133    /// lapsed windows are dropped first, then the oldest live entry.
2134    fn report_unauthorized_membership_change(
2135        &mut self,
2136        group_id: &str,
2137        committer: &str,
2138        added: &[String],
2139        removed: &[String],
2140        reason: &str,
2141        enforced: bool,
2142    ) {
2143        let key = (group_id.to_string(), committer.to_string(), enforced);
2144        if !claim_report_window(&mut self.group_mesh.unauthorized_change_reports, key) {
2145            debug!(
2146                group_id = %group_id,
2147                sender = %committer,
2148                reason,
2149                enforced,
2150                "Suppressing repeated unauthorized-membership-change report within the rate-limit window"
2151            );
2152            return;
2153        }
2154        if enforced {
2155            warn!(
2156                group_id = %group_id,
2157                sender = %committer,
2158                reason,
2159                added = ?added,
2160                removed = ?removed,
2161                "SECURITY: Unauthorized group membership change refused — the commit was not merged, so our epoch now trails members that accepted it"
2162            );
2163        } else {
2164            warn!(
2165                group_id = %group_id,
2166                sender = %committer,
2167                reason,
2168                added = ?added,
2169                removed = ?removed,
2170                "SECURITY: Unauthorized group membership change applied — MLS accepted the commit, but the SDK's admin policy did not authorize it"
2171            );
2172        }
2173        self.emit_event(Event::group_unauthorized_membership_change(
2174            group_id.to_string(),
2175            committer.to_string(),
2176            added.to_vec(),
2177            removed.to_vec(),
2178            reason.to_string(),
2179            enforced,
2180        ));
2181    }
2182
2183    /// Releases every replay-protection record for a buffered entry dropped
2184    /// undelivered (cap eviction or TTL expiry), so a sender-side redelivery
2185    /// is accepted fresh instead of being rejected as a replay of a copy
2186    /// that no longer exists anywhere.
2187    ///
2188    /// Two layers must be released. The group-level dedup table rejects
2189    /// redeliveries inside the group handlers; the transport-level
2190    /// deduplicator rejects them earlier, at the receive loop — on the mesh
2191    /// path the envelope ID doubles as the group dedup key, so without this
2192    /// release a redelivery would be swallowed (and re-ACKed as delivered)
2193    /// for up to the deduplicator's retention window without ever reaching
2194    /// the group handlers. Relay-path IDs are relay payload IDs that never
2195    /// enter the transport deduplicator (the platform bridges mint a fresh
2196    /// envelope UUID per injected message), so `from_str` fails or the
2197    /// unmark is a no-op there — both harmless.
2198    pub(crate) fn release_replay_protection(&mut self, message_id: &str) {
2199        self.group_mesh.message_dedup.remove(message_id);
2200        if let Ok(envelope_id) = MessageId::from_str(message_id) {
2201            self.deduplicator.unmark_seen(&envelope_id);
2202        }
2203    }
2204
2205    /// Buffers a failed commit for deferred retry.
2206    ///
2207    /// The commit's message ID is already in the dedup table, so this
2208    /// buffered copy is its only path to processing — whenever a buffered
2209    /// commit is dropped unprocessed (cap eviction here, TTL expiry at
2210    /// drain), its replay protection is released so a redelivery gets a
2211    /// fresh chance instead of being permanently rejected, mirroring
2212    /// `buffer_pending_group_message`.
2213    pub(crate) fn buffer_pending_commit(
2214        &mut self,
2215        group_id: &str,
2216        message_id: &str,
2217        sender: &str,
2218        data: &str,
2219    ) {
2220        let pending = PendingCommit {
2221            sender: sender.to_string(),
2222            message_id: message_id.to_string(),
2223            data: data.to_string(),
2224            buffered_at: Instant::now(),
2225            retry_count: 0,
2226        };
2227        let Some(evicted) = enforce_global_buffer_bound(
2228            &mut self.group_mesh.pending_commits,
2229            group_id,
2230            &PENDING_COMMIT_CAPS,
2231            pending.data.len(),
2232            |c| c.data.len(),
2233            |c| c.buffered_at,
2234        ) else {
2235            // Only the ciphertext field is size-guarded at decode time; the
2236            // JSON around it is attacker-sized, so an oversized payload must
2237            // be dropped rather than allowed to purge the whole buffer.
2238            warn!(
2239                group_id = %group_id,
2240                size = pending.data.len(),
2241                "Pending commit exceeds the global byte budget, dropping"
2242            );
2243            return;
2244        };
2245        // An evicted commit was never processed — release its replay
2246        // protection so a sender-side redelivery is accepted fresh instead
2247        // of being rejected as a replay of a copy that no longer exists.
2248        for entry in &evicted {
2249            self.release_replay_protection(&entry.message_id);
2250        }
2251        let displaced = {
2252            let buf = self
2253                .group_mesh
2254                .pending_commits
2255                .entry(group_id.to_string())
2256                .or_default();
2257            let displaced = if buf.len() >= MAX_PENDING_COMMITS_PER_GROUP {
2258                warn!(
2259                    group_id = %group_id,
2260                    "Pending commit buffer full, dropping oldest"
2261                );
2262                buf.pop_front()
2263            } else {
2264                None
2265            };
2266            buf.push_back(pending);
2267            debug!(
2268                group_id = %group_id,
2269                buffered_count = buf.len(),
2270                "Buffered out-of-order commit for deferred retry"
2271            );
2272            displaced
2273        };
2274        if let Some(displaced) = displaced {
2275            self.release_replay_protection(&displaced.message_id);
2276        }
2277    }
2278
2279    /// Drains and retries buffered pending commits for a group after a
2280    /// successful commit advanced the epoch. Each successful retry triggers
2281    /// another drain pass (at most `MAX_PENDING_COMMITS_PER_GROUP` iterations).
2282    ///
2283    /// Call-site contract: a commit success inside this drain can unblock
2284    /// buffered application messages, but this function does not drain them —
2285    /// every caller must follow with `drain_pending_group_messages`.
2286    ///
2287    /// Uses `process_commit_core` (not `handle_group_mls_commit`) to avoid
2288    /// double-buffering: this method owns the retry lifecycle and re-buffers
2289    /// only via `still_pending`, never through the core processing path.
2290    pub(crate) fn drain_pending_commits(&mut self, group_id: &str) {
2291        // Track commits that were retried at least once and still expired —
2292        // these are strong evidence of epoch mismatch (true fork), not just
2293        // slow delivery from the mesh network.
2294        let mut retried_expired_count: usize = 0;
2295
2296        // Limit iterations to avoid unbounded looping
2297        for _ in 0..MAX_PENDING_COMMITS_PER_GROUP {
2298            let pending = match self.group_mesh.pending_commits.get_mut(group_id) {
2299                Some(buf) if !buf.is_empty() => std::mem::take(buf),
2300                _ => break,
2301            };
2302
2303            let mut any_succeeded = false;
2304            let mut still_pending = VecDeque::new();
2305
2306            for entry in pending {
2307                // Drop expired entries — only count retried ones for fork detection
2308                if entry.buffered_at.elapsed() > StdDuration::from_secs(PENDING_COMMIT_TTL_SECS) {
2309                    debug!(
2310                        group_id = %group_id,
2311                        retry_count = entry.retry_count,
2312                        "Dropping expired pending commit"
2313                    );
2314                    if entry.retry_count > 0 {
2315                        retried_expired_count += 1;
2316                    }
2317                    // Never processed — release replay protection so a
2318                    // redelivery is accepted fresh even before the periodic
2319                    // dedup sweep runs.
2320                    self.release_replay_protection(&entry.message_id);
2321                    continue;
2322                }
2323                match self.process_commit_core(&entry.sender, &entry.data) {
2324                    CommitOutcome::Success(_) => {
2325                        any_succeeded = true;
2326                    }
2327                    CommitOutcome::Retriable(_) => {
2328                        // Still out-of-order — keep for next pass with incremented retry count
2329                        let mut entry = entry;
2330                        entry.retry_count += 1;
2331                        still_pending.push_back(entry);
2332                    }
2333                    CommitOutcome::Rejected => {
2334                        // Permanently bad — drop silently
2335                    }
2336                }
2337            }
2338
2339            // Re-buffer commits that still failed
2340            if !still_pending.is_empty() {
2341                self.group_mesh
2342                    .pending_commits
2343                    .entry(group_id.to_string())
2344                    .or_default()
2345                    .extend(still_pending);
2346            }
2347
2348            if !any_succeeded {
2349                break;
2350            }
2351            // Another commit succeeded — loop again in case it unblocked more
2352        }
2353
2354        // Clean up empty entries
2355        if self
2356            .group_mesh
2357            .pending_commits
2358            .get(group_id)
2359            .is_none_or(|v| v.is_empty())
2360        {
2361            self.group_mesh.pending_commits.remove(group_id);
2362        }
2363
2364        // If commits that were retried (and kept failing) expired, this is a
2365        // strong signal of epoch fork — the commits were valid but for a
2366        // different epoch branch. Commits that expired without any retries
2367        // are more likely just slow mesh delivery and are not flagged.
2368        if retried_expired_count > 0 && !self.group_mesh.epoch_forks.contains_key(group_id) {
2369            self.flag_potential_epoch_fork(group_id);
2370        }
2371    }
2372
2373    /// Buffers a group application message whose decryption failed because
2374    /// local group state lagged (Welcome not yet processed / epoch behind).
2375    ///
2376    /// The message ID is already in the dedup table, so redeliveries via
2377    /// other transports are rejected there — this buffered copy is the
2378    /// message's only path to delivery, via `drain_pending_group_messages`.
2379    /// Correspondingly, whenever a buffered copy is dropped undelivered
2380    /// (cap eviction here, TTL expiry at drain), its replay protection is
2381    /// released so a redelivery gets a fresh chance instead of being
2382    /// permanently lost.
2383    pub(crate) fn buffer_pending_group_message(
2384        &mut self,
2385        group_id: &str,
2386        pending: PendingGroupMessage,
2387    ) {
2388        let Some(evicted) = enforce_global_buffer_bound(
2389            &mut self.group_mesh.pending_group_messages,
2390            group_id,
2391            &PENDING_GROUP_MESSAGE_CAPS,
2392            pending.ciphertext_b64.len(),
2393            |m| m.ciphertext_b64.len(),
2394            |m| m.buffered_at,
2395        ) else {
2396            // Unreachable in practice — the ciphertext passed the 1 MiB
2397            // base64_decode guard on both inbound paths — but the invariant
2398            // belongs here, not in the callers.
2399            warn!(
2400                group_id = %group_id,
2401                size = pending.ciphertext_b64.len(),
2402                "Pending group message exceeds the global byte budget, dropping"
2403            );
2404            return;
2405        };
2406        // An evicted message was never delivered — release its replay
2407        // protection so a sender-side redelivery is accepted fresh instead
2408        // of being rejected as a replay of a copy that no longer exists.
2409        for entry in &evicted {
2410            self.release_replay_protection(&entry.message_id);
2411        }
2412        let displaced = {
2413            let buf = self
2414                .group_mesh
2415                .pending_group_messages
2416                .entry(group_id.to_string())
2417                .or_default();
2418            let displaced = if buf.len() >= MAX_PENDING_GROUP_MESSAGES_PER_GROUP {
2419                warn!(
2420                    group_id = %group_id,
2421                    "Pending group message buffer full, dropping oldest"
2422                );
2423                buf.pop_front()
2424            } else {
2425                None
2426            };
2427            buf.push_back(pending);
2428            debug!(
2429                group_id = %group_id,
2430                buffered_count = buf.len(),
2431                "Buffered out-of-order group message for deferred retry"
2432            );
2433            displaced
2434        };
2435        if let Some(displaced) = displaced {
2436            self.release_replay_protection(&displaced.message_id);
2437        }
2438    }
2439
2440    /// Whether a message id is currently buffered awaiting decryption for the
2441    /// given group. Drives the deferred-ACK decision in
2442    /// [`Self::handle_group_mls_msg_via`]'s duplicate branch: a retransmit of a
2443    /// still-pending message must be deferred (no ACK), while a duplicate of an
2444    /// already-delivered one is re-ACKed so the sender can stop.
2445    /// Matches both the envelope id and (for re-issued broadcast copies) the
2446    /// logical id, so a cross-path retransmit of a still-pending logical
2447    /// message is deferred rather than re-ACKed as delivered.
2448    fn is_group_message_pending(&self, group_id: &str, message_id: &str) -> bool {
2449        self.group_mesh
2450            .pending_group_messages
2451            .get(group_id)
2452            .is_some_and(|buf| {
2453                buf.iter().any(|m| {
2454                    m.message_id == message_id || m.logical_id.as_deref() == Some(message_id)
2455                })
2456            })
2457    }
2458
2459    /// Sends the deferred delivery ACK for a group message surfaced by the
2460    /// drain, on the transport it originally arrived on.
2461    ///
2462    /// Mirrors the DM `ack_drained_message`: it closes the ACK-latency window
2463    /// so a sender that exhausts its retry budget before local group state
2464    /// catches up still learns of delivery, instead of marking a
2465    /// locally-delivered message undeliverable. `received_via` is `None` for
2466    /// relay-path entries (the relay sender is not ACK-gated) and transport-less
2467    /// test enqueue, in which case this is a correct no-op and recovery falls
2468    /// back to the sender's next resend hitting the duplicate re-ACK path.
2469    fn ack_drained_group_message(
2470        &mut self,
2471        ack_to: &str,
2472        acked_message_id: &str,
2473        received_via: Option<TransportType>,
2474    ) {
2475        let Some(transport) = received_via else {
2476            return;
2477        };
2478        if let Err(err) = self.send_group_delivery_ack(ack_to, acked_message_id, transport) {
2479            error!(
2480                msg_id = %acked_message_id,
2481                error = %err,
2482                "Failed to send deferred delivery ACK for drained group message"
2483            );
2484        }
2485    }
2486
2487    /// Drains buffered group application messages after local MLS state for
2488    /// the group advanced (successful Welcome join or commit). Messages are
2489    /// retried in arrival order; entries that still fail retriably are
2490    /// re-buffered until their TTL expires.
2491    ///
2492    /// A buffered entry can turn out to be a commit or proposal riding the
2493    /// application channel (`NonApplication`): MLS consumes it, so group
2494    /// state may have advanced mid-drain. When that happens, buffered
2495    /// commits are drained and another pass runs, since entries re-buffered
2496    /// earlier in the same pass may now decrypt. Terminates: a pass loops
2497    /// only after consuming a `NonApplication` entry, which is never
2498    /// re-buffered, so each looping pass shrinks the buffer.
2499    ///
2500    /// Double delivery is impossible by construction rather than by
2501    /// bookkeeping: MLS consumes a ratchet generation on decrypt, so a copy
2502    /// of a ciphertext that was already decrypted elsewhere fails here
2503    /// (`Decryption` → `Retriable`) and never reaches the emit. Reaching the
2504    /// `Plaintext` branch *proves* this logical message has not been
2505    /// delivered before. `delivered_here` exists only to stop a second
2506    /// buffered copy from burning that pointless decrypt within one drain —
2507    /// see the declaration.
2508    pub(crate) fn drain_pending_group_messages(&mut self, group_id: &str) {
2509        // Ids (envelope and logical) this drain has already delivered. Both
2510        // the mesh re-issue of a relay broadcast and the relay's own copy of
2511        // it can sit buffered at once while group state lags, and they carry
2512        // the same ciphertext. Once one is delivered, the other must be
2513        // dropped without a decrypt attempt: its generation is spent, so MLS
2514        // would classify it `Retriable` and re-buffer it as noise until its
2515        // TTL — whose expiry would then call `release_replay_protection` on
2516        // an id that WAS delivered, re-opening a replay window for it.
2517        let mut delivered_here: HashSet<String> = HashSet::new();
2518        loop {
2519            let pending = match self.group_mesh.pending_group_messages.get_mut(group_id) {
2520                Some(buf) if !buf.is_empty() => std::mem::take(buf),
2521                _ => break,
2522            };
2523
2524            let mut state_advanced = false;
2525            let mut still_pending = VecDeque::new();
2526            for entry in pending {
2527                // A second buffered copy of something this drain just
2528                // delivered. Checked before the TTL branch so it can never
2529                // release replay protection for a delivered id.
2530                if delivered_here.contains(&entry.message_id)
2531                    || entry
2532                        .logical_id
2533                        .as_deref()
2534                        .is_some_and(|l| delivered_here.contains(l))
2535                {
2536                    debug!(
2537                        group_id = %group_id,
2538                        msg_id = %entry.message_id,
2539                        "Buffered copy of a message this drain already delivered, dropping"
2540                    );
2541                    // The frame *is* delivered (as a duplicate), so ACK it or
2542                    // the sender keeps retransmitting a message we hold.
2543                    // `received_via` is `None` on the relay path, where the
2544                    // sender is not ACK-gated — a correct no-op there.
2545                    self.ack_drained_group_message(
2546                        &entry.sender,
2547                        &entry.message_id,
2548                        entry.received_via,
2549                    );
2550                    continue;
2551                }
2552                if entry.buffered_at.elapsed()
2553                    > StdDuration::from_secs(PENDING_GROUP_MESSAGE_TTL_SECS)
2554                {
2555                    debug!(
2556                        group_id = %group_id,
2557                        msg_id = %entry.message_id,
2558                        "Dropping expired pending group message"
2559                    );
2560                    // Never delivered — release replay protection so a
2561                    // redelivery is accepted fresh even before the periodic
2562                    // dedup sweep runs.
2563                    self.release_replay_protection(&entry.message_id);
2564                    continue;
2565                }
2566                // Validated at buffer time; failure here is defensive only.
2567                let Ok(ciphertext_bytes) = base64_decode(&entry.ciphertext_b64) else {
2568                    continue;
2569                };
2570                match self.decrypt_group_application(group_id, ciphertext_bytes, &entry.sender) {
2571                    GroupDecryptOutcome::Plaintext(plaintext) => {
2572                        let Ok(text) = String::from_utf8(plaintext) else {
2573                            warn!(
2574                                group_id = %group_id,
2575                                "Decrypted group payload is not valid UTF-8, dropping"
2576                            );
2577                            continue;
2578                        };
2579                        info!(
2580                            group_id = %group_id,
2581                            msg_id = %entry.message_id,
2582                            "Delivered buffered group message after group state caught up"
2583                        );
2584                        // Capture the ACK targets before the fields move into
2585                        // the event: the message is delivered now, so the drain
2586                        // sends the deferred delivery ACK directly on the
2587                        // recorded arrival transport instead of waiting for the
2588                        // sender's next resend. The ACK is always keyed on the
2589                        // envelope id (`message_id`) — that is what the sender's
2590                        // ack_manager awaits.
2591                        let received_via = entry.received_via;
2592                        let ack_sender = entry.sender.clone();
2593                        let ack_message_id = entry.message_id.clone();
2594                        // A re-issued broadcast copy carries the logical id the
2595                        // whole group knows this message by: emit that id and
2596                        // mark it delivered, so the relay path's copy of the
2597                        // same logical message dedups against this one.
2598                        //
2599                        // No "already delivered elsewhere?" check is needed
2600                        // here, and none would be correct: the successful
2601                        // decrypt above already proves this ciphertext's
2602                        // ratchet generation was unspent, i.e. that no other
2603                        // copy was delivered before it. A copy that *was*
2604                        // beaten to it fails decrypt and lands in `Retriable`,
2605                        // never here.
2606                        let emit_id = match entry.logical_id {
2607                            Some(logical) => {
2608                                self.group_mesh
2609                                    .message_dedup
2610                                    .insert(logical.clone(), Instant::now());
2611                                logical
2612                            }
2613                            None => entry.message_id,
2614                        };
2615                        delivered_here.insert(ack_message_id.clone());
2616                        delivered_here.insert(emit_id.clone());
2617                        let (content, media_metadata, content_type, forward_info_event) =
2618                            Self::restore_group_rich(text, entry.forward_info, &entry.sender);
2619                        let timestamp = entry
2620                            .timestamp
2621                            .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
2622                        self.emit_event(Event::group_message_received(
2623                            group_id.to_string(),
2624                            entry.sender,
2625                            content,
2626                            timestamp,
2627                            emit_id,
2628                            entry.reply_to,
2629                            forward_info_event,
2630                            media_metadata,
2631                            content_type,
2632                        ));
2633                        self.ack_drained_group_message(&ack_sender, &ack_message_id, received_via);
2634                    }
2635                    GroupDecryptOutcome::Retriable => {
2636                        still_pending.push_back(entry);
2637                    }
2638                    GroupDecryptOutcome::NonApplication => {
2639                        state_advanced = true;
2640                    }
2641                    GroupDecryptOutcome::PolicyRejected {
2642                        committer,
2643                        added,
2644                        removed,
2645                    } => {
2646                        // Dropped, not re-buffered: the refusal is permanent,
2647                        // so re-queueing would retry it until the entry ages
2648                        // out and then misreport the expiry as an epoch fork.
2649                        self.report_unauthorized_membership_change(
2650                            group_id,
2651                            &committer,
2652                            &added,
2653                            &removed,
2654                            "sender_not_admin",
2655                            true,
2656                        );
2657                    }
2658                    GroupDecryptOutcome::SecurityRejected
2659                    | GroupDecryptOutcome::NotMlsCiphertext
2660                    | GroupDecryptOutcome::Failed => {
2661                        // Dropped unprocessed — release replay protection, for
2662                        // the same reason the arrival path unmarks a rejected
2663                        // relay copy (see the marking note atop
2664                        // `handle_relay_group_message_with_mls`) and the TTL
2665                        // arm above releases an expired one. A relay copy can
2666                        // outrun its Welcome, so a mis-attributed one is judged
2667                        // *here* rather than at arrival — and a hostile relay
2668                        // picks that ordering for free. Left marked, the id
2669                        // reads as delivered while no longer being pending, so
2670                        // the per-member re-issue that is this message's own
2671                        // delivery safety net is absorbed as an
2672                        // already-delivered duplicate and re-ACKed: delivered
2673                        // nowhere, sender told otherwise.
2674                        //
2675                        // `PolicyRejected` above deliberately does not release:
2676                        // that refusal is permanent, so a later copy could only
2677                        // waste work.
2678                        self.release_replay_protection(&entry.message_id);
2679                    }
2680                }
2681            }
2682
2683            if !still_pending.is_empty() {
2684                self.group_mesh
2685                    .pending_group_messages
2686                    .entry(group_id.to_string())
2687                    .or_default()
2688                    .extend(still_pending);
2689            }
2690
2691            if !state_advanced {
2692                break;
2693            }
2694            // The consumed commit may also unblock buffered commit-channel
2695            // commits; drain them before the next message pass (the pass itself
2696            // satisfies drain_pending_commits' call-site contract).
2697            self.drain_pending_commits(group_id);
2698        }
2699
2700        // Clean up empty entries
2701        if self
2702            .group_mesh
2703            .pending_group_messages
2704            .get(group_id)
2705            .is_none_or(|v| v.is_empty())
2706        {
2707            self.group_mesh.pending_group_messages.remove(group_id);
2708        }
2709    }
2710
2711    /// Flags a group as having a potential epoch fork.
2712    ///
2713    /// Called when buffered commits expire without resolution, indicating
2714    /// the group may have diverged due to concurrent commits.
2715    fn flag_potential_epoch_fork(&mut self, group_id: &str) {
2716        if self.group_mesh.epoch_forks.len() >= MAX_EPOCH_FORK_ENTRIES {
2717            // Evict the oldest entry to prevent unbounded growth
2718            if let Some(oldest_key) = self
2719                .group_mesh
2720                .epoch_forks
2721                .values()
2722                .min_by_key(|f| f.detected_at)
2723                .map(|f| f.group_id.clone())
2724            {
2725                self.group_mesh.epoch_forks.remove(&oldest_key);
2726            }
2727        }
2728
2729        let local_epoch = self.refresh_group_members(group_id).ok().and_then(|_| {
2730            let mls_guard = self.read_mls_guard().ok()?;
2731            let gid = offline_protocol_mls::GroupId::new(group_id).ok()?;
2732            mls_guard
2733                .get_group_info(&gid)
2734                .ok()
2735                .flatten()
2736                .map(|i| i.epoch)
2737        });
2738
2739        if local_epoch.is_none() {
2740            warn!(
2741                group_id = %group_id,
2742                "Could not read local MLS epoch during fork detection — MLS unavailable or group not found"
2743            );
2744        }
2745
2746        warn!(
2747            group_id = %group_id,
2748            local_epoch = ?local_epoch,
2749            "Potential epoch fork detected — buffered commits expired without resolution"
2750        );
2751
2752        self.group_mesh.epoch_forks.insert(
2753            group_id.to_string(),
2754            EpochForkState {
2755                group_id: group_id.to_string(),
2756                local_epoch,
2757                detected_at: Instant::now(),
2758                resolution_attempted: false,
2759            },
2760        );
2761
2762        self.emit_event(Event::group_epoch_fork_detected(
2763            group_id.to_string(),
2764            local_epoch,
2765        ));
2766    }
2767
2768    /// Handles an incoming group leave notification.
2769    ///
2770    /// After verifying the sender, the lexicographically-first remaining member
2771    /// (deterministic election) issues an MLS remove-commit to advance the group
2772    /// epoch and revoke the leaving member's keys. Other members will receive
2773    /// the commit via `handle_group_mls_commit`.
2774    ///
2775    /// # Security limitations
2776    ///
2777    /// Leave notifications are **not** MLS-authenticated because a member cannot
2778    /// issue a self-removal Commit in MLS. The `sender` field comes from the
2779    /// `Message` envelope which is not cryptographically bound. In an adversarial
2780    /// mesh environment a relay node could forge a leave notification to force-
2781    /// remove a legitimate member. Mitigations:
2782    ///
2783    /// 1. `sender == leaving_member` check prevents cross-member impersonation
2784    ///    when the transport layer preserves sender identity.
2785    /// 2. Membership is verified against the **MLS group state** (authoritative),
2786    ///    not just the local cache.
2787    /// 3. The elected remover issues a real MLS Commit that all members verify
2788    ///    cryptographically.
2789    ///
2790    /// For fully adversarial environments, consider requiring admin-only removal
2791    /// or adding an MLS application-message-signed leave proof.
2792    pub(crate) fn handle_group_mls_leave(&mut self, message_id: &str, sender: &str, data: &str) {
2793        let payload = match serde_json::from_str::<GroupMlsLeavePayload>(data) {
2794            Ok(p) => p,
2795            Err(_) => {
2796                warn!(sender = %sender, "Failed to parse GroupMlsLeave payload");
2797                return;
2798            }
2799        };
2800
2801        // Dedup check — prevents duplicate leave notifications from resetting
2802        // the election timer or triggering redundant remove-commit attempts.
2803        let dedup_key = message_id.to_string();
2804        if self.group_mesh.message_dedup.contains_key(&dedup_key) {
2805            debug!(
2806                group_id = %payload.group_id,
2807                msg_id = %dedup_key,
2808                "Duplicate leave notification, skipping"
2809            );
2810            return;
2811        }
2812        self.group_mesh
2813            .message_dedup
2814            .insert(dedup_key, Instant::now());
2815        if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
2816            self.cleanup_group_message_dedup();
2817        }
2818
2819        // Verify sender matches the claimed leaving member to prevent spoofing
2820        if payload.leaving_member != sender {
2821            error!(
2822                sender = %sender,
2823                claimed = %payload.leaving_member,
2824                group_id = %payload.group_id,
2825                "SECURITY: Leave notification sender mismatch — possible spoofing attempt, ignoring"
2826            );
2827            return;
2828        }
2829
2830        // Verify sender is actually a member of the group using MLS state
2831        // (authoritative) rather than only the local cache.
2832        let self_id = self.local_id.clone();
2833        let members = self
2834            .refresh_group_members(&payload.group_id)
2835            .ok()
2836            .or_else(|| self.group_mesh.members.get(&payload.group_id).cloned())
2837            .unwrap_or_default();
2838
2839        if !members.iter().any(|m| m == sender) {
2840            error!(
2841                sender = %sender,
2842                group_id = %payload.group_id,
2843                "SECURITY: Leave notification from non-member, ignoring"
2844            );
2845            return;
2846        }
2847
2848        info!(
2849            group_id = %payload.group_id,
2850            leaving_member = %payload.leaving_member,
2851            "Received mesh group leave notification"
2852        );
2853
2854        // Deterministic election: lexicographically-first remaining member
2855        // (excluding the leaver) issues the MLS remove-commit to advance epoch.
2856        let mut remaining: Vec<String> = members
2857            .iter()
2858            .filter(|m| m.as_str() != sender)
2859            .cloned()
2860            .collect();
2861        remaining.sort();
2862
2863        let should_remove = remaining
2864            .first()
2865            .map(|first| first == &self_id)
2866            .unwrap_or(false);
2867
2868        if should_remove {
2869            debug!(
2870                group_id = %payload.group_id,
2871                leaving_member = %sender,
2872                "Elected to issue MLS remove-commit for leaving member"
2873            );
2874            // Issue MLS remove + distribute commit to advance epoch and revoke keys.
2875            // remove_from_group emits GroupMemberRemoved, so we skip the event below.
2876            if let Err(e) = self.remove_from_group(&payload.group_id, sender) {
2877                warn!(
2878                    group_id = %payload.group_id,
2879                    leaving_member = %sender,
2880                    error = %e,
2881                    "Failed to issue MLS remove-commit for leaving member"
2882                );
2883            }
2884            // Clear any pending election for this leave (we handled it)
2885            let key = (payload.group_id.clone(), sender.to_string());
2886            self.group_mesh.pending_leave_elections.remove(&key);
2887        } else {
2888            // We're not the elected remover — record the election so we can
2889            // take over if the elected member fails to issue the commit in time.
2890            if self.group_mesh.pending_leave_elections.len() >= MAX_PENDING_LEAVE_ELECTIONS {
2891                // Evict the oldest entry to prevent unbounded growth.
2892                if let Some(oldest_key) = self
2893                    .group_mesh
2894                    .pending_leave_elections
2895                    .iter()
2896                    .min_by_key(|(_, e)| e.received_at)
2897                    .map(|(k, _)| k.clone())
2898                {
2899                    self.group_mesh.pending_leave_elections.remove(&oldest_key);
2900                }
2901            }
2902            let key = (payload.group_id.clone(), sender.to_string());
2903            self.group_mesh.pending_leave_elections.insert(
2904                key,
2905                PendingLeaveElection {
2906                    group_id: payload.group_id.clone(),
2907                    leaving_member: sender.to_string(),
2908                    received_at: Instant::now(),
2909                    last_attempt_at: None,
2910                },
2911            );
2912        }
2913
2914        // Do NOT emit GroupMemberRemoved here for non-elected nodes.
2915        // The elected node issues an MLS remove-commit which all members
2916        // (including us) will process via `handle_group_mls_commit` →
2917        // `process_commit_core`, which emits the event based on the actual
2918        // MLS membership delta. Emitting here would cause a duplicate event
2919        // because we'd emit once now (premature, before MLS state changes)
2920        // and once when the commit arrives.
2921    }
2922
2923    /// Refreshes the cached member list for a group from MlsManager.
2924    ///
2925    /// Also the reporting seam for leaves already seated in local group state:
2926    /// `get_group_info` filters out any leaf that does not derive its own
2927    /// credential and hands back the count, and a non-zero one is surfaced here
2928    /// as `GroupLeafIdentityUnproven`. This is the chokepoint for it because it
2929    /// is the roster read that runs after every membership change and before
2930    /// every fan-out, and — unlike the public `get_group_info` getter beside it
2931    /// — it holds `&mut self`, so it can emit.
2932    ///
2933    /// Rate-limited like the two wire sites, which matters more here than
2934    /// there: a seated leaf is *persistent*, so without suppression every
2935    /// commit, send and drain in the group would re-report the same finding.
2936    pub(crate) fn refresh_group_members(&mut self, group_id: &str) -> Result<Vec<String>> {
2937        let mls_guard = self.read_mls_guard()?;
2938        let gid = offline_protocol_mls::GroupId::new(group_id)?;
2939        let info = mls_guard
2940            .get_group_info(&gid)?
2941            .ok_or_else(|| Error::GroupNotFound(group_id.to_string()))?;
2942        let members = info.members.clone();
2943        let unproven_members = info.unproven_members;
2944        drop(mls_guard);
2945        self.group_mesh
2946            .members
2947            .insert(group_id.to_string(), members.clone());
2948
2949        if unproven_members > 0 {
2950            let peer = self.local_id.clone();
2951            // The log rides the rate limit rather than the condition, which the
2952            // other three sites do not need to do: they log once per refused
2953            // *frame*, so inbound traffic bounds them. This one is driven by our
2954            // own activity — a roster read runs on every commit, send and drain
2955            // — and the finding is persistent, so an unthrottled line here would
2956            // put an `error!` in the log for every message the user sends, for
2957            // as long as the group exists. Same placement as
2958            // `report_unauthorized_membership_change`, which also logs only
2959            // after claiming the window.
2960            let reported = self.report_unproven_leaf(
2961                group_id,
2962                &peer,
2963                UnprovenLeafSite::RosterEntry,
2964                // Identifier-free like the other two sites, and count-free for
2965                // one further reason: the count is a property of local state,
2966                // not of anything a peer did, so it says nothing a sink needs
2967                // and narrows which install a record came from. It is in the
2968                // `error!` below.
2969                "This group holds membership state that could not be verified against the keys \
2970                 it claims, so messages in this group cannot be reliably attributed. The group \
2971                 should be treated as compromised and abandoned rather than repaired."
2972                    .to_string(),
2973            );
2974            if reported {
2975                error!(
2976                    group_id = %group_id,
2977                    unproven_members,
2978                    "SECURITY: local group state holds leaves that do not prove the identity \
2979                     they claim; they are kept out of the roster and cannot speak, but they \
2980                     have read everything sent to this group"
2981                );
2982            }
2983        }
2984        Ok(members)
2985    }
2986
2987    /// Creates a new MLS group.
2988    ///
2989    /// The group is created locally via MLS. Members can be invited with
2990    /// `invite_to_group()`. Messages sent via `send_group_message()` are
2991    /// MLS-encrypted and route through whichever transport DORS selects.
2992    /// If Internet is available the group is also registered with the
2993    /// relay server for optimized fan-out.
2994    pub fn create_group(&mut self, group_name: &str) -> Result<offline_protocol_mls::GroupInfo> {
2995        let trimmed = group_name.trim();
2996        if trimmed.is_empty() {
2997            return Err(Error::InvalidArgument(
2998                "Group name cannot be empty".to_string(),
2999            ));
3000        }
3001        let mls_guard = self.read_mls_guard()?;
3002        let group_info = mls_guard.create_group(group_name)?;
3003        let group_id = group_info.group_id.as_str().to_string();
3004        let members = group_info.members.clone();
3005        drop(mls_guard);
3006
3007        self.group_mesh
3008            .members
3009            .insert(group_id.clone(), members.clone());
3010
3011        self.emit_event(Event::group_created(
3012            group_id.clone(),
3013            group_name.to_string(),
3014        ));
3015
3016        // Best-effort relay registration
3017        let _ = self.try_relay_register_group(&group_id, Some(group_name), &members);
3018
3019        Ok(group_info)
3020    }
3021
3022    /// Invites a user to an MLS group.
3023    ///
3024    /// Requires the invitee's key package to be available in `pending_key_packages`.
3025    /// Sends a Welcome to the invitee and a Commit to all existing members.
3026    ///
3027    /// Admission is where recipient validation belongs. The removal and role
3028    /// paths deliberately do *not* validate: a roster entry admitted by an
3029    /// older build (before the id rules tightened) must stay removable, and a
3030    /// gate there would turn "a bad member exists" into "a bad member cannot be
3031    /// removed" — the wrong direction for a moderation control.
3032    pub fn invite_to_group(&mut self, group_id: &str, invitee_user_id: &str) -> Result<()> {
3033        Self::validate_outbound_recipient(invitee_user_id)?;
3034
3035        // Admin check
3036        let self_id = self.local_id.clone();
3037        if !self.check_is_admin(group_id, &self_id)? {
3038            return Err(Error::PermissionDenied(
3039                "Only admins can invite members".to_string(),
3040            ));
3041        }
3042
3043        // Check group member cap before adding
3044        let current_count = self
3045            .group_mesh
3046            .members
3047            .get(group_id)
3048            .map(|m| m.len())
3049            .or_else(|| {
3050                self.read_mls_guard().ok().and_then(|g| {
3051                    g.get_group_info(&offline_protocol_mls::GroupId::new(group_id).ok()?)
3052                        .ok()
3053                        .flatten()
3054                        .map(|info| info.members.len())
3055                })
3056            })
3057            .unwrap_or(0);
3058        let max_members = self.config.group.max_group_members;
3059        if current_count >= max_members {
3060            return Err(Error::InvalidState(format!(
3061                "Group has {} members, cannot exceed {} limit",
3062                current_count, max_members
3063            )));
3064        }
3065
3066        // Try loading key package from storage (e.g. after restart or rapid
3067        // re-invite where a fresh package arrived between calls).
3068        self.try_load_key_package_from_storage_into_memory(invitee_user_id);
3069
3070        // Get the invitee's key package and check expiry
3071        let now_ms = Utc::now().timestamp_millis() as u64;
3072        let received_pkg = self
3073            .pending_key_packages
3074            .get(invitee_user_id)
3075            .ok_or_else(|| Error::NoKeyPackage(invitee_user_id.to_string()))?;
3076        if now_ms >= received_pkg.local_expires_at_ms {
3077            self.pending_key_packages.remove(invitee_user_id);
3078            return Err(Error::InvalidState(format!(
3079                "Key package for {} has expired",
3080                invitee_user_id
3081            )));
3082        }
3083        let key_pkg = received_pkg.key_package_data.clone();
3084
3085        // Add member via MLS — returns both Welcome (for invitee) and Commit (for existing members)
3086        let mls_guard = self.read_mls_guard()?;
3087        let gid = offline_protocol_mls::GroupId::new(group_id)?;
3088        let (welcome, commit) = mls_guard.add_group_member(&gid, invitee_user_id, &key_pkg)?;
3089        let group_name = welcome.group_name.clone();
3090        drop(mls_guard);
3091
3092        // MLS key packages are single-use (RFC 9420). Now that add_group_member
3093        // consumed the package, remove it so subsequent group invites for the
3094        // same peer don't reuse a stale package.
3095        self.pending_key_packages.remove(invitee_user_id);
3096        self.delete_peer_key_package_from_storage(invitee_user_id);
3097
3098        // Clear key-package-sent tracking so the invitee can reciprocate.
3099        // Without this, the invitee's handler sees us in `key_package_sent_to`
3100        // and skips reciprocation, leaving us without a key package for
3101        // future group invites.
3102        self.key_package_sent_to.remove(invitee_user_id);
3103
3104        // Send our key package to the invitee. Their message dispatch handler
3105        // will reciprocate with a fresh key package, replenishing our supply
3106        // for future group invites or 1:1 session creation.
3107        if let Err(e) = self.send_key_package_to(invitee_user_id, false) {
3108            debug!(
3109                invitee = %invitee_user_id,
3110                error = %e,
3111                "Key package send to invitee deferred (will retry on next discovery)"
3112            );
3113        }
3114
3115        // Refresh member list after add
3116        let members = self.refresh_group_members(group_id)?;
3117
3118        // Store invitee as member role, then read back both role-overlay
3119        // fields the Welcome carries (roles and the creator of record) from a
3120        // single metadata load.
3121        let (member_roles, created_by) = {
3122            let mls_guard = self.read_mls_guard()?;
3123            let gid = offline_protocol_mls::GroupId::new(group_id)?;
3124            if let Err(e) = mls_guard.set_member_role(&gid, invitee_user_id, GroupRole::Member) {
3125                warn!(invitee = %invitee_user_id, error = %e, "Failed to store invitee role");
3126            }
3127            mls_guard
3128                .get_group_metadata(&gid)
3129                .ok()
3130                .flatten()
3131                .map(|m| (m.get_all_roles(), m.created_by.clone()))
3132                .unwrap_or_default()
3133        };
3134
3135        // Attest rich-payload capability both directions of the knowledge
3136        // gap an add creates: existing members never exchange key packages
3137        // with the invitee (commit field below), and the invitee never
3138        // exchanges with anyone but us (welcome map here). Entries only
3139        // exist for members we know capable — directly or via an earlier
3140        // attestation, which is how knowledge chains across successive adds
3141        // — so absence means "no information", never a downgrade. Our own
3142        // entry self-advertises from config (belt to the key package sent
3143        // above, whose delivery is best-effort).
3144        let invitee_rich = self.attestable_rich_versions(invitee_user_id);
3145        let member_rich: HashMap<String, Vec<u8>> = members
3146            .iter()
3147            .filter(|m| m.as_str() != invitee_user_id)
3148            .filter_map(|m| {
3149                if *m == self_id {
3150                    self.config
3151                        .encryption
3152                        .rich_payload_enabled
3153                        .then(|| (m.clone(), vec![RICH_PAYLOAD_V1]))
3154                } else {
3155                    self.attestable_rich_versions(m).map(|v| (m.clone(), v))
3156                }
3157            })
3158            .collect();
3159
3160        // Send Welcome to invitee
3161        let welcome_payload = GroupMlsWelcomePayload {
3162            group_id: group_id.to_string(),
3163            group_name,
3164            welcome_data: base64_encode(&welcome.welcome_data),
3165            member_list: members.clone(),
3166            member_roles,
3167            member_rich,
3168            // Carries our creator of record so the joiner's `check_is_admin`
3169            // has a fallback when the role snapshot above is incomplete.
3170            // Absent when we never learned it ourselves (joined via an older
3171            // SDK's Welcome) — absence is "no information", not a downgrade.
3172            created_by,
3173        };
3174        let welcome_content = format!(
3175            "{}{}",
3176            internal_prefixes::GROUP_MLS_WELCOME,
3177            serde_json::to_string(&welcome_payload)
3178                .map_err(|e| Error::Serialization(format!("Serialize welcome: {}", e)))?
3179        );
3180        self.send_internal_message(invitee_user_id, welcome_content, MessagePriority::High)?;
3181
3182        // Send Commit to all existing members (excluding self and invitee)
3183        // so they can process it and advance their MLS epoch
3184        let commit_payload = GroupMlsCommitPayload {
3185            group_id: group_id.to_string(),
3186            commit_type: GroupCommitType::Add,
3187            ciphertext: base64_encode(&commit.ciphertext),
3188            epoch: commit.epoch,
3189            affected_member: Some(invitee_user_id.to_string()),
3190            role: Some(GroupRole::Member),
3191            affected_member_rich: invitee_rich,
3192        };
3193        let commit_content = format!(
3194            "{}{}",
3195            internal_prefixes::GROUP_MLS_COMMIT,
3196            serde_json::to_string(&commit_payload)
3197                .map_err(|e| Error::Serialization(format!("Serialize commit: {}", e)))?
3198        );
3199        let mut failed_commit_members: Vec<String> = Vec::new();
3200        for member in &members {
3201            if member == &self_id || member == invitee_user_id {
3202                continue;
3203            }
3204            if let Err(e) =
3205                self.send_internal_message(member, commit_content.clone(), MessagePriority::High)
3206            {
3207                warn!(
3208                    group_id = %group_id,
3209                    member = %member,
3210                    error = %e,
3211                    "Failed to send commit to group member during invite"
3212                );
3213                failed_commit_members.push(member.clone());
3214            }
3215        }
3216        // Single best-effort retry pass for transient failures
3217        for member in &failed_commit_members {
3218            let _ =
3219                self.send_internal_message(member, commit_content.clone(), MessagePriority::High);
3220        }
3221
3222        self.emit_event(Event::group_member_added(
3223            group_id.to_string(),
3224            invitee_user_id.to_string(),
3225            self_id,
3226            None,
3227            // Our own invite, gated by the admin check at the top of this fn.
3228            Some(true),
3229        ));
3230
3231        // Sync membership update to relay
3232        let _ = self.try_relay_register_group(group_id, None, &members);
3233
3234        info!(group_id = %group_id, invitee = %invitee_user_id, "Invited member to group");
3235        Ok(())
3236    }
3237
3238    /// Removes a member from an MLS group.
3239    ///
3240    /// Sends a Commit to all remaining members.
3241    pub fn remove_from_group(&mut self, group_id: &str, member_id: &str) -> Result<()> {
3242        // Deliberately unvalidated — see `invite_to_group`. Removal must work
3243        // on whatever is actually on the roster.
3244        let self_id = self.local_id.clone();
3245
3246        // Admin check + last-admin guard (single metadata load)
3247        {
3248            let mls_guard = self.read_mls_guard()?;
3249            let gid = offline_protocol_mls::GroupId::new(group_id)?;
3250            let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id)?;
3251
3252            let is_admin = if let Some(ref meta) = metadata {
3253                if meta.has_any_admin() {
3254                    meta.get_role(&self_id) == GroupRole::Admin
3255                } else if let Some(ref creator) = meta.created_by {
3256                    creator == &self_id
3257                } else {
3258                    false
3259                }
3260            } else {
3261                false
3262            };
3263            if !is_admin {
3264                return Err(Error::PermissionDenied(
3265                    "Only admins can remove members".to_string(),
3266                ));
3267            }
3268
3269            if let Some(ref meta) = metadata {
3270                if meta.get_role(member_id) == GroupRole::Admin {
3271                    let admin_count = meta
3272                        .get_all_roles()
3273                        .values()
3274                        .filter(|r| **r == GroupRole::Admin)
3275                        .count();
3276                    let member_count = self
3277                        .group_mesh
3278                        .members
3279                        .get(group_id)
3280                        .map(|m| m.len())
3281                        .unwrap_or(0);
3282                    // member_count > 2: the count is *before* removal, so > 2
3283                    // means at least 2 members will remain after the removal.
3284                    // When only 2 members exist (the admin being removed + one
3285                    // other), the remaining sole member gets auto-promoted, so
3286                    // we allow the removal.
3287                    if admin_count <= 1 && member_count > 2 {
3288                        return Err(Error::InvalidState(
3289                            "Cannot remove the last admin while other members remain. Promote another member to admin first.".to_string(),
3290                        ));
3291                    }
3292                }
3293            }
3294        }
3295
3296        let mls_guard = self.read_mls_guard()?;
3297        let gid = offline_protocol_mls::GroupId::new(group_id)?;
3298        let commit_msg = mls_guard.remove_group_member(&gid, member_id)?;
3299        drop(mls_guard);
3300
3301        // Refresh member list after removal
3302        let members = self.refresh_group_members(group_id)?;
3303        // Clean up removed member's role metadata
3304        if let Ok(mls_guard) = self.read_mls_guard() {
3305            if let Ok(gid) = offline_protocol_mls::GroupId::new(group_id) {
3306                if let Err(e) = mls_guard.remove_member_role(&gid, member_id) {
3307                    warn!(member = %member_id, error = %e, "Failed to clean up role metadata for removed member");
3308                }
3309            }
3310        }
3311        // Auto-promote if removal left zero admins
3312        self.auto_promote_if_no_admin(group_id, &members);
3313
3314        let commit_payload = GroupMlsCommitPayload {
3315            group_id: group_id.to_string(),
3316            commit_type: GroupCommitType::Remove,
3317            ciphertext: base64_encode(&commit_msg.ciphertext),
3318            epoch: commit_msg.epoch,
3319            affected_member: Some(member_id.to_string()),
3320            role: None,
3321            affected_member_rich: None,
3322        };
3323        let commit_content = format!(
3324            "{}{}",
3325            internal_prefixes::GROUP_MLS_COMMIT,
3326            serde_json::to_string(&commit_payload)
3327                .map_err(|e| Error::Serialization(format!("Serialize commit: {}", e)))?
3328        );
3329        let mut failed_commit_members: Vec<String> = Vec::new();
3330        for member in &members {
3331            if member == &self_id {
3332                continue;
3333            }
3334            if let Err(e) =
3335                self.send_internal_message(member, commit_content.clone(), MessagePriority::High)
3336            {
3337                warn!(
3338                    group_id = %group_id,
3339                    member = %member,
3340                    error = %e,
3341                    "Failed to send commit to group member during remove"
3342                );
3343                failed_commit_members.push(member.clone());
3344            }
3345        }
3346        // Single best-effort retry pass for transient failures
3347        for member in &failed_commit_members {
3348            let _ =
3349                self.send_internal_message(member, commit_content.clone(), MessagePriority::High);
3350        }
3351
3352        // Send a plaintext removal notification directly to the removed member.
3353        // The MLS commit alone is insufficient: the removed member cannot decrypt
3354        // it (they're no longer in the group), so they would never learn they were
3355        // removed. The plaintext notification uses the existing GROUP_MEMBER_REMOVED
3356        // prefix which the message dispatcher handles by emitting a
3357        // GroupMemberRemoved event and cleaning up local state.
3358        let removed_payload = GroupMemberRemovedPayload {
3359            group_id: group_id.to_string(),
3360            user_id: member_id.to_string(),
3361            removed_by: self_id.clone(),
3362        };
3363        if let Ok(json) = serde_json::to_string(&removed_payload) {
3364            let removed_content = format!("{}{}", internal_prefixes::GROUP_MEMBER_REMOVED, json);
3365            if let Err(e) =
3366                self.send_internal_message(member_id, removed_content, MessagePriority::High)
3367            {
3368                warn!(
3369                    group_id = %group_id,
3370                    member = %member_id,
3371                    error = %e,
3372                    "Failed to send removal notification to removed member"
3373                );
3374            }
3375        }
3376
3377        self.emit_event(Event::group_member_removed(
3378            group_id.to_string(),
3379            member_id.to_string(),
3380            self_id,
3381            // Our own removal, gated by the admin check at the top of this fn.
3382            Some(true),
3383        ));
3384
3385        // Sync membership update to relay
3386        let _ = self.try_relay_register_group(group_id, None, &members);
3387
3388        info!(group_id = %group_id, member = %member_id, "Removed member from group");
3389        Ok(())
3390    }
3391
3392    /// Leaves an MLS group.
3393    ///
3394    /// Notifies remaining members and then removes local group state.
3395    /// Note: The MLS layer does not support self-removal Commits, so remaining
3396    /// members receive a plaintext leave notification. The deterministic election
3397    /// in `handle_group_mls_leave` will select one member to issue the MLS
3398    /// remove-commit to properly advance the epoch.
3399    ///
3400    /// **Ordering note:** Leave notifications are sent *before* deleting local
3401    /// MLS state. If all notification sends fail, local state is preserved and
3402    /// the caller receives an error so the leave can be retried. This prevents
3403    /// orphaned membership where the leaver is gone locally but peers never
3404    /// learn about the departure.
3405    pub fn leave_group(&mut self, group_id: &str) -> Result<()> {
3406        // Get members before leaving
3407        let members = self
3408            .group_mesh
3409            .members
3410            .get(group_id)
3411            .cloned()
3412            .or_else(|| self.refresh_group_members(group_id).ok())
3413            .unwrap_or_default();
3414
3415        let self_id = self.local_id.clone();
3416
3417        // Block last admin from leaving if other members remain
3418        let other_members_exist = members.iter().any(|m| m != &self_id);
3419        if other_members_exist {
3420            let mls_guard = self.read_mls_guard()?;
3421            let gid = offline_protocol_mls::GroupId::new(group_id)?;
3422            if let Some(metadata) = mls_guard.get_group_metadata(&gid)? {
3423                let is_admin = metadata.get_role(&self_id) == GroupRole::Admin;
3424                if is_admin {
3425                    let admin_count = metadata
3426                        .get_all_roles()
3427                        .values()
3428                        .filter(|r| **r == GroupRole::Admin)
3429                        .count();
3430                    if admin_count <= 1 {
3431                        return Err(Error::InvalidState(
3432                            "Cannot leave group as the last admin while other members remain. Promote another member to admin first.".to_string(),
3433                        ));
3434                    }
3435                }
3436            }
3437            drop(mls_guard);
3438        }
3439
3440        // Build leave notification payload before touching MLS state
3441        let leave_payload = GroupMlsLeavePayload {
3442            group_id: group_id.to_string(),
3443            leaving_member: self_id.clone(),
3444        };
3445        let leave_content = format!(
3446            "{}{}",
3447            internal_prefixes::GROUP_MLS_LEAVE,
3448            serde_json::to_string(&leave_payload)
3449                .map_err(|e| Error::Serialization(format!("Serialize leave: {}", e)))?
3450        );
3451
3452        // Send notifications first — if all fail, keep local state intact for retry
3453        let mut any_sent = false;
3454        let mut had_recipients = false;
3455        for member in &members {
3456            if member == &self_id {
3457                continue;
3458            }
3459            had_recipients = true;
3460            match self.send_internal_message(member, leave_content.clone(), MessagePriority::Medium)
3461            {
3462                Ok(_) => {
3463                    any_sent = true;
3464                }
3465                Err(e) => {
3466                    warn!(
3467                        group_id = %group_id,
3468                        member = %member,
3469                        error = %e,
3470                        "Failed to send leave notification to group member"
3471                    );
3472                }
3473            }
3474        }
3475
3476        // If there were other members but no notification succeeded, fail so the
3477        // caller can retry rather than silently orphaning the membership.
3478        if had_recipients && !any_sent {
3479            return Err(Error::Transport(
3480                offline_protocol_transport::Error::SendFailed(
3481                    "All leave notifications failed — local state preserved for retry".to_string(),
3482                ),
3483            ));
3484        }
3485
3486        // Now safe to delete local MLS state — at least one peer was notified
3487        let mls_guard = self.read_mls_guard()?;
3488        let gid = offline_protocol_mls::GroupId::new(group_id)?;
3489        mls_guard.leave_group(&gid)?;
3490        drop(mls_guard);
3491
3492        // Remove from caches. Buffered commits go too — they can never apply
3493        // after we leave, and a stale one retried after a rapid re-invite
3494        // would expire with retry_count > 0 and falsely flag an epoch fork.
3495        self.group_mesh.members.remove(group_id);
3496        let was_synced = self.group_mesh.relay_synced.remove(group_id);
3497        let was_pending = self
3498            .group_mesh
3499            .relay_register_pending
3500            .remove(group_id)
3501            .is_some();
3502        if was_synced || was_pending {
3503            self.emit_event(Event::group_relay_sync_changed(
3504                group_id.to_string(),
3505                false,
3506                "left",
3507            ));
3508        }
3509        self.group_mesh.pending_group_messages.remove(group_id);
3510        self.group_mesh.pending_commits.remove(group_id);
3511
3512        info!(group_id = %group_id, "Left group");
3513        Ok(())
3514    }
3515
3516    /// Sends a message to all members of an MLS group.
3517    ///
3518    /// If Internet transport is available and relay-enabled, sends a single
3519    /// relay broadcast (`__GRP_RELAY_BCAST__`). Otherwise falls back to
3520    /// per-member fan-out via `send_internal_message()` where each member's
3521    /// delivery goes through the full DORS/ACK/retry stack independently.
3522    pub fn send_group_message(
3523        &mut self,
3524        group_id: &str,
3525        content: &str,
3526        priority: Option<MessagePriority>,
3527        reply_to_msg: Option<&str>,
3528    ) -> Result<Vec<MessageId>> {
3529        // Reject reserved internal prefixes: receivers parse decrypted group
3530        // plaintext for the sealed `__RICH_V1__` body, so user content must
3531        // never be able to impersonate one (same rule as `send_message` and
3532        // the forward paths).
3533        if Self::is_internal_prefix(content) {
3534            return Err(Error::InvalidArgument(
3535                "Message content must not start with a reserved internal prefix".to_string(),
3536            ));
3537        }
3538        self.send_group_message_inner(
3539            group_id,
3540            content,
3541            priority.unwrap_or(MessagePriority::Medium),
3542            reply_to_msg,
3543            None,
3544            None,
3545            ContentType::Text,
3546        )
3547    }
3548
3549    /// Like [`Self::send_group_message`], with rich media fields.
3550    ///
3551    /// The rich fields travel inside the MLS-sealed `__RICH_V1__` body of
3552    /// the group ciphertext, and only when every other member advertised
3553    /// `rich_versions` (see [`GroupSendOptions`]); otherwise they are
3554    /// silently dropped, never cleartext.
3555    pub fn send_group_message_with(
3556        &mut self,
3557        group_id: &str,
3558        content: &str,
3559        options: GroupSendOptions,
3560    ) -> Result<Vec<MessageId>> {
3561        // Same internal-prefix rejection as `send_group_message`.
3562        if Self::is_internal_prefix(content) {
3563            return Err(Error::InvalidArgument(
3564                "Message content must not start with a reserved internal prefix".to_string(),
3565            ));
3566        }
3567        // FileChunk is an internal transport content type — same boundary
3568        // rule as `send_message_with`.
3569        if options.content_type == Some(ContentType::FileChunk) {
3570            return Err(Error::InvalidArgument(
3571                "FileChunk is an internal content type and cannot be sent directly".to_string(),
3572            ));
3573        }
3574        Self::check_group_rich_extras_size(options.media_metadata.as_ref(), None)?;
3575        self.send_group_message_inner(
3576            group_id,
3577            content,
3578            options.priority.unwrap_or(MessagePriority::Medium),
3579            options.reply_to_msg.as_deref(),
3580            None,
3581            options.media_metadata,
3582            options.content_type.unwrap_or_default(),
3583        )
3584    }
3585
3586    /// Forwards a message to all members of a group with forwarding attribution.
3587    ///
3588    /// Similar to `send_group_message` but attaches `ForwardInfo` to preserve
3589    /// the original sender and forward count. The message content is encrypted
3590    /// via MLS for the group and fan-out follows the same path as regular
3591    /// group messages (including relay broadcast when available).
3592    ///
3593    /// When every other group member advertised the sealed rich payload, the
3594    /// attribution and the original `media_metadata` — including cloud-media
3595    /// `encryption_key`/`iv` secrets, which only the sealed body may carry —
3596    /// travel inside the group MLS ciphertext, so forwarded cloud media stays
3597    /// openable. Otherwise the media metadata is dropped (never cleartext)
3598    /// and only the hop-visible payload attribution survives.
3599    pub fn forward_message_to_group(
3600        &mut self,
3601        original_message: &Message,
3602        group_id: &str,
3603        priority: Option<MessagePriority>,
3604    ) -> Result<Vec<MessageId>> {
3605        // Reject content that starts with an internal control prefix to prevent
3606        // injection of protocol-level messages through the forwarding API.
3607        if Self::is_internal_prefix(&original_message.content) {
3608            return Err(Error::InvalidArgument(
3609                "Cannot forward a message with reserved internal prefix content".to_string(),
3610            ));
3611        }
3612
3613        // FileChunk is an internal transport content type — same boundary
3614        // rule as the DM forward path.
3615        if original_message.content_type == ContentType::FileChunk {
3616            return Err(Error::InvalidArgument(
3617                "FileChunk is an internal content type and cannot be forwarded".to_string(),
3618            ));
3619        }
3620
3621        let forward_info = ForwardInfo::from_message(original_message);
3622
3623        if forward_info.forward_count > crate::constants::MAX_FORWARD_COUNT {
3624            return Err(Error::InvalidArgument(format!(
3625                "Forward count {} exceeds maximum of {}",
3626                forward_info.forward_count,
3627                crate::constants::MAX_FORWARD_COUNT,
3628            )));
3629        }
3630
3631        Self::check_group_rich_extras_size(
3632            original_message.media_metadata.as_ref(),
3633            Some(&forward_info),
3634        )?;
3635
3636        self.send_group_message_inner(
3637            group_id,
3638            &original_message.content,
3639            priority.unwrap_or(MessagePriority::Medium),
3640            None,
3641            Some(forward_info),
3642            original_message.media_metadata.clone(),
3643            original_message.content_type,
3644        )
3645    }
3646
3647    /// Boundary cap for group rich extras, mirroring `send_message_with`:
3648    /// bound the serialized extras here so the seal (or a relay-broadcast
3649    /// fallback re-send) can never fail on size later.
3650    fn check_group_rich_extras_size(
3651        media_metadata: Option<&MediaMetadata>,
3652        forward_info: Option<&ForwardInfo>,
3653    ) -> Result<()> {
3654        RichSendExtras {
3655            reply_context: None,
3656            media_metadata: media_metadata.cloned(),
3657            forward_info: forward_info.cloned(),
3658        }
3659        .check_size()
3660    }
3661
3662    /// Whether a rich group send right now would seal its extras, and which
3663    /// members are in the way. See [`GroupRichReadiness`]. Read-only: uses
3664    /// the fan-out cache with an MLS fallback but never mutates state, and
3665    /// does not probe unknown members (the send path's drop branch does).
3666    pub fn group_rich_readiness(&self, group_id: &str) -> Result<GroupRichReadiness> {
3667        let members = match self.group_mesh.members.get(group_id) {
3668            Some(m) => m.clone(),
3669            None => {
3670                let mls_guard = self.read_mls_guard()?;
3671                let gid = offline_protocol_mls::GroupId::new(group_id)?;
3672                mls_guard
3673                    .get_group_info(&gid)?
3674                    .ok_or_else(|| Error::GroupNotFound(group_id.to_string()))?
3675                    .members
3676            }
3677        };
3678        let unknown_members = if self.config.encryption.rich_payload_enabled {
3679            self.group_rich_unknown_members(&members)
3680        } else {
3681            Vec::new()
3682        };
3683        Ok(GroupRichReadiness {
3684            ready: self.group_rich_seal_active(&members),
3685            unknown_members,
3686        })
3687    }
3688
3689    /// The relay-side registration state of a group. See [`RelaySyncState`].
3690    ///
3691    /// `Synced` wins over an outstanding re-registration: once the relay has
3692    /// positively acknowledged the group on this connection, its registry
3693    /// entry survives regardless of any in-flight idempotent re-sync, so
3694    /// relay-dependent commands are already safe to issue.
3695    pub fn group_relay_sync_state(&self, group_id: &str) -> RelaySyncState {
3696        if self.group_mesh.relay_synced.contains(group_id) {
3697            RelaySyncState::Synced
3698        } else if self
3699            .group_mesh
3700            .relay_register_pending
3701            .contains_key(group_id)
3702        {
3703            RelaySyncState::Pending
3704        } else {
3705            RelaySyncState::Unsynced
3706        }
3707    }
3708
3709    /// Registers (or re-registers) a group with the relay server on demand.
3710    ///
3711    /// The supported path for making a mesh-created group known to the
3712    /// relay before issuing relay-dependent server commands for it (invite
3713    /// links, server-side fan-out) — never raw-send `CreateGroup`. The
3714    /// outcome arrives asynchronously as `GroupRelaySyncChanged`:
3715    /// `synced: true, reason: "registered"` on the relay's ack, `false`
3716    /// with `"error"` / `"ack_timeout"` on denial or a relay that never
3717    /// answers. Idempotent: an already-`Synced` group returns `Ok(true)`
3718    /// without re-sending (the automatic re-sync on membership changes
3719    /// covers roster updates).
3720    ///
3721    /// Returns `Ok(true)` when the registration frame was queued (or the
3722    /// group is already synced), `Ok(false)` when relay grouping is
3723    /// disabled or the Internet transport is unavailable, `Err` when the
3724    /// group is unknown locally.
3725    pub fn request_group_relay_registration(&mut self, group_id: &str) -> Result<bool> {
3726        if self.group_mesh.relay_synced.contains(group_id) {
3727            return Ok(true);
3728        }
3729        // Fresh membership, like the retry processor: the MLS roster is
3730        // authoritative, the fan-out cache is the fallback.
3731        let members = self
3732            .refresh_group_members(group_id)
3733            .ok()
3734            .or_else(|| self.group_mesh.members.get(group_id).cloned())
3735            .ok_or_else(|| Error::GroupNotFound(group_id.to_string()))?;
3736        self.try_relay_register_group(group_id, None, &members)
3737    }
3738
3739    /// Shared implementation for sending and forwarding group messages.
3740    ///
3741    /// Handles the sealed-rich decision, MLS encryption, member list
3742    /// caching, relay broadcast attempt, per-member fan-out, and event
3743    /// emission.
3744    #[allow(clippy::too_many_arguments)]
3745    fn send_group_message_inner(
3746        &mut self,
3747        group_id: &str,
3748        content: &str,
3749        priority: MessagePriority,
3750        reply_to_msg: Option<&str>,
3751        forward_info: Option<ForwardInfo>,
3752        media_metadata: Option<MediaMetadata>,
3753        content_type: ContentType,
3754    ) -> Result<Vec<MessageId>> {
3755        // Read member list from cache, falling back to MLS on cache miss.
3756        // Fetched before encryption because the sealed-rich decision below
3757        // needs the full membership: one ciphertext serves every member.
3758        let members = match self.group_mesh.members.get(group_id) {
3759            Some(m) => m.clone(),
3760            None => {
3761                let mls_guard = self.read_mls_guard()?;
3762                let gid = offline_protocol_mls::GroupId::new(group_id)?;
3763                let info = mls_guard
3764                    .get_group_info(&gid)?
3765                    .ok_or_else(|| Error::GroupNotFound(group_id.to_string()))?;
3766                info.members.clone()
3767            }
3768        };
3769
3770        // Update cache if it was a miss
3771        if !self.group_mesh.members.contains_key(group_id) {
3772            self.group_mesh
3773                .members
3774                .insert(group_id.to_string(), members.clone());
3775        }
3776
3777        // Rich extras travel only inside the sealed `__RICH_V1__` body of
3778        // the group ciphertext (media secrets must never leave the AEAD
3779        // boundary), and only when every other member advertised the
3780        // capability — a legacy member would render the sealed body as
3781        // literal JSON text. For unsealed sends the attribution rides the
3782        // hop-visible payload below as the fallback; media metadata has no
3783        // such fallback and simply drops (surfaced to the app via
3784        // `GroupRichExtrasDropped` — the send itself still succeeds). A
3785        // non-Text hint seals a hint-only body even without extras
3786        // (mirroring the DM path): the group payload has no outer
3787        // content_type carrier, so an unsealed hint would not merely go
3788        // unprotected — it would be lost.
3789        let extras = RichSendExtras {
3790            reply_context: None,
3791            media_metadata,
3792            forward_info: forward_info.clone(),
3793        };
3794        let wants_seal = extras.is_any() || content_type != ContentType::Text;
3795        let sealed = wants_seal && self.group_rich_seal_active(&members);
3796        let sealed_body;
3797        let plaintext: &str = if sealed {
3798            sealed_body = Self::seal_rich_payload(content, &extras, content_type)?;
3799            &sealed_body
3800        } else {
3801            // Members holding the gate closed (empty when the local kill
3802            // switch is the cause — with recording disabled, listing every
3803            // member would be noise, and probing them could not reopen the
3804            // gate anyway). Probing sends them our key package so their
3805            // auto-exchange reply teaches us their capability: unknown
3806            // members are typically ones somebody else added, healed here
3807            // for groups predating attestation or behind an old-SDK inviter.
3808            // Only sends that wanted to seal consume the set (backfill here,
3809            // blame on the drop event below) — plain text sends skip the
3810            // membership scan entirely.
3811            let unknown = if wants_seal && self.config.encryption.rich_payload_enabled {
3812                self.group_rich_unknown_members(&members)
3813            } else {
3814                Vec::new()
3815            };
3816            if !unknown.is_empty() {
3817                self.backfill_group_rich_capabilities(&unknown);
3818            }
3819            if extras.media_metadata.is_some() {
3820                warn!(
3821                    group_id = %group_id,
3822                    "Group not fully rich-capable, dropping rich media metadata (members \
3823                     will receive the text without the media attachment)"
3824                );
3825                self.emit_event(Event::group_rich_extras_dropped(
3826                    group_id.to_string(),
3827                    unknown,
3828                ));
3829            }
3830            content
3831        };
3832
3833        // When the body sealed, every member reads the sealed attribution
3834        // and the receiver ignores the payload copy wholesale — omit it
3835        // rather than expose original-sender metadata to relays and mesh
3836        // hops for nobody's benefit. (A stale-cache just-added non-capable
3837        // member loses degraded-display attribution in that window;
3838        // accepted, consistent with them rendering the sealed body as
3839        // literal JSON anyway.)
3840        let payload_forward_info = if sealed { None } else { forward_info };
3841
3842        // Encrypt via MLS — release the guard immediately after encryption
3843        // to minimize lock contention during the fan-out phase.
3844        let encrypted = {
3845            let mls_guard = self.read_mls_guard()?;
3846            let gid = offline_protocol_mls::GroupId::new(group_id)?;
3847            mls_guard.encrypt_for_group(&gid, plaintext.as_bytes())?
3848        };
3849
3850        let ciphertext_b64 = base64_encode(&encrypted.ciphertext);
3851        let epoch = encrypted.epoch;
3852
3853        let self_id = self.local_id.clone();
3854
3855        // --- Attempt relay broadcast first ---
3856        // If the group is registered, the app opted in, and the relay
3857        // advertised the v3 group-delivery contract, a single relay
3858        // broadcast is O(1) instead of O(N) individual sends.
3859        //
3860        // The capability gate is what makes the broadcast safe to take by
3861        // default: a `group_delivery_v3` relay answers every broadcast with
3862        // a settled per-recipient delivery report, which the sender consumes
3863        // (`handle_relay_group_delivery_report`) to re-send per-member
3864        // copies to anyone the relay did not reach — so the broadcast keeps
3865        // a delivery contract instead of being fire-and-forget — and names
3866        // members in the same address space this SDK registered, so that
3867        // report is comparable against the MLS roster at all (see
3868        // [`RELAY_CAP_GROUP_DELIVERY_V3`] for why a v2 relay must fail this
3869        // gate). Against an older relay the gate simply fails and sends take
3870        // the always-correct per-member fan-out.
3871        //
3872        // `is_internet_available()` is re-checked here and not inferred from
3873        // `relay_synced`: sync state is cleared by the `process()` tick, so
3874        // between Internet dropping and the next tick a stale `relay_synced`
3875        // would otherwise route the broadcast into the mesh.
3876        if self.config.group.relay_broadcast_enabled
3877            && self.group_mesh.relay_synced.contains(group_id)
3878            && self
3879                .group_mesh
3880                .relay_capabilities
3881                .contains(RELAY_CAP_GROUP_DELIVERY_V3)
3882            && self.is_internet_available()
3883        {
3884            if let Ok(mid) = self.try_relay_broadcast(
3885                group_id,
3886                &ciphertext_b64,
3887                epoch,
3888                reply_to_msg,
3889                payload_forward_info.clone(),
3890                priority,
3891            ) {
3892                let member_count = members.iter().filter(|m| m.as_str() != self_id).count() as u32;
3893                self.emit_event(Event::group_message_sent(
3894                    group_id.to_string(),
3895                    vec![mid.as_str().to_string()],
3896                    member_count,
3897                ));
3898                return Ok(vec![mid]);
3899            }
3900            // Relay broadcast failed — fall through to per-member fan-out
3901        }
3902
3903        // Per-member fan-out (mesh or DORS-selected transport)
3904        let (message_ids, succeeded_members, failed_members) = self
3905            .fanout_group_frames_per_member(
3906                group_id,
3907                &members,
3908                None,
3909                &ciphertext_b64,
3910                epoch,
3911                reply_to_msg,
3912                payload_forward_info,
3913                priority,
3914            )?;
3915
3916        let member_count = succeeded_members.len() as u32;
3917
3918        // Check for total delivery failure: there were members to send to but
3919        // every send failed. Return an error so callers don't confuse this with
3920        // a solo-group scenario (which legitimately returns Ok(vec![])).
3921        let had_recipients = members.iter().any(|m| m != &self_id);
3922        if had_recipients && message_ids.is_empty() {
3923            self.emit_event(Event::group_message_partial_failure(
3924                group_id.to_string(),
3925                failed_members,
3926                succeeded_members,
3927            ));
3928            return Err(Error::Transport(
3929                offline_protocol_transport::Error::SendFailed(
3930                    "All group message sends failed".to_string(),
3931                ),
3932            ));
3933        }
3934
3935        // Emit appropriate event
3936        if failed_members.is_empty() {
3937            self.emit_event(Event::group_message_sent(
3938                group_id.to_string(),
3939                message_ids.iter().map(|m| m.as_str().to_string()).collect(),
3940                member_count,
3941            ));
3942        } else {
3943            self.emit_event(Event::group_message_partial_failure(
3944                group_id.to_string(),
3945                failed_members,
3946                succeeded_members,
3947            ));
3948        }
3949
3950        Ok(message_ids)
3951    }
3952
3953    /// Lists all MLS groups (excluding 1:1 sessions).
3954    pub fn list_groups(&self) -> Result<Vec<String>> {
3955        let mls_guard = self.read_mls_guard()?;
3956        let groups = mls_guard.list_groups()?;
3957        Ok(groups.into_iter().map(|g| g.as_str().to_string()).collect())
3958    }
3959
3960    /// Gets information about an MLS group.
3961    pub fn get_group_info(
3962        &self,
3963        group_id: &str,
3964    ) -> Result<Option<offline_protocol_mls::GroupInfo>> {
3965        let mls_guard = self.read_mls_guard()?;
3966        let gid = offline_protocol_mls::GroupId::new(group_id)?;
3967        Ok(mls_guard.get_group_info(&gid)?)
3968    }
3969
3970    // ========================================================================
3971    // GROUP ROLES
3972    // ========================================================================
3973
3974    /// Deterministically promotes the lexicographically-first remaining member
3975    /// to admin if no admin role remains. All nodes use the same sort order,
3976    /// so the promotion converges to the same candidate network-wide.
3977    fn auto_promote_if_no_admin(&self, group_id: &str, members: &[String]) {
3978        if members.is_empty() {
3979            return;
3980        }
3981        if let Ok(mls_guard) = self.read_mls_guard() {
3982            let Ok(gid) = offline_protocol_mls::GroupId::new(group_id) else {
3983                return;
3984            };
3985            if let Some(metadata) = mls_guard.get_group_metadata(&gid).ok().flatten() {
3986                if metadata.has_any_admin() {
3987                    return;
3988                }
3989                let mut sorted = members.to_vec();
3990                sorted.sort();
3991                let promote_id = &sorted[0];
3992                if let Err(e) = mls_guard.set_member_role(&gid, promote_id, GroupRole::Admin) {
3993                    warn!(user_id = %promote_id, error = %e, "Failed to auto-promote member to admin");
3994                } else {
3995                    info!(user_id = %promote_id, group_id = %group_id, "Auto-promoted member to admin after last admin was removed");
3996                }
3997            }
3998        }
3999    }
4000
4001    /// Loads a group's role metadata, failing with [`Error::GroupNotFound`]
4002    /// when the group has neither metadata nor MLS state. Admin-gated
4003    /// operations use this so a missing group is not misreported as a
4004    /// permissions failure.
4005    fn group_metadata_or_not_found(
4006        mls_guard: &offline_protocol_mls::MlsManager,
4007        gid: &offline_protocol_mls::GroupId,
4008        group_id: &str,
4009    ) -> Result<Option<offline_protocol_mls::GroupMetadata>> {
4010        let metadata = mls_guard.get_group_metadata(gid)?;
4011        if metadata.is_none() && mls_guard.get_group_info(gid)?.is_none() {
4012            return Err(Error::GroupNotFound(group_id.to_string()));
4013        }
4014        Ok(metadata)
4015    }
4016
4017    /// Checks if a user is an admin of the given group.
4018    ///
4019    /// Falls back to `created_by` when no admin role has been stored yet
4020    /// (handles groups created before role tracking was introduced).
4021    ///
4022    /// Returns `GroupNotFound` when no MLS group exists locally, so
4023    /// admin-gated operations don't misreport a missing group as a
4024    /// permissions failure. Inbound handlers that verify senders treat
4025    /// this error the same as a deny.
4026    pub(crate) fn check_is_admin(&self, group_id: &str, user_id: &str) -> Result<bool> {
4027        let mls_guard = self.read_mls_guard()?;
4028        let gid = offline_protocol_mls::GroupId::new(group_id)?;
4029        let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id)?;
4030        drop(mls_guard);
4031
4032        if let Some(meta) = &metadata {
4033            if meta.has_any_admin() {
4034                return Ok(meta.get_role(user_id) == GroupRole::Admin);
4035            }
4036            // No admin role stored — fall back to created_by if available
4037            if let Some(creator) = &meta.created_by {
4038                return Ok(creator == user_id);
4039            }
4040        }
4041
4042        // Group exists but has no role metadata — deny by default
4043        Ok(false)
4044    }
4045
4046    /// Changes a member's role in a group (admin only).
4047    /// Broadcasts the change to all group members.
4048    pub fn set_member_role(
4049        &mut self,
4050        group_id: &str,
4051        target_user_id: &str,
4052        role: GroupRole,
4053    ) -> Result<()> {
4054        // Deliberately unvalidated — see `invite_to_group`. Demoting a member
4055        // admitted under older id rules must stay possible.
4056        let self_id = self.local_id.clone();
4057        if !self.check_is_admin(group_id, &self_id)? {
4058            return Err(Error::PermissionDenied(
4059                "Only admins can change roles".to_string(),
4060            ));
4061        }
4062
4063        // Verify target is a member (before acquiring guard for role operations)
4064        let members = self
4065            .group_mesh
4066            .members
4067            .get(group_id)
4068            .cloned()
4069            .or_else(|| self.refresh_group_members(group_id).ok())
4070            .unwrap_or_default();
4071        if !members.iter().any(|m| m == target_user_id) {
4072            return Err(Error::InvalidState(format!(
4073                "User {} is not a member of group {}",
4074                target_user_id, group_id
4075            )));
4076        }
4077
4078        // Single guard acquisition for both last-admin validation and role write
4079        {
4080            let mls_guard = self.read_mls_guard()?;
4081            let gid = offline_protocol_mls::GroupId::new(group_id)?;
4082
4083            // Prevent demoting the last admin (whether self or another admin)
4084            if role == GroupRole::Member {
4085                if let Some(metadata) = mls_guard.get_group_metadata(&gid).ok().flatten() {
4086                    if metadata.get_role(target_user_id) == GroupRole::Admin {
4087                        let admin_count = metadata
4088                            .get_all_roles()
4089                            .values()
4090                            .filter(|r| **r == GroupRole::Admin)
4091                            .count();
4092                        if admin_count <= 1 {
4093                            return Err(Error::InvalidState(
4094                                "Cannot demote the last admin".to_string(),
4095                            ));
4096                        }
4097                    }
4098                }
4099            }
4100
4101            // Store locally
4102            mls_guard.set_member_role(&gid, target_user_id, role)?;
4103        }
4104
4105        // Broadcast to all members
4106        let payload = GroupRoleChangePayload {
4107            group_id: group_id.to_string(),
4108            target_user_id: target_user_id.to_string(),
4109            new_role: role,
4110            changed_by: self_id.clone(),
4111        };
4112        let content = format!(
4113            "{}{}",
4114            internal_prefixes::GROUP_ROLE_CHANGE,
4115            serde_json::to_string(&payload)
4116                .map_err(|e| Error::Serialization(format!("Serialize role change: {}", e)))?
4117        );
4118        for member in &members {
4119            if member == &self_id {
4120                continue;
4121            }
4122            if let Err(e) =
4123                self.send_internal_message(member, content.clone(), MessagePriority::High)
4124            {
4125                warn!(member = %member, group_id = %group_id, error = %e, "Failed to send role change broadcast");
4126            }
4127        }
4128
4129        self.emit_event(Event::group_role_changed(
4130            group_id.to_string(),
4131            target_user_id.to_string(),
4132            role.to_string(),
4133            self_id,
4134        ));
4135
4136        info!(group_id = %group_id, target = %target_user_id, role = %role, "Changed member role");
4137        Ok(())
4138    }
4139
4140    /// Gets a member's role in a group.
4141    ///
4142    /// A group that exists but has no role metadata (created before role
4143    /// tracking) reports the same default an unrecorded user gets: `Member`.
4144    pub fn get_member_role(&self, group_id: &str, user_id: &str) -> Result<GroupRole> {
4145        let mls_guard = self.read_mls_guard()?;
4146        let gid = offline_protocol_mls::GroupId::new(group_id)?;
4147        let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id)?;
4148        Ok(metadata.map(|m| m.get_role(user_id)).unwrap_or_default())
4149    }
4150
4151    /// Gets all member roles in a group.
4152    ///
4153    /// A group that exists but has no role metadata (created before role
4154    /// tracking) reports no explicit roles.
4155    pub fn get_group_roles(&self, group_id: &str) -> Result<HashMap<String, GroupRole>> {
4156        let mls_guard = self.read_mls_guard()?;
4157        let gid = offline_protocol_mls::GroupId::new(group_id)?;
4158        let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id)?;
4159        Ok(metadata.map(|m| m.get_all_roles()).unwrap_or_default())
4160    }
4161
4162    /// Handles an incoming group role change notification.
4163    pub(crate) fn handle_group_role_change(&mut self, message_id: &str, sender: &str, data: &str) {
4164        let payload = match serde_json::from_str::<GroupRoleChangePayload>(data) {
4165            Ok(p) => p,
4166            Err(_) => {
4167                warn!(sender = %sender, "Failed to parse GroupRoleChange payload");
4168                return;
4169            }
4170        };
4171
4172        // Dedup
4173        let dedup_key = message_id.to_string();
4174        if self.group_mesh.message_dedup.contains_key(&dedup_key) {
4175            return;
4176        }
4177        self.group_mesh
4178            .message_dedup
4179            .insert(dedup_key, Instant::now());
4180
4181        // Verify sender is admin (use transport-authenticated sender, not payload.changed_by)
4182        match self.check_is_admin(&payload.group_id, sender) {
4183            Ok(true) => {}
4184            Ok(false) => {
4185                error!(
4186                    sender = %sender,
4187                    group_id = %payload.group_id,
4188                    "SECURITY: Role change from non-admin, ignoring"
4189                );
4190                return;
4191            }
4192            Err(e) => {
4193                warn!(
4194                    sender = %sender,
4195                    group_id = %payload.group_id,
4196                    error = %e,
4197                    "Failed to verify admin status for role change"
4198                );
4199                return;
4200            }
4201        }
4202
4203        // Store role locally
4204        let Ok(gid) = offline_protocol_mls::GroupId::new(&payload.group_id) else {
4205            warn!(group_id = %payload.group_id, "Dropping role change with invalid group id");
4206            return;
4207        };
4208        if let Ok(mls_guard) = self.read_mls_guard() {
4209            if let Err(e) =
4210                mls_guard.set_member_role(&gid, &payload.target_user_id, payload.new_role)
4211            {
4212                warn!(
4213                    target = %payload.target_user_id,
4214                    error = %e,
4215                    "Failed to store role from incoming role change"
4216                );
4217            }
4218        }
4219
4220        // Use transport-authenticated sender as changed_by, not the untrusted payload field
4221        self.emit_event(Event::group_role_changed(
4222            payload.group_id,
4223            payload.target_user_id,
4224            payload.new_role.to_string(),
4225            sender.to_string(),
4226        ));
4227    }
4228
4229    // ========================================================================
4230    // GROUP RENAME
4231    // ========================================================================
4232
4233    /// Renames a group (admin only).
4234    /// Updates the local group name and broadcasts the change to all members.
4235    pub fn rename_group(&mut self, group_id: &str, new_name: &str) -> Result<()> {
4236        let trimmed = new_name.trim();
4237        if trimmed.is_empty() {
4238            return Err(Error::InvalidArgument(
4239                "Group name cannot be empty".to_string(),
4240            ));
4241        }
4242        let self_id = self.local_id.clone();
4243        if !self.check_is_admin(group_id, &self_id)? {
4244            return Err(Error::PermissionDenied(
4245                "Only admins can rename groups".to_string(),
4246            ));
4247        }
4248
4249        // Load old name before updating
4250        let old_name = {
4251            let mls_guard = self.read_mls_guard()?;
4252            let gid = offline_protocol_mls::GroupId::new(group_id)?;
4253            mls_guard
4254                .get_group_metadata(&gid)?
4255                .and_then(|m| m.name.clone())
4256        };
4257
4258        // Update locally
4259        {
4260            let mls_guard = self.read_mls_guard()?;
4261            let gid = offline_protocol_mls::GroupId::new(group_id)?;
4262            mls_guard.set_group_name(&gid, new_name)?;
4263        }
4264
4265        // Broadcast to all members
4266        let members = self
4267            .group_mesh
4268            .members
4269            .get(group_id)
4270            .cloned()
4271            .or_else(|| self.refresh_group_members(group_id).ok())
4272            .unwrap_or_default();
4273
4274        let payload = GroupRenamePayload {
4275            group_id: group_id.to_string(),
4276            new_name: new_name.to_string(),
4277            renamed_by: self_id.clone(),
4278        };
4279        let content = format!(
4280            "{}{}",
4281            internal_prefixes::GROUP_RENAME,
4282            serde_json::to_string(&payload)
4283                .map_err(|e| Error::Serialization(format!("Serialize group rename: {}", e)))?
4284        );
4285        for member in &members {
4286            if member == &self_id {
4287                continue;
4288            }
4289            if let Err(e) =
4290                self.send_internal_message(member, content.clone(), MessagePriority::High)
4291            {
4292                warn!(member = %member, group_id = %group_id, error = %e, "Failed to send group rename broadcast");
4293            }
4294        }
4295
4296        self.emit_event(Event::group_renamed(
4297            group_id.to_string(),
4298            new_name.to_string(),
4299            old_name,
4300            self_id,
4301        ));
4302
4303        // Update relay registration with new name
4304        let _ = self.try_relay_register_group(group_id, Some(new_name), &members);
4305
4306        info!(group_id = %group_id, new_name = %new_name, "Renamed group");
4307        Ok(())
4308    }
4309
4310    /// Handles an incoming group rename notification.
4311    pub(crate) fn handle_group_rename(&mut self, message_id: &str, sender: &str, data: &str) {
4312        let payload = match serde_json::from_str::<GroupRenamePayload>(data) {
4313            Ok(p) => p,
4314            Err(_) => {
4315                warn!(sender = %sender, "Failed to parse GroupRename payload");
4316                return;
4317            }
4318        };
4319
4320        // Dedup
4321        let dedup_key = message_id.to_string();
4322        if self.group_mesh.message_dedup.contains_key(&dedup_key) {
4323            return;
4324        }
4325        self.group_mesh
4326            .message_dedup
4327            .insert(dedup_key, Instant::now());
4328
4329        // Verify sender is admin (use transport-authenticated sender)
4330        match self.check_is_admin(&payload.group_id, sender) {
4331            Ok(true) => {}
4332            Ok(false) => {
4333                error!(
4334                    sender = %sender,
4335                    group_id = %payload.group_id,
4336                    "SECURITY: Group rename from non-admin, ignoring"
4337                );
4338                return;
4339            }
4340            Err(e) => {
4341                warn!(
4342                    sender = %sender,
4343                    group_id = %payload.group_id,
4344                    error = %e,
4345                    "Failed to verify admin status for group rename"
4346                );
4347                return;
4348            }
4349        }
4350
4351        let Ok(gid) = offline_protocol_mls::GroupId::new(&payload.group_id) else {
4352            warn!(group_id = %payload.group_id, "Dropping group rename with invalid group id");
4353            return;
4354        };
4355
4356        // Load old name before updating
4357        let old_name = if let Ok(mls_guard) = self.read_mls_guard() {
4358            mls_guard
4359                .get_group_metadata(&gid)
4360                .ok()
4361                .flatten()
4362                .and_then(|m| m.name.clone())
4363        } else {
4364            None
4365        };
4366
4367        // Store new name locally
4368        if let Ok(mls_guard) = self.read_mls_guard() {
4369            if let Err(e) = mls_guard.set_group_name(&gid, &payload.new_name) {
4370                warn!(
4371                    group_id = %payload.group_id,
4372                    error = %e,
4373                    "Failed to store new group name from incoming rename"
4374                );
4375            }
4376        }
4377
4378        // Use transport-authenticated sender as renamed_by
4379        self.emit_event(Event::group_renamed(
4380            payload.group_id,
4381            payload.new_name,
4382            old_name,
4383            sender.to_string(),
4384        ));
4385    }
4386
4387    /// Cleans up expired group message dedup entries and enforces size cap.
4388    pub(crate) fn cleanup_group_message_dedup(&mut self) {
4389        // Ages are compared with `saturating_duration_since` rather than a
4390        // precomputed `now - TTL` cutoff: on platforms where the monotonic
4391        // clock starts at boot, that subtraction underflows (and panics)
4392        // when the process is younger than the TTL.
4393        let now = Instant::now();
4394        let dedup_ttl = StdDuration::from_secs(GROUP_MESSAGE_DEDUP_TTL_SECS);
4395        self.group_mesh
4396            .message_dedup
4397            .retain(|_, seen_at| now.saturating_duration_since(*seen_at) < dedup_ttl);
4398        // If still over cap after TTL cleanup, drop oldest entries using O(N) selection
4399        let len = self.group_mesh.message_dedup.len();
4400        if len > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
4401            let mut entries: Vec<_> = self.group_mesh.message_dedup.drain().collect();
4402            // Partition so the newest MAX entries are in [..MAX]
4403            entries.select_nth_unstable_by_key(MAX_GROUP_MESSAGE_DEDUP_ENTRIES, |(_, ts)| {
4404                std::cmp::Reverse(*ts)
4405            });
4406            entries.truncate(MAX_GROUP_MESSAGE_DEDUP_ENTRIES);
4407            self.group_mesh.message_dedup = entries.into_iter().collect();
4408        }
4409
4410        // Expire stale pending commits, tracking groups where retried commits
4411        // expired — these are strong epoch fork signals (same as drain_pending_commits).
4412        // Expired IDs are collected for replay-protection release below: the
4413        // group-level dedup entry is at least as old as the buffered copy and
4414        // was already dropped by the retain above (same `now`, same TTL), but
4415        // the transport-level deduplicator has a longer retention window and
4416        // must be released explicitly or a redelivery is swallowed at the
4417        // receive loop.
4418        let commit_ttl = StdDuration::from_secs(PENDING_COMMIT_TTL_SECS);
4419        let mut fork_candidates: Vec<String> = Vec::new();
4420        let mut expired_ids: Vec<String> = Vec::new();
4421        self.group_mesh.pending_commits.retain(|group_id, commits| {
4422            let mut retried_expired = false;
4423            commits.retain(|c| {
4424                let alive = now.saturating_duration_since(c.buffered_at) < commit_ttl;
4425                if !alive {
4426                    if c.retry_count > 0 {
4427                        retried_expired = true;
4428                    }
4429                    expired_ids.push(c.message_id.clone());
4430                }
4431                alive
4432            });
4433            if retried_expired {
4434                fork_candidates.push(group_id.clone());
4435            }
4436            !commits.is_empty()
4437        });
4438        // Flag forks for groups where retried commits expired during periodic
4439        // cleanup — this covers the case where drain_pending_commits is never
4440        // called because no new commits succeed for the group.
4441        for group_id in fork_candidates {
4442            if !self.group_mesh.epoch_forks.contains_key(&group_id) {
4443                self.flag_potential_epoch_fork(&group_id);
4444            }
4445        }
4446
4447        // Expire stale pending group messages — covers groups where no
4448        // Welcome or commit ever arrives to trigger a drain.
4449        let msg_ttl = StdDuration::from_secs(PENDING_GROUP_MESSAGE_TTL_SECS);
4450        self.group_mesh.pending_group_messages.retain(|_, msgs| {
4451            msgs.retain(|m| {
4452                let alive = now.saturating_duration_since(m.buffered_at) < msg_ttl;
4453                if !alive {
4454                    expired_ids.push(m.message_id.clone());
4455                }
4456                alive
4457            });
4458            !msgs.is_empty()
4459        });
4460
4461        // Entries that expired here were never processed/delivered — release
4462        // their replay protection so a redelivery is accepted fresh.
4463        for message_id in expired_ids {
4464            self.release_replay_protection(&message_id);
4465        }
4466    }
4467
4468    // ========================================================================
4469    // RELAY OPTIMIZATION
4470    // ========================================================================
4471
4472    /// Best-effort admin hint for relay group registration: `Some` only when
4473    /// role metadata (or the creator fallback) makes the answer definite,
4474    /// `None` when it cannot be determined. Unlike [`Self::check_is_admin`],
4475    /// this must not deny by default — a wrong `false` would make the bridge
4476    /// silently skip member deltas the relay would have accepted, and the
4477    /// relay registry would never learn the membership.
4478    fn relay_registration_admin_hint(&self, group_id: &str) -> Option<bool> {
4479        let mls_guard = self.read_mls_guard().ok()?;
4480        let gid = offline_protocol_mls::GroupId::new(group_id).ok()?;
4481        let metadata = Self::group_metadata_or_not_found(&mls_guard, &gid, group_id).ok()??;
4482        if metadata.has_any_admin() {
4483            return Some(metadata.get_role(&self.local_id) == GroupRole::Admin);
4484        }
4485        metadata
4486            .created_by
4487            .as_ref()
4488            .map(|creator| creator == &self.local_id)
4489    }
4490
4491    /// Attempts to register (or update) a group with the relay server.
4492    ///
4493    /// Sends a `__GRP_RELAY_REG__` message to the user's own ID via Internet
4494    /// transport, for a relay (or platform bridge acting as relay adapter)
4495    /// that translates the prefix into a server-side group registration.
4496    ///
4497    /// Enqueueing this frame proves nothing about relay support: a relay
4498    /// without prefix interception simply echoes a self-addressed message
4499    /// back to the sender. `relay_synced` must therefore only be set by a
4500    /// positive acknowledgment from the relay (an inbound `__GROUP_CREATED__`
4501    /// for the group), never here — otherwise `send_group_message` takes the
4502    /// O(1) broadcast path and the messages vanish into the echo.
4503    ///
4504    /// Fire-and-forget: returns `Ok(true)` if sent, `Ok(false)` if Internet
4505    /// is unavailable, `Err` on serialization failure.
4506    fn try_relay_register_group(
4507        &mut self,
4508        group_id: &str,
4509        group_name: Option<&str>,
4510        members: &[String],
4511    ) -> Result<bool> {
4512        if !self.config.group.relay_enabled || !self.is_internet_available() {
4513            return Ok(false);
4514        }
4515
4516        let payload = RelayGroupRegistrationPayload {
4517            group_id: group_id.to_string(),
4518            group_name: group_name.map(|s| s.to_string()),
4519            members: members.to_vec(),
4520            is_admin: self.relay_registration_admin_hint(group_id),
4521        };
4522        let content = format!(
4523            "{}{}",
4524            internal_prefixes::GROUP_RELAY_REGISTER,
4525            serde_json::to_string(&payload).map_err(|e| Error::Serialization(format!(
4526                "Serialize relay registration: {}",
4527                e
4528            )))?
4529        );
4530
4531        // Send to self — a prefix-aware relay (or bridge adapter) translates it.
4532        // Deliberately does NOT set `relay_synced`: Ok here only means the frame
4533        // was queued locally, and a prefix-unaware relay echoes it back instead
4534        // of registering anything. Sync is confirmed only by the relay's
4535        // `__GROUP_CREATED__` acknowledgment.
4536        //
4537        // Un-ACKed and Internet-pinned (see `send_relay_hint_message`): the
4538        // retry policy for registration is the `relay_register_pending`
4539        // tracker armed just below, not the message ACK ladder — which could
4540        // never settle this frame and would re-send it ~10 times.
4541        match self.send_relay_hint_message(content, MessagePriority::Medium) {
4542            Ok(_) => {
4543                // Arm the ack correlation: only a `__GROUP_CREATED__` that
4544                // answers an outstanding registration may set `relay_synced`.
4545                // Attempts accumulate across re-sends on this connection so
4546                // the expiry processor can give up on a relay that never
4547                // answers; the counter resets whenever the entry is removed
4548                // (ack, error, leave, internet drop).
4549                let entry = self
4550                    .group_mesh
4551                    .relay_register_pending
4552                    .entry(group_id.to_string())
4553                    .or_insert(RelayRegisterPending {
4554                        armed_at: Utc::now(),
4555                        attempts: 0,
4556                    });
4557                entry.armed_at = Utc::now();
4558                entry.attempts = entry.attempts.saturating_add(1);
4559                debug!(group_id = %group_id, attempts = entry.attempts, "Sent group registration to relay server");
4560                Ok(true)
4561            }
4562            Err(e) => {
4563                debug!(group_id = %group_id, error = %e, "Failed to register group with relay");
4564                Ok(false)
4565            }
4566        }
4567    }
4568
4569    /// Applies the sealed `__RICH_V1__` restore to a decrypted group
4570    /// message plaintext, returning the event-ready fields: content, media
4571    /// metadata, content-type hint, and forward attribution.
4572    ///
4573    /// Parsing is never capability-gated (whatever a peer chose to seal, we
4574    /// try to read). When the body parses, the sealed attribution is
4575    /// authoritative wholesale — absence included: a sealing sender always
4576    /// seals its `forward_info` when one exists, so the hop-visible payload
4577    /// copy is consulted only for unsealed messages. Falling back to it on
4578    /// a sealed body would let a relay attach fabricated attribution to a
4579    /// rich message that carried none (mirrors the DM restore, which
4580    /// overwrites the outer fields wholesale). A body that fails to parse
4581    /// surfaces as raw text with nothing restored: never drop an
4582    /// authenticated message.
4583    fn restore_group_rich(
4584        text: String,
4585        payload_forward_info: Option<ForwardInfo>,
4586        sender: &str,
4587    ) -> (
4588        String,
4589        Option<MediaMetadata>,
4590        Option<String>,
4591        Option<crate::events::ForwardInfoEvent>,
4592    ) {
4593        if let Some(rich) = RichPayloadV1::parse_sealed(&text, sender) {
4594            return (
4595                rich.text,
4596                rich.media_metadata,
4597                rich.content_type.map(|ct| ct.to_string()),
4598                rich.forward_info
4599                    .as_ref()
4600                    .map(crate::events::ForwardInfoEvent::from),
4601            );
4602        }
4603        (
4604            text,
4605            None,
4606            None,
4607            payload_forward_info
4608                .as_ref()
4609                .map(crate::events::ForwardInfoEvent::from),
4610        )
4611    }
4612
4613    /// Attempts to send a group message via relay broadcast.
4614    ///
4615    /// Sends a single `__GRP_RELAY_BCAST__` message to the user's own ID.
4616    /// The bridge translator (not the relay — see
4617    /// [`RelayGroupBroadcastPayload`]) turns it into a relay-native
4618    /// `SendGroupMessage`; the relay fans out to registered members, whose
4619    /// bridges inject the delivery as `__GROUP_MSG__` frames.
4620    ///
4621    /// Callers must only take this path when all four of
4622    /// `GroupConfig::relay_broadcast_enabled`, `relay_synced` (the relay
4623    /// acknowledged the group; otherwise a prefix-unaware relay swallows the
4624    /// frame), the relay's [`RELAY_CAP_GROUP_DELIVERY_V3`] capability, and
4625    /// `is_internet_available()` hold.
4626    ///
4627    /// Mints the logical message id the whole group will know this message
4628    /// by, carries it in the payload (the bridge stamps it onto the relay
4629    /// frame), and arms a [`RelayBroadcastPending`] entry keyed by it: the
4630    /// relay's settled `GroupMessageSent` report settles the entry and
4631    /// re-sends per-member copies to members the relay did not reach; a
4632    /// lost report re-sends the broadcast (bounded) and finally downgrades
4633    /// to full per-member fan-out. Returns that logical id on success — it
4634    /// is what `group_message_sent` reports to the app.
4635    ///
4636    /// On error (relay unreachable) the caller falls back to per-member
4637    /// fan-out. The frame goes out via [`Self::send_relay_hint_message`], so
4638    /// it is a pinned, un-ACKed one-shot: a failure surfaces here
4639    /// immediately instead of being deferred into a retry ladder that could
4640    /// never settle — the *report* tracker above is the bounded retry.
4641    fn try_relay_broadcast(
4642        &mut self,
4643        group_id: &str,
4644        ciphertext_b64: &str,
4645        epoch: u64,
4646        reply_to: Option<&str>,
4647        forward_info: Option<ForwardInfo>,
4648        priority: MessagePriority,
4649    ) -> Result<MessageId> {
4650        let logical_id = MessageId::new();
4651        let payload = RelayGroupBroadcastPayload {
4652            group_id: group_id.to_string(),
4653            ciphertext: ciphertext_b64.to_string(),
4654            epoch,
4655            reply_to: reply_to.map(|s| s.to_string()),
4656            forward_info: forward_info.clone(),
4657            message_id: logical_id.as_str(),
4658        };
4659        self.send_relay_broadcast_frame(&payload)?;
4660        // Keep the tracker bounded (see `MAX_RELAY_BROADCAST_PENDING`).
4661        // Terminates: every iteration removes exactly one entry.
4662        while self.group_mesh.relay_broadcast_pending.len() >= MAX_RELAY_BROADCAST_PENDING {
4663            let Some(oldest) = self
4664                .group_mesh
4665                .relay_broadcast_pending
4666                .iter()
4667                .min_by_key(|(_, pending)| pending.armed_at)
4668                .map(|(id, _)| id.clone())
4669            else {
4670                break;
4671            };
4672            let Some(stale) = self.group_mesh.relay_broadcast_pending.remove(&oldest) else {
4673                break;
4674            };
4675            warn!(
4676                msg_id = %oldest,
4677                "Relay broadcast tracker full, downgrading oldest to per-member fan-out"
4678            );
4679            self.downgrade_broadcast_to_per_member(&oldest, stale);
4680        }
4681        self.group_mesh.relay_broadcast_pending.insert(
4682            logical_id.as_str(),
4683            RelayBroadcastPending {
4684                group_id: group_id.to_string(),
4685                ciphertext_b64: ciphertext_b64.to_string(),
4686                epoch,
4687                reply_to: reply_to.map(|s| s.to_string()),
4688                forward_info,
4689                priority,
4690                armed_at: Utc::now(),
4691                attempts: 1,
4692            },
4693        );
4694        debug!(
4695            group_id = %group_id,
4696            msg_id = %logical_id,
4697            "Sent group message via relay broadcast, awaiting delivery report"
4698        );
4699        Ok(logical_id)
4700    }
4701
4702    /// Serializes and sends one `__GRP_RELAY_BCAST__` hint frame. Shared by
4703    /// the initial broadcast and the lost-report re-send, which must reuse
4704    /// the same payload (same logical `message_id`) so the relay's push
4705    /// dedup and every receiver's dedup hold across attempts.
4706    fn send_relay_broadcast_frame(&mut self, payload: &RelayGroupBroadcastPayload) -> Result<()> {
4707        let content = format!(
4708            "{}{}",
4709            internal_prefixes::GROUP_RELAY_BROADCAST,
4710            serde_json::to_string(payload)
4711                .map_err(|e| Error::Serialization(format!("Serialize relay broadcast: {}", e)))?
4712        );
4713        self.send_relay_hint_message(content, MessagePriority::Medium)?;
4714        Ok(())
4715    }
4716
4717    /// Fans a group message's MLS ciphertext out as one `__GRP_MLS_MSG__`
4718    /// frame per member (skipping self), each through
4719    /// `send_internal_message` and therefore the whole per-member delivery
4720    /// ladder. `logical_id` is `Some` when the copies re-issue a relay
4721    /// broadcast — receivers then dedup/emit under that id (see
4722    /// [`GroupMlsMessagePayload::message_id`]).
4723    ///
4724    /// Returns `(frame ids, succeeded members, failed members)`; errors only
4725    /// on payload serialization.
4726    #[allow(clippy::too_many_arguments)]
4727    fn fanout_group_frames_per_member(
4728        &mut self,
4729        group_id: &str,
4730        members: &[String],
4731        logical_id: Option<&str>,
4732        ciphertext_b64: &str,
4733        epoch: u64,
4734        reply_to: Option<&str>,
4735        forward_info: Option<ForwardInfo>,
4736        priority: MessagePriority,
4737    ) -> Result<(Vec<MessageId>, Vec<String>, Vec<String>)> {
4738        let msg_payload = GroupMlsMessagePayload {
4739            group_id: group_id.to_string(),
4740            ciphertext: ciphertext_b64.to_string(),
4741            epoch,
4742            reply_to: reply_to.map(|s| s.to_string()),
4743            forward_info,
4744            message_id: logical_id.map(|s| s.to_string()),
4745        };
4746        let base_content = format!(
4747            "{}{}",
4748            internal_prefixes::GROUP_MLS_MSG,
4749            serde_json::to_string(&msg_payload)
4750                .map_err(|e| Error::Serialization(format!("Serialize group message: {}", e)))?
4751        );
4752
4753        let self_id = self.local_id.clone();
4754        let mut message_ids = Vec::new();
4755        let mut succeeded_members = Vec::new();
4756        let mut failed_members = Vec::new();
4757        for member in members {
4758            if member == &self_id {
4759                continue;
4760            }
4761            match self.send_internal_message(member, base_content.clone(), priority) {
4762                Ok(mid) => {
4763                    message_ids.push(mid);
4764                    succeeded_members.push(member.clone());
4765                }
4766                Err(e) => {
4767                    warn!(
4768                        group_id = %group_id,
4769                        member = %member,
4770                        error = %e,
4771                        "Failed to send group message to member"
4772                    );
4773                    failed_members.push(member.clone());
4774                }
4775            }
4776        }
4777        Ok((message_ids, succeeded_members, failed_members))
4778    }
4779
4780    /// Replaces the set of capability tokens advertised by the connected
4781    /// relay.
4782    ///
4783    /// Called by the platform internet bridge with the `capabilities` list
4784    /// from the relay's `Authenticated` answer, **before** it reports the
4785    /// transport up via `internet_status_changed(true)` — the false→true
4786    /// flush must already see the capabilities, or the first sends after a
4787    /// reconnect would take the capability-less path. Wholesale replace:
4788    /// each authentication describes the relay actually connected now.
4789    /// Relays that predate the advertisement yield an empty list, which
4790    /// reads as "no v2 contract" and keeps group sends on per-member
4791    /// fan-out.
4792    ///
4793    /// The list is wire-supplied: it is bounded to
4794    /// `MAX_RELAY_CAPABILITIES` tokens of at most
4795    /// `MAX_RELAY_CAPABILITY_TOKEN_BYTES` bytes each; excess entries and
4796    /// oversized tokens are dropped.
4797    pub fn set_relay_capabilities(&mut self, capabilities: Vec<String>) {
4798        let caps: HashSet<String> = capabilities
4799            .into_iter()
4800            .filter(|c| !c.is_empty() && c.len() <= MAX_RELAY_CAPABILITY_TOKEN_BYTES)
4801            .take(MAX_RELAY_CAPABILITIES)
4802            .collect();
4803        debug!(count = caps.len(), "Relay capabilities updated");
4804        self.group_mesh.relay_capabilities = caps;
4805    }
4806
4807    /// Consumes a relay `GroupMessageSent` settled delivery report
4808    /// (forwarded verbatim by the platform bridge) for a broadcast this
4809    /// sender has in flight.
4810    ///
4811    /// Settles the `RelayBroadcastPending` entry correlated by
4812    /// `message_id`, then re-sends a per-member `__GRP_MLS_MSG__` copy —
4813    /// through the ordinary outbox/ACK/park ladder — to every MLS roster
4814    /// member the relay did not reach over a socket or a push: the members
4815    /// it names in `missed`, plus every roster member it does not name at
4816    /// all (the relay's registered roster can be a strict subset of the MLS
4817    /// roster, so "not mentioned" must read as "not reached", never as
4818    /// "covered"). Duplicates are safe: the copies carry the logical id, so
4819    /// a member that did receive the relay's copy absorbs them by dedup.
4820    ///
4821    /// Reports that correlate with nothing (already settled by a previous
4822    /// attempt's report, or downgraded meanwhile) are ignored. A report
4823    /// whose `group_id` does not match the armed entry is ignored too — the
4824    /// entry stays armed and the timeout path recovers.
4825    ///
4826    /// Emits [`Event::group_message_delivery_report`] with the relay's
4827    /// `delivered`/`pushed` lists and the members re-issued per-member.
4828    pub fn handle_relay_group_delivery_report(&mut self, report_json: &str) -> Result<()> {
4829        let report: RelayGroupDeliveryReport = serde_json::from_str(report_json)
4830            .map_err(|e| Error::Serialization(format!("Parse group delivery report: {}", e)))?;
4831
4832        let Some(entry) = self
4833            .group_mesh
4834            .relay_broadcast_pending
4835            .get(&report.message_id)
4836        else {
4837            debug!(
4838                group_id = %report.group_id,
4839                msg_id = %report.message_id,
4840                "Group delivery report correlates with no in-flight broadcast, ignoring"
4841            );
4842            return Ok(());
4843        };
4844        if entry.group_id != report.group_id {
4845            warn!(
4846                report_group = %report.group_id,
4847                armed_group = %entry.group_id,
4848                msg_id = %report.message_id,
4849                "Group delivery report names a different group than the armed broadcast, ignoring"
4850            );
4851            return Ok(());
4852        }
4853        let entry = self
4854            .group_mesh
4855            .relay_broadcast_pending
4856            .remove(&report.message_id)
4857            .expect("checked above");
4858
4859        for miss in &report.missed {
4860            debug!(
4861                group_id = %report.group_id,
4862                member = %miss.username,
4863                reason = %miss.reason,
4864                "Relay reports group member missed"
4865            );
4866        }
4867
4868        // Authoritative roster from MLS, cache as fallback (idiom shared
4869        // with the relay registration paths).
4870        let members = self
4871            .refresh_group_members(&entry.group_id)
4872            .ok()
4873            .or_else(|| self.group_mesh.members.get(&entry.group_id).cloned())
4874            .unwrap_or_default();
4875        let reached: HashSet<&str> = report
4876            .delivered
4877            .iter()
4878            .chain(report.pushed.iter())
4879            .map(String::as_str)
4880            .collect();
4881        let self_id = self.local_id.clone();
4882        let unreached: Vec<String> = members
4883            .iter()
4884            .filter(|m| m.as_str() != self_id && !reached.contains(m.as_str()))
4885            .cloned()
4886            .collect();
4887
4888        let mut reissued = Vec::new();
4889        if !unreached.is_empty() {
4890            info!(
4891                group_id = %entry.group_id,
4892                msg_id = %report.message_id,
4893                count = unreached.len(),
4894                "Re-sending group message per-member to members the relay did not reach"
4895            );
4896            // Never `?` here: the tracker entry is already removed, so
4897            // propagating would strand the whole contract — no re-issue, no
4898            // event, and no timeout left to recover it. Report what happened
4899            // instead and let the app see an empty `missed_reissued`.
4900            match self.fanout_group_frames_per_member(
4901                &entry.group_id,
4902                &unreached,
4903                Some(&report.message_id),
4904                &entry.ciphertext_b64,
4905                entry.epoch,
4906                entry.reply_to.as_deref(),
4907                entry.forward_info.clone(),
4908                entry.priority,
4909            ) {
4910                Ok((_ids, succeeded, _failed)) => reissued = succeeded,
4911                Err(e) => {
4912                    error!(
4913                        group_id = %entry.group_id,
4914                        msg_id = %report.message_id,
4915                        error = %e,
4916                        "Failed to re-issue per-member copies for unreached members"
4917                    );
4918                }
4919            }
4920        }
4921
4922        self.emit_event(Event::group_message_delivery_report(
4923            entry.group_id,
4924            report.message_id,
4925            report.delivered,
4926            report.pushed,
4927            reissued,
4928        ));
4929        Ok(())
4930    }
4931
4932    /// Returns `true` if the Internet transport is currently available.
4933    pub(crate) fn is_internet_available(&self) -> bool {
4934        self.transport_manager
4935            .get_available_transports()
4936            .contains_key(&TransportType::Internet)
4937    }
4938
4939    /// Reads the current MLS epoch for a group, if available.
4940    fn read_current_epoch(&self, group_id: &str) -> Option<u64> {
4941        let guard = self.read_mls_guard().ok()?;
4942        let gid = offline_protocol_mls::GroupId::new(group_id).ok()?;
4943        let info = guard.get_group_info(&gid).ok()??;
4944        Some(info.epoch)
4945    }
4946
4947    // ========================================================================
4948    // EPOCH FORK RESOLUTION
4949    // ========================================================================
4950
4951    /// Checks for pending epoch forks and attempts resolution.
4952    ///
4953    /// Called from the `process()` tick. After the resolution delay, the
4954    /// deterministic leader (lex-first member) issues an `update_keys` commit
4955    /// to re-establish a canonical epoch. Members on the same branch will
4956    /// process the commit normally. Members on a different branch will fail
4957    /// to process it, at which point they need a re-invite (the leader
4958    /// emits `GroupEpochForkResolved` and the application layer can trigger
4959    /// re-invites for unreachable members).
4960    ///
4961    /// ## Stuck fork recovery
4962    ///
4963    /// If `update_keys` fails (e.g., the leader's MLS state is corrupted),
4964    /// `resolution_attempted` is set to `true` and no further automatic
4965    /// resolution is tried. The fork entry remains until the 5-minute stale
4966    /// cleanup removes it, at which point the fork may be re-detected if
4967    /// the underlying issue persists (creating a detect → fail → stale-cleanup
4968    /// → re-detect cycle). This is intentional: a persistent `update_keys`
4969    /// failure indicates the group likely needs manual intervention (e.g.,
4970    /// re-creating the group or re-inviting all members). The application
4971    /// layer should monitor `GroupEpochForkDetected` events and escalate if
4972    /// the same group is repeatedly detected.
4973    pub(crate) fn check_epoch_forks(&mut self) {
4974        let delay = StdDuration::from_secs(EPOCH_FORK_RESOLUTION_DELAY_SECS);
4975        let self_id = self.local_id.clone();
4976
4977        // Collect forks ready for resolution
4978        let ready: Vec<EpochForkState> = self
4979            .group_mesh
4980            .epoch_forks
4981            .values()
4982            .filter(|f| !f.resolution_attempted && f.detected_at.elapsed() > delay)
4983            .cloned()
4984            .collect();
4985
4986        for fork in ready {
4987            // Re-check: if the epoch has advanced since detection, the fork
4988            // was a delayed commit, not a real fork — cancel it.
4989            if let Some(detected_epoch) = fork.local_epoch {
4990                let current_epoch = self.read_current_epoch(&fork.group_id);
4991                if let Some(current) = current_epoch {
4992                    if current > detected_epoch {
4993                        debug!(
4994                            group_id = %fork.group_id,
4995                            detected_epoch,
4996                            current_epoch = current,
4997                            "Epoch fork auto-cancelled: epoch advanced since detection"
4998                        );
4999                        self.group_mesh.epoch_forks.remove(&fork.group_id);
5000                        continue;
5001                    }
5002                }
5003            }
5004
5005            // Check if we're the leader for this group
5006            let members = self
5007                .refresh_group_members(&fork.group_id)
5008                .ok()
5009                .or_else(|| self.group_mesh.members.get(&fork.group_id).cloned())
5010                .unwrap_or_default();
5011
5012            let mut sorted = members.clone();
5013            sorted.sort();
5014            let am_leader = sorted.first().map(|f| f == &self_id).unwrap_or(false);
5015
5016            // Mark as attempted regardless — only the leader acts, but all
5017            // members should stop re-checking.
5018            if let Some(state) = self.group_mesh.epoch_forks.get_mut(&fork.group_id) {
5019                state.resolution_attempted = true;
5020            }
5021
5022            if !am_leader {
5023                continue;
5024            }
5025
5026            info!(
5027                group_id = %fork.group_id,
5028                local_epoch = ?fork.local_epoch,
5029                "Attempting epoch fork resolution as elected leader"
5030            );
5031
5032            // Issue a key update commit to establish a canonical next epoch.
5033            // Members on our branch will process this normally and advance.
5034            // Members on a forked branch will fail — the app layer should
5035            // handle re-inviting them.
5036            //
5037            // NOTE: We use `read_mls_guard` (RwLock read lock) even though
5038            // `update_keys` mutates MLS state. This is correct because
5039            // `MlsManager` uses interior mutability (internal Mutex on the
5040            // group store), so a read guard is sufficient for all operations.
5041            //
5042            // Acquire MLS lock, perform key update, and fully release the
5043            // guard before touching any other &mut self state.
5044            let update_result = {
5045                if let Ok(guard) = self.read_mls_guard() {
5046                    let r = offline_protocol_mls::GroupId::new(&fork.group_id)
5047                        .and_then(|gid| guard.update_keys(&gid));
5048                    drop(guard);
5049                    Some(r)
5050                } else {
5051                    None
5052                }
5053            };
5054            // Guard is fully dropped here — safe to use &mut self.
5055            let update_result = match update_result {
5056                Some(r) => r,
5057                None => {
5058                    warn!(group_id = %fork.group_id, "MLS unavailable during epoch fork resolution");
5059                    if let Some(state) = self.group_mesh.epoch_forks.get_mut(&fork.group_id) {
5060                        state.resolution_attempted = false;
5061                    }
5062                    continue;
5063                }
5064            };
5065
5066            match update_result {
5067                Ok(commit_msg) => {
5068                    // Distribute the key-update commit to all members
5069                    let commit_payload = GroupMlsCommitPayload {
5070                        group_id: fork.group_id.clone(),
5071                        commit_type: GroupCommitType::KeyUpdate,
5072                        ciphertext: base64_encode(&commit_msg.ciphertext),
5073                        epoch: commit_msg.epoch,
5074                        affected_member: None,
5075                        role: None,
5076                        affected_member_rich: None,
5077                    };
5078                    let commit_content = match serde_json::to_string(&commit_payload) {
5079                        Ok(json) => format!("{}{}", internal_prefixes::GROUP_MLS_COMMIT, json),
5080                        Err(e) => {
5081                            warn!(
5082                                group_id = %fork.group_id,
5083                                error = %e,
5084                                "Failed to serialize fork resolution commit"
5085                            );
5086                            continue;
5087                        }
5088                    };
5089                    let mut failed_members = Vec::new();
5090                    for member in &members {
5091                        if member == &self_id {
5092                            continue;
5093                        }
5094                        if let Err(e) = self.send_internal_message(
5095                            member,
5096                            commit_content.clone(),
5097                            MessagePriority::High,
5098                        ) {
5099                            warn!(
5100                                group_id = %fork.group_id,
5101                                member = %member,
5102                                error = %e,
5103                                "Failed to send fork resolution commit to member"
5104                            );
5105                            failed_members.push(member.clone());
5106                        }
5107                    }
5108
5109                    info!(
5110                        group_id = %fork.group_id,
5111                        new_epoch = commit_msg.epoch,
5112                        failed_count = failed_members.len(),
5113                        "Epoch fork resolution commit distributed"
5114                    );
5115
5116                    self.emit_event(Event::group_epoch_fork_resolved(
5117                        fork.group_id.clone(),
5118                        commit_msg.epoch,
5119                        failed_members,
5120                    ));
5121
5122                    // Clean up the fork state
5123                    self.group_mesh.epoch_forks.remove(&fork.group_id);
5124                }
5125                Err(e) => {
5126                    warn!(
5127                        group_id = %fork.group_id,
5128                        error = %e,
5129                        "Failed to issue key update for epoch fork resolution"
5130                    );
5131                    // Leave resolution_attempted = true — a persistent failure
5132                    // means the group may need manual intervention.
5133                }
5134            }
5135        }
5136
5137        // Clean up stale fork entries (older than 5 minutes)
5138        let stale_threshold = StdDuration::from_secs(300);
5139        self.group_mesh
5140            .epoch_forks
5141            .retain(|_, f| f.detected_at.elapsed() < stale_threshold);
5142    }
5143
5144    // ========================================================================
5145    // LEAVE ELECTION FALLBACK
5146    // ========================================================================
5147
5148    /// Checks for timed-out leave elections and re-elects the next eligible
5149    /// member to issue the MLS remove-commit.
5150    ///
5151    /// Called from the `process()` tick. If the originally elected remover
5152    /// hasn't issued a commit within `LEAVE_ELECTION_TIMEOUT_SECS`, we check
5153    /// whether the leaving member is still in the group (via MLS state). If
5154    /// so, the next eligible member in the sorted remaining list takes over.
5155    pub(crate) fn check_leave_election_timeouts(&mut self) {
5156        let timeout = StdDuration::from_secs(LEAVE_ELECTION_TIMEOUT_SECS);
5157        let max_lifetime = StdDuration::from_secs(LEAVE_ELECTION_MAX_LIFETIME_SECS);
5158        let attempt_cooldown = StdDuration::from_secs(LEAVE_ELECTION_ATTEMPT_COOLDOWN_SECS);
5159        let self_id = self.local_id.clone();
5160
5161        // Collect timed-out elections (keys + values) for processing.
5162        let timed_out: Vec<((String, String), PendingLeaveElection)> = self
5163            .group_mesh
5164            .pending_leave_elections
5165            .iter()
5166            .filter(|(_, e)| e.received_at.elapsed() > timeout)
5167            .map(|(k, v)| (k.clone(), v.clone()))
5168            .collect();
5169
5170        for (election_key, election) in timed_out {
5171            // Circuit breaker: abandon the election after max lifetime to
5172            // prevent infinite per-tick retry loops when all candidates fail.
5173            if election.received_at.elapsed() > max_lifetime {
5174                warn!(
5175                    group_id = %election.group_id,
5176                    leaving_member = %election.leaving_member,
5177                    "Leave election exceeded max lifetime, abandoning — member may need manual removal"
5178                );
5179                self.group_mesh
5180                    .pending_leave_elections
5181                    .remove(&election_key);
5182                continue;
5183            }
5184
5185            // Refresh membership from MLS (authoritative) to avoid using
5186            // stale election-time lists where members may have since left.
5187            let current_members = self
5188                .refresh_group_members(&election.group_id)
5189                .ok()
5190                .or_else(|| self.group_mesh.members.get(&election.group_id).cloned())
5191                .unwrap_or_default();
5192
5193            let still_member = current_members
5194                .iter()
5195                .any(|id| id == &election.leaving_member);
5196
5197            if !still_member {
5198                // Already removed — clean up
5199                self.group_mesh
5200                    .pending_leave_elections
5201                    .remove(&election_key);
5202                continue;
5203            }
5204
5205            // Use current membership for candidate selection, not the stale
5206            // election-time list — members may have left since the election.
5207            let mut remaining: Vec<String> = current_members
5208                .iter()
5209                .filter(|m| m.as_str() != election.leaving_member)
5210                .cloned()
5211                .collect();
5212            remaining.sort();
5213
5214            // Re-elect: walk the sorted remaining members. For each timeout
5215            // interval that has passed, advance one position in the list.
5216            // This staggers re-election so members don't all fire at once.
5217            // Interval 1 = first fallback (idx 0), interval 2 = second (idx 1), etc.
5218            // The original elected member (lex-first) already had their shot
5219            // during handle_group_mls_leave; this path only fires after timeout.
5220            let elapsed_intervals =
5221                (election.received_at.elapsed().as_secs() / LEAVE_ELECTION_TIMEOUT_SECS) as usize;
5222            let candidate_idx = elapsed_intervals.min(remaining.len().saturating_sub(1));
5223
5224            if remaining
5225                .get(candidate_idx)
5226                .map(|c| c == &self_id)
5227                .unwrap_or(false)
5228            {
5229                // Rate-limit: skip if we already attempted within the cooldown window.
5230                if let Some(last) = election.last_attempt_at {
5231                    if last.elapsed() < attempt_cooldown {
5232                        continue;
5233                    }
5234                }
5235
5236                info!(
5237                    group_id = %election.group_id,
5238                    leaving_member = %election.leaving_member,
5239                    attempt = candidate_idx + 1,
5240                    "Re-elected to issue MLS remove-commit (prior elected member timed out)"
5241                );
5242
5243                // Record the attempt timestamp before trying, so the cooldown
5244                // applies even on failure.
5245                if let Some(state) = self
5246                    .group_mesh
5247                    .pending_leave_elections
5248                    .get_mut(&election_key)
5249                {
5250                    state.last_attempt_at = Some(Instant::now());
5251                }
5252
5253                if let Err(e) = self.remove_from_group(&election.group_id, &election.leaving_member)
5254                {
5255                    warn!(
5256                        group_id = %election.group_id,
5257                        leaving_member = %election.leaving_member,
5258                        error = %e,
5259                        "Failed to issue MLS remove-commit during re-election"
5260                    );
5261                    // Don't remove from pending — cooldown will prevent spam,
5262                    // next interval will advance to the next candidate.
5263                } else {
5264                    self.group_mesh
5265                        .pending_leave_elections
5266                        .remove(&election_key);
5267                }
5268            }
5269            // If we're not the candidate at this interval, leave the election
5270            // in place — either the current candidate will handle it, or the
5271            // next interval will advance to us.
5272        }
5273    }
5274
5275    // ========================================================================
5276    // TRANSPORT-RESILIENT SYNC
5277    // ========================================================================
5278
5279    /// Checks for Internet availability transitions and syncs group state.
5280    ///
5281    /// Called from the `process()` tick. On a 0→1 transition (Internet just
5282    /// became available), all unsynced groups are registered with the relay.
5283    /// On a 1→0 transition (Internet lost), relay sync flags are cleared so
5284    /// groups are re-registered when connectivity returns.
5285    pub(crate) fn check_relay_group_sync(&mut self) {
5286        if !self.config.group.relay_enabled {
5287            return;
5288        }
5289        let internet_available = self.is_internet_available();
5290        let was_available = self.group_mesh.internet_was_available;
5291        self.group_mesh.internet_was_available = internet_available;
5292
5293        if internet_available && !was_available {
5294            // Internet just became available — sync all groups
5295            self.sync_groups_to_relay();
5296        } else if !internet_available && was_available {
5297            // Internet just went down — clear sync state. In-flight
5298            // registration acks can never arrive on this connection, so the
5299            // pending set goes too (re-armed by the 0→1 re-sync). Each
5300            // affected group gets a sync-changed event: apps gating
5301            // relay-dependent commands (invite links) must re-wait for the
5302            // reconnect's re-registration ack.
5303            let mut affected: Vec<String> = self.group_mesh.relay_synced.drain().collect();
5304            for group_id in self.group_mesh.relay_register_pending.drain() {
5305                if !affected.contains(&group_id.0) {
5306                    affected.push(group_id.0);
5307                }
5308            }
5309            for group_id in affected {
5310                self.emit_event(Event::group_relay_sync_changed(
5311                    group_id,
5312                    false,
5313                    "internet_dropped",
5314                ));
5315            }
5316            // Capabilities described the relay on the dead connection; a
5317            // reconnect re-injects them (possibly from a different relay)
5318            // before the transport is reported up again.
5319            self.group_mesh.relay_capabilities.clear();
5320            // In-flight broadcast delivery reports can never arrive on this
5321            // connection either. Downgrade each to per-member fan-out now
5322            // rather than after the report timeout: the copies enter the
5323            // ordinary outbox/park machinery, which flushes on whatever
5324            // transport (mesh now, Internet on reconnect) becomes usable —
5325            // and members the relay did reach absorb them via the logical
5326            // id.
5327            let stranded: Vec<(String, RelayBroadcastPending)> =
5328                self.group_mesh.relay_broadcast_pending.drain().collect();
5329            for (logical_id, entry) in stranded {
5330                self.downgrade_broadcast_to_per_member(&logical_id, entry);
5331            }
5332        }
5333    }
5334
5335    /// Expires outstanding relay registrations the relay never answered.
5336    ///
5337    /// Called from the `process()` tick. An entry older than
5338    /// [`RELAY_REGISTER_ACK_TIMEOUT_SECS`] either gets its registration
5339    /// re-sent (the frame may have been lost) or, past
5340    /// [`RELAY_REGISTER_MAX_ATTEMPTS`], is dropped so the ack-acceptance
5341    /// window closes: against a relay that never answers (prefix-unaware
5342    /// echo relay, legacy server, or a `__GROUP_ERROR__` that arrived
5343    /// without a `group_id` and so could not consume the correlation) an
5344    /// armed entry would otherwise sit indefinitely for a forged
5345    /// `__GROUP_CREATED__` to claim. Giving up is safe — the group stays
5346    /// unsynced and sends take the always-correct per-member fan-out path.
5347    pub(crate) fn process_relay_register_retries(&mut self) {
5348        if !self.config.group.relay_enabled
5349            || self.group_mesh.relay_register_pending.is_empty()
5350            || !self.is_internet_available()
5351        {
5352            return;
5353        }
5354        let cutoff = Utc::now() - chrono::Duration::seconds(RELAY_REGISTER_ACK_TIMEOUT_SECS);
5355        let expired: Vec<(String, u32)> = self
5356            .group_mesh
5357            .relay_register_pending
5358            .iter()
5359            .filter(|(_, pending)| pending.armed_at <= cutoff)
5360            .map(|(group_id, pending)| (group_id.clone(), pending.attempts))
5361            .collect();
5362        for (group_id, attempts) in expired {
5363            if attempts >= RELAY_REGISTER_MAX_ATTEMPTS
5364                || !self.group_mesh.members.contains_key(&group_id)
5365                || self.group_mesh.relay_synced.contains(&group_id)
5366            {
5367                self.group_mesh.relay_register_pending.remove(&group_id);
5368                debug!(
5369                    group_id = %group_id,
5370                    attempts,
5371                    "Expired unanswered relay group registration"
5372                );
5373                // Giving up on a still-tracked, unsynced group is an
5374                // app-visible outcome: without this event a caller awaiting
5375                // the registration ack (`ensure_group_registered`) only
5376                // learns via its own timeout. The other two removal causes
5377                // stay silent — a vanished group already surfaced its
5378                // teardown, and an already-synced group's stale entry is
5379                // pure bookkeeping.
5380                if attempts >= RELAY_REGISTER_MAX_ATTEMPTS
5381                    && self.group_mesh.members.contains_key(&group_id)
5382                    && !self.group_mesh.relay_synced.contains(&group_id)
5383                {
5384                    self.emit_event(Event::group_relay_sync_changed(
5385                        group_id.clone(),
5386                        false,
5387                        "ack_timeout",
5388                    ));
5389                }
5390                continue;
5391            }
5392            // Re-send with membership refreshed from MLS, like the 0→1
5393            // re-sync; try_relay_register_group re-arms the entry in place
5394            // with the attempt count carried forward.
5395            let members = self
5396                .refresh_group_members(&group_id)
5397                .ok()
5398                .or_else(|| self.group_mesh.members.get(&group_id).cloned());
5399            if let Some(members) = members {
5400                let _ = self.try_relay_register_group(&group_id, None, &members);
5401            } else {
5402                self.group_mesh.relay_register_pending.remove(&group_id);
5403            }
5404        }
5405    }
5406
5407    /// Expires in-flight relay broadcasts whose settled delivery report
5408    /// never arrived.
5409    ///
5410    /// Called from the `process()` tick. An entry older than
5411    /// [`RELAY_BROADCAST_REPORT_TIMEOUT_SECS`] gets its broadcast re-sent
5412    /// under the same logical id (safe: the relay echoes the id, so
5413    /// receiver and push dedup absorb the duplicate fan-out) while the
5414    /// attempt budget and the broadcast gate still hold; past
5415    /// [`RELAY_BROADCAST_MAX_ATTEMPTS`] — or as soon as the gate fails —
5416    /// the message is downgraded to full per-member fan-out, which needs no
5417    /// report to be correct.
5418    pub(crate) fn process_relay_broadcast_report_timeouts(&mut self) {
5419        if self.group_mesh.relay_broadcast_pending.is_empty() {
5420            return;
5421        }
5422        let cutoff = Utc::now() - chrono::Duration::seconds(RELAY_BROADCAST_REPORT_TIMEOUT_SECS);
5423        let expired: Vec<String> = self
5424            .group_mesh
5425            .relay_broadcast_pending
5426            .iter()
5427            .filter(|(_, pending)| pending.armed_at <= cutoff)
5428            .map(|(logical_id, _)| logical_id.clone())
5429            .collect();
5430        for logical_id in expired {
5431            let Some(mut entry) = self.group_mesh.relay_broadcast_pending.remove(&logical_id)
5432            else {
5433                continue;
5434            };
5435            let gate_holds = self.config.group.relay_broadcast_enabled
5436                && self.group_mesh.relay_synced.contains(&entry.group_id)
5437                && self
5438                    .group_mesh
5439                    .relay_capabilities
5440                    .contains(RELAY_CAP_GROUP_DELIVERY_V3)
5441                && self.is_internet_available();
5442            if entry.attempts < RELAY_BROADCAST_MAX_ATTEMPTS && gate_holds {
5443                let payload = RelayGroupBroadcastPayload {
5444                    group_id: entry.group_id.clone(),
5445                    ciphertext: entry.ciphertext_b64.clone(),
5446                    epoch: entry.epoch,
5447                    reply_to: entry.reply_to.clone(),
5448                    forward_info: entry.forward_info.clone(),
5449                    message_id: logical_id.clone(),
5450                };
5451                if self.send_relay_broadcast_frame(&payload).is_ok() {
5452                    warn!(
5453                        group_id = %entry.group_id,
5454                        msg_id = %logical_id,
5455                        attempts = entry.attempts + 1,
5456                        "Group broadcast delivery report lost, re-sent broadcast"
5457                    );
5458                    entry.attempts += 1;
5459                    entry.armed_at = Utc::now();
5460                    self.group_mesh
5461                        .relay_broadcast_pending
5462                        .insert(logical_id, entry);
5463                    continue;
5464                }
5465                // Re-send failed — fall through to per-member downgrade.
5466            }
5467            self.downgrade_broadcast_to_per_member(&logical_id, entry);
5468        }
5469    }
5470
5471    /// Downgrades one in-flight broadcast to full per-member fan-out (every
5472    /// roster member; receivers that did get the relay's copy absorb the
5473    /// duplicate via the logical id). Terminal for the broadcast attempt:
5474    /// the per-member copies own delivery from here through the ordinary
5475    /// outbox/ACK/park ladder.
5476    fn downgrade_broadcast_to_per_member(
5477        &mut self,
5478        logical_id: &str,
5479        entry: RelayBroadcastPending,
5480    ) {
5481        let members = self
5482            .refresh_group_members(&entry.group_id)
5483            .ok()
5484            .or_else(|| self.group_mesh.members.get(&entry.group_id).cloned())
5485            .unwrap_or_default();
5486        info!(
5487            group_id = %entry.group_id,
5488            msg_id = %logical_id,
5489            members = members.len(),
5490            "Downgrading relay group broadcast to per-member fan-out"
5491        );
5492        if let Err(e) = self.fanout_group_frames_per_member(
5493            &entry.group_id,
5494            &members,
5495            Some(logical_id),
5496            &entry.ciphertext_b64,
5497            entry.epoch,
5498            entry.reply_to.as_deref(),
5499            entry.forward_info.clone(),
5500            entry.priority,
5501        ) {
5502            error!(
5503                group_id = %entry.group_id,
5504                msg_id = %logical_id,
5505                error = %e,
5506                "Failed to downgrade group broadcast to per-member fan-out"
5507            );
5508        }
5509    }
5510
5511    /// Re-registers all unsynced groups with the relay server.
5512    ///
5513    /// Refreshes each group's member list from MLS state before syncing so
5514    /// that the relay receives authoritative membership, not a stale cache.
5515    fn sync_groups_to_relay(&mut self) {
5516        let group_ids: Vec<String> = self.group_mesh.members.keys().cloned().collect();
5517        for group_id in group_ids {
5518            if self.group_mesh.relay_synced.contains(&group_id) {
5519                continue;
5520            }
5521            // Refresh from MLS to avoid syncing stale membership to relay
5522            let members = self
5523                .refresh_group_members(&group_id)
5524                .ok()
5525                .or_else(|| self.group_mesh.members.get(&group_id).cloned());
5526            if let Some(members) = members {
5527                let _ = self.try_relay_register_group(&group_id, None, &members);
5528            }
5529        }
5530    }
5531
5532    // ========================================================================
5533    // RELAY INBOUND — MLS-AWARE ROUTING
5534    // ========================================================================
5535
5536    /// Returns `true` if we hold local MLS state for `group_id`.
5537    ///
5538    /// The raw-emit fallbacks in [`Self::handle_relay_group_message_with_mls`]
5539    /// attribute unauthenticated plaintext to a caller-supplied `sender`. That
5540    /// is only ever acceptable for a genuine legacy relay-only (unencrypted)
5541    /// group, which has no MLS state. If we secure this group with MLS, a real
5542    /// member always sends MLS ciphertext, so plaintext naming the group is a
5543    /// spoof — this test lets the caller drop it rather than surface a message
5544    /// forged in a trusted member's name.
5545    ///
5546    /// Fail-closed: when MLS is initialized but the lookup cannot complete
5547    /// (lock poisoned, invalid id), assume state may exist and return `true`
5548    /// so a transient fault can never be leveraged to force a plaintext spoof
5549    /// through. When MLS is not initialized at all, there is no group to
5550    /// secure, so return `false` (genuine legacy).
5551    fn has_mls_group_state(&self, group_id: &str) -> bool {
5552        if !self.is_mls_initialized() {
5553            return false;
5554        }
5555        let Ok(gid) = offline_protocol_mls::GroupId::new(group_id) else {
5556            // An id we cannot even construct names no real MLS group.
5557            return false;
5558        };
5559        match self.read_mls_guard() {
5560            Ok(guard) => guard.has_group(&gid).unwrap_or(true),
5561            Err(_) => true,
5562        }
5563    }
5564
5565    /// Handles an inbound relay group message by routing through MLS
5566    /// decryption.
5567    ///
5568    /// Called from `process_internal_message` for every relay `__GROUP_MSG__`
5569    /// when MLS is initialized (or when the group already has local MLS
5570    /// state) — including groups we have not joined yet, because a relay
5571    /// group message can outrun its Welcome exactly like a mesh one; such
5572    /// messages are buffered for deferred retry. Content that is not MLS
5573    /// ciphertext (base64-undecodable, or base64 that is not MLS wire
5574    /// framing for a group without local state) is a legacy relay-only
5575    /// group message and is emitted raw — but only for a group we do *not*
5576    /// secure with MLS. Plaintext naming a group we hold MLS state for is a
5577    /// sender-spoofing attempt (a real member sends ciphertext) and is
5578    /// dropped, since the raw emit would attribute attacker content to a
5579    /// trusted member with no authentication.
5580    #[allow(clippy::too_many_arguments)]
5581    pub(crate) fn handle_relay_group_message_with_mls(
5582        &mut self,
5583        group_id: &str,
5584        sender: &str,
5585        content: &str,
5586        timestamp: &str,
5587        message_id: &str,
5588        reply_to_msg: Option<String>,
5589        forward_info: Option<offline_protocol_core::ForwardInfo>,
5590    ) {
5591        // Dedup check — same pattern as handle_group_mls_msg, with one
5592        // deliberate difference. The relay-supplied id IS the logical id on
5593        // this path, and it is marked here, pre-decrypt, as the replay-
5594        // amplification defense (one MLS crypto op per id). But the mesh
5595        // handler's marking discipline exists for a reason this path is not
5596        // exempt from: a marked id suppresses every later copy of the same
5597        // logical message, including the per-member re-issue that is this
5598        // message's own delivery safety net. So every arm below that ends
5599        // with the frame neither delivered, nor buffered, nor consumed by
5600        // MLS must unmark before returning — otherwise a copy that was
5601        // *rejected* (e.g. mis-attributed by the relay) reads as *delivered*
5602        // to the duplicate check, and the genuine copy that follows is
5603        // absorbed and re-ACKed without ever being surfaced: silent loss.
5604        let dedup_key = message_id.to_string();
5605        if self.group_mesh.message_dedup.contains_key(&dedup_key) {
5606            debug!(group_id = %group_id, msg_id = %dedup_key, "Duplicate relay group message, skipping");
5607            return;
5608        }
5609        self.group_mesh
5610            .message_dedup
5611            .insert(dedup_key.clone(), Instant::now());
5612        if self.group_mesh.message_dedup.len() > MAX_GROUP_MESSAGE_DEDUP_ENTRIES {
5613            self.cleanup_group_message_dedup();
5614        }
5615
5616        let forward_info_event = forward_info
5617            .as_ref()
5618            .map(crate::events::ForwardInfoEvent::from);
5619
5620        // Attempt base64 decode — if it fails, the content is plaintext (legacy)
5621        let ciphertext_bytes = match base64_decode(content) {
5622            Ok(bytes) => bytes,
5623            Err(_) => {
5624                // Plaintext content naming a group we secure with MLS cannot be
5625                // legitimate: a real member always sends MLS ciphertext (which
5626                // is base64 and decodes here). Emitting it raw would attribute
5627                // attacker-chosen content to the unauthenticated inner `sender`
5628                // — a message forged in a trusted member's name. Drop it. Only
5629                // a genuine legacy relay-only group (no local MLS state) may be
5630                // emitted as plaintext.
5631                if self.has_mls_group_state(group_id) {
5632                    warn!(
5633                        group_id = %group_id,
5634                        sender = %sender,
5635                        "SECURITY: dropping non-MLS plaintext naming an MLS-secured group (sender spoofing)"
5636                    );
5637                    self.emit_event(Event::security_warning(
5638                        sender.to_string(),
5639                        crate::events::SecurityWarningCode::PlaintextReceiveRejected,
5640                        "Plaintext group message rejected: the named group is secured with MLS, \
5641                         so unauthenticated plaintext cannot be attributed to the claimed sender"
5642                            .to_string(),
5643                    ));
5644                    // Nothing was delivered and no MLS state was touched: the
5645                    // id on this frame is attacker-chosen wire input, and
5646                    // leaving it marked would let a spoofed frame suppress the
5647                    // genuine logical message it names (see the marking note
5648                    // at the top of this function).
5649                    self.group_mesh.message_dedup.remove(&dedup_key);
5650                    return;
5651                }
5652                // Not ciphertext and no MLS state — legacy relay-only group.
5653                self.emit_event(Event::group_message_received(
5654                    group_id.to_string(),
5655                    sender.to_string(),
5656                    content.to_string(),
5657                    timestamp.to_string(),
5658                    message_id.to_string(),
5659                    reply_to_msg,
5660                    forward_info_event,
5661                    None,
5662                    None,
5663                ));
5664                return;
5665            }
5666        };
5667
5668        match self.decrypt_group_application(group_id, ciphertext_bytes, sender) {
5669            GroupDecryptOutcome::Plaintext(pt) => match String::from_utf8(pt) {
5670                Ok(text) => {
5671                    let (content, media_metadata, content_type, forward_info_event) =
5672                        Self::restore_group_rich(text, forward_info, sender);
5673                    self.emit_event(Event::group_message_received(
5674                        group_id.to_string(),
5675                        sender.to_string(),
5676                        content,
5677                        timestamp.to_string(),
5678                        message_id.to_string(),
5679                        reply_to_msg,
5680                        forward_info_event,
5681                        media_metadata,
5682                        content_type,
5683                    ));
5684                }
5685                Err(_) => {
5686                    warn!(
5687                        group_id = %group_id,
5688                        "Decrypted relay group payload is not valid UTF-8, dropping"
5689                    );
5690                }
5691            },
5692            GroupDecryptOutcome::Retriable => {
5693                self.buffer_pending_group_message(
5694                    group_id,
5695                    PendingGroupMessage {
5696                        sender: sender.to_string(),
5697                        message_id: message_id.to_string(),
5698                        // The relay-supplied id IS the logical id on this
5699                        // path (a v2 relay echoes the broadcast's id), so a
5700                        // separate copy would be redundant.
5701                        logical_id: None,
5702                        ciphertext_b64: content.to_string(),
5703                        timestamp: Some(timestamp.to_string()),
5704                        reply_to: reply_to_msg,
5705                        forward_info,
5706                        buffered_at: Instant::now(),
5707                        // Relay path: the relay sender is not ACK-gated (it uses
5708                        // try_relay_broadcast, not per-member ensure_ack_registration),
5709                        // so there is no deferred ACK to send on drain.
5710                        received_via: None,
5711                    },
5712                );
5713            }
5714            GroupDecryptOutcome::NonApplication => {
5715                // Same as the mesh path: MLS consumed a commit or proposal
5716                // riding the message channel — group state may have advanced.
5717                self.drain_pending_commits(group_id);
5718                self.drain_pending_group_messages(group_id);
5719            }
5720            GroupDecryptOutcome::PolicyRejected {
5721                committer,
5722                added,
5723                removed,
5724            } => {
5725                // Same as the mesh path: a commit reframed onto the message
5726                // channel, refused before merging. Dropped, never buffered.
5727                self.report_unauthorized_membership_change(
5728                    group_id,
5729                    &committer,
5730                    &added,
5731                    &removed,
5732                    "sender_not_admin",
5733                    true,
5734                );
5735            }
5736            GroupDecryptOutcome::NotMlsCiphertext => {
5737                // Valid base64 that is not MLS framing, for a group without
5738                // local MLS state: a legacy relay-only group message whose
5739                // plaintext happens to decode (e.g. a base64 media blob).
5740                // Emit it raw, exactly like the base64-undecodable path.
5741                self.emit_event(Event::group_message_received(
5742                    group_id.to_string(),
5743                    sender.to_string(),
5744                    content.to_string(),
5745                    timestamp.to_string(),
5746                    message_id.to_string(),
5747                    reply_to_msg,
5748                    forward_info_event,
5749                    None,
5750                    None,
5751                ));
5752            }
5753            GroupDecryptOutcome::SecurityRejected | GroupDecryptOutcome::Failed => {
5754                warn!(
5755                    group_id = %group_id,
5756                    "Failed to decrypt relay group message via MLS, dropping"
5757                );
5758                // Rejected, not delivered — unmark so a later copy of the
5759                // same logical message is processed instead of absorbed.
5760                // `SecurityRejected` is the load-bearing case: a v2 relay
5761                // attributes group frames by username, which fails the
5762                // SEC-M1 wire-sender/credential match after the decrypt —
5763                // leaving the id marked would then swallow the per-member
5764                // re-issue of the very same message (delivered exactly
5765                // nowhere, ACKed as a duplicate). `Failed` covers pre-decrypt
5766                // refusals (MLS unavailable, invalid group id) that a later
5767                // copy may outlive. The cost of unmarking is one MLS crypto
5768                // op per replayed copy — bounded by the same reasoning as
5769                // the mesh handler's post-decrypt-only logical-id marking.
5770                self.group_mesh.message_dedup.remove(&dedup_key);
5771            }
5772        }
5773    }
5774}