Skip to content

Group Management and Federation

fn create_group(
founder: &DeviceIdentity,
initial_members: &[LocalContact],
group_name: String,
provider: &impl OpenMlsProvider,
) -> Result<MultiUserGroup> {
// 1. Generate random group ID
let group_id = Uuid::new_v4();
// 2. Create permission extension
let mut device_permissions = HashMap::new();
device_permissions.insert(
founder.device_id,
Permissions::ADMINISTRATOR
);
let perm_ext = CryptidPermissionExtension {
device_permissions,
founder: founder.device_id,
version: 0,
last_updated_at: current_unix_timestamp(),
last_updated_by: founder.device_id,
};
// 3. Create group metadata extension
let metadata_ext = CryptidGroupMetadata {
group_name,
group_picture: None,
group_description: None,
threads: HashMap::new(),
version: 0,
last_updated_at: current_unix_timestamp(),
last_updated_by: founder.device_id,
};
// 4. Serialize both extensions
let perm_data = serde_json::to_vec(&perm_ext)?;
let metadata_data = serde_json::to_vec(&metadata_ext)?;
let perm_extension = Extension::new(
ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT),
extension_data.into()
);
let metadata_extension = Extension::new(
ExtensionType::Unknown(CRYPTID_GROUP_METADATA_EXT),
extension_data.into()
);
// 5. Create MLS group with founder's credential, RatchetTreeExtension, and Extensions
let mut extensions = Extensions::empty();
extensions.add(perm_extension)?;
extensions.add(metadata_extension)?;
let config = MlsGroupCreateConfig::builder()
.with_ratchet_tree_extension(true)
.with_group_context_extensions(extensions)?
.build();
let mut mls_group = MlsGroup::new(
provider,
&founder.keypair,
&config,
founder.create_mls_credential(),
)?;
// 6. Fetch KeyPackages for ALL devices of each user
let mut all_keypackages = Vec::new();
let mut device_to_user_map = HashMap::new();
for user_contact in initial_members {
// Fetch KeyPackages for all of this user's devices
for device in &user_contact.devices {
match fetch_keypackage(&device.keypackage_server, &device.device_id) {
Ok(keypackage) => {
all_keypackages.push((device.clone(), keypackage));
device_to_user_map.insert(device.device_id, user_contact.user_id);
}
Err(e) => {
log::warn!("Failed to fetch KeyPackage for device {}: {}",
hex::encode(&device.device_id), e);
// Continue with other devices (partial addition)
}
}
}
}
if all_keypackages.is_empty() {
return Err("No KeyPackages available for any invited user");
}
// Extract just the KeyPackages for MLS
let keypackage: Vec<KeyPackage> = all_keypackages.iter()
.map(|(_, kp)| kp.clone())
.collect();
// 7. Add all devices in one operation (OpenMLS API)
let (commit, welcomes) = mls_group.add_members(
provider,
&founder.keypair,
&keypackages,
)?;
// Commit the changes
mls_group.merge_pending_commit(provider)?;
// 8. Update permission extension to include new members
// (Give them default member permissions)
let mut updated_perm_ext = perm_ext.clone();
for (device, _) in &all_keypackages {
updated_perm_ext.device_permissions.insert(
device.device_id,
Permissions::default_member()
);
}
updated_perm_ext.version += 1;
// Create updated extension
let updated_extension_data = serde_json::to_vec(&updated_perm_ext)?;
let updated_extension = Extension::new(
ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT),
updated_extension_data.into()
);
// Update group context with new permissions
let (perm_commit, _) = mls_group.update_group_context_extensions(
provider,
Extensions::single(updated_extension),
&founder.signer,
)?;
mls_group.merge_pending_commit(provider)?;
// 9. Send Welcome messages to all added devices
for ((device, _), welcome) in all_keypackages.iter().zip(welcomes) {
send_message(
welcome,
&device.initial_delivery_address,
)?;
}
// 10. Build member list (users, not individual devices)
let members = build_member_list_from_mls(&mls_group, &device_to_user_map);
Ok(MultiUserGroup {
group_id,
founder_device: founder.device_id,
members,
device_to_user: device_to_user_map,
mls_state: mls_group,
created_at: current_unix_timestamp(),
})
}

