block/buzz · error · anyhow::Error

target event belongs to a different channel

Error message

target event belongs to a different channel

What it means

The kind:9005 delete handler requires the delete event's h tag (the channel scope) to match the stored channel_id of the e-tag target. If the target message lives in a different channel, the relay rejects the delete before storage. This prevents a member of channel A from deleting messages in channel B by forging an h tag, and keeps NIP-29 channel scoping intact.

Solutions

  1. Derive the h tag from the target event itself: fetch the target first and copy its channel (h) tag into the delete event.
  2. Never hardcode the channel in delete paths — bind it to the message being deleted.
  3. Log the delete's (h, e) pair next to the target's channel_id on failure to find the mismatch source.
  4. Fix the client so both tags come from the same message context, then re-publish.

Example fix

// before: hardcoded channel tag
await publish({ kind: 9005, tags: [['e', targetId], ['h', 'general-channel-uuid']] });
// after: take the channel from the target event
const target = await relay.queryOne({ ids: [targetId] });
const channel = target.tags.find(([k]) => k === 'h')?.[1];
await publish({ kind: 9005, tags: [['e', targetId], ['h', channel]] });
Defensive patterns

Strategy: validation

Validate before calling

// build the h tag from the target's own channel, not from client state
const target = await relay.queryOne({ ids: [targetId] });
const targetChannel = target?.tags.find(([k]) => k === 'h')?.[1];
if (!targetChannel || targetChannel !== deleteChannelArg) {
  throw new Error(`channel mismatch: target lives in ${targetChannel}`);
}

Type guard

function deleteTagsConsistent(tags: string[][]): boolean {
  const e = tags.find(([k]) => k === 'e')?.[1];
  const h = tags.find(([k]) => k === 'h')?.[1];
  return typeof e === 'string' && e.length === 64 && typeof h === 'string' && h.length > 0;
}

Prevention

When it happens

Trigger: A client builds the 9005 event with an h tag copied from the wrong channel while its e tag points at a message in another channel; channel UUIDs mistyped or swapped; code that hardcodes a 'general' channel h tag for all deletes.

Common situations: UI delete buttons reusing a stale channel id after the user switched channels; tooling that forwards deletes between bots without rewriting the h tag; typos in UUID constants.

Related errors


AI-assisted analysis of block/buzz@dad5a33865 (2026-08-20). Data as JSON: /api/errors/e93261ce4f94d938. Report an issue: GitHub.

Appendix: source

Thrown at crates/buzz-relay/src/handlers/side_effects.rs:601

                    } else {
                        None
                    }
                })
                .ok_or_else(|| anyhow::anyhow!("missing e tag for target event"))?;

            // Verify the target event exists and belongs to the h-tag channel
            // BEFORE storage. Fail closed: missing target → reject.
            let target_event = state
                .db
                .get_event_by_id_for_event_write(tenant.community(), &target_id)
                .await
                .map_err(|e| anyhow::anyhow!("db error looking up target: {e}"))?
                .ok_or_else(|| anyhow::anyhow!("target event not found"))?;

            match target_event.channel_id {
                Some(target_ch) if target_ch != channel_id => {
                    return Err(anyhow::anyhow!(
                        "target event belongs to a different channel"
                    ));
                }
                None => {
                    return Err(anyhow::anyhow!("target event has no channel"));
                }
                _ => {} // Same channel — OK
            }

            // Check if actor is the event author.
            // For relay-signed REST messages, the real author is in the p tag.
            let author =
                effective_message_author(&target_event.event, &state.relay_keypair.public_key());
            if author_delete_can_use_self_delete_path(&author, &actor_bytes, event) {
                // Author deleting their own message: re-gate on membership/open visibility so that
                // a removed private-channel member cannot mutate old messages after access is revoked.
                let is_member = state
                    .is_member_cached(tenant.community(), channel_id, &actor_bytes)
                    .await?;

View on GitHub (pinned to dad5a33865)