From a69e57e929f720fc6652ec0f9af072a30dba2a5d Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:09:44 -0500 Subject: [PATCH] fix(relay): classify self-joining agents as bots, not members (#6561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kind:9021 carries no role tag and buzz channels join exposes no --role, so a self-joining agent could not ask for anything and handle_join_request recorded it as MemberRole::Member. Clients read the channel role as the "is this an agent" signal, so a self-joined agent disappears from @mention autocomplete — and the repair needs role:bot on a kind:9000, which the relay restricts to owners/admins, so there is no in-app way back. MemberRole's own docs say Bot "is not part of the linear hierarchy" but a separate designation, so Member is not a conservative choice here — it is the wrong category, and it is the category clients read. Uses the agent discriminator the codebase already relies on, agent_owner_pubkey IS NOT NULL (see buzz_db::usage::user_counts), via the get_agent_channel_policy accessor already called elsewhere in this file. No new policy is introduced. Scoped deliberately to the self-join path: handle_put_user is untouched, so a caller that states admin/member/guest for a human still gets exactly that. The relay decides only where no one else can express intent. A failed classification lookup logs and falls back to Member, preserving the previous behaviour rather than failing the join. The decision is extracted into self_join_role so it is unit-testable without a database; three tests cover agent, human, and no-user-row. Note for closed relays: agent_owner_pubkey is the field that stays NULL when require_relay_membership is true (#5581), so this is a no-op there until that lands. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../buzz-relay/src/handlers/side_effects.rs | 86 +++++++++++++++++-- 1 file changed, 79 insertions(+), 7 deletions(-) diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 89595fbee17..97a2e4204fd 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1986,6 +1986,24 @@ async fn handle_delete_group( Ok(()) } +/// Pick the channel role for a `kind:9021` self-join. +/// +/// The joiner cannot state a role — the event carries none — so the relay has to +/// choose. `Bot` is not a rung on the Owner > Admin > Member > Guest ladder; it +/// is a separate designation, so classifying an agent here cannot shadow a human +/// role. `agent_owner_pubkey IS NOT NULL` is the same agent discriminator used +/// elsewhere in the codebase (see `buzz_db::usage::user_counts`). +/// +/// Takes the `get_agent_channel_policy` row so the decision stays unit-testable. +fn self_join_role( + agent_channel_policy: Option<&(String, Option>)>, +) -> buzz_db::channel::MemberRole { + match agent_channel_policy { + Some((_, Some(_owner))) => buzz_db::channel::MemberRole::Bot, + _ => buzz_db::channel::MemberRole::Member, + } +} + async fn handle_join_request( tenant: &TenantContext, event: &Event, @@ -2017,16 +2035,41 @@ async fn handle_join_request( return Ok(()); } + // Role on self-join: kind:9021 carries no role and the joiner cannot express + // one, so the relay has to pick. Use the same agent discriminator the rest of + // the codebase already relies on — `agent_owner_pubkey IS NOT NULL`, see + // `buzz_db::usage::user_counts` — instead of assuming every joiner is human. + // + // Assigning `Member` to an agent is not cosmetic: clients treat the channel + // role as the agent signal, so a self-joined agent is silently excluded from + // @mention eligibility with no in-app way to repair it (changing an active + // member's role is owner/admin-only). + // + // This deliberately does NOT touch `handle_put_user`: there the caller states + // the role explicitly and must keep admin/member/guest for humans. The relay + // only decides where no one else can. + let role = match state + .db + .get_agent_channel_policy(tenant.community(), &actor_bytes) + .await + { + Ok(policy) => self_join_role(policy.as_ref()), + // Never fail a join over the classification lookup — fall back to the + // previous behaviour, which is also the safer of the two roles. + Err(e) => { + warn!( + channel = %channel_id, + error = %e, + "kind:9021 join — agent classification lookup failed, defaulting to member" + ); + buzz_db::channel::MemberRole::Member + } + }; + // Add as member (idempotent — add_member handles duplicates). state .db - .add_member( - tenant.community(), - channel_id, - &actor_bytes, - buzz_db::channel::MemberRole::Member, - None, - ) + .add_member(tenant.community(), channel_id, &actor_bytes, role, None) .await?; state.invalidate_membership(tenant, channel_id, &actor_bytes); @@ -3591,6 +3634,35 @@ pub async fn publish_nipia_unarchived( mod tests { use super::*; + #[test] + fn self_join_role_classifies_an_agent_as_bot() { + let policy = ("anyone".to_string(), Some(vec![0xab; 32])); + assert_eq!( + self_join_role(Some(&policy)), + buzz_db::channel::MemberRole::Bot, + "a user row carrying an agent owner is an agent" + ); + } + + #[test] + fn self_join_role_classifies_a_human_as_member() { + let policy = ("anyone".to_string(), None); + assert_eq!( + self_join_role(Some(&policy)), + buzz_db::channel::MemberRole::Member, + "a user row with no agent owner is a human" + ); + } + + #[test] + fn self_join_role_defaults_to_member_without_a_user_row() { + assert_eq!( + self_join_role(None), + buzz_db::channel::MemberRole::Member, + "no row means nothing is known — keep the pre-existing behaviour" + ); + } + #[test] fn group_members_snapshot_keeps_members_past_one_thousand() { let channel_id = Uuid::new_v4();