{"record":{"id":"3e99896c188a8312","repo":"block/buzz","slug":"cannot-demote-the-last-owner-transfer-ownership","errorCode":null,"errorMessage":"cannot demote the last owner — transfer ownership first","messagePattern":"cannot demote the last owner — transfer ownership first","errorType":"validation","errorClass":"IngestError::Rejected","httpStatus":null,"severity":"error","filePath":"crates/buzz-relay/src/handlers/side_effects.rs","lineNumber":406,"sourceCode":"            // client gets a real error instead of an OK for an event whose side\n            // effect then fails. Re-adding at the same role stays idempotent —\n            // the huddle bot-add path relies on that.\n            if let Some((target, role)) = members\n                .iter()\n                .find(|m| m.pubkey == target_pubkey)\n                .zip(requested_role)\n                .filter(|(m, role)| m.role != role.as_str())\n            {\n                if !actor_role.is_some_and(|r| r.is_elevated()) {\n                    return Err(anyhow::anyhow!(\n                        \"only owners/admins may change an active member's role\"\n                    ));\n                }\n                if target.role == \"owner\"\n                    && role != buzz_db::channel::MemberRole::Owner\n                    && members.iter().filter(|m| m.role == \"owner\").count() <= 1\n                {\n                    return Err(anyhow::anyhow!(\n                        \"cannot demote the last owner — transfer ownership first\"\n                    ));\n                }\n            }\n\n            // Self-add: always allowed regardless of policy.\n            if target_pubkey == actor_bytes {\n                return Ok(());\n            }\n\n            // Third-party add: check channel_add_policy on the target.\n            if let Some((policy, owner)) = state\n                .db\n                .get_agent_channel_policy(tenant.community(), &target_pubkey)\n                .await?\n            {\n                match policy.as_str() {\n                    \"owner_only\" => {","sourceCodeStart":388,"sourceCodeEnd":424,"githubUrl":"https://github.com/block/buzz/blob/f956e6fe06a76e50cbd8fba1a162482e752e7f1a/crates/buzz-relay/src/handlers/side_effects.rs#L388-L424","documentation":"The 9000 event would demote the target from owner to a non-owner role while the channel has exactly one active owner (members with role \"owner\" count <= 1). Buzz blocks demotion (or effective removal of authority) of the last owner to guarantee a channel always has someone who can administer it — including unarchiving it. Promote or transfer ownership first, then demote.","triggerScenarios":"9000 targeting the sole owner with role member/guest/bot; deleting the channel owner's membership via a role-flip; two-step ownership transfers done in the wrong order (demote before promoting the successor); test teardowns stripping roles from every member including the last owner.","commonSituations":"Ownership handover scripts that demote the outgoing owner before the incoming owner's promotion lands; onboarding templates that reset all roles; archived channels that need their owner freed up for reuse.","solutions":["First promote another member to owner (9000 with role owner, signed by an owner/admin), then demote the original owner","If nobody else should own it, archive or delete the channel instead of demoting the last owner","In automation, order operations promote → demote and verify owner_count > 1 between steps"],"exampleFix":"// before\nput_user(ch, sole_owner).with_role(\"member\").publish_as(sole_owner_keys)\n// → cannot demote the last owner — transfer ownership first\n\n// after: promote successor first, then demote\nput_user(ch, successor).with_role(\"owner\").publish_as(sole_owner_keys).await?;\nput_user(ch, sole_owner).with_role(\"member\").publish_as(sole_owner_keys).await?;","handlingStrategy":"validation","validationCode":"// Guard the last-owner demotion before publishing\nlet owner_count = members.iter().filter(|m| m.role == \"owner\").count();\nlet demotes_owner = target.role == \"owner\" && requested_role != Some(MemberRole::Owner);\nanyhow::ensure!(\n    !demotes_owner || owner_count > 1,\n    \"promote a second owner before demoting the last one\"\n);","typeGuard":"const isLastOwnerDemotion = (\n  targetRole: string,\n  requested: string | null,\n  ownerCount: number,\n): boolean => targetRole === \"owner\" && requested !== \"owner\" && ownerCount <= 1;","tryCatchPattern":"match validate_admin_event(&tenant, 9000, &event, &state).await {\n    Err(e) if e.to_string().contains(\"cannot demote the last owner\") => {\n        // order matters: promote successor (9000 role=owner) first, then replay the demotion once\n        promote_then_demote(channel, target, requested_role).await\n    }\n    other => other,\n}","preventionTips":["Implement ownership transfer as an ordered transaction: promote successor, verify owner_count > 1, then demote","In test teardown, archive/delete channels instead of stripping the last owner's role","Surface the owner count in admin UIs so the guard is visible before the event is built"],"tags":["nostr","nip-29","group-admin","ownership","lifecycle","guard-rail"],"backgroundTag":"last-owner-protection","analyzedSha":"f956e6fe06a76e50cbd8fba1a162482e752e7f1a","analyzedAt":"2026-08-16T22:11:40.750Z","schemaVersion":2},"datasetVersion":"2026-08-16T23:17:17.608Z"}