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
- Derive the h tag from the target event itself: fetch the target first and copy its channel (h) tag into the delete event.
- Never hardcode the channel in delete paths — bind it to the message being deleted.
- Log the delete's (h, e) pair next to the target's channel_id on failure to find the mismatch source.
- 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
- Always copy h from the message object being deleted; never from route state or a default channel.
- Add a UI-level invariant: the delete menu is bound to the message row, so the channel travels with it.
- Log (h, e) pairs on delete failures to catch swapped-UUID regressions quickly.
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
- must be event author or channel owner/admin
- target event has no channel
- actor is not an active member
- only owner can delete group
- target event not found
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)