Members can add people they have as contacts. When adding a member, KeyPackages for all their devices must be fetched from their designated server.

Multi-Device Invitation:

When inviting a user to a group:

  1. Fetch KeyPackages for ALL of the user’s devices
  2. Add all devices to the MLS group in a single commit
  3. Send Welcome messages to each device’s delivery address
  4. Handle partial failures (some devices may not have KeyPackages available)

See Contact Exchange and Trust for details on KeyPackage management.

fn add_member_to_group(
group: &mut MultiUserGroup,
user_contact: &LocalContact, // Represents a User
inviting_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// 1. Check permissions from MLS group extension
let perm_ext = group.get_permission_extension()?;
let inviter_perms = perm_ext.device_permissions
.get(&inviting_device.device_id)
.ok_or("Inviting device not in group")?;
if !inviter_perms.contains(Permissions::INVITE_MEMBERS) &&
!inviter_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to invite new members".into());
}
// 2. Fetch KeyPackages for ALL of the user's devices
let mut successful_devices = Vec::new();
let mut failed_devices = Vec::new();
for device in &user_contact.devices {
match fetch_keypackage(&device.keypackage_server, &device.deviceid) {
Ok(keypackage) => {
successful_devices.push((device.clone(), keypackage));
}
Err(e) => {
failed_devices.push((device.deviceid, e.to_string()));
log::warn!("Failed to fetch KeyPackage for device {}: {}",
hex::encode(&device.deviceid), e);
}
}
}
// 3. Proceed if at least ONE device has a KeyPackage
if successful_devices.is_empty() {
return Err("No KeyPackages available for any of the user's devices");
}
// Extract KeyPackages for MLS
let keypackages: Vec<KeyPackage> = successful_devices.iter()
.map(|(_, kp)| kp.clone())
.collect();
// 4. Add all successful devices to MLS group in a single commit
let (commit, welcomes) = group.mls_state.add_members(
provider,
&inviting_device.keypair,
&keypackages,
)?;
// 5. Merge pending commit to update local group state
group.mls_state.merge_pending_commit(provider)?;
// 6. Update permission extension to grant default permissions to new devices
let mut perm_ext = group.get_permission_extension()?;
for (device, _) in &successful_devices {
perm_ext.device_permissions.insert(
device.device_id,
Permissions::default_member()
);
}
perm_ext.version += 1;
perm_ext.last_updated_at = current_unix_timestamp();
perm_ext.last_updated_by = inviting_device.device_id;
// Update group context with new permissions
let extension_data = serde_json::to_vec(&perm_ext)?;
let extension = Extension::new(
ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT),
extension_data.into()
);
let (perm_commit, _) = group.mls_state.update_group_context_extensions(
provider,
Extensions::single(extension),
&inviting_device.signer,
)?;
group.mls_state.merge_pending_commit(provider)?;
// 7. Serialize commit for distribution
let commit_message = commit.tls_serialize_detached()?;
let perm_commit_message = perm_commit.tls_serialize_detached()?;
// 8. Send commits to all current members (updates their group state)
for member in &group.members {
// Get all devices for this member
for device_id in &member.devices_in_group {
// Send both the add commit and permission update commit
for msg in [&commit_message, &perm_commit_message] {
let secure_message = SecureMessage {
message_id: Uuid::now_v7(),
group_id: group.group_id,
mls_ciphertext: msg.clone(),
sender_signature: inviting_device.sign(msg),
timestamp: current_unix_timestamp(),
message_type: MessageType::SystemOperation,
};
// Send to device's delivery address
if let Some(device_info) = find_device_info(device_id) {
send_message(
secure_message,
&device_info.initial_delivery_address,
)?;
}
}
}
}
// 9. Send Welcome to each successfully added device
for ((device, _), welcome) in successful_devices.iter().zip(welcomes) {
let welcome_message = welcome.tls_serialize_detached()?;
send_message(welcome_message, &device.initial_delivery_address)?;
}
// 10. Update local group state
// Check if user is already a member (adding new devices to existing user)
if let Some(existing_member) = group.members.iter_mut()
.find(|m| m.user_id == user_contact.user_id) {
// Add new devices to existing member
for (device, _) in &successful_devices {
if !existing_member.devices_in_group.contains(&device.device_id) {
existing_member.devices_in_group.insert(device.device_id);
group.device_to_user.insert(device.device_id, user_contact.user_id);
}
}
} else {
// Add as new member
let device_ids: HashSet<_> = successful_devices.iter()
.map(|(d, _)| d.device_id)
.collect();
for device_id in &device_ids {
group.device_to_user.insert(*device_id, user_contact.user_id);
}
group.members.push(GroupMember {
user_id: user_contact.user_id,
default_persona: user_contact.default_persona.clone(),
personas: user_contact.personas.clone(),
devices_in_group: device_ids,
devices_pending: failed_devices.iter()
.filter_map(|(device_id, _)| {
user_contact.devices.iter()
.find(|d| &d.device_id == device_id)
.cloned()
})
.collect(),
added_by: inviting_device.device_id,
added_at: current_unix_timestamp(),
});
}
Ok(InvitationResult {
invited_user: user_contact.user_id,
successful_devices: successful_devices.iter()
.map(|(d, _)| d.device_id)
.collect(),
failed_devices,
})
}
struct InvitationResult {
invited_user: UserId,
successful_devices: Vec<DeviceId>, // device_ids that were added
failed_devices: Vec<(DeviceId, String)>, // device_ids that failed + reason
}

