Skip to main content

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}