block/buzz · error · anyhow::Error

missing p tag

Error message

missing p tag

What it means

For kind 9000 (PUT_USER) admin events, validate_admin_event extracts the target member from the event's `p` tag via extract_p_tag. If the tag is absent it fails with 'missing p tag'. The p tag identifies which member the role/membership mutation applies to.

Solutions

  1. Add a `p` tag containing the target member's 32-byte hex pubkey to the kind:9000 event
  2. Validate event shape client-side before publishing (required tags per kind)
  3. Check the client library's PUT_USER helper is actually attaching the tag

Example fix

// before
let ev = EventBuilder::new(9000, "").tag(Tag::custom("h", [channel_id]));
// after
let ev = EventBuilder::new(9000, "")
    .tag(Tag::custom("h", [channel_id]))
    .tag(Tag::public_key(target_pubkey)); // p tag: mutation target
Defensive patterns

Strategy: validation

Validate before calling

// validate before publishing kind:9000
const p = event.tags.find(t => t[0] === 'p');
if (!p || !/^[0-9a-f]{64}$/.test(p[1])) {
  throw new Error('kind:9000 requires a p tag with a 32-byte hex pubkey');
}

Type guard

function hasPTag(tags: string[][]): tags is [string[], ...string[][]] & { p: string } {
  return tags.some(t => t[0] === 'p' && /^[0-9a-f]{64}$/.test(t[1] ?? ''));
}

Try / catch

match publish(put_user_event).await {
    Err(e) if e.to_string().contains("missing p tag") => fix_and_republish_with_p_tag(),
    other => other?,
}

Prevention

When it happens

Trigger: A kind:9000 channel PUT_USER event published without a `p` tag (or with an empty/malformed one that extract_p_tag rejects).

Common situations: A bot or script constructs the admin event manually and forgets the p tag; a client bug after a UI change drops the target member tag; hand-crafted events during interop testing.

Related errors


AI-assisted analysis of block/buzz@dad5a33865 (2026-09-05). Data as JSON: /api/errors/5266242998b86b62. Report an issue: GitHub.

Appendix: source

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

            // member that preserves the role they already hold, and only defaults
            // to Member for a genuinely new member. Defaulting unconditionally to
            // Member made a bare self-targeted PUT_USER silently demote an owner.
            let role_str = extract_tag_value(event, "role");
            let requested_role = match role_str {
                Some(ref s) => match s.parse::<buzz_db::channel::MemberRole>() {
                    Ok(r) => Some(r),
                    Err(_) => return Err(anyhow::anyhow!("invalid role: {s}")),
                },
                None => None,
            };

            let members = state.db.get_members(tenant.community(), channel_id).await?;
            let actor_role: Option<buzz_db::channel::MemberRole> = members
                .iter()
                .find(|m| m.pubkey == actor_bytes)
                .and_then(|m| m.role.parse().ok());
            let target_pubkey =
                extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?;

            // Authorization policy — visibility gate, elevated-grant gate,
            // active-member role-change gate, and last-owner demotion — lives in
            // `channel_authz`, which is pure and table-tested. The database reads
            // it depends on stay here.
            match channel_authz::decide_put_user(
                &channel.visibility,
                actor_role,
                requested_role,
                &members,
                &target_pubkey,
                &actor_bytes,
            )? {
                // Self-add: always allowed regardless of policy.
                PutUserDecision::Allow => Ok(()),
                // Third-party add: check channel_add_policy on the target.
                PutUserDecision::CheckAddPolicy => {
                    if let Some((policy, owner)) = state

View on GitHub (pinned to dad5a33865)