The GroupMember struct now represents users (not individual devices):

struct GroupMember {
// User identity
user_id: UserId,
// Personas/identities for this user
// See the Crypitd's Identity System doc for more info
default_persona: Persona,
personas: Vec<Persona>,
// Device membership tracking
devices_in_group: HashSet<DeviceId>, // Device IDs
devices_pending: Vec<DevicePublicInfo>, // Failed during initial invite
// Who invited this member to the group
added_by: DeviceId,
added_at: u64,
}
struct MultiUserGroup {
// Cryptographically random
group_id: GroupId,
// Who created the group (device-level)
founder_device: DeviceId,
// Members are users, not devices
members: Vec<GroupMember>,
// Fast lookup: device_id -> user_id
device_to_user: HashMap<DeviceId, UserId>,
// Current MLS epoch and keys (contains permission extension)
mls_state: MlsGroup,
created_at: u64,
}
impl MultiUserGroup {
/// Extract permissions from MLS group context
fn get_permission_extension(&self) -> Result<CryptidPermissionExtension> {
let extensions = self.mls_state.group_context().extensions();
let perm_ext = extensions
.iter()
.find(|ext| matches!(
ext.extension_type(),
ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT)
))
.ok_or("Permission extension missing from group")?;
let permissions: CryptidPermissionExtension =
serde_json::from_slice(perm_ext.extension_data())?;
Ok(permissions)
}
/// Check if device has specific permission
fn has_permission(
&self,
device_id: &DeviceId,
perm: Permissions
) -> Result<bool> {
let perm_ext = self.get_permission_extension()?;
if let Some(device_perms) = perm_ext.device_permissions.get(device_id) {
Ok(device_perms.contains(perm))
} else {
Ok(false)
}
}
/// Fast lookup to get user info from device_id
fn get_user_for_device(&self, device_id: &DeviceId) -> Option<&GroupMember> {
let user_id = self.device_to_user.get(device_id)?;
self.members.iter().find(|m| &m.user_id == user_id)
}
}

Why track devices within users?

  • UI displays “Alice (3 devices in group, 2 pending)”
  • Enables per-device operations (e.g., removing a compromising device)
  • Maintains user-centric view while preserving device-level MLS membership

Users can retry adding devices that failed during initial invitation:

