block/buzz · error · IngestError::Rejected
only owners/admins may change an active member's role
Error message
only owners/admins may change an active member's role
What it means
The 9000 event changes the role of an existing ACTIVE member (stored role differs from requested role) and the actor is not channel owner/admin. Unlike first-time adds, role changes of active members are privileged in both directions on every channel visibility — open channels too — because a demotion or promotion mutates live authority. This validator intentionally mirrors add_member so the client gets a real error instead of an OK whose side effect later fails.
Source
Thrown at crates/buzz-relay/src/handlers/side_effects.rs:398
// directions, on every visibility. `get_members` filters
// `removed_at IS NULL`, so a soft-removed row is deliberately not an
// "existing member" here: its stored role is history, not live
// authority, and reactivation is governed by the elevated-granter
// check above rather than by the role the row remembers.
//
// `add_member` is the authority (it also covers the desktop/admin
// callers that skip this validator); rejecting here too means the
// 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(());
}
View on GitHub (pinned to f956e6fe06)
Solutions
- Route role changes through a channel owner/admin signer
- Omit the role tag when re-adding an existing member — absent role means 'keep current role', which is idempotent and needs no elevation
- Before publishing, fetch members and only include a role tag when you intend a change you are authorized to make
Example fix
// before: member re-adds an existing admin with role member (silent demotion attempt)
put_user(ch, admin_pk).with_role("member").publish_as(member_keys)
// → only owners/admins may change an active member's role
// after: idempotent re-add without role tag
put_user(ch, admin_pk).publish_as(member_keys); // keeps current role
// or a real role change signed by an admin:
put_user(ch, admin_pk).with_role("member").publish_as(channel_admin_keys); Defensive patterns
Strategy: validation
Validate before calling
// Only include a role tag when it actually changes an active member's role AND you are elevated
let existing = members.iter().find(|m| m.pubkey == target_pubkey);
let changes_role = existing.zip(requested_role).is_some_and(|(m, r)| m.role != r.as_str());
if changes_role {
anyhow::ensure!(
actor_role.is_some_and(|r| r.is_elevated()),
"role changes of active members need owner/admin"
);
} Type guard
const isRoleChange = ( current: string | undefined, requested: string | null, ): boolean => current !== undefined && requested !== null && current !== requested;
Try / catch
match validate_admin_event(&tenant, 9000, &event, &state).await {
Err(e) if e.to_string().contains("may change an active member's role") => {
// re-issue without the role tag (idempotent no-op add), or escalate to an admin signer
retry_without_role_or_escalate(event).await
}
other => other,
} Prevention
- Omit the role tag when re-adding existing members — absent means keep-current and is always safe
- Fetch the member list before sending so you know whether your 9000 is a new add or a role change
- Remember this applies on open channels too: first adds are free, role changes never are
When it happens
Trigger: On an open channel, any authenticated non-elevated user sends 9000 with a role tag that differs from the target's current role (e.g. flipping someone from member to guest); a member 're-adds' an existing admin at role member (a disguised demotion); a stale client re-sends an add with the original role after the member was promoted, which counts as a change if the tags differ.
Common situations: Developers assume open-channel adds are fully permissionless — true only for brand-new members or same-role re-adds (idempotent); retry logic replaying old payloads after a role was updated elsewhere; huddle bot-add paths relying on idempotent same-role re-adds suddenly changing the role value.
Related errors
- only owners/admins may grant elevated roles
- actor not authorized
- moderator access required
- missing or invalid h tag
- channel not found
AI-assisted analysis of block/buzz@f956e6fe06 (2026-08-16).
Data as JSON: /api/errors/fcccdb1407868570.
Report an issue: GitHub.