de_mls/core/group_handle.rs
1//! Per-group state container for app-level operations.
2//!
3//! This module provides [`GroupHandle`], which holds app-level state for
4//! a single group: proposal tracking, steward status, and deduplication ID.
5//!
6//! **Note**: MLS cryptographic state is managed by `MlsService` internally.
7//! This handle only tracks application-layer concerns.
8//!
9//! # Architecture
10//!
11//! ```text
12//! ┌─────────────────────────────────────────────────────────────┐
13//! │ GroupHandle │
14//! ├─────────────────────────────────────────────────────────────┤
15//! │ group_name │ Human-readable group identifier │
16//! │ app_id │ UUID for message deduplication │
17//! │ steward │ Whether this user batches commits │
18//! │ proposals │ Voting + approved proposal queues │
19//! │ mls_initialized │ Whether MLS state exists in MlsService │
20//! └─────────────────────────────────────────────────────────────┘
21//! ```
22//!
23//! # Lifecycle
24//!
25//! **Creating a group (as steward):**
26//! ```ignore
27//! mls_service.create_group(group_name)?;
28//! let handle = GroupHandle::new_as_creator(group_name);
29//! // handle.is_steward() == true
30//! ```
31//!
32//! **Joining a group (as member):**
33//! ```ignore
34//! let handle = GroupHandle::new_for_join(group_name);
35//! // handle.is_steward() == false
36//!
37//! // Later, when welcome is received:
38//! mls_service.join_group(&welcome)?;
39//! handle.set_mls_initialized();
40//! ```
41//!
42//! # Proposal Flow
43//!
44//! The handle tracks proposals through their lifecycle:
45//!
46//! ```text
47//! 1. store_voting_proposal() → Proposal created, waiting for votes
48//! 2. mark_proposal_as_approved() → Consensus reached, ready for commit
49//! OR mark_proposal_as_rejected() → Consensus rejected, discard
50//! 3. approved_proposals() → Steward reads approved proposals
51//! 4. clear_approved_proposals() → After commit, archive to history
52//! ```
53//!
54//! Non-owners (members who didn't create the proposal) use:
55//! - `insert_approved_proposal()` - Add proposal directly to approved queue
56
57use std::collections::{HashMap, VecDeque};
58
59use crate::core::group_update_handle::{CurrentEpochProposals, ProposalId};
60use crate::protos::de_mls::messages::v1::GroupUpdateRequest;
61
62/// Handle for a single MLS group's app-level state.
63///
64/// Contains state needed for group operations:
65/// - Application ID for message deduplication across instances
66/// - Steward flag indicating whether this user batches commits
67/// - Proposal queues for tracking voting and approved proposals
68///
69/// **Note**: MLS cryptographic state (encryption keys, group members) is
70/// managed by `MlsService`. Use `mls_service.encrypt()`, `mls_service.decrypt()`,
71/// etc. for MLS operations.
72///
73/// # Thread Safety
74///
75/// The `GroupHandle` should be wrapped in `RwLock` or similar by the
76/// application layer (see `User.groups` in the app module).
77///
78/// # Steward vs Member
79///
80/// - **Steward**: Creates proposals, collects votes, batches approved proposals
81/// into MLS commits. Created via `new_as_creator()`.
82/// - **Member**: Votes on proposals, receives commits. Created via `new_for_join()`.
83#[derive(Clone, Debug)]
84pub struct GroupHandle {
85 /// The name of the group.
86 group_name: String,
87 /// Unique application instance ID for message deduplication.
88 app_id: Vec<u8>,
89 /// Whether this user is the steward for this group.
90 steward: bool,
91 /// Whether MLS state is initialized in MlsService.
92 mls_initialized: bool,
93 /// Proposal for current steward epoch.
94 proposals: CurrentEpochProposals,
95}
96
97impl GroupHandle {
98 /// Create a new group handle for an existing group (joining).
99 ///
100 /// # Arguments
101 /// * `group_name` - The name of the group
102 pub fn new_for_join(group_name: &str) -> Self {
103 Self {
104 group_name: group_name.to_string(),
105 app_id: uuid::Uuid::new_v4().as_bytes().to_vec(),
106 steward: false,
107 mls_initialized: false,
108 proposals: CurrentEpochProposals::new(),
109 }
110 }
111
112 /// Create a new group handle for creating a new group (as steward).
113 ///
114 /// The MLS group should be created via `mls_service.create_group()` first.
115 ///
116 /// # Arguments
117 /// * `group_name` - The name of the group
118 pub fn new_as_creator(group_name: &str) -> Self {
119 Self {
120 group_name: group_name.to_string(),
121 app_id: uuid::Uuid::new_v4().as_bytes().to_vec(),
122 steward: true,
123 mls_initialized: true,
124 proposals: CurrentEpochProposals::new(),
125 }
126 }
127
128 /// Get the group name.
129 pub fn group_name(&self) -> &str {
130 &self.group_name
131 }
132
133 /// Get the group name as bytes.
134 pub fn group_name_bytes(&self) -> &[u8] {
135 self.group_name.as_bytes()
136 }
137
138 /// Get the application ID.
139 pub fn app_id(&self) -> &[u8] {
140 &self.app_id
141 }
142
143 /// Check if this user is the steward.
144 pub fn is_steward(&self) -> bool {
145 self.steward
146 }
147
148 /// Check if the MLS group is initialized.
149 pub fn is_mls_initialized(&self) -> bool {
150 self.mls_initialized
151 }
152
153 /// Mark MLS as initialized (called after joining via welcome).
154 pub fn set_mls_initialized(&mut self) {
155 self.mls_initialized = true;
156 }
157
158 /// Become the steward of this group.
159 pub fn become_steward(&mut self) {
160 self.steward = true;
161 }
162
163 /// Resign as steward of this group.
164 pub fn resign_steward(&mut self) {
165 self.steward = false;
166 }
167
168 // ─────────────────────────── Proposal Handle Operations ───────────────────────────
169
170 /// Check if this user owns (created) the given proposal.
171 ///
172 /// Owners are responsible for broadcasting the proposal to peers and
173 /// must include the full `Proposal` when casting their vote.
174 pub fn is_owner_of_proposal(&self, proposal_id: ProposalId) -> bool {
175 self.proposals.is_owner_of_proposal(proposal_id)
176 }
177
178 /// Get the count of approved proposals waiting to be committed.
179 ///
180 /// The steward checks this to determine when to create a batch commit.
181 pub fn approved_proposals_count(&self) -> usize {
182 self.proposals.approved_proposals_count()
183 }
184
185 /// Get a copy of all approved proposals.
186 ///
187 /// Called by the steward when creating a batch commit. The proposals
188 /// are sorted by SHA256 hash for deterministic ordering.
189 pub fn approved_proposals(&self) -> HashMap<ProposalId, GroupUpdateRequest> {
190 self.proposals.approved_proposals()
191 }
192
193 /// Move a proposal from voting to approved queue.
194 ///
195 /// Called when consensus is reached with `result = true` and this user
196 /// is the proposal owner.
197 pub fn mark_proposal_as_approved(&mut self, proposal_id: ProposalId) {
198 self.proposals.move_proposal_to_approved(proposal_id);
199 }
200
201 /// Remove a proposal from the voting queue (rejected or failed).
202 ///
203 /// Called when consensus is reached with `result = false` or when
204 /// consensus fails (timeout, insufficient votes).
205 pub fn mark_proposal_as_rejected(&mut self, proposal_id: ProposalId) {
206 self.proposals.remove_voting_proposal(proposal_id);
207 }
208
209 /// Store a newly created proposal in the voting queue.
210 ///
211 /// Called after `start_voting()` successfully creates a proposal.
212 /// The proposal remains here until consensus completes.
213 pub fn store_voting_proposal(&mut self, proposal_id: ProposalId, proposal: GroupUpdateRequest) {
214 self.proposals.add_voting_proposal(proposal_id, proposal);
215 }
216
217 /// Insert a proposal directly into the approved queue.
218 ///
219 /// Called by non-owners when they receive a consensus result for a
220 /// proposal they didn't create. They fetch the payload from the
221 /// consensus service and insert it directly as approved.
222 pub fn insert_approved_proposal(
223 &mut self,
224 proposal_id: ProposalId,
225 proposal: GroupUpdateRequest,
226 ) {
227 self.proposals.add_proposal(proposal_id, proposal);
228 }
229
230 /// Clear approved proposals after a commit, archiving to history.
231 ///
232 /// Called after a batch commit is successfully applied. The proposals
233 /// are moved to `epoch_history` for UI display (up to 10 epochs retained).
234 pub fn clear_approved_proposals(&mut self) {
235 self.proposals.clear_approved_proposals();
236 }
237
238 /// Get the epoch history (past batches of approved proposals).
239 ///
240 /// Returns up to 10 past epochs, most recent last. Useful for UI
241 /// to show recent membership changes.
242 pub fn epoch_history(&self) -> &VecDeque<HashMap<ProposalId, GroupUpdateRequest>> {
243 self.proposals.epoch_history()
244 }
245}