{"record":{"id":"fc7eca308c80685e","repo":"block/buzz","slug":"channel-is-archived","errorCode":null,"errorMessage":"channel is archived","messagePattern":"channel is archived","errorType":"validation","errorClass":"IngestError::Rejected","httpStatus":null,"severity":"error","filePath":"crates/buzz-relay/src/handlers/side_effects.rs","lineNumber":335,"sourceCode":"    let channel_id =\n        extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!(\"missing or invalid h tag\"))?;\n\n    let actor_bytes = event.pubkey.to_bytes().to_vec();\n\n    // Reject mutations on archived channels — except kind:9002 with archived=false\n    // (unarchive), which must be allowed through so the channel can be restored.\n    let channel = state\n        .db\n        .get_channel(tenant.community(), channel_id)\n        .await\n        .map_err(|_| anyhow::anyhow!(\"channel not found\"))?;\n    let is_unarchive_request = kind == 9002\n        && event.tags.iter().any(|t| {\n            let parts = t.as_slice();\n            parts.len() >= 2 && parts[0] == \"archived\" && parts[1] == \"false\"\n        });\n    if channel.archived_at.is_some() && !is_unarchive_request {\n        return Err(anyhow::anyhow!(\"channel is archived\"));\n    }\n\n    match kind {\n        9000 => {\n            // An absent role tag means \"no role change requested\": for an existing\n            // member that preserves the role they already hold, and only defaults\n            // to Member for a genuinely new member. Defaulting unconditionally to\n            // Member made a bare self-targeted PUT_USER silently demote an owner.\n            let role_str = extract_tag_value(event, \"role\");\n            let requested_role = match role_str {\n                Some(ref s) => match s.parse::<buzz_db::channel::MemberRole>() {\n                    Ok(r) => Some(r),\n                    Err(_) => return Err(anyhow::anyhow!(\"invalid role: {s}\")),\n                },\n                None => None,\n            };\n\n            let members = state.db.get_members(tenant.community(), channel_id).await?;","sourceCodeStart":317,"sourceCodeEnd":353,"githubUrl":"https://github.com/block/buzz/blob/f956e6fe06a76e50cbd8fba1a162482e752e7f1a/crates/buzz-relay/src/handlers/side_effects.rs#L317-L353","documentation":"The target channel has archived_at set and the incoming event is not a kind:9002 carrying an [\"archived\", \"false\"] tag. Archived channels are frozen for mutations — PUT_USER, REMOVE_USER, metadata edits (except the unarchive edit itself), deletes, join/leave requests — so the event is rejected before storage. The only key that opens the gate is a 9002 edit whose tags include archived=false.","triggerScenarios":"Issuing kind 9000/9001/9005/9008/9021/9022 against a channel that was archived earlier; a 9002 edit that changes name/topic without also including archived=false on an archived channel; an unarchive attempt sent as a different kind or with the tag misspelled (e.g. [\"unarchive\",\"true\"]); a user re-joining an old archived channel via 9021.","commonSituations":"Team archives a project channel, later automation still posts membership updates to it; unarchive event built with the wrong tag value ('false' vs false, or 0); clients not surfacing archived state so users retry joins repeatedly.","solutions":["Unarchive first: publish kind 9002 with tags including [\"archived\", \"false\"], then retry the original mutation","Or point the operation at a live channel instead of the archived one","Surface channel.archived_at in the client UI and block admin actions on archived channels preemptively","Check the 9002 unarchive tag is exactly [\"archived\", \"false\"] — parts[0]==\"archived\" && parts[1]==\"false\""],"exampleFix":"// before: mutation on archived channel → \"channel is archived\"\nEventBuilder::new(Kind::from(9000), \"\", [/* p, role, h */])\n\n// after: unarchive, then mutate\nEventBuilder::new(Kind::from(9002), \"\", [\n    Tag::custom(TagKind::Custom(\"h\"), vec![channel_uuid]),\n    Tag::custom(TagKind::Custom(\"archived\"), vec![\"false\"]),\n]).to_event(&owner_keys).await?;\nEventBuilder::new(Kind::from(9000), \"\", [/* p, role, h */])","handlingStrategy":"validation","validationCode":"// Check archived state client-side before issuing admin events\nlet ch = client.get_channel(community, channel_uuid).await?;\nlet unarchive = kind == 9002 && event_has_tag(&event.tags, \"archived\", \"false\");\nanyhow::ensure!(\n    ch.archived_at.is_none() || unarchive,\n    \"channel is archived — send 9002 with [\\\"archived\\\",\\\"false\\\"] first\"\n);","typeGuard":"const isUnarchive = (kind: number, tags: string[][]): boolean =>\n  kind === 9002 && tags.some(t => t[0] === \"archived\" && t[1] === \"false\");","tryCatchPattern":"match validate_admin_event(&tenant, kind, &event, &state).await {\n    Err(e) if e.to_string().contains(\"channel is archived\") => {\n        // Deterministic: send the 9002 unarchive edit (with archived=false), then replay the mutation once\n        unarchive_then_retry(channel_uuid, event).await\n    }\n    other => other,\n}","preventionTips":["Show archived state in channel UIs and disable membership/role actions there","Automations that manage members should pre-check archived_at or self-heal via the 9002 unarchive path","The unarchive tag is exactly [\"archived\", \"false\"] — any other spelling still counts as a frozen mutation"],"tags":["nostr","nip-29","group-admin","archived","lifecycle"],"backgroundTag":"channel-archived","analyzedSha":"f956e6fe06a76e50cbd8fba1a162482e752e7f1a","analyzedAt":"2026-08-16T22:11:40.750Z","schemaVersion":2},"datasetVersion":"2026-08-16T23:17:17.608Z"}