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