fn add_pending_devices_to_group(
group: &mut MultiUserGroup,
member_user_id: &UserId,
inviting_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<Vec<DeviceId>> {
// 1. Check permissions
let perm_ext = group.get_permission_extension()?;
let inviter_perms = perm_ext.device_permissions
.get(&inviting_device.device_id)
.ok_or("Inviting device not in group")?;
if !inviter_perms.contains(Permissions::INVITE_MEMBERS) &&
!inviter_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to add devices".into());
}
// 2. Find member
let member = group.members.iter_mut()
.find(|m| &m.user_id == member_user_id)
.ok_or("User not in group")?;
let mut added_devices = Vec::new();
// 3. Try to add each pending device
for pending_device in &member.devices_pending.clone() {
match fetch_keypackage(&pending_device.keypackage_server, &pending_device.deviceid) {
Ok(keypackage) => {
// Add this single device
let (commit, welcome) = group.mls_state.add_members(
provider,
&inviting_device.keypair,
&[keypackage],
)?;
group.mls_state.merge_pending_commit(provider)?;
// Update permissions for the new device
let mut perm_ext = group.get_permission_extension()?;
perm_ext.device_permissions.insert(
pending_device.device_id,
Permissions::default_member()
);
perm_ext.version += 1;
let extension_data = serde_json::to_vec(&perm_ext)?;
let extension = Extension::new(
ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT),
extension_data.into()
);
let (perm_commit, _) = group.mls_state.update_group_context_extensions(
provider,
Extensions::single(extension),
&inviting_device.signer,
)?;
group.mls_state.merge_pending_commit(provider)?;
send_message(welcome, &pending_device.initial_delivery_address)?;
added_devices.push(pending_device.deviceid);
member.devices_in_group.insert(pending_device.deviceid);
group.device_to_user.insert(pending_device.device_id, *member_user_id);
}
Err(_) => continue, // Still no KeyPackage, leave pending
}
}
// Remove successfully added devices from pending list
member.devices_pending.retain(|d| !added_devices.contains(&d.deviceid));
Ok(added_devices)
}

Devices with MANAGE_ROLES or ADMINISTRATOR permission can grant permissions to other group members:

async fn grant_permission_to_member(
group: &mut MultiUserGroup,
target_device: DeviceId,
new_permissions: Permissions,
granting_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// 1. Verify granting device has authority
let perm_ext = group.get_permission_extension()?;
let granter_perms = perm_ext.device_permissions
.get(&granting_device.device_id)
.ok_or("Granting device not in group")?;
if !granter_perms.contains(Permissions::MANAGE_ROLES) &&
!granter_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to manage roles".into());
}
// 2. Update permission extension
let mut updated_perm_ext = perm_ext.clone();
updated_perm_ext.device_permissions.insert(target_device, new_permissions);
updated_perm_ext.version += 1;
updated_perm_ext.last_updated_at = current_unix_timestamp();
updated_perm_ext.last_updated_by = granting_device.device_id;
// 3. Create MLS commit with updated permissions
let extension_data = serde_json::to_vec(&updated_perm_ext)?;
let extension = Extension::new(
ExtensionType::Unknown(CRYPTID_PERMISSIONS_EXT),
extension_data.into()
);
let (commit, _) = group.mls_state.update_group_context_extensions(
provider,
Extensions::single(extension),
&granting_device.signer,
)?;
// 4. Merge and distribute
group.mls_state.merge_pending_commit(provider)?;
let commit_message = commit.tls_serialize_detached()?;
for member in &group.members {
for device_id in &member.devices_in_group {
let secure_message = SecureMessage {
message_id: Uuid::now_v7(),
group_id: group.group_id,
mls_ciphertext: commit_message.clone(),
sender_signature: granting_device.sign(&commit_message),
timestamp: current_unix_timestamp(),
message_type: MessageType::SystemOperation,
};
if let Some(device_info) = find_device_info(device_id) {
send_message(secure_message, &device_info.initial_delivery_address)?;
}
}
}
Ok(())
}
async fn revoke_permission_from_member(
group: &mut MultiUserGroup,
target_device: DeviceId,
permission_to_revoke: Permissions,
revoking_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// Get current permissions
let mut perm_ext = group.get_permission_extension()?;
// Verify authority
let revoker_perms = perm_ext.device_permissions
.get(&revoking_device.device_id)
.ok_or("Revoking device not in group")?;
if !revoker_perms.contains(Permissions::MANAGE_ROLES) &&
!revoker_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to manage roles".into());
}
// Cannot revoke founder's permissions
if target_device == perm_ext.founder {
return Err("Cannot revoke founder's permissions".into());
}
// Update target's permissions
if let Some(target_perms) = perm_ext.device_permissions.get_mut(&target_device) {
*target_perms = target_perms.difference(permission_to_revoke);
}
perm_ext.version += 1;
perm_ext.last_updated_at = current_unix_timestamp();
perm_ext.last_updated_by = revoking_device.device_id;
// Same commit/merge/distribute process as granting...
// (See grant_permission_to_member for full implementation)
Ok(())
}

