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