block/buzz · error · IngestError::Rejected
cannot demote the last owner — transfer ownership first
Error message
cannot demote the last owner — transfer ownership first
What it means
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.
Source
Thrown at crates/buzz-relay/src/handlers/side_effects.rs:406
// client gets a real error instead of an OK for an event whose side
// effect then fails. Re-adding at the same role stays idempotent —
// the huddle bot-add path relies on that.
if let Some((target, role)) = members
.iter()
.find(|m| m.pubkey == target_pubkey)
.zip(requested_role)
.filter(|(m, role)| m.role != role.as_str())
{
if !actor_role.is_some_and(|r| r.is_elevated()) {
return Err(anyhow::anyhow!(
"only owners/admins may change an active member's role"
));
}
if target.role == "owner"
&& role != buzz_db::channel::MemberRole::Owner
&& members.iter().filter(|m| m.role == "owner").count() <= 1
{
return Err(anyhow::anyhow!(
"cannot demote the last owner — transfer ownership first"
));
}
}
// Self-add: always allowed regardless of policy.
if target_pubkey == actor_bytes {
return Ok(());
}
// Third-party add: check channel_add_policy on the target.
if let Some((policy, owner)) = state
.db
.get_agent_channel_policy(tenant.community(), &target_pubkey)
.await?
{
match policy.as_str() {
"owner_only" => {View on GitHub (pinned to f956e6fe06)
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
Example fix
// before
put_user(ch, sole_owner).with_role("member").publish_as(sole_owner_keys)
// → cannot demote the last owner — transfer ownership first
// after: promote successor first, then demote
put_user(ch, successor).with_role("owner").publish_as(sole_owner_keys).await?;
put_user(ch, sole_owner).with_role("member").publish_as(sole_owner_keys).await?; Defensive patterns
Strategy: validation
Validate before calling
// Guard the last-owner demotion before publishing
let owner_count = members.iter().filter(|m| m.role == "owner").count();
let demotes_owner = target.role == "owner" && requested_role != Some(MemberRole::Owner);
anyhow::ensure!(
!demotes_owner || owner_count > 1,
"promote a second owner before demoting the last one"
); Type guard
const isLastOwnerDemotion = ( targetRole: string, requested: string | null, ownerCount: number, ): boolean => targetRole === "owner" && requested !== "owner" && ownerCount <= 1;
Try / catch
match validate_admin_event(&tenant, 9000, &event, &state).await {
Err(e) if e.to_string().contains("cannot demote the last owner") => {
// order matters: promote successor (9000 role=owner) first, then replay the demotion once
promote_then_demote(channel, target, requested_role).await
}
other => other,
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
AI-assisted analysis of block/buzz@f956e6fe06 (2026-08-16).
Data as JSON: /api/errors/3e99896c188a8312.
Report an issue: GitHub.