Permission Rules:

  • Only MANAGE_ROLES or ADMINISTRATOR can change permissions
  • Founder’s ADMINISTRATOR permission is immutable
  • Permission changes are logged in group history
  • All clients must verify permission changes before accepting

See Moderation Architecture for the complete permission flag list and presets.

Group metadata (name, picture, description, and thread organization) is stored in an MLS group context extension, ensuring that all cosmetic and organizational data is cryptographically protected and automatically synchronized across members.

const CRYPTID_GROUP_METADATA_EXT: u16 = 0xF001;
struct CryptidGroupMetadata {
// Group cosmetics
group_name: Option<String>,
group_picture: Option<GroupPicture>,
group_description: Option<String>,
// Thread organization
threads: HashMap<ThreadId, ThreadMetadata>,
// Version tracking
version: u64,
last_updated_at: u64,
last_updated_by: DeviceId,
}
struct GroupPicture {
// Small images stored inline (< 100KB)
inline_data: Option<InlineMedia>,
// Large images via file transfer reference
file_reference: Option<FileReference>,
}
struct ThreadMetadata {
id: ThreadId,
name: String,
created_at: u64,
created_by: DeviceId,
pinned: bool,
archived: bool,
}
impl MultiUserGroup {
/// Extract metadata from MLS group context
fn get_group_metadata_extension(&self) -> Result<CryptidGroupMetadata> {
let extensions = self.mls_state.group_context().extensions();
let metadata_ext = extensions
.iter()
.find(|ext| matches!(
ext.extension_type(),
ExtensionType::Unknown(CRYPTID_GROUP_METADATA_EXT)
))
.ok_or("Group metadata extension missing")?;
let metadata: CryptidGroupMetadata =
serde_json::from_slice(metadata_ext.extension_data())?;
Ok(metadata)
}
}
async fn update_group_name(
group: &mut MultiUserGroup,
new_name: String,
updating_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// 1. Check permissions
let perm_ext = group.get_permission_extension()?;
let updater_perms = perm_ext.device_permissions
.get(&updating_device.device_id)
.ok_or("Updating device not in group")?;
if !updater_perms.contains(Permissions::CHANGE_GROUP_NAME) &&
!updater_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to change group name".into());
}
// 2. Update metadata
let mut metadata = group.get_group_metadata_extension()?;
metadata.group_name = Some(new_name);
metadata.version += 1;
metadata.last_updated_at = current_unix_timestamp();
metadata.last_updated_by = updating_device.device_id;
// 3. Create MLS commit
let extension_data = serde_json::to_vec(&metadata)?;
let extension = Extension::new(
ExtensionType::Unknown(CRYPTID_GROUP_METADATA_EXT),
extension_data.into(),
);
let (commit, _) = group.mls_state.update_group_context_extensions(
provider,
Extensions::single(extension),
&updating_device.signer,
)?;
// 4. Merge and distribute
group.mls_state.merge_pending_commit(provider)?;
let commit_message = commit.tls_serialize_detached()?;
for member in &group.members {
for device_id in &member.devices_in_group {
let secure_message = SecureMessage {
message_id: Uuid::now_v7(),
group_id: group.group_id,
mls_ciphertext: commit_message.clone(),
sender_signature: updating_device.sign(&commit_message),
timestamp: current_unix_timestamp(),
message_type: MessageType::SystemOperation,
};
if let Some(device_info) = find_device_info(device_id) {
send_message(secure_message, &device_info.initial_delivery_address)?;
}
}
}
Ok(())
}
async fn update_group_picture(
group: &mut MultiUserGroup,
picture: GroupPicture,
updating_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// Check permissions
let perm_ext = group.get_permission_extension()?;
let updater_perms = perm_ext.device_permissions
.get(&updating_device.device_id)
.ok_or("Updating device not in group")?;
if !updater_perms.contains(Permissions::CHANGE_GROUP_ICON) &&
!updater_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to change group picture".into());
}
// Update metadata (same pattern as update_group_name)
let mut metadata = group.get_group_metadata_extension()?;
metadata.group_picture = Some(picture);
metadata.version += 1;
metadata.last_updated_at = current_unix_timestamp();
metadata.last_updated_by = updating_device.device_id;
// Create commit and distribute (same stuff as update_group_name)
}

