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