block/buzz · error · anyhow::Error
cannot remove the last owner
Error message
cannot remove the last owner
What it means
Buzz-relay rejects a kind 9022 (LEAVE_REQUEST) when the actor is an owner and the channel has owner_count <= 1. The guard counts members with role == "owner" and refuses to let the last one walk away, preventing an unadministerable channel. The event is rejected during side-effect validation and nothing is mutated.
Solutions
- Promote another active member to the owner role first (admin membership/role update event), wait for it to land, then publish the leave request
- If the channel should not exist at all, delete it via kind 9008 (DELETE_GROUP) as owner instead of leaving it
- Verify owner count via the member list (`buzz channels members`) before sending the leave
Example fix
// before: sole owner leaves directly
relay.publish(EventBuilder::leave_request(channel_id, keys)).await?;
// -> rejected: cannot remove the last owner
// after: check owner count and transfer first
let members = client.query_members(channel_id).await?;
let owner_count = members.iter().filter(|m| m.role == "owner").count();
if owner_count <= 1 {
// promote another member to owner, or delete the channel (kind 9008)
} else {
relay.publish(EventBuilder::leave_request(channel_id, keys)).await?;
} Defensive patterns
Strategy: validation
Validate before calling
// Before publishing kind 9022 as an owner:
let members = client.query_members(channel_id).await?;
let owner_count = members.iter().filter(|m| m.role == "owner").count();
let actor_is_owner = members.iter().any(|m| m.pubkey == actor && m.role == "owner");
if actor_is_owner && owner_count <= 1 {
// promote another member to owner first, or delete the channel (kind 9008)
} Type guard
fn would_remove_last_owner(members: &[Member], actor: &str) -> bool {
let actor_is_owner = members.iter().any(|m| m.pubkey == actor && m.role == "owner");
let owner_count = members.iter().filter(|m| m.role == "owner").count();
actor_is_owner && owner_count <= 1
} Try / catch
if err.to_string().contains("cannot remove the last owner") {
// surface a prompt: transfer ownership or delete the channel — do not auto-retry
} Prevention
- Provision channels with at least two owners if any owner may leave
- Check owner_count from a live member query before sending a leave
- Prefer channel deletion over last-owner leave when the channel is being decommissioned
When it happens
Trigger: The sole owner of a channel publishing kind 9022 against that channel. Happens when every other owner has already left, or the channel was provisioned with exactly one owner (solo community, demo channel).
Common situations: Single-admin communities where the creator tries to leave or delete their presence; account-cleanup automation that leaves all channels including owned ones; two-owner channels where one owner already left and the second now tries.
Related errors
- actor is not an active member
- invalid BUZZ_RELAY_PRIVATE_KEY
- must be event author or channel owner/admin
- only owner can delete group
- target event belongs to a different channel
AI-assisted analysis of block/buzz@eed74bde2f (2026-08-20).
Data as JSON: /api/errors/64edf34e85c7faac.
Report an issue: GitHub.
Appendix: source
Thrown at crates/buzz-relay/src/handlers/side_effects.rs:751
{
return Ok(());
}
Err(anyhow::anyhow!("only owner can delete group"))
}
}
}
9022 => {
// LEAVE_REQUEST: must be an active member, and cannot be the last owner.
let members = state.db.get_members(tenant.community(), channel_id).await?;
let actor_member = members.iter().find(|m| m.pubkey == actor_bytes);
match actor_member {
None => {
return Err(anyhow::anyhow!("actor is not an active member"));
}
Some(m) if m.role == "owner" => {
let owner_count = members.iter().filter(|m| m.role == "owner").count();
if owner_count <= 1 {
return Err(anyhow::anyhow!("cannot remove the last owner"));
}
}
_ => {}
}
Ok(())
}
_ => Ok(()),
}
}
/// Emit a system message (kind 40099) signed by the relay keypair.
///
/// `idempotency_ts` is used as the event's `created_at`. Passing a stable
/// timestamp (e.g. from the outbox row's `created_at`) makes re-tries produce
/// the same Nostr event ID — the existing `ON CONFLICT DO NOTHING` in
/// `insert_event` then provides DB-enforced delivery idempotency.
///
/// Returns `Err` if the event could not be durably inserted; fanout remainsView on GitHub (pinned to eed74bde2f)