Threads organize messages within a group. Each thread has a name, creation metadata, and status flags.

async fn create_thread(
group: &mut MultiUserGroup,
thread_name: String,
creating_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<ThreadId> {
// Check permissions
let perm_ext = group.get_permission_extension()?;
let creator_perms = perm_ext.device_permissions
.get(&creating_device.device_id)
.ok_or("Creating device not in group")?;
if !creator_perms.contains(Permissions::MANAGE_THREADS) &&
!creator_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to create threads".into());
}
// Generate thread ID
let thread_id = Uuid::new_v4();
// Update metadata
let mut metadata = group.get_group_metadata_extension()?;
metadata.threads.insert(
thread_id,
ThreadMetadata {
id: thread_id,
name: thread_name,
created_at: current_unix_timestamp(),
created_by: creating_device.device_id,
pinned: false,
archived: false,
}
);
metadata.version += 1;
metadata.last_updated_at = current_unix_timestamp();
metadata.last_updated_by = creating_device.device_id;
// Create commit and distribute (same pattern as other metadata updates)
let extension_data = serde_json::to_vec(&metadata)?;
let extension = Extension::new(
ExtensionType::Unknown(CRYPTID_GROUP_METADATA_EXT),
extension_data.into(),
);
let (commit, _) = group.mls_state.update_group_context_extensions(
provider,
Extensions::single(extension),
&creating_device.signer,
)?;
group.mls_state.merge_pending_commit(provider)?;
// Distribute commit to all members
Ok(thread_id)
}
async fn rename_thread(
group: &mut MultiUserGroup,
thread_id: ThreadId,
new_name: String,
renaming_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// Check permissions
let perm_ext = group.get_permission_extension()?;
let renamer_perms = perm_ext.device_permissions
.get(&renaming_device.device_id)
.ok_or("Renaming device not in group")?;
if !renamer_perms.contains(Permissions::MANAGE_THREADS) &&
!renamer_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to rename threads".into());
}
// Update thread name
let mut metadata = group.get_group_metadata_extension()?;
let thread = metadata.threads.get_mut(&thread_id)
.ok_or("Thread not found")?;
thread.name = new_name;
metadata.version += 1;
metadata.last_updated_at = current_unix_timestamp();
metadata.last_updated_by = renaming_device.device_id;
// Create commit and distribute
}
async fn update_thread_status(
group: &mut MultiUserGroup,
thread_id: ThreadId,
pinned: Option<bool>,
archived: Option<bool>,
updating_device: &DeviceIdentity,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// Permission check
let perm_ext = group.get_permission_extension()?;
let updater_perms = perm_ext.device_permissions
.get(&updating_device.device_id)
.ok_or("Updating device not in group")?;
if !updater_perms.contains(Permissions::MANAGE_THREADS) &&
!updater_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to update thread status".into());
}
let mut metadata = group.get_group_metadata_extension()?;
let thread = metadata.threads.get_mut(&thread_id)
.ok_or("Thread not found")?;
if let Some(pin) = pinned {
thread.pinned = pin;
}
if let Some(arch) = archived {
thread.archived = arch;
}
metadata.version += 1;
metadata.last_updated_at = current_unix_timestamp();
metadata.last_updated_by = updating_device.device_id;
// Create commit and distribute...
}

