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

  1. Promote another active member to the owner role first (admin membership/role update event), wait for it to land, then publish the leave request
  2. If the channel should not exist at all, delete it via kind 9008 (DELETE_GROUP) as owner instead of leaving it
  3. 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

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


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 remains

View on GitHub (pinned to eed74bde2f)