# Space Permission Cascading Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Implement server-side Space permission cascading — power levels and role-based access flow from Spaces to their direct child rooms. **Architecture:** Custom state events (`m.space.roles`, `m.space.role.member`, `m.space.role.room`) define roles in Space rooms. An in-memory cache indexes these for fast enforcement. The server intercepts joins, membership changes, and state event updates to enforce cascading. A server-wide config flag (`space_permission_cascading`) gates the entire feature. **Tech Stack:** Rust, ruma (Matrix types), conduwuit service layer, clap (admin commands), serde, LruCache/HashMap, tokio async **Design doc:** `docs/plans/2026-03-17-space-permission-cascading-design.md` --- ### Task 1: Add Config Flag **Files:** - Modify: `src/core/config/mod.rs` (add field to Config struct, near line 604) **Step 1: Add the config field** Add after the `suspend_on_register` field (around line 604) in the `Config` struct: ```rust /// Enable space permission cascading (power levels and role-based access). /// When enabled, power levels cascade from Spaces to child rooms and rooms /// can require roles for access. Applies to all Spaces on this server. /// /// default: false #[serde(default)] pub space_permission_cascading: bool, ``` **Step 2: Verify build** Run: `cargo check -p conduwuit-core 2>&1 | tail -20` Expected: Compiles successfully. The `conduwuit-example.toml` is auto-generated from doc comments by the `#[config_example_generator]` macro. **Step 3: Commit** ```bash git add src/core/config/mod.rs git commit -m "feat(spaces): add space_permission_cascading config flag" ``` --- ### Task 2: Define Custom State Event Content Types **Files:** - Create: `src/core/matrix/space_roles.rs` - Modify: `src/core/matrix/mod.rs` (add module declaration) **Step 1: Create the event content types** Create `src/core/matrix/space_roles.rs` with serde types for the three custom state events: ```rust //! Custom state event content types for space permission cascading. //! //! These events live in Space rooms and define roles, user-role assignments, //! and room-role requirements. use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; /// Content for `m.space.roles` (state key: "") /// /// Defines available roles for a Space. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct SpaceRolesEventContent { pub roles: BTreeMap, } /// A single role definition within a Space. #[derive(Clone, Debug, Deserialize, Serialize)] pub struct RoleDefinition { pub description: String, /// If present, users with this role receive this power level in child /// rooms. #[serde(skip_serializing_if = "Option::is_none")] pub power_level: Option, } /// Content for `m.space.role.member` (state key: user ID) /// /// Assigns roles to a user within a Space. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct SpaceRoleMemberEventContent { pub roles: Vec, } /// Content for `m.space.role.room` (state key: room ID) /// /// Declares which roles a child room requires for access. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct SpaceRoleRoomEventContent { pub required_roles: Vec, } ``` **Step 2: Register the module** In `src/core/matrix/mod.rs`, add: ```rust pub mod space_roles; ``` **Step 3: Write tests for serde round-tripping** Add to the bottom of `src/core/matrix/space_roles.rs`: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn serialize_space_roles() { let mut roles = BTreeMap::new(); roles.insert( "admin".to_owned(), RoleDefinition { description: "Space administrator".to_owned(), power_level: Some(100), }, ); roles.insert( "nsfw".to_owned(), RoleDefinition { description: "NSFW access".to_owned(), power_level: None, }, ); let content = SpaceRolesEventContent { roles }; let json = serde_json::to_string(&content).unwrap(); let deserialized: SpaceRolesEventContent = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.roles.len(), 2); assert_eq!(deserialized.roles["admin"].power_level, Some(100)); assert!(deserialized.roles["nsfw"].power_level.is_none()); } #[test] fn serialize_role_member() { let content = SpaceRoleMemberEventContent { roles: vec!["nsfw".to_owned(), "vip".to_owned()], }; let json = serde_json::to_string(&content).unwrap(); let deserialized: SpaceRoleMemberEventContent = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.roles, vec!["nsfw", "vip"]); } #[test] fn serialize_role_room() { let content = SpaceRoleRoomEventContent { required_roles: vec!["nsfw".to_owned()], }; let json = serde_json::to_string(&content).unwrap(); let deserialized: SpaceRoleRoomEventContent = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.required_roles, vec!["nsfw"]); } #[test] fn empty_roles_deserialize() { let json = r#"{"roles":{}}"#; let content: SpaceRolesEventContent = serde_json::from_str(json).unwrap(); assert!(content.roles.is_empty()); } } ``` **Step 4: Run tests** Run: `cargo test -p conduwuit-core space_roles 2>&1 | tail -20` Expected: All 4 tests pass. **Step 5: Commit** ```bash git add src/core/matrix/space_roles.rs src/core/matrix/mod.rs git commit -m "feat(spaces): add custom state event types for space roles" ``` --- ### Task 3: Create the Space Roles Service **Files:** - Create: `src/service/rooms/roles/mod.rs` - Modify: `src/service/rooms/mod.rs` (add module) - Modify: `src/service/service.rs` or equivalent service registry (register new service) This is the core service that manages the in-memory cache and provides lookup methods. **Step 1: Create the service skeleton** Create `src/service/rooms/roles/mod.rs`: ```rust //! Space permission cascading service. //! //! Maintains an in-memory index of space roles, user-role assignments, and //! room-role requirements. Source of truth is always the state events in the //! Space room. use std::{ collections::{BTreeMap, HashMap, HashSet}, fmt::Write, sync::Arc, }; use async_trait::async_trait; use conduwuit_core::{ Result, implement, matrix::space_roles::{ SpaceRoleRoomEventContent, SpaceRoleMemberEventContent, SpaceRolesEventContent, RoleDefinition, }, }; use ruma::{OwnedRoomId, OwnedUserId, RoomId, UserId}; use tokio::sync::RwLock; use crate::{Dep, rooms}; pub struct Service { services: Services, /// Space ID -> role definitions pub roles: RwLock>>, /// Space ID -> user ID -> assigned roles pub user_roles: RwLock>>>, /// Space ID -> child room ID -> required roles pub room_requirements: RwLock>>>, /// Child room ID -> parent Space ID (reverse lookup) pub room_to_space: RwLock>, } struct Services { state_accessor: Dep, state_cache: Dep, state: Dep, spaces: Dep, timeline: Dep, server: Arc, } #[async_trait] impl crate::Service for Service { fn build(args: crate::Args<'_>) -> Result> { Ok(Arc::new(Self { services: Services { state_accessor: args .depend::("rooms::state_accessor"), state_cache: args.depend::("rooms::state_cache"), state: args.depend::("rooms::state"), spaces: args.depend::("rooms::spaces"), timeline: args.depend::("rooms::timeline"), server: args.server.clone(), }, roles: RwLock::new(HashMap::new()), user_roles: RwLock::new(HashMap::new()), room_requirements: RwLock::new(HashMap::new()), room_to_space: RwLock::new(HashMap::new()), })) } async fn memory_usage(&self, out: &mut (dyn Write + Send)) -> Result { let roles = self.roles.read().await.len(); let user_roles = self.user_roles.read().await.len(); let room_requirements = self.room_requirements.read().await.len(); let room_to_space = self.room_to_space.read().await.len(); writeln!(out, "space_roles_definitions: {roles}")?; writeln!(out, "space_user_roles: {user_roles}")?; writeln!(out, "space_room_requirements: {room_requirements}")?; writeln!(out, "space_room_to_space_index: {room_to_space}")?; Ok(()) } async fn clear_cache(&self) { self.roles.write().await.clear(); self.user_roles.write().await.clear(); self.room_requirements.write().await.clear(); self.room_to_space.write().await.clear(); } fn name(&self) -> &str { crate::service::make_name(std::module_path!()) } } ``` **Step 2: Register the module in rooms** In `src/service/rooms/mod.rs`, add: ```rust pub mod roles; ``` **Step 3: Register the service in the service registry** Find where services are registered (likely in `src/service/services.rs` or similar) and add the `rooms::roles::Service` following the same pattern as other room services. This requires reading the exact registration pattern used. **Step 4: Verify build** Run: `cargo check -p conduwuit-service 2>&1 | tail -20` Expected: Compiles successfully. **Step 5: Commit** ```bash git add src/service/rooms/roles/ src/service/rooms/mod.rs git commit -m "feat(spaces): add space roles service skeleton with cache structures" ``` --- ### Task 4: Implement Cache Population and Lookup Methods **Files:** - Modify: `src/service/rooms/roles/mod.rs` **Step 1: Add the `is_enabled()` check** ```rust #[implement(Service)] pub fn is_enabled(&self) -> bool { self.services.server.config.space_permission_cascading } ``` **Step 2: Add cache population from state events** ```rust /// Rebuild the cache for a single Space by reading its state events. #[implement(Service)] pub async fn populate_space(&self, space_id: &RoomId) -> Result { if !self.is_enabled() { return Ok(()); } // Load role definitions from m.space.roles let roles_content: Option = self .services .state_accessor .room_state_get_content(space_id, &StateEventType::from("m.space.roles"), "") .await .ok(); if let Some(content) = roles_content { self.roles .write() .await .insert(space_id.to_owned(), content.roles); } // Load user role assignments from m.space.role.member state events // Iterate all state events of type m.space.role.member // Load room requirements from m.space.role.room state events // Build room_to_space reverse index from m.space.child events Ok(()) } ``` **Step 3: Add lookup methods** ```rust /// Get a user's effective power level from Space roles. /// Returns None if user has no roles with power levels. #[implement(Service)] pub async fn get_user_power_level( &self, space_id: &RoomId, user_id: &UserId, ) -> Option { let roles_map = self.roles.read().await; let user_roles_map = self.user_roles.read().await; let role_defs = roles_map.get(space_id)?; let user_assigned = user_roles_map.get(space_id)?.get(user_id)?; user_assigned .iter() .filter_map(|role_name| role_defs.get(role_name)?.power_level) .max() } /// Check if a user has all required roles for a room. #[implement(Service)] pub async fn user_qualifies_for_room( &self, space_id: &RoomId, room_id: &RoomId, user_id: &UserId, ) -> bool { let reqs = self.room_requirements.read().await; let Some(space_reqs) = reqs.get(space_id) else { return true; // no requirements tracked for this space }; let Some(required) = space_reqs.get(room_id) else { return true; // room has no role requirements }; if required.is_empty() { return true; } let user_map = self.user_roles.read().await; let Some(space_users) = user_map.get(space_id) else { return false; }; let Some(user_assigned) = space_users.get(user_id) else { return false; }; required.iter().all(|r| user_assigned.contains(r)) } /// Get the parent Space of a child room, if any. #[implement(Service)] pub async fn get_parent_space(&self, room_id: &RoomId) -> Option { self.room_to_space.read().await.get(room_id).cloned() } ``` **Step 4: Verify build** Run: `cargo check -p conduwuit-service 2>&1 | tail -20` Expected: Compiles successfully. **Step 5: Commit** ```bash git add src/service/rooms/roles/mod.rs git commit -m "feat(spaces): add cache population and lookup methods for space roles" ``` --- ### Task 5: Implement Default Roles Initialization **Files:** - Modify: `src/service/rooms/roles/mod.rs` **Step 1: Add default role creation** ```rust /// Ensure a Space has the default admin/mod roles. Sends an m.space.roles /// state event if none exists. #[implement(Service)] pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result { if !self.is_enabled() { return Ok(()); } // Check if m.space.roles already exists let existing: Result = self .services .state_accessor .room_state_get_content(space_id, &StateEventType::from("m.space.roles"), "") .await; if existing.is_ok() { return Ok(()); } // Create default roles let mut roles = BTreeMap::new(); roles.insert( "admin".to_owned(), RoleDefinition { description: "Space administrator".to_owned(), power_level: Some(100), }, ); roles.insert( "mod".to_owned(), RoleDefinition { description: "Space moderator".to_owned(), power_level: Some(50), }, ); let content = SpaceRolesEventContent { roles }; // Send the state event as the server user // This requires finding or creating a suitable sender // Use the server's service user or the space creator Ok(()) } ``` **Step 2: Verify build** Run: `cargo check -p conduwuit-service 2>&1 | tail -20` Expected: Compiles. **Step 3: Commit** ```bash git add src/service/rooms/roles/mod.rs git commit -m "feat(spaces): add default admin/mod role initialization" ``` --- ### Task 6: Implement Join Gating **Files:** - Modify: `src/api/client/membership/join.rs` (add role check before join) **Step 1: Add role-based join check** In `join_room_by_id_helper()` or equivalent join path, after existing authorization checks and before the actual join, add: ```rust // Space permission cascading: check if user has required roles if services.rooms.roles.is_enabled() { if let Some(parent_space) = services.rooms.roles.get_parent_space(&room_id).await { if !services .rooms .roles .user_qualifies_for_room(&parent_space, &room_id, sender_user) .await { return Err!(Request(Forbidden( "You do not have the required Space roles to join this room" ))); } } } ``` **Step 2: Verify build** Run: `cargo check -p conduwuit-api 2>&1 | tail -20` Expected: Compiles. **Step 3: Commit** ```bash git add src/api/client/membership/join.rs git commit -m "feat(spaces): add role-based join gating for space child rooms" ``` --- ### Task 7: Implement Power Level Override **Files:** - Modify: `src/service/rooms/roles/mod.rs` (add PL sync method) **Step 1: Add power level synchronization method** ```rust /// Synchronize power levels in a child room based on Space roles. /// This overrides per-room power levels with Space-granted levels. #[implement(Service)] pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Result { if !self.is_enabled() { return Ok(()); } let state_lock = self.services.state.mutex.lock(room_id).await; // Get current power levels for the room let mut power_levels: RoomPowerLevelsEventContent = self .services .state_accessor .room_state_get_content(room_id, &StateEventType::RoomPowerLevels, "") .await .unwrap_or_default(); let mut changed = false; // Get all members of the room let members: Vec<_> = self .services .state_cache .room_members(room_id) .collect() .await; for user_id in &members { if let Some(pl) = self.get_user_power_level(space_id, user_id).await { let current = power_levels .users .get(user_id) .copied() .unwrap_or(power_levels.users_default); if i64::from(current) != pl { power_levels.users.insert(user_id.to_owned(), pl.into()); changed = true; } } } if changed { // Send updated power levels as the server/space admin // Use PduBuilder::state to create the event // timeline.build_and_append_pdu(...) } Ok(()) } ``` **Step 2: Verify build** Run: `cargo check -p conduwuit-service 2>&1 | tail -20` Expected: Compiles. **Step 3: Commit** ```bash git add src/service/rooms/roles/mod.rs git commit -m "feat(spaces): add power level synchronization from space roles" ``` --- ### Task 8: Implement Auto-Join and Auto-Kick **Files:** - Modify: `src/service/rooms/roles/mod.rs` (add enforcement methods) **Step 1: Add auto-join method** ```rust /// Auto-join a user to all qualifying child rooms of a Space. #[implement(Service)] pub async fn auto_join_qualifying_rooms( &self, space_id: &RoomId, user_id: &UserId, ) -> Result { if !self.is_enabled() { return Ok(()); } // Get all child rooms from m.space.child events let child_rooms: Vec = self .services .spaces .get_space_child_events(space_id) .filter_map(|pdu| { RoomId::parse(pdu.state_key()?).ok().map(|r| r.to_owned()) }) .collect() .await; for child_room_id in &child_rooms { // Skip if already joined if self .services .state_cache .is_joined(user_id, child_room_id) .await { continue; } // Check if user qualifies if self .user_qualifies_for_room(space_id, child_room_id, user_id) .await { // Perform the join via the membership service // This needs to create a join membership event } } Ok(()) } ``` **Step 2: Add auto-kick method** ```rust /// Remove a user from all child rooms they no longer qualify for. #[implement(Service)] pub async fn kick_unqualified_from_rooms( &self, space_id: &RoomId, user_id: &UserId, ) -> Result { if !self.is_enabled() { return Ok(()); } let child_rooms: Vec = self .room_requirements .read() .await .get(space_id) .map(|reqs| reqs.keys().cloned().collect()) .unwrap_or_default(); for child_room_id in &child_rooms { if !self .services .state_cache .is_joined(user_id, child_room_id) .await { continue; } if !self .user_qualifies_for_room(space_id, child_room_id, user_id) .await { // Kick the user by sending a leave membership event // with reason "No longer has required Space roles" } } Ok(()) } ``` **Step 3: Verify build** Run: `cargo check -p conduwuit-service 2>&1 | tail -20` Expected: Compiles. **Step 4: Commit** ```bash git add src/service/rooms/roles/mod.rs git commit -m "feat(spaces): add auto-join and auto-kick enforcement methods" ``` --- ### Task 9: Hook State Event Changes for Enforcement **Files:** - Modify: `src/service/rooms/timeline/append.rs` (add hooks after PDU append) **Step 1: Add enforcement hook after event append** In the `append_pdu()` function, after the event is successfully appended, add a check for space role events: ```rust // Space permission cascading: react to role-related state events if self.services.roles.is_enabled() { if let Some(state_key) = &pdu.state_key { match pdu.event_type() { // m.space.roles changed -> revalidate all members t if t == "m.space.roles" => { self.services.roles.populate_space(&pdu.room_id).await?; // Revalidate all members against all child rooms } // m.space.role.member changed -> auto-join/kick that user t if t == "m.space.role.member" => { if let Ok(user_id) = UserId::parse(state_key) { self.services.roles.populate_space(&pdu.room_id).await?; self.services .roles .auto_join_qualifying_rooms(&pdu.room_id, &user_id) .await?; self.services .roles .kick_unqualified_from_rooms(&pdu.room_id, &user_id) .await?; // Sync power levels in all child rooms for this user } } // m.space.role.room changed -> auto-join/kick for that room t if t == "m.space.role.room" => { if let Ok(room_id) = RoomId::parse(state_key) { self.services.roles.populate_space(&pdu.room_id).await?; // Check all members of room_id against new requirements } } // m.space.child added/removed -> update room_to_space index t if t == StateEventType::SpaceChild.to_string() => { self.services.roles.populate_space(&pdu.room_id).await?; // If new child, auto-join qualifying members } // m.room.member join in a Space -> auto-join child rooms t if t == StateEventType::RoomMember.to_string() => { // Check if this room is a Space and user just joined // If so, auto-join them to qualifying child rooms } _ => {} } } } ``` **Step 2: Verify build** Run: `cargo check -p conduwuit-service 2>&1 | tail -20` Expected: Compiles. **Step 3: Commit** ```bash git add src/service/rooms/timeline/append.rs git commit -m "feat(spaces): hook state event changes for role enforcement" ``` --- ### Task 10: Implement Cache Rebuild on Startup **Files:** - Modify: `src/service/rooms/roles/mod.rs` (add `worker()` implementation) **Step 1: Add startup rebuild in the `worker()` method** In the `Service` trait impl, add: ```rust async fn worker(self: Arc) -> Result { if !self.is_enabled() { return Ok(()); } // Find all spaces (rooms with type m.space) and populate cache // Iterate all rooms, check room type, populate if space // This can use rooms::metadata to list all rooms Ok(()) } ``` **Step 2: Verify build** Run: `cargo check -p conduwuit-service 2>&1 | tail -20` Expected: Compiles. **Step 3: Commit** ```bash git add src/service/rooms/roles/mod.rs git commit -m "feat(spaces): rebuild role cache from state events on startup" ``` --- ### Task 11: Add Admin Commands — Module Structure **Files:** - Create: `src/admin/space/mod.rs` - Create: `src/admin/space/commands.rs` - Modify: `src/admin/mod.rs` (add module declaration) - Modify: `src/admin/admin.rs` (add to AdminCommand enum) **Step 1: Create the command enum** Create `src/admin/space/mod.rs`: ```rust mod commands; use clap::Subcommand; use ruma::{OwnedRoomId, OwnedRoomOrAliasId, OwnedUserId}; use crate::admin_command_dispatch; #[admin_command_dispatch] #[derive(Debug, Subcommand)] pub enum SpaceCommand { #[command(subcommand)] /// Manage space roles and permissions Roles(SpaceRolesCommand), } #[admin_command_dispatch] #[derive(Debug, Subcommand)] pub enum SpaceRolesCommand { /// List all roles defined in a space List { /// The space room ID or alias space: OwnedRoomOrAliasId, }, /// Add a new role to a space Add { /// The space room ID or alias space: OwnedRoomOrAliasId, /// Role name role_name: String, /// Human-readable description #[arg(long)] description: Option, /// Power level to grant in child rooms #[arg(long)] power_level: Option, }, /// Remove a role from a space Remove { /// The space room ID or alias space: OwnedRoomOrAliasId, /// Role name to remove role_name: String, }, /// Assign a role to a user Assign { /// The space room ID or alias space: OwnedRoomOrAliasId, /// User to assign the role to user_id: OwnedUserId, /// Role name to assign role_name: String, }, /// Revoke a role from a user Revoke { /// The space room ID or alias space: OwnedRoomOrAliasId, /// User to revoke the role from user_id: OwnedUserId, /// Role name to revoke role_name: String, }, /// Require a role for a room Require { /// The space room ID or alias space: OwnedRoomOrAliasId, /// Child room ID room_id: OwnedRoomId, /// Role name to require role_name: String, }, /// Remove a role requirement from a room Unrequire { /// The space room ID or alias space: OwnedRoomOrAliasId, /// Child room ID room_id: OwnedRoomId, /// Role name to remove from requirements role_name: String, }, /// Show a user's roles in a space User { /// The space room ID or alias space: OwnedRoomOrAliasId, /// User to check user_id: OwnedUserId, }, /// Show a room's role requirements in a space Room { /// The space room ID or alias space: OwnedRoomOrAliasId, /// Room to check room_id: OwnedRoomId, }, } ``` **Step 2: Register in admin module** In `src/admin/mod.rs`, add: ```rust pub(crate) mod space; ``` In `src/admin/admin.rs`, add to imports: ```rust use crate::space::{self, SpaceCommand}; ``` Add to `AdminCommand` enum: ```rust #[command(subcommand)] /// Commands for managing space permissions Spaces(SpaceCommand), ``` Add to the `process()` match: ```rust | Spaces(command) => { context.bail_restricted()?; space::process(command, context).await }, ``` **Step 3: Verify build** Run: `cargo check -p conduwuit-admin 2>&1 | tail -20` Expected: Compiles (commands.rs can have stub implementations initially). **Step 4: Commit** ```bash git add src/admin/space/ src/admin/mod.rs src/admin/admin.rs git commit -m "feat(spaces): add admin command structure for space role management" ``` --- ### Task 12: Implement Admin Command Handlers **Files:** - Modify: `src/admin/space/commands.rs` **Step 1: Implement the command handlers** Create `src/admin/space/commands.rs` with handlers for each command. Each handler should: 1. Resolve the space room alias to ID 2. Read the current state event 3. Modify the content 4. Send the updated state event via `PduBuilder::state()` 5. Return a success message Example for the `list` command: ```rust use conduwuit::Result; use conduwuit_core::matrix::space_roles::SpaceRolesEventContent; use ruma::events::StateEventType; use crate::admin_command; #[admin_command] pub(super) async fn list(&self, space: OwnedRoomOrAliasId) -> Result { let space_id = self.services.rooms.alias.resolve(&space).await?; let content: SpaceRolesEventContent = self .services .rooms .state_accessor .room_state_get_content(&space_id, &StateEventType::from("m.space.roles"), "") .await .unwrap_or_default(); if content.roles.is_empty() { return self.write_str("No roles defined in this space.").await; } let mut output = String::from("Roles:\n"); for (name, def) in &content.roles { output.push_str(&format!( "- **{}**: {} {}\n", name, def.description, def.power_level .map(|pl| format!("(PL {pl})")) .unwrap_or_default() )); } self.write_str(&output).await } ``` Implement similar handlers for: `add`, `remove`, `assign`, `revoke`, `require`, `unrequire`, `user`, `room`. Each mutation handler follows this pattern: 1. Read current state → modify → send updated state event via `PduBuilder::state()` 2. The state event hook (Task 9) will handle cache invalidation and enforcement **Step 2: Verify build** Run: `cargo check -p conduwuit-admin 2>&1 | tail -20` Expected: Compiles. **Step 3: Commit** ```bash git add src/admin/space/commands.rs git commit -m "feat(spaces): implement admin command handlers for space roles" ``` --- ### Task 13: Implement Power Level Override Rejection **Files:** - Modify: `src/service/rooms/timeline/append.rs` or `src/core/matrix/state_res/event_auth.rs` **Step 1: Add PL override rejection** When a `m.room.power_levels` event is submitted for a child room of a Space, check that it doesn't conflict with Space-granted power levels. If a user's PL is being set lower than their Space-granted level, reject the event. Add this check in the event append path or auth check path: ```rust // Reject power level changes that conflict with Space roles if pdu.event_type() == StateEventType::RoomPowerLevels.to_string() { if let Some(parent_space) = self.services.roles.get_parent_space(&pdu.room_id).await { // Parse the proposed power levels // For each user, check if proposed PL < Space-granted PL // If so, reject } } ``` **Step 2: Verify build** Run: `cargo check -p conduwuit-service 2>&1 | tail -20` Expected: Compiles. **Step 3: Commit** ```bash git add -A git commit -m "feat(spaces): reject power level changes that conflict with space roles" ``` --- ### Task 14: Integration Testing **Files:** - Create: `src/service/rooms/roles/tests.rs` **Step 1: Write unit tests for the roles service** Test the lookup methods with pre-populated cache data: ```rust #[cfg(test)] mod tests { // Test user_qualifies_for_room with various role combinations // Test get_user_power_level with multiple roles // Test cache invalidation paths // Test default role creation } ``` **Step 2: Run tests** Run: `cargo test -p conduwuit-service roles 2>&1 | tail -20` Expected: All tests pass. **Step 3: Commit** ```bash git add src/service/rooms/roles/tests.rs git commit -m "test(spaces): add unit tests for space roles service" ``` --- ### Task 15: Documentation **Files:** - Modify: `docs/plans/2026-03-17-space-permission-cascading-design.md` (mark as implemented) **Step 1: Update design doc status** Change `**Status:** Approved` to `**Status:** Implemented` **Step 2: Commit** ```bash git add docs/plans/2026-03-17-space-permission-cascading-design.md git commit -m "docs: mark space permission cascading design as implemented" ``` --- ## Task Dependency Graph ``` Task 1 (config flag) └─> Task 2 (event types) └─> Task 3 (service skeleton) └─> Task 4 (cache + lookups) ├─> Task 5 (default roles) ├─> Task 6 (join gating) ├─> Task 7 (PL override) ├─> Task 8 (auto-join/kick) │ └─> Task 9 (state event hooks) │ └─> Task 10 (startup rebuild) ├─> Task 13 (PL rejection) └─> Task 11 (admin cmd structure) └─> Task 12 (admin cmd handlers) Task 14 (tests) - can run after Task 8 Task 15 (docs) - final ``` Tasks 5-8, 11, and 13 can be worked on in parallel after Task 4 is complete.