When a group member rotates their delivery addresses, they notify the group via a system message.

fn rotate_address_in_group(
group: &mut MultiUserGroup,
device: &DeviceIdentity,
new_address: String,
provider: &impl OpenMlsProvider,
) -> Result<()> {
// 1. Create system message announcing address change
let address_rotation = SystemOperation::AddressRotation {
device_id: device.device_id,
new_delivery_address: new_address.clone(),
timestamp: current_unix_timestamp(),
};
// 2. Serialize and sign the message
let message_bytes = serialize(&address_rotation)?;
let signature = device.sign(&message_bytes);
// 3. Encrypt with MLS
let encrypted = group.mls_state.encrypt_application_message(
provider,
&device.keypair,
&message_bytes,
)?;
// 4. Send to all group members
for member in &group.members {
let secure_message = SecureMessage {
message_id: Uuid::now_v7(),
group_id: group.group_id,
mls_ciphertext: encrypted.clone(),
sender_signature: signature.clone(),
timestamp: current_unix_timestamp(),
message_type: MessageType::SystemOperation,
};
send_message(
secure_message,
&member.delivery_addresses,
)?;
}
// 5. Update local group state
if let Some(member) = group.members.iter_mut()
.find(|m| m.device_id == device.device_id) {
member.delivery_addresses.push(new_address);
}
Ok(())
}

When members receive an address rotation message:

fn handle_address_rotation(
group: &mut MultiUserGroup,
rotation: &SystemOperation::AddressRotation,
) -> Result<()> {
// 1. Find the member
let member = group.members.iter_mut()
.find(|m| m.device_id == rotation.device_id)
.ok_or("Unknown member")?;
// 2. Add new address to their list
if !member.delivery_addresses.contains(&rotation.new_delivery_address) {
member.delivery_addresses.push(rotation.new_delivery_address.clone());
}
// 3. Optional: Remove old addresses after grace period
// (Keep old addresses for a few days to handle in-flight messages)
Ok(())
}

Address rotation properties:

  • Members maintain multiple addresses during transition
  • Old addresses remain valid for a grace period (e.g., 7 days)
  • Group members automatically learn new addresses
  • No manual contact updates needed
GET /.well-known/cryptid
Host: server.example.com
{
"protocol_version": "1.0",
"server_name": "server.example.com",
"federation_enabled": true,
"supported_features": [
"mls_messaging"
],
"rate_limits": {
"federated_messages_per_minute": 1000,
"max_message_size": 10485760
},
"certificate_fingerprint": "sha256:a1b2c3d4..."
}
PUT /federation/v1/deliver/{transaction_id}
Content-Type: application/json
Authorization: Bearer <federation_token>
{
"origin_server": "alice.server1.com",
"transaction_id": "23bcb253-6fb0-4369-9545-706b0a863da3",
"messages": [
{
"message_id": "019a821b-d8d4-7dc1-8ea4-28dfcf55346b",
"recipient_addresses": ["f9c66cde-be8c-43c7-853a-fb1307755cc2@server2.com"],
"group_id": "b79da4c3-a5aa-451d-90be-45336b0522ee",
"mls_ciphertext": "base64_of_encrypted_content",
"sender_signature": "ed25519_signature",
"timestamp": 1758999498
}
]
}

The recipient_addresses field contains delivery addresses (not device_ids). If a device has multiple delivery addresses on different servers, the message may be sent to any active address. The receiving server routes to the device regardless of which address was used.

Example:

{
"origin_server": "server1.com",
"messages": [
{
"recipient_addresses": [
"bob-work@server2.com", // Bob's work address
"bob-personal@server2.com" // Bob's personal address (same device)
],
// Server2 delivers to Bob's device via either address